commit 30810eecd7aeba9e697b89630beb8532ff774e85 Author: upstream-mirror Date: Wed Sep 16 01:21:02 2026 +0500 snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted) diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/all-lights-go.md b/.changeset/all-lights-go.md new file mode 100644 index 0000000..d605672 --- /dev/null +++ b/.changeset/all-lights-go.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/media-signaling': minor +'@rocket.chat/media-calls': minor +'@rocket.chat/ui-voip': minor +--- + +Improves the logic of when the Busy Tone should be played after a call ends diff --git a/.changeset/argon2-musl-prebuild.md b/.changeset/argon2-musl-prebuild.md new file mode 100644 index 0000000..1699b47 --- /dev/null +++ b/.changeset/argon2-musl-prebuild.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the Docker image crashing at boot on arm64 with `Error loading shared library ld-linux-aarch64.so.1` while loading `argon2.node`. The Meteor bundle carries `build/Release` output compiled on the glibc-based CI host, and `node-gyp-build` prefers it over the musl binaries shipped under `prebuilds/` in the same package, so the wrong one was selected at runtime. diff --git a/.changeset/autotranslate-provider-switch.md b/.changeset/autotranslate-provider-switch.md new file mode 100644 index 0000000..d26a85f --- /dev/null +++ b/.changeset/autotranslate-provider-switch.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes auto-translate continuing to translate messages with the previously selected service provider after switching providers, until the server was restarted or the feature was disabled and re-enabled. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..ab919c2 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", + "changelog": ["@rocket.chat/release-changelog", { "repo": "RocketChat/Rocket.Chat" }], + "commit": false, + "fixed": [["@rocket.chat/meteor", "@rocket.chat/core-typings", "@rocket.chat/rest-typings"]], + "linked": [], + "access": "public", + "baseBranch": "develop", + "updateInternalDependencies": "patch", + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } +} diff --git a/.changeset/css-supports-monorepo-migration.md b/.changeset/css-supports-monorepo-migration.md new file mode 100644 index 0000000..63e723b --- /dev/null +++ b/.changeset/css-supports-monorepo-migration.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/css-supports': patch +--- + +Moved `@rocket.chat/css-supports` into the Rocket.Chat monorepo, continuing from the frozen Fuselage 0.31.25 release. The memoized, SSR-safe `CSS.supports` facade keeps the same API and runtime behaviour; only its build, lint and test wiring changed, none of which is published. diff --git a/.changeset/curly-ravens-shout.md b/.changeset/curly-ravens-shout.md new file mode 100644 index 0000000..b172ab9 --- /dev/null +++ b/.changeset/curly-ravens-shout.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the custom emoji picker returning zero emoji on workspaces running with `ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS=true`, caused by the client sending an empty `query` string to `emoji-custom.list` diff --git a/.changeset/ddp-deprecate-get-room-join-code.md b/.changeset/ddp-deprecate-get-room-join-code.md new file mode 100644 index 0000000..8227fdc --- /dev/null +++ b/.changeset/ddp-deprecate-get-room-join-code.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deprecates the `getRoomJoinCode` real-time API method. It has had no caller since the room settings panel stopped revealing join codes, and will be removed in 9.0.0 without a replacement. diff --git a/.changeset/ddp-deprecate-raix-push-setuser.md b/.changeset/ddp-deprecate-raix-push-setuser.md new file mode 100644 index 0000000..c90195a --- /dev/null +++ b/.changeset/ddp-deprecate-raix-push-setuser.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deprecates the `raix:push-setuser` real-time API method. It was inherited from the `raix:push` package for Cordova clients and has never been called by Rocket.Chat clients, which bind push tokens to users through `POST /v1/push.token`. It will be removed in 9.0.0 without a replacement. diff --git a/.changeset/ddp-migrate-get-messages-caller.md b/.changeset/ddp-migrate-get-messages-caller.md new file mode 100644 index 0000000..4924250 --- /dev/null +++ b/.changeset/ddp-migrate-get-messages-caller.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deprecates the `getMessages` real-time API method in favor of the new `POST /v1/chat.getMessages` endpoint. The method keeps working until it is removed in 9.0.0. diff --git a/.changeset/ddp-migrate-room-history-callers.md b/.changeset/ddp-migrate-room-history-callers.md new file mode 100644 index 0000000..b2815be --- /dev/null +++ b/.changeset/ddp-migrate-room-history-callers.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deprecates the `loadHistory` and `loadNextMessages` real-time API methods in favor of the new `GET /v1/rooms.history` endpoint. Both methods keep working until they are removed in 9.0.0. diff --git a/.changeset/ddp-migrate-setup-wizard-callers.md b/.changeset/ddp-migrate-setup-wizard-callers.md new file mode 100644 index 0000000..2340389 --- /dev/null +++ b/.changeset/ddp-migrate-setup-wizard-callers.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/ui-client': patch +--- + +Migrates the setup wizard off the `getSetupWizardParameters`, `cloud:getWorkspaceRegisterData` and `registerUser` real-time API methods, in favor of `GET /v1/setupWizard.parameters`, `GET /v1/cloud.workspaceRegisterData` and `POST /v1/users.register`. The first two methods are now deprecated and keep working until they are removed in 9.0.0. diff --git a/.changeset/ddp-migrate-surrounding-messages-caller.md b/.changeset/ddp-migrate-surrounding-messages-caller.md new file mode 100644 index 0000000..7eb7367 --- /dev/null +++ b/.changeset/ddp-migrate-surrounding-messages-caller.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Deprecates the `loadSurroundingMessages` real-time API method in favor of the `aroundId` parameter of `GET /v1/rooms.history`. The method keeps working until it is removed in 9.0.0. diff --git a/.changeset/deprecate-gravatar-integration.md b/.changeset/deprecate-gravatar-integration.md new file mode 100644 index 0000000..56b680c --- /dev/null +++ b/.changeset/deprecate-gravatar-integration.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Deprecates the Gravatar integration. Default avatars fetched from Gravatar on user creation and Gravatar-based avatar suggestions in the profile settings will be removed in a future major release; a warning is now logged when they are used. diff --git a/.changeset/directory-leftover-federation-origin.md b/.changeset/directory-leftover-federation-origin.md new file mode 100644 index 0000000..8fda257 --- /dev/null +++ b/.changeset/directory-leftover-federation-origin.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes the admin and search directory omitting local users whose documents still carried data from the removed legacy federation, making them unsearchable in Directory > Users while still visible in Spotlight and Administration > Users. diff --git a/.changeset/fuzzy-terms-brake.md b/.changeset/fuzzy-terms-brake.md new file mode 100644 index 0000000..7c07ead --- /dev/null +++ b/.changeset/fuzzy-terms-brake.md @@ -0,0 +1,12 @@ +--- +'@rocket.chat/media-calls': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds name and avatar resolution for external voice calls diff --git a/.changeset/giphy-collapse-state-scroll.md b/.changeset/giphy-collapse-state-scroll.md new file mode 100644 index 0000000..13f4ed2 --- /dev/null +++ b/.changeset/giphy-collapse-state-scroll.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where message attachments lose their collapsed state when scrolled out of view. diff --git a/.changeset/heavy-lions-attend.md b/.changeset/heavy-lions-attend.md new file mode 100644 index 0000000..169f319 --- /dev/null +++ b/.changeset/heavy-lions-attend.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/federation-matrix': patch +'@rocket.chat/meteor': patch +--- + +Fixes federation endpoints rejecting requests that are valid per the Matrix specification: + +- `publicRooms` (GET and POST) required params/fields the spec marks optional +- `query/profile` rejected spec-valid profile fields such as `m.tz` +- `get_missing_events` required the optional `limit` field and bounded it +- `make_join` returned 500 instead of 400 `M_INCOMPATIBLE_ROOM_VERSION` for unsupported room versions +- `backfill` rejected spec-valid `limit` values +- `send` rejected an entire transaction when a single PDU didn't match a fixed event shape, instead of reporting failures per PDU + +Also links every federation endpoint to its definition in the Matrix specification. diff --git a/.changeset/large-spies-raise.md b/.changeset/large-spies-raise.md new file mode 100644 index 0000000..70d39ec --- /dev/null +++ b/.changeset/large-spies-raise.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Improves draft handling by discarding the stored draft as soon as a message is sent, instead of keeping it until the room is changed diff --git a/.changeset/lazy-hats-hammer.md b/.changeset/lazy-hats-hammer.md new file mode 100644 index 0000000..657e85b --- /dev/null +++ b/.changeset/lazy-hats-hammer.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes long custom statuses being cut off without an ellipsis in the user status menu. \ No newline at end of file diff --git a/.changeset/light-kiwis-share.md b/.changeset/light-kiwis-share.md new file mode 100644 index 0000000..e393735 --- /dev/null +++ b/.changeset/light-kiwis-share.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Improves the sidebar to keep the room currently open listed under its category even while that category is collapsed, so it stays clear which room is being viewed diff --git a/.changeset/meteor-3-5.md b/.changeset/meteor-3-5.md new file mode 100644 index 0000000..6178f5a --- /dev/null +++ b/.changeset/meteor-3-5.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Update Meteor to 3.5.2 and bump Node.js to 24.15.0 to match the new bundled runtime. Also bumps `isolated-vm` to 6.2.0 for Node.js 24 compatibility. diff --git a/.changeset/mobile-web-ui-tweaks.md b/.changeset/mobile-web-ui-tweaks.md new file mode 100644 index 0000000..8bd737c --- /dev/null +++ b/.changeset/mobile-web-ui-tweaks.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/web-ui-registration": patch +--- + +Improves the mobile web experience: the page background now follows the active theme so Safari no longer shows white strips in the overscroll/safe-area regions (and tints its toolbar) in dark mode; the login "Welcome to " title is scaled down on small screens; focusing a field no longer triggers iOS Safari's auto zoom-in (inputs use a 16px minimum on mobile); the login email/username field no longer auto-capitalizes/auto-corrects; and the connection status bar layout no longer truncates its "Connect" button on narrow screens. diff --git a/.changeset/new-towns-wink.md b/.changeset/new-towns-wink.md new file mode 100644 index 0000000..56b786e --- /dev/null +++ b/.changeset/new-towns-wink.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes empty notifications when video conferences are created diff --git a/.changeset/odd-times-sleep.md b/.changeset/odd-times-sleep.md new file mode 100644 index 0000000..28feabd --- /dev/null +++ b/.changeset/odd-times-sleep.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Introduces custom, user-defined categories to the sidebar (Enterprise only). Users can create, rename, delete and reorder categories (menu-driven), and move rooms into them via the room context menu or the room header. diff --git a/.changeset/perf-quick-wins.md b/.changeset/perf-quick-wins.md new file mode 100644 index 0000000..c7767f2 --- /dev/null +++ b/.changeset/perf-quick-wins.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/random': patch +'@rocket.chat/i18n': patch +'@rocket.chat/license': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/meteor': patch +--- + +Improves performance of several hot code paths without changing behavior: generates random IDs with a single `crypto.randomBytes` call instead of one per character, caches constant regular expressions used by the markdown/mention/autotranslate parsers instead of recompiling them for every message, skips the channel-mention database query for messages without channel mentions, deduplicates the room member count query when a message contains both `@all` and `@here`, and replaces linear array scans and spread-accumulators with Map/Set lookups in API response shaping (files, DM members, directory search and team listing). diff --git a/.changeset/proud-pears-wait.md b/.changeset/proud-pears-wait.md new file mode 100644 index 0000000..1a63958 --- /dev/null +++ b/.changeset/proud-pears-wait.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes users not being added to the default channels (such as `#general`) when they are assigned their first username, which affected the admin created by the setup wizard on a brand new workspace and OAuth, SAML and LDAP users that pick a username through the "Register Username" screen. diff --git a/.changeset/real-teeth-clean.md b/.changeset/real-teeth-clean.md new file mode 100644 index 0000000..b0d9559 --- /dev/null +++ b/.changeset/real-teeth-clean.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/media-calls': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Started storing SIP Call Id on the media calls model +Added an endpoint to retrieve media calls data diff --git a/.changeset/rest-chat-get-messages.md b/.changeset/rest-chat-get-messages.md new file mode 100644 index 0000000..303ab88 --- /dev/null +++ b/.changeset/rest-chat-get-messages.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a new `POST /v1/chat.getMessages` endpoint to fetch several messages at once by id, including messages from different rooms. The request fails if any of the messages belongs to a room the caller cannot read. diff --git a/.changeset/rest-cloud-workspace-register-data.md b/.changeset/rest-cloud-workspace-register-data.md new file mode 100644 index 0000000..c58e349 --- /dev/null +++ b/.changeset/rest-cloud-workspace-register-data.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a new `GET /v1/cloud.workspaceRegisterData` endpoint returning the base64 encoded workspace registration payload used by the offline cloud registration flow. It requires the `manage-cloud` permission. diff --git a/.changeset/rest-rooms-history-around.md b/.changeset/rest-rooms-history-around.md new file mode 100644 index 0000000..500c053 --- /dev/null +++ b/.changeset/rest-rooms-history-around.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds an `aroundId` parameter to `GET /v1/rooms.history`, returning a window of messages centered on a given message instead of a page from one end of the room. This is what jumping to a quoted message, a search result or a thread message uses to load the surrounding conversation. diff --git a/.changeset/rest-rooms-history.md b/.changeset/rest-rooms-history.md new file mode 100644 index 0000000..a1938d4 --- /dev/null +++ b/.changeset/rest-rooms-history.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a new `GET /v1/rooms.history` endpoint to load a room's message history. Unlike the existing `channels.history`, `groups.history`, `im.history` and `dm.history` endpoints, it works with any room type through a single route, and can be used to read public channels anonymously when `Accounts_AllowAnonymousRead` is enabled. diff --git a/.changeset/rest-setup-wizard-parameters.md b/.changeset/rest-setup-wizard-parameters.md new file mode 100644 index 0000000..c4f18f5 --- /dev/null +++ b/.changeset/rest-setup-wizard-parameters.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds a new `GET /v1/setupWizard.parameters` endpoint returning the settings flagged for the setup wizard along with a `serverAlreadyRegistered` flag. diff --git a/.changeset/room-toolbox-layout.md b/.changeset/room-toolbox-layout.md new file mode 100644 index 0000000..27211ad --- /dev/null +++ b/.changeset/room-toolbox-layout.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/i18n': minor +--- + +Adds a configurable **Room Toolbox Layout**, available as an opt-in **Feature Preview** (My Account → Feature Preview → Room → **Room Toolbox Layout**). While the feature preview is off, the room header toolbox keeps its current behavior and the setting has no effect. + +Admins can define the layout through the new workspace setting **Room Toolbox Layout** (`Room_Toolbox_Layout`) under **Admin → Settings → Layout → Room Header**, which is itself only shown when **Feature Preview** is enabled for the workspace. The setting value is validated against a JSON schema when saved, so a malformed configuration is rejected with an error instead of being silently ignored \ No newline at end of file diff --git a/.changeset/session-resume-skeleton.md b/.changeset/session-resume-skeleton.md new file mode 100644 index 0000000..d969665 --- /dev/null +++ b/.changeset/session-resume-skeleton.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Shows the loading skeleton instead of flashing the login form while a stored session is being resumed diff --git a/.changeset/sidebar-stale-drafts-category.md b/.changeset/sidebar-stale-drafts-category.md new file mode 100644 index 0000000..a411eb2 --- /dev/null +++ b/.changeset/sidebar-stale-drafts-category.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes the removed `Drafts` sidebar group reappearing as an empty category on workspaces upgraded from a build that shipped it diff --git a/.changeset/storybook-dark-mode-fork.md b/.changeset/storybook-dark-mode-fork.md new file mode 100644 index 0000000..e6cf2e6 --- /dev/null +++ b/.changeset/storybook-dark-mode-fork.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/storybook-dark-mode': minor +--- + +Declares `storybook` as a peer dependency, at `^9.0.0`. The addon has always imported from `storybook/preview-api`, `storybook/manager-api`, `storybook/theming` and several `storybook/internal/*` paths, but never said so, which left the requirement implicit and the internal APIs it relies on without a version contract. + +Also stops shipping `.d.ts` and `.d.ts.map` files inside `dist/esm` and `dist/cjs`. Declarations were being emitted three times over and only the `dist/ts` copy is referenced by `types`, so the other two were dead weight in the tarball. Anything deep-importing types from `dist/esm` or `dist/cjs` — as opposed to resolving them through the package entry point — needs to use `dist/ts` instead. diff --git a/.changeset/swift-otters-listen.md b/.changeset/swift-otters-listen.md new file mode 100644 index 0000000..b574afa --- /dev/null +++ b/.changeset/swift-otters-listen.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes 'mark as unread' action not available for federated messages diff --git a/.changeset/tall-pandas-repeat.md b/.changeset/tall-pandas-repeat.md new file mode 100644 index 0000000..ff19a46 --- /dev/null +++ b/.changeset/tall-pandas-repeat.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Aligns the rate limits of `spotlight`, `directory`, `chat.followMessage` and `chat.unfollowMessage` with the values defined in the DDP methods they replaced. They were falling back to the generic REST default of 10 requests per 60 seconds instead. diff --git a/.changeset/tasty-dingos-sit.md b/.changeset/tasty-dingos-sit.md new file mode 100644 index 0000000..479fd9c --- /dev/null +++ b/.changeset/tasty-dingos-sit.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes team deletion failing when the team's main room no longer exists. Such teams were left orphaned: hidden from the admin UI, still reserving their name, and impossible to remove through any supported path. Deleting a team now completes even when its main room is already gone, so a deletion interrupted midway can be finished by simply retrying it, and the team name becomes available again. diff --git a/.changeset/tidy-hoops-refuse.md b/.changeset/tidy-hoops-refuse.md new file mode 100644 index 0000000..02bd991 --- /dev/null +++ b/.changeset/tidy-hoops-refuse.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/models': patch +'@rocket.chat/model-typings': patch +--- + +Fixes Omnichannel rooms failing to register agent responses (and showing send errors on messages and file uploads that were actually delivered) when the room carried corrupted visitor activity data created by older app integrations diff --git a/.changeset/tired-camels-bow.md b/.changeset/tired-camels-bow.md new file mode 100644 index 0000000..e6ed011 --- /dev/null +++ b/.changeset/tired-camels-bow.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the message composer is cleared after a slash command is executed. diff --git a/.changeset/ui-voip-call-widget-layout.md b/.changeset/ui-voip-call-widget-layout.md new file mode 100644 index 0000000..e5af78b --- /dev/null +++ b/.changeset/ui-voip-call-widget-layout.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-voip': minor +'@rocket.chat/meteor': patch +--- + +Improved the in-call voice widget: media elements no longer detach and re-attach when an unrelated device changes, so a shared screen or camera stops blinking whenever the microphone is muted or a device is switched. diff --git a/.changeset/warm-baboons-rescue.md b/.changeset/warm-baboons-rescue.md new file mode 100644 index 0000000..40cf473 --- /dev/null +++ b/.changeset/warm-baboons-rescue.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes ignored messages from ignored users in threads. diff --git a/.changeset/witty-beds-bow.md b/.changeset/witty-beds-bow.md new file mode 100644 index 0000000..5ff449f --- /dev/null +++ b/.changeset/witty-beds-bow.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes direct messages addressed by username, such as the ones opened with **Reply in direct message**, not being found on the first lookup — which made opening a conversation cost an extra request and log an avoidable `Invalid Room` error. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..46c3c8b --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,41 @@ +FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu + +COPY package.json /tmp/package.json + +RUN apt-get update && apt-get install -y \ + apt-transport-https \ + build-essential \ + ca-certificates \ + curl \ + jq \ + libssl-dev && \ + rm -rf /var/lib/apt/lists/* + +ENV NVM_DIR /usr/local/nvm +RUN mkdir -p $NVM_DIR && \ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash && \ + bash -c ". $NVM_DIR/nvm.sh && nvm install $(jq -r '.engines.node' /tmp/package.json) && npm install -g yarn" + +RUN echo "export NVM_DIR=$NVM_DIR" >> /etc/bash.bashrc && \ + echo "[ -s \"$NVM_DIR/nvm.sh\" ] && \. \"$NVM_DIR/nvm.sh\"" >> /etc/bash.bashrc + +ENV DENO_INSTALL=/usr/local/deno +RUN mkdir -p $DENO_INSTALL && \ + curl -fsSL https://deno.land/install.sh | sh + +ENV DENO_INSTALL="/usr/local/deno" +ENV PATH="$DENO_INSTALL/bin:$PATH" + +RUN echo 'export DENO_INSTALL="/usr/local/deno"' >> /etc/bash.bashrc && \ + echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /etc/bash.bashrc + +ENV METEOR_INSTALL="/usr/local/meteor" +ENV PATH="$METEOR_INSTALL/bin:$PATH" + +RUN curl https://install.meteor.com/ | sh && \ + mv ~/.meteor $METEOR_INSTALL + +RUN echo 'export METEOR_INSTALL="/usr/local/meteor"' >> /etc/bash.bashrc && \ + echo 'export PATH="$METEOR_INSTALL/bin:$PATH"' >> /etc/bash.bashrc + +CMD ["bash"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1b2960d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "Rocket.Chat Gitpod", + "build": { + "context": "..", + "dockerfile": "./Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "forwardPorts": [3000], + "customizations": { + "vscode": { + "extensions": [ + "esbenp.prettier-vscode" + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f2f826b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js,coffee,html,less,css,json}] +indent_style = tab + +[*.i18n.json] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dfdf2c0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,42 @@ +/packages/* @RocketChat/Architecture +/packages/apps-engine/ @RocketChat/apps +/packages/apps/ @RocketChat/apps +/packages/core-typings/ @RocketChat/Architecture +/packages/rest-typings/ @RocketChat/Architecture @RocketChat/backend +/packages/ui-contexts/ @RocketChat/frontend +/packages/eslint-config/ @RocketChat/Architecture +/packages/livechat/ @RocketChat/frontend @RocketChat/Architecture +/.vscode/ @RocketChat/Architecture +/.github/ @RocketChat/Architecture +/_templates/ @RocketChat/Architecture +/apps/meteor/client/ @RocketChat/frontend +/apps/meteor/tests/e2e @RocketChat/frontend +/apps/meteor/tests/end-to-end @RocketChat/backend +/apps/meteor/tests/unit/app @RocketChat/backend +/apps/meteor/tests/unit/client @RocketChat/frontend +/apps/meteor/tests/unit/server @RocketChat/backend +/apps/meteor/app/apps/ @RocketChat/apps +/apps/meteor/app/livechat @RocketChat/omnichannel +/apps/meteor/app/file-upload @RocketChat/backend +/apps/meteor/app/integrations @RocketChat/backend +/apps/meteor/server @RocketChat/backend +/apps/meteor/server/lib/omnichannel @RocketChat/omnichannel +/apps/meteor/server/hooks/omnichannel @RocketChat/omnichannel +/apps/meteor/server/api/v1/omnichannel @RocketChat/omnichannel +/apps/meteor/server/meteor-methods/omnichannel @RocketChat/omnichannel +/packages/models @RocketChat/Architecture +apps/meteor/server/startup/migrations @RocketChat/Architecture +/apps/meteor/packages/rocketchat-livechat @RocketChat/omnichannel +/apps/meteor/server/features/EmailInbox @RocketChat/omnichannel +/apps/meteor/ee/server/apps/ @RocketChat/apps +/apps/meteor/ee/tests/unit/server/apps/ @RocketChat/apps +/apps/meteor/ee/server/lib/canned-responses @RocketChat/omnichannel +/apps/meteor/ee/server/hooks/canned-responses @RocketChat/omnichannel +/apps/meteor/ee/server/api/v1/canned-responses.ts @RocketChat/omnichannel +/apps/meteor/ee/server/lib/omnichannel @RocketChat/omnichannel +/apps/meteor/ee/server/hooks/omnichannel @RocketChat/omnichannel +/apps/meteor/ee/server/api/v1/omnichannel @RocketChat/omnichannel +/apps/meteor/ee/server/meteor-methods/saveCannedResponse.ts @RocketChat/omnichannel +/apps/meteor/ee/server/meteor-methods/removeCannedResponse.ts @RocketChat/omnichannel +/apps/meteor/client/omnichannel @RocketChat/omnichannel +/apps/meteor/client/components/omnichannel @RocketChat/omnichannel diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..85a3365 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing to Rocket.Chat + +**First off, thanks for taking the time to contribute! :tada::+1:** + +> There are many ways to contribute to Rocket.Chat even if you're not technical or a developer: +> +> * Email us at marketing@rocket.chat to tell us how much you love the project +> * Write about us in your blogs +> * Fix some small typos in our [documentation](https://docs.rocket.chat/contributing) +> * Become our [GitHub sponsor](https://github.com/sponsors/RocketChat) +> * Tell others about us and help us spread the word +> +> Every bit of contribution is appreciated 🙂 thank you! + +The following is a set of guidelines for contributing to Rocket.Chat, which are hosted in the [Rocket.Chat Organization](https://github.com/RocketChat) on GitHub. + +__Note:__ If there's a feature you'd like, there's a bug you'd like to fix, or you'd just like to get involved please raise an issue and start a conversation. We'll help as much as we can so you can get contributing - although we may not always be able to respond right away :) + +## Development Guidelines + +Check out our Handbook for the [Development Guidelines](https://handbook.rocket.chat/space/RnD/359891343/%F0%9F%8C%90+Development+Guidelines) on how to set up your environment, do code, test and push your code. There you find our patterns on how to compose your Pull Requests' titles to have your contribution accepted. + +## Contributor License Agreement + +To have your contribution accepted you must sign our [Contributor License Agreement](https://cla-assistant.io/RocketChat/Rocket.Chat). In case you submit a Pull Request before signing the CLA GitHub will alert you with a new comment asking you to sign and will block the Pull Request from being merged by us. + +Please review and sign our CLA at https://cla-assistant.io/RocketChat/Rocket.Chat diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5a077e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,59 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + + + +### Description: + + + +### Steps to reproduce: + +1. +2. +3. + +### Expected behavior: + + + +### Actual behavior: + + + +### Server Setup Information: + +- Version of Rocket.Chat Server: +- License Type: +- Number of Users: +- Operating System: +- Deployment Method: +- Number of Running Instances: +- DB Replicaset Oplog: +- NodeJS Version: +- MongoDB Version: + +### Client Setup Information + +- Desktop App or Browser Version: +- Operating System: + +### Additional context + + + +### Relevant logs: + + + diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md new file mode 100644 index 0000000..98bdf88 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release.md @@ -0,0 +1,59 @@ +--- +name: Release +about: Internal release checklist template + +--- + +# Release {version} +We are releasing a new version, this issue will keep track of the progress between the first release candidate (20th of each month) to the final release (27th of each month). + +Beginning on the 20th of each month, we will start the release process which ends 7 days later (the 27th). During this period of time, we will enter a "Feature Freeze". This Feature Freeze means that we will only be merging pull requests which fix bugs and not ones that add new features. + +When you find a bug that is a regression, please open a new issue and link it to this one. + + +## Before Release - Preparation - 1 business day before the day 20th +- [x] Create the issue to track the release progress +- [ ] Define the highlights from release PRs as a suggestion to be included on Blog Post +- [ ] Talk to the Marketing Team about starting the release Blog Post +- [ ] Talk to the Documentation Team about ensuring the Docs are up to date and all pull requests are merged +- [ ] Sync translations from [LingoHub](https://translate.lingohub.com/rocketchat/rocket-dot-chat/dashboard) + +## Release Candidate 1 - On the 20th +- [ ] Execute action `Release Candidate` via [Houston CLI](https://github.com/RocketChat/Rocket.Chat.Houston) (`houston release`) +- [ ] Check if `release-candidate` branch was published +- [ ] Check if the tag was published and contains the history +- [ ] Ensure the build is passing on [CircleCI](https://circleci.com/gh/RocketChat/Rocket.Chat) +- [ ] Ensure the image was sent to [Docker Hub](https://hub.docker.com/r/rocketchat/rocket.chat/tags/) + + + +## Final Release - On the 27th +- [ ] Execute action `Final Release` via [Houston CLI](https://github.com/RocketChat/Rocket.Chat.Houston) (`houston release`) +- [ ] Check if `release-{version}` branch was published +- [ ] Check if the release was created as **draft** and contains the history +- [ ] Check if the release Pull Request was created and contains the history +- [ ] Ensure the **Pull Request** build is passing on [CircleCI](https://circleci.com/gh/RocketChat/Rocket.Chat) +- [ ] When the build is passing ask for Pull Request approval +- [ ] When approved, merge it! +- [ ] Ensure the **Tag** build is passing on [CircleCI](https://circleci.com/gh/RocketChat/Rocket.Chat) +- [ ] Ensure the image was sent to [Docker Hub](https://hub.docker.com/r/rocketchat/rocket.chat/tags/) +- [ ] After all edit the release/tag and publish it + +## After Release - Conclusion - 1 business day after the 27th +- [ ] Ensure all of the related issues were closed +- [ ] Determine if all of the related issues were correctly assigned to this version's milestone +- [ ] Get an update from Marketing Team about the release of Blog Post +- [ ] Check with the Documentation Team about the Docs release +- [ ] Sync develop + - [ ] Execute action `Develop Sync` via [Houston CLI](https://github.com/RocketChat/Rocket.Chat.Houston) (`houston release`) + - [ ] Ensure the **Pull Request** build is passing on [CircleCI](https://circleci.com/gh/RocketChat/Rocket.Chat) + - [ ] When the build has passed, ask for approval and wait + - [ ] Merge Sync PR diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0b2af2e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ + + + + + + +## Proposed changes (including videos or screenshots) + + +## Issue(s) + + +## Steps to test or reproduce + + +## Further comments + diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000..4613e16 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..205a414 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +paths: + .github/workflows/**/*.yml: + ignore: ['file "dist/index\.js" does not exist in .*/packages/release-action'] diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml new file mode 100644 index 0000000..827264b --- /dev/null +++ b/.github/actions/build-docker/action.yml @@ -0,0 +1,246 @@ +name: 'Build Docker' +description: 'Build Docker images for Rocket.Chat and related services' + +inputs: + CR_USER: + required: true + description: 'GitHub Container Registry username' + CR_PAT: + required: true + description: 'GitHub Container Registry Personal Access Token' + DOCKER_USER: + required: false + description: 'DockerHub username (required for FIPS builds to pull private base image)' + DOCKER_PASS: + required: false + description: 'DockerHub password (required for FIPS builds to pull private base image)' + deno-version: + required: true + description: 'Deno version' + arch: + required: false + description: 'Architecture' + default: 'arm64' + service: + required: false + description: 'Container to build' + publish-image: + required: false + description: 'Publish image' + default: 'true' + setup-docker: + required: false + description: 'Setup Docker' + default: 'true' + type: + required: false + description: 'production, coverage, or fips' + default: 'coverage' + +runs: + using: composite + + steps: + - name: Login to GitHub Container Registry + if: inputs.publish-image == 'true' && github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ inputs.CR_USER }} + password: ${{ inputs.CR_PAT }} + + - name: Login to DockerHub + # FIPS base image (rocketchat/dhi-node) lives in a private DockerHub repo and requires auth to pull. + if: inputs.type == 'fips' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.event_name == 'merge_group' || github.ref == 'refs/heads/develop') + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ inputs.DOCKER_USER }} + password: ${{ inputs.DOCKER_PASS }} + + - name: Restore meteor build + if: inputs.service == 'rocketchat' + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: build-${{ inputs.type == 'fips' && ((github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production') || inputs.type }} + path: /tmp/build + + - name: Unpack meteor build + if: inputs.service == 'rocketchat' + shell: bash + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Set up Docker + if: inputs.setup-docker == 'true' + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 + with: + daemon-config: | + { + "debug": false, + "features": { + "containerd-snapshotter": true + } + } + + # Microservice images build from a pruned, self-contained context (out/) + # produced by `turbo prune`, instead of a hand-maintained list of COPY lines. + # The rocketchat (Meteor) image is a prebuilt bundle and is staged separately above. + - name: Stage service build context + if: inputs.service != 'rocketchat' + shell: bash + env: + INPUT_SERVICE: ${{ inputs.service }} + run: | + set -o xtrace + + # The Docker SERVICE arg (folder under ee/apps) lives in the compose build args; + # the compose service name (INPUT_SERVICE) can differ (e.g. ddp-streamer-service). + SERVICE_DIR=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$INPUT_SERVICE" '.services[$s].build.args.SERVICE') + PKG_NAME=$(node -p "require('./ee/apps/${SERVICE_DIR}/package.json').name") + TURBO_VERSION=$(node -p "require('./package.json').devDependencies.turbo") + + rm -rf "out/${SERVICE_DIR}" + + # `turbo prune` derives the exact workspace dependency closure for the service — no + # hand-maintained list. --use-gitignore=false also captures the gitignored build output + # we need (dist/, generated definition/), at the cost of dragging in node_modules/.turbo, + # which we strip below; `yarn workspaces focus --production` rebuilds node_modules in-image. + npx -y "turbo@${TURBO_VERSION}" prune "$PKG_NAME" --skip-infer --use-gitignore=false --out-dir "out/${SERVICE_DIR}" + + find "out/${SERVICE_DIR}" \( -name node_modules -o -name .turbo \) -type d -prune -exec rm -rf {} + + + - name: Build Docker images + shell: bash + env: + DENO_VERSION: ${{ inputs.deno-version }} + INPUT_ARCH: ${{ inputs.arch }} + INPUT_SERVICE: ${{ inputs.service }} + INPUT_PUBLISH_IMAGE: ${{ inputs.publish-image }} + INPUT_TYPE: ${{ inputs.type }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + run: | + set -o xtrace + compose_fips_override='' + if [[ "$INPUT_TYPE" == 'fips' ]]; then + compose_fips_override='-f docker-compose-ci.fips.yml' + fi + + # Removes unnecessary swc cores and sharp binaries to reduce image size + swc_arch='x64' + if [[ "$INPUT_SERVICE" == 'rocketchat' ]]; then + if [[ "$INPUT_ARCH" == 'arm64' ]]; then + swc_arch='arm64' + fi + + find /tmp/build/bundle/programs/server/npm/node_modules/@img -type d -name 'sharp-*' -not -name "*-linuxmusl-${swc_arch}" -exec rm -rf {} + + + find /tmp/build/bundle/programs/server/npm/node_modules/@napi-rs -type d -name 'pinyin-linux-*' -not -name "*-linux-${swc_arch}-*" -exec rm -rf {} + + + find /tmp/build/bundle/programs/server/npm/node_modules/@esbuild -type d -name 'linux-*' -not -name "*-${swc_arch}" -exec rm -rf {} + + + find /tmp/build/bundle/programs/server/npm/node_modules/@rocket.chat/apps/node_modules/@esbuild -type d -name 'linux-*' -not -name "*-${swc_arch}" -exec rm -rf {} + + fi + + if [[ "$INPUT_PUBLISH_IMAGE" == 'true' ]]; then + LOAD_OR_PUSH="--push" + else + LOAD_OR_PUSH="--load" + fi + + # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) + IMAGE=$(docker compose -f docker-compose-ci.yml $compose_fips_override config --format json 2>/dev/null | jq -r --arg s "$INPUT_SERVICE" '.services[$s].image') + + docker buildx bake \ + -f docker-compose-ci.yml $compose_fips_override \ + ${LOAD_OR_PUSH} \ + --allow=fs.read=/tmp/build \ + --set "*.tags+=${IMAGE}-gha-run-${GITHUB_RUN_ID}" \ + --set "*.labels.org.opencontainers.image.description=Build run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --set "*.labels.org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --set "*.platform=linux/${INPUT_ARCH}" \ + --set *.cache-from=type=gha \ + --set *.cache-to=type=gha,mode=max \ + --provenance=false \ + --sbom=false \ + --metadata-file "/tmp/meta.json" \ + "$INPUT_SERVICE" + + echo "Contents of /tmp/meta.json:" + cat /tmp/meta.json + + if [[ "$INPUT_PUBLISH_IMAGE" == 'true' ]]; then + SERVICE_SUFFIX='' + if [[ "$INPUT_SERVICE" == 'rocketchat' && "$INPUT_TYPE" == 'coverage' ]] && [[ "$GITHUB_EVENT_NAME" == 'release' || "$GITHUB_REF" == 'refs/heads/develop' ]]; then + SERVICE_SUFFIX='-cov' + elif [[ "$INPUT_TYPE" == 'fips' ]]; then + SERVICE_SUFFIX='-fips' + fi + + mkdir -p "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}" + + # Get digest and image info + DIGEST=$(jq -r --arg service "$INPUT_SERVICE" '.[$service].["containerimage.digest"]' "/tmp/meta.json") + IMAGE_NO_TAG=$(echo "$IMAGE" | sed 's/:.*$//') + FULL_IMAGE="${IMAGE_NO_TAG}@${DIGEST}" + + echo "Inspecting image: $FULL_IMAGE" + + # Inspect the image and save complete manifest with sizes (using -v for verbose) + docker manifest inspect -v "$FULL_IMAGE" > "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json" + + echo "Saved manifest to /tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json" + cat "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}/manifest.json" | jq '.' + fi + + - name: Save Docker image as artifact + if: inputs.publish-image == 'false' && inputs.arch == 'amd64' + shell: bash + env: + SERVICE: ${{ inputs.service }} + ARCH: ${{ inputs.arch }} + TYPE: ${{ inputs.type }} + run: | + set -o xtrace + compose_fips_override='' + if [[ "$TYPE" == 'fips' ]]; then + compose_fips_override='-f docker-compose-ci.fips.yml' + fi + + # Get image name from docker-compose-ci.yml + IMAGE=$(docker compose -f docker-compose-ci.yml $compose_fips_override config --format json 2>/dev/null | jq -r --arg s "$SERVICE" '.services[$s].image') + + # Create directory for image archives + mkdir -p /tmp/docker-images + + # Save the image to a tar file + docker save "${IMAGE}" -o "/tmp/docker-images/${SERVICE}-${ARCH}-${TYPE}.tar" + + echo "Saved image to /tmp/docker-images/${SERVICE}-${ARCH}-${TYPE}.tar" + ls -lh /tmp/docker-images/ + + - name: Upload Docker image artifact + if: inputs.publish-image == 'false' && inputs.arch == 'amd64' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: docker-image-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }} + path: /tmp/docker-images/${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }}.tar + retention-days: 1 + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: inputs.publish-image == 'true' + with: + name: manifests-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }} + path: /tmp/manifests + retention-days: 5 + + - name: Clean up temporary files + if: inputs.service == 'rocketchat' + shell: bash + run: | + sudo rm -rf /tmp/build diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml new file mode 100644 index 0000000..31d887c --- /dev/null +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -0,0 +1,581 @@ +name: 'Docker Image Size Tracker' +description: 'Track and report Docker image sizes in Pull Requests' +author: 'Rocket.Chat' + +inputs: + github-token: + description: 'GitHub token for commenting on PRs' + required: true + ci-pat: + description: 'GitHub token for committing to history branch' + required: true + registry: + description: 'Container registry (e.g., ghcr.io)' + required: false + default: 'ghcr.io' + repository: + description: 'Repository name (e.g., rocketchat)' + required: true + tag: + description: 'Image tag to measure' + required: true + baseline-tag: + description: 'Baseline tag to compare against' + required: false + default: 'develop' + platform: + description: 'Platform architecture to compare (amd64 or arm64)' + required: false + default: 'amd64' + size-thresholds: + description: 'Optional JSON: per-image trigger thresholds. Only comment when an image increase exceeds its threshold. Example: {"rocketchat":{"mb":50,"percent":5},"omnichannel":{"mb":10,"percent":2}}' + required: false + default: '' + fail-thresholds: + description: 'Optional JSON: per-image fail thresholds. Task fails when an image increase exceeds its threshold (mb and/or percent). Example: {"rocketchat":{"mb":100,"percent":15}}' + required: false + default: '' + +outputs: + total-size: + description: 'Total size in bytes' + value: ${{ steps.measure.outputs.total-size }} + size-diff: + description: 'Size difference in bytes' + value: ${{ steps.compare.outputs.size-diff }} + size-diff-percent: + description: 'Size difference percentage' + value: ${{ steps.compare.outputs.size-diff-percent }} + comment-triggered: + description: 'Whether to post PR comment (only when size is bigger and thresholds met)' + value: ${{ steps.compare.outputs.comment-triggered }} + failed: + description: 'True if image size exceeded fail-thresholds' + value: ${{ steps.compare.outputs.failed }} + +runs: + using: 'composite' + steps: + - name: Download manifests + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + pattern: manifests-* + path: /tmp/manifests + merge-multiple: true + + - name: Measure image sizes from artifacts + id: measure + shell: bash + env: + PLATFORM: ${{ inputs.platform }} + TAG: ${{ inputs.tag }} + run: | + echo "Reading image sizes from build artifacts for platform: $PLATFORM" + + declare -A sizes + declare -a services_list + total=0 + + # Loop through service directories (same as publish workflow) + shopt -s nullglob + for service_dir in /tmp/manifests/*; do + [[ -d "$service_dir" ]] || continue + service="$(basename "$service_dir")" + + echo "Processing service: $service" + services_list+=("$service") + + size=0 + # Read only the specified platform architecture + manifest_file="$service_dir/$PLATFORM/manifest.json" + if [[ -f "$manifest_file" ]]; then + # docker manifest inspect -v puts the manifest under SchemaV2Manifest + # (Docker schema2 images) or OCIManifest (OCI images, which buildx + # produces by default). Coalesce so both formats are read. + config_size=$(jq -r '(.SchemaV2Manifest // .OCIManifest).config.size // 0' "$manifest_file") + layers_size=$(jq '[(.SchemaV2Manifest // .OCIManifest).layers[]?.size // 0] | add // 0' "$manifest_file") + size=$((config_size + layers_size)) + + echo " → Found $manifest_file: $size bytes (config: $config_size, layers: $layers_size)" + else + echo " ⚠ Manifest not found for platform $PLATFORM: $manifest_file" + fi + + sizes[$service]=$size + total=$((total + size)) + done + + echo "Total size (all services, $PLATFORM only): $total bytes" + echo "total-size=$total" >> $GITHUB_OUTPUT + + # Save to JSON + echo "{" > current-sizes.json + echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> current-sizes.json + echo " \"tag\": \"$TAG\"," >> current-sizes.json + echo " \"total\": $total," >> current-sizes.json + echo " \"services\": {" >> current-sizes.json + + first=true + for service in "${services_list[@]}"; do + if [[ "$first" == "true" ]]; then + first=false + else + echo "," >> current-sizes.json + fi + echo " \"$service\": ${sizes[$service]}" >> current-sizes.json + done + + echo " }" >> current-sizes.json + echo "}" >> current-sizes.json + + echo "Current sizes saved:" + cat current-sizes.json + + # Save services list for baseline measurement + printf '%s\n' "${services_list[@]}" > /tmp/services-list.txt + + - name: Measure baseline + id: baseline + shell: bash + continue-on-error: true + env: + REGISTRY: ${{ inputs.registry }} + ORG: ${{ inputs.repository }} + TAG: ${{ inputs.baseline-tag }} + PLATFORM: ${{ inputs.platform }} + run: | + echo "Measuring baseline: $REGISTRY/$ORG/*:$TAG (platform: $PLATFORM)" + + declare -A sizes + declare -a services_list + total=0 + + # Read services list from current measurement + while IFS= read -r service; do + services_list+=("$service") + + # Map service name to image name (handle rocketchat -> rocket.chat) + if [[ "$service" == "rocketchat" ]] || [[ "$service" == "rocketchat-cov" ]]; then + image_name="rocket.chat" + [[ "$service" == "rocketchat-cov" ]] && image_name="rocket.chat-cov" + else + image_name="$service" + fi + + image="$REGISTRY/$ORG/$image_name:$TAG" + echo " → Inspecting $image" + + size=0 + if manifest=$(docker manifest inspect "$image" 2>/dev/null); then + # Check if it's a manifest list (multi-arch) + if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then + # Manifest list - find the specified platform + echo " → Multi-arch manifest detected, filtering for $PLATFORM" + platform_digest=$(echo "$manifest" | jq -r --arg arch "$PLATFORM" '.manifests[] | select(.platform.architecture == $arch) | .digest' | head -1) + + if [[ -n "$platform_digest" ]]; then + echo " → Inspecting $PLATFORM platform: $platform_digest" + if platform_manifest=$(docker manifest inspect "$REGISTRY/$ORG/$image_name@$platform_digest" 2>/dev/null); then + config_size=$(echo "$platform_manifest" | jq -r '.config.size // 0') + layers_size=$(echo "$platform_manifest" | jq '[.layers[]?.size // 0] | add // 0') + size=$((config_size + layers_size)) + echo " → Size: $size bytes" + fi + else + echo " ⚠ Platform $PLATFORM not found in manifest" + fi + else + # Single arch manifest + config_size=$(echo "$manifest" | jq -r '.config.size // 0') + layers_size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') + size=$((config_size + layers_size)) + fi + fi + + sizes[$service]=$size + total=$((total + size)) + done < /tmp/services-list.txt + + echo "baseline-total=$total" >> $GITHUB_OUTPUT + + echo "{" > baseline-sizes.json + echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> baseline-sizes.json + echo " \"tag\": \"$TAG\"," >> baseline-sizes.json + echo " \"total\": $total," >> baseline-sizes.json + echo " \"services\": {" >> baseline-sizes.json + + first=true + for service in "${services_list[@]}"; do + if [[ "$first" == "true" ]]; then + first=false + else + echo "," >> baseline-sizes.json + fi + echo " \"$service\": ${sizes[$service]}" >> baseline-sizes.json + done + + echo " }" >> baseline-sizes.json + echo "}" >> baseline-sizes.json + + - name: Setup history storage + id: history + shell: bash + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # Create a separate worktree for history branch + mkdir -p /tmp/history-worktree + + # Try to fetch history branch + if git ls-remote --heads origin image-size-history | grep -q image-size-history; then + git fetch origin image-size-history + git worktree add /tmp/history-worktree image-size-history + else + # Create orphan branch for history in worktree + git worktree add --detach /tmp/history-worktree + cd /tmp/history-worktree + git checkout --orphan image-size-history + git rm -rf . 2>/dev/null || true + mkdir -p history + echo "# Image Size History" > README.md + echo "This branch stores historical image size data for tracking" >> README.md + git add README.md + git commit -m "Initialize image size history" + git push origin image-size-history + cd - + fi + + mkdir -p /tmp/history-worktree/history + + - name: Load historical data + shell: bash + run: | + # Load last 30 measurements and group by day (keep only last entry per day) + echo "[]" > history-data.json + if [[ -d /tmp/history-worktree/history ]]; then + jq -s '.' /tmp/history-worktree/history/*.json 2>/dev/null | jq ' + sort_by(.timestamp) | + group_by(.timestamp[0:10]) | + map(.[-1]) | + .[-30:] + ' > history-data.json || echo "[]" > history-data.json + fi + + count=$(jq 'length' history-data.json) + echo "Loaded $count historical measurements (one per day)" + + - name: Save current measurement to history + if: github.ref == 'refs/heads/develop' + shell: bash + env: + CI_PAT: ${{ inputs.ci-pat }} + run: | + timestamp=$(date -u +%Y%m%d-%H%M%S) + commit_sha="${{ github.sha }}" + + # Add commit info to current measurement + jq --arg sha "$commit_sha" '. + {commit: $sha}' current-sizes.json > "/tmp/history-worktree/history/${timestamp}.json" + + cd /tmp/history-worktree + git add "history/${timestamp}.json" + git commit -m "Add measurement for ${timestamp} (${commit_sha:0:7})" + git config --global user.email "ci@rocket.chat" + git config --global user.name "rocketchat-ci[bot]" + git config --global url.https://$CI_PAT@github.com/.insteadOf https://github.com/ + git push origin image-size-history + cd - + + echo "Saved measurement to history" + + - name: Compare and generate report + id: compare + shell: bash + env: + SIZE_THRESHOLDS: ${{ inputs.size-thresholds }} + FAIL_THRESHOLDS: ${{ inputs.fail-thresholds }} + TAG: ${{ inputs.tag }} + BASELINE_TAG: ${{ inputs.baseline-tag }} + run: | + current_total=$(jq -r '.total' current-sizes.json) + + if [[ ! -f baseline-sizes.json ]]; then + echo "No baseline available" + echo "size-diff=0" >> $GITHUB_OUTPUT + echo "size-diff-percent=0" >> $GITHUB_OUTPUT + echo "comment-triggered=false" >> $GITHUB_OUTPUT + + cat > report.md << 'EOF' + # 📦 Docker Image Size Report + + **Status:** First measurement - no baseline for comparison + + **Total Size:** $(numfmt --to=iec-i --suffix=B $current_total) + EOF + exit 0 + fi + + baseline_total=$(jq -r '.total' baseline-sizes.json) + diff=$((current_total - baseline_total)) + + if [[ $baseline_total -gt 0 ]]; then + percent=$(awk "BEGIN {printf \"%.2f\", ($diff / $baseline_total) * 100}") + else + percent=0 + fi + + echo "size-diff=$diff" >> $GITHUB_OUTPUT + echo "size-diff-percent=$percent" >> $GITHUB_OUTPUT + + # Only comment when size is bigger than baseline; optionally require per-image thresholds + THRESHOLDS="$SIZE_THRESHOLDS" + FAIL_THRESHOLDS="$FAIL_THRESHOLDS" + comment_triggered=false + fail_triggered=false + if [[ $diff -gt 0 ]]; then + if [[ -z "$THRESHOLDS" ]] || [[ "$THRESHOLDS" == "{}" ]]; then + comment_triggered=true + fi + fi + + color="gray" + if (( $(awk "BEGIN {print ($percent > 0.01)}") )); then + color="red" + elif (( $(awk "BEGIN {print ($percent < -0.01)}") )); then + color="green" + fi + + # Generate report + if [[ $diff -gt 0 ]]; then + emoji="📈" + badge="![](https://img.shields.io/badge/size-+${percent}%25-${color})" + sign="+" + elif [[ $diff -lt 0 ]]; then + emoji="📉" + badge="![](https://img.shields.io/badge/size--${percent}%25-${color})" + sign="-" + else + emoji="➡️" + badge="![](https://img.shields.io/badge/size-unchanged-${color})" + sign="" + fi + + cat > report.md << EOF + # 📦 Docker Image Size Report + + ## $emoji Changes $badge + + | Service | Current | Baseline | Change | Percent | + |---------|---------|----------|--------|---------| + | **sum of all images** | **$(numfmt --to=iec-i --suffix=B $current_total)** | **$(numfmt --to=iec-i --suffix=B $baseline_total)** | **${sign}$(numfmt --to=iec-i --suffix=B ${diff#-})** | $badge | + EOF + + # Get services dynamically from current-sizes.json, sorted by size (largest first) + for service in $(jq -r '.services | to_entries | sort_by(-.value) | .[].key' current-sizes.json); do + current=$(jq -r ".services.\"$service\"" current-sizes.json) + baseline=$(jq -r ".services.\"$service\" // 0" baseline-sizes.json) + service_diff=$((current - baseline)) + + if [[ $baseline -gt 0 ]]; then + service_percent=$(awk "BEGIN {printf \"%.2f\", ($service_diff / $baseline) * 100}") + else + service_percent=0 + fi + + # Check per-image thresholds for comment trigger (only when size increased) + if [[ $diff -gt 0 ]] && [[ -n "$THRESHOLDS" ]] && [[ "$THRESHOLDS" != "{}" ]]; then + threshold_mb=$(echo "$THRESHOLDS" | jq -r ".\"$service\".mb // empty") + threshold_pct=$(echo "$THRESHOLDS" | jq -r ".\"$service\".percent // empty") + if [[ -n "$threshold_mb" ]] || [[ -n "$threshold_pct" ]]; then + exceeded=false + if [[ -n "$threshold_mb" ]] && [[ $service_diff -ge $(awk "BEGIN {printf \"%.0f\", $threshold_mb * 1048576}") ]]; then + exceeded=true + fi + if [[ -n "$threshold_pct" ]] && [[ $service_percent != "0.00" ]] && (( $(awk "BEGIN {print ($service_percent >= $threshold_pct)}") )); then + exceeded=true + fi + [[ "$exceeded" == "true" ]] && comment_triggered=true + fi + fi + + # Check per-image fail thresholds (task fails when exceeded) + if [[ $diff -gt 0 ]] && [[ -n "$FAIL_THRESHOLDS" ]] && [[ "$FAIL_THRESHOLDS" != "{}" ]]; then + fail_mb=$(echo "$FAIL_THRESHOLDS" | jq -r ".\"$service\".mb // empty") + fail_pct=$(echo "$FAIL_THRESHOLDS" | jq -r ".\"$service\".percent // empty") + if [[ -n "$fail_mb" ]] || [[ -n "$fail_pct" ]]; then + exceeded=false + if [[ -n "$fail_mb" ]] && [[ $service_diff -ge $(awk "BEGIN {printf \"%.0f\", $fail_mb * 1048576}") ]]; then + exceeded=true + fi + if [[ -n "$fail_pct" ]] && [[ $service_percent != "0.00" ]] && (( $(awk "BEGIN {print ($service_percent >= $fail_pct)}") )); then + exceeded=true + fi + [[ "$exceeded" == "true" ]] && fail_triggered=true + fi + fi + + color="gray" + if (( $(awk "BEGIN {print ($service_percent > 0.01)}") )); then + color="red" + elif (( $(awk "BEGIN {print ($service_percent < -0.01)}") )); then + color="green" + fi + + if [[ $service_diff -gt 0 ]]; then + badge="![](https://img.shields.io/badge/size-+${service_percent}%25-${color})" + sign="+" + elif [[ $service_diff -lt 0 ]]; then + badge="![](https://img.shields.io/badge/size--${service_percent}%25-${color})" + sign="-" + else + badge="![](https://img.shields.io/badge/size-unchanged-${color})" + sign="" + fi + + echo "| $service | $(numfmt --to=iec-i --suffix=B $current) | $(numfmt --to=iec-i --suffix=B $baseline) | ${sign}$(numfmt --to=iec-i --suffix=B ${service_diff#-}) | $badge |" >> report.md + done + + # Generate historical trend chart + echo "" >> report.md + echo "## 📊 Historical Trend" >> report.md + echo "" >> report.md + + # Load history and generate mermaid chart + history_count=$(jq 'length' history-data.json) + + if [[ $history_count -gt 0 ]]; then + # Get all services from current build + all_services=$(jq -r '.services | keys | .[]' current-sizes.json | sort) + + # Generate Mermaid chart data + x_labels="" + declare -A service_data + + # Initialize service data arrays + for service in $all_services; do + service_data[$service]="" + done + + # Process historical data + while IFS= read -r line; do + timestamp=$(echo "$line" | jq -r '.timestamp') + date_label=$(date -d "$timestamp" +"%m/%d %H:%M") + + if [[ -z "$x_labels" ]]; then + x_labels="\"$date_label\"" + else + x_labels="$x_labels, \"$date_label\"" + fi + + # Add data point for each service + for service in $all_services; do + size=$(echo "$line" | jq -r ".services.\"$service\" // 0") + size_gb=$(awk "BEGIN {printf \"%.2f\", $size / 1073741824}") + + if [[ -z "${service_data[$service]}" ]]; then + service_data[$service]="$size_gb" + else + service_data[$service]="${service_data[$service]}, $size_gb" + fi + done + done < <(jq -c '.[]' history-data.json) + + # Add current PR as last point + current_date=$(date -u +"%m/%d %H:%M") + x_labels="$x_labels, \"$current_date (PR)\"" + + for service in $all_services; do + size=$(jq -r ".services.\"$service\" // 0" current-sizes.json) + size_gb=$(awk "BEGIN {printf \"%.2f\", $size / 1073741824}") + service_data[$service]="${service_data[$service]}, $size_gb" + done + + # Generate mermaid chart with multiple lines + cat >> report.md << EOF + \`\`\`mermaid + --- + config: + theme: "dark" + xyChart: + width: 900 + height: 400 + --- + xychart + title "Image Size Evolution by Service (Last 30 Days + This PR)" + x-axis [$x_labels] + y-axis "Size (GB)" 0 --> 0.5 + EOF + + # Add a line for each service + for service in $all_services; do + echo " line \"$service\" [${service_data[$service]}]" >> report.md + done + + cat >> report.md << 'EOF' + ``` + + EOF + + # Add summary stats + min_size=$(jq '[.[].total] | min' history-data.json) + max_size=$(jq '[.[].total] | max' history-data.json) + avg_size=$(jq '[.[].total] | add / length' history-data.json) + + cat >> report.md << EOF + **Statistics (last $history_count days):** + - 📊 Average: $(numfmt --to=iec-i --suffix=B ${avg_size%.*}) + - ⬇️ Minimum: $(numfmt --to=iec-i --suffix=B $min_size) + - ⬆️ Maximum: $(numfmt --to=iec-i --suffix=B $max_size) + - 🎯 Current PR: $(numfmt --to=iec-i --suffix=B $current_total) + + EOF + + else + cat >> report.md << 'EOF' + *No historical data available yet. History tracking starts after merging to develop.* + + EOF + fi + + cat >> report.md << EOF + +
+ ℹ️ About this report + + This report compares Docker image sizes from this build against the \`$BASELINE_TAG\` baseline. + + - **Tag:** \`$TAG\` + - **Baseline:** \`$BASELINE_TAG\` + - **Timestamp:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + - **Historical data points:** $history_count + +
+ EOF + + echo "comment-triggered=$comment_triggered" >> $GITHUB_OUTPUT + echo "failed=$fail_triggered" >> $GITHUB_OUTPUT + + if [[ "$fail_triggered" == "true" ]]; then + echo "::error::Docker image size exceeded fail-thresholds (mb/percent). Check the report for details." + exit 1 + fi + + - name: Add report to Job Summary + if: always() + shell: bash + run: | + if [[ -f report.md ]]; then + cat report.md >> $GITHUB_STEP_SUMMARY + fi + + - name: Cleanup worktree + if: always() + shell: bash + run: | + if [[ -d /tmp/history-worktree ]]; then + git worktree remove /tmp/history-worktree --force 2>/dev/null || true + fi + +branding: + icon: 'package' + color: 'blue' diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml new file mode 100644 index 0000000..5347013 --- /dev/null +++ b/.github/actions/meteor-build/action.yml @@ -0,0 +1,218 @@ +name: 'Meteor Build' +description: 'Build Meteor bundle for production or coverage' + +inputs: + type: + required: false + description: 'production or coverage' + default: 'production' + reset-meteor: + required: false + description: 'Reset Meteor' + node-version: + required: true + description: 'Node version' + NPM_TOKEN: + required: false + description: 'NPM token' + deno-version: + required: true + description: 'Deno version' + source-hash: + required: true + description: 'RC cache id (e.g. packages-build-cache-key + meteor apps/meteor archive hash from CI)' + +runs: + using: composite + + steps: + - name: Cache build + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: cache-build + with: + path: /tmp/Rocket.Chat.tar.gz + key: ${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}-rc-build-${{ inputs.source-hash }} + + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + if: steps.cache-build.outputs.cache-hit != 'true' + with: + swap-size-gb: 4 + + - name: Merge dependencies for build + shell: bash + if: steps.cache-build.outputs.cache-hit != 'true' + run: | + # Merge dependencies and devDependencies into a new 'dependencies' field + cd apps/meteor + cp package.json package.json.bak + jq '.dependencies = (.dependencies + .devDependencies) | del(.devDependencies)' package.json > package.json.tmp && mv package.json.tmp package.json + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + if: steps.cache-build.outputs.cache-hit != 'true' + with: + node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} + cache-modules: true + install: true + type: 'production' + NPM_TOKEN: ${{ inputs.NPM_TOKEN }} + + # - name: Free disk space + # run: | + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - name: Cache vite + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: steps.cache-build.outputs.cache-hit != 'true' + with: + path: ./node_modules/.vite + key: vite-local-cache-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('package.json') }} + restore-keys: | + vite-local-cache-${{ runner.arch }}-${{ runner.os }}- + + - name: Cache meteor local + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: steps.cache-build.outputs.cache-hit != 'true' + with: + path: ./apps/meteor/.meteor/local + key: meteor-local-cache-${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}-${{ hashFiles('apps/meteor/.meteor/versions') }} + restore-keys: | + meteor-local-cache-${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}- + + - name: Cache meteor + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: steps.cache-build.outputs.cache-hit != 'true' + with: + path: ~/.meteor + key: meteor-cache-${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}-${{ hashFiles('apps/meteor/.meteor/release') }} + restore-keys: | + meteor-cache-${{ runner.arch }}-${{ runner.os }}-${{ inputs.type }}- + + - name: Install Meteor + shell: bash + if: steps.cache-build.outputs.cache-hit != 'true' + run: | + # Restore bin from cache + set +e + METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) + METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") + set -e + LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor + if [ -e $LAUNCHER ] + then + echo "Cached Meteor bin found, restoring it" + sudo cp "$LAUNCHER" "/usr/local/bin/meteor" + else + echo "No cached Meteor bin found." + fi + + # only install meteor if bin isn't found + command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + + - name: Versions + shell: bash + if: steps.cache-build.outputs.cache-hit != 'true' + run: | + npm --versions + yarn -v + node -v + meteor --version + meteor npm --versions + meteor node -v + git version + + - uses: rharkor/caching-for-turbo@00a0515f175df9fd2e15c4560144ad5fdbebb0c7 # v2.3.13 + + - name: Reset Meteor + shell: bash + if: ${{ steps.cache-build.outputs.cache-hit != 'true' && inputs.reset-meteor == 'true' }} + working-directory: ./apps/meteor + run: meteor reset + + - name: Restore packages build + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: packages-build + path: /tmp + + - name: Unpack packages build + shell: bash + run: | + tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . + + - name: Build Rocket.Chat + shell: bash + if: steps.cache-build.outputs.cache-hit != 'true' + env: + METEOR_PROFILE: 1000 + BABEL_ENV: ${{ inputs.type }} + run: | + # check if BABEL_ENV is set to coverage + if [[ $BABEL_ENV == "coverage" ]]; then + echo -e "rocketchat:coverage\n" >> ./apps/meteor/.meteor/packages + # Inject swc-plugin-coverage-instrument into .swcrc for istanbul-compatible coverage + node -e " + const fs = require('fs'); + const swcrc = JSON.parse(fs.readFileSync('./apps/meteor/.swcrc', 'utf8')); + swcrc.jsc.experimental = swcrc.jsc.experimental || {}; + swcrc.jsc.experimental.plugins = swcrc.jsc.experimental.plugins || []; + swcrc.jsc.experimental.plugins.push(['swc-plugin-coverage-instrument', {}]); + fs.writeFileSync('./apps/meteor/.swcrc', JSON.stringify(swcrc, null, 2) + '\n'); + " + echo "Coverage enabled (SWC istanbul plugin injected)" + fi + + # Restore original package.json so meteor should not copy devDependencies + mv apps/meteor/package.json.bak apps/meteor/package.json + + yarn build:ci + + declare -a meter_modules_to_remove=( + "meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc/core-darwin-arm64" # Removes 35M + "meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc/core-linux-x64-musl" # Removes 58M + "meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc/core-linux-arm64-musl" # Removes 44M + "meteor/babel-compiler/node_modules/typescript" # Removes 31M + # "meteor/babel-compiler/node_modules/@babel" # Removes 14M + + "@rocket.chat/i18n/src" # Removes 16M + "typescript" # Removes 19M + # "@babel" # Removes 34M - Needed by Minimongo + ) + + du -s /tmp/dist/bundle + + for dir_path in "${meter_modules_to_remove[@]}"; do + path=/tmp/dist/bundle/programs/server/npm/node_modules/${dir_path} + + if [ -d "$path" ]; then + rm -rf "$path" + + echo "Removed directory: $path" + else + echo "Path is not a directory or does not exist: $path" + fi + done + + # Remove all .d.ts files from node_modules to reduce size + # Removes 184M + find /tmp/dist/bundle -type f -name "*.d.ts" -delete + du -s /tmp/dist/bundle + + - name: Prepare build + shell: bash + if: steps.cache-build.outputs.cache-hit != 'true' + run: | + cd /tmp/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle + + - name: Store build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: build-${{ inputs.type }} + path: /tmp/Rocket.Chat.tar.gz + overwrite: true + include-hidden-files: true diff --git a/.github/actions/restore-packages/action.yml b/.github/actions/restore-packages/action.yml new file mode 100644 index 0000000..fa9508d --- /dev/null +++ b/.github/actions/restore-packages/action.yml @@ -0,0 +1,16 @@ +name: 'Restore Packages Build' +description: 'Downloads and unpacks the packages build artifact' + +runs: + using: 'composite' + steps: + - name: Restore packages build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: packages-build + path: /tmp + + - name: Unpack packages build + shell: bash + run: | + tar -xzf /tmp/RocketChat-packages-build.tar.gz -C . diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml new file mode 100644 index 0000000..c19d060 --- /dev/null +++ b/.github/actions/setup-node/action.yml @@ -0,0 +1,120 @@ +name: 'Setup Node' +description: 'Setup NodeJS' + +inputs: + node-version: + required: false + description: 'Node version. If not provided, read from package.json volta.node' + cache-modules: + required: false + description: 'Cache node_modules' + install: + required: false + description: 'Install dependencies' + type: + required: false + description: 'development or production' + default: 'development' + deno-version: + required: false + description: 'Deno version. If not provided, read from .tool-versions' + NPM_TOKEN: + required: false + description: 'NPM token' + HARDENED_MODE: + required: false + description: 'Hardened mode' + default: '0' + +outputs: + node-version: + description: 'Node version' + value: ${{ steps.node-version.outputs.node-version }} + cache-deno-hit: + description: 'Exact key hit for Deno cache' + value: ${{ steps.cache-deno.outputs.cache-hit }} + +runs: + using: composite + + steps: + - name: Resolve tool versions + shell: bash + run: | + NODE_VERSION="${{ inputs.node-version }}" + if [ -z "$NODE_VERSION" ]; then + NODE_VERSION=$(node -p "require('./package.json').volta.node") + fi + echo "NODE_VERSION: ${NODE_VERSION}" + echo "NODE_VERSION=${NODE_VERSION}" >> $GITHUB_ENV + + DENO_VERSION="${{ inputs.deno-version }}" + if [ -z "$DENO_VERSION" ]; then + DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions) + fi + echo "DENO_VERSION: ${DENO_VERSION}" + echo "DENO_VERSION=${DENO_VERSION}" >> $GITHUB_ENV + + # Turbo local cache: use rharkor/caching-for-turbo in workflows — not stored here to avoid huge redundant keys. + - name: Cache Deno (apps) + if: inputs.cache-modules + id: cache-deno + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: packages/apps/.deno-cache + key: deno-${{ runner.os }}-${{ runner.arch }}-v${{ env.DENO_VERSION }}-${{ hashFiles('packages/apps/deno-runtime/deno.lock') }} + restore-keys: | + deno-${{ runner.os }}-${{ runner.arch }}-v${{ env.DENO_VERSION }}- + + - name: Use Node.js ${{ env.NODE_VERSION }} + id: node-version + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: ${{ inputs.cache-modules && 'yarn' || '' }} + + - name: Use Deno ${{ env.DENO_VERSION }} + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Cache mongodb-memory-server binary + if: inputs.cache-modules + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ runner.temp }}/mongodb-memory-server + key: mongoms-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Configure mongodb-memory-server cache + shell: bash + run: | + CACHE_DIR="${{ runner.temp }}/mongodb-memory-server" + echo "MONGOMS_DOWNLOAD_DIR=$CACHE_DIR" >> $GITHUB_ENV + echo "MONGOMS_PREFER_GLOBAL_PATH=false" >> $GITHUB_ENV + + - name: yarn login + shell: bash + if: inputs.NPM_TOKEN + run: | + echo "//registry.npmjs.org/:_authToken=${{ inputs.NPM_TOKEN }}" > ~/.npmrc + + - name: yarn config + if: inputs.install + shell: bash + run: | + yarn config set supportedArchitectures --json '{"os": ["linux"], "cpu": ["arm64", "x64"], "libc": ["glibc", "musl"]}' + + - name: yarn install + if: inputs.install && inputs.type == 'development' + shell: bash + run: YARN_ENABLE_HARDENED_MODE=${{ inputs.HARDENED_MODE }} yarn + + - name: yarn install production + if: inputs.install && inputs.type == 'production' + shell: bash + run: YARN_ENABLE_HARDENED_MODE=${{ inputs.HARDENED_MODE }} yarn workspaces focus --all --production + + - name: restore yarn config file + if: inputs.install + shell: bash + run: git checkout -- .yarnrc.yml diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 0000000..f4fc638 --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,21 @@ +name: Setup Playwright +description: 'Setup Playwright binaries and dependencies' + +runs: + using: 'composite' + + steps: + - name: Cache Playwright binaries + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: cache-playwright + with: + path: | + ~/.cache/ms-playwright + # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. + key: playwright-${{ runner.arch }}-${{ runner.os }}-1.52.0 + + - name: Install Playwright + shell: bash + if: steps.cache-playwright.outputs.cache-hit != 'true' + working-directory: . + run: npx playwright install --with-deps diff --git a/.github/actions/update-version-durability/action.yml b/.github/actions/update-version-durability/action.yml new file mode 100644 index 0000000..85e4ec2 --- /dev/null +++ b/.github/actions/update-version-durability/action.yml @@ -0,0 +1,26 @@ +name: Update Version Durability +description: Update Version Durability page on Document360 + +inputs: + LTS_VERSIONS: + required: true + description: Comma separated list of LTS versions + GH_TOKEN: + required: true + description: GitHub API Token + D360_TOKEN: + required: true + description: Document360 API Token + D360_ARTICLE_ID: + required: true + description: Document360 Article ID + D360_USER_ID: + required: true + description: Document360 User ID used to fork and publish the article + PUBLISH: + required: true + description: Publish Draft + +runs: + using: node20 + main: index.js diff --git a/.github/actions/update-version-durability/index.js b/.github/actions/update-version-durability/index.js new file mode 100644 index 0000000..062f13e --- /dev/null +++ b/.github/actions/update-version-durability/index.js @@ -0,0 +1,228 @@ +import 'colors'; +import axios from 'axios'; +import * as Diff from 'diff'; +import semver from 'semver'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import BeautyHtml from 'beauty-html'; +import { DOMParser } from '@xmldom/xmldom'; +import core from '@actions/core'; +import { Octokit } from '@octokit/rest'; + +const D360_TOKEN = core.getInput('D360_TOKEN'); +const D360_ARTICLE_ID = core.getInput('D360_ARTICLE_ID'); +const D360_USER_ID = core.getInput('D360_USER_ID'); +const PUBLISH = core.getInput('PUBLISH') === 'true'; +const LTS_VERSIONS = (core.getInput('LTS_VERSIONS') || '7.10').split(',').map((v) => v.trim()); + +const octokit = new Octokit({ + auth: core.getInput('GH_TOKEN'), +}); + + +async function requestDocument360(method = 'get', api, data = {}) { + return axios.request({ + method, + maxBodyLength: Infinity, + url: `https://apihub.us.document360.io/v1/${api}`, + headers: { + 'accept': 'application/json', + 'api_token': D360_TOKEN, + }, + data, + }); +} + +function md5(text) { + return crypto.createHash('md5').update(text).digest("hex"); +} + +async function generateTable({ owner, repo } = {}) { + const response = await requestDocument360('get', `Articles/${D360_ARTICLE_ID}/en`); + + // console.log(response.data.data); + + // const releasesResult = JSON.parse(await fs.readFile('/tmp/releasesResult')); + const releasesResult = await octokit.paginate(octokit.repos.listReleases.endpoint.merge({ owner, repo, per_page: 100 })); + // await fs.writeFile('/tmp/releasesResult', JSON.stringify(releasesResult)); + + const releases = releasesResult + .filter((release) => !release.tag_name.includes('-rc') && semver.gte(release.tag_name, '1.0.0')) + .sort((a, b) => semver.compare(b.tag_name, a.tag_name)); + + const releasesMap = {}; + + for (const release of releases) { + release.releaseDate = new Date(release.published_at); + + releasesMap[release.tag_name] = release; + } + + let index = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const release = releases[index]; + + release.minor_tag = release.tag_name.replace(/\.\d+$/, ''); + release.minorRelease = releasesMap[`${release.minor_tag}.0`]; + + if (!releases[index + 1]) { + break; + } + + const currentVersion = semver.parse(release.tag_name); + const previousVersion = semver.parse(releases[index + 1].tag_name); + + releases[index + 1].nextRelease = release; + + // Remove duplicated due to patches + if (currentVersion.major === previousVersion.major && currentVersion.minor === previousVersion.minor) { + releases.splice(index + 1, 1); + continue; + } + + // Mark LTS + if (LTS_VERSIONS.includes(`${currentVersion.major}.${currentVersion.minor}`)) { + release.lts = true; + } + + index++; + } + + releases[0].last = true; + + const releaseData = []; + + for (const { tag_name, html_url, lts, last, nextRelease, minorRelease, minor_tag} of releases) { + let supportDate; + let supportDateStart; + + let releasedAt = new Date(minorRelease.releaseDate); + releasedAt.setDate(1); + + let minorDate = new Date(minorRelease.releaseDate); + minorDate.setDate(1); + supportDateStart = minorDate; + supportDate = new Date(minorDate); + supportDate.setMonth(supportDate.getMonth() + (lts ? 12 : 6)); + + releaseData.push({ + release: { + version: minor_tag, + releasedAt, + extendedSupport: { + start: supportDateStart, + end: supportDate, + }, + lts: lts === true, + }, + latestPatch: { + version: tag_name, + url: html_url, + } + }) + } + + function header({data, salt = ''}) { + return [ + '', + `

${data}

`, + '', + ].join(''); + } + + function line({data, salt = ''}) { + return [ + '', + `

${data}

`, + '', + ].join(''); + } + + const text = [ + '', + header({data: 'Rocket.Chat Release'}), + header({data: 'Released At'}), + header({data: 'End of Life'}), + '', + ]; + + releaseData.forEach(({release, latestPatch}) => { + const releasedAt = release.releasedAt.toLocaleString('en', { month: 'short', year: "numeric" }); + const endOfLife = !release.extendedSupport + ? 'TBD' + : release.extendedSupport.end.toLocaleString('en', { month: 'short', year: "numeric" }); + const link = `${release.version}${release.lts ? " LTS" : ""} (${latestPatch.version})`; + + text.push( + '', + line({data: link}), + line({data: releasedAt, salt: release.version}), + line({data: endOfLife, salt: release.version}), + '', + ); + }); + + const content = response.data.data.html_content.replace(/.+(\n.+)*<\/tbody>/m, `${text.join('').replace(/\t|\n/g, '')}`) + + // console.log(content); + + const parser = new BeautyHtml({ parser: DOMParser }); + const diff = Diff.diffLines(parser.beautify(response.data.data.html_content), parser.beautify(content), { ignoreWhitespace: true, newlineIsToken: false }); + diff.forEach((item) => { + let color = 'green'; + + if (item.removed) { + color = 'red'; + } + + if (item.removed || item.added) { + item.value.split('\n').forEach((line) => { + if (line === '') { return }; + console.log(`${item.removed ? '-' : '+'} ${line}`[color]); + }) + } + }); + + if (diff.length === 1) { + console.log('No changes found'); + return; + } + + if (response.data.data.status === 3) { + console.log('forking article', response.data.data.version_number); + + const forkResponse = await requestDocument360('put', `Articles/${D360_ARTICLE_ID}/fork`, { + lang_code: "en", + user_id: D360_USER_ID, + version_number: response.data.data.version_number, + }); + + console.log(forkResponse.data); + + if (!forkResponse.data.success) { + throw new Error(`Failed to fork article: ${JSON.stringify(forkResponse.data.errors)}`); + } + } + + console.log('Updating article'); + const updateResponse = await requestDocument360('put', `Articles/${D360_ARTICLE_ID}/en`, { + content, + }); + + console.log(updateResponse.data); + + if (PUBLISH) { + console.log('publishing article', updateResponse.data.data.version_number); + + const forkResponse = await requestDocument360('post', `Articles/${D360_ARTICLE_ID}/en/publish`, { + user_id: D360_USER_ID, + version_number: updateResponse.data.data.version_number, + publish_message: 'Update support versions table via GitHub Action', + }); + + console.log(forkResponse.data); + } +} + +generateTable({ owner: 'RocketChat', repo: 'Rocket.Chat' }); diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json new file mode 100644 index 0000000..9dfd152 --- /dev/null +++ b/.github/actions/update-version-durability/package-lock.json @@ -0,0 +1,717 @@ +{ + "name": "scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scripts", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@actions/core": "^1.10.1", + "@octokit/rest": "^21.0.2", + "@xmldom/xmldom": "^0.8.15", + "axios": "^1.18.1", + "beauty-html": "^1.3.1", + "colors": "^1.4.0", + "diff": "^5.1.0", + "semver": "^7.5.4" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.1.tgz", + "integrity": "sha512-KhC/cZsq7f8I4LfZSJKgCvEwfkE8o1538VoBeoGzokVLLnbFDEAdFD3UhoMklxo2un9NJVBdANOresx7vTHlHw==", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "license": "MIT" + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", + "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", + "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.2.tgz", + "integrity": "sha512-BXJ7XPCTDXFF+wxcg/zscfgw2O/iDPtNSkwwR1W1W5c4Mb3zav/M2XvxQ23nVmKj7jpweB4g8viMeCQdm7LMVA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/request/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", + "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^23.0.1" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/beauty-html": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/beauty-html/-/beauty-html-1.3.1.tgz", + "integrity": "sha512-c0iKWc527T2MQcYhIMMw9OHN8kcXSf/ijadWzURhZWi6e6cnBXxAQ5IlXbYd0YZJE9lFtXRB1fJVQrvJf5DmPQ==" + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/.github/actions/update-version-durability/package.json b/.github/actions/update-version-durability/package.json new file mode 100644 index 0000000..f7bbf41 --- /dev/null +++ b/.github/actions/update-version-durability/package.json @@ -0,0 +1,24 @@ +{ + "name": "scripts", + "version": "1.0.0", + "type": "module", + "description": "", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@actions/core": "^1.10.1", + "@octokit/rest": "^21.0.2", + "axios": "^1.18.1", + "beauty-html": "^1.3.1", + "colors": "^1.4.0", + "diff": "^5.1.0", + "semver": "^7.5.4", + "@xmldom/xmldom": "^0.8.15" + }, + "overrides": { + "undici": "6.28.0" + } +} diff --git a/.github/agents/bug-resolution-agent.md b/.github/agents/bug-resolution-agent.md new file mode 100644 index 0000000..45af5ac --- /dev/null +++ b/.github/agents/bug-resolution-agent.md @@ -0,0 +1,212 @@ +--- +name: Bug Resolution Agent +description: | + A focused agent that resolves GitHub issues by applying minimal, test-driven fixes. + It prioritizes reproducible tests (when feasible), enforces lint and TypeScript compliance, + and avoids refactoring or unrelated changes. +--- + +# Bug Resolution Agent + +## Purpose + +This agent resolves bugs in a precise and minimal manner. +Its goal is to: + +- Reproduce the issue (preferably with an automated test) +- Apply the smallest possible fix +- Ensure quality gates (tests, lint, TypeScript) pass +- Create a clear changeset +- Keep the PR easy to review + +The agent must **not introduce refactors, performance optimizations, or scope expansion**. + +--- + +## Operating Principles + +1. **Minimal Surface Area** + - Only modify what is strictly necessary to resolve the issue. + - Do not refactor unrelated code. + - Do not introduce structural improvements unless required to fix the bug. + +2. **Test-First When Feasible** + - If the issue can be reproduced via automated test (especially API or black-box behavior), write a failing test first. + - The test must fail before the fix and pass after the fix. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Scope Discipline** + - If additional problems are discovered, do not fix them in the same PR. + - Document them as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover problems outside the current scope during your work, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [high-priority] Race condition in message delivery +// Problem: When multiple messages are sent rapidly, the order is not guaranteed +// due to async handling without proper sequencing. +// Location: apps/meteor/server/services/messages/sendMessage.ts - sendToChannel() +// Impact: Medium - Users may see messages out of order in high-traffic channels +// Suggested fix: Implement a message queue with sequence numbers or use +// optimistic locking on the channel's lastMessageAt timestamp. +// Discovered while: Fixing #12345 - Message duplication bug +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this work and should be addressed in separate PRs: + +- `bug` [high-priority]: **Race condition in message delivery** - See TODO in `sendMessage.ts:142` +- `test`: **Missing input validation tests** - See TODO in `userController.ts:87` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze the Issue + +- Carefully read the issue description. +- Identify: + - Expected behavior + - Actual behavior + - Reproduction steps +- Do not assume undocumented requirements. + +--- + +### 2. Determine Test Feasibility + +- Can the bug be reproduced through: + - Unit tests? + - Integration tests? + - API black-box tests? + +If **yes** → proceed to Step 3. +If **no** → proceed directly to Step 4. + +--- + +### 3. Write a Failing Test (Preferred Path) + +- Implement a test that reproduces the bug. +- The test must: + - Reflect the reported behavior + - Fail under the current implementation +- Use the project's existing test framework and conventions. +- Avoid introducing new testing patterns unless strictly required. + +--- + +### 4. Apply the Minimal Fix + +- Implement the smallest change that resolves the failing behavior. +- Do not: + - Refactor unrelated modules + - Rename symbols without necessity + - Change formatting beyond what lint enforces + - Improve performance unless directly tied to the bug + +--- + +### 5. Validate the Fix + +Ensure: + +- The newly created test passes. +- All existing tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. + +If any of these fail, adjust only what is required to restore compliance. + +--- + +### 6. Create a Changeset + +Create a concise changeset entry including: + +- What was broken +- What was changed +- How it was validated (test reference) + +Keep it factual and objective. + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly reference the original issue. +- Highlight the test added (if applicable). +- Describe the minimal fix applied. +- Avoid mentioning improvements outside the issue scope. + +--- + +## Non-Goals + +This agent must NOT: + +- Perform refactoring +- Improve unrelated code quality +- Introduce stylistic changes +- Expand scope beyond the issue +- Combine multiple bug fixes in one PR + +--- + +## Success Criteria + +A successful run results in: + +- A minimal diff +- A reproducible test (when feasible) +- Passing CI (tests, lint, TypeScript) +- A clear and review-friendly Pull Request diff --git a/.github/agents/feature-development-agent.md b/.github/agents/feature-development-agent.md new file mode 100644 index 0000000..f7df896 --- /dev/null +++ b/.github/agents/feature-development-agent.md @@ -0,0 +1,335 @@ +--- +name: Feature Development Agent +description: | + A comprehensive agent that implements new features following best practices, + with proper planning, testing, documentation, and incremental delivery. + It ensures features are well-designed, tested, and maintainable. +--- + +# Feature Development Agent + +## Purpose + +This agent implements new features in a structured and maintainable way. +Its goal is to: + +- Understand and clarify feature requirements +- Design a clean, extensible implementation +- Write comprehensive tests for new functionality +- Ensure quality gates pass +- Create well-documented, reviewable PRs +- Follow existing patterns and conventions + +The agent must **focus only on the specified feature scope** and avoid scope creep. + +--- + +## Operating Principles + +1. **Requirements First** + - Fully understand the feature before writing code. + - Clarify ambiguities before implementation. + - Define acceptance criteria upfront. + +2. **Design Before Code** + - Plan the architecture and approach. + - Consider edge cases and error handling. + - Identify integration points with existing code. + +3. **Test-Driven Development** + - Write tests that define expected behavior. + - Tests should cover happy paths and edge cases. + - Tests serve as living documentation. + +4. **Quality Gates Are Mandatory** + - All tests (new and existing) must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +5. **Incremental Delivery** + - Break features into deliverable increments. + - Each increment should be functional and valuable. + - Prefer feature flags for large features. + +6. **Consistency** + - Follow existing code patterns and conventions. + - Maintain consistency with the codebase style. + - Reuse existing utilities and components. + +7. **Scope Discipline** + - Focus only on the specified feature requirements. + - Do not fix unrelated bugs discovered during implementation. + - Do not refactor existing code beyond what's needed for the feature. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current feature scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: bug [security] Missing rate limiting on user search endpoint +// Problem: The /api/v1/users.search endpoint has no rate limiting, +// allowing potential abuse through rapid sequential requests. +// Location: apps/meteor/app/api/server/v1/users.ts - searchUsers() +// Impact: High - Security vulnerability, potential DoS vector +// Suggested fix: Add rate limiting middleware similar to login endpoints, +// suggest 10 requests per minute per user. +// Discovered while: Implementing user mention autocomplete feature #54321 +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this feature implementation and should be addressed in separate PRs: + +- `bug` [security]: **Missing rate limiting on user search** - See TODO in `users.ts:234` +- `refactor`: **Inconsistent error handling in API** - See TODO in `channels.ts:156` +- `chore`: **Outdated TypeScript types** - See TODO in `types/user.d.ts:12` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Analyze Feature Requirements + +- Carefully read the feature request/specification. +- Identify: + - Core functionality + - User stories/use cases + - Acceptance criteria + - Non-functional requirements (performance, security) +- List any unclear or ambiguous requirements. +- Do not assume undocumented behavior. + +--- + +### 2. Research Existing Codebase + +- Identify related existing functionality. +- Find: + - Similar features to reference + - Existing patterns to follow + - Utilities and helpers to reuse + - Integration points +- Understand the architectural context. + +--- + +### 3. Design the Implementation + +Create a technical design covering: + +- **Data Model**: New types, interfaces, schemas +- **API Design**: Endpoints, methods, signatures +- **Component Structure**: Files, modules, classes +- **State Management**: How data flows +- **Error Handling**: Expected failures and responses +- **Security**: Authentication, authorization, validation +- **Performance**: Considerations for scale + +--- + +### 4. Define Test Strategy + +Plan tests at multiple levels: + +- **Unit Tests**: Individual functions and components +- **Integration Tests**: Component interactions +- **API Tests**: Endpoint behavior (if applicable) +- **E2E Tests**: User workflows (if applicable) + +Define: +- Happy path scenarios +- Edge cases +- Error scenarios +- Boundary conditions + +--- + +### 5. Implement Incrementally + +For each implementation increment: + +#### 5.1 Write Tests First +- Define expected behavior through tests. +- Tests should initially fail (TDD red phase). + +#### 5.2 Implement the Code +- Write the minimum code to pass tests. +- Follow existing patterns and conventions. +- Add proper TypeScript types. +- Include error handling. + +#### 5.3 Refactor if Needed +- Clean up the implementation. +- Ensure code quality standards are met. +- Keep tests passing. + +#### 5.4 Verify Quality Gates +- Run all tests. +- Run lint. +- Run TypeScript compilation. +- Build the project. + +--- + +### 6. Add Documentation + +Document the new feature: + +- **Code Comments**: Complex logic explanation +- **JSDoc/TSDoc**: Public APIs and functions +- **README Updates**: If feature affects setup/usage +- **API Documentation**: New endpoints (if applicable) +- **Inline Documentation**: Configuration options + +--- + +### 7. Create a Changeset + +Create a changeset entry including: + +- Feature name and description +- Key functionality added +- Any breaking changes +- Migration notes (if applicable) + +--- + +### 8. Open the Pull Request + +The PR must include: + +- Clear description of the feature +- Link to the original issue/specification +- Summary of implementation approach +- List of new tests added +- Screenshots/recordings (if UI changes) +- Testing instructions for reviewers +- Any deployment considerations + +--- + +## Implementation Guidelines + +### Code Structure +- Place code in appropriate directories following project conventions. +- Create new files for distinct functionality. +- Keep files focused and reasonably sized. + +### Type Safety +- Define explicit types for all new code. +- Avoid `any` types. +- Use generics where appropriate. +- Export types that consumers need. + +### Error Handling +- Handle all expected error cases. +- Provide meaningful error messages. +- Use appropriate error types/codes. +- Log errors appropriately. + +### Security +- Validate all inputs. +- Sanitize outputs where needed. +- Follow authentication/authorization patterns. +- Never expose sensitive data. + +### Performance +- Consider performance implications. +- Avoid unnecessary computations. +- Use appropriate data structures. +- Add caching where beneficial. + +--- + +## Feature Categories + +### API Features +- Define clear endpoint contracts. +- Follow REST/GraphQL conventions. +- Include proper validation. +- Document request/response schemas. + +### UI Features +- Follow design system patterns. +- Ensure accessibility (a11y). +- Support internationalization (i18n). +- Handle loading and error states. + +### Backend Features +- Design for scalability. +- Consider data migration needs. +- Handle edge cases gracefully. +- Add appropriate logging. + +### Integration Features +- Define clear interfaces. +- Handle external service failures. +- Add retry logic where appropriate. +- Document integration requirements. + +--- + +## Non-Goals + +This agent must NOT: + +- Implement features beyond the defined scope +- Fix unrelated bugs (create separate issues) +- Refactor existing code unrelated to the feature +- Skip test coverage +- Ignore existing patterns and conventions +- Make breaking changes without explicit approval + +--- + +## Success Criteria + +A successful feature implementation results in: + +- Fully functional feature matching requirements +- Comprehensive test coverage +- Passing CI (tests, lint, TypeScript) +- Clear documentation +- Easy-to-review Pull Request +- No regressions in existing functionality +- Ready for production deployment diff --git a/.github/agents/refactor-agent.md b/.github/agents/refactor-agent.md new file mode 100644 index 0000000..9e855f3 --- /dev/null +++ b/.github/agents/refactor-agent.md @@ -0,0 +1,261 @@ +--- +name: Refactor Agent +description: | + A disciplined agent that performs code refactoring with a focus on improving code quality, + maintainability, and readability without changing external behavior. It ensures all tests + pass before and after changes, and creates incremental, reviewable PRs. +--- + +# Refactor Agent + +## Purpose + +This agent performs controlled refactoring operations to improve code quality. +Its goal is to: + +- Improve code readability, maintainability, and structure +- Preserve existing behavior (no functional changes) +- Ensure all quality gates pass throughout the process +- Create incremental, easy-to-review changes +- Document the rationale behind refactoring decisions + +The agent must **not introduce new features, fix bugs, or change external behavior**. + +--- + +## Operating Principles + +1. **Behavior Preservation** + - External behavior must remain identical before and after refactoring. + - All existing tests must continue to pass. + - If tests fail, the refactoring approach must be reconsidered. + +2. **Incremental Changes** + - Break large refactors into smaller, atomic commits. + - Each commit should be independently valid and reviewable. + - Prefer multiple small PRs over one large PR when appropriate. + +3. **Quality Gates Are Mandatory** + - All existing tests must pass. + - Lint must pass with no new warnings or errors. + - TypeScript type checking must pass without errors. + - Build must succeed. + +4. **Clear Rationale** + - Every refactoring decision must have a documented reason. + - Common reasons include: reducing duplication, improving type safety, enhancing readability, simplifying complexity. + +5. **Scope Discipline** + - Stay focused on the refactoring goal. + - Do not fix unrelated bugs discovered during refactoring. + - Do not add new features or optimizations. + - Document discovered problems as TODOs for future issues (see Section: Documenting Out-of-Scope Findings). + +--- + +## Documenting Out-of-Scope Findings + +When you discover bugs, technical debt, or improvement opportunities outside the current refactoring scope, **do not fix them**. Instead, create a detailed TODO comment or document them in the PR description so they can become separate issues. + +### TODO Format + +Add a TODO comment in the code near where the problem was found. + +**Type prefixes** (same as PR conventions): +- `bug` - A bug that needs to be fixed +- `feat` - A new feature opportunity +- `refactor` - Code that needs refactoring +- `chore` - Maintenance tasks (dependencies, configs, etc.) +- `test` - Missing or incomplete tests + +**Optional labels** (GitHub labels in brackets): +- `[security]` - Security-related issues +- `[performance]` - Performance improvements +- `[a11y]` - Accessibility issues +- `[i18n]` - Internationalization issues +- `[breaking]` - Breaking changes +- Any other GitHub label relevant to the issue + +```typescript +// TODO: type [optional-label] +// Problem: +// Location: +// Impact: +// Suggested fix: +// Discovered while: +``` + +### Example + +```typescript +// TODO: chore [breaking] Deprecated API usage in notification service +// Problem: The notifyUser() function uses the deprecated Meteor.defer() API +// which will be removed in the next major version. +// Location: apps/meteor/server/services/notifications/notifyUser.ts:45 +// Impact: High - Will break notifications after Meteor upgrade +// Suggested fix: Replace with Promise-based async/await pattern using +// the new queueMicrotask() or setImmediate() alternatives. +// Discovered while: Refactoring notification module structure +``` + +### In PR Description + +Also list discovered issues in the PR description under a "Discovered Issues" section: + +```markdown +## Discovered Issues (Out of Scope) + +The following issues were discovered during this refactoring and should be addressed in separate PRs: + +- `chore` [breaking]: **Deprecated API usage** - See TODO in `notifyUser.ts:45` +- `bug`: **Missing error boundary** - See TODO in `MessageList.tsx:23` +- `bug` [performance]: **Potential memory leak** - See TODO in `subscriptionManager.ts:112` +``` + +--- + +## Step-by-Step Execution Flow + +### 1. Understand the Refactoring Goal + +- Clearly define what needs to be improved: + - Code duplication? + - Complex conditionals? + - Poor naming? + - Missing type safety? + - Tight coupling? + - Large files/functions? +- Identify the scope and boundaries of the refactoring. + +--- + +### 2. Analyze Current State + +- Review the existing code structure. +- Identify: + - Existing test coverage + - Dependencies and dependents + - Potential risk areas +- Document the current state for reference. + +--- + +### 3. Ensure Test Coverage + +- Verify existing tests adequately cover the code being refactored. +- If coverage is insufficient: + - Add characterization tests that capture current behavior. + - These tests act as a safety net during refactoring. +- Tests must pass before any refactoring begins. + +--- + +### 4. Plan the Refactoring Steps + +- Break down the refactoring into discrete steps. +- Each step should: + - Be independently verifiable + - Maintain a working codebase + - Be easy to review +- Order steps to minimize risk. + +--- + +### 5. Execute Refactoring Incrementally + +For each step: + +1. Make the targeted change. +2. Run tests to verify behavior preservation. +3. Run lint and type checking. +4. Commit with a clear message explaining the change. + +Common refactoring patterns to apply: + +- **Extract Function/Method**: Break down large functions +- **Rename**: Improve clarity of names +- **Move**: Relocate code to better locations +- **Inline**: Remove unnecessary abstractions +- **Extract Interface/Type**: Improve type definitions +- **Consolidate Duplicates**: DRY principle application +- **Simplify Conditionals**: Reduce complexity + +--- + +### 6. Validate Final State + +After all refactoring steps: + +- All tests pass. +- Lint passes. +- TypeScript compilation passes. +- Build succeeds. +- Code review checklist: + - Is the code more readable? + - Is the code more maintainable? + - Is the structure improved? + - Are there any regressions? + +--- + +### 7. Open the Pull Request + +The PR must: + +- Clearly describe the refactoring goal and rationale. +- List the refactoring patterns applied. +- Confirm that no behavioral changes were introduced. +- Reference any related issues or technical debt items. +- Include before/after examples if helpful. + +--- + +## Refactoring Categories + +### Structural Refactoring +- File/folder reorganization +- Module extraction +- Dependency restructuring + +### Code Quality Refactoring +- Naming improvements +- Function decomposition +- Duplication removal +- Complexity reduction + +### Type Safety Refactoring +- Adding explicit types +- Removing `any` types +- Improving generic usage +- Adding type guards + +### Pattern Application +- Applying design patterns +- Removing anti-patterns +- Standardizing approaches + +--- + +## Non-Goals + +This agent must NOT: + +- Change external behavior +- Fix bugs (create separate issues) +- Add new features +- Optimize performance (unless part of explicit refactoring goal) +- Make changes outside the defined scope +- Skip quality gate verification + +--- + +## Success Criteria + +A successful refactoring results in: + +- Improved code quality metrics (readability, maintainability) +- All tests passing (no behavioral changes) +- Passing CI (tests, lint, TypeScript) +- Clear documentation of changes +- Easy-to-review Pull Request +- No new bugs introduced diff --git a/.github/bot-config.yml b/.github/bot-config.yml new file mode 100644 index 0000000..65ab966 --- /dev/null +++ b/.github/bot-config.yml @@ -0,0 +1,21 @@ +bot: + name: "@rocket-cat" +whitelist: + users: + - wreiske + - TwizzyDizzy + - theorenck + - JSzaszvari + - reetp + labels: + - "Contributions: welcome" + - "Contributions: only core team" + - "Feature: Request" + - "Feature: Planned" + - "type: bug" + - "help wanted" + - duplicate + - enhancement + - invalid + - question + - wontfix diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cb8b695 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Main repo NPM package + - package-ecosystem: npm + directory: '/' + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - 'automerge' + + ## EE dependencies + - package-ecosystem: npm + directory: '/ee/server/services' + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - 'automerge' + + # Maintain dependencies for GitHub Actions + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'daily' + groups: + # CodeQL steps must all run the same version, so they have to be bumped together + codeql-action: + patterns: + - 'github/codeql-action*' diff --git a/.github/history-manual.json b/.github/history-manual.json new file mode 100644 index 0000000..3089a4b --- /dev/null +++ b/.github/history-manual.json @@ -0,0 +1,221 @@ +{ + "0.55.0": [{ + "title": "[BREAK] `getUsersOfRoom` API to return array of objects with user and username, instead of array of strings", + "userLogin": "rodrigok", + "contributors": [] + }, { + "title": "[FIX] Incoming integrations would break when trying to use the `Store` feature.`", + "userLogin": "rodrigok", + "contributors": [] + }, { + "title": "[FIX] Removed Deprecated Package rocketchat:sharedsecret`", + "userLogin": "rodrigok", + "contributors": [] + }], + "0.72.0-rc.0": [{ + "title": "[BREAK] Support for Cordova (Rocket.Chat Legacy app) has reached End-of-life, support has been discontinued", + "userLogin": "sampaiodiego", + "contributors": [] + }], + "3.0.0-rc.9": [{ + "title": "[BREAK] TLS v1.0 and TLS v1.1 were disabled by due to NodeJS update to v12. You can still enable them by using flags like `--tls-min-v1.0` and `--tls-min-v1.1`", + "userLogin": "sampaiodiego", + "contributors": [] + }], + "3.6.0-rc.0": [{ + "pr": "18642", + "title": "[NEW] Admin option to reset other users’ E2E encryption key", + "userLogin": "rodrigok", + "contributors": [], + "description": "Requires the 2FA password fallback enforcement enabled to work" + }], + "3.7.3": [{ + "title": "[FIX] Exception on certain login cases including SAML", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "3.8.3": [{ + "title": "[FIX] Exception on certain login cases including SAML", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "3.9.1": [{ + "title": "[FIX] Exception on certain login cases including SAML", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "3.10.5": [{ + "title": "[FIX] Security Hotfix", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "3.12.0-rc.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "3.12.2": [{ + "title": "[FIX] Bump Livechat widget", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }, { + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "g-thome", + "KevLehman", + "matheusbsilva137" + ] + }], + "3.13.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "KevLehman", + "renatobecker" + ] + }], + "3.14.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "KevLehman", + "g-thome" + ] + }], + "3.14.4": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }], + "3.16.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "g-thome", + "ggazzo", + "sampaiodiego", + "matheusbsilva137", + "pierre-lehnen-rc" + ] + }], + "3.18.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "pierre-lehnen-rc" + ] + }], + "3.18.6": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "3.18.7": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "4.1.1": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "4.3.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego", + "yash-rajpal", + "pierre-lehnen-rc", + "gronke" + ] + }], + "4.3.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "gronke" + ] + }], + "4.4.4": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "4.4.5": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "4.7.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "4.7.4": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "d-gubert", + "contributors": [ + "ggazzo" + ] + }], + "4.8.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "sampaiodiego", + "contributors": [ + "sampaiodiego" + ] + }], + "5.1.2": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }], + "5.3.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }], + "5.4.3": [{ + "title": "[FIX] Security Hotfix (https://docs.rocket.chat/guides/security/security-updates)", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + }] +} diff --git a/.github/issue-close-app.yml b/.github/issue-close-app.yml new file mode 100644 index 0000000..2a0b464 --- /dev/null +++ b/.github/issue-close-app.yml @@ -0,0 +1,29 @@ +# Config for Close Issue app: https://probot.github.io/apps/close-issue/ + +# Comment that will be sent if an issue is judged to be closed +comment: | + This issue was closed because it does not use our bug report issue template. + + Please make sure to use it and fill it as much as you can so we can provide better and faster support. + + The following sections **must not** be removed, or else the BOT will close it immediately again: + + * Steps to reproduce + * Expected behavior + * Actual behavior + * Server Setup Information + * Version of Rocket.Chat Server +issueConfigs: +# There can be several configs for different kind of issues. +- content: +# Bug report + - "Steps to reproduce" + - "Expected behavior" + - "Actual behavior" + - "Server Setup Information" + - "Version of Rocket.Chat Server" +- content: +# Release issue + - "Before Release - Preparation" +# The issue is considered to be valid if it includes all keywords from any of these two configs. +# Or it will be closed by the app. diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml new file mode 100644 index 0000000..98be431 --- /dev/null +++ b/.github/workflows/auto-close-duplicates.yml @@ -0,0 +1,33 @@ +name: Auto-close duplicate issues + +on: + schedule: + - cron: '0 9 * * *' + workflow_dispatch: + +permissions: {} + +jobs: + auto-close-duplicates: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Auto-close duplicate issues + run: bun run scripts/auto-close-duplicates.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml new file mode 100644 index 0000000..e6bc0bc --- /dev/null +++ b/.github/workflows/ci-code-check.yml @@ -0,0 +1,128 @@ +name: Code Checks + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + deno-version: + required: true + type: string + +env: + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + +permissions: {} + +jobs: + code-check: + runs-on: ubuntu-24.04-arm + permissions: + contents: read + + name: ${{ matrix.check == 'ts' && 'TypeScript' || 'Code Lint' }} + + strategy: + fail-fast: false + matrix: + check: ['ts', 'lint'] + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0.0 + with: + swap-size-gb: 4 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - uses: ./.github/actions/restore-packages + + - name: Restore TypeScript incremental cache + id: restore-typecheck + if: matrix.check == 'ts' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo + key: typecheck-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + typecheck-cache-${{ runner.os }}- + + - name: Cache observability (typecheck) + if: matrix.check == 'ts' + run: | + { + echo "### TypeScript incremental cache" + echo "- **exact hit**: \`${{ steps.restore-typecheck.outputs.cache-hit }}\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Install Meteor + shell: bash + run: | + # Restore bin from cache + set +e + METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) + METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") + set -e + LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor + if [ -e "$LAUNCHER" ] + then + echo "Cached Meteor bin found, restoring it" + sudo cp "$LAUNCHER" "/usr/local/bin/meteor" + else + echo "No cached Meteor bin found." + fi + + # only install meteor if bin isn't found + command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + + - name: TS TypeCheck + if: matrix.check == 'ts' + run: yarn turbo run typecheck --concurrency=5 + + - name: Save TypeScript incremental cache + if: matrix.check == 'ts' && github.ref == 'refs/heads/develop' && github.event_name == 'push' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./apps/meteor/tsconfig.typecheck.tsbuildinfo + key: typecheck-cache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Restore ESLint cache + id: restore-eslint + if: matrix.check == 'lint' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./apps/meteor/.eslintcache + key: eslintcache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + eslintcache-${{ runner.os }}- + + - name: Cache observability (eslint) + if: matrix.check == 'lint' + run: | + { + echo "### ESLint cache" + echo "- **exact hit**: \`${{ steps.restore-eslint.outputs.cache-hit }}\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Lint + if: matrix.check == 'lint' + run: yarn lint + + - name: Save ESLint cache + if: matrix.check == 'lint' && github.ref == 'refs/heads/develop' && github.event_name == 'push' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ./apps/meteor/.eslintcache + key: eslintcache-${{ runner.os }}-${{ hashFiles('yarn.lock') }} diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml new file mode 100644 index 0000000..1d243ea --- /dev/null +++ b/.github/workflows/ci-deploy-gh-pages.yml @@ -0,0 +1,42 @@ +# .github/workflows/ci-preview-deploy.yml +name: Deploy GitHub Pages +concurrency: preview-deploy-${{ github.ref }} +on: + push: + branches: + - main + - master + - develop + +permissions: {} + +jobs: + deploy-preview: + runs-on: ubuntu-24.04-arm + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + cache-modules: true + install: true + + - name: Build + run: | + yarn turbo run build-preview + yarn turbo run .:build-preview-move + npx indexifier .preview --html --extensions .html > .preview/index.html + mv .preview ${{ github.ref_name }} + mkdir .preview + mv ${{ github.ref_name }} .preview + + - name: Deploy + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: .preview + keep_files: true diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml new file mode 100644 index 0000000..28f2282 --- /dev/null +++ b/.github/workflows/ci-test-e2e.yml @@ -0,0 +1,344 @@ +name: Tests E2E + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + deno-version: + required: true + type: string + lowercase-repo: + required: true + type: string + gh-docker-tag: + required: true + type: string + enterprise-license: + type: string + transporter: + type: string + mongodb-version: + default: "['8.0']" + required: false + type: string + release: + required: true + type: string + fips: + default: false + required: false + type: boolean + shard: + default: '[1]' + required: false + type: string + total-shard: + default: 1 + required: false + type: number + retries: + default: 0 + required: false + type: number + type: + required: true + type: string + coverage: + required: false + type: string + secrets: + CR_USER: + required: true + CR_PAT: + required: true + QASE_API_TOKEN: + required: false + REPORTER_ROCKETCHAT_URL: + required: false + REPORTER_ROCKETCHAT_API_KEY: + required: false + CODECOV_TOKEN: + required: false + REPORTER_JIRA_ROCKETCHAT_API_KEY: + required: false + NPM_TOKEN: + required: false + +env: + MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + LOWERCASE_REPOSITORY: ${{ inputs.lowercase-repo }} + DOCKER_TAG: ${{ inputs.gh-docker-tag }}-amd64 + +permissions: {} + +jobs: + test: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + + env: + # if building for production on develop branch or release, add suffix for coverage images + DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ inputs.coverage == matrix.mongodb-version && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }} + MONGODB_VERSION: ${{ matrix.mongodb-version }} + COVERAGE_DIR: '/tmp/coverage/${{ startsWith(inputs.type, ''api'') && ''api'' || inputs.type }}' + COVERAGE_FILE_NAME: '${{ inputs.type }}-${{ matrix.shard }}.json' + COVERAGE_REPORTER: ${{ inputs.coverage == matrix.mongodb-version && 'json' || '' }} + + strategy: + fail-fast: false + matrix: + mongodb-version: ${{ fromJSON(inputs.mongodb-version) }} + shard: ${{ fromJSON(inputs.shard) }} + + name: MongoDB ${{ matrix.mongodb-version }}${{ inputs.coverage == matrix.mongodb-version && ' coverage' || '' }} (${{ matrix.shard }}/${{ inputs.total-shard }}) + + steps: + - name: Collect Workflow Telemetry + if: inputs.type == 'perf' + uses: catchpoint/workflow-telemetry-action@94c3c3d9567a0205de6da68a76c428ce4e769af1 # v2.0.0 + with: + theme: dark + job_summary: true + comment_on_pr: false + + - name: Setup kernel limits + run: | + echo "500 65535" > sudo tee -a /proc/sys/net/ipv4/ip_local_port_range + sudo sysctl -w net.ipv4.tcp_mem="383865 511820 2303190" + + echo fs.file-max=20000500 | sudo tee -a /etc/sysctl.conf + echo fs.nr_open=20000500 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + + - 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - uses: ./.github/actions/restore-packages + + # Download Docker images from build artifacts + - name: Download Docker images + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' + with: + pattern: ${{ inputs.release == 'ce' && 'docker-image-rocketchat-amd64-coverage' || (inputs.fips && 'docker-image-*-amd64-fips' || 'docker-image-*-amd64-coverage') }} + path: /tmp/docker-images + merge-multiple: true + + # Load Docker images + - name: Load Docker images + if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' + shell: bash + run: | + set -o xtrace + + # Load all downloaded images + for image_file in /tmp/docker-images/*.tar; do + if [ -f "$image_file" ]; then + echo "Loading image from $image_file" + docker load -i "$image_file" + rm "$image_file" + fi + done + + # List loaded images + docker images + + - name: Set DEBUG_LOG_LEVEL (debug enabled) + if: runner.debug == '1' + run: echo "DEBUG_LOG_LEVEL=2" >> "$GITHUB_ENV" + + - name: Start httpbin container and wait for it to be ready + if: startsWith(inputs.type, 'api') + run: | + docker compose -f docker-compose-ci.yml up -d httpbin + + - name: Prepare code coverage directory + run: | + set -o xtrace + + mkdir -p "$COVERAGE_DIR" + chmod 777 "$COVERAGE_DIR" + + - name: Start containers for CE + if: inputs.release == 'ce' + env: + # API tests intentionally invoke deprecated DDP methods through + # /v1/method.call/:method. TEST_MODE='api' keeps every other test + # behavior on (rate-limiter bypass, short cache TTLs) while letting + # the deprecation logger log without throwing. Other suites use the + # docker-compose default of TEST_MODE='true'. + TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }} + TEST_TYPE: ${{ inputs.type }} + run: | + # when we are testing CE, we only need to start the rocketchat container (plus mock-server for the api suite) + services=(rocketchat) + if [ "$TEST_TYPE" == "api" ]; then + services+=(mock-server) + fi + DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d "${services[@]}" --wait + + - name: Start containers for EE + if: inputs.release == 'ee' + env: + ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }} + TRANSPORTER: ${{ inputs.transporter }} + COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }} + TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }} + APPS_ENGINE_RUNTIME_BACKEND: ${{ inputs.type == 'api-apps-deno' && 'deno' || '' }} + FIPS_OVERRIDE: ${{ inputs.fips && '-f docker-compose-ci.fips.yml' || '' }} + run: | + read -r -a fips_override <<< "$FIPS_OVERRIDE" + DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml "${fips_override[@]}" up -d --wait + + - uses: ./.github/actions/setup-playwright + if: inputs.type == 'ui' + + - name: Wait services to start up + if: inputs.release == 'ee' + run: | + docker ps + + until docker compose -f docker-compose-ci.yml logs ddp-streamer-service | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'ddp-streamer' to start up" + ((c++)) && ((c==10)) && docker compose -f docker-compose-ci.yml logs ddp-streamer-service && exit 1 + sleep 10 + done; + + - name: Remove unused Docker images + run: docker system prune -af + + - name: E2E Test API + if: inputs.type == 'api' + working-directory: ./apps/meteor + env: + WEBHOOK_TEST_URL: 'http://httpbin' + IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + run: | + set -o xtrace + + npm run testapi || s=$? + + docker compose -f ../../docker-compose-ci.yml stop + + ls -la "$COVERAGE_DIR" + exit "${s:-0}" + + # This step should be temporary, only here until we remove the deno-runtime + - name: E2E Test API (apps + deno-runtime) + if: (inputs.type == 'api-apps-deno' && inputs.release == 'ee') + working-directory: ./apps/meteor + env: + WEBHOOK_TEST_URL: 'http://httpbin' + IS_EE: 'true' + run: | + set -o xtrace + + npm run testapi:apps || s=$? + + docker compose -f ../../docker-compose-ci.yml stop + + ls -la "$COVERAGE_DIR" + exit "${s:-0}" + + - name: E2E Test API (Livechat) + if: inputs.type == 'api-livechat' + working-directory: ./apps/meteor + env: + WEBHOOK_TEST_URL: 'http://httpbin' + IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + run: | + set -o xtrace + + npm run testapi:livechat || s=$? + + docker compose -f ../../docker-compose-ci.yml stop + + ls -la "$COVERAGE_DIR" + exit "${s:-0}" + + - name: E2E Test UI (${{ matrix.shard }}/${{ inputs.total-shard }}) + if: inputs.type == 'ui' + env: + E2E_COVERAGE: ${{ inputs.coverage == matrix.mongodb-version && 'true' || '' }} + IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_REPORT: ${{ github.event.pull_request.draft != 'true' && secrets.REPORTER_ROCKETCHAT_URL != '' && 'true' || '' }} + REPORTER_ROCKETCHAT_RUN: ${{ github.run_number }} + REPORTER_ROCKETCHAT_BRANCH: ${{ github.ref }} + REPORTER_ROCKETCHAT_DRAFT: ${{ github.event.pull_request.draft }} + REPORTER_ROCKETCHAT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPORTER_ROCKETCHAT_AUTHOR: ${{ github.event.pull_request.user.login }} + REPORTER_ROCKETCHAT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPORTER_ROCKETCHAT_PR: ${{ github.event.pull_request.number }} + QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} + QASE_REPORT: ${{ github.ref == 'refs/heads/develop' && 'true' || '' }} + CI: true + PLAYWRIGHT_RETRIES: ${{ inputs.retries }} + E2E_SHARD: ${{ matrix.shard }} + E2E_TOTAL_SHARD: ${{ inputs.total-shard }} + working-directory: ./apps/meteor + run: | + set -o xtrace + + yarn prepare + yarn test:e2e --shard="$E2E_SHARD/$E2E_TOTAL_SHARD" + + - name: Merge ui coverage files + if: inputs.type == 'ui' && inputs.coverage == matrix.mongodb-version + working-directory: ./apps/meteor + run: | + npx nyc merge .nyc_output "${COVERAGE_DIR}/${COVERAGE_FILE_NAME}" + ls -la "$COVERAGE_DIR" || true + + - name: Store playwright test trace + if: inputs.type == 'ui' && always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-test-trace-${{ inputs.release }}${{ inputs.fips && '-fips' || '' }}-${{ matrix.mongodb-version }}-${{ matrix.shard }} + path: ./apps/meteor/tests/e2e/.playwright* + include-hidden-files: true + + - name: Show server logs if E2E test failed + if: failure() + run: docker compose -f docker-compose-ci.yml logs rocketchat authorization-service queue-worker-service ddp-streamer-service account-service presence-service omnichannel-transcript-service + + - name: Show mongo logs if E2E test failed + if: failure() + run: docker compose -f docker-compose-ci.yml logs mongo + + - name: Show traefik logs if E2E test failed + if: failure() + run: docker compose -f docker-compose-ci.yml logs traefik + + - name: Store coverage + if: inputs.coverage == matrix.mongodb-version + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ inputs.type }}-${{ matrix.shard }} + path: /tmp/coverage + include-hidden-files: true diff --git a/.github/workflows/ci-test-storybook.yml b/.github/workflows/ci-test-storybook.yml new file mode 100644 index 0000000..ea538eb --- /dev/null +++ b/.github/workflows/ci-test-storybook.yml @@ -0,0 +1,62 @@ +name: Test Storybook + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + deno-version: + required: true + type: string + + secrets: + CODECOV_TOKEN: + required: true + NPM_TOKEN: + required: false + +env: + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + +permissions: {} + +jobs: + test: + runs-on: ubuntu-24.04-arm + permissions: + contents: read + + name: Test Storybook + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - uses: ./.github/actions/restore-packages + + - uses: ./.github/actions/setup-playwright + + - name: Run tests + run: yarn test-storybook + env: + STORYBOOK_DISABLE_TELEMETRY: 1 + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + flags: unit + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + # tests don't re-run on the develop push, so the merge queue run (same SHA + # that lands on develop) provides the develop coverage baseline + override_branch: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/develop' && 'develop' || '' }} diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml new file mode 100644 index 0000000..2b33f81 --- /dev/null +++ b/.github/workflows/ci-test-unit.yml @@ -0,0 +1,62 @@ +name: Tests Unit + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + deno-version: + required: true + type: string + enterprise-license: + required: false + type: string + secrets: + CODECOV_TOKEN: + required: false + NPM_TOKEN: + required: false + +env: + MONGO_URL: mongodb://localhost:27017/rocketchat?replicaSet=rs0&directConnection=true + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }} + +permissions: {} + +jobs: + test: + runs-on: ubuntu-24.04-arm + permissions: + contents: read + + name: Unit Tests + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - uses: ./.github/actions/restore-packages + + - name: Unit Test + run: yarn testunit --concurrency=1 + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + flags: unit + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + # tests don't re-run on the develop push, so the merge queue run (same SHA + # that lands on develop) provides the develop coverage baseline + override_branch: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/develop' && 'develop' || '' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc1f0d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,1333 @@ +name: CI + +on: + release: + types: [published] + pull_request: + branches: + - '**' + paths-ignore: + - '**.md' + merge_group: + push: + branches: + - develop + paths-ignore: + - '**.md' + +concurrency: + # merge_group runs group by the merge-group ref so a recreated group cancels the stale run; + # push/release runs group by run_id on purpose (never cancel a deploy in progress) + group: ${{ github.workflow }}-${{ github.head_ref || (github.event_name == 'merge_group' && github.ref) || github.run_id }} + cancel-in-progress: true + +env: + TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} + +permissions: + contents: read + actions: read + +jobs: + release-versions: + name: ⚙️ Variables Setup + runs-on: ubuntu-24.04-arm + permissions: + contents: read + pull-requests: read + outputs: + release: ${{ steps.by-tag.outputs.release }} + latest-release: ${{ steps.latest.outputs.latest-release }} + gh-docker-tag: ${{ steps.docker.outputs.gh-docker-tag }} + lowercase-repo: ${{ steps.var.outputs.lowercase-repo }} + node-version: ${{ steps.var.outputs.node-version }} + deno-version: ${{ steps.var.outputs.deno-version }} + source-hash: ${{ steps.source.outputs.hash }} + fips-skip: ${{ steps.fips.outputs.fips-skip }} + packages-build-cache-key: ${{ steps.ci-cache-keys.outputs.packages-build-cache-key }} + meteor-rc-cache-key: ${{ steps.ci-cache-keys.outputs.meteor-rc-cache-key }} + github-actions-changed: ${{ steps.diff.outputs.actions-changed }} + # this is 100% intentional, secrets are not available for forks, so ee-tests will always fail + # to avoid this, we are using a dummy license, expiring at 2027-07-01 + enterprise-license: MK+bpK5NveUuNlWGaQXGoy+8b74Luet82M3ZGcBB8b5P9Y+m67NEtpW64dc1d5lEWi6d0nFjCjtCMneVD7bKxodz/Cml8URKEo5P7cQb/9wmeT0MzAhYNaRFZlIGkZ3ITF59pDV2u4HZuosEDJikVRwnaJ5ZoU/pOsHSPUPhTyGNIqLeKynODtUpfwDdIKEmHxpf2yVkKjgRiIJmbWjM6A4k+MNNYXWVXHzye7GggqWVg/ZcT7nKU1CCadpLhTJiIrgrrPzil1G5DQ4xnLs3Q2tu2dILSDiW5OYw/ywu2yCMicTjMq4MLL5SXDQJj6WoJzZ54HosbvsDzOXvsdC9gI1CjhPL2uRuvC8XLrzn3vL2UgXnifzD1VrLTtdZ+aSADveqtlzYlRWtqoUFBbNw8o+YVHdhbZGR0beMoAyRbHi5EMpxpad3L+NyztUIT/Uh/IjQ/C2SQZ6jB0GKPBOPxFLN56FNhTGrffLFR++TVoBu0Iquc7kajWkNit3bVbZvbx+oFcVW2PcjQ/+i2jpJjbgtUFUKrTKxGMAXTWoDzIQQ35zNzGAy268IM4Ymp5JmsVEnBOEUkbF9yx6fzkO6xZhpsHf0muklnW0kA+Tlore/TUrBWh1/RwWlQeZlxM5NyWoRM5onQmr/k/4BmObtL1Hpmbk8oMG29z89xtE9y/4= + steps: + - name: Github Info + run: | + echo "GITHUB_ACTION: $GITHUB_ACTION" + echo "GITHUB_ACTOR: $GITHUB_ACTOR" + echo "GITHUB_REF: $GITHUB_REF" + echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" + echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" + echo "github.event_name: ${{ github.event_name }}" + cat "$GITHUB_EVENT_PATH" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # with: + # sparse-checkout: | + # package.json + # .tool-versions + # sparse-checkout-cone-mode: false + # ref: ${{ github.ref }} + + - id: source + run: | + ls -la + + tar -cf /tmp/RocketChat-source.tar \ + --sort=name \ + --owner=0 --group=0 \ + --mtime='1970-01-01' \ + --exclude='.github' \ + --exclude='.git' \ + . + + SOURCE_HASH=$(sha256sum /tmp/RocketChat-source.tar | awk '{ print $1 }')-v9 + + # Uncomment the following line to include the run ID in the hash and disable caching between runs + # SOURCE_HASH=$(sha256sum /tmp/RocketChat-source.tar | awk '{ print $1 }')-${{ github.run_id }} + + echo "hash=${SOURCE_HASH}" + + echo "hash=${SOURCE_HASH}" >> "$GITHUB_OUTPUT" + + - id: var + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + echo "LOWERCASE_REPOSITORY: ${LOWERCASE_REPOSITORY}" + echo "lowercase-repo=${LOWERCASE_REPOSITORY}" >> "$GITHUB_OUTPUT" + + NODE_VERSION=$(node -p "require('./package.json').volta.node") + echo "NODE_VERSION: ${NODE_VERSION}" + echo "node-version=${NODE_VERSION}" >> "$GITHUB_OUTPUT" + + DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions) + echo "DENO_VERSION: ${DENO_VERSION}" + echo "deno-version=${DENO_VERSION}" >> "$GITHUB_OUTPUT" + + - id: ci-cache-keys + run: | + set -euo pipefail + PACKAGES_HASH=$(cat yarn.lock .yarnrc.yml package.json turbo.json | sha256sum | awk '{print $1}') + echo "packages-build-cache-key=${PACKAGES_HASH}" >> "$GITHUB_OUTPUT" + METEOR_HASH=$(git archive HEAD apps/meteor | sha256sum | awk '{print $1}') + echo "meteor-rc-cache-key=${METEOR_HASH}" >> "$GITHUB_OUTPUT" + + - id: by-tag + run: | + if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then + RELEASE="latest" + elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then + RELEASE="release-candidate" + fi + echo "RELEASE: ${RELEASE}" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + + - id: latest + run: | + LATEST_RELEASE="$( + git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | + awk -F/ '$NF !~ /rc|beta/ { print $NF; exit }' + )" + echo "LATEST_RELEASE: ${LATEST_RELEASE}" + echo "latest-release=${LATEST_RELEASE}" >> "$GITHUB_OUTPUT" + + - id: docker + run: | + if [[ '${{ github.event_name }}' == 'pull_request' ]]; then + DOCKER_TAG="pr-${{ github.event.number }}" + else + DOCKER_TAG=$GITHUB_REF_NAME + fi + # Docker tags cannot contain '/'; merge queue refs do (e.g. gh-readonly-queue/develop/pr-123-sha) + DOCKER_TAG="${DOCKER_TAG//\//-}" + echo "DOCKER_TAG: ${DOCKER_TAG}" + echo "gh-docker-tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" + + # How much of the FIPS suite to skip: + # all - build no fips images and run no fips tests + # ui-suite - build the images, run the api suites, skip the expensive UI shards (default) + # none - skip nothing, the full fips suite runs + # Forks skip 'all': the fips base image (rocketchat/dhi-node) sits in a private DockerHub + # repo and secrets are unavailable to forks, so the pull could never succeed. + - id: fips + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REPO: ${{ github.repository }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO_IS_FORK: ${{ github.event.repository.fork }} + HAS_FIPS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'fips') }} + run: | + if [[ "$EVENT_NAME" == 'pull_request' ]]; then + if [[ "$PR_HEAD_REPO" == "$BASE_REPO" ]]; then + IS_FORK='false' + else + IS_FORK='true' + fi + else + IS_FORK="$REPO_IS_FORK" + fi + + echo "EVENT_NAME: ${EVENT_NAME} / IS_FORK: ${IS_FORK}" + + if [[ "$IS_FORK" == 'true' ]]; then + FIPS_SKIP='all' + elif [[ "$HAS_FIPS_LABEL" == 'true' ]]; then + FIPS_SKIP='none' + else + FIPS_SKIP='ui-suite' + fi + + echo "FIPS_SKIP: ${FIPS_SKIP}" + echo "fips-skip=${FIPS_SKIP}" >> "$GITHUB_OUTPUT" + + - id: diff + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PR_NUM: ${{ github.event.number }} + run: | + if gh pr diff --name-only --repo "${GITHUB_REPOSITORY}" "${GH_PR_NUM}" | grep -q -e .github/workflows -e .github/actions; then + echo "Workflow changes detected" + echo "actions-changed=true" >> "$GITHUB_OUTPUT" + fi + + test-guard: + name: ⚙️ Test Guard + runs-on: ubuntu-24.04-arm + permissions: + checks: read + outputs: + skip-tests: ${{ steps.check.outputs.skip-tests }} + steps: + # Commits only reach develop through the merge queue, where the full test + # suite already ran on this exact SHA — skip re-running it on the push event. + # Direct pushes (admin/revert) have no successful "Tests Done" check, so they + # still get the full suite. continue-on-error keeps this fail-open: if the + # API call fails, tests run. + - name: Check if this commit already passed tests + id: check + if: github.event_name == 'push' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + # --paginate applies the jq filter per page, so emit names and count with + # wc -l to get a single integer across pages + passed=$(gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/check-runs" --paginate \ + --jq '.check_runs[] | select(.name == "✅ Tests Done" and .conclusion == "success") | .name' | wc -l) + + if [[ "$passed" -gt 0 ]]; then + echo "Tests already passed for ${{ github.sha }} in the merge queue, skipping test jobs" + echo "skip-tests=true" >> "$GITHUB_OUTPUT" + else + echo "skip-tests=false" >> "$GITHUB_OUTPUT" + fi + + notify-draft-services: + name: 🚀 Notify external services - draft + runs-on: ubuntu-24.04-arm + needs: [release-versions] + if: github.event_name == 'release' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} + + - name: Register release on cloud as Draft + if: github.event_name == 'release' + env: + UPDATE_TOKEN: ${{ secrets.UPDATE_TOKEN }} + run: | + REPO_VERSION=$(node -p "require('./package.json').version") + + if [[ '${{ github.event_name }}' = 'release' ]]; then + GIT_TAG="${GITHUB_REF#*tags/}" + GIT_BRANCH="" + ARTIFACT_NAME="${REPO_VERSION}" + RC_VERSION=$GIT_TAG + + if [[ '${{ needs.release-versions.outputs.release }}' = 'release-candidate' ]]; then + RC_RELEASE=candidate + elif [[ '${{ needs.release-versions.outputs.release }}' = 'latest' ]]; then + RC_RELEASE=stable + fi + else + GIT_TAG="" + GIT_BRANCH="${GITHUB_REF#*heads/}" + ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA" + RC_VERSION="${REPO_VERSION}" + RC_RELEASE=develop + fi; + + curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ + "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\",\"compatibleMongoVersions\": [\"8.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"draft\", \"draftAs\": \"$RC_RELEASE\"}" \ + https://releases.rocket.chat/update + + packages-build: + name: 📦 Build Packages + needs: [release-versions] + runs-on: ubuntu-24.04-arm + steps: + - name: Cache build + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: packages-cache-build + with: + path: | + /tmp/RocketChat-packages-build.tar.gz + key: ${{ runner.arch }}-${{ runner.os }}-packages-build-${{ needs.release-versions.outputs.source-hash }} + + - name: Cache observability (packages-build) + run: | + { + echo "### 📦 packages-build cache" + echo "- **hit**: \`${{ steps.packages-cache-build.outputs.cache-hit }}\`" + echo "- **key**: \`${{ needs.release-versions.outputs.packages-build-cache-key }}\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + if: steps.packages-cache-build.outputs.cache-hit != 'true' + with: + swap-size-gb: 4 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.packages-cache-build.outputs.cache-hit != 'true' + + - name: Setup NodeJS + id: setup-node-packages + uses: ./.github/actions/setup-node + if: steps.packages-cache-build.outputs.cache-hit != 'true' + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + HARDENED_MODE: '1' + + - name: Cache observability (node_modules) + if: steps.packages-cache-build.outputs.cache-hit != 'true' + run: | + { + echo "### 🟢 node_modules cache" + echo "- **deno cache hit**: \`${{ steps.setup-node-packages.outputs.cache-deno-hit }}\`" + echo "- **node_modules size**: \`$(du -sh node_modules 2>/dev/null | cut -f1 || echo 'n/a')\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cache vite + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + if: steps.packages-cache-build.outputs.cache-hit != 'true' + with: + path: ./node_modules/.vite + key: vite-local-cache-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('package.json') }} + restore-keys: | + vite-local-cache-${{ runner.arch }}-${{ runner.os }}- + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + if: steps.packages-cache-build.outputs.cache-hit != 'true' + + - name: Build Rocket.Chat Packages + if: steps.packages-cache-build.outputs.cache-hit != 'true' + run: yarn build + + - name: Archive packages build output + if: steps.packages-cache-build.outputs.cache-hit != 'true' + run: | + git ls-files -oi --exclude-standard -- ':(exclude)node_modules/*' ':(exclude)**/node_modules/*' ':(exclude)**/.meteor/*' ':(exclude)**/.turbo/*' ':(exclude).turbo/*' ':(exclude)**/.yarn/*' ':(exclude).yarn/*' ':(exclude).git/*' | tar -czf /tmp/RocketChat-packages-build.tar.gz -T - + + - name: Upload packages build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: packages-build + path: /tmp/RocketChat-packages-build.tar.gz + retention-days: 5 + + - name: Store turbo build + if: steps.packages-cache-build.outputs.cache-hit != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-build + path: .turbo/cache + overwrite: true + include-hidden-files: true + + build: + name: 📦 Meteor Build (${{ matrix.type }}) + needs: [release-versions, packages-build] + runs-on: ubuntu-24.04-arm + + strategy: + fail-fast: false + matrix: + type: + - production + - coverage + exclude: + - type: ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'production' || '' }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/meteor-build + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + source-hash: ${{ needs.release-versions.outputs.packages-build-cache-key }}-${{ needs.release-versions.outputs.meteor-rc-cache-key }} + type: ${{ matrix.type }} + + build-gh-docker: + name: 🚢 Build Docker + needs: [build, release-versions] + runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }} + + env: + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }}-${{ matrix.arch }} + LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} + + strategy: + fail-fast: false + matrix: + arch: [arm64, amd64] + service: + [ + [authorization-service, queue-worker-service, ddp-streamer-service], + [account-service, presence-service, omnichannel-transcript-service], + [rocketchat], + ] + type: + - ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }} + - fips + exclude: + - arch: arm64 + type: fips + # drop the whole fips leg when there is nothing downstream to consume it (fork PRs) + - type: ${{ needs.release-versions.outputs.fips-skip == 'all' && 'fips' || '' }} + include: + - arch: amd64 + service: [rocketchat] + type: coverage + - arch: arm64 + service: [rocketchat] + type: coverage + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/restore-packages + + # we only build and publish the actual docker images if not a PR from a fork + - name: Image ${{ matrix.service[0] }} + uses: ./.github/actions/build-docker + if: github.actor != 'dependabot[bot]' + env: + # add suffix for the extra images with coverage if building for production + DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ matrix.type == 'coverage' && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }} + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + arch: ${{ matrix.arch }} + service: ${{ matrix.service[0] }} + type: ${{ matrix.type }} + publish-image: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' }} + + - name: Image ${{ matrix.service[1] || '"skipped"' }} + uses: ./.github/actions/build-docker + if: matrix.service[1] && github.actor != 'dependabot[bot]' + env: + DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ matrix.type == 'coverage' && '-cov' || '' }} + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + arch: ${{ matrix.arch }} + service: ${{ matrix.service[1] }} + type: ${{ matrix.type }} + publish-image: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' }} + setup-docker: false + + - name: Image ${{ matrix.service[2] || '"skipped"' }} + uses: ./.github/actions/build-docker + if: matrix.service[2] && github.actor != 'dependabot[bot]' + env: + DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ matrix.type == 'coverage' && '-cov' || '' }} + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + arch: ${{ matrix.arch }} + service: ${{ matrix.service[2] }} + type: ${{ matrix.type }} + publish-image: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' }} + setup-docker: false + + - name: Image ${{ matrix.service[3] || '"skipped"' }} + uses: ./.github/actions/build-docker + if: matrix.service[3] && github.actor != 'dependabot[bot]' + env: + DOCKER_TAG_SUFFIX_ROCKETCHAT: ${{ matrix.type == 'coverage' && '-cov' || '' }} + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + arch: ${{ matrix.arch }} + service: ${{ matrix.service[3] }} + type: ${{ matrix.type }} + publish-image: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' }} + setup-docker: false + + build-gh-docker-publish: + name: 🚢 Publish Docker Images (ghcr.io) + needs: [build-gh-docker, release-versions] + runs-on: ubuntu-24.04-arm + + env: + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + with: + sparse-checkout: | + docker-compose-ci.yml + docker-compose-ci.fips.yml + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} + + - name: Login to GitHub Container Registry + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Download manifests + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: manifests-* + path: /tmp/manifests + merge-multiple: true + + - name: Create and push multi-arch manifests + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + run: | + set -o xtrace + shopt -s nullglob + + for service_dir in /tmp/manifests/*; do + [[ -d "$service_dir" ]] || continue + service="$(basename "$service_dir")" + echo "Creating manifest for $service" + + # Extract digests from manifest.json files + # shellcheck disable=SC2016 + mapfile -t refs < <( + find "$service_dir" -type f -name 'manifest.json' -print0 \ + | xargs -0 -I{} jq -r '.Descriptor.digest as $digest | .Ref | split("@")[0] + "@" + $digest' {} + ) + + echo "Digest for ${service}: ${refs[*]}" + + # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) + if [ "$service" == "rocketchat-cov" ]; then + IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "rocketchat" '.services[$s].image')-cov + elif [[ "$service" == *"-fips" ]]; then + base_service="${service%-fips}" + IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$base_service" '.services[$s].image')-fips + else + IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$service" '.services[$s].image') + fi + + echo "$IMAGE" + + docker buildx imagetools create \ + --debug \ + --annotation "manifest-descriptor:org.opencontainers.image.description=Build run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --tag "${IMAGE}" \ + --tag "${IMAGE}-gha-run-${{ github.run_id }}" \ + "${refs[@]}" + done + + track-image-sizes: + name: 📦 Track Image Sizes + needs: [build-gh-docker-publish, release-versions] + runs-on: ubuntu-24.04-arm + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/develop' + permissions: + pull-requests: write + contents: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Track Docker image sizes + uses: ./.github/actions/docker-image-size-tracker + if: github.actor != 'dependabot[bot]' && (github.ref == 'refs/heads/develop' || github.event.pull_request.head.repo.full_name == github.repository) + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + ci-pat: ${{ secrets.CI_PAT }} + registry: ghcr.io + repository: ${{ needs.release-versions.outputs.lowercase-repo }} + tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + baseline-tag: develop + size-thresholds: '{"rocketchat":{"mb":11}}' + + actionlint: + name: 🔎 Code Check - Action Lint + needs: [release-versions] + if: needs.release-versions.outputs.github-actions-changed + runs-on: ubuntu-24.04-arm + env: + ACTIONLINT_VERSION: 1.7.12 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Get actionlint + shell: bash + run: | + TAR_NAME="actionlint_${ACTIONLINT_VERSION}_$(echo "${RUNNER_OS}_${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]').tar.gz" + echo "Downloading ${TAR_NAME}..." + curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TAR_NAME}" + curl -fsSLo checksums.txt "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + grep " ${TAR_NAME}$" "checksums.txt" | sha256sum -c - + tar -xzf "${TAR_NAME}" actionlint + + - name: Run actionlint + shell: bash + run: | + ./actionlint -color + + + # gating `checks` also skips every e2e test job (they all `need` it); + # test-storybook and test-unit don't, so they carry the same gate below + checks: + needs: [release-versions, packages-build, test-guard] + if: needs.test-guard.outputs.skip-tests != 'true' + + name: 🔎 Code Check + uses: ./.github/workflows/ci-code-check.yml + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + + test-storybook: + name: 🔨 Test Storybook + needs: [packages-build, release-versions, test-guard] + if: needs.test-guard.outputs.skip-tests != 'true' + + uses: ./.github/workflows/ci-test-storybook.yml + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + test-unit: + name: 🔨 Test Unit + needs: [packages-build, release-versions, test-guard] + if: needs.test-guard.outputs.skip-tests != 'true' + + uses: ./.github/workflows/ci-test-unit.yml + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + test-api: + name: 🔨 Test API (CE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api + release: ce + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-api-livechat: + name: 🔨 Test API Livechat (CE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ce + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-ui: + name: 🔨 Test UI (CE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: ui + release: ce + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + shard: '[1, 2, 3, 4]' + total-shard: 4 + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + retries: ${{ (github.event_name == 'release' || github.event_name == 'merge_group' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') && 2 || 0 }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} + REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + + test-api-ee: + name: 🔨 Test API (EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-api-livechat-ee: + name: 🔨 Test API Livechat (EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-api-apps-deno-ee: + name: 🔨 Test API Apps (deno-runtime - EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-apps-deno + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-ui-ee: + name: 🔨 Test UI (EE) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: ui + release: ee + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + shard: '[1, 2, 3, 4, 5]' + total-shard: 5 + mongodb-version: "['8.0']" + coverage: '8.0' + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + retries: ${{ (github.event_name == 'release' || github.event_name == 'merge_group' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') && 2 || 0 }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} + REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + + test-api-fips: + name: 🔨 Test API (FIPS) + if: needs.release-versions.outputs.fips-skip != 'all' + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api + release: ee + fips: true + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-api-livechat-fips: + name: 🔨 Test API Livechat (FIPS) + if: needs.release-versions.outputs.fips-skip != 'all' + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: ee + fips: true + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-ui-fips: + name: 🔨 Test UI (FIPS) + # only the 'fips' label opts a PR into the UI shards + if: needs.release-versions.outputs.fips-skip == 'none' + needs: [checks, build-gh-docker-publish, release-versions] + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: ui + release: ee + fips: true + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + shard: '[1, 2, 3, 4, 5]' + total-shard: 5 + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + retries: ${{ (github.event_name == 'release' || github.event_name == 'merge_group' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') && 2 || 0 }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} + REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + 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 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - name: Restore turbo build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + 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" + + # Download Docker images from build artifacts + - name: Download Docker images + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' + with: + pattern: 'docker-image-rocketchat-amd64-coverage' + path: /tmp/docker-images + merge-multiple: true + + # Load Docker images + - name: Load Docker images + if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' + shell: bash + run: | + set -o xtrace + + # Load all downloaded images + for image_file in /tmp/docker-images/*.tar; do + if [ -f "$image_file" ]; then + echo "Loading image from $image_file" + docker load -i "$image_file" + rm "$image_file" + fi + done + + # List loaded images + docker images + + - name: Start federation services + 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 }}-amd64 + ENTERPRISE_LICENSE_RC1: ZAikY+LLaal7mT6RNYxpyWEmMQyucrl50/7pYBXqHczc90j+RLwF+T0xuCT2pIpKMC5DxcZ1TtkV6MYJk5whrwmap+mQ0FV+VpILJlL0i4T21K4vMfzZXTWm/pzcAy2fMTUNH+mUA9HTBD6lYYh40KnbGXPAd80VbZk0MO/WbWBm2dOT0YCwfvlRyurRqkDAQrftLaffzCNUsMKk0fh+MKs73UDHZQDp1yvs7WoGpPu5ZVi5mTBOt3ZKVz5KjGfClLwJptFPmW1w6nKelAiJBDPpjcX1ylfjxpnBoixko7uN52zlyaeoAYwfRcdDLnZ8k0Ou6tui/vTQUXjGIjHw2AhMaKwonn4E9LYpuA1KEXt08qJL5J3ZtjSCV1T+A9Z3zFhhLgp5dxP/PPUbxDn/P8XKp7nXM9duIfcCMlnea7V8ixEyCHwwvKQaXVVidcsUGtB8CwS0GlsAEBLOzqMehuQUK2rdQ4WgEz3AYveikeVvSzgBHvyXsxssWAThc0Mht0eEJqdDhUB2QeZ2WmPsaSSD639Z4WgjSUoR0zh8bfqepH+2XRcUryXe2yN+iU+3POzi9wfg0k65MxXT8pBg3PD5RHnR8oflEP0tpZts33JiBhYRxX3MKplAFm4dMuphTsDJTh+e534pT7IPuZF79QSVaLEWZfVVVb7nGFtmMwA= + QASE_TESTOPS_JEST_API_TOKEN: ${{ secrets.QASE_TESTOPS_JEST_API_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + run: yarn test:integration --start-containers-only --image "${ROCKETCHAT_IMAGE}" + + - name: Run federation integration tests + working-directory: ./ee/packages/federation-matrix + env: + QASE_TESTOPS_JEST_API_TOKEN: ${{ secrets.QASE_TESTOPS_JEST_API_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + run: yarn test:integration --ci + + - name: Show federation integration tests logs + if: failure() + working-directory: ./ee/packages/federation-matrix + run: yarn test:integration --logs + + - name: Show mongo logs if tests failed + if: failure() + working-directory: ./ee/packages/federation-matrix + run: docker compose -f docker-compose.test.yml logs mongo + + report-coverage: + name: 📊 Report Coverage + runs-on: ubuntu-24.04 + needs: [release-versions, test-api-ee, test-api-livechat-ee, test-ui-ee] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-versions.outputs.node-version }} + + - name: Restore coverage folder + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-* + path: /tmp/coverage + merge-multiple: true + + - name: Generate lcov report + run: | + set -o xtrace + + npx nyc report --reporter=lcovonly --report-dir=/tmp/coverage_report/api --temp-dir=/tmp/coverage/api + npx nyc report --reporter=lcovonly --report-dir=/tmp/coverage_report/ui --temp-dir=/tmp/coverage/ui + + - name: Store coverage-reports + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reports-coverage + path: /tmp/coverage_report + include-hidden-files: true + + - name: Report API coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + files: /tmp/coverage_report/api/lcov.info + working-directory: . + flags: e2e-api + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + # tests don't re-run on the develop push, so the merge queue run (same SHA + # that lands on develop) provides the develop coverage baseline + override_branch: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/develop' && 'develop' || '' }} + + - name: Report UI coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + files: /tmp/coverage_report/ui/lcov.info + working-directory: . + flags: e2e + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + override_branch: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/develop' && 'develop' || '' }} + + tests-done: + name: ✅ Tests Done + runs-on: ubuntu-24.04-arm + needs: [test-guard, checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-api-livechat, test-api-livechat-ee, test-api-apps-deno-ee, test-api-fips, test-api-livechat-fips, test-ui-fips, test-federation-matrix] + if: always() && needs.test-guard.outputs.skip-tests != 'true' + steps: + - name: Test finish aggregation + run: | + if [[ '${{ needs.checks.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-unit.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-ee.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui-ee.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat-ee.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-apps-deno-ee.result }}' != 'success' ]]; then + exit 1 + fi + + # the fips jobs are gated by the release-versions 'fips-skip' output, so any of them + # can legitimately report 'skipped' (always on fork PRs, and test-ui-fips unless the + # PR carries the 'fips' label) + if [[ '${{ needs.test-api-fips.result }}' != 'success' && '${{ needs.test-api-fips.result }}' != 'skipped' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat-fips.result }}' != 'success' && '${{ needs.test-api-livechat-fips.result }}' != 'skipped' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui-fips.result }}' != 'success' && '${{ needs.test-ui-fips.result }}' != 'skipped' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-federation-matrix.result }}' != 'success' ]]; then + exit 1 + fi + + echo finished + + deploy: + name: 🚀 Publish build assets + runs-on: ubuntu-24.04-arm + if: github.event_name == 'release' || github.ref == 'refs/heads/develop' + needs: [build-gh-docker-publish, release-versions] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} + + - name: Restore build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-production + path: /tmp/build + + - name: Publish assets + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: 'us-east-1' + GPG_PASSWORD: ${{ secrets.GPG_PASSWORD }} + run: | + REPO_VERSION=$(node -p "require('./package.json').version") + + if [[ "${{ github.event_name }}" = 'release' ]]; then + ARTIFACT_NAME="${REPO_VERSION}" + else + ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA" + fi; + + ROCKET_DEPLOY_DIR="/tmp/deploy" + FILENAME="$ROCKET_DEPLOY_DIR/rocket.chat-$ARTIFACT_NAME.tgz" + + aws s3 cp s3://rocketchat/sign.key.gpg .github/sign.key.gpg + + mkdir -p "$ROCKET_DEPLOY_DIR" + + cp .github/sign.key.gpg /tmp + gpg --yes --batch --passphrase="$GPG_PASSWORD" /tmp/sign.key.gpg + gpg --allow-secret-key-import --import /tmp/sign.key + rm /tmp/sign.key + + ln -s /tmp/build/Rocket.Chat.tar.gz "$FILENAME" + gpg --armor --detach-sign "$FILENAME" + + aws s3 cp "$ROCKET_DEPLOY_DIR/" s3://download.rocket.chat/build/ --recursive + + docker-image-publish: + name: 🚀 Publish Docker Images (DockerHub) + runs-on: ubuntu-24.04-arm + needs: [deploy, release-versions] + + env: + DOCKER_TAG: ${{ needs.release-versions.outputs.gh-docker-tag }} + LOWERCASE_REPOSITORY: ${{ needs.release-versions.outputs.lowercase-repo }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + docker-compose-ci.yml + docker-compose-ci.fips.yml + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} + + - name: Login to DockerHub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASS }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Download manifests + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: manifests-* + path: /tmp/manifests + merge-multiple: true + + - name: Publish Docker images + run: | + set -euo pipefail + set -o xtrace + shopt -s nullglob + + # sudo apt-get update -y + # sudo apt-get install -y skopeo + + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + DOCKER_TAG="${DOCKER_TAG//\//-}" + + declare -a TAGS=() + + # tag specific tag version + TAGS+=("$DOCKER_TAG") + + if [[ $GITHUB_REF == refs/tags/* ]]; then + RELEASE="${{ needs.release-versions.outputs.release }}" + + echo "RELEASE: $RELEASE" + + if [[ "$RELEASE" == 'latest' ]]; then + if [[ '${{ needs.release-versions.outputs.latest-release }}' == "$GITHUB_REF_NAME" ]]; then + TAGS+=("$RELEASE") + fi + else + TAGS+=("$RELEASE") + fi + fi + + # commit hash + COMMIT_SHA="sha-${GITHUB_SHA:0:7}" + echo "COMMIT_SHA: ${COMMIT_SHA}" + TAGS+=("${COMMIT_SHA}") + + echo "Tags: ${TAGS[*]}" + + # get first tag as primary + PRIMARY="${TAGS[0]}" + + for service_dir in /tmp/manifests/*; do + [[ -d "$service_dir" ]] || continue + service="$(basename "$service_dir")" + + if [ "$service" == "rocketchat-cov" ]; then + continue + fi + + echo "Promoting $service" + + # FIPS images publish to the same repo as their base service, distinguished by a + # '-fips' tag suffix (matching GHCR), not a separate '-fips' repository. + # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) + TAG_SUFFIX='' + if [[ "$service" == *"-fips" ]]; then + base_service="${service%-fips}" + TAG_SUFFIX='-fips' + SRC=$(docker compose -f docker-compose-ci.yml -f docker-compose-ci.fips.yml config --format json 2>/dev/null | jq -r --arg s "$base_service" '.services[$s].image') + else + base_service="$service" + SRC=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$service" '.services[$s].image') + fi + + if [[ "${base_service}" == 'rocketchat' ]]; then + IMAGE_NAME="${{ needs.release-versions.outputs.lowercase-repo }}/rocket.chat" + else + IMAGE_NAME="${{ needs.release-versions.outputs.lowercase-repo }}/${base_service}" + fi + + DEST_REPO="docker.io/${IMAGE_NAME}" + + echo "Copying $SRC to ${DEST_REPO}:${PRIMARY}${TAG_SUFFIX}" + skopeo copy --all \ + "docker://${SRC}" \ + "docker://${DEST_REPO}:${PRIMARY}${TAG_SUFFIX}" + + # copy additional tags + if (( ${#TAGS[@]} > 1 )); then + for t in "${TAGS[@]:1}"; do + echo "Copying $SRC to ${DEST_REPO}:${t}${TAG_SUFFIX}" + skopeo copy --all \ + "docker://${SRC}" \ + "docker://${DEST_REPO}:${t}${TAG_SUFFIX}" + done + fi + + done + + notify-services: + name: 🚀 Notify external services + runs-on: ubuntu-24.04-arm + needs: + - docker-image-publish + - release-versions + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + package.json + sparse-checkout-cone-mode: false + ref: ${{ github.ref }} + + - name: Releases service + env: + UPDATE_TOKEN: ${{ secrets.UPDATE_TOKEN }} + run: | + REPO_VERSION=$(node -p "require('./package.json').version") + + if [[ '${{ github.event_name }}' = 'release' ]]; then + GIT_TAG="${GITHUB_REF#*tags/}" + GIT_BRANCH="" + ARTIFACT_NAME="${REPO_VERSION}" + RC_VERSION=$GIT_TAG + + if [[ '${{ needs.release-versions.outputs.release }}' = 'release-candidate' ]]; then + RC_RELEASE=candidate + elif [[ '${{ needs.release-versions.outputs.release }}' = 'latest' ]]; then + RC_RELEASE=stable + fi + else + GIT_TAG="" + GIT_BRANCH="${GITHUB_REF#*heads/}" + ARTIFACT_NAME="${REPO_VERSION}.$GITHUB_SHA" + RC_VERSION="${REPO_VERSION}" + RC_RELEASE=develop + fi; + + curl -H "Content-Type: application/json" -H "X-Update-Token: $UPDATE_TOKEN" -d \ + "{\"nodeVersion\": \"${{ needs.release-versions.outputs.node-version }}\", \"denoVersion\": \"${{ needs.release-versions.outputs.deno-version }}\", \"compatibleMongoVersions\": [\"8.0\"], \"commit\": \"$GITHUB_SHA\", \"tag\": \"$RC_VERSION\", \"branch\": \"$GIT_BRANCH\", \"artifactName\": \"$ARTIFACT_NAME\", \"releaseType\": \"$RC_RELEASE\"}" \ + https://releases.rocket.chat/update + + # Makes build fail if the release isn't there + curl --fail "https://releases.rocket.chat/$RC_VERSION/info" + + docs-update: + name: Update Version Durability + + if: github.event_name == 'release' + needs: + - docker-image-publish + + uses: ./.github/workflows/update-version-durability.yml + with: + LTS_VERSIONS: ${{ vars.LTS_VERSIONS }} + secrets: + CI_PAT: ${{ secrets.CI_PAT }} + D360_TOKEN: ${{ secrets.D360_TOKEN }} + D360_USER_ID: ${{ secrets.D360_USER_ID }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..b6ce9df --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,57 @@ +name: 'Code scanning - action' + +on: + push: + branches-ignore: + - dependabot/** + pull_request: + schedule: + - cron: '0 13 * * *' + +jobs: + CodeQL-Build: + # CodeQL runs on ubuntu-latest and windows-latest + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + # Override language selection by uncommenting this and choosing your languages + with: + languages: javascript + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 diff --git a/.github/workflows/dedupe-issues.yml b/.github/workflows/dedupe-issues.yml new file mode 100644 index 0000000..a527333 --- /dev/null +++ b/.github/workflows/dedupe-issues.yml @@ -0,0 +1,85 @@ +name: Rocket.Chat Issue Dedupe + +on: + issues: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to process for duplicate detection' + required: true + type: string + +permissions: {} + +jobs: + dedupe-issues: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run Claude Code slash command + uses: anthropics/claude-code-base-action@e8132bc5e637a42c27763fc757faa37e1ee43b34 # v0.0.63beta + with: + prompt: '/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}' + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: 'claude-sonnet-4-5-20250929' + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Log duplicate comment event to Statsig + if: always() + env: + STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }} + run: | + ISSUE_NUMBER=${{ github.event.issue.number || inputs.issue_number }} + REPO=${{ github.repository }} + + if [ -z "$STATSIG_API_KEY" ]; then + echo "STATSIG_API_KEY not found, skipping Statsig logging" + exit 0 + fi + + # Prepare the event payload + EVENT_PAYLOAD=$(jq -n \ + --arg issue_number "$ISSUE_NUMBER" \ + --arg repo "$REPO" \ + --arg triggered_by "${{ github.event_name }}" \ + --arg actor "${{ github.actor }}" \ + '{ + events: [{ + eventName: "github_duplicate_comment_added", + value: 1, + metadata: { + repository: $repo, + issue_number: ($issue_number | tonumber), + triggered_by: $triggered_by, + workflow_run_id: "${{ github.run_id }}", + actor: $actor + }, + time: (now | floor | tostring) + }] + }') + + # Send to Statsig API + echo "Logging duplicate comment event to Statsig for issue #${ISSUE_NUMBER}" + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://events.statsigapi.net/v1/log_event \ + -H "Content-Type: application/json" \ + -H "STATSIG-API-KEY: ${STATSIG_API_KEY}" \ + -d "$EVENT_PAYLOAD") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 202 ]; then + echo "Successfully logged duplicate comment event for issue #${ISSUE_NUMBER}" + else + echo "Failed to log duplicate comment event for issue #${ISSUE_NUMBER}. HTTP ${HTTP_CODE}: ${BODY}" + fi diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml new file mode 100644 index 0000000..a5339c8 --- /dev/null +++ b/.github/workflows/pr-update-description.yml @@ -0,0 +1,42 @@ +name: 'Release PR Description' + +on: + pull_request: + branches: + - master + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: {} + +jobs: + update-pr: + runs-on: ubuntu-24.04 + if: startsWith(github.head_ref, 'release-') + permissions: + contents: read + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - name: Build packages + run: yarn build + + - name: Update PR description + uses: ./packages/release-action + with: + action: update-pr-description + env: + GITHUB_TOKEN: ${{ secrets.CI_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..02ebc89 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Single entry point for every path that publishes to npm. npm allows one trusted +# publisher per package and validates the entry-point workflow filename, so `cut`, +# `next` and `publish-final` all have to run from this file. +on: + push: + branches: + - master + schedule: + - cron: '28 21 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH + workflow_dispatch: + inputs: + name: + type: choice + description: Release type + default: next + required: true + options: + - next + - patch + - cut + base-ref: + description: Base version + default: develop + required: false + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +env: + HUSKY: 0 + +permissions: {} + +jobs: + release: + name: Release + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write # npm trusted publishing (OIDC) + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'schedule' && github.ref_name || github.event_name == 'workflow_dispatch' && inputs.base-ref || '' }} + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + cache-modules: true + install: true + # Transitional: npm attempts the OIDC exchange first and overrides this token on + # success, so it only takes effect when the exchange fails. That keeps releases + # alive while packages are registered as trusted publishers one by one. Remove + # once every package reports a non-null dist.attestations — see docs/npm-publishing.md. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Node 22 bundles npm 10, trusted publishing needs >= 11.5.1 + - name: Setup npm + run: | + npm install -g npm@^11.15.0 + npm --version + + - uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1 + + - name: Build packages + run: yarn build + + - name: Release + uses: ./packages/release-action + with: + action: ${{ github.event_name == 'push' && 'publish-final' || github.event_name == 'schedule' && 'next' || inputs.name }} + base-ref: ${{ github.event_name == 'schedule' && github.ref_name || github.event_name == 'workflow_dispatch' && inputs.base-ref || '' }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # transitional, see the Setup NodeJS step + GITHUB_TOKEN: ${{ secrets.CI_PAT }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..dd76553 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,20 @@ +name: Close inactive issues +on: + schedule: + - cron: '0 */6 * * *' + +jobs: + close-issues: + runs-on: ubuntu-24.04 + permissions: + issues: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + days-before-issue-stale: 14 + days-before-issue-close: 14 + any-of-labels: 'stat: need more info,stat: waiting response' + stale-issue-label: 'stat: no response' + stale-issue-message: 'This issue has been marked as stale because there has been no further activity in the last 10 days. If the issue remains stale for the next 4 days (a total of 14 days with no activity), then it will be assumed that the question has been resolved and the issue will be automatically closed.' + close-issue-message: 'This issue was closed because it has been inactive for 14 days since being marked as stale.' + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml new file mode 100644 index 0000000..0158eb8 --- /dev/null +++ b/.github/workflows/todo.yml @@ -0,0 +1,58 @@ +name: Create issues from TODOs + +on: + workflow_dispatch: + inputs: + importAll: + default: false + required: false + type: boolean + description: Enable, if you want to import all TODOs. Runs on checked out branch! Only use if you're sure what you are doing. + sha: + default: '' + required: false + type: string + description: 'A commit SHA or range (e.g. "abc123" or "abc123...def456"). Single SHA compares against its parent.' + path: + default: '' + required: false + type: string + description: 'Import TODOs from a specific path (e.g. "apps/meteor/client" or "packages/core-typings/src/IMessage.ts").' + push: + branches: # do not set multiple branches, todos might be added and then get referenced by themselves in case of a merge + - develop + +permissions: + issues: write + repository-projects: read + contents: read + +jobs: + todos: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: scripts/todo-issue + + - name: Create issues from TODOs + run: bun run src/index.ts + working-directory: scripts/todo-issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + GITHUB_SHA: ${{ github.sha }} + BEFORE_SHA: ${{ github.event.before }} + IMPORT_ALL: ${{ inputs.importAll || 'false' }} + SHA_INPUT: ${{ inputs.sha || '' }} + PATH_FILTER: ${{ inputs.path || '' }} diff --git a/.github/workflows/update-version-durability.yml b/.github/workflows/update-version-durability.yml new file mode 100644 index 0000000..2c824c0 --- /dev/null +++ b/.github/workflows/update-version-durability.yml @@ -0,0 +1,47 @@ +name: Update Version Durability + +on: + workflow_dispatch: + workflow_call: + inputs: + LTS_VERSIONS: + required: false + type: string + secrets: + CI_PAT: + required: true + D360_TOKEN: + required: true + D360_USER_ID: + required: true + +permissions: {} + +jobs: + update-versions: + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + + - name: Install dependencies + run: | + cd ./.github/actions/update-version-durability + npm install + + - name: Update Version Durability + uses: ./.github/actions/update-version-durability + with: + LTS_VERSIONS: ${{ inputs.LTS_VERSIONS != '' && inputs.LTS_VERSIONS || vars.LTS_VERSIONS }} + GH_TOKEN: ${{ secrets.CI_PAT }} + D360_TOKEN: ${{ secrets.D360_TOKEN }} + D360_ARTICLE_ID: 800f8d52-409d-478d-b560-f82a2c0eb7fb + D360_USER_ID: ${{ secrets.D360_USER_ID }} + PUBLISH: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ee032f --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build +dist +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +storybook-static +# turbo +.turbo + +# yarn +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +.nvmrc +.idea/ +.exrc +.history +.envrc + +.preview + +*.sublime-workspace + +**/.vim/ + +data/ +!**/tests/data/ +registration.yaml + +storybook-static +development/tempo-data/ + +.env diff --git a/.gitpod/.gitpod.yml b/.gitpod/.gitpod.yml new file mode 100644 index 0000000..b4caac9 --- /dev/null +++ b/.gitpod/.gitpod.yml @@ -0,0 +1,19 @@ +tasks: + - init: | + nvm install $(jq -r .engines.node package.json) && + curl https://install.meteor.com/?release=$( + curl -so- https://raw.githubusercontent.com/RocketChat/Rocket.Chat/develop/apps/meteor/.meteor/release | cut -d@ -f2 + ) | sh && + export PATH=$PATH:/home/gitpod/.meteor && + yarn && + export ROOT_URL=$(gp url 3000) + command: yarn dsv + +ports: + - port: 3000 + visibility: public + onOpen: open-preview + +vscode: + extensions: + - esbenp.prettier-vscode \ No newline at end of file diff --git a/.gitpod/automations.yaml b/.gitpod/automations.yaml new file mode 100644 index 0000000..be8ea9f --- /dev/null +++ b/.gitpod/automations.yaml @@ -0,0 +1,15 @@ +services: + development-server: + name: Rocket.Chat development server + description: Starts the Rocket.Chat development server (yarn dsv) + triggeredBy: + - postDevcontainerStart + - manual + commands: + start: | + yarn + yarn dsv + ready: | + export ROOT_URL=$(gitpod environment port open 3000 --name "Rocket.Chat application" | sed 's|https://||') + echo $ROOT_URL + gitpod environment port open 3000 --name "Rocket.Chat application" diff --git a/.houston/metadata.js b/.houston/metadata.js new file mode 100644 index 0000000..4a66fdd --- /dev/null +++ b/.houston/metadata.js @@ -0,0 +1,74 @@ +const { execSync } = require('child_process'); + +const getMongoVersion = async function({ version, git }) { + try { + const workflows = await git.show([`${ version }:.github/workflows/ci.yml`]); + + const mongoMatch = workflows.match(/compatibleMongoVersions\\": \[([^\]]+)\]/); + if (!mongoMatch) { + return []; + } + + return mongoMatch[1].replace(/["'\\ ]/g, '').split(','); + } catch (e) { + console.error(e); + } + return []; +}; + +const getNodeNpmVersions = async function({ version, git, request }) { + try { + const meteorRelease = await git.show([`${ version }:apps/meteor/.meteor/release`]); + if (!/^METEOR@(\d+\.){1,2}\d/.test(meteorRelease)) { + return {}; + } + + const meteorVersion = meteorRelease.replace(/\n|\s/g, ''); + + const requestResult = await request(`https://raw.githubusercontent.com/meteor/meteor/release/${ meteorVersion }/scripts/build-dev-bundle-common.sh`); + + return { + node_version: requestResult.match(/NODE_VERSION=((?:\d+\.){2}\d+)/m)[1], + npm_version: requestResult.match(/NPM_VERSION=((?:\d+\.){2}\d+)/m)[1], + }; + } catch (e) { + console.error(e); + } + + return {}; +}; + +const getAppsEngineVersion = async function({ version, git }) { + try { + const result = execSync('yarn why @rocket.chat/apps-engine --json'); + + const resultString = result.toString(); + + const match = resultString.match(/"@rocket\.chat\/meteor@workspace:apps\/meteor".*"@rocket\.chat\/apps\-engine@[^#]+#npm:([^"]+)"/); + if (match) { + return match[1]; + } + } catch (e) { + console.error(e); + } + + return undefined; +}; + +module.exports = async function({ version, git, request }) { + const mongo_versions = await getMongoVersion({ version, git }); + const apps_engine_version = await getAppsEngineVersion({ version, git }); + + const { + node_version, + npm_version, + } = await getNodeNpmVersions({ version, git, request }); + + return { + node_version, + npm_version, + apps_engine_version, + mongo_versions, + }; +}; + diff --git a/.houston/templates/prs.hbs b/.houston/templates/prs.hbs new file mode 100644 index 0000000..34a9666 --- /dev/null +++ b/.houston/templates/prs.hbs @@ -0,0 +1,2 @@ +{{> versions}} +{{> _prs}} diff --git a/.houston/templates/versions.hbs b/.houston/templates/versions.hbs new file mode 100644 index 0000000..46da32d --- /dev/null +++ b/.houston/templates/versions.hbs @@ -0,0 +1,16 @@ +{{#if (or release.node_version release.npm_version release.mongo_versions)}} + +### Engine versions +{{#if release.node_version}} +- Node: `{{ release.node_version }}` +{{/if}} +{{#if release.npm_version}} +- NPM: `{{ release.npm_version }}` +{{/if}} +{{#if release.mongo_versions}} +- MongoDB: `{{ join release.mongo_versions ', ' }}` +{{/if}} +{{#if release.apps_engine_version}} +- Apps-Engine: `{{ release.apps_engine_version }}` +{{/if}} +{{/if}} diff --git a/.kodiak.toml b/.kodiak.toml new file mode 100644 index 0000000..378f922 --- /dev/null +++ b/.kodiak.toml @@ -0,0 +1,20 @@ +# .kodiak.toml +version = 1 + +[merge] +method = "squash" +automerge_label = ["stat: ready to merge", "automerge"] +block_on_neutral_required_check_runs = true +blocking_labels = ["stat: needs QA", "Invalid PR Title", "do not merge"] +prioritize_ready_to_merge = true + +[merge.message] +title = "pull_request_title" +body = "empty" +include_coauthors=true + +[merge.automerge_dependencies] +versions = ["minor", "patch"] +usernames = ["dependabot"] +[approve] +auto_approve_usernames = ["dependabot"] diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c42da84 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict = true diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f290d45 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "semi": true, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "jsxSingleQuote": true, + "printWidth": 140, + "quoteProps": "consistent", + "singleQuote": true, + "trailingComma": "all", + "useTabs": true +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..1ba63f1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +deno 2.3.1 diff --git a/.vscode/client.code-snippets b/.vscode/client.code-snippets new file mode 100644 index 0000000..8629bff --- /dev/null +++ b/.vscode/client.code-snippets @@ -0,0 +1,35 @@ +{ + "Storybook stories module for React component": { + "scope": "typescriptreact", + "prefix": "sbmodule", + "body": [ + "import type { Meta, StoryFn } from '@storybook/react';", + "", + "import $1 from './$1';", + "", + "export default {", + "\ttitle: '$2',", + "\tcomponent: $1,", + "} satisfies Meta;", + "", + "export const Example: StoryFn = (args) => <$1 {...args} />;", + ] + }, + "Storybook meta": { + "scope": "typescriptreact", + "prefix": "sbmeta", + "body": [ + "export default {", + "\ttitle: '$1',", + "\tcomponent: $2,", + "} satisfies Meta;" + ] + }, + "Storybook story": { + "scope": "typescriptreact", + "prefix": "sbstory", + "body": [ + "export const $1: StoryFn = (args) => <$2 {...args} />;" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9bc41d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "typescript.tsdk": "./node_modules/typescript/lib", + "cSpell.words": [ + "autotranslate", + "ciphertext", + "Contextualbar", + "fname", + "Gazzodown", + "katex", + "listbox", + "livechat", + "oauthapps", + "omnichannel", + "photoswipe", + "proxify", + "searchbox", + "tmid", + "tshow" + ] +} diff --git a/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch b/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch new file mode 100644 index 0000000..476a246 --- /dev/null +++ b/.yarn/patches/@react-aria-i18n-npm-3.12.5-435edff786.patch @@ -0,0 +1,144 @@ +diff --git a/dist/import.mjs b/dist/import.mjs +index 668b87f9fbf77162f4baa4b02fe3586c3a2c0b26..6b6dc85b39196597504df1a287495a24ce3c0831 100644 +--- a/dist/import.mjs ++++ b/dist/import.mjs +@@ -1,31 +1,7 @@ +-import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.mjs"; +-import {useMessageFormatter as $321bc95feeb923dd$export$ec23bf898b1eed85} from "./useMessageFormatter.mjs"; +-import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.mjs"; +-import {useListFormatter as $33bf17300c498528$export$a2f47a3d2973640} from "./useListFormatter.mjs"; +-import {useDateFormatter as $896ba0a80a8f4d36$export$85fd5fdf27bacc79} from "./useDateFormatter.mjs"; +-import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.mjs"; +-import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.mjs"; +-import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.mjs"; +- +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +- +- +- +- +- +- +- +- +-export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $321bc95feeb923dd$export$ec23bf898b1eed85 as useMessageFormatter, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $33bf17300c498528$export$a2f47a3d2973640 as useListFormatter, $896ba0a80a8f4d36$export$85fd5fdf27bacc79 as useDateFormatter, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter}; +-//# sourceMappingURL=module.js.map ++import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.module.js"; ++import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js"; ++import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.module.js"; ++import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.module.js"; ++import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.module.js"; ++// Slim barrel: removed useDateFormatter (@internationalized/date ~40KB) and useMessageFormatter (@formatjs ~62KB) ++export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter}; +diff --git a/dist/main.js b/dist/main.js +index 62b1f5e2054df93a1f3e851f191c42441cf33699..820e10e6bf275b396a46e26a8e80f46b59bffb5c 100644 +--- a/dist/main.js ++++ b/dist/main.js +@@ -1,45 +1,13 @@ + var $47fa5ec5ff482271$exports = require("./context.main.js"); +-var $c376aa482226bf60$exports = require("./useMessageFormatter.main.js"); +-var $fc53663969a3d00a$exports = require("./useLocalizedStringFormatter.main.js"); +-var $cb6a3e7d490e97a4$exports = require("./useListFormatter.main.js"); +-var $b80c530ff2e20243$exports = require("./useDateFormatter.main.js"); +-var $fea93c5b7c90d9f4$exports = require("./useNumberFormatter.main.js"); +-var $27a5ce66022270ad$exports = require("./useCollator.main.js"); +-var $832d079b867c7223$exports = require("./useFilter.main.js"); +- +- +-function $parcel$export(e, n, v, s) { +- Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true}); +-} +- +-$parcel$export(module.exports, "I18nProvider", () => $47fa5ec5ff482271$exports.I18nProvider); +-$parcel$export(module.exports, "useLocale", () => $47fa5ec5ff482271$exports.useLocale); +-$parcel$export(module.exports, "useMessageFormatter", () => $c376aa482226bf60$exports.useMessageFormatter); +-$parcel$export(module.exports, "useLocalizedStringFormatter", () => $fc53663969a3d00a$exports.useLocalizedStringFormatter); +-$parcel$export(module.exports, "useLocalizedStringDictionary", () => $fc53663969a3d00a$exports.useLocalizedStringDictionary); +-$parcel$export(module.exports, "useListFormatter", () => $cb6a3e7d490e97a4$exports.useListFormatter); +-$parcel$export(module.exports, "useDateFormatter", () => $b80c530ff2e20243$exports.useDateFormatter); +-$parcel$export(module.exports, "useNumberFormatter", () => $fea93c5b7c90d9f4$exports.useNumberFormatter); +-$parcel$export(module.exports, "useCollator", () => $27a5ce66022270ad$exports.useCollator); +-$parcel$export(module.exports, "useFilter", () => $832d079b867c7223$exports.useFilter); +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +- +- +- +- +- +- +- +- +-//# sourceMappingURL=main.js.map ++var $fc53663969a3d00a$exports = require("./useLocalizedStringFormatter.main.js"); ++var $8c5e0e4c6ca34e89$exports = require("./useNumberFormatter.main.js"); ++var $22f23e74d8a5e30d$exports = require("./useCollator.main.js"); ++var $7d0e351af20babe4$exports = require("./useFilter.main.js"); ++// Slim barrel: removed useDateFormatter, useMessageFormatter, useListFormatter ++exports.I18nProvider = $47fa5ec5ff482271$exports.I18nProvider; ++exports.useLocale = $47fa5ec5ff482271$exports.useLocale; ++exports.useLocalizedStringFormatter = $fc53663969a3d00a$exports.useLocalizedStringFormatter; ++exports.useLocalizedStringDictionary = $fc53663969a3d00a$exports.useLocalizedStringDictionary; ++exports.useNumberFormatter = $8c5e0e4c6ca34e89$exports.useNumberFormatter; ++exports.useCollator = $22f23e74d8a5e30d$exports.useCollator; ++exports.useFilter = $7d0e351af20babe4$exports.useFilter; +diff --git a/dist/module.js b/dist/module.js +index fb8c9b0ce2c72d49866c95c3fd6e73cd26149775..6b6dc85b39196597504df1a287495a24ce3c0831 100644 +--- a/dist/module.js ++++ b/dist/module.js +@@ -1,31 +1,7 @@ + import {I18nProvider as $18f2051aff69b9bf$export$a54013f0d02a8f82, useLocale as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7} from "./context.module.js"; +-import {useMessageFormatter as $321bc95feeb923dd$export$ec23bf898b1eed85} from "./useMessageFormatter.module.js"; +-import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js"; +-import {useListFormatter as $33bf17300c498528$export$a2f47a3d2973640} from "./useListFormatter.module.js"; +-import {useDateFormatter as $896ba0a80a8f4d36$export$85fd5fdf27bacc79} from "./useDateFormatter.module.js"; ++import {useLocalizedStringDictionary as $fca6afa0e843324b$export$87b761675e8eaa10, useLocalizedStringFormatter as $fca6afa0e843324b$export$f12b703ca79dfbb1} from "./useLocalizedStringFormatter.module.js"; + import {useNumberFormatter as $a916eb452884faea$export$b7a616150fdb9f44} from "./useNumberFormatter.module.js"; + import {useCollator as $325a3faab7a68acd$export$a16aca283550c30d} from "./useCollator.module.js"; + import {useFilter as $bb77f239b46e8c72$export$3274cf84b703fff} from "./useFilter.module.js"; +- +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +- +- +- +- +- +- +- +- +-export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $321bc95feeb923dd$export$ec23bf898b1eed85 as useMessageFormatter, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $33bf17300c498528$export$a2f47a3d2973640 as useListFormatter, $896ba0a80a8f4d36$export$85fd5fdf27bacc79 as useDateFormatter, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter}; +-//# sourceMappingURL=module.js.map ++// Slim barrel: removed useDateFormatter (@internationalized/date ~40KB) and useMessageFormatter (@formatjs ~62KB) ++export {$18f2051aff69b9bf$export$a54013f0d02a8f82 as I18nProvider, $18f2051aff69b9bf$export$43bb16f9c6d9e3f7 as useLocale, $fca6afa0e843324b$export$f12b703ca79dfbb1 as useLocalizedStringFormatter, $fca6afa0e843324b$export$87b761675e8eaa10 as useLocalizedStringDictionary, $a916eb452884faea$export$b7a616150fdb9f44 as useNumberFormatter, $325a3faab7a68acd$export$a16aca283550c30d as useCollator, $bb77f239b46e8c72$export$3274cf84b703fff as useFilter}; diff --git a/.yarn/patches/@react-pdf-layout-npm-4.6.1-b12fdb71f4.patch b/.yarn/patches/@react-pdf-layout-npm-4.6.1-b12fdb71f4.patch new file mode 100644 index 0000000..740c148 --- /dev/null +++ b/.yarn/patches/@react-pdf-layout-npm-4.6.1-b12fdb71f4.patch @@ -0,0 +1,24 @@ +diff --git a/lib/index.js b/lib/index.js +index b7ea5e30fddb9bdef0034c00f28bbd4f0dee584a..47f7717632bd55d3c42ebb39f1ef5a23c14cfa3a 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -2,8 +2,8 @@ import { upperFirst, capitalize, parseFloat as parseFloat$1, without, pick, comp + import * as P from '@react-pdf/primitives'; + import resolveStyle, { transformColor, flatten } from '@react-pdf/stylesheet'; + import layoutEngine, { fontSubstitution, wordHyphenation, scriptItemizer, textDecoration, justification, linebreaker, bidi, fromFragments } from '@react-pdf/textkit'; +-import * as Yoga from 'yoga-layout/load'; +-import { loadYoga as loadYoga$1 } from 'yoga-layout/load'; ++import * as Yoga from 'yoga-layout/dist/src/load.js'; ++import { loadYoga as loadYoga$1 } from 'yoga-layout/dist/src/load.js'; + import emojiRegex from 'emoji-regex-xs'; + import resolveImage from '@react-pdf/image'; + +@@ -2998,7 +2998,7 @@ const splitPage = (page, pageNumber, fontStore, yoga) => { + const dynamicPage = resolveDynamicPage({ pageNumber }, page, fontStore, yoga); + const height = page.style.height; + const [currentChilds, nextChilds] = splitNodes(wrapArea, contentArea, dynamicPage.children); +- const relayout = (node) => ++ const relayout = (node) => + // @ts-expect-error rework pagination + relayoutPage(node, fontStore, yoga); + const currentBox = { ...page.box, height }; diff --git a/.yarn/patches/@sematext-gc-stats-npm-1.5.9-01e77be4d0.patch b/.yarn/patches/@sematext-gc-stats-npm-1.5.9-01e77be4d0.patch new file mode 100644 index 0000000..c411f8f --- /dev/null +++ b/.yarn/patches/@sematext-gc-stats-npm-1.5.9-01e77be4d0.patch @@ -0,0 +1,12 @@ +diff --git a/package.json b/package.json +index cad8d557e25394d01bc4e95af2f4eb6f11de61ce..ef669ff1a8595e0a347c78055b2f3b9bac035b73 100644 +--- a/package.json ++++ b/package.json +@@ -20,7 +20,6 @@ + }, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.10", +- "depcheck": "^1.4.3", + "nan": "^2.17.0" + }, + "gypfile": true, diff --git a/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch b/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch new file mode 100644 index 0000000..f0d5b5d --- /dev/null +++ b/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch @@ -0,0 +1,73 @@ +diff --git a/lib/@types/index.d.ts b/lib/@types/index.d.ts +index f71a82f458c1432202be8d4585fc70ba94bee4a4..4a6fee9f2aeac1a926d591aabab19f927d1bc3af 100644 +--- a/lib/@types/index.d.ts ++++ b/lib/@types/index.d.ts +@@ -40,6 +40,7 @@ declare namespace Srf { + stackTime: string; + calledNumber: string; + callingNumber: string; ++ callingName: string; + raw: string; + get(name: string): string; + has(name: string): boolean; +@@ -101,7 +102,7 @@ declare namespace Srf { + local: { uri: string; sdp: string; }; + remote: { uri: string; sdp: string; }; + req: SrfRequest; +- destroy(opts?: { headers: Record; }, callback?: (err: any, msg: SrfRequest) => void): void; ++ destroy(opts?: { headers: Record; }, callback?: (err: any, msg: SrfRequest) => void): Promise; + modify(sdp: string, opts?: { noAck: boolean }): Promise; + modify(opts: { noAck: boolean }): Promise; + modify(sdp: string, opts?: { noAck: boolean }, callback?: (err: any, msg: SrfResponse) => void): void; +@@ -119,12 +120,12 @@ declare namespace Srf { + on(messageType: "modify", callback: (req: SrfRequest, res: SrfResponse) => void): void; + once(messageType: string, callback: (msg: SrfResponse) => void): void; + listeners(messageType: string): any[]; +- request(opts?: SrfRequest): Promise; +- request(opts: SrfRequest, callback?: (err: any, msg: SrfResponse) => void): void; ++ request(opts?: Partial): Promise; ++ request(opts: Partial, callback?: (err: any, msg: SrfResponse) => void): void; + } + + export interface CreateUASOptions { +- localSdp: string; ++ localSdp: string | (() => string | Promise); + headers?: SipMessageHeaders; + } + +@@ -135,6 +136,8 @@ declare namespace Srf { + localSdp?: string; + proxy?: string; + auth?: { username: string; password: string; }; ++ callingName?: string; ++ callingNumber?: string; + } + + export interface CreateB2BUAOptions { +@@ -155,7 +158,7 @@ declare class Srf extends EventEmitter { + constructor(); + constructor(tags: string | string[]); + connect(config?: Srf.SrfConfig): Promise; +- disconnect(): void; ++ disconnect(socket?: Socket): void; + use(callback: (req: Srf.SrfRequest, res: Srf.SrfResponse, next: Function) => void): void; + use(messageType: string, callback: (req: Srf.SrfRequest, res: Srf.SrfResponse, next: Function) => void): void; + invite(callback: (req: Srf.SrfRequest, res: Srf.SrfResponse) => void): void; +@@ -164,12 +167,12 @@ declare class Srf extends EventEmitter { + proxyRequest(req: Srf.SrfRequest, destination: string | string[], opts?: Srf.ProxyRequestOptions, callback?: (err: any, results: string) => void): void; + createUAS(req: Srf.SrfRequest, res: Srf.SrfResponse, opts: Srf.CreateUASOptions): Promise; + createUAS(req: Srf.SrfRequest, res: Srf.SrfResponse, opts: Srf.CreateUASOptions, callback?: (err: any, dialog: Srf.Dialog) => void): this; +- createUAC(uri: string | Srf.CreateUACOptions, opts?: Srf.CreateUACOptions, progressCallbacks?: { cbRequest?: (req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Srf.SrfResponse) => void; }): Promise; +- createUAC(uri: string | Srf.CreateUACOptions, opts?: Srf.CreateUACOptions, progressCallbacks?: { cbRequest?: (req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Srf.SrfResponse) => void; }, callback?: (err: any, dialog: Srf.Dialog) => void): this; +- createB2BUA(req: Srf.SrfRequest, res: Srf.SrfResponse, uri: string, opts: Srf.CreateB2BUAOptions, progressCallbacks?: { cbRequest?: (req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Response) => void; cbFinalizedUac?: (uac: Srf.Dialog) => void; }): Promise<{ uas: Srf.Dialog; uac: Srf.Dialog }>; +- createB2BUA(req: Srf.SrfRequest, res: Srf.SrfResponse, uri: string, opts: Srf.CreateB2BUAOptions, progressCallbacks?: { cbRequest?: (req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Response) => void; cbFinalizedUac?: (uac: Srf.Dialog) => void; }, callback?: (err: any, dialog: Srf.Dialog) => void): this; ++ createUAC(uri: string | Srf.CreateUACOptions, opts?: Srf.CreateUACOptions, progressCallbacks?: { cbRequest?: (error: unknown, req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Srf.SrfResponse) => void; }): Promise; ++ createUAC(uri: string | Srf.CreateUACOptions, opts?: Srf.CreateUACOptions, progressCallbacks?: { cbRequest?: (error: unknown, req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Srf.SrfResponse) => void; }, callback?: (err: any, dialog: Srf.Dialog) => void): this; ++ createB2BUA(req: Srf.SrfRequest, res: Srf.SrfResponse, uri: string, opts: Srf.CreateB2BUAOptions, progressCallbacks?: { cbRequest?: (error: unknown, req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Response) => void; cbFinalizedUac?: (uac: Srf.Dialog) => void; }): Promise<{ uas: Srf.Dialog; uac: Srf.Dialog }>; ++ createB2BUA(req: Srf.SrfRequest, res: Srf.SrfResponse, uri: string, opts: Srf.CreateB2BUAOptions, progressCallbacks?: { cbRequest?: (error: unknown, req: Srf.SrfRequest) => void; cbProvisional?: (provisionalRes: Response) => void; cbFinalizedUac?: (uac: Srf.Dialog) => void; }, callback?: (err: any, dialog: Srf.Dialog) => void): this; + on(event: 'connect', listener: (err: Error, hostPort: string) => void): this; +- on(event: 'error', listener: (err: Error) => void): this; ++ on(event: 'error', listener: (err: Error, socket?: Socket) => void): this; + on(event: 'disconnect', listener: () => void): this; + on(event: 'message', listener: (req: Srf.SrfRequest, res: Srf.SrfResponse) => void): this; + on(event: 'request', listener: (req: Srf.SrfRequest, res: Srf.SrfResponse) => void): this; diff --git a/.yarn/patches/eslint-plugin-react-npm-7.37.5-d03f6b6543.patch b/.yarn/patches/eslint-plugin-react-npm-7.37.5-d03f6b6543.patch new file mode 100644 index 0000000..3155008 --- /dev/null +++ b/.yarn/patches/eslint-plugin-react-npm-7.37.5-d03f6b6543.patch @@ -0,0 +1,39 @@ +diff --git a/lib/rules/forward-ref-uses-ref.js b/lib/rules/forward-ref-uses-ref.js +index 3a0b7de4c951e7ea92a9de7bac924ba95d643342..3fb493ec2be2589a6c0d3da8e060520d71cbcb6c 100644 +--- a/lib/rules/forward-ref-uses-ref.js ++++ b/lib/rules/forward-ref-uses-ref.js +@@ -57,7 +57,7 @@ module.exports = { + }, + + create(context) { +- const sourceCode = context.getSourceCode(); ++ const sourceCode = context.getSourceCode ? context.getSourceCode() : context.sourceCode; + + return { + 'FunctionExpression, ArrowFunctionExpression'(node) { +diff --git a/lib/rules/jsx-filename-extension.js b/lib/rules/jsx-filename-extension.js +index 4a2cfeede66b68255ca48bc38c0eb2f1537a785a..437f6cdc81b3a788c0c25e741956ed1832f93f79 100644 +--- a/lib/rules/jsx-filename-extension.js ++++ b/lib/rules/jsx-filename-extension.js +@@ -61,7 +61,7 @@ module.exports = { + }, + + create(context) { +- const filename = context.getFilename(); ++ const filename = context.getFilename ? context.getFilename() : context.filename; + + let jsxNode; + +diff --git a/lib/util/version.js b/lib/util/version.js +index c9dc683b7d149c5a3a941bdcbd32f657facdfbf6..3ad3df59001608bf75bd3da89557680fc944d84c 100644 +--- a/lib/util/version.js ++++ b/lib/util/version.js +@@ -28,7 +28,7 @@ function resetDetectedVersion() { + + function resolveBasedir(contextOrFilename) { + if (contextOrFilename) { +- const filename = typeof contextOrFilename === 'string' ? contextOrFilename : contextOrFilename.getFilename(); ++ const filename = typeof contextOrFilename === 'string' ? contextOrFilename : (contextOrFilename.getFilename ? contextOrFilename.getFilename() : contextOrFilename.filename); + const dirname = path.dirname(filename); + try { + if (fs.statSync(filename).isFile()) { diff --git a/.yarn/patches/file-type-npm-16.5.4-d653e66a40.patch b/.yarn/patches/file-type-npm-16.5.4-d653e66a40.patch new file mode 100644 index 0000000..a712347 --- /dev/null +++ b/.yarn/patches/file-type-npm-16.5.4-d653e66a40.patch @@ -0,0 +1,23 @@ +diff --git a/core.js b/core.js +index d653e66a40..0f5d672dc4 100644 +--- a/core.js ++++ b/core.js +@@ -1074,6 +1074,7 @@ async function _fromTokenizer(tokenizer) { + await tokenizer.ignore(30); + // Search for header should be in first 1KB of file. + while (tokenizer.position + 24 < tokenizer.fileInfo.size) { ++ const previousPosition = tokenizer.position; + const header = await readHeader(); + let payload = header.size - 24; + if (_check(header.id, [0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65])) { +@@ -1101,6 +1102,10 @@ async function _fromTokenizer(tokenizer) { + } + + await tokenizer.ignore(payload); ++ ++ if (tokenizer.position <= previousPosition) { ++ break; ++ } + } + + // Default to ASF generic extension diff --git a/.yarn/patches/image-size-npm-1.2.1-e285f3c080.patch b/.yarn/patches/image-size-npm-1.2.1-e285f3c080.patch new file mode 100644 index 0000000..f288017 --- /dev/null +++ b/.yarn/patches/image-size-npm-1.2.1-e285f3c080.patch @@ -0,0 +1,31 @@ +diff --git a/dist/types/icns.js b/dist/types/icns.js +index f2bfafef3723cb423b110304815e56e3f97f81e0..87e0b73af9d789c3b9b5fb0c303fab35b62d6462 100644 +--- a/dist/types/icns.js ++++ b/dist/types/icns.js +@@ -92,6 +92,9 @@ exports.ICNS = { + }; + while (imageOffset < fileLength && imageOffset < inputLength) { + imageHeader = readImageHeader(input, imageOffset); ++ if (imageHeader[1] <= 0) { ++ break; ++ } + imageSize = getImageSize(imageHeader[0]); + imageOffset += imageHeader[1]; + result.images.push(imageSize); +diff --git a/dist/types/jxl.js b/dist/types/jxl.js +index f557b0aea7e679d2cb84d9915e957dd2254eba97..3f551b98cb7160dff283dd7ebbc9280505d3ac3c 100644 +--- a/dist/types/jxl.js ++++ b/dist/types/jxl.js +@@ -24,7 +24,11 @@ function extractPartialStreams(input) { + if (!jxlpBox) + break; + partialStreams.push(input.slice(jxlpBox.offset + 12, jxlpBox.offset + jxlpBox.size)); +- offset = jxlpBox.offset + jxlpBox.size; ++ const nextOffset = jxlpBox.offset + jxlpBox.size; ++ if (nextOffset <= offset) { ++ break; ++ } ++ offset = nextOffset; + } + return partialStreams; + } diff --git a/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch b/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch new file mode 100644 index 0000000..bf3c527 --- /dev/null +++ b/.yarn/patches/react-aria-npm-3.37.0-83959bd2fa.patch @@ -0,0 +1,316 @@ +diff --git a/dist/import.mjs b/dist/import.mjs +index a13ba8b37e27230ec01d4c31140127912208666c..4208f8b7e8c3ff25b4eff8cce9017e8cfb8d3b4e 100644 +--- a/dist/import.mjs ++++ b/dist/import.mjs +@@ -1,50 +1,14 @@ +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +-export {useBreadcrumbItem, useBreadcrumbs} from '@react-aria/breadcrumbs'; +-export {useButton, useToggleButton, useToggleButtonGroup, useToggleButtonGroupItem} from '@react-aria/button'; +-export {useCalendar, useCalendarCell, useCalendarGrid, useRangeCalendar} from '@react-aria/calendar'; +-export {useCheckbox, useCheckboxGroup, useCheckboxGroupItem} from '@react-aria/checkbox'; +-export {useColorArea, useColorChannelField, useColorField, useColorSlider, useColorSwatch, useColorWheel} from '@react-aria/color'; +-export {useComboBox} from '@react-aria/combobox'; +-export {useDateField, useDatePicker, useDateRangePicker, useDateSegment, useTimeField} from '@react-aria/datepicker'; +-export {useDialog} from '@react-aria/dialog'; +-export {useDisclosure} from '@react-aria/disclosure'; +-export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd'; ++// Slim barrel: only exports actually used ++export {useButton} from '@react-aria/button'; + export {FocusRing, FocusScope, useFocusManager, useFocusRing, useFocusable} from '@react-aria/focus'; +-export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; ++export {useNumberFormatter} from '@react-aria/i18n'; + export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress} from '@react-aria/interactions'; +-export {useField, useLabel} from '@react-aria/label'; +-export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; +-export {useLink} from '@react-aria/link'; + export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox'; +-export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; +-export {useMeter} from '@react-aria/meter'; +-export {useNumberField} from '@react-aria/numberfield'; +-export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll} from '@react-aria/overlays'; +-export {useProgressBar} from '@react-aria/progress'; +-export {useRadio, useRadioGroup} from '@react-aria/radio'; +-export {useSearchField} from '@react-aria/searchfield'; +-export {HiddenSelect, useHiddenSelect, useSelect} from '@react-aria/select'; +-export {ListKeyboardDelegate} from '@react-aria/selection'; ++export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; ++export {DismissButton, Overlay, useOverlayTrigger, usePopover} from '@react-aria/overlays'; ++export {HiddenSelect, useSelect} from '@react-aria/select'; + export {useSeparator} from '@react-aria/separator'; +-export {SSRProvider, useIsSSR} from '@react-aria/ssr'; + export {useSlider, useSliderThumb} from '@react-aria/slider'; +-export {useSwitch} from '@react-aria/switch'; +-export {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table'; +-export {useTab, useTabList, useTabPanel} from '@react-aria/tabs'; +-export {useTag, useTagGroup} from '@react-aria/tag'; +-export {useTextField} from '@react-aria/textfield'; +-export {useTooltip, useTooltipTrigger} from '@react-aria/tooltip'; +-export {chain, mergeProps, useId, useObjectRef, RouterProvider} from '@react-aria/utils'; ++export {chain, mergeProps, useId, useObjectRef} from '@react-aria/utils'; + export {VisuallyHidden, useVisuallyHidden} from '@react-aria/visually-hidden'; +- ++export {useDialog} from '@react-aria/dialog'; +diff --git a/dist/main.js b/dist/main.js +index b6a2ce3a780a09aeb416d7330ffcf6fd0b36b218..c5e9d3e7ba3c8f1a4db5a8e2f7b9c4d6a1e3f2b5 100644 +--- a/dist/main.js ++++ b/dist/main.js +@@ -1,172 +1,15 @@ + "use strict"; +- + exports.__esModule = true; +-exports.useRadioGroup = exports.useRadio = exports.useProgressBar = exports.usePreventScroll = exports.usePress = exports.usePopover = exports.useOverlayTrigger = exports.useOverlayPosition = exports.useOverlay = exports.useOption = exports.useObjectRef = exports.useNumberFormatter = exports.useNumberField = exports.useMove = exports.useModalProvider = exports.useModalOverlay = exports.useModal = exports.useMeter = exports.useMessageFormatter = exports.useMenuTrigger = exports.useMenuSection = exports.useMenuItem = exports.useMenu = exports.useLongPress = exports.useLocalizedStringFormatter = exports.useLocale = exports.useListBoxSection = exports.useListBox = exports.useLink = exports.useLabel = exports.useKeyboard = exports.useIsSSR = exports.useInteractOutside = exports.useId = exports.useHover = exports.useHiddenSelect = exports.useGridListSelectionCheckbox = exports.useGridListItem = exports.useGridList = exports.useFocusable = exports.useFocusWithin = exports.useFocusVisible = exports.useFocusRing = exports.useFocusManager = exports.useFocus = exports.useFilter = exports.useField = exports.useDroppableItem = exports.useDroppableCollection = exports.useDropIndicator = exports.useDrop = exports.useDraggableItem = exports.useDraggableCollection = exports.useDrag = exports.useDisclosure = exports.useDialog = exports.useDateSegment = exports.useDateRangePicker = exports.useDatePicker = exports.useDateFormatter = exports.useDateField = exports.useComboBox = exports.useColorWheel = exports.useColorSwatch = exports.useColorSlider = exports.useColorField = exports.useColorChannelField = exports.useColorArea = exports.useCollator = exports.useClipboard = exports.useCheckboxGroupItem = exports.useCheckboxGroup = exports.useCheckbox = exports.useCalendarGrid = exports.useCalendarCell = exports.useCalendar = exports.useButton = exports.useBreadcrumbs = exports.useBreadcrumbItem = exports.mergeProps = exports.isTextDropItem = exports.isFileDropItem = exports.isDirectoryDropItem = exports.chain = exports.VisuallyHidden = exports.SSRProvider = exports.RouterProvider = exports.OverlayProvider = exports.OverlayContainer = exports.Overlay = exports.ModalProvider = exports.ListKeyboardDelegate = exports.ListDropTargetDelegate = exports.I18nProvider = exports.HiddenSelect = exports.FocusScope = exports.FocusRing = exports.DragPreview = exports.DismissButton = exports.DIRECTORY_DRAG_TYPE = void 0; +-exports.useVisuallyHidden = exports.useTooltipTrigger = exports.useTooltip = exports.useToggleButtonGroupItem = exports.useToggleButtonGroup = exports.useToggleButton = exports.useTimeField = exports.useTextField = exports.useTagGroup = exports.useTag = exports.useTableSelectionCheckbox = exports.useTableSelectAllCheckbox = exports.useTableRowGroup = exports.useTableRow = exports.useTableHeaderRow = exports.useTableColumnResize = exports.useTableColumnHeader = exports.useTableCell = exports.useTable = exports.useTabPanel = exports.useTabList = exports.useTab = exports.useSwitch = exports.useSubmenuTrigger = exports.useSliderThumb = exports.useSlider = exports.useSeparator = exports.useSelect = exports.useSearchField = exports.useRangeCalendar = void 0; +-var _breadcrumbs = require("@react-aria/breadcrumbs"); +-exports.useBreadcrumbItem = _breadcrumbs.useBreadcrumbItem; +-exports.useBreadcrumbs = _breadcrumbs.useBreadcrumbs; +-var _button = require("@react-aria/button"); +-exports.useButton = _button.useButton; +-exports.useToggleButton = _button.useToggleButton; +-exports.useToggleButtonGroup = _button.useToggleButtonGroup; +-exports.useToggleButtonGroupItem = _button.useToggleButtonGroupItem; +-var _calendar = require("@react-aria/calendar"); +-exports.useCalendar = _calendar.useCalendar; +-exports.useCalendarCell = _calendar.useCalendarCell; +-exports.useCalendarGrid = _calendar.useCalendarGrid; +-exports.useRangeCalendar = _calendar.useRangeCalendar; +-var _checkbox = require("@react-aria/checkbox"); +-exports.useCheckbox = _checkbox.useCheckbox; +-exports.useCheckboxGroup = _checkbox.useCheckboxGroup; +-exports.useCheckboxGroupItem = _checkbox.useCheckboxGroupItem; +-var _color = require("@react-aria/color"); +-exports.useColorArea = _color.useColorArea; +-exports.useColorChannelField = _color.useColorChannelField; +-exports.useColorField = _color.useColorField; +-exports.useColorSlider = _color.useColorSlider; +-exports.useColorSwatch = _color.useColorSwatch; +-exports.useColorWheel = _color.useColorWheel; +-var _combobox = require("@react-aria/combobox"); +-exports.useComboBox = _combobox.useComboBox; +-var _datepicker = require("@react-aria/datepicker"); +-exports.useDateField = _datepicker.useDateField; +-exports.useDatePicker = _datepicker.useDatePicker; +-exports.useDateRangePicker = _datepicker.useDateRangePicker; +-exports.useDateSegment = _datepicker.useDateSegment; +-exports.useTimeField = _datepicker.useTimeField; +-var _dialog = require("@react-aria/dialog"); +-exports.useDialog = _dialog.useDialog; +-var _disclosure = require("@react-aria/disclosure"); +-exports.useDisclosure = _disclosure.useDisclosure; +-var _dnd = require("@react-aria/dnd"); +-exports.useDrag = _dnd.useDrag; +-exports.useDrop = _dnd.useDrop; +-exports.useDraggableCollection = _dnd.useDraggableCollection; +-exports.useDroppableCollection = _dnd.useDroppableCollection; +-exports.useDroppableItem = _dnd.useDroppableItem; +-exports.useDropIndicator = _dnd.useDropIndicator; +-exports.useDraggableItem = _dnd.useDraggableItem; +-exports.useClipboard = _dnd.useClipboard; +-exports.DragPreview = _dnd.DragPreview; +-exports.ListDropTargetDelegate = _dnd.ListDropTargetDelegate; +-exports.DIRECTORY_DRAG_TYPE = _dnd.DIRECTORY_DRAG_TYPE; +-exports.isDirectoryDropItem = _dnd.isDirectoryDropItem; +-exports.isFileDropItem = _dnd.isFileDropItem; +-exports.isTextDropItem = _dnd.isTextDropItem; +-var _focus = require("@react-aria/focus"); +-exports.FocusRing = _focus.FocusRing; +-exports.FocusScope = _focus.FocusScope; +-exports.useFocusManager = _focus.useFocusManager; +-exports.useFocusRing = _focus.useFocusRing; +-exports.useFocusable = _focus.useFocusable; +-var _i18n = require("@react-aria/i18n"); +-exports.I18nProvider = _i18n.I18nProvider; +-exports.useCollator = _i18n.useCollator; +-exports.useDateFormatter = _i18n.useDateFormatter; +-exports.useFilter = _i18n.useFilter; +-exports.useLocale = _i18n.useLocale; +-exports.useLocalizedStringFormatter = _i18n.useLocalizedStringFormatter; +-exports.useMessageFormatter = _i18n.useMessageFormatter; +-exports.useNumberFormatter = _i18n.useNumberFormatter; +-var _interactions = require("@react-aria/interactions"); +-exports.useFocus = _interactions.useFocus; +-exports.useFocusVisible = _interactions.useFocusVisible; +-exports.useFocusWithin = _interactions.useFocusWithin; +-exports.useHover = _interactions.useHover; +-exports.useInteractOutside = _interactions.useInteractOutside; +-exports.useKeyboard = _interactions.useKeyboard; +-exports.useMove = _interactions.useMove; +-exports.usePress = _interactions.usePress; +-exports.useLongPress = _interactions.useLongPress; +-var _label = require("@react-aria/label"); +-exports.useField = _label.useField; +-exports.useLabel = _label.useLabel; +-var _gridlist = require("@react-aria/gridlist"); +-exports.useGridList = _gridlist.useGridList; +-exports.useGridListItem = _gridlist.useGridListItem; +-exports.useGridListSelectionCheckbox = _gridlist.useGridListSelectionCheckbox; +-var _link = require("@react-aria/link"); +-exports.useLink = _link.useLink; +-var _listbox = require("@react-aria/listbox"); +-exports.useListBox = _listbox.useListBox; +-exports.useListBoxSection = _listbox.useListBoxSection; +-exports.useOption = _listbox.useOption; +-var _menu = require("@react-aria/menu"); +-exports.useMenu = _menu.useMenu; +-exports.useMenuItem = _menu.useMenuItem; +-exports.useMenuSection = _menu.useMenuSection; +-exports.useMenuTrigger = _menu.useMenuTrigger; +-exports.useSubmenuTrigger = _menu.useSubmenuTrigger; +-var _meter = require("@react-aria/meter"); +-exports.useMeter = _meter.useMeter; +-var _numberfield = require("@react-aria/numberfield"); +-exports.useNumberField = _numberfield.useNumberField; +-var _overlays = require("@react-aria/overlays"); +-exports.DismissButton = _overlays.DismissButton; +-exports.ModalProvider = _overlays.ModalProvider; +-exports.Overlay = _overlays.Overlay; +-exports.OverlayContainer = _overlays.OverlayContainer; +-exports.OverlayProvider = _overlays.OverlayProvider; +-exports.useModal = _overlays.useModal; +-exports.useModalOverlay = _overlays.useModalOverlay; +-exports.useModalProvider = _overlays.useModalProvider; +-exports.useOverlay = _overlays.useOverlay; +-exports.useOverlayPosition = _overlays.useOverlayPosition; +-exports.useOverlayTrigger = _overlays.useOverlayTrigger; +-exports.usePopover = _overlays.usePopover; +-exports.usePreventScroll = _overlays.usePreventScroll; +-var _progress = require("@react-aria/progress"); +-exports.useProgressBar = _progress.useProgressBar; +-var _radio = require("@react-aria/radio"); +-exports.useRadio = _radio.useRadio; +-exports.useRadioGroup = _radio.useRadioGroup; +-var _searchfield = require("@react-aria/searchfield"); +-exports.useSearchField = _searchfield.useSearchField; +-var _select = require("@react-aria/select"); +-exports.HiddenSelect = _select.HiddenSelect; +-exports.useHiddenSelect = _select.useHiddenSelect; +-exports.useSelect = _select.useSelect; +-var _selection = require("@react-aria/selection"); +-exports.ListKeyboardDelegate = _selection.ListKeyboardDelegate; +-var _separator = require("@react-aria/separator"); +-exports.useSeparator = _separator.useSeparator; +-var _ssr = require("@react-aria/ssr"); +-exports.SSRProvider = _ssr.SSRProvider; +-exports.useIsSSR = _ssr.useIsSSR; +-var _slider = require("@react-aria/slider"); +-exports.useSlider = _slider.useSlider; +-exports.useSliderThumb = _slider.useSliderThumb; +-var _switch = require("@react-aria/switch"); +-exports.useSwitch = _switch.useSwitch; +-var _table = require("@react-aria/table"); +-exports.useTable = _table.useTable; +-exports.useTableCell = _table.useTableCell; +-exports.useTableColumnHeader = _table.useTableColumnHeader; +-exports.useTableColumnResize = _table.useTableColumnResize; +-exports.useTableHeaderRow = _table.useTableHeaderRow; +-exports.useTableRow = _table.useTableRow; +-exports.useTableRowGroup = _table.useTableRowGroup; +-exports.useTableSelectAllCheckbox = _table.useTableSelectAllCheckbox; +-exports.useTableSelectionCheckbox = _table.useTableSelectionCheckbox; +-var _tabs = require("@react-aria/tabs"); +-exports.useTab = _tabs.useTab; +-exports.useTabList = _tabs.useTabList; +-exports.useTabPanel = _tabs.useTabPanel; +-var _tag = require("@react-aria/tag"); +-exports.useTag = _tag.useTag; +-exports.useTagGroup = _tag.useTagGroup; +-var _textfield = require("@react-aria/textfield"); +-exports.useTextField = _textfield.useTextField; +-var _tooltip = require("@react-aria/tooltip"); +-exports.useTooltip = _tooltip.useTooltip; +-exports.useTooltipTrigger = _tooltip.useTooltipTrigger; +-var _utils = require("@react-aria/utils"); +-exports.chain = _utils.chain; +-exports.mergeProps = _utils.mergeProps; +-exports.useId = _utils.useId; +-exports.useObjectRef = _utils.useObjectRef; +-exports.RouterProvider = _utils.RouterProvider; +-var _visuallyHidden = require("@react-aria/visually-hidden"); +-exports.VisuallyHidden = _visuallyHidden.VisuallyHidden; +-exports.useVisuallyHidden = _visuallyHidden.useVisuallyHidden; ++var _button = require("@react-aria/button"); exports.useButton = _button.useButton; ++var _focus = require("@react-aria/focus"); exports.FocusRing = _focus.FocusRing; exports.FocusScope = _focus.FocusScope; exports.useFocusManager = _focus.useFocusManager; exports.useFocusRing = _focus.useFocusRing; exports.useFocusable = _focus.useFocusable; ++var _i18n = require("@react-aria/i18n"); exports.useNumberFormatter = _i18n.useNumberFormatter; ++var _interactions = require("@react-aria/interactions"); exports.useFocus = _interactions.useFocus; exports.useFocusVisible = _interactions.useFocusVisible; exports.useFocusWithin = _interactions.useFocusWithin; exports.useHover = _interactions.useHover; exports.useInteractOutside = _interactions.useInteractOutside; exports.useKeyboard = _interactions.useKeyboard; exports.useMove = _interactions.useMove; exports.usePress = _interactions.usePress; exports.useLongPress = _interactions.useLongPress; ++var _listbox = require("@react-aria/listbox"); exports.useListBox = _listbox.useListBox; exports.useListBoxSection = _listbox.useListBoxSection; exports.useOption = _listbox.useOption; ++var _menu = require("@react-aria/menu"); exports.useMenu = _menu.useMenu; exports.useMenuItem = _menu.useMenuItem; exports.useMenuSection = _menu.useMenuSection; exports.useMenuTrigger = _menu.useMenuTrigger; exports.useSubmenuTrigger = _menu.useSubmenuTrigger; ++var _overlays = require("@react-aria/overlays"); exports.DismissButton = _overlays.DismissButton; exports.Overlay = _overlays.Overlay; exports.useOverlayTrigger = _overlays.useOverlayTrigger; exports.usePopover = _overlays.usePopover; ++var _select = require("@react-aria/select"); exports.HiddenSelect = _select.HiddenSelect; exports.useSelect = _select.useSelect; ++var _separator = require("@react-aria/separator"); exports.useSeparator = _separator.useSeparator; ++var _slider = require("@react-aria/slider"); exports.useSlider = _slider.useSlider; exports.useSliderThumb = _slider.useSliderThumb; ++var _utils = require("@react-aria/utils"); exports.chain = _utils.chain; exports.mergeProps = _utils.mergeProps; exports.useId = _utils.useId; exports.useObjectRef = _utils.useObjectRef; ++var _visuallyHidden = require("@react-aria/visually-hidden"); exports.VisuallyHidden = _visuallyHidden.VisuallyHidden; exports.useVisuallyHidden = _visuallyHidden.useVisuallyHidden; ++var _dialog = require("@react-aria/dialog"); exports.useDialog = _dialog.useDialog; +diff --git a/dist/module.js b/dist/module.js +index a13ba8b37e27230ec01d4c31140127912208666c..4208f8b7e8c3ff25b4eff8cce9017e8cfb8d3b4e 100644 +--- a/dist/module.js ++++ b/dist/module.js +@@ -1,50 +1,14 @@ +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +-export {useBreadcrumbItem, useBreadcrumbs} from '@react-aria/breadcrumbs'; +-export {useButton, useToggleButton, useToggleButtonGroup, useToggleButtonGroupItem} from '@react-aria/button'; +-export {useCalendar, useCalendarCell, useCalendarGrid, useRangeCalendar} from '@react-aria/calendar'; +-export {useCheckbox, useCheckboxGroup, useCheckboxGroupItem} from '@react-aria/checkbox'; +-export {useColorArea, useColorChannelField, useColorField, useColorSlider, useColorSwatch, useColorWheel} from '@react-aria/color'; +-export {useComboBox} from '@react-aria/combobox'; +-export {useDateField, useDatePicker, useDateRangePicker, useDateSegment, useTimeField} from '@react-aria/datepicker'; +-export {useDialog} from '@react-aria/dialog'; +-export {useDisclosure} from '@react-aria/disclosure'; +-export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd'; ++// Slim barrel: only exports actually used ++export {useButton} from '@react-aria/button'; + export {FocusRing, FocusScope, useFocusManager, useFocusRing, useFocusable} from '@react-aria/focus'; +-export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; ++export {useNumberFormatter} from '@react-aria/i18n'; + export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress} from '@react-aria/interactions'; +-export {useField, useLabel} from '@react-aria/label'; +-export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; +-export {useLink} from '@react-aria/link'; + export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox'; +-export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; +-export {useMeter} from '@react-aria/meter'; +-export {useNumberField} from '@react-aria/numberfield'; +-export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll} from '@react-aria/overlays'; +-export {useProgressBar} from '@react-aria/progress'; +-export {useRadio, useRadioGroup} from '@react-aria/radio'; +-export {useSearchField} from '@react-aria/searchfield'; +-export {HiddenSelect, useHiddenSelect, useSelect} from '@react-aria/select'; +-export {ListKeyboardDelegate} from '@react-aria/selection'; ++export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; ++export {DismissButton, Overlay, useOverlayTrigger, usePopover} from '@react-aria/overlays'; ++export {HiddenSelect, useSelect} from '@react-aria/select'; + export {useSeparator} from '@react-aria/separator'; +-export {SSRProvider, useIsSSR} from '@react-aria/ssr'; + export {useSlider, useSliderThumb} from '@react-aria/slider'; +-export {useSwitch} from '@react-aria/switch'; +-export {useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox} from '@react-aria/table'; +-export {useTab, useTabList, useTabPanel} from '@react-aria/tabs'; +-export {useTag, useTagGroup} from '@react-aria/tag'; +-export {useTextField} from '@react-aria/textfield'; +-export {useTooltip, useTooltipTrigger} from '@react-aria/tooltip'; +-export {chain, mergeProps, useId, useObjectRef, RouterProvider} from '@react-aria/utils'; ++export {chain, mergeProps, useId, useObjectRef} from '@react-aria/utils'; + export {VisuallyHidden, useVisuallyHidden} from '@react-aria/visually-hidden'; +- ++export {useDialog} from '@react-aria/dialog'; diff --git a/.yarn/patches/react-stately-npm-3.35.0-f77b012b54.patch b/.yarn/patches/react-stately-npm-3.35.0-f77b012b54.patch new file mode 100644 index 0000000..5694c88 --- /dev/null +++ b/.yarn/patches/react-stately-npm-3.35.0-f77b012b54.patch @@ -0,0 +1,133 @@ +diff --git a/dist/main.js b/dist/main.js +index fab7548..6d19514 100644 +--- a/dist/main.js ++++ b/dist/main.js +@@ -1,78 +1,8 @@ + "use strict"; +- + exports.__esModule = true; +-exports.useTreeState = exports.useTreeData = exports.useTooltipTriggerState = exports.useToggleState = exports.useToggleGroupState = exports.useTimeFieldState = exports.useTableState = exports.useTableColumnResizeState = exports.useTabListState = exports.useSubmenuTriggerState = exports.useSliderState = exports.useSingleSelectListState = exports.useSelectState = exports.useSearchFieldState = exports.useRangeCalendarState = exports.useRadioGroupState = exports.useOverlayTriggerState = exports.useNumberFieldState = exports.useMultipleSelectionState = exports.useMenuTriggerState = exports.useListState = exports.useListData = exports.useDroppableCollectionState = exports.useDraggableCollectionState = exports.useDisclosureState = exports.useDisclosureGroupState = exports.useDateRangePickerState = exports.useDatePickerState = exports.useDateFieldState = exports.useComboBoxState = exports.useColorWheelState = exports.useColorSliderState = exports.useColorPickerState = exports.useColorFieldState = exports.useColorChannelFieldState = exports.useColorAreaState = exports.useCollection = exports.useCheckboxGroupState = exports.useCalendarState = exports.useAsyncList = exports.parseColor = exports.getColorChannels = exports.TableHeader = exports.TableBody = exports.Section = exports.Row = exports.Item = exports.FormValidationContext = exports.Column = exports.Cell = void 0; +-var _calendar = require("@react-stately/calendar"); +-exports.useCalendarState = _calendar.useCalendarState; +-exports.useRangeCalendarState = _calendar.useRangeCalendarState; +-var _checkbox = require("@react-stately/checkbox"); +-exports.useCheckboxGroupState = _checkbox.useCheckboxGroupState; +-var _color = require("@react-stately/color"); +-exports.getColorChannels = _color.getColorChannels; +-exports.parseColor = _color.parseColor; +-exports.useColorAreaState = _color.useColorAreaState; +-exports.useColorChannelFieldState = _color.useColorChannelFieldState; +-exports.useColorFieldState = _color.useColorFieldState; +-exports.useColorPickerState = _color.useColorPickerState; +-exports.useColorSliderState = _color.useColorSliderState; +-exports.useColorWheelState = _color.useColorWheelState; +-var _combobox = require("@react-stately/combobox"); +-exports.useComboBoxState = _combobox.useComboBoxState; +-var _datepicker = require("@react-stately/datepicker"); +-exports.useDateFieldState = _datepicker.useDateFieldState; +-exports.useDatePickerState = _datepicker.useDatePickerState; +-exports.useDateRangePickerState = _datepicker.useDateRangePickerState; +-exports.useTimeFieldState = _datepicker.useTimeFieldState; +-var _disclosure = require("@react-stately/disclosure"); +-exports.useDisclosureState = _disclosure.useDisclosureState; +-exports.useDisclosureGroupState = _disclosure.useDisclosureGroupState; +-var _dnd = require("@react-stately/dnd"); +-exports.useDraggableCollectionState = _dnd.useDraggableCollectionState; +-exports.useDroppableCollectionState = _dnd.useDroppableCollectionState; +-var _collections = require("@react-stately/collections"); +-exports.Item = _collections.Item; +-exports.Section = _collections.Section; +-exports.useCollection = _collections.useCollection; +-var _data = require("@react-stately/data"); +-exports.useAsyncList = _data.useAsyncList; +-exports.useListData = _data.useListData; +-exports.useTreeData = _data.useTreeData; +-var _list = require("@react-stately/list"); +-exports.useListState = _list.useListState; +-exports.useSingleSelectListState = _list.useSingleSelectListState; +-var _menu = require("@react-stately/menu"); +-exports.useMenuTriggerState = _menu.useMenuTriggerState; +-exports.useSubmenuTriggerState = _menu.useSubmenuTriggerState; +-var _numberfield = require("@react-stately/numberfield"); +-exports.useNumberFieldState = _numberfield.useNumberFieldState; +-var _overlays = require("@react-stately/overlays"); +-exports.useOverlayTriggerState = _overlays.useOverlayTriggerState; +-var _radio = require("@react-stately/radio"); +-exports.useRadioGroupState = _radio.useRadioGroupState; +-var _searchfield = require("@react-stately/searchfield"); +-exports.useSearchFieldState = _searchfield.useSearchFieldState; +-var _select = require("@react-stately/select"); +-exports.useSelectState = _select.useSelectState; +-var _slider = require("@react-stately/slider"); +-exports.useSliderState = _slider.useSliderState; +-var _selection = require("@react-stately/selection"); +-exports.useMultipleSelectionState = _selection.useMultipleSelectionState; +-var _table = require("@react-stately/table"); +-exports.useTableState = _table.useTableState; +-exports.TableHeader = _table.TableHeader; +-exports.TableBody = _table.TableBody; +-exports.Column = _table.Column; +-exports.Row = _table.Row; +-exports.Cell = _table.Cell; +-exports.useTableColumnResizeState = _table.useTableColumnResizeState; +-var _tabs = require("@react-stately/tabs"); +-exports.useTabListState = _tabs.useTabListState; +-var _toggle = require("@react-stately/toggle"); +-exports.useToggleState = _toggle.useToggleState; +-exports.useToggleGroupState = _toggle.useToggleGroupState; +-var _tooltip = require("@react-stately/tooltip"); +-exports.useTooltipTriggerState = _tooltip.useTooltipTriggerState; +-var _tree = require("@react-stately/tree"); +-exports.useTreeState = _tree.useTreeState; +-var _form = require("@react-stately/form"); +-exports.FormValidationContext = _form.FormValidationContext; ++var _collections = require("@react-stately/collections"); exports.Item = _collections.Item; exports.Section = _collections.Section; ++var _menu = require("@react-stately/menu"); exports.useMenuTriggerState = _menu.useMenuTriggerState; exports.useSubmenuTriggerState = _menu.useSubmenuTriggerState; ++var _overlays = require("@react-stately/overlays"); exports.useOverlayTriggerState = _overlays.useOverlayTriggerState; ++var _select = require("@react-stately/select"); exports.useSelectState = _select.useSelectState; ++var _slider = require("@react-stately/slider"); exports.useSliderState = _slider.useSliderState; ++var _tree = require("@react-stately/tree"); exports.useTreeState = _tree.useTreeState; +diff --git a/dist/module.js b/dist/module.js +index fe89bbc..07bbd23 100644 +--- a/dist/module.js ++++ b/dist/module.js +@@ -1,37 +1,7 @@ +-/* +- * Copyright 2020 Adobe. All rights reserved. +- * This file is licensed to you under the Apache License, Version 2.0 (the "License"); +- * you may not use this file except in compliance with the License. You may obtain a copy +- * of the License at http://www.apache.org/licenses/LICENSE-2.0 +- * +- * Unless required by applicable law or agreed to in writing, software distributed under +- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +- * OF ANY KIND, either express or implied. See the License for the specific language +- * governing permissions and limitations under the License. +- */ +- +- +-export {useCalendarState, useRangeCalendarState} from '@react-stately/calendar'; +-export {useCheckboxGroupState} from '@react-stately/checkbox'; +-export {getColorChannels, parseColor, useColorAreaState, useColorChannelFieldState, useColorFieldState, useColorPickerState, useColorSliderState, useColorWheelState} from '@react-stately/color'; +-export {useComboBoxState} from '@react-stately/combobox'; +-export {useDateFieldState, useDatePickerState, useDateRangePickerState, useTimeFieldState} from '@react-stately/datepicker'; +-export {useDisclosureState, useDisclosureGroupState} from '@react-stately/disclosure'; +-export {useDraggableCollectionState, useDroppableCollectionState} from '@react-stately/dnd'; +-export {Item, Section, useCollection} from '@react-stately/collections'; +-export {useAsyncList, useListData, useTreeData} from '@react-stately/data'; +-export {useListState, useSingleSelectListState} from '@react-stately/list'; ++// Slim barrel: only exports actually used ++export {Item, Section} from '@react-stately/collections'; + export {useMenuTriggerState, useSubmenuTriggerState} from '@react-stately/menu'; +-export {useNumberFieldState} from '@react-stately/numberfield'; + export {useOverlayTriggerState} from '@react-stately/overlays'; +-export {useRadioGroupState} from '@react-stately/radio'; +-export {useSearchFieldState} from '@react-stately/searchfield'; + export {useSelectState} from '@react-stately/select'; + export {useSliderState} from '@react-stately/slider'; +-export {useMultipleSelectionState} from '@react-stately/selection'; +-export {useTableState, TableHeader, TableBody, Column, Row, Cell, useTableColumnResizeState} from '@react-stately/table'; +-export {useTabListState} from '@react-stately/tabs'; +-export {useToggleState, useToggleGroupState} from '@react-stately/toggle'; +-export {useTooltipTriggerState} from '@react-stately/tooltip'; + export {useTreeState} from '@react-stately/tree'; +-export {FormValidationContext} from '@react-stately/form'; diff --git a/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch b/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch new file mode 100644 index 0000000..b6f291d --- /dev/null +++ b/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch @@ -0,0 +1,17 @@ +diff --git a/lib/index.mjs b/lib/index.mjs +index 35c18fc129e349205d3db64bfb7b9c589c726d35..7fe10a375d6e6d4d46bb1c4a776fdc75f062c3d3 100644 +--- a/lib/index.mjs ++++ b/lib/index.mjs +@@ -1,4 +1,4 @@ +-import * as module from './module.mjs'; ++import * as _module from './module.mjs'; + export { assert, assertEquals, assertGuard, assertGuardEquals, createAssert, createAssertEquals, createAssertGuard, createAssertGuardEquals, createEquals, createIs, createRandom, createValidate, createValidateEquals, equals, is, random, validate, validateEquals } from './module.mjs'; + import * as functional from './functional.mjs'; + export { functional }; +@@ -22,5 +22,5 @@ export { TypeGuardError } from './TypeGuardError.mjs'; + + + +-export { module as default }; ++export { _module as default }; + //# sourceMappingURL=index.mjs.map diff --git a/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch b/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch new file mode 100644 index 0000000..c67d2ce --- /dev/null +++ b/.yarn/patches/yoga-layout-npm-3.2.1-51ec934670.patch @@ -0,0 +1,26 @@ +diff --git a/dist/binaries/yoga-wasm-base64-esm.js b/dist/binaries/yoga-wasm-base64-esm.js +index 350866aeaf90bdcc7bea18adfaae1cfcf2e40af6..a973419e8569791396b7a123599fd464b8c0cff4 100644 +--- a/dist/binaries/yoga-wasm-base64-esm.js ++++ b/dist/binaries/yoga-wasm-base64-esm.js +@@ -1,6 +1,6 @@ + + var loadYoga = (() => { +- var _scriptDir = import.meta.url; ++ var _scriptDir = undefined; + + return ( + function(loadYoga) { +diff --git a/package.json b/package.json +index 1fb0482c9451d745ca010f9c1ad58f5d0f74a559..8f7705e1325c046fd671ddc163bc34b74d4389cf 100644 +--- a/package.json ++++ b/package.json +@@ -14,7 +14,8 @@ + "types": "./dist/src/index.d.ts", + "exports": { + ".": "./dist/src/index.js", +- "./load": "./dist/src/load.js" ++ "./load": "./dist/src/load.js", ++ "./dist/src/load.js": "./dist/src/load.js" + }, + "files": [ + "dist/binaries/**", diff --git a/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch b/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch new file mode 100644 index 0000000..f409b5b --- /dev/null +++ b/.yarn/patches/zod-npm-4.3.6-a096e305e6.patch @@ -0,0 +1,105 @@ +diff --git a/v4/classic/external.cjs b/v4/classic/external.cjs +index ee133825d44ab44b86dd8aef6bd985653ebde06c..ea74468d20c2371f9c4d8941467e3b7fe93a00ef 100644 +--- a/v4/classic/external.cjs ++++ b/v4/classic/external.cjs +@@ -29,7 +29,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.coerce = exports.iso = exports.ZodISODuration = exports.ZodISOTime = exports.ZodISODate = exports.ZodISODateTime = exports.locales = exports.fromJSONSchema = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0; ++exports.coerce = exports.iso = exports.ZodISODuration = exports.ZodISOTime = exports.ZodISODate = exports.ZodISODateTime = exports.fromJSONSchema = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0; + exports.core = __importStar(require("../core/index.cjs")); + __exportStar(require("./schemas.cjs"), exports); + __exportStar(require("./checks.cjs"), exports); +@@ -60,7 +60,7 @@ var json_schema_processors_js_1 = require("../core/json-schema-processors.cjs"); + Object.defineProperty(exports, "toJSONSchema", { enumerable: true, get: function () { return json_schema_processors_js_1.toJSONSchema; } }); + var from_json_schema_js_1 = require("./from-json-schema.cjs"); + Object.defineProperty(exports, "fromJSONSchema", { enumerable: true, get: function () { return from_json_schema_js_1.fromJSONSchema; } }); +-exports.locales = __importStar(require("../locales/index.cjs")); ++// locales removed from barrel + // iso + // must be exported from top-level + // https://github.com/colinhacks/zod/issues/4491 +diff --git a/v4/classic/external.js b/v4/classic/external.js +index 9567900bbb57099481c0ea5864fbf697d62021a5..98ee2af29038deca6c38c46c24d5fb14725e40f0 100644 +--- a/v4/classic/external.js ++++ b/v4/classic/external.js +@@ -11,7 +11,7 @@ config(en()); + export { globalRegistry, registry, config, $output, $input, $brand, clone, regexes, treeifyError, prettifyError, formatError, flattenError, TimePrecision, util, NEVER, } from "../core/index.js"; + export { toJSONSchema } from "../core/json-schema-processors.js"; + export { fromJSONSchema } from "./from-json-schema.js"; +-export * as locales from "../locales/index.js"; ++// locales removed from barrel + // iso + // must be exported from top-level + // https://github.com/colinhacks/zod/issues/4491 +diff --git a/v4/core/index.cjs b/v4/core/index.cjs +index c3fa303104a191d142a5212f69e7954cba3606a7..44bdc3437c6a88971c1c85b63024213880fe0fff 100644 +--- a/v4/core/index.cjs ++++ b/v4/core/index.cjs +@@ -26,7 +26,7 @@ var __importStar = (this && this.__importStar) || function (mod) { + return result; + }; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.JSONSchema = exports.JSONSchemaGenerator = exports.toJSONSchema = exports.locales = exports.regexes = exports.util = void 0; ++exports.JSONSchema = exports.JSONSchemaGenerator = exports.toJSONSchema = exports.regexes = exports.util = void 0; + __exportStar(require("./core.cjs"), exports); + __exportStar(require("./parse.cjs"), exports); + __exportStar(require("./errors.cjs"), exports); +@@ -35,7 +35,7 @@ __exportStar(require("./checks.cjs"), exports); + __exportStar(require("./versions.cjs"), exports); + exports.util = __importStar(require("./util.cjs")); + exports.regexes = __importStar(require("./regexes.cjs")); +-exports.locales = __importStar(require("../locales/index.cjs")); ++// locales removed from barrel + __exportStar(require("./registries.cjs"), exports); + __exportStar(require("./doc.cjs"), exports); + __exportStar(require("./api.cjs"), exports); +diff --git a/v4/core/index.js b/v4/core/index.js +index d334515bf004600f2e55ff85e3730f1b2f2ff703..4ff979844ba18d012893064b01889bf70ec245c8 100644 +--- a/v4/core/index.js ++++ b/v4/core/index.js +@@ -6,7 +6,7 @@ export * from "./checks.js"; + export * from "./versions.js"; + export * as util from "./util.js"; + export * as regexes from "./regexes.js"; +-export * as locales from "../locales/index.js"; ++// locales removed from barrel + export * from "./registries.js"; + export * from "./doc.js"; + export * from "./api.js"; +diff --git a/v4/mini/external.cjs b/v4/mini/external.cjs +index 0d0079d94d94010b51abe23042418adbc6077b7f..fd3acb42a12dde65f383eb2c3f46a9108dd8c865 100644 +--- a/v4/mini/external.cjs ++++ b/v4/mini/external.cjs +@@ -26,7 +26,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); + }; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.coerce = exports.ZodMiniISODuration = exports.ZodMiniISOTime = exports.ZodMiniISODate = exports.ZodMiniISODateTime = exports.iso = exports.locales = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0; ++exports.coerce = exports.ZodMiniISODuration = exports.ZodMiniISOTime = exports.ZodMiniISODate = exports.ZodMiniISODateTime = exports.iso = exports.toJSONSchema = exports.NEVER = exports.util = exports.TimePrecision = exports.flattenError = exports.formatError = exports.prettifyError = exports.treeifyError = exports.regexes = exports.clone = exports.$brand = exports.$input = exports.$output = exports.config = exports.registry = exports.globalRegistry = exports.core = void 0; + exports.core = __importStar(require("../core/index.cjs")); + __exportStar(require("./parse.cjs"), exports); + __exportStar(require("./schemas.cjs"), exports); +@@ -49,7 +49,7 @@ Object.defineProperty(exports, "util", { enumerable: true, get: function () { re + Object.defineProperty(exports, "NEVER", { enumerable: true, get: function () { return index_js_1.NEVER; } }); + var json_schema_processors_js_1 = require("../core/json-schema-processors.cjs"); + Object.defineProperty(exports, "toJSONSchema", { enumerable: true, get: function () { return json_schema_processors_js_1.toJSONSchema; } }); +-exports.locales = __importStar(require("../locales/index.cjs")); ++// locales removed from barrel + /** A special constant with type `never` */ + // export const NEVER = {} as never; + // iso +diff --git a/v4/mini/external.js b/v4/mini/external.js +index d6523c153e213be110c21b370164b5eca1cdf744..7a1addf9ce6e331e56c943867bf04cf199745145 100644 +--- a/v4/mini/external.js ++++ b/v4/mini/external.js +@@ -4,7 +4,7 @@ export * from "./schemas.js"; + export * from "./checks.js"; + export { globalRegistry, registry, config, $output, $input, $brand, clone, regexes, treeifyError, prettifyError, formatError, flattenError, TimePrecision, util, NEVER, } from "../core/index.js"; + export { toJSONSchema } from "../core/json-schema-processors.js"; +-export * as locales from "../locales/index.js"; ++// locales removed from barrel + /** A special constant with type `never` */ + // export const NEVER = {} as never; + // iso diff --git a/.yarn/plugins/@yarnpkg/plugin-engines.cjs b/.yarn/plugins/@yarnpkg/plugin-engines.cjs new file mode 100644 index 0000000..8347186 --- /dev/null +++ b/.yarn/plugins/@yarnpkg/plugin-engines.cjs @@ -0,0 +1,9 @@ +/* eslint-disable */ +//prettier-ignore +module.exports = { +name: "@yarnpkg/plugin-engines", +factory: function (require) { +var plugin=(()=>{var l=Object.create,c=Object.defineProperty;var v=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,h=Object.prototype.hasOwnProperty;var y=e=>c(e,"__esModule",{value:!0});var t=e=>{if(typeof require!="undefined")return require(e);throw new Error('Dynamic require of "'+e+'" is not supported')};var g=(e,o)=>{for(var r in o)c(e,r,{get:o[r],enumerable:!0})},P=(e,o,r)=>{if(o&&typeof o=="object"||typeof o=="function")for(let s of u(o))!h.call(e,s)&&s!=="default"&&c(e,s,{get:()=>o[s],enumerable:!(r=v(o,s))||r.enumerable});return e},i=e=>P(y(c(e!=null?l(m(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var k={};g(k,{default:()=>$});var n=i(t("@yarnpkg/core")),p=i(t("@yarnpkg/fslib")),d=i(t("fs")),f=i(t("path")),a=i(t("semver")),N={hooks:{validateProject:e=>{let o=(0,d.readFileSync)((0,f.resolve)(p.npath.fromPortablePath(e.cwd),"package.json"),"utf-8"),{engines:r={}}=JSON.parse(o);if(r.node!=null&&!(0,a.satisfies)(process.version,r.node))throw new n.ReportError(n.MessageName.UNNAMED,`The current Node version ${process.version} does not satisfy the required version ${r.node}.`);if(r.yarn!=null&&!(0,a.satisfies)(n.YarnVersion,r.yarn))throw new n.ReportError(n.MessageName.UNNAMED,`The current Yarn version v${n.YarnVersion} does not satisfy the required version ${r.yarn}.`)},setupScriptEnvironment:async e=>{let o=(0,d.readFileSync)((0,f.resolve)(p.npath.fromPortablePath(e.cwd),"package.json"),"utf-8"),{engines:r={}}=JSON.parse(o);r.node!=null&&!(0,a.satisfies)(process.version,r.node)&&(console.error(`The current Node version ${process.version} does not satisfy the required version ${r.node}.`),process.exit(1)),r.yarn!=null&&!(0,a.satisfies)(n.YarnVersion,r.yarn)&&(console.error(`The current Yarn version v${n.YarnVersion} does not satisfy the required version ${r.yarn}.`),process.exit(1))}}},$=N;return k;})(); +return plugin; +} +}; diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..492590b --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,22 @@ +approvedGitRepositories: + - "**" + +checksumBehavior: update + +compressionLevel: mixed + +enableGlobalCache: false + +enableImmutableInstalls: false + +enableScripts: true + +nodeLinker: node-modules + +npmMinimalAgeGate: 0 + +plugins: + - path: .yarn/plugins/@yarnpkg/plugin-engines.cjs + spec: "https://raw.githubusercontent.com/devoto13/yarn-plugin-engines/main/bundles/%40yarnpkg/plugin-engines.js" + +yarnPath: .yarn/releases/yarn-4.18.0.cjs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7896a62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# Rocket.Chat + +Monorepo: the main Meteor app lives in `apps/meteor/`, shared libraries in `packages/`, other services in `apps/` and `ee/`. + +## Documentation index + +Read the doc that matches the task instead of scanning `docs/` wholesale. + +### Cross-cutting + +- [docs/i18n.md](docs/i18n.md) — translation keys: where they live, naming, namespaces, interpolation, plurals, server-side `lng`, what the i18n linter enforces + +### Frontend + +- [docs/frontend/](docs/frontend/) — frontend guidelines, split by topic: + - [typescript-conventions.md](docs/frontend/typescript-conventions.md) — ES modules, `import type`, `type` vs `interface`, `any` vs `unknown` + - [migrating-from-javascript.md](docs/frontend/migrating-from-javascript.md) — gradual JS → TS migration + - [react.md](docs/frontend/react.md) — component structure, naming, exports, explicit and generic props types + - [building-components.md](docs/frontend/building-components.md) — application vs Fuselage components, styling rules, Storybook-first + - [i18n.md](docs/frontend/i18n.md) — client side only: `useTranslation`, `Trans`, runtime keys, escaping (shared rules in [docs/i18n.md](docs/i18n.md)) +- [docs/form-validation.md](docs/form-validation.md) — standardized form validation patterns +- [docs/anchor-navigation.md](docs/anchor-navigation.md) — deep-linking to a field via URL hash fragments +- [docs/bundle-optimization-react-aria.md](docs/bundle-optimization-react-aria.md) — barrel-import patches; Meteor's bundler does not tree-shake + +### Backend + +- [docs/backend-folder-structure.md](docs/backend-folder-structure.md) — how `apps/meteor/` server code is organized and where new code goes +- [docs/api-endpoint-migration.md](docs/api-endpoint-migration.md) — migrating `API.v1.addRoute()` to the validated `API.v1.get()`/`.post()`/… pattern +- [docs/ajv-instances.md](docs/ajv-instances.md) — when to use `ajv` vs `ajvQuery` in `@rocket.chat/rest-typings` +- [docs/apps-engine-migration.md](docs/apps-engine-migration.md) — phased extraction of apps execution into a microservice + +### Build and tooling + +- [docs/meteor-modern-stack.md](docs/meteor-modern-stack.md) — Meteor modern build stack, file-watching caveats +- [docs/coverage.md](docs/coverage.md) — coverage instrumentation in build and CI +- [docs/npm-publishing.md](docs/npm-publishing.md) — how the public packages reach npm: `release.yml`, changesets, OIDC trusted publishing, provenance + +### Testing + +- [Playwright E2E testing guide](apps/meteor/tests/e2e/README.md) — setup, locators, page objects, cleanup, performance patterns, and testing conventions + +### Other + +- [docs/adr/](docs/adr/) — architecture decision records +- [docs/features/](docs/features/) — per-feature notes +- [docs/proposals/](docs/proposals/) — design proposals, not yet implemented diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8b863ff --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at team@rocket.chat. The project team +will review and investigate all complaints and will respond in a way that it deems +appropriate to the circumstances. The project team is obligated to maintain +confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..6784838 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,66 @@ +# Features + +- Self Host + - Docker + - Multiple Deployment Options (Heroku, Digital Ocean, Sandstorm, etc.) +- Authentication Options + - OAuth + - SAML + - LDAP + - CAS (1.0, 2.0 + attribute sync) + - Facebook, Github, Google, etc. +- I18N: More than 30 languages so far +- File Uploading + - Drag-and-drop + - Upload to file system + - AWS + CDN +- Live chat + - SMS Integration +- Rich Media + - Audio Calls + - Video Conferencing + - Screen Sharing +- Notifications + - Desktop and Mobile + - Use your own gateway + - Use @all to notify everyone in the room (configurable to max. users) + - Receive missed messages in your e-mail +- Apps + - Mobile + - Native +- Integrations + - Hubot + - Incoming / Outgoing Webhooks + - Data Importer + - Import from Slack + - Slack Bridge +- Profiles + - Custom avatars +- Text Decorations + - Mentions + - Link Preview + - Embedded videos + - Markdown + - Code, single or multi-line + - TexMath + - Emojis + - Reactions +- Message editing + - Editing is as simple as using your arrow keys for picking the right message to edit + - Setup to keep the history of edits or discard the previous text + - Show or hide edited/deleted status +- History + - Search + - Full-text search + - Regex search +- Add stars to rooms and mark them as favorites +- Add stars and pins to messages + - Star messages that are important to you. Only you have access to your stars. + - Pin messages that are important to everyone. + - Access your starred/pinned and messages you were mentioned on quickly through sidebar buttons +- REST API +- Roles and Permissions +- Public and Private multi-user rooms +- One-on-one conversations + - Off-the-record messaging (messages are encrypted and transiently saved on the database) +- Slash commands diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8cce703 --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +Copyright (c) 2015-2022 Rocket.Chat Technologies Corp. + +Portions of this software are licensed as follows: + +* All content that resides under the "apps/meteor/ee/" and "ee/" directories + of this repository, if that directory exists, is licensed under the license + defined in "apps/meteor/ee/LICENSE". +* All third-party components incorporated into the Rocket.Chat Software are + licensed under the original license provided by the owner of the applicable + component. +* Content outside of the above-mentioned directories or restrictions above is + available under the MIT (Expat) license as defined below. + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LIMITATION_OF_RESPONSIBILITY.md b/LIMITATION_OF_RESPONSIBILITY.md new file mode 100644 index 0000000..531868d --- /dev/null +++ b/LIMITATION_OF_RESPONSIBILITY.md @@ -0,0 +1,23 @@ +## WARNING + +Rocket.Chat is open source software. Anyone in the world can download and run a Rocket.Chat server at any time. + +As a user of Rocket.Chat, someone with a Rocket.Chat account, you need to be aware that you may be using a Rocket.Chat server that is operated by arbitrary businesses, groups or individuals with no relationship to Rocket.Chat Technologies Corp. + +In particular: + +- Rocket.Chat Technologies Corp. do not have access to these servers. +- Rocket.Chat Technologies Corp. do not and cannot control or regulate how these servers are operated. +- Rocket.Chat Technologies Corp. cannot access, determine or regulate any contents or information flow on these servers. + +## PUBLIC SERVER + +For total transparency, Rocket.Chat Technologies Corp. owns and operates only ONE publicly available Rocket.Chat server in the world. The server that Rocket.Chat Technologies Corp. operates can only be accessed at: + +https://open.rocket.chat + +Any other Rocket.Chat server you access is not operated by Rocket.Chat Technologies Corp. and is subjected to the usage warning above. + +## ROCKET.CHAT CLOUD + +Rocket.Chat Technologies Corp. provides a cloud service for hosting Rocket.Chat instances. The data, messages and files on those instances are subject to our [Terms of Use](https://rocket.chat/terms). If you have evidence of misuse or a breach of our terms, contact us at [contact@rocket.chat](mailto:contact@rocket.chat) and include a description of the breach as well as the instance's URL. diff --git a/README.md b/README.md new file mode 100644 index 0000000..84109f3 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +

+ +

+ +

+ The ultimate secure open-source solution for team communications +

+ +

+ + + + Codecov branch +

+ + + +[Rocket.Chat](https://rocket.chat) is an open-source, secure, fully customizable communications platform developed in TypeScript for organizations with high standards of data protection. + +We are the ultimate solution for team communications, enabling real-time conversations between colleagues, with other companies, and with your customers or citizens, regardless of how they connect with you. The result is an increase in productivity and user satisfaction rates. + +Every day, tens of millions of users in over 150 countries and in organizations such as Deutsche Bahn, The US Navy, and Credit Suisse trust Rocket.Chat to keep their communications completely private and secure. + +# 🚀 Platform overview + +Rocket.Chat provides endless possibilities. Here’s an overview of the features you benefit from: + +- [**Deployment and workspace management**](https://docs.rocket.chat/docs/deploy-rocketchat): Flexible and reliable self-hosted, cloud, and air-gapped deployment options with scalable architecture and performance monitoring. + +- [**Security and sovereignty**](https://docs.rocket.chat/docs/secure-rocketchat): Rocket.Chat is secure by design, with features such as identity management, end-to-end encryption, and role and attribute-based access control. + +- [**Team collaboration**](https://docs.rocket.chat/docs/collaborate-using-rocketchat): Unified collaboration experience for asynchronous and real-time communication with seamless messaging options, voice calls, and federation. + +- [**Extend and integrate**](https://docs.rocket.chat/docs/extend-and-integrate-rocketchat-capabilities): Install public apps from the Rocket.Chat Marketplace, build your own apps with Apps-Engine, and integrate with external systems. + + + +# 💻 Deploy Rocket.Chat + +We support different methods of deployment for different needs. Choose your method: + +- Are you deploying Rocket.Chat on your own servers? You can deploy using one of the recommended methods: Docker, Podman, or Kubernetes. Refer to the [Deployment Guide](https://docs.rocket.chat/docs/deploy-rocketchat) for details. Before you deploy, make sure to check the [system requirements](https://docs.rocket.chat/docs/system-requirements) to deploy a workspace successfully. Alternatively, deploy Rocket.Chat using [Launchpad](https://docs.rocket.chat/docs/deploy-with-launchpad) for a quick and straightforward Kubernetes setup where you don't have to manage each dependency. + +- Do you need to run the workspace on an isolated network? Set up an [air-gapped workspace](https://docs.rocket.chat/docs/rocketchat-air-gapped-deployment) to use Rocket.Chat without internet access, suitable for high-security or regulated environments. + +- Looking for a custom cloud-hosted solution without handling infrastructure? Check out our premium, dedicated [cloud hosting options](https://docs.rocket.chat/docs/rocketchat-cloud-hosting-service-level-agreement-sla) that adapt to your needs. + +- Interested in decentralized communication? Enable [federation](https://docs.rocket.chat/docs/rocketchat-native-federation) to securely communicate and share resources across a federated network. + +# 📱 Desktop and mobile apps + +In addition to the web app, you can also download the Rocket.Chat mobile app: + +[![Rocket.Chat on Apple App Store](https://user-images.githubusercontent.com/551004/29770691-a2082ff4-8bc6-11e7-89a6-964cd405ea8e.png)](https://itunes.apple.com/us/app/rocket-chat/id1148741252?mt=8) [![Rocket.Chat on Google Play](https://user-images.githubusercontent.com/551004/29770692-a20975c6-8bc6-11e7-8ab0-1cde275496e0.png)](https://play.google.com/store/apps/details?id=chat.rocket.android) + +Download the desktop app: +- [Download on the Mac App Store](https://apps.apple.com/us/app/rocket-chat/id1086818840?mt=12) +- [Install from the Windows Store](https://apps.microsoft.com/detail/9nblggh52jv6?hl=en-us&gl=NG&ocid=pdpshare) +- [Get from Snapcraft for Linux](https://snapcraft.io/rocketchat-desktop) or use the command `sudo snap install rocketchat-desktop` + +Refer to the [Installation guide](https://docs.rocket.chat/docs/desktop-mobile-apps) for further details. + +# 📖 Rocket.Chat documentation + +- [User documentation](https://docs.rocket.chat/docs/rocketchat) +- [Administrator's guide](https://docs.rocket.chat/docs/administrators-guide) +- [Developer documentation](https://developer.rocket.chat/docs/rocketchat-developer) +- [API documentation](https://developer.rocket.chat/apidocs) +- [YouTube Rocket.Chat Learning Center](https://www.youtube.com/playlist?list=PLee3gqXJQrFVXnNs5GiuoTMy269ydvcOB) + +# 🛡️ Trust and compliance + +The architecture of Rocket.Chat is designed to protect sensitive data, enforce rigorous compliance, and ensure secure, reliable communications for governments, defense, and critical infrastructure organizations operating in high-stakes environments. + +- **[Trust Center](https://trust.rocket.chat/)**: The Trust Center provides a comprehensive view of how we protect data and maintain transparency. You will find details on our security practices, privacy commitments, compliance certifications, and governance policies. + +- **[Compliance Center](https://docs.rocket.chat/docs/compliance-center)**: Explore the Compliance Center for additional information on how we commit to providing modern collaboration while protecting sensitive data. + +# 🛠️ Additional resources for developers + +You can set up a Rocket.Chat development environment by following the guides below: + +- **[Server](https://developer.rocket.chat/docs/server)**: See how to set up a Rocket.Chat server development environment on Linux distributions, Windows, Mac, and Gitpod. + +- **[Desktop](https://developer.rocket.chat/docs/desktop-app)**: Learn how to set up and customize a desktop development environment. You can follow and contribute to the [Rocket.Chat.Electron](https://github.com/RocketChat/Rocket.Chat.Electron) repository. + +- **[Mobile](https://developer.rocket.chat/docs/mobile-app)**: Learn about mobile app development and configuring push notifications. You can follow and contribute to the [Rocket.Chat.ReactNative](https://github.com/RocketChat/Rocket.Chat.ReactNative) repository. + +# 🧩 Apps-Engine + +Develop your own apps that can be integrated with Rocket.Chat. We provide an [open-source Apps-Engine framework](https://developer.rocket.chat/docs/rocketchat-apps-engine) which expands the integration possibilities within the Rocket.Chat ecosystem. + +# 🆕 Feature request + +[Rocket.Chat/feature-requests](https://github.com/RocketChat/feature-requests) is used to track Rocket.Chat feature requests and discussions. Click [here](https://github.com/RocketChat/feature-requests/issues/new?template=feature_request.md) to open a new feature request. [Feature Request Forums](https://forums.rocket.chat/c/feature-requests/8) stores the historical archives of old feature requests (up to 2018). + +# 🤝 Community + +Join thousands of members worldwide in our [community server](https://open.rocket.chat). +Join [#support](https://open.rocket.chat/channel/support) and [#general](https://open.rocket.chat/channel/general) for help from the community. + +![Alt](https://repobeats.axiom.co/api/embed/1efe0f0a7c366bd58068a1ab1f555ab912ec3895.svg "Repobeats analytics image") + +# 👥 Contributions + +Rocket.Chat is an open-source project, and we are very happy to accept community contributions. Refer to the [Contribution guide](https://developer.rocket.chat/docs/contribute-to-rocketchat) for more details. + + +# 💼 Become a Rocketeer + +We're hiring developers, technical support, and product managers all the time. Check out our [jobs page](https://rocket.chat/jobs). + +# 🗞️ Get the Latest News + +- [Blog](https://rocket.chat/blog) +- [Twitter](https://twitter.com/RocketChat) +- [Facebook](https://www.facebook.com/RocketChatApp) +- [LinkedIn](https://www.linkedin.com/company/rocket-chat) +- [YouTube](https://www.youtube.com/channel/UCin9nv7mUjoqrRiwrzS5UVQ) diff --git a/Rocket.Chat.sublime-project b/Rocket.Chat.sublime-project new file mode 100644 index 0000000..d0659c9 --- /dev/null +++ b/Rocket.Chat.sublime-project @@ -0,0 +1,55 @@ +{ + "folders": + [ + { + "name": "Monorepo Root", + "path": ".", + "folder_exclude_patterns": [ + "//apps/meteor", + "//packages", + "//ee/apps", + "//ee/packages" + ], + }, + { + "name": "Rocket.Chat", + "path": "./apps/meteor", + }, + { + "name": "Packages", + "path": "./packages", + }, + { + "name": "Enterprise Apps", + "path": "./ee/apps", + }, + { + "name": "Enterprise Packages", + "path": "./ee/packages", + } + ], + "settings": { + "LSP": { + "LSP-typescript": { + "enabled": true, + "disabled_capabilities": { + // Uncomment the line below to load auto complete suggestions from sublime's index instead of typescript. + // The index is much faster, but doesn't filter out results based on the current context. + + // "completionProvider": true, + }, + }, + "LSP-eslint": { + "settings": { + // If node path is not automatically detected, you can specify it here (use the global eslint settings if you have a global node) + // "nodePath": null + "workingDirectories": [ + { + "mode": "auto", + } + ], + }, + }, + }, + }, +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9f71d91 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +You can find our full security documentation in our [handbook](https://handbook.rocket.chat/space/Handbook/74155072/%F0%9F%94%90+Security). + +--- + +## Supported Versions + +We currently provide security updates for the latest versions. +You can find more information about this [here](https://docs.rocket.chat/docs/version-durability). + +--- + +## Reporting Vulnerabilities + +If you discover a security vulnerability, please report it by following the instructions below: + +We accept reports through our **Bug Bounty VDP program** on the **HackerOne** platform, which you can access [here](https://hackerone.com/rocket_chat). + +Alternatively, you can: + +- Send an email to [security@rocket.chat](mailto:security@rocket.chat) with details about the vulnerability. +- Include as much information as possible: + - Description + - Affected version + - Steps to reproduce + - Identified impact + - Proof of Concept (PoC) + +> **Note:** At this time, we do not offer monetary rewards for reported vulnerabilities. + +--- + +## Disclosure + +We are committed to: + +- Responsibly validating and fixing vulnerabilities. +- Notifying users when a security update is released. +- Formally acknowledging researchers who help improve Rocket.Chat's security. + +--- + +## Thank You + +Thank you for helping us keep this project safe for everyone! \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..54ff885 --- /dev/null +++ b/app.json @@ -0,0 +1,21 @@ +{ + "name": "Rocket.Chat", + "description": "Have your own open-source Slack-like online chat platform.", + "repository": "https://github.com/RocketChat/Rocket.Chat", + "logo": "https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/2020/png/logo-horizontal-red.png", + "keywords": ["meteor", "social", "community", "chat"], + "website": "https://rocket.chat", + "env": { + "BUILDPACK_URL": "https://github.com/RocketChat/meteor-buildpack-horse.git", + "METEOR_APP_DIR": "apps/meteor/", + "HEROKU_APP_NAME": { + "description": "Please re-enter your App Name from the top.", + "required": true + }, + "DEPLOY_PLATFORM": "heroku" + }, + "addons": [ + "mongolab", + "logentries" + ] +} diff --git a/apps/meteor/.babelrc b/apps/meteor/.babelrc new file mode 100644 index 0000000..67b3522 --- /dev/null +++ b/apps/meteor/.babelrc @@ -0,0 +1,24 @@ +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic" }], + [ + "@babel/preset-typescript", + { + "allowDeclareFields": true + } + ] + ], + "env": { + "coverage": { + "plugins": [ + [ + "istanbul", + { + "exclude": ["**/*.spec.js", "**/*.test.js"] + } + ] + ] + } + } +} diff --git a/apps/meteor/.codeclimate.yml b/apps/meteor/.codeclimate.yml new file mode 100644 index 0000000..0b13a7e --- /dev/null +++ b/apps/meteor/.codeclimate.yml @@ -0,0 +1,44 @@ +# This is a sample .codeclimate.yml configured for Engine analysis on Code +# Climate Platform. For an overview of the Code Climate Platform, see here: +# http://docs.codeclimate.com/article/300-the-codeclimate-platform + +# Under the engines key, you can configure which engines will analyze your repo. +# Each key is an engine name. For each value, you need to specify enabled: true +# to enable the engine as well as any other engines-specific configuration. + +# For more details, see here: +# http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform + +# For a list of all available engines, see here: +# http://docs.codeclimate.com/article/296-engines-available-engines + +engines: +# to turn on an engine, add it here and set enabled to `true` +# to turn off an engine, set enabled to `false` or remove it + coffeelint: + enabled: true + eslint: + enabled: true + csslint: + enabled: true + +# Engines can analyze files and report issues on them, but you can separately +# decide which files will receive ratings based on those issues. This is +# specified by path patterns under the ratings key. + +# For more details see here: +# http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform + +ratings: + paths: + - "**.coffee" + - "**.js" + - "**.css" + +# You can globally exclude files from being analyzed by any engine using the +# exclude_paths key. + +exclude_paths: +- "private/moment-locales/**/*" +- "packages/rocketchat-favico/favico.js" +#- "vendor/**/*"" diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine new file mode 100644 index 0000000..37c12d3 --- /dev/null +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -0,0 +1,102 @@ +FROM node:24.15.0-alpine3.23 AS builder + +ENV LANG=C.UTF-8 + +RUN apk add --no-cache python3 make g++ py3-setuptools libc6-compat + +COPY . /app + +ENV NODE_ENV=production + +RUN cd /app/bundle/programs/server \ + && npm install --omit=dev \ + && npm cache clean --force \ + # Drop sourcemaps from third-party prod deps and the client bundle: + # - server/npm/node_modules: .map files shipped inside packages + # (openpgp, date-fns, etc.). Node does not read them at runtime. + # - web.browser: client-side bundle, maps have no runtime value in the image. + # Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor} + # are loaded by Meteor's source-map-support at boot, so they are kept. + && find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \ + && find /app/bundle/programs/web.browser -type f -name '*.map' -delete \ + # The bundle carries build/Release output compiled on the glibc CI host. + # node-gyp-build prefers it over prebuilds/, so that binary shadows the musl + # one shipped in the same package and fails to load at runtime. + && STALE_BUILDS="$(find /app/bundle/programs/server/npm/node_modules -type d -name prebuilds)" \ + && for d in $STALE_BUILDS; do rm -rf "${d%/prebuilds}/build"; done + +FROM node:24.15.0-alpine3.23 AS release-standard + +LABEL maintainer="buildmaster@rocket.chat" + +ENV LANG=C.UTF-8 + +# `nogroup` group is historically reserved for NFS. +# We don't use any NFS related tools in this image. +# For the same reason of NFS using the gid, we can also use it as long as there are no conflicts in terms of running processes with the same egid (which is 1 in our case). +# While 65533 raw gid could be used, renaming nogroup to rocketchat here for maximum compatibility with older debian image. +# More info on nobody/nogroup - https://wiki.ubuntu.com/nobody +# Debian wiki - https://wiki.debian.org/SystemGroups +# """ +# daemon: Some unprivileged daemons that need to write to files on disk run as daemon.daemon (e.g., portmap, atd, probably others). +# Daemons that don't need to own any files can run as nobody.nogroup instead, +# and more complex or security conscious daemons run as dedicated users. +# The daemon user is also handy for locally installed daemons. +# """ +RUN apk add --no-cache shadow deno ttf-dejavu \ + # Update OpenSSL + # CVE -> https://scout.docker.com/vulnerabilities/id/CVE-2025-9230?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.22 + && apk upgrade --no-cache openssl \ + && groupmod -n rocketchat nogroup \ + && useradd -u 65533 -r -g rocketchat rocketchat + +# needs a mongo instance - defaults to container linking with alias 'mongo' +ENV DEPLOY_METHOD=docker \ + NODE_ENV=production \ + MONGO_URL=mongodb://mongo:27017/rocketchat \ + HOME=/tmp \ + PORT=3000 \ + ROOT_URL=http://localhost:3000 \ + Accounts_AvatarStorePath=/app/uploads + +USER rocketchat + +COPY --from=builder --chown=rocketchat:rocketchat /app /app + +VOLUME /app/uploads + +WORKDIR /app/bundle + +EXPOSE 3000 + +CMD ["node", "main.js"] + +FROM rocketchat/dhi-node:24.15.0-alpine3.23-fips AS release-fips + +LABEL maintainer="buildmaster@rocket.chat" + +# needs a mongo instance - defaults to container linking with alias 'mongo' +ENV DEPLOY_METHOD=docker \ + NODE_ENV=production \ + MONGO_URL=mongodb://mongo:27017/rocketchat \ + HOME=/tmp \ + PORT=3000 \ + ROOT_URL=http://localhost:3000 \ + Accounts_AvatarStorePath=/app/uploads \ + APPS_ENGINE_RUNTIME_BACKEND=node + +# the hardened image has no /etc/alpine-release, so node-gyp-build would detect +# glibc and load the wrong prebuild for native modules +ENV LIBC=musl + +USER node + +COPY --from=builder --chown=node:node /app /app + +VOLUME /app/uploads + +WORKDIR /app/bundle + +EXPOSE 3000 + +CMD ["node", "--force-fips", "main.js"] diff --git a/apps/meteor/.docker/Dockerfile.debian b/apps/meteor/.docker/Dockerfile.debian new file mode 100644 index 0000000..597f25e --- /dev/null +++ b/apps/meteor/.docker/Dockerfile.debian @@ -0,0 +1,71 @@ +ARG DENO_VERSION="1.37.1" + +FROM denoland/deno:bin-${DENO_VERSION} as deno + +FROM node:24.15.0-bullseye-slim + +LABEL maintainer="buildmaster@rocket.chat" + +# dependencies +RUN groupadd -g 65533 -r rocketchat \ + && useradd -u 65533 -r -g rocketchat rocketchat \ + && mkdir -p /app/uploads \ + && chown rocketchat:rocketchat /app/uploads \ + && apt-get update \ + && apt-get install -y --no-install-recommends fontconfig + +COPY --from=deno /deno /bin/deno + +# --chown requires Docker 17.12 and works only on Linux +ADD --chown=rocketchat:rocketchat . /app + +# needs a mongoinstance - defaults to container linking with alias 'mongo' +ENV DEPLOY_METHOD=docker \ + NODE_ENV=production \ + MONGO_URL=mongodb://mongo:27017/rocketchat \ + HOME=/tmp \ + PORT=3000 \ + ROOT_URL=http://localhost:3000 \ + Accounts_AvatarStorePath=/app/uploads + +RUN aptMark="$(apt-mark showmanual)" \ + && apt-get install -y --no-install-recommends g++ make python3 ca-certificates \ + && apt-mark auto '.*' > /dev/null \ + && apt-mark manual $aptMark > /dev/null + +USER rocketchat + +RUN cd /app/bundle/programs/server \ + && npm install --omit=dev \ + && cd npm/node_modules/isolated-vm \ + && npm install --omit=dev \ + && npm cache clear --force \ + # Drop sourcemaps from third-party prod deps and the client bundle: + # - server/npm/node_modules: .map files shipped inside packages + # (openpgp, date-fns, etc.). Node does not read them at runtime. + # - web.browser: client-side bundle, maps have no runtime value in the image. + # Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor} + # are loaded by Meteor's source-map-support at boot, so they are kept. + && find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \ + && find /app/bundle/programs/web.browser -type f -name '*.map' -delete + +USER root + +RUN find /usr/local -type f -executable -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false + +USER rocketchat + +VOLUME /app/uploads + +WORKDIR /app/bundle + +EXPOSE 3000 + +CMD ["node", "main.js"] diff --git a/apps/meteor/.docker/licenses/LICENSE b/apps/meteor/.docker/licenses/LICENSE new file mode 100644 index 0000000..96cd938 --- /dev/null +++ b/apps/meteor/.docker/licenses/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015-2022 Rocket.Chat Technologies Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/apps/meteor/.dockerignore b/apps/meteor/.dockerignore new file mode 100644 index 0000000..5404e28 --- /dev/null +++ b/apps/meteor/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +LICENSE +README.md +docker-compose.yml +tests/e2e/test-failures/ diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore new file mode 100644 index 0000000..6411fe0 --- /dev/null +++ b/apps/meteor/.gitignore @@ -0,0 +1,90 @@ +**/bin/** +**/build/* +**/node_modules/* +**/tmp/* +**/.meteor/.id +**/.meteor/dev_bundle +**/.meteor/local* +**/.meteor/meteorite +/private/certs/* +*.bak +*.iml +*.ipr +*.iws +*.launch +*.log +*.pydevproject +*.sublime-project +*.sublime-workspace +*.swp +*.tmp +*.tokens +*.un~ +*~ +*~.nib +.*.sw[a-z] +.\#* +._* +.buildpath +.classpath +.clover +.cproject +.DS_Store +.elasticbeanstalk +.elc +.emacs.desktop +.emacs.desktop.lock +.env +.externalToolBuilders +.idea +.vscode +.loadpath +.map +.metadata +packages/rocketchat-livechat/assets/rocketchat-livechat.min.js +.mule +.pmd +.project +.sass-cache +.settings +.Spotlight-V100 +tatus +.Trashes +.wtpmodules +\#*\# +Desktop.ini +ehthumbs.db +example.css +jrat.output +jrat.xml +local.properties +meteor-vulcanize +nb-configuration.xml +nbactions.xml +nbproject +profiles.xml +Session.vim +smart.lock +temp_* +Thumbs.db +thumbs.db +tramp +ecosystem.json +settings.json +/public/livechat +packages/rocketchat-i18n/i18n/livechat.* +tests/end-to-end/temporary_staged_test +.screenshots +/private/livechat +/storybook-static +/tests/e2e/.playwright +coverage +.nyc_output +/data +tests/e2e/test-failures/ +out.txt +dist +*-session.json +matrix-federation-config/* +.eslintcache +tsconfig.typecheck.tsbuildinfo diff --git a/apps/meteor/.meteor/.finished-upgraders b/apps/meteor/.meteor/.finished-upgraders new file mode 100644 index 0000000..bc5b50f --- /dev/null +++ b/apps/meteor/.meteor/.finished-upgraders @@ -0,0 +1,20 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.3.5-remove-old-dev-bundle-link +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package +1.4.3-split-account-service-packages +1.5-add-dynamic-import-package +1.7-split-underscore-from-meteor-base +1.8.3-split-jquery-from-blaze diff --git a/apps/meteor/.meteor/.gitignore b/apps/meteor/.meteor/.gitignore new file mode 100644 index 0000000..501f92e --- /dev/null +++ b/apps/meteor/.meteor/.gitignore @@ -0,0 +1,2 @@ +dev_bundle +local diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages new file mode 100644 index 0000000..b080add --- /dev/null +++ b/apps/meteor/.meteor/packages @@ -0,0 +1,63 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +rocketchat:mongo-config +rocketchat:livechat +rocketchat:version + +accounts-base@3.3.1 +accounts-facebook@1.3.4 +accounts-google@1.4.1 +accounts-meteor-developer@1.5.1 +accounts-oauth@1.4.7 +accounts-password@3.3.1 +accounts-twitter@1.5.2 + +google-oauth@1.4.5 +oauth@3.0.4 +oauth2@1.3.3 + +check@1.5.0 +ddp-rate-limiter@1.3.0 +rate-limit@1.2.0 +email@3.2.0 + +meteor-base@1.5.2 +ddp-common@1.4.5 +webapp@2.3.0 + +mongo@2.5.1 + +reload@1.3.2 +service-configuration@1.3.5 +session@1.2.3 +shell-server@0.7.0 + +dispatch:run-as-user +ostrio:cookies + + +meteorhacks:inject-initial + +routepolicy@1.1.2 + +webapp-hashing@1.1.2 +facts-base@1.0.2 + +tracker@1.3.4 +reactive-dict@1.3.2 +reactive-var@1.0.13 + +standard-minifier-css@1.10.1 +dynamic-import@0.7.4 +ecmascript@0.19.1 +typescript@5.11.1 + +autoupdate@2.0.1 + +# photoswipe + +zodern:types +standard-minifier-js@3.2.0 diff --git a/apps/meteor/.meteor/platforms b/apps/meteor/.meteor/platforms new file mode 100644 index 0000000..62ba921 --- /dev/null +++ b/apps/meteor/.meteor/platforms @@ -0,0 +1,3 @@ +server +browser +modern \ No newline at end of file diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release new file mode 100644 index 0000000..fde21b2 --- /dev/null +++ b/apps/meteor/.meteor/release @@ -0,0 +1 @@ +METEOR@3.5.2 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions new file mode 100644 index 0000000..c698812 --- /dev/null +++ b/apps/meteor/.meteor/versions @@ -0,0 +1,90 @@ +accounts-base@3.3.1 +accounts-facebook@1.3.4 +accounts-google@1.4.1 +accounts-meteor-developer@1.5.1 +accounts-oauth@1.4.7 +accounts-password@3.3.1 +accounts-twitter@1.5.2 +allow-deny@2.1.0 +autoupdate@2.0.1 +babel-compiler@7.15.1 +babel-runtime@1.5.2 +base64@1.0.14 +binary-heap@1.0.13 +boilerplate-generator@2.1.0 +callback-hook@1.8.0 +check@1.5.0 +core-runtime@1.0.0 +ddp@1.4.2 +ddp-client@3.4.1 +ddp-common@1.4.5 +ddp-rate-limiter@1.3.0 +ddp-server@3.4.0 +diff-sequence@1.1.3 +dispatch:run-as-user@1.1.1 +dynamic-import@0.7.4 +ecmascript@0.19.1 +ecmascript-runtime@0.8.3 +ecmascript-runtime-client@0.13.0 +ecmascript-runtime-server@0.11.1 +ejson@1.2.0 +email@3.2.0 +es5-shim@4.8.1 +facebook-oauth@1.11.6 +facts-base@1.0.2 +fetch@0.2.0 +geojson-utils@1.0.12 +google-oauth@1.4.5 +hot-code-push@1.0.5 +http@3.0.0 +id-map@1.2.0 +inter-process-messaging@0.1.3 +localstorage@1.2.1 +logging@1.3.6 +meteor@2.3.1 +meteor-base@1.5.2 +meteor-developer-oauth@1.3.3 +meteorhacks:inject-initial@1.0.5 +minifier-css@2.0.2 +minifier-js@3.3.1 +minimongo@2.2.0 +modern-browsers@0.2.3 +modules@0.20.3 +modules-runtime@0.13.2 +mongo@2.5.1 +mongo-decimal@0.2.0 +mongo-dev-server@1.1.1 +mongo-id@1.0.9 +npm-mongo@6.16.3 +oauth@3.0.4 +oauth1@1.5.3 +oauth2@1.3.3 +ordered-dict@1.2.0 +ostrio:cookies@2.7.2 +promise@1.0.0 +random@1.2.2 +rate-limit@1.2.0 +react-fast-refresh@0.3.0 +reactive-dict@1.3.2 +reactive-var@1.0.13 +reload@1.3.2 +retry@1.1.1 +rocketchat:livechat@0.0.1 +rocketchat:mongo-config@0.0.1 +rocketchat:version@1.0.0 +routepolicy@1.1.2 +service-configuration@1.3.5 +session@1.2.3 +sha@1.0.10 +shell-server@0.7.0 +socket-stream-client@0.7.1 +standard-minifier-css@1.10.1 +standard-minifier-js@3.2.0 +tracker@1.3.4 +twitter-oauth@1.3.4 +typescript@5.11.1 +underscore@1.6.4 +url@1.3.6 +webapp@2.3.0 +webapp-hashing@1.1.2 +zodern:types@1.0.13 diff --git a/apps/meteor/.meteorignore b/apps/meteor/.meteorignore new file mode 100644 index 0000000..7b2dc6e --- /dev/null +++ b/apps/meteor/.meteorignore @@ -0,0 +1,4 @@ +ee/server/services +coverage +data +dist diff --git a/apps/meteor/.mocharc.api.apps.js b/apps/meteor/.mocharc.api.apps.js new file mode 100644 index 0000000..7eea51b --- /dev/null +++ b/apps/meteor/.mocharc.api.apps.js @@ -0,0 +1,15 @@ +'use strict'; + +/* + * Mocha configuration for REST API integration tests. + */ + +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: false, + retries: 0, + file: 'tests/end-to-end/teardown.ts', + reporter: 'tests/end-to-end/reporter.ts', + spec: ['tests/end-to-end/apps/*'], +}); diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js new file mode 100644 index 0000000..94abb12 --- /dev/null +++ b/apps/meteor/.mocharc.api.js @@ -0,0 +1,15 @@ +'use strict'; + +/* + * Mocha configuration for REST API integration tests. + */ + +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: false, + retries: 0, + file: 'tests/end-to-end/teardown.ts', + reporter: 'tests/end-to-end/reporter.ts', + spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'], +}); diff --git a/apps/meteor/.mocharc.api.livechat.js b/apps/meteor/.mocharc.api.livechat.js new file mode 100644 index 0000000..7b98b65 --- /dev/null +++ b/apps/meteor/.mocharc.api.livechat.js @@ -0,0 +1,15 @@ +'use strict'; + +/* + * Mocha configuration for Livechat REST API integration tests. + */ + +module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ + ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 + timeout: 10000, + bail: true, + retries: 0, + file: 'tests/end-to-end/teardown.ts', + reporter: 'tests/end-to-end/reporter.ts', + spec: ['tests/end-to-end/api/livechat/**/*'], +}); diff --git a/apps/meteor/.mocharc.base.json b/apps/meteor/.mocharc.base.json new file mode 100644 index 0000000..b081321 --- /dev/null +++ b/apps/meteor/.mocharc.base.json @@ -0,0 +1,14 @@ +{ + "ui": "bdd", + "reporter": "spec", + "extension": ["js", "ts", "tsx"], + "require": [ + "tsx", + "./tests/setup/chaiPlugins.ts" + ], + "watch-files": [ + "./**/*.js", + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/apps/meteor/.mocharc.definition.js b/apps/meteor/.mocharc.definition.js new file mode 100644 index 0000000..faeef22 --- /dev/null +++ b/apps/meteor/.mocharc.definition.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * Mocha configuration for unit tests for type guards. + */ + +const base = require('./.mocharc.base.json'); + +module.exports = { + ...base, // see https://github.com/mochajs/mocha/issues/3916 + require: [...base.require], + exit: false, + slow: 200, + spec: ['tests/unit/definition/**/*.spec.ts'], +}; diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js new file mode 100644 index 0000000..6837d59 --- /dev/null +++ b/apps/meteor/.mocharc.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Mocha configuration for general unit tests. + */ + +const base = require('./.mocharc.base.json'); + +module.exports = { + ...base, // see https://github.com/mochajs/mocha/issues/3916 + exit: true, + // getUserInfo.spec.ts lives under server/api/lib but is a Jest test (run via jest.config.ts), + // so it must be excluded from the mocha `server/api/lib/**` glob below. Same for the + // business-hour spec under server/lib/omnichannel. + ignore: ['server/api/lib/getUserInfo.spec.ts', 'server/lib/omnichannel/business-hour/**/*.spec.ts'], + spec: [ + 'server/lib/callbacks.spec.ts', + 'server/lib/cas/*.spec.ts', + 'server/lib/messages/**/*.spec.ts', + 'server/lib/ldap/*.spec.ts', + 'server/lib/ldap/**/*.spec.ts', + 'server/lib/dataExport/**/*.spec.ts', + 'server/ufs/*.spec.ts', + 'ee/server/lib/ldap/*.spec.ts', + 'ee/tests/**/*.tests.ts', + 'ee/tests/**/*.spec.ts', + 'tests/unit/app/**/*.spec.ts', + 'tests/unit/app/**/*.tests.js', + 'tests/unit/app/**/*.tests.ts', + 'tests/unit/lib/**/*.tests.ts', + 'server/routes/avatar/**/*.spec.ts', + 'tests/unit/lib/**/*.spec.ts', + 'tests/unit/server/**/*.tests.js', + 'tests/unit/server/**/*.tests.ts', + 'tests/unit/server/**/*.spec.ts', + 'server/lib/2fa/**/*.spec.ts', + 'server/api/lib/**/*.spec.ts', + 'server/lib/media/**/*.spec.ts', + 'server/lib/statistics/**/*.spec.ts', + 'server/meteor-methods/**/*.spec.ts', + 'server/lib/omnichannel/**/*.spec.ts', + 'server/lib/notifications/push/**/*.spec.ts', + 'server/lib/utils/**/*.spec.ts', + ], +}; diff --git a/apps/meteor/.openshift/rocket-chat-ephemeral.json b/apps/meteor/.openshift/rocket-chat-ephemeral.json new file mode 100644 index 0000000..dd25e87 --- /dev/null +++ b/apps/meteor/.openshift/rocket-chat-ephemeral.json @@ -0,0 +1,443 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "name": "rocket-chat-ephemeral", + "annotations": { + "description": "Rocket.Chat with a MongoDB database running with an Ephemeral storage.", + "tags": "quickstart,nodejs,mongodb,instant-app", + "iconClass": "icon-nodejs" + } + }, + "objects": [ + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "mongodb", + "labels": { + "template": "mongodb-ephemeral-template" + } + }, + "spec": { + "strategy": { + "type": "Recreate", + "recreateParams": { + "timeoutSeconds": 600 + } + }, + "triggers": [ + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "mongodb" + ], + "from": { + "kind": "ImageStreamTag", + "namespace": "openshift", + "name": "mongodb:latest" + } + } + }, + { + "type": "ConfigChange" + } + ], + "replicas": 1, + "test": false, + "selector": { + "name": "mongodb" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "name": "mongodb" + } + }, + "spec": { + "volumes": [ + { + "name": "mongodb-data", + "emptyDir": {} + } + ], + "containers": [ + { + "name": "mongodb", + "image": "registry.access.redhat.com/rhscl/mongodb-26-rhel7:latest", + "ports": [ + { + "containerPort": 27017, + "protocol": "TCP" + } + ], + "env": [ + { + "name": "MONGODB_USER", + "value": "${MONGODB_USER}" + }, + { + "name": "MONGODB_PASSWORD", + "value": "${MONGODB_PASSWORD}" + }, + { + "name": "MONGODB_DATABASE", + "value": "${MONGODB_DATABASE}" + }, + { + "name": "MONGODB_ADMIN_PASSWORD", + "value": "${MONGODB_ADMIN_PASSWORD}" + } + ], + "resources": { + "limits": { + "memory": "${MEMORY_LIMIT}" + } + }, + "volumeMounts": [ + { + "name": "mongodb-data", + "mountPath": "/var/lib/mongodb/data" + } + ], + "livenessProbe": { + "tcpSocket": { + "port": 27017 + }, + "initialDelaySeconds": 30, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "readinessProbe": { + "exec": { + "command": [ + "/bin/sh", + "-i", + "-c", + "mongo 127.0.0.1:27017/$MONGODB_DATABASE -u $MONGODB_USER -p $MONGODB_PASSWORD --eval=\"quit()\"" + ] + }, + "initialDelaySeconds": 3, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "imagePullPolicy": "IfNotPresent", + "securityContext": { + "capabilities": {}, + "privileged": false + } + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {} + } + } + } + }, + { + "kind": "ImageStream", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat" + }, + "spec": { + "dockerImageRepository": "${ROCKETCHAT_IMAGE}", + "tags": [ + { + "name": "latest", + "annotations": { + "description": "Provides a Rocket.Chat application", + "iconClass": "icon-nodejs", + "tags": "rocketchat" + }, + "from": { + "kind": "ImageStreamTag", + "name": "latest" + }, + "generation": 1, + "importPolicy": {} + } + ] + } + }, + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "strategy": { + "type": "Rolling", + "rollingParams": { + "updatePeriodSeconds": 1, + "intervalSeconds": 1, + "timeoutSeconds": 600, + "maxUnavailable": "25%", + "maxSurge": "25%" + }, + "resources": {} + }, + "triggers": [ + { + "type": "ConfigChange" + }, + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "rocketchat" + ], + "from": { + "kind": "ImageStreamTag", + "name": "rocketchat:latest" + } + } + } + ], + "replicas": 1, + "test": false, + "selector": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "annotations": { + "openshift.io/container.rocketchat.image.entrypoint": "[\"node\",\"main.js\"]", + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "volumes": [ + { + "name": "rocketchat-volume-1", + "emptyDir": {} + } + ], + "containers": [ + { + "name": "rocketchat", + "image": "${ROCKETCHAT_IMAGE}:latest", + "ports": [ + { + "containerPort": 3000, + "protocol": "TCP" + } + ], + "env": [ + { + "name": "MONGO_URL", + "value": "mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}" + } + ], + "resources": {}, + "volumeMounts": [ + { + "name": "rocketchat-volume-1", + "mountPath": "/app/uploads" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/api/v1/info", + "port": 3000, + "scheme": "HTTP" + }, + "initialDelaySeconds": 5, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "livenessProbe": { + "httpGet": { + "path": "/api/v1/info", + "port": 3000, + "scheme": "HTTP" + }, + "initialDelaySeconds": 30, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "imagePullPolicy": "Always" + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {} + } + } + }, + "status": {} + }, + { + "kind": "Route", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/host.generated": "true" + } + }, + "spec": { + "to": { + "kind": "Service", + "name": "rocketchat" + }, + "port": { + "targetPort": "3000-tcp" + } + }, + "status": { + "ingress": null + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "mongodb", + "creationTimestamp": null, + "labels": { + "template": "mongodb-ephemeral-template" + } + }, + "spec": { + "ports": [ + { + "name": "mongo", + "protocol": "TCP", + "port": 27017, + "targetPort": 27017 + } + ], + "selector": { + "name": "mongodb" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "ports": [ + { + "name": "3000-tcp", + "protocol": "TCP", + "port": 3000, + "targetPort": 3000 + } + ], + "selector": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + } + ], + "parameters": [ + { + "name": "MEMORY_LIMIT", + "displayName": "Memory Limit", + "description": "Maximum amount of memory the container can use.", + "value": "512Mi" + }, + { + "name": "DATABASE_SERVICE_NAME", + "displayName": "Database Service Name", + "description": "The name of the OpenShift Service exposed for the database.", + "value": "mongodb", + "required": true + }, + { + "name": "MONGODB_USER", + "displayName": "MongoDB User", + "description": "Username for MongoDB user that will be used for accessing the database.", + "generate": "expression", + "from": "user[A-Z0-9]{3}", + "required": true + }, + { + "name": "MONGODB_PASSWORD", + "displayName": "MongoDB Password", + "description": "Password for the MongoDB user.", + "generate": "expression", + "from": "[a-zA-Z0-9]{16}", + "required": true + }, + { + "name": "MONGODB_DATABASE", + "displayName": "MongoDB Database Name", + "description": "Name of the MongoDB database accessed.", + "value": "rocketchatdb", + "required": true + }, + { + "name": "ROCKETCHAT_IMAGE", + "displayName": "RocketChat Image", + "description": "The RocketChat image to use for this deployment", + "required": true, + "value": "rocketchat/rocket.chat" + }, + { + "name": "MONGODB_ADMIN_PASSWORD", + "displayName": "MongoDB Admin Password", + "description": "Password for the database admin user.", + "generate": "expression", + "from": "[a-zA-Z0-9]{16}", + "required": true + } + ] +} diff --git a/apps/meteor/.openshift/rocket-chat-persistent.json b/apps/meteor/.openshift/rocket-chat-persistent.json new file mode 100644 index 0000000..fe2a992 --- /dev/null +++ b/apps/meteor/.openshift/rocket-chat-persistent.json @@ -0,0 +1,470 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "name": "rocket-chat", + "annotations": { + "description": "Rocket.Chat with a MongoDB database running with a Persistent storage. Use this template if you want to run Rocket.Chat in production.", + "tags": "quickstart,nodejs,mongodb,instant-app", + "iconClass": "icon-nodejs" + } + }, + "objects": [ + { + "kind": "PersistentVolumeClaim", + "apiVersion": "v1", + "metadata": { + "name": "${DATABASE_SERVICE_NAME}" + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "${VOLUME_CAPACITY}" + } + } + } + }, + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "mongodb", + "labels": { + "template": "mongodb-persistent-template" + } + }, + "spec": { + "strategy": { + "type": "Recreate", + "recreateParams": { + "timeoutSeconds": 600 + } + }, + "triggers": [ + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "mongodb" + ], + "from": { + "kind": "ImageStreamTag", + "namespace": "openshift", + "name": "mongodb:latest" + } + } + }, + { + "type": "ConfigChange" + } + ], + "replicas": 1, + "test": false, + "selector": { + "name": "mongodb" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "name": "mongodb" + } + }, + "spec": { + "containers": [ + { + "name": "mongodb", + "image": "registry.access.redhat.com/rhscl/mongodb-26-rhel7:latest", + "ports": [ + { + "containerPort": 27017, + "protocol": "TCP" + } + ], + "env": [ + { + "name": "MONGODB_USER", + "value": "${MONGODB_USER}" + }, + { + "name": "MONGODB_PASSWORD", + "value": "${MONGODB_PASSWORD}" + }, + { + "name": "MONGODB_DATABASE", + "value": "${MONGODB_DATABASE}" + }, + { + "name": "MONGODB_ADMIN_PASSWORD", + "value": "${MONGODB_ADMIN_PASSWORD}" + } + ], + "resources": { + "limits": { + "memory": "${MEMORY_LIMIT}" + } + }, + "volumeMounts": [ + { + "name": "mongodb-data", + "mountPath": "/var/lib/mongodb/data" + } + ], + "livenessProbe": { + "tcpSocket": { + "port": 27017 + }, + "initialDelaySeconds": 30, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "readinessProbe": { + "exec": { + "command": [ + "/bin/sh", + "-i", + "-c", + "mongo 127.0.0.1:27017/$MONGODB_DATABASE -u $MONGODB_USER -p $MONGODB_PASSWORD --eval=\"quit()\"" + ] + }, + "initialDelaySeconds": 3, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "imagePullPolicy": "IfNotPresent", + "securityContext": { + "capabilities": {}, + "privileged": false + } + } + ], + "volumes": [ + { + "name": "${DATABASE_SERVICE_NAME}-data", + "persistentVolumeClaim": { + "claimName": "${DATABASE_SERVICE_NAME}" + } + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {} + } + } + }, + "status": {} + }, + { + "kind": "ImageStream", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat" + }, + "spec": { + "dockerImageRepository": "${ROCKETCHAT_IMAGE}", + "tags": [ + { + "name": "latest", + "annotations": { + "description": "Provides a Rocket.Chat application", + "iconClass": "icon-nodejs", + "tags": "rocketchat" + }, + "from": { + "kind": "ImageStreamTag", + "name": "latest" + }, + "generation": 1, + "importPolicy": {} + } + ] + } + }, + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "strategy": { + "type": "Rolling", + "rollingParams": { + "updatePeriodSeconds": 1, + "intervalSeconds": 1, + "timeoutSeconds": 600, + "maxUnavailable": "25%", + "maxSurge": "25%" + }, + "resources": {} + }, + "triggers": [ + { + "type": "ConfigChange" + }, + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "rocketchat" + ], + "from": { + "kind": "ImageStreamTag", + "name": "rocketchat:latest" + } + } + } + ], + "replicas": 1, + "test": false, + "selector": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "annotations": { + "openshift.io/container.rocketchat.image.entrypoint": "[\"node\",\"main.js\"]", + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "volumes": [ + { + "name": "rocketchat-volume-1", + "emptyDir": {} + } + ], + "containers": [ + { + "name": "rocketchat", + "image": "${ROCKETCHAT_IMAGE}:latest", + "ports": [ + { + "containerPort": 3000, + "protocol": "TCP" + } + ], + "env": [ + { + "name": "MONGO_URL", + "value": "mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}" + } + ], + "resources": {}, + "volumeMounts": [ + { + "name": "rocketchat-volume-1", + "mountPath": "/app/uploads" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/api/v1/info", + "port": 3000, + "scheme": "HTTP" + }, + "initialDelaySeconds": 5, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "livenessProbe": { + "httpGet": { + "path": "/api/v1/info", + "port": 3000, + "scheme": "HTTP" + }, + "initialDelaySeconds": 30, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "imagePullPolicy": "Always" + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {} + } + } + }, + "status": {} + }, + { + "kind": "Route", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/host.generated": "true" + } + }, + "spec": { + "to": { + "kind": "Service", + "name": "rocketchat" + }, + "port": { + "targetPort": "3000-tcp" + } + }, + "status": { + "ingress": null + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "mongodb", + "creationTimestamp": null, + "labels": { + "template": "mongodb-persistent-template" + } + }, + "spec": { + "ports": [ + { + "name": "mongo", + "protocol": "TCP", + "port": 27017, + "targetPort": 27017 + } + ], + "selector": { + "name": "mongodb" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "rocketchat", + "creationTimestamp": null, + "labels": { + "app": "rocketchat" + }, + "annotations": { + "openshift.io/generated-by": "OpenShiftNewApp" + } + }, + "spec": { + "ports": [ + { + "name": "3000-tcp", + "protocol": "TCP", + "port": 3000, + "targetPort": 3000 + } + ], + "selector": { + "app": "rocketchat", + "deploymentconfig": "rocketchat" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + } + ], + "parameters": [ + { + "name": "MEMORY_LIMIT", + "displayName": "Memory Limit", + "description": "Maximum amount of memory the container can use.", + "value": "512Mi" + }, + { + "name": "DATABASE_SERVICE_NAME", + "displayName": "Database Service Name", + "description": "The name of the OpenShift Service exposed for the database.", + "value": "mongodb", + "required": true + }, + { + "name": "MONGODB_USER", + "displayName": "MongoDB User", + "description": "Username for MongoDB user that will be used for accessing the database.", + "generate": "expression", + "from": "user[A-Z0-9]{3}", + "required": true + }, + { + "name": "MONGODB_PASSWORD", + "displayName": "MongoDB Password", + "description": "Password for the MongoDB user.", + "generate": "expression", + "from": "[a-zA-Z0-9]{16}", + "required": true + }, + { + "name": "MONGODB_DATABASE", + "displayName": "MongoDB Database Name", + "description": "Name of the MongoDB database accessed.", + "value": "rocketchatdb", + "required": true + }, + { + "name": "MONGODB_ADMIN_PASSWORD", + "displayName": "MongoDB Admin Password", + "description": "Password for the database admin user.", + "generate": "expression", + "from": "[a-zA-Z0-9]{16}", + "required": true + }, + { + "name": "ROCKETCHAT_IMAGE", + "displayName": "RocketChat Image", + "description": "The RocketChat image to use for this deployment", + "required": true, + "value": "rocketchat/rocket.chat" + }, + { + "name": "VOLUME_CAPACITY", + "displayName": "Volume Capacity", + "description": "Volume space available for data, e.g. 512Mi, 2Gi.", + "value": "1Gi", + "required": true + } + ] +} diff --git a/apps/meteor/.postcssrc b/apps/meteor/.postcssrc new file mode 100644 index 0000000..5a23f05 --- /dev/null +++ b/apps/meteor/.postcssrc @@ -0,0 +1,14 @@ +{ + "plugins": { + "postcss-easy-import": {}, + "postcss-custom-properties": { + "preserve": true + }, + "postcss-media-minmax": {}, + "postcss-nested": {}, + "autoprefixer": {} + }, + "excludedPackages": [ + "deepwell:bootstrap-datepicker2" + ] +} diff --git a/apps/meteor/.scripts/make-migration.ts b/apps/meteor/.scripts/make-migration.ts new file mode 100644 index 0000000..1120eeb --- /dev/null +++ b/apps/meteor/.scripts/make-migration.ts @@ -0,0 +1,50 @@ +import { readdirSync, readFileSync, writeFileSync } from 'fs'; + +import { renderFile } from 'template-file'; + +function main(number: string, comment: string): void { + if (!(Number(number) >= 0)) { + console.error(`1st param must be a valid number. ${number} provided`); + return; + } + + if (comment.trim()) { + comment = `// ${comment}`; + } + + // check if migration will conflict with current on-branch migrations + const migrationName = `v${number}`; + const fileList = readdirSync('./server/startup/migrations'); + if (fileList.includes(`${migrationName}.ts`)) { + console.error('Migration with specified number already exists'); + return; + } + + renderFile('./.scripts/migration.template', { number, comment }) + .then((renderedMigration) => { + // generate new migration file + writeFileSync(`./server/startup/migrations/${migrationName}.ts`, renderedMigration); + + // get contents of index.ts to append new migration + const indexFile = readFileSync('./server/startup/migrations/index.ts'); + const splittedIndexLines = indexFile.toString().split('\n'); + + // remove end line + xrun import + splittedIndexLines.splice(splittedIndexLines.length - 2, 0, `import './${migrationName}';`); + const data = splittedIndexLines.join('\n'); + + // append migration import to indexfile + writeFileSync('./server/startup/migrations/index.ts', data); + console.log(`Migration ${migrationName} created`); + }) + .catch(console.error); +} + +const [, , number, comment = ''] = process.argv; + +if (!number || (comment && !comment.trim())) { + console.error('Usage:\n\tmeteor npm run migration:add [migration comment: optional]\n'); + process.exit(1); +} + +main(number, comment); diff --git a/apps/meteor/.scripts/migration.template b/apps/meteor/.scripts/migration.template new file mode 100644 index 0000000..0b1ba7b --- /dev/null +++ b/apps/meteor/.scripts/migration.template @@ -0,0 +1,9 @@ +import { addMigration } from '../../lib/migrations'; + +{{ comment }} +addMigration({ + version: {{ number }}, + up() { + + }, +}); diff --git a/apps/meteor/.scripts/run-ha.ts b/apps/meteor/.scripts/run-ha.ts new file mode 100644 index 0000000..2b53a96 --- /dev/null +++ b/apps/meteor/.scripts/run-ha.ts @@ -0,0 +1,98 @@ +import type { SpawnOptions } from 'child_process'; +import { spawn } from 'child_process'; +import * as path from 'path'; + +enum ModeParam { + MAIN = 'main', + INSTANCE = 'instance', +} + +interface IConfig extends SpawnOptions { + customEnv: typeof process.env; + parentEnv: typeof process.env; +} + +function isMode(value: any): value is ModeParam { + return Object.values(ModeParam).includes(value); +} + +function buildConfig(cwd: string): IConfig { + const customEnv: IConfig['customEnv'] = { + INSTANCE_IP: '127.0.0.1', + MONGO_URL: 'mongodb://localhost:3001/meteor', + MONGO_OPLOG_URL: 'mongodb://localhost:3001/local', + }; + + return { + cwd, + stdio: 'inherit', + shell: true, + customEnv, + parentEnv: process.env, + env: { + ...customEnv, + ...process.env, + }, + }; +} + +async function runMain(config: IConfig): Promise { + const { + customEnv: { INSTANCE_IP }, + parentEnv, + ...mainConfig + } = config; + + const spawnConfig = { + ...mainConfig, + env: { + INSTANCE_IP, + ...parentEnv, + }, + }; + + spawn('meteor', spawnConfig); +} + +async function runInstance(config: IConfig): Promise { + // Desctructuring the unused variables allows us to omit them in the `mainConfig` + + const { customEnv, parentEnv, ...mainConfig } = config; + + const env = { + PORT: '3030', + ROOT_URL: '', + ...mainConfig.env, + }; + + env.ROOT_URL = `http://localhost:${env.PORT}`; + + const spawnConfig = { + ...mainConfig, + env, + }; + + spawn('node', ['.meteor/local/build/main.js'], spawnConfig); +} + +async function main(mode: any): Promise { + if (!isMode(mode)) { + mode = 'main'; + } + + const config = buildConfig(path.resolve(__dirname, '..')); + + switch (mode) { + case ModeParam.MAIN: + void runMain(config); + break; + case ModeParam.INSTANCE: + void runInstance(config); + break; + } +} + +// First two parameters are the executable and the path to this script +const [, , mode] = process.argv; + +void main(mode); diff --git a/apps/meteor/.scripts/version.js b/apps/meteor/.scripts/version.js new file mode 100644 index 0000000..d332373 --- /dev/null +++ b/apps/meteor/.scripts/version.js @@ -0,0 +1,12 @@ +const path = require('path'); + +let pkgJson = {}; + +try { + // eslint-disable-next-line import-x/no-dynamic-require + pkgJson = require(path.resolve(process.cwd(), './package.json')); +} catch (err) { + console.error('no root package.json found'); +} + +console.log(pkgJson.version); diff --git a/apps/meteor/.storybook/decorators.tsx b/apps/meteor/.storybook/decorators.tsx new file mode 100644 index 0000000..75d24df --- /dev/null +++ b/apps/meteor/.storybook/decorators.tsx @@ -0,0 +1,25 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Decorator } from '@storybook/react'; + +import ModalContextMock from '../client/stories/contexts/ModalContextMock'; +import RouterContextMock from '../client/stories/contexts/RouterContextMock'; +import ServerContextMock from '../client/stories/contexts/ServerContextMock'; +import TranslationContextMock from '../client/stories/contexts/TranslationContextMock'; + +const MockedAppRoot = mockAppRoot().build(); + +export const RocketChatDecorator: Decorator = (Story, { parameters }) => ( + + + + + +
+ +
+
+
+
+
+
+); diff --git a/apps/meteor/.storybook/main.ts b/apps/meteor/.storybook/main.ts new file mode 100644 index 0000000..1cbaa4b --- /dev/null +++ b/apps/meteor/.storybook/main.ts @@ -0,0 +1,50 @@ +import { dirname, join, resolve } from 'path'; + +import baseConfig from '@rocket.chat/storybook-config/main'; +import webpack from 'webpack'; + +export default baseConfig({ + stories: ['../client/**/*.stories.{js,tsx}'], + + webpackFinal: async (config) => { + // Those aliases are needed because dependencies in the monorepo use another + // dependencies that are not hoisted on this workspace + const swiperRoot = dirname(require.resolve('swiper/package.json')); + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + 'react$': require.resolve('../../../node_modules/react'), + // 'react/jsx-runtime': require.resolve('../../../node_modules/react/jsx-runtime'), + '@tanstack/react-query': require.resolve('../../../node_modules/@tanstack/react-query'), + '@rocket.chat/fuselage$': require.resolve('../../../node_modules/@rocket.chat/fuselage'), + // Meteor's bundler ignores the `exports` field, so source code reaches + // into swiper's internals via deep file paths. Webpack honors `exports` + // and rejects them, so map each subpath to the actual file. + 'swiper/swiper-react.mjs$': join(swiperRoot, 'swiper-react.mjs'), + 'swiper/swiper-react$': join(swiperRoot, 'swiper-react.d.ts'), + 'swiper/modules/index.mjs$': join(swiperRoot, 'modules/index.mjs'), + 'swiper/swiper.css$': join(swiperRoot, 'swiper.css'), + 'swiper/modules/zoom.css$': join(swiperRoot, 'modules/zoom.css'), + }, + // This is only needed because of Rocket.Chat's icon font. + roots: [...(config.resolve?.roots ?? []), resolve(__dirname, '../../../apps/meteor/public')], + }; + + // Strip the `env` option that addon-webpack5-compiler-swc injects on swc-loader; + // it conflicts with `jsc.target` from `.swcrc` (Meteor's Modern Build Stack). + for (const rule of (config.module?.rules ?? []) as any[]) { + for (const use of Array.isArray(rule?.use) ? rule.use : []) { + if (use?.loader?.includes?.('swc-loader') && use.options) delete use.options.env; + } + } + + config.plugins?.push( + new webpack.NormalModuleReplacementPlugin(/^meteor/, require.resolve('./mocks/meteor.ts')), + new webpack.NormalModuleReplacementPlugin(/(app)\/*.*\/(server)\/*/, require.resolve('./mocks/empty.ts')), + new webpack.NormalModuleReplacementPlugin(/rocketchat\.info$/, require.resolve('./mocks/rocketchat.info.ts')), + ); + + return config; + }, +}); diff --git a/apps/meteor/.storybook/mocks/empty.ts b/apps/meteor/.storybook/mocks/empty.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/apps/meteor/.storybook/mocks/empty.ts @@ -0,0 +1 @@ +export default {}; diff --git a/apps/meteor/.storybook/mocks/meteor.ts b/apps/meteor/.storybook/mocks/meteor.ts new file mode 100644 index 0000000..e3f0e0a --- /dev/null +++ b/apps/meteor/.storybook/mocks/meteor.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +export const Meteor = { + Device: { + isDesktop: () => false, + }, + isClient: true, + isServer: false, + _localStorage: window.localStorage, + absoluteUrl: Object.assign(() => {}, { + defaultOptions: {}, + }), + userId: () => {}, + Streamer: () => ({ + on: () => {}, + removeListener: () => {}, + }), + StreamerCentral: { + on: () => {}, + removeListener: () => {}, + }, + startup: () => {}, + methods: () => {}, + call: () => {}, + connection: { + _stream: { + on: () => {}, + }, + }, + users: {}, +}; + +export const Tracker = { + autorun: () => ({ + stop: () => {}, + }), + nonreactive: (fn: () => any) => fn(), + Dependency: () => {}, +}; + +export const Accounts = { + onLogin: () => {}, + onLogout: () => {}, +}; + +export const Mongo = { + Collection: () => ({ + find: () => ({ + observe: () => {}, + fetch: () => [], + }), + }), +}; + +export const ReactiveVar = (val: unknown) => { + let currentVal = val; + return { + get: () => currentVal, + set: (val: unknown) => { + currentVal = val; + }, + }; +}; + +export const ReactiveDict = () => ({ + get: () => {}, + set: () => {}, + all: () => {}, +}); + +export const Template = Object.assign( + () => ({ + onCreated: () => {}, + onRendered: () => {}, + onDestroyed: () => {}, + helpers: () => {}, + events: () => {}, + }), + { + registerHelper: () => {}, + __checkName: () => {}, + }, +); + +export const check = () => {}; + +export const FlowRouter = { + route: () => {}, + group: () => ({ + route: () => {}, + }), +}; + +export const Session = { + get: () => {}, + set: () => {}, +}; + +export const DDPCommon = {}; diff --git a/apps/meteor/.storybook/mocks/rocketchat.info.ts b/apps/meteor/.storybook/mocks/rocketchat.info.ts new file mode 100644 index 0000000..c1f0afb --- /dev/null +++ b/apps/meteor/.storybook/mocks/rocketchat.info.ts @@ -0,0 +1,3 @@ +export const version = '1.0.0'; + +export const Info = { version }; diff --git a/apps/meteor/.storybook/preview.ts b/apps/meteor/.storybook/preview.ts new file mode 100644 index 0000000..fdcb2ab --- /dev/null +++ b/apps/meteor/.storybook/preview.ts @@ -0,0 +1,17 @@ +import { parameters, decorators } from '@rocket.chat/storybook-config/preview'; +import type { Preview } from '@storybook/react'; + +import { RocketChatDecorator } from './decorators'; + +import '../app/theme/client/main.css'; +import 'highlight.js/styles/github.css'; + +const preview: Preview = { + parameters: { + ...parameters, + }, + decorators: [...decorators, RocketChatDecorator], + tags: ['autodocs'], +}; + +export default preview; diff --git a/apps/meteor/.stylelintignore b/apps/meteor/.stylelintignore new file mode 100644 index 0000000..edc9384 --- /dev/null +++ b/apps/meteor/.stylelintignore @@ -0,0 +1,2 @@ +app/meteor-autocomplete/client/autocomplete.css +storybook-static diff --git a/apps/meteor/.stylelintrc b/apps/meteor/.stylelintrc new file mode 100644 index 0000000..f0a8cfc --- /dev/null +++ b/apps/meteor/.stylelintrc @@ -0,0 +1,59 @@ +{ + "extends": ["stylelint-config-standard"], + "plugins": ["stylelint-order", "stylelint-selector-bem-pattern"], + "rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": ["blockless-after-same-name-blockless", "first-nested"], + "ignore": ["after-comment"] + } + ], + "block-no-empty": true, + "color-hex-length": "long", + "color-no-invalid-hex": true, + "comment-empty-line-before": [ + "always", + { + "except": ["first-nested"], + "ignore": ["stylelint-commands"] + } + ], + "comment-no-empty": true, + "comment-whitespace-inside": "always", + "custom-property-empty-line-before": "never", + "declaration-block-no-duplicate-properties": [ + true, + { + "ignore": ["consecutive-duplicates-with-different-values"] + } + ], + "declaration-block-no-redundant-longhand-properties": true, + "declaration-block-no-shorthand-property-overrides": true, + "declaration-block-single-line-max-declarations": 1, + "font-family-no-duplicate-names": true, + "function-linear-gradient-no-nonstandard-direction": true, + "function-name-case": "lower", + "keyframe-declaration-no-important": true, + "length-zero-no-unit": true, + "media-feature-name-no-unknown": true, + "no-duplicate-selectors": true, + "no-empty-source": true, + "property-no-unknown": true, + "rule-empty-line-before": [ + "always", + { + "except": ["first-nested"], + "ignore": ["after-comment"] + } + ], + "selector-pseudo-class-no-unknown": true, + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-type-case": "lower", + "selector-type-no-unknown": true, + "shorthand-property-no-redundant-values": true, + "string-no-newline": true, + "unit-no-unknown": true + } +} diff --git a/apps/meteor/.swcrc b/apps/meteor/.swcrc new file mode 100644 index 0000000..003417d --- /dev/null +++ b/apps/meteor/.swcrc @@ -0,0 +1,10 @@ +{ + "jsc": { + "target": "es2022", + "transform": { + "react": { + "runtime": "automatic" + } + } + } +} diff --git a/apps/meteor/app/apple/lib/config.ts b/apps/meteor/app/apple/lib/config.ts new file mode 100644 index 0000000..b8fde01 --- /dev/null +++ b/apps/meteor/app/apple/lib/config.ts @@ -0,0 +1,12 @@ +import type { OauthConfig } from '@rocket.chat/core-typings'; + +export const config = { + serverURL: 'https://appleid.apple.com', + authorizePath: '/auth/authorize?response_mode=form_post', + responseType: 'code id_token', + tokenPath: '/auth/token', + scope: 'name email', + mergeUsers: true, + accessTokenParam: 'access_token', + loginStyle: 'popup', +} as const satisfies OauthConfig; diff --git a/apps/meteor/app/apps/.gitignore b/apps/meteor/app/apps/.gitignore new file mode 100644 index 0000000..918ef5d --- /dev/null +++ b/apps/meteor/app/apps/.gitignore @@ -0,0 +1 @@ +.npm diff --git a/apps/meteor/app/apps/README.md b/apps/meteor/app/apps/README.md new file mode 100644 index 0000000..9704738 --- /dev/null +++ b/apps/meteor/app/apps/README.md @@ -0,0 +1,11 @@ +# Rocket.Chat Apps +Finally! :smile: + +## What is an "Orchestrator"? +An orchestrator is the file/class which is responsible for orchestrating (starting up) everything which is required of the system to get up and going. There are two of these. One for the server and one for the client. + +## What is a "Bridge"? +A bridge is a file/class which is responsible for bridging the Rocket.Chat system's data and the App system's data. They are implementations of the interfaces inside of the Rocket.Chat Apps-engine project `src/server/bridges`. They allow the two systems to talk to each other (hence the name bridge, as they "bridge the gap"). + +## What is a "Converter"? +A converter does what the name implies, it handles converting from one system's data type into the other's. **Note**: This causes a schema to be forced on the rooms and messages. diff --git a/apps/meteor/app/apps/server/bridges/activation.ts b/apps/meteor/app/apps/server/bridges/activation.ts new file mode 100644 index 0000000..b22aeac --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/activation.ts @@ -0,0 +1,42 @@ +import type { IAppServerOrchestrator, AppStatus } from '@rocket.chat/apps'; +import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp'; +import { AppActivationBridge as ActivationBridge } from '@rocket.chat/apps/dist/server/bridges/AppActivationBridge'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export class AppActivationBridge extends ActivationBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async appAdded(_app: ProxiedApp): Promise { + // await this.orch.getNotifier().appAdded(app.getID()); + + // Calls made via AppActivationBridge should NOT go through + // View https://github.com/RocketChat/Rocket.Chat/pull/29180 for details + return undefined; + } + + protected async appUpdated(_app: ProxiedApp): Promise { + // Calls made via AppActivationBridge should NOT go through + // View https://github.com/RocketChat/Rocket.Chat/pull/29180 for details + // await this.orch.getNotifier().appUpdated(app.getID()); + return undefined; + } + + protected async appRemoved(app: ProxiedApp): Promise { + await this.orch.getNotifier().appRemoved(app.getID()); + } + + protected async appStatusChanged(app: ProxiedApp, status: AppStatus): Promise { + const userStatus = ['auto_enabled', 'manually_enabled'].includes(status) ? UserStatus.ONLINE : UserStatus.OFFLINE; + + await Users.updateStatusByAppId(app.getID(), userStatus); + + await this.orch.getNotifier().appStatusUpdated(app.getID(), status); + } + + protected async actionsChanged(): Promise { + await this.orch.getNotifier().actionsChanged(); + } +} diff --git a/apps/meteor/app/apps/server/bridges/api.ts b/apps/meteor/app/apps/server/bridges/api.ts new file mode 100644 index 0000000..6323aa3 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/api.ts @@ -0,0 +1,127 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ApiBridge } from '@rocket.chat/apps/dist/server/bridges/ApiBridge'; +import type { AppApi } from '@rocket.chat/apps/dist/server/managers/AppApi'; +import type { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiRequest, IApiEndpoint, IApi } from '@rocket.chat/apps-engine/definition/api'; +import type { Response, Request, IRouter, RequestHandler } from 'express'; +import express from 'express'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { apiServer } from './router'; +import { authenticationMiddleware } from '../../../../server/api/v1/middlewares/authentication'; + +WebApp.rawConnectHandlers.use(apiServer); + +interface IRequestWithPrivateHash extends Request { + _privateHash?: string; + content?: any; +} + +export class AppApisBridge extends ApiBridge { + appRouters: Map; + + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + this.appRouters = new Map(); + + apiServer.use('/api/apps/private/:appId/:hash', (req: IRequestWithPrivateHash, res: Response) => { + const notFound = (): Response => res.sendStatus(404); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + req._privateHash = req.params.hash; + return router(req, res, notFound); + } + + notFound(); + }); + + apiServer.use('/api/apps/public/:appId', (req: Request, res: Response) => { + const notFound = (): Response => res.sendStatus(404); + + const router = this.appRouters.get(req.params.appId); + + if (router) { + return router(req, res, notFound); + } + + notFound(); + }); + } + + public async registerApi({ api, computedPath, endpoint }: AppApi, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is registering the api: "${endpoint.path}" (${computedPath})`); + + this._verifyApi(api, endpoint); + + let router = this.appRouters.get(appId); + + if (!router) { + router = express.Router(); // eslint-disable-line new-cap + this.appRouters.set(appId, router); + } + + const method = 'all'; + + let routePath = endpoint.path.trim(); + if (!routePath.startsWith('/')) { + routePath = `/${routePath}`; + } + + if (router[method] instanceof Function) { + router[method]( + routePath, + authenticationMiddleware({ rejectUnauthorized: !!endpoint.authRequired }), + Meteor.bindEnvironment(this._appApiExecutor(endpoint, appId)), + ); + } + } + + public async unregisterApis(appId: string): Promise { + this.orch.debugLog(`The App ${appId} is unregistering all apis`); + + if (this.appRouters.get(appId)) { + this.appRouters.delete(appId); + } + } + + private _verifyApi(api: IApi, endpoint: IApiEndpoint): void { + if (typeof api !== 'object') { + throw new Error('Invalid Api parameter provided, it must be a valid IApi object.'); + } + + if (typeof endpoint.path !== 'string') { + throw new Error('Invalid Api parameter provided, it must be a valid IApi object.'); + } + } + + private _appApiExecutor(endpoint: IApiEndpoint, appId: string): RequestHandler { + return (req: IRequestWithPrivateHash, res: Response): void => { + const request: IApiRequest = { + method: req.method.toLowerCase() as RequestMethod, + headers: req.headers as { [key: string]: string }, + query: (req.query as { [key: string]: string }) || {}, + params: req.params || {}, + content: req.body, + privateHash: req._privateHash, + user: req.user && this.orch.getConverters()?.get('users')?.convertToApp(req.user), + }; + + this.orch + .getManager() + ?.getApiManager() + .executeApi(appId, endpoint.path, request) + .then(({ status, headers = {}, content }) => { + res.set(headers); + res.status(status); + res.send(content); + }) + .catch((reason) => { + // Should we handle this as an error? + res.status(500).send(reason.message); + }); + }; + } +} diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js new file mode 100644 index 0000000..4205c2a --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -0,0 +1,177 @@ +import { AppBridges } from '@rocket.chat/apps/dist/server/bridges/AppBridges'; + +import { AppActivationBridge } from './activation'; +import { AppApisBridge } from './api'; +import { AppCloudBridge } from './cloud'; +import { AppCommandsBridge } from './commands'; +import { AppContactBridge } from './contact'; +import { AppDetailChangesBridge } from './details'; +import { AppEmailBridge } from './email'; +import { AppEnvironmentalVariableBridge } from './environmental'; +import { AppExperimentalBridge } from './experimental'; +import { AppHttpBridge } from './http'; +import { AppInternalBridge } from './internal'; +import { AppInternalFederationBridge } from './internalFederation'; +import { AppListenerBridge } from './listeners'; +import { AppLivechatBridge } from './livechat'; +import { AppMessageBridge } from './messages'; +import { AppModerationBridge } from './moderation'; +import { AppOAuthAppsBridge } from './oauthApps'; +import { OutboundCommunicationBridge } from './outboundCommunication'; +import { AppPersistenceBridge } from './persistence'; +import { AppRoleBridge } from './roles'; +import { AppRoomBridge } from './rooms'; +import { AppSchedulerBridge } from './scheduler'; +import { AppSettingBridge } from './settings'; +import { AppThreadBridge } from './thread'; +import { UiInteractionBridge } from './uiInteraction'; +import { AppUploadBridge } from './uploads'; +import { AppUserBridge } from './users'; +import { AppVideoConferenceBridge } from './videoConferences'; + +export class RealAppBridges extends AppBridges { + constructor(orch) { + super(); + + this._actBridge = new AppActivationBridge(orch); + this._cmdBridge = new AppCommandsBridge(orch); + this._apiBridge = new AppApisBridge(orch); + this._detBridge = new AppDetailChangesBridge(orch); + this._envBridge = new AppEnvironmentalVariableBridge(orch); + this._httpBridge = new AppHttpBridge(orch); + this._lisnBridge = new AppListenerBridge(orch); + this._msgBridge = new AppMessageBridge(orch); + this._persistBridge = new AppPersistenceBridge(orch); + this._roomBridge = new AppRoomBridge(orch); + this._internalBridge = new AppInternalBridge(orch); + this._setsBridge = new AppSettingBridge(orch); + this._userBridge = new AppUserBridge(orch); + this._livechatBridge = new AppLivechatBridge(orch); + this._uploadBridge = new AppUploadBridge(orch); + this._uiInteractionBridge = new UiInteractionBridge(orch); + this._schedulerBridge = new AppSchedulerBridge(orch); + this._cloudWorkspaceBridge = new AppCloudBridge(orch); + this._videoConfBridge = new AppVideoConferenceBridge(orch); + this._oAuthBridge = new AppOAuthAppsBridge(orch); + this._internalFedBridge = new AppInternalFederationBridge(); + this._moderationBridge = new AppModerationBridge(orch); + this._threadBridge = new AppThreadBridge(orch); + this._roleBridge = new AppRoleBridge(orch); + this._emailBridge = new AppEmailBridge(orch); + this._contactBridge = new AppContactBridge(orch); + this._outboundMessageBridge = new OutboundCommunicationBridge(orch); + this._experimentalBridge = new AppExperimentalBridge(orch); + } + + getCommandBridge() { + return this._cmdBridge; + } + + getApiBridge() { + return this._apiBridge; + } + + getEnvironmentalVariableBridge() { + return this._envBridge; + } + + getHttpBridge() { + return this._httpBridge; + } + + getListenerBridge() { + return this._lisnBridge; + } + + getMessageBridge() { + return this._msgBridge; + } + + getThreadBridge() { + return this._threadBridge; + } + + getPersistenceBridge() { + return this._persistBridge; + } + + getAppActivationBridge() { + return this._actBridge; + } + + getAppDetailChangesBridge() { + return this._detBridge; + } + + getRoomBridge() { + return this._roomBridge; + } + + getInternalBridge() { + return this._internalBridge; + } + + getServerSettingBridge() { + return this._setsBridge; + } + + getUserBridge() { + return this._userBridge; + } + + getLivechatBridge() { + return this._livechatBridge; + } + + getUploadBridge() { + return this._uploadBridge; + } + + getUiInteractionBridge() { + return this._uiInteractionBridge; + } + + getSchedulerBridge() { + return this._schedulerBridge; + } + + getCloudWorkspaceBridge() { + return this._cloudWorkspaceBridge; + } + + getVideoConferenceBridge() { + return this._videoConfBridge; + } + + getOutboundMessageBridge() { + return this._outboundMessageBridge; + } + + getOAuthAppsBridge() { + return this._oAuthBridge; + } + + getInternalFederationBridge() { + return this._internalFedBridge; + } + + getModerationBridge() { + return this._moderationBridge; + } + + getRoleBridge() { + return this._roleBridge; + } + + getEmailBridge() { + return this._emailBridge; + } + + getContactBridge() { + return this._contactBridge; + } + + getExperimentalBridge() { + return this._experimentalBridge; + } +} diff --git a/apps/meteor/app/apps/server/bridges/cloud.ts b/apps/meteor/app/apps/server/bridges/cloud.ts new file mode 100644 index 0000000..e3f108e --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/cloud.ts @@ -0,0 +1,19 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { CloudWorkspaceBridge } from '@rocket.chat/apps/dist/server/bridges/CloudWorkspaceBridge'; +import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import { getWorkspaceAccessTokenWithScope } from '../../../../server/lib/cloud'; + +export class AppCloudBridge extends CloudWorkspaceBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + public async getWorkspaceToken(scope: string, appId: string): Promise { + this.orch.debugLog(`App ${appId} is getting the workspace's token`); + + const token = await getWorkspaceAccessTokenWithScope({ scope }); + + return token; + } +} diff --git a/apps/meteor/app/apps/server/bridges/commands.ts b/apps/meteor/app/apps/server/bridges/commands.ts new file mode 100644 index 0000000..5282e63 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/commands.ts @@ -0,0 +1,221 @@ +import type { IAppServerOrchestrator, IAppsRoom, IAppsUser } from '@rocket.chat/apps'; +import { CommandBridge } from '@rocket.chat/apps/dist/server/bridges/CommandBridge'; +import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; +import type { IMessage, RequiredField, SlashCommand, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { Utilities } from '../../../../ee/lib/misc/Utilities'; +import { parseParameters } from '../../../../lib/utils/parseParameters'; +import { slashCommands } from '../../../../server/lib/utils/slashCommand'; + +export class AppCommandsBridge extends CommandBridge { + disabledCommands: Map; + + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + this.disabledCommands = new Map(); + } + + protected async doesCommandExist(command: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is checking if "${command}" command exists.`); + + if (typeof command !== 'string' || command.length === 0) { + return false; + } + + const cmd = command.toLowerCase(); + + return typeof slashCommands.commands[cmd] === 'object' || this.disabledCommands.has(cmd); + } + + protected async enableCommand(command: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is attempting to enable the command: "${command}"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + if (!this.disabledCommands.has(cmd)) { + throw new Error(`The command is not currently disabled: "${cmd}"`); + } + + slashCommands.commands[cmd] = this.disabledCommands.get(cmd) as (typeof slashCommands.commands)[string]; + this.disabledCommands.delete(cmd); + + void this.orch.getNotifier().commandUpdated(cmd); + } + + protected async disableCommand(command: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is attempting to disable the command: "${command}"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + if (this.disabledCommands.has(cmd)) { + // The command is already disabled, no need to disable it yet again + return; + } + + const commandObj = slashCommands.commands[cmd]; + + if (typeof commandObj === 'undefined') { + throw new Error(`Command does not exist in the system currently: "${cmd}"`); + } + + this.disabledCommands.set(cmd, commandObj); + delete slashCommands.commands[cmd]; + + void this.orch.getNotifier().commandDisabled(cmd); + } + + // command: { command, paramsExample, i18nDescription, executor: function } + protected async modifyCommand(command: ISlashCommand, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is attempting to modify the command: "${command}"`); + + this._verifyCommand(command); + + const cmd = command.command.toLowerCase(); + if (typeof slashCommands.commands[cmd] === 'undefined') { + throw new Error(`Command does not exist in the system currently (or it is disabled): "${cmd}"`); + } + + const item = slashCommands.commands[cmd]; + + item.params = command.i18nParamsExample ? command.i18nParamsExample : item.params; + item.description = command.i18nDescription ? command.i18nDescription : item.params; + item.callback = this._appCommandExecutor.bind(this); + item.providesPreview = command.providesPreview; + item.previewer = command.previewer ? this._appCommandPreviewer.bind(this) : item.previewer; + item.previewCallback = ( + command.executePreviewItem ? this._appCommandPreviewExecutor.bind(this) : item.previewCallback + ) as (typeof slashCommands.commands)[string]['previewCallback']; + + slashCommands.commands[cmd] = item; + void this.orch.getNotifier().commandUpdated(cmd); + } + + protected async registerCommand(command: ISlashCommand, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is registering the command: "${command.command}"`); + + this._verifyCommand(command); + + const item = { + appId, + command: command.command.toLowerCase(), + params: Utilities.getI18nKeyForApp(command.i18nParamsExample, appId), + description: Utilities.getI18nKeyForApp(command.i18nDescription, appId), + permission: command.permission, + callback: this._appCommandExecutor.bind(this), + providesPreview: command.providesPreview, + previewer: command.providesPreview ? this._appCommandPreviewer.bind(this) : undefined, + previewCallback: (command.providesPreview ? this._appCommandPreviewExecutor.bind(this) : undefined) as + (typeof slashCommands.commands)[string]['previewCallback'] | undefined, + } as SlashCommand; + + slashCommands.commands[command.command.toLowerCase()] = item; + void this.orch.getNotifier().commandAdded(command.command.toLowerCase()); + } + + protected async unregisterCommand(command: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is unregistering the command: "${command}"`); + + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('Invalid command parameter provided, must be a string.'); + } + + const cmd = command.toLowerCase(); + this.disabledCommands.delete(cmd); + delete slashCommands.commands[cmd]; + + void this.orch.getNotifier().commandRemoved(cmd); + } + + private _verifyCommand(command: ISlashCommand): void { + if (typeof command !== 'object') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (typeof command.command !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (command.i18nParamsExample && typeof command.i18nParamsExample !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (command.i18nDescription && typeof command.i18nDescription !== 'string') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + + if (typeof command.providesPreview !== 'boolean') { + throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.'); + } + } + + private async _appCommandExecutor({ command, message, params, triggerId, userId }: SlashCommandCallbackParams): Promise { + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId); + const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid); + const threadId = message.tmid; + const parameters = parseParameters(params); + + const context = new SlashCommandContext( + Object.freeze(user as IAppsUser), + Object.freeze(room as IAppsRoom), + Object.freeze(parameters) as string[], + threadId, + triggerId, + ); + + await this.orch.getManager()?.getCommandManager().executeCommand(command, context); + } + + private async _appCommandPreviewer( + command: string, + parameters: any, + message: RequiredField, 'rid'>, + userId: string, + ): Promise { + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId); + const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid); + const threadId = message.tmid; + const params = parseParameters(parameters); + + const context = new SlashCommandContext( + Object.freeze(user as IAppsUser), + Object.freeze(room as IAppsRoom), + Object.freeze(params) as string[], + threadId, + ); + return this.orch.getManager()?.getCommandManager().getPreviews(command, context); + } + + private async _appCommandPreviewExecutor( + command: string, + parameters: any, + message: IMessage, + preview: ISlashCommandPreviewItem, + userId: string, + triggerId: string, + ): Promise { + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId); + const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid); + const threadId = message.tmid; + const params = parseParameters(parameters); + + const context = new SlashCommandContext( + Object.freeze(user as IAppsUser), + Object.freeze(room as IAppsRoom), + Object.freeze(params) as string[], + threadId, + triggerId, + ); + + await this.orch.getManager()?.getCommandManager().executePreview(command, preview, context); + } +} diff --git a/apps/meteor/app/apps/server/bridges/contact.ts b/apps/meteor/app/apps/server/bridges/contact.ts new file mode 100644 index 0000000..b8705e8 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/contact.ts @@ -0,0 +1,39 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ContactBridge } from '@rocket.chat/apps/dist/server/bridges/ContactBridge'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import { addContactEmail } from '../../../../server/lib/omnichannel/contacts/addContactEmail'; +import { verifyContactChannel } from '../../../../server/lib/omnichannel/contacts/verifyContactChannel'; + +export class AppContactBridge extends ContactBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + async getById(contactId: ILivechatContact['_id'], appId: string): Promise { + this.orch.debugLog(`The app ${appId} is fetching a contact`); + return this.orch.getConverters().get('contacts').convertById(contactId); + } + + async verifyContact( + verifyContactChannelParams: { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; + }, + appId: string, + ): Promise { + this.orch.debugLog(`The app ${appId} is verifing a contact`); + // Note: If there is more than one app installed, whe should validate the app that called this method to be same one + // selected in the setting. + await verifyContactChannel(verifyContactChannelParams); + } + + protected async addContactEmail(contactId: ILivechatContact['_id'], email: string, appId: string): Promise { + this.orch.debugLog(`The app ${appId} is adding a new email to the contact`); + const contact = await addContactEmail(contactId, email); + return this.orch.getConverters().get('contacts').convertContact(contact); + } +} diff --git a/apps/meteor/app/apps/server/bridges/details.ts b/apps/meteor/app/apps/server/bridges/details.ts new file mode 100644 index 0000000..03f25d9 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/details.ts @@ -0,0 +1,19 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { AppDetailChangesBridge as DetailChangesBridge } from '@rocket.chat/apps/dist/server/bridges/AppDetailChangesBridge'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +export class AppDetailChangesBridge extends DetailChangesBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected onAppSettingsChange(appId: string, setting: ISetting): void { + const logFailure = () => console.warn('failed to notify about the setting change.', appId); + + try { + this.orch.getNotifier().appSettingsChange(appId, setting).catch(logFailure); + } catch (e) { + logFailure(); + } + } +} diff --git a/apps/meteor/app/apps/server/bridges/email.ts b/apps/meteor/app/apps/server/bridges/email.ts new file mode 100644 index 0000000..8a5d7a9 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/email.ts @@ -0,0 +1,23 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { EmailBridge } from '@rocket.chat/apps/dist/server/bridges/EmailBridge'; +import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; + +import * as Mailer from '../../../../server/lib/notifications/email/api'; +import { settings } from '../../../../server/settings'; + +export class AppEmailBridge extends EmailBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async sendEmail(email: IEmail, appId: string): Promise { + let { from } = email; + if (!from) { + this.orch.debugLog(`The app ${appId} didn't provide a from address, using the default one.`); + from = String(settings.get('From_Email')); + } + + this.orch.debugLog(`The app ${appId} is sending an email.`); + await Mailer.send({ ...email, from }); + } +} diff --git a/apps/meteor/app/apps/server/bridges/environmental.ts b/apps/meteor/app/apps/server/bridges/environmental.ts new file mode 100644 index 0000000..c79ffbe --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/environmental.ts @@ -0,0 +1,45 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { EnvironmentalVariableBridge } from '@rocket.chat/apps/dist/server/bridges/EnvironmentalVariableBridge'; + +export class AppEnvironmentalVariableBridge extends EnvironmentalVariableBridge { + allowed: Array; + + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + this.allowed = ['NODE_ENV', 'ROOT_URL', 'INSTANCE_IP']; + } + + protected async getValueByName(envVarName: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the environmental variable value ${envVarName}.`); + + if (!(await this.isReadable(envVarName, appId))) { + throw new Error(`The environmental variable "${envVarName}" is not readable.`); + } + + return process.env[envVarName]; + } + + protected async isReadable(envVarName: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is checking if the environmental variable is readable ${envVarName}.`); + + return this.allowed.includes(envVarName.toUpperCase()) || this.isAppsOwnVariable(envVarName, appId); + } + + protected isAppsOwnVariable(envVarName: string, appId: string): boolean { + /** + * Replace the letter `-` with `_` since environment variable name doesn't support it + */ + const appVariablePrefix = `RC_APPS_${appId.toUpperCase().replace(/-/g, '_')}`; + return envVarName.toUpperCase().startsWith(appVariablePrefix); + } + + protected async isSet(envVarName: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is checking if the environmental variable is set ${envVarName}.`); + + if (!(await this.isReadable(envVarName, appId))) { + throw new Error(`The environmental variable "${envVarName}" is not readable.`); + } + + return typeof process.env[envVarName] !== 'undefined'; + } +} diff --git a/apps/meteor/app/apps/server/bridges/experimental.ts b/apps/meteor/app/apps/server/bridges/experimental.ts new file mode 100644 index 0000000..fea16f9 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/experimental.ts @@ -0,0 +1,8 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ExperimentalBridge } from '@rocket.chat/apps/dist/server/bridges/ExperimentalBridge'; + +export class AppExperimentalBridge extends ExperimentalBridge { + constructor(protected readonly orch: IAppServerOrchestrator) { + super(); + } +} diff --git a/apps/meteor/app/apps/server/bridges/http.ts b/apps/meteor/app/apps/server/bridges/http.ts new file mode 100644 index 0000000..4ecbcbb --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/http.ts @@ -0,0 +1,131 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import type { IHttpBridgeRequestInfo } from '@rocket.chat/apps/dist/server/bridges/HttpBridge'; +import { HttpBridge } from '@rocket.chat/apps/dist/server/bridges/HttpBridge'; +import type { IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors'; +import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; +import { censorUrl } from '@rocket.chat/tools'; + +import { settings } from '../../../../server/settings'; + +const isGetOrHead = (method: string): boolean => ['GET', 'HEAD'].includes(method.toUpperCase()); + +// Previously, there was no timeout for HTTP requests. +// We're setting the default timeout now to 3 minutes as it +// seems to be a good balance +const DEFAULT_TIMEOUT = 3 * 60 * 1000; + +export class AppHttpBridge extends HttpBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async call(info: IHttpBridgeRequestInfo): Promise { + // begin comptability with old HTTP.call API + const url = new URL(info.url); + + const { request, method } = info; + + const { headers = {} } = request; + + let { content } = request; + + if (!content && typeof request.data === 'object') { + content = request.data; + } + + if (request.auth) { + if (request.auth.indexOf(':') < 0) { + throw new Error('auth option should be of the form "username:password"'); + } + + const base64 = Buffer.from(request.auth, 'ascii').toString('base64'); + headers.Authorization = `Basic ${base64}`; + } + + let paramsForBody; + + if (content || isGetOrHead(method)) { + if (request.params) { + Object.keys(request.params).forEach((key) => { + if (request.params?.[key]) { + url.searchParams.append(key, request.params?.[key]); + } + }); + } + } else { + paramsForBody = request.params; + } + + if (paramsForBody) { + const data = new URLSearchParams(); + Object.entries(paramsForBody).forEach(([key, value]) => { + data.append(key, value); + }); + content = data.toString(); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (isGetOrHead(method)) { + content = undefined; + } + + const timeout = request.timeout || DEFAULT_TIMEOUT; + + // end comptability with old HTTP.call API + + this.orch.debugLog({ msg: `The App ${info.appId} is requesting from the outter webs:`, info: { ...info, url: censorUrl(info.url) } }); + + const shouldIgnoreSsrf = request.ssrfValidation !== true; + const fetchOptions: ExtendedFetchOptions = { + method, + body: content, + headers, + timeout, + ...(shouldIgnoreSsrf + ? { ignoreSsrfValidation: true } + : { ignoreSsrfValidation: false, allowList: settings.get('SSRF_Allowlist') }), + }; + + const response = await fetch( + url.href, + fetchOptions, + (request.hasOwnProperty('strictSSL') && !request.strictSSL) || + (request.hasOwnProperty('rejectUnauthorized') && request.rejectUnauthorized), + ); + + const result: IHttpResponse = { + url: info.url, + method: info.method, + statusCode: response.status, + headers: Object.fromEntries(response.headers as unknown as any), + }; + + const body = Buffer.from(await response.arrayBuffer()); + + if (request.encoding === null) { + /** + * The property `content` is not appropriately typed in the + * Apps-engine definition, and we can't simply change it there + * as it would be a breaking change. Thus, we're left with this + * type assertion. + */ + result.content = body as any; + } else { + result.content = body.toString(request.encoding as BufferEncoding); + result.data = ((): any => { + const contentType = (response.headers.get('content-type') || '').split(';')[0]; + if (!['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].includes(contentType)) { + return null; + } + + try { + return JSON.parse(result.content); + } catch { + return null; + } + })(); + } + + return result; + } +} diff --git a/apps/meteor/app/apps/server/bridges/index.ts b/apps/meteor/app/apps/server/bridges/index.ts new file mode 100644 index 0000000..7ac3977 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/index.ts @@ -0,0 +1 @@ +export * from './bridges'; diff --git a/apps/meteor/app/apps/server/bridges/internal.ts b/apps/meteor/app/apps/server/bridges/internal.ts new file mode 100644 index 0000000..8f5ab68 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/internal.ts @@ -0,0 +1,48 @@ +import type { IAppServerOrchestrator, IAppsSetting } from '@rocket.chat/apps'; +import { InternalBridge } from '@rocket.chat/apps/dist/server/bridges/InternalBridge'; +import type { ISetting, ISubscription } from '@rocket.chat/core-typings'; +import { Settings, Subscriptions } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; + +import { deasyncPromise } from '../../../../server/deasync/deasync'; + +export class AppInternalBridge extends InternalBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected getUsernamesOfRoomByIdSync(roomId: string): Array { + return deasyncPromise(this.getUsernamesOfRoomById(roomId)); + } + + protected async getUsernamesOfRoomById(roomId: string): Promise> { + // This function will be converted to sync inside the apps-engine code + // TODO: Track Deprecation + + if (!roomId) { + return []; + } + + const records = await Subscriptions.findByRoomIdWhenUsernameExists(roomId, { + projection: { + 'u.username': 1, + }, + }).toArray(); + + if (!records || records.length === 0) { + return []; + } + + return records.map((s: ISubscription) => s.u.username).filter(isTruthy); + } + + protected async getWorkspacePublicKey(): Promise { + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const publicKeySetting: ISetting | null = await Settings.findOneById('Cloud_Workspace_PublicKey'); + + return this.orch + .getConverters() + ?.get('settings') + .convertToApp(publicKeySetting as ISetting); + } +} diff --git a/apps/meteor/app/apps/server/bridges/internalFederation.ts b/apps/meteor/app/apps/server/bridges/internalFederation.ts new file mode 100644 index 0000000..1b22184 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/internalFederation.ts @@ -0,0 +1,12 @@ +import type { IInternalFederationBridge } from '@rocket.chat/apps/dist/server/bridges/IInternalFederationBridge'; +import { FederationKeys } from '@rocket.chat/models'; + +export class AppInternalFederationBridge implements IInternalFederationBridge { + async getPrivateKey(): Promise { + return FederationKeys.getKey('private'); + } + + async getPublicKey(): Promise { + return FederationKeys.getKey('public'); + } +} diff --git a/apps/meteor/app/apps/server/bridges/listeners.ts b/apps/meteor/app/apps/server/bridges/listeners.ts new file mode 100644 index 0000000..35ba619 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/listeners.ts @@ -0,0 +1,560 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsMessage } from '@rocket.chat/apps'; +import type { IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/email'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; +import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat'; +import { isLivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; +import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit'; +import type { IUIKitLivechatIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat'; +import type { IUserContext, IUserUpdateContext } from '@rocket.chat/apps-engine/definition/users'; +import type { IMessage, IRoom, IUser, ILivechatDepartment, IUpload } from '@rocket.chat/core-typings'; + +type LivechatTransferData = { + type: LivechatTransferEventType; + room: string; + from: string; + to: string; +}; + +type LivechatAgentData = { + room: IRoom; + user: IUser; +}; + +type UserStatusChangedData = { + user: IUser; + currentStatus: string; + previousStatus: string; +}; + +type UserCrudData = { + user: IUser; + performedBy?: IUser; + previousUser?: IUser; +}; + +// IPostMessageSentToBot is an internally triggered event, based on IPostMessageSent +// so we don't add it here +type HandleMessageEvent = + | { + event: AppInterface.IPostMessageDeleted; + payload: [IMessage, IUser]; + } + | { + event: AppInterface.IPostMessageReacted; + payload: [IMessage, IUser, string, boolean]; + } + | { + event: AppInterface.IPostMessageFollowed; + payload: [IMessage, IUser, boolean]; + } + | { + event: AppInterface.IPostMessagePinned; + payload: [IMessage, IUser, boolean]; + } + | { + event: AppInterface.IPostMessageStarred; + payload: [IMessage, IUser, boolean]; + } + | { + event: AppInterface.IPostMessageReported; + payload: [IMessage, IUser, string]; + } + | { + event: + | AppInterface.IPostSystemMessageSent + | AppInterface.IPreMessageSentPrevent + | AppInterface.IPreMessageSentExtend + | AppInterface.IPreMessageSentModify + | AppInterface.IPostMessageSent + | AppInterface.IPreMessageDeletePrevent + | AppInterface.IPreMessageUpdatedPrevent + | AppInterface.IPreMessageUpdatedExtend + | AppInterface.IPreMessageUpdatedModify + | AppInterface.IPostMessageUpdated; + payload: [IMessage]; + }; + +type HandleRoomEvent = + | { + event: AppInterface.IPreRoomUserJoined | AppInterface.IPostRoomUserJoined; + payload: [IRoom, IUser, IUser]; + } + | { + event: AppInterface.IPreRoomUserLeave | AppInterface.IPostRoomUserLeave; + payload: [IRoom, IUser, IUser]; + } + | { + event: + | AppInterface.IPreRoomCreatePrevent + | AppInterface.IPreRoomCreateExtend + | AppInterface.IPreRoomCreateModify + | AppInterface.IPostRoomCreate + | AppInterface.IPreRoomDeletePrevent + | AppInterface.IPostRoomDeleted + | AppInterface.IPreRoomUserJoined + | AppInterface.IPostRoomUserJoined + | AppInterface.IPreRoomUserLeave + | AppInterface.IPostRoomUserLeave; + payload: [IRoom]; + }; + +type HandleLivechatEvent = + | { + event: AppInterface.IPostLivechatAgentAssigned | AppInterface.IPostLivechatAgentUnassigned; + payload: [LivechatAgentData]; + } + | { + event: AppInterface.IPostLivechatRoomTransferred; + payload: [LivechatTransferData]; + } + | { + event: AppInterface.IPostLivechatGuestSaved; + payload: [string]; + } + | { + event: AppInterface.IPostLivechatRoomSaved; + payload: [string]; + } + | { + event: AppInterface.IPostLivechatDepartmentRemoved; + payload: [ILivechatDepartment]; + } + | { + event: AppInterface.IPostLivechatDepartmentDisabled; + payload: [ILivechatDepartment]; + } + | { + event: + | AppInterface.ILivechatRoomClosedHandler + | AppInterface.IPreLivechatRoomCreatePrevent + | AppInterface.IPostLivechatRoomStarted + | AppInterface.IPostLivechatRoomClosed; + payload: [IRoom]; + }; + +type HandleUserEvent = + | { + event: AppInterface.IPostUserLoggedIn | AppInterface.IPostUserLoggedOut; + payload: [IUser]; + } + | { + event: AppInterface.IPostUserStatusChanged; + payload: [UserStatusChangedData]; + } + | { + event: AppInterface.IPostUserDeleted | AppInterface.IPostUserCreated | AppInterface.IPostUserUpdated; + payload: [UserCrudData]; + }; + +type HandleDefaultEvent = + | { + event: AppInterface.IPostExternalComponentOpened | AppInterface.IPostExternalComponentClosed; + payload: [IExternalComponent]; + } + | { + event: AppInterface.IUIKitInteractionHandler; + payload: [UIKitIncomingInteraction]; + } + | { + event: AppInterface.IUIKitLivechatInteractionHandler; + payload: [IUIKitLivechatIncomingInteraction]; + } + | { + event: AppInterface.IPreEmailSent; + payload: [IPreEmailSentContext]; + }; + +type HandleFileUploadEvent = { + event: AppInterface.IPreFileUpload; + payload: [{ file: IUpload; content: Buffer | string }]; +}; + +export type HandleEvent = + HandleMessageEvent | HandleRoomEvent | HandleLivechatEvent | HandleUserEvent | HandleFileUploadEvent | HandleDefaultEvent; + +export class AppListenerBridge { + constructor(private readonly orch: IAppServerOrchestrator) {} + + // eslint-disable-next-line complexity + async handleEvent(args: HandleEvent): Promise { + switch (args.event) { + case AppInterface.IPreFileUpload: + return this.uploadEvent(args); + case AppInterface.IPostMessageDeleted: + case AppInterface.IPostMessageReacted: + case AppInterface.IPostMessageFollowed: + case AppInterface.IPostMessagePinned: + case AppInterface.IPostMessageStarred: + case AppInterface.IPostMessageReported: + case AppInterface.IPostSystemMessageSent: + case AppInterface.IPreMessageSentPrevent: + case AppInterface.IPreMessageSentExtend: + case AppInterface.IPreMessageSentModify: + case AppInterface.IPostMessageSent: + case AppInterface.IPreMessageDeletePrevent: + case AppInterface.IPreMessageUpdatedPrevent: + case AppInterface.IPreMessageUpdatedExtend: + case AppInterface.IPreMessageUpdatedModify: + case AppInterface.IPostMessageUpdated: + return this.messageEvent(args); + case AppInterface.IPreRoomCreatePrevent: + case AppInterface.IPreRoomCreateExtend: + case AppInterface.IPreRoomCreateModify: + case AppInterface.IPostRoomCreate: + case AppInterface.IPreRoomDeletePrevent: + case AppInterface.IPostRoomDeleted: + case AppInterface.IPreRoomUserJoined: + case AppInterface.IPostRoomUserJoined: + case AppInterface.IPreRoomUserLeave: + case AppInterface.IPostRoomUserLeave: + return this.roomEvent(args); + /** + * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event + */ + case AppInterface.ILivechatRoomClosedHandler: + case AppInterface.IPreLivechatRoomCreatePrevent: + case AppInterface.IPostLivechatRoomStarted: + case AppInterface.IPostLivechatRoomClosed: + case AppInterface.IPostLivechatAgentAssigned: + case AppInterface.IPostLivechatAgentUnassigned: + case AppInterface.IPostLivechatRoomTransferred: + case AppInterface.IPostLivechatGuestSaved: + case AppInterface.IPostLivechatRoomSaved: + case AppInterface.IPostLivechatDepartmentRemoved: + case AppInterface.IPostLivechatDepartmentDisabled: + return this.livechatEvent(args); + case AppInterface.IPostUserCreated: + case AppInterface.IPostUserUpdated: + case AppInterface.IPostUserDeleted: + case AppInterface.IPostUserLoggedIn: + case AppInterface.IPostUserLoggedOut: + case AppInterface.IPostUserStatusChanged: + return this.userEvent(args); + default: + return this.defaultEvent(args); + } + } + + async defaultEvent(args: HandleDefaultEvent): Promise { + return this.orch.getManager().getListenerManager().executeListener(args.event, args.payload[0]); + } + + async uploadEvent(args: HandleFileUploadEvent): Promise { + const [{ file, content }] = args.payload; + + const tmpfile = path.join(this.orch.getManager().getTempFilePath(), crypto.randomUUID()); + + if (typeof content === 'string') { + // If content is a string, we assume it's a path and create a symlink to avoid file duplication + await fs.promises.symlink(content, tmpfile, 'file').catch((err) => { + this.orch.getRocketChatLogger().error({ msg: `AppListenerBridge: Could not create symlink at ${tmpfile}`, err }); + + throw new Error('Error sending file to apps', { cause: err }); + }); + } else { + // Otherwise, we write the buffer content to a temporary file + await fs.promises.writeFile(tmpfile, content).catch((err) => { + this.orch.getRocketChatLogger().error({ msg: `AppListenerBridge: Could not write temporary file at ${tmpfile}`, err }); + + throw new Error('Error sending file to apps', { cause: err }); + }); + } + + try { + const uploadDetails = { + name: file.name || '', + size: file.size || 0, + type: file.type || '', + rid: file.rid || '', + userId: file.userId || '', + visitorToken: file.token, + }; + + // Execute both events for backward compatibility + await this.orch.getManager().getListenerManager().executeListener(AppInterface.IPreFileUpload, { + file: uploadDetails, + path: tmpfile, + }); + } finally { + await fs.promises + .unlink(tmpfile) + .catch((err) => + this.orch.getRocketChatLogger().warn({ msg: `AppListenerBridge: Could not delete temporary file at ${tmpfile}`, err }), + ); + } + } + + async messageEvent(args: HandleMessageEvent): Promise { + const [message] = args.payload; + + const msg = await this.orch.getConverters().get('messages').convertMessage(message); + + const result = await (() => { + switch (args.event) { + case AppInterface.IPostMessageDeleted: + const [, userDeleted] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userDeleted), + }); + case AppInterface.IPostMessageReacted: + const [, userReacted, reaction, isReacted] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userReacted), + reaction, + isReacted, + }); + case AppInterface.IPostMessageFollowed: + const [, userFollowed, isFollowed] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userFollowed), + isFollowed, + }); + case AppInterface.IPostMessagePinned: + const [, userPinned, isPinned] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userPinned), + isPinned, + }); + case AppInterface.IPostMessageStarred: + const [, userStarred, isStarred] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userStarred), + isStarred, + }); + case AppInterface.IPostMessageReported: + const [, userReported, reason] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + message: msg, + user: this.orch.getConverters().get('users').convertToApp(userReported), + reason, + }); + default: + return this.orch.getManager().getListenerManager().executeListener(args.event, msg); + } + })(); + + // TODO: weird that boolean is not returned by executeListener + if (typeof result === 'boolean' || result === undefined) { + return result ?? undefined; + } + + return this.orch + .getConverters() + .get('messages') + .convertAppMessage(result as IAppsMessage); + } + + async roomEvent(args: HandleRoomEvent): Promise { + const [room] = args.payload; + + const rm = await this.orch.getConverters().get('rooms').convertRoom(room); + + const result = await (() => { + switch (args.event) { + case AppInterface.IPreRoomUserJoined: + case AppInterface.IPostRoomUserJoined: + const [, joiningUser, invitingUser] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + room: rm, + joiningUser: this.orch.getConverters().get('users').convertToApp(joiningUser)!, + inviter: this.orch.getConverters().get('users').convertToApp(invitingUser), + }); + case AppInterface.IPreRoomUserLeave: + case AppInterface.IPostRoomUserLeave: + const [, leavingUser, removedBy] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + room: rm, + leavingUser: this.orch.getConverters().get('users').convertToApp(leavingUser)!, + removedBy: this.orch.getConverters().get('users').convertToApp(removedBy), + }); + default: + return this.orch.getManager().getListenerManager().executeListener(args.event, rm); + } + })(); + + if (typeof result === 'boolean' || result === undefined) { + return result ?? undefined; + } + + return this.orch.getConverters().get('rooms').convertAppRoom(result); + } + + async livechatEvent(args: HandleLivechatEvent): Promise { + switch (args.event) { + case AppInterface.IPostLivechatAgentAssigned: + case AppInterface.IPostLivechatAgentUnassigned: + const [agentData] = args.payload; + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + room: (await this.orch.getConverters().get('rooms').convertRoom(agentData.room)) as IAppsLivechatRoom, + agent: this.orch.getConverters().get('users').convertToApp(agentData.user), + }); + + case AppInterface.IPostLivechatRoomTransferred: { + const [transferData] = args.payload; + const converter = transferData.type === LivechatTransferEventType.AGENT ? 'users' : 'departments'; + + const room = await this.orch.getConverters().get('rooms').convertById(transferData.room); + const from = await this.orch.getConverters().get(converter).convertById(transferData.from); + const to = await this.orch.getConverters().get(converter).convertById(transferData.to); + + if (!room) { + throw new Error(`Room with id ${transferData.room} not found`); + } + + if (!to) { + throw new Error(`Transfer to entity with id ${transferData.to} not found`); + } + + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, { + room, + from: from as NonNullable, // type definition in the apps-engine seems to be incorrect + to, + type: transferData.type, + }); + } + + case AppInterface.IPostLivechatGuestSaved: { + const [visitorId] = args.payload; + const visitor = await this.orch.getConverters().get('visitors').convertById(visitorId); + + if (!visitor) { + throw new Error(`Visitor with id ${visitorId} not found`); + } + + return this.orch.getManager().getListenerManager().executeListener(args.event, visitor); + } + + case AppInterface.IPostLivechatRoomSaved: { + const [roomId] = args.payload; + const room = await this.orch.getConverters().get('rooms').convertById(roomId); + + if (!room) { + throw new Error(`Room with id ${roomId} not found`); + } + + return this.orch + .getManager() + .getListenerManager() + .executeListener(args.event, room as IAppsLivechatRoom); + } + + case AppInterface.IPostLivechatDepartmentDisabled: { + const [departmentData] = args.payload; + const department = await this.orch.getConverters().get('departments').convertDepartment(departmentData); + + if (!department) { + throw new Error(`Department ${departmentData._id} not found`); + } + + return this.orch.getManager().getListenerManager().executeListener(args.event, { department }); + } + + case AppInterface.IPostLivechatDepartmentRemoved: { + const [departmentData] = args.payload; + const department = await this.orch.getConverters().get('departments').convertDepartment(departmentData); + + if (!department) { + throw new Error(`Department ${departmentData._id} not found`); + } + + return this.orch.getManager().getListenerManager().executeListener(args.event, { department }); + } + + default: + const [roomData] = args.payload; + const room = await this.orch.getConverters().get('rooms').convertRoom(roomData); + + if (!room) { + throw new Error(`Room ${roomData._id} not found`); + } + + if (!isLivechatRoom(room)) { + throw new Error(`Room ${roomData._id} is not a livechat room`); + } + + return this.orch.getManager().getListenerManager().executeListener(args.event, room); + } + } + + async userEvent(args: HandleUserEvent): Promise { + switch (args.event) { + case AppInterface.IPostUserLoggedIn: + case AppInterface.IPostUserLoggedOut: { + const [loggedInUser] = args.payload; + const context = this.orch.getConverters().get('users').convertToApp(loggedInUser); + return this.orch.getManager().getListenerManager().executeListener(args.event, context); + } + case AppInterface.IPostUserStatusChanged: { + const [statusData] = args.payload; + const { currentStatus, previousStatus } = statusData; + const context = { + user: this.orch.getConverters().get('users').convertToApp(statusData.user), + currentStatus, + previousStatus, + }; + + return this.orch.getManager().getListenerManager().executeListener(args.event, context); + } + case AppInterface.IPostUserCreated: + case AppInterface.IPostUserDeleted: { + const [crudData] = args.payload; + const context: IUserContext = { + user: this.orch.getConverters().get('users').convertToApp(crudData.user), + performedBy: this.orch.getConverters().get('users').convertToApp(crudData.performedBy), + }; + + return this.orch.getManager().getListenerManager().executeListener(args.event, context); + } + case AppInterface.IPostUserUpdated: { + const [crudData] = args.payload; + const context: IUserUpdateContext = { + user: this.orch.getConverters().get('users').convertToApp(crudData.user), + performedBy: this.orch.getConverters().get('users').convertToApp(crudData.performedBy), + previousData: this.orch.getConverters().get('users').convertToApp(crudData.previousUser), + }; + + return this.orch.getManager().getListenerManager().executeListener(args.event, context); + } + } + } +} diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts new file mode 100644 index 0000000..5cd6e7e --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -0,0 +1,416 @@ +import type { IAppServerOrchestrator, IAppsLivechatMessage } from '@rocket.chat/apps'; +import { LivechatBridge } from '@rocket.chat/apps/dist/server/bridges/LivechatBridge'; +import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { + IVisitorExternalIdentifier, + IVisitor, + ILivechatRoom, + ILivechatTransferData, + IDepartment, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; + +import { deasyncPromise } from '../../../../server/deasync/deasync'; +import { callbacks } from '../../../../server/lib/callbacks'; +import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom'; +import { setCustomFields } from '../../../../server/lib/omnichannel/custom-fields'; +import { getRoomMessages } from '../../../../server/lib/omnichannel/getRoomMessages'; +import type { ILivechatMessage } from '../../../../server/lib/omnichannel/localTypes'; +import { updateMessage, sendMessage } from '../../../../server/lib/omnichannel/messages'; +import { resolveVisitor } from '../../../../server/lib/omnichannel/resolveVisitor'; +import { createRoom } from '../../../../server/lib/omnichannel/rooms'; +import { online } from '../../../../server/lib/omnichannel/service-status'; +import { transfer } from '../../../../server/lib/omnichannel/transfer'; +import { settings } from '../../../../server/settings'; + +declare module '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator' { + interface IExtraRoomParams { + customFields?: Record; + } +} + +export class AppLivechatBridge extends LivechatBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected isOnline(departmentId?: string): boolean { + // This function will be converted to sync inside the apps-engine code + // TODO: Track Deprecation + return deasyncPromise(online(departmentId)); + } + + protected async isOnlineAsync(departmentId?: string): Promise { + return online(departmentId); + } + + protected async createMessage(message: IAppsLivechatMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new message.`); + + if (!message.token) { + throw new Error('Invalid token for livechat message'); + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const guest = this.orch.getConverters().get('visitors').convertAppVisitor(message.visitor); + const appMessage = await this.orch.getConverters().get('messages').convertAppMessage(message); + const livechatMessage = appMessage as ILivechatMessage | undefined; + + const msg = await sendMessage({ + guest: guest as ILivechatVisitor, + message: livechatMessage as ILivechatMessage, + agent: undefined, + roomInfo: { + source: { + type: OmnichannelSourceType.APP, + id: appId, + alias: this.orch.getManager()?.getOneById(appId)?.getNameSlug(), + }, + }, + }); + + return msg._id; + } + + protected async getMessageById(messageId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the message: "${messageId}"`); + + const message = await this.orch.getConverters().get('messages').convertById(messageId); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + return message as IAppsLivechatMessage; + } + + protected async updateMessage(message: IAppsLivechatMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a message.`); + + const data = { + guest: message.visitor, + message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), + }; + + // @ts-expect-error IVisitor vs ILivechatVisitor :( + await updateMessage(data); + } + + protected async createRoom( + visitor: IVisitor, + agent: IUser, + appId: string, + { source, customFields }: IExtraRoomParams = {}, + ): Promise { + this.orch.debugLog(`The App ${appId} is creating a livechat room.`); + + let agentRoom: SelectedAgent | undefined; + if (agent?.id) { + const user = await Users.getAgentInfo(agent.id, settings.get('Livechat_show_agent_email')); + if (!user) { + throw new Error(`The agent with id "${agent.id}" was not found.`); + } + agentRoom = { agentId: user._id, username: user.username }; + } + + const room = await createRoom({ + visitor: this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), + roomInfo: { + source: { + type: OmnichannelSourceType.APP, + id: appId, + alias: this.orch.getManager()?.getOneById(appId)?.getName(), + ...(source?.type === 'app' && { + sidebarIcon: source.sidebarIcon, + defaultIcon: source.defaultIcon, + label: source.label, + destination: source.destination, + }), + }, + }, + agent: agentRoom, + extraData: customFields && { customFields }, + }); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + return this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise; + } + + protected async closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is closing a livechat room.`); + + const user = closer && this.orch.getConverters()?.get('users').convertToRocketChat(closer); + const visitor = this.orch.getConverters()?.get('visitors').convertAppVisitor(room.visitor); + + const closeData: any = { + room: await this.orch.getConverters()?.get('rooms').convertAppRoom(room), + comment, + ...(user && { user }), + ...(visitor && { visitor }), + }; + + await closeRoom(closeData); + + return true; + } + + protected async findOpenRoomsByAgentId(agentId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat rooms associated with agent ${agentId}`); + + if (!agentId) { + throw new Error('Invalid agentId'); + } + + const rooms = await LivechatRooms.findOpenByAgent(agentId).toArray(); + return Promise.all(rooms.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise)); + } + + protected async countOpenRoomsByAgentId(agentId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is counting livechat rooms associated with agent ${agentId}`); + + if (!agentId) { + throw new Error('Invalid agentId'); + } + + return LivechatRooms.countOpenByAgent(agentId); + } + + protected async findRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + if (!visitor) { + return []; + } + + let result; + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + + if (departmentId) { + result = await LivechatRooms.findOpenByVisitorTokenAndDepartmentId(visitor.token, departmentId, {}, extraQuery).toArray(); + } else { + result = await LivechatRooms.findOpenByVisitorToken(visitor.token, {}, extraQuery).toArray(); + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + return Promise.all(result.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise)); + } + + /** + * @deprecated Use `createAndReturnVisitor` instead. + * Note: This method does not support `externalIds`. + */ + protected async createVisitor(visitor: IVisitor, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`); + + const registerData = { + department: visitor.department, + username: visitor.username, + name: visitor.name, + token: visitor.token, + email: '', + id: visitor.id, + ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), + ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), + }; + + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); + + if (!livechatVisitor) { + throw new Error('Invalid visitor, cannot create'); + } + + return livechatVisitor._id; + } + + protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`); + + // Add appId to each externalId entry + const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId })); + + const registerData = { + department: visitor.department, + username: visitor.username, + name: visitor.name, + token: visitor.token, + email: '', + id: visitor.id, + ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), + ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), + ...(externalIds?.length && { externalIds }), + }; + + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); + + return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); + } + + protected async transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is transfering a livechat.`); + + if (!visitor) { + throw new Error('Invalid visitor, cannot transfer'); + } + + const { targetAgent, targetDepartment: departmentId, currentRoom } = transferData; + + const appUser = await Users.findOneByAppId(appId, {}); + if (!appUser) { + throw new Error('Invalid app user, cannot transfer'); + } + const { _id, username, name, type } = appUser; + const transferredBy = { + _id, + username, + name, + type, + userType: 'user', + } as const; + + let userId; + let transferredTo; + + if (targetAgent?.id) { + transferredTo = await Users.findOneAgentById(targetAgent.id, { + projection: { _id: 1, username: 1, name: 1 }, + }); + if (!transferredTo) { + throw new Error('Invalid target agent, cannot transfer'); + } + + userId = transferredTo._id; + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + return transfer( + (await this.orch.getConverters()?.get('rooms').convertAppRoom(currentRoom)) as IOmnichannelRoom, + this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), + { userId, departmentId, transferredBy, transferredTo }, + ); + } + + protected async findVisitors(query: object, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + if (this.orch.isDebugging()) { + console.warn('The method AppLivechatBridge.findVisitors is deprecated. Please consider using its alternatives'); + } + + return Promise.all( + (await LivechatVisitors.findEnabled(query).toArray()).map( + async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor), + ), + ); + } + + protected async findVisitorById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + return this.orch.getConverters()?.get('visitors').convertById(id); + } + + protected async findVisitorByEmail(email: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + return this.orch + .getConverters() + ?.get('visitors') + .convertVisitor(await LivechatVisitors.findOneGuestByEmailAddress(email)); + } + + protected async findVisitorByToken(token: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + return this.orch + .getConverters() + ?.get('visitors') + .convertVisitor(await LivechatVisitors.getVisitorByToken(token, {})); + } + + protected async findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`); + + return this.orch + .getConverters() + ?.get('visitors') + .convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber)); + } + + protected async resolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`); + + const visitor = await resolveVisitor({ + appId, + externalId, + contactData, + }); + + return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); + } + + protected async updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`); + + const visitor = await LivechatVisitors.updateExternalIdById(visitorId, appId, externalId); + + return this.orch.getConverters()?.get('visitors').convertVisitor(visitor); + } + + protected async findDepartmentByIdOrName(value: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is looking for livechat departments.`); + + return this.orch + .getConverters() + ?.get('departments') + .convertDepartment(await LivechatDepartment.findOneByIdOrName(value, {})); + } + + protected async findDepartmentsEnabledWithAgents(appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is looking for livechat departments.`); + + const converter = this.orch.getConverters()?.get('departments'); + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const boundConverter = converter.convertDepartment.bind(converter) as (_: ILivechatDepartment) => Promise; + + return Promise.all((await LivechatDepartment.findEnabledWithAgents().toArray()).map(boundConverter)); + } + + protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the transcript for livechat room ${roomId}.`); + const messageConverter = this.orch.getConverters()?.get('messages'); + + if (!messageConverter) { + throw new Error('Could not get the message converter to process livechat room messages'); + } + + const livechatMessages = await getRoomMessages({ rid: roomId }); + return Promise.all(await livechatMessages.map((message) => messageConverter.convertMessage(message, livechatMessages)).toArray()); + } + + protected async setCustomFields( + data: { token: IVisitor['token']; key: string; value: string; overwrite: boolean }, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is setting livechat visitor's custom fields.`); + + return setCustomFields(data); + } +} diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts new file mode 100644 index 0000000..61d23f1 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -0,0 +1,139 @@ +import type { IAppServerOrchestrator, IAppsMessage, IAppsUser } from '@rocket.chat/apps'; +import type { ITypingDescriptor } from '@rocket.chat/apps/dist/server/bridges/MessageBridge'; +import { MessageBridge } from '@rocket.chat/apps/dist/server/bridges/MessageBridge'; +import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { api } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Users, Subscriptions } from '@rocket.chat/models'; + +import { deleteMessage } from '../../../../server/lib/messages/deleteMessage'; +import { updateMessage } from '../../../../server/lib/messages/updateMessage'; +import { executeSetReaction } from '../../../../server/lib/messaging/reactions/setReaction'; +import notifications from '../../../../server/lib/notifications/core/lib/Notifications'; +import { executeSendMessage } from '../../../../server/meteor-methods/messages/sendMessage'; + +export class AppMessageBridge extends MessageBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async create(message: IAppsMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new message.`); + + const convertedMessage: IMessage | undefined = await this.orch.getConverters()?.get('messages').convertAppMessage(message); + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const definedMessage = convertedMessage as IMessage; + + const sentMessage = await executeSendMessage(definedMessage.u._id, definedMessage); + return sentMessage._id; + } + + protected async getById(messageId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the message: "${messageId}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const message: IAppsMessage | undefined = await this.orch.getConverters()?.get('messages').convertById(messageId); + return message as IAppsMessage; + } + + protected async update(message: IAppsMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a message.`); + + if (!message.editor) { + throw new Error('Invalid editor assigned to the message for the update.'); + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message, true); + const editor = await Users.findOneById(message.editor.id); + + if (!editor) { + throw new Error('Invalid editor assigned to the message for the update.'); + } + + await updateMessage(msg as IMessage, editor); + } + + protected async delete(message: IAppsMessage, user: IAppsUser, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deleting a message.`); + + if (!message.id) { + throw new Error('Invalid message id'); + } + + const convertedMsg = await this.orch.getConverters()?.get('messages').convertAppMessage(message); + const convertedUser = (await Users.findOneById(user.id)) || this.orch.getConverters()?.get('users').convertToRocketChat(user); + + await deleteMessage(convertedMsg as IMessage, convertedUser); + } + + protected async notifyUser(user: IAppsUser, message: IAppsMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is notifying a user.`); + + const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message); + + if (!msg) { + return; + } + + void api.broadcast('notify.ephemeralMessage', user.id, msg.rid, { + ...msg, + }); + } + + protected async notifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is notifying a room's users.`); + + if (!room?.id) { + return; + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const msg: IMessage | undefined = await this.orch.getConverters()?.get('messages').convertAppMessage(message); + const convertedMessage = msg as IMessage; + + const users = (await Subscriptions.findByRoomIdWhenUserIdExists(room.id, { projection: { 'u._id': 1 } }).toArray()).map((s) => s.u._id); + + await Users.findByIds(users, { projection: { _id: 1 } }).forEach( + ({ _id }: { _id: string }) => + void api.broadcast('notify.ephemeralMessage', _id, room.id, { + ...convertedMessage, + }), + ); + } + + protected async typing({ scope, id, username, isTyping }: ITypingDescriptor): Promise { + switch (scope) { + case 'room': + if (!username) { + throw new Error('Invalid username'); + } + + notifications.notifyRoom(id, 'user-activity', username, isTyping ? ['user-typing'] : []); + return; + default: + throw new Error('Unrecognized typing scope provided'); + } + } + + private isValidReaction(reaction: Reaction): boolean { + return reaction.startsWith(':') && reaction.endsWith(':'); + } + + protected async addReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, true); + } + + protected async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise { + if (!this.isValidReaction(reaction)) { + throw new Error('Invalid reaction'); + } + + return executeSetReaction(userId, reaction, messageId, false); + } +} diff --git a/apps/meteor/app/apps/server/bridges/moderation.ts b/apps/meteor/app/apps/server/bridges/moderation.ts new file mode 100644 index 0000000..42d521b --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/moderation.ts @@ -0,0 +1,46 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ModerationBridge } from '@rocket.chat/apps/dist/server/bridges/ModerationBridge'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { ModerationReports } from '@rocket.chat/models'; + +import { reportMessage } from '../../../../server/lib/moderation/reportMessage'; + +export class AppModerationBridge extends ModerationBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async report(messageId: IMessage['id'], description: string, userId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new report.`); + + if (!messageId) { + throw new Error('Invalid message id'); + } + + if (!description) { + throw new Error('Invalid description'); + } + + await reportMessage(messageId, description, userId || 'rocket.cat'); + } + + protected async dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is dismissing reports by message id.`); + + if (!messageId) { + throw new Error('Invalid message id'); + } + + await ModerationReports.hideMessageReportsByMessageId(messageId, appId, reason, action); + } + + protected async dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is dismissing reports by user id.`); + + if (!userId) { + throw new Error('Invalid user id'); + } + await ModerationReports.hideMessageReportsByUserId(userId, appId, reason, action); + } +} diff --git a/apps/meteor/app/apps/server/bridges/oauthApps.ts b/apps/meteor/app/apps/server/bridges/oauthApps.ts new file mode 100644 index 0000000..d16087c --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/oauthApps.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'node:crypto'; + +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { OAuthAppsBridge } from '@rocket.chat/apps/dist/server/bridges/OAuthAppsBridge'; +import type { IOAuthApp, IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; +import type { IOAuthApps } from '@rocket.chat/core-typings'; +import { OAuthApps, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +export class AppOAuthAppsBridge extends OAuthAppsBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async create(oAuthApp: IOAuthAppParams, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new OAuth app.`); + const { clientId, clientSecret } = oAuthApp; + const botUser = await Users.findOne({ appId }); + + if (!botUser) { + throw new Error(`The user for app ${appId} is not registered.`); + } + + const { _id, username } = botUser; + + return ( + await OAuthApps.insertOne({ + ...oAuthApp, + _id: randomUUID(), + appId, + clientId: clientId ?? Random.id(), + clientSecret: clientSecret ?? Random.secret(), + _createdAt: new Date(), + _createdBy: { + _id, + username, + }, + } as unknown as IOAuthApps) + ).insertedId; + } + + protected async getById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the OAuth app by ID ${id}.`); + const data = await OAuthApps.findOne({ _id: id, appId }); + if (data) { + const { _id, _createdAt, _createdBy, _updatedAt, ...rest } = data as any; + return { + ...rest, + id: _id, + createdAt: _createdAt.toDateString(), + createdBy: { + id: _createdBy._id, + username: _createdBy.username, + }, + updatedAt: _updatedAt, + }; + } + + return null; + } + + protected async getByName(name: string, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the OAuth apps by name.`); + return OAuthApps.find({ name, appId }).toArray() as unknown as Array; + } + + protected async update(oAuthApp: IOAuthAppParams, id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating the OAuth app ${id}.`); + await OAuthApps.updateOne({ _id: id, appId }, { $set: oAuthApp }, { upsert: true }); + } + + protected async delete(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deleting the OAuth app ${id}.`); + await OAuthApps.deleteOne({ _id: id, appId }); + } + + protected async purge(appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deleting an OAuth app.`); + await OAuthApps.deleteMany({ appId }); + } +} diff --git a/apps/meteor/app/apps/server/bridges/outboundCommunication.ts b/apps/meteor/app/apps/server/bridges/outboundCommunication.ts new file mode 100644 index 0000000..ba0f88d --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/outboundCommunication.ts @@ -0,0 +1,45 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { OutboundMessageBridge } from '@rocket.chat/apps/dist/server/bridges/OutboundMessagesBridge'; +import type { + IOutboundEmailMessageProvider, + IOutboundMessageProviders, + IOutboundPhoneMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import { getOutboundService } from '../../../../server/lib/omnichannel/outboundcommunication'; + +export class OutboundCommunicationBridge extends OutboundMessageBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async registerPhoneProvider(provider: IOutboundPhoneMessageProvider, appId: string): Promise { + try { + this.orch.debugLog(`App ${appId} is registering a phone outbound provider.`); + getOutboundService().outboundMessageProvider.registerPhoneProvider(provider); + } catch (err) { + this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to register phone provider' }); + throw new Error('error-registering-provider'); + } + } + + protected async registerEmailProvider(provider: IOutboundEmailMessageProvider, appId: string): Promise { + try { + this.orch.debugLog(`App ${appId} is registering an email outbound provider.`); + getOutboundService().outboundMessageProvider.registerEmailProvider(provider); + } catch (err) { + this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to register email provider' }); + throw new Error('error-registering-provider'); + } + } + + protected async unRegisterProvider(provider: IOutboundMessageProviders, appId: string): Promise { + try { + this.orch.debugLog(`App ${appId} is unregistering an outbound provider.`); + getOutboundService().outboundMessageProvider.unregisterProvider(appId, provider.type); + } catch (err) { + this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to unregister provider' }); + throw new Error('error-unregistering-provider'); + } + } +} diff --git a/apps/meteor/app/apps/server/bridges/persistence.ts b/apps/meteor/app/apps/server/bridges/persistence.ts new file mode 100644 index 0000000..ce85b80 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/persistence.ts @@ -0,0 +1,138 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { PersistenceBridge } from '@rocket.chat/apps/dist/server/bridges/PersistenceBridge'; +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; +import type { InsertOneResult } from 'mongodb'; + +export class AppPersistenceBridge extends PersistenceBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async purge(appId: string): Promise { + this.orch.debugLog(`The App's persistent storage is being purged: ${appId}`); + + await this.orch.getPersistenceModel().remove({ appId }); + } + + protected async create(data: object, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is storing a new object in their persistence.`); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + return this.orch + .getPersistenceModel() + .insertOne({ appId, data }) + .then(({ insertedId }: InsertOneResult) => (insertedId as unknown as string) || ''); + } + + protected async createWithAssociations(data: object, associations: Array, appId: string): Promise { + this.orch.debugLog({ + msg: `The App ${appId} is storing a new object in their persistence that is associated with some models.`, + associations, + }); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + return this.orch + .getPersistenceModel() + .insertOne({ appId, associations, data }) + .then(({ insertedId }: InsertOneResult) => (insertedId as unknown as string) || ''); + } + + protected async readById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is reading their data in their persistence with the id: "${id}"`); + + const record = await this.orch.getPersistenceModel().findOneById(id); + + return record?.data; + } + + protected async readByAssociations(associations: Array, appId: string): Promise> { + this.orch.debugLog({ msg: `The App ${appId} is searching for records that are associated with the following:`, associations }); + + const records = await this.orch + .getPersistenceModel() + .find({ + appId, + associations: { $all: associations }, + }) + .toArray(); + + return Array.isArray(records) ? records.map((r) => r.data) : []; + } + + protected async remove(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is removing one of their records by the id: "${id}"`); + + const record = await this.orch.getPersistenceModel().findOne({ _id: id, appId }); + + if (!record) { + return undefined; + } + + await this.orch.getPersistenceModel().remove({ _id: id, appId }); + + return record.data; + } + + protected async removeByAssociations( + associations: Array, + appId: string, + ): Promise | undefined> { + this.orch.debugLog({ msg: `The App ${appId} is removing records with the following associations:`, associations }); + + const query = { + appId, + associations: { + $all: associations, + }, + }; + + const records = await this.orch.getPersistenceModel().find(query).toArray(); + + if (!records?.length) { + return undefined; + } + + await this.orch.getPersistenceModel().remove(query); + + return Array.isArray(records) ? records.map((r) => r.data) : []; + } + + protected async update(id: string, data: object, _upsert: boolean, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating the record "${id}"`); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + throw new Error('Not implemented.'); + } + + protected async updateByAssociations( + associations: Array, + data: object, + upsert = true, + appId: string, + ): Promise { + this.orch.debugLog({ msg: `The App ${appId} is updating the record with association to data as follows:`, associations }); + + if (typeof data !== 'object') { + throw new Error('Attempted to store an invalid data type, it must be an object.'); + } + + const query = { + appId, + associations, + }; + + return this.orch + .getPersistenceModel() + .update(query, { $set: { data } }, { upsert }) + .then(({ upsertedId }: any) => upsertedId || ''); + } +} diff --git a/apps/meteor/app/apps/server/bridges/roles.ts b/apps/meteor/app/apps/server/bridges/roles.ts new file mode 100644 index 0000000..7f7dadd --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/roles.ts @@ -0,0 +1,36 @@ +import type { IAppServerOrchestrator, IAppsRole } from '@rocket.chat/apps'; +import { RoleBridge } from '@rocket.chat/apps/dist/server/bridges/RoleBridge'; +import type { IRole } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +export class AppRoleBridge extends RoleBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getOneByIdOrName(idOrName: IAppsRole['id'] | IAppsRole['name'], appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the roleByIdOrName: "${idOrName}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const role: IRole | null = await Roles.findOneByIdOrName(idOrName); + return this.orch + .getConverters() + ?.get('roles') + .convertRole(role as IRole); + } + + protected async getCustomRoles(appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the custom roles`); + + const cursor = Roles.findCustomRoles(); + + const roles: IAppsRole[] = []; + + for await (const role of cursor) { + const convRole = await this.orch.getConverters()?.get('roles').convertRole(role); + roles.push(convRole); + } + + return roles; + } +} diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts new file mode 100644 index 0000000..76c2dbc --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -0,0 +1,385 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '@rocket.chat/apps/dist/server/bridges/RoomBridge'; +import { RoomBridge } from '@rocket.chat/apps/dist/server/bridges/RoomBridge'; +import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom, IMessage as ICoreMessage } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms, Messages } from '@rocket.chat/models'; +import type { FindOptions, Sort } from 'mongodb'; + +import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom'; +import { deleteRoom } from '../../../../server/lib/rooms/deleteRoom'; +import { removeUserFromRoom } from '../../../../server/lib/rooms/removeUserFromRoom'; +import { createDirectMessage } from '../../../../server/meteor-methods/messages/createDirectMessage'; +import { createDiscussion } from '../../../../server/meteor-methods/messages/createDiscussion'; +import { createChannelMethod } from '../../../../server/meteor-methods/rooms/createChannel'; +import { createPrivateGroupMethod } from '../../../../server/meteor-methods/rooms/createPrivateGroup'; + +const rawRoomProjection: FindOptions['projection'] = { + _id: 1, + fname: 1, + name: 1, + usernames: 1, + members: 1, + uids: 1, + default: 1, + ro: 1, + sysMes: 1, + msgs: 1, + ts: 1, + _updatedAt: 1, + closedAt: 1, + lm: 1, + description: 1, + customFields: 1, + prid: 1, + teamId: 1, + teamMain: 1, + federated: 1, + federation: 1, + livechatData: 1, + waitingResponse: 1, + open: 1, + source: 1, + closer: 1, + t: 1, + u: 1, + v: 1, + contactId: 1, + departmentId: 1, + closedBy: 1, + servedBy: 1, + responseBy: 1, +}; + +export class AppRoomBridge extends RoomBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async create(room: IRoom, members: Array, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a new room.`, room); + + const rcRoom = await this.orch.getConverters()?.get('rooms').convertAppRoom(room); + + switch (room.type) { + case RoomType.CHANNEL: + return this.createChannel(room.creator.id, rcRoom, members); + case RoomType.PRIVATE_GROUP: + return this.createPrivateGroup(room.creator.id, rcRoom, members); + case RoomType.DIRECT_MESSAGE: + return this.createDirectMessage(room.creator.id, members); + default: + throw new Error('Only channels, private groups and direct messages can be created.'); + } + } + + private prepareExtraData(room: Record): Record { + const extraData = Object.assign({}, room); + delete extraData.name; + delete extraData.t; + delete extraData.ro; + delete extraData.customFields; + + return extraData; + } + + private async createChannel(userId: string, room: ICoreRoom, members: string[]): Promise { + return (await createChannelMethod(userId, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; + } + + private async createDirectMessage(userId: string, members: string[]): Promise { + return (await createDirectMessage(members, userId)).rid; + } + + private async createPrivateGroup(userId: string, room: ICoreRoom, members: string[]): Promise { + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Invalid user'); + } + return (await createPrivateGroupMethod(user, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; + } + + protected async getById(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the roomById: "${roomId}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('rooms').convertById(roomId); + return promise as Promise; + } + + protected async getByName(roomName: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the roomByName: "${roomName}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('rooms').convertByName(roomName); + return promise as Promise; + } + + protected async getCreatorById(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the room's creator by id: "${roomId}"`); + + const room = await Rooms.findOneById(roomId); + + if (!room?.u?._id) { + return undefined; + } + + return this.orch.getConverters()?.get('users').convertById(room.u._id); + } + + protected async getCreatorByName(roomName: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the room's creator by name: "${roomName}"`); + + const room = await Rooms.findOneByName(roomName, {}); + + if (!room?.u?._id) { + return undefined; + } + + return this.orch.getConverters()?.get('users').convertById(room.u._id); + } + + protected async getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the messages of the room: "${roomId}" with options:`, options); + + const { limit, skip = 0, sort: _sort, showThreadMessages } = options; + + const messageConverter = this.orch.getConverters()?.get('messages'); + if (!messageConverter) { + throw new Error('Message converter not found'); + } + + const threadFilterQuery = showThreadMessages ? {} : { tmid: { $exists: false } }; + + // We support only one field for now + const sort: Sort | undefined = _sort?.createdAt ? { ts: _sort.createdAt } : undefined; + + const messageQueryOptions: FindOptions = { + limit, + skip, + sort, + }; + + const query = { + rid: roomId, + _hidden: { $ne: true }, + t: { $exists: false }, + ...threadFilterQuery, + }; + + const cursor = Messages.find(query, messageQueryOptions); + + const messagePromises: Promise[] = await cursor.map((message) => messageConverter.convertMessageRaw(message)).toArray(); + + return Promise.all(messagePromises); + } + + protected async getMembers(roomId: string, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the room's members by room id: "${roomId}"`); + const subscriptions = await Subscriptions.findByRoomId(roomId, {}); + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promises: Promise<(IUser | undefined)[]> = Promise.all( + (await subscriptions.toArray()).map((sub: ISubscription) => this.orch.getConverters()?.get('users').convertById(sub.u?._id)), + ); + + return promises as Promise; + } + + protected async getAllRooms(filters: GetRoomsFilters = {}, options: GetRoomsOptions = {}, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting all rooms with options`, options); + + const { limit = 100, skip = 0 } = options; + + const findOptions: FindOptions = { + sort: { ts: -1 }, + skip, + limit: Math.min(limit, 100), + projection: rawRoomProjection, + }; + + const { types, discussions, teams } = filters; + + const rooms: IRoomRaw[] = []; + + const roomConverter = this.orch.getConverters()?.get('rooms'); + if (!roomConverter) { + throw new Error('Room converter not found'); + } + + for await (const room of Rooms.findAllByTypesAndDiscussionAndTeam({ types, discussions, teams }, findOptions)) { + const converted = await roomConverter.convertRoomRaw(room); + if (converted) { + rooms.push(converted); + } + } + + return rooms; + } + + protected async getDirectByUsernames(usernames: Array, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting direct room by usernames: "${usernames}"`); + const room = await Rooms.findDirectRoomContainingAllUsernames(usernames, {}); + if (!room) { + return undefined; + } + return this.orch.getConverters()?.get('rooms').convertRoom(room); + } + + protected async update(room: IRoom, members: Array = [], appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a room.`); + + const rm = await this.orch.getConverters()?.get('rooms').convertAppRoom(room, true); + + const updateResult = await Rooms.updateOne({ _id: room.id }, { $set: rm }); + + if (!updateResult.matchedCount) { + throw new Error('Room id not found'); + } + + for (const username of members) { + const member = await Users.findOneByUsername(username, {}); + + if (!member) { + continue; + } + + await addUserToRoom(room.id, member); + } + } + + protected async delete(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deleting a room.`); + await deleteRoom(roomId); + } + + protected async createDiscussion( + room: IRoom, + parentMessage: IMessage | undefined = undefined, + reply: string | undefined = '', + members: Array = [], + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is creating a new discussion.`, room); + + const rcRoom = await this.orch.getConverters()?.get('rooms').convertAppRoom(room); + + let rcMessage; + if (parentMessage) { + rcMessage = await this.orch.getConverters()?.get('messages').convertAppMessage(parentMessage); + } + + if (!rcRoom.prid || !(await Rooms.findOneById(rcRoom.prid))) { + throw new Error('There must be a parent room to create a discussion.'); + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const discussion = { + prid: rcRoom.prid, + t_name: rcRoom.fname as string, + pmid: rcMessage ? rcMessage._id : undefined, + reply: reply && reply.trim() !== '' ? reply : undefined, + users: members.length > 0 ? members : [], + }; + + const { rid } = await createDiscussion(room.creator.id, discussion); + + return rid; + } + + protected getModerators(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting room moderators for room id: ${roomId}`); + return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'moderator'); + } + + protected getOwners(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting room owners for room id: ${roomId}`); + return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'owner'); + } + + protected getLeaders(roomId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting room leaders for room id: ${roomId}`); + return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'leader'); + } + + private async getUsersByRoomIdAndSubscriptionRole(roomId: string, role: string): Promise { + const subs = await Subscriptions.findByRoomIdAndRoles<{ uid: string }>(roomId, [role], { + projection: { uid: '$u._id', _id: 0 }, + }).toArray(); + // Was this a bug? + const users = await Users.findByIds(subs.map((user: { uid: string }) => user.uid)).toArray(); + const userConverter = this.orch.getConverters().get('users'); + return users.map((user: ICoreUser) => userConverter.convertToApp(user)); + } + + protected async getUnreadByUser(roomId: string, uid: string, options: GetMessagesOptions, appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting the unread messages for the user: "${uid}" in the room: "${roomId}"`); + + const messageConverter = this.orch.getConverters()?.get('messages'); + if (!messageConverter) { + throw new Error('Message converter not found'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, uid, { projection: { ls: 1 } }); + + if (!subscription) { + const errorMessage = `No subscription found for user with ID "${uid}" in room with ID "${roomId}". This means the user is not subscribed to the room.`; + this.orch.debugLog(errorMessage); + throw new Error('User not subscribed to room'); + } + + const lastSeen = subscription?.ls; + if (!lastSeen) { + return []; + } + + const sort: Sort = options.sort?.createdAt ? { ts: options.sort.createdAt } : { ts: 1 }; + + const cursor = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + roomId, + lastSeen, + new Date(), + [], + { + limit: options.limit, + skip: options.skip, + sort, + }, + options.showThreadMessages, + ); + + const messages = await cursor.toArray(); + return Promise.all(messages.map((msg) => messageConverter.convertMessageRaw(msg))); + } + + protected async getUserUnreadMessageCount(roomId: string, uid: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the unread messages count of the room: "${roomId}" for the user: "${uid}"`); + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, uid, { projection: { ls: 1 } }); + + if (!subscription) { + const errorMessage = `No subscription found for user with ID "${uid}" in room with ID "${roomId}". This means the user is not subscribed to the room.`; + this.orch.debugLog(errorMessage); + throw new Error('User not subscribed to room'); + } + + const lastSeen = subscription?.ls; + if (!lastSeen) { + return 0; + } + + return Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(roomId, lastSeen, new Date(), []); + } + + protected async removeUsers(roomId: string, usernames: Array, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is removing users ${usernames} from room id: ${roomId}`); + if (!roomId) { + throw new Error('roomId was not provided.'); + } + + const members = await Users.findUsersByUsernames(usernames, { limit: 50 }).toArray(); + await Promise.all(members.map((user) => removeUserFromRoom(roomId, user))); + } +} diff --git a/apps/meteor/app/apps/server/bridges/router.spec.ts b/apps/meteor/app/apps/server/bridges/router.spec.ts new file mode 100644 index 0000000..61ebe71 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/router.spec.ts @@ -0,0 +1,95 @@ +import request from 'supertest'; + +import { apiServer } from './router'; + +apiServer.post('/api/apps/private/:appId/:hash', (req, res) => { + res.json({ + body: req.body, + params: req.params, + query: req.query, + }); +}); + +apiServer.post('/api/apps/public/:appId', (req, res) => { + res.json({ + body: req.body, + params: req.params, + query: req.query, + }); +}); + +describe('API Server Routes', () => { + it('should handle POST requests to /api/apps/private/:appId/:hash using json encoding', async () => { + const appId = 'testAppId'; + const hash = 'testHash'; + await request(apiServer) + .post(`/api/apps/private/${appId}/${hash}`) + .set('Content-Type', 'application/json') + .send({ + key: 'value', + }) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + body: { key: 'value' }, + params: { appId, hash }, + query: {}, + }); + }); + }); + + it('should handle POST requests to /api/apps/private/:appId/:hash using x-www-form-urlencoded encoding', async () => { + const appId = 'testAppId'; + const hash = 'testHash'; + await request(apiServer) + .post(`/api/apps/private/${appId}/${hash}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('key=value') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + body: { key: 'value' }, + params: { appId, hash }, + query: {}, + }); + }); + }); + + it('should handle POST requests to /api/apps/public/:appId using json encoding', async () => { + const appId = 'testAppId'; + await request(apiServer) + .post(`/api/apps/public/${appId}`) + .set('Content-Type', 'application/json') + .send({ + key: 'value', + }) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + body: { key: 'value' }, + params: { appId }, + query: {}, + }); + }); + }); + + it('should handle POST requests to /api/apps/public/:appId using x-www-form-urlencoded encoding', async () => { + const appId = 'testAppId'; + await request(apiServer) + .post(`/api/apps/public/${appId}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send('key=value') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + body: { key: 'value' }, + params: { appId }, + query: {}, + }); + }); + }); +}); diff --git a/apps/meteor/app/apps/server/bridges/router.ts b/apps/meteor/app/apps/server/bridges/router.ts new file mode 100644 index 0000000..3578b31 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/router.ts @@ -0,0 +1,9 @@ +import bodyParser from 'body-parser'; +import express from 'express'; + +export const apiServer = express(); + +apiServer.disable('x-powered-by'); + +apiServer.use('/api/apps/private/:appId/:hash', bodyParser.urlencoded({ extended: true }), bodyParser.json()); +apiServer.use('/api/apps/public/:appId', bodyParser.urlencoded({ extended: true }), bodyParser.json()); diff --git a/apps/meteor/app/apps/server/bridges/scheduler.ts b/apps/meteor/app/apps/server/bridges/scheduler.ts new file mode 100644 index 0000000..8f044cf --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/scheduler.ts @@ -0,0 +1,201 @@ +import type { Job } from '@rocket.chat/agenda'; +import { Agenda } from '@rocket.chat/agenda'; +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { SchedulerBridge } from '@rocket.chat/apps/dist/server/bridges/SchedulerBridge'; +import type { IProcessor, IOnetimeSchedule, IRecurringSchedule, IJobContext } from '@rocket.chat/apps-engine/definition/scheduler'; +import { StartupType } from '@rocket.chat/apps-engine/definition/scheduler'; +import { ObjectId } from 'bson'; +import { MongoInternals } from 'meteor/mongo'; + +function _callProcessor(processor: IProcessor['processor']): (job: Job) => Promise { + return (job) => { + const data = job?.attrs?.data || {}; + + // This field is for internal use, no need to leak to app processor + delete (data as any).appId; + + data.jobId = job.attrs._id.toString(); + + return (processor as (jobContext: IJobContext) => Promise)(data).then(async () => { + // ensure the 'normal' ('onetime' in our vocab) type job is removed after it is run + // as Agenda does not remove it from the DB + if (job.attrs.type === 'normal') { + await job.agenda.cancel({ _id: job.attrs._id }); + } + }); + }; +} + +/** + * Provides the Apps Engine with task scheduling capabilities. + * It uses {@link agenda:github.com/agenda/agenda} as backend + */ +export class AppSchedulerBridge extends SchedulerBridge { + private isConnected: boolean; + + private scheduler: Agenda; + + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + this.scheduler = new Agenda({ + mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(), + db: { collection: 'rocketchat_apps_scheduler' }, + // this ensures the same job doesn't get executed multiple times in a cluster + defaultConcurrency: 1, + }); + this.isConnected = false; + } + + /** + * Register processors that can be scheduled to run + * + * @param processors An array of processors + * @param appId + * + * @returns List of task ids run at startup, or void no startup run is set + */ + protected async registerProcessors(processors: Array = [], appId: string): Promise> { + const runAfterRegister: Promise[] = []; + this.orch.debugLog(`The App ${appId} is registering job processors`, processors); + processors.forEach(({ id, processor, startupSetting }: IProcessor) => { + this.scheduler.define(id, _callProcessor(processor)); + + if (!startupSetting) { + return; + } + + switch (startupSetting.type) { + case StartupType.ONETIME: + runAfterRegister.push( + this.scheduleOnceAfterRegister({ id, when: startupSetting.when, data: startupSetting.data }, appId) as Promise, + ); + break; + case StartupType.RECURRING: + runAfterRegister.push( + this.scheduleRecurring( + { + id, + interval: startupSetting.interval, + skipImmediate: startupSetting.skipImmediate, + data: startupSetting.data, + }, + appId, + ) as Promise, + ); + break; + default: + this.orch.getRocketChatLogger().error({ msg: 'Unknown startup setting type', type: (startupSetting as any).type }); + break; + } + }); + + if (runAfterRegister.length) { + return Promise.all(runAfterRegister); + } + } + + /** + * Schedules a registered processor to run _once_. + */ + protected async scheduleOnce({ id, when, data }: IOnetimeSchedule, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is scheduling an onetime job (processor ${id})`); + try { + await this.startScheduler(); + const job = await this.scheduler.schedule(when, id, this.decorateJobData(data, appId)); + return job.attrs._id.toString(); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); + } + } + + private async scheduleOnceAfterRegister(job: IOnetimeSchedule, appId: string): Promise { + const scheduledJobs = await this.scheduler.jobs({ name: job.id, type: 'normal' }, {}, 1); + if (!scheduledJobs.length) { + return this.scheduleOnce(job, appId); + } + } + + /** + * Schedules a registered processor to run recurrently according to a given interval. + * + * @param {Object} job + * @param {string} job.id The processor's id + * @param {string} job.interval When the processor will be re executed + * @param {boolean} job.skipImmediate=false Whether to let the first iteration to execute as soon as the task is registered + * @param {Object} [job.data] An optional object that is passed to the processor + * @param {string} appId + * + * @returns {string} taskid + */ + protected async scheduleRecurring( + { id, interval, skipImmediate = false, data }: IRecurringSchedule, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is scheduling a recurring job (processor ${id})`); + try { + await this.startScheduler(); + const job = await this.scheduler.every(interval, id, this.decorateJobData(data, appId), { + skipImmediate, + }); + return job.attrs._id.toString(); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); + } + } + + /** + * Cancels a running job given its jobId + * + * @param {string} jobId + * @param {string} appId + * + * @returns Promise + */ + protected async cancelJob(jobId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is canceling a job`, jobId); + await this.startScheduler(); + + let cancelQuery; + try { + cancelQuery = { _id: new ObjectId(jobId.split('_')[0]) }; + } catch (jobDocIdError) { + // it is not a valid objectid, so it won't try to cancel by document id + cancelQuery = { name: jobId }; + } + + try { + await this.scheduler.cancel(cancelQuery); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); + } + } + + /** + * Cancels all the running jobs from the app + * + * @param {string} appId + * + * @returns Promise + */ + protected async cancelAllJobs(appId: string): Promise { + this.orch.debugLog(`Canceling all jobs of App ${appId}`); + await this.startScheduler(); + const matcher = new RegExp(`_${appId}$`); + try { + await this.scheduler.cancel({ name: { $regex: matcher } }); + } catch (err) { + this.orch.getRocketChatLogger().error({ err }); + } + } + + public async startScheduler(): Promise { + if (!this.isConnected) { + await this.scheduler.start(); + this.isConnected = true; + } + } + + private decorateJobData(jobData: object | undefined, appId: string): object { + return Object.assign({}, jobData, { appId }); + } +} diff --git a/apps/meteor/app/apps/server/bridges/settings.ts b/apps/meteor/app/apps/server/bridges/settings.ts new file mode 100644 index 0000000..1900a80 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/settings.ts @@ -0,0 +1,127 @@ +import { Apps, type IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ServerSettingBridge } from '@rocket.chat/apps/dist/server/bridges/ServerSettingBridge'; +import type { IReadSettingPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import { Settings } from '@rocket.chat/models'; + +import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../../server/lib/notifyListener'; +import { updateAuditedByApp } from '../../../../server/settings/lib/auditedSettingUpdates'; + +export class AppSettingBridge extends ServerSettingBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getAll(appId: string): Promise> { + this.orch.debugLog(`The App ${appId} is getting all the settings.`); + + const settings = await Settings.find({ secret: false }).toArray(); + return settings.map((s) => this.orch.getConverters()?.get('settings').convertToApp(s)); + } + + protected async getOneById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the setting by id ${id}.`); + + const setting = await this.getReadableSettingById(id, appId); + if (!setting) { + throw new Error(`The setting "${id}" is not readable.`); + } + + return setting; + } + + protected async hideGroup(name: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is hidding the group ${name}.`); + + throw new Error('Method not implemented.'); + } + + protected async hideSetting(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is hidding the setting ${id}.`); + + if (!(await this.isReadableById(id, appId))) { + throw new Error(`The setting "${id}" is not readable.`); + } + + throw new Error('Method not implemented.'); + } + + protected async isReadableById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is checking if they can read the setting ${id}.`); + const setting = await Settings.findOneById(id); + return Boolean(setting && !setting.secret); + } + + protected async getReadableSettingById(id: string, appId: string): Promise { + this.orch.debugLog(`The app ${appId} is checking if it can read the setting ${id}`); + const app = Apps.self?.getManager().getOneById(appId); + if (!app) { + this.orch.debugLog(`The app ${appId} is not found.`); + return null; + } + + const { permissions } = app.getInfo(); + + // If the app does not have any permissions we must assume it has a set of default permissions + // so, for being cautious, we will not allow it to read all settings. + // If one desires to read a hidden setting it must ask explicitly for it. + if (!permissions) { + const setting = await Settings.findOneNotHiddenById(id); + if (!setting) { + this.orch.debugLog(`The setting ${id} is not found.`); + return null; + } + + return this.orch.getConverters()?.get('settings').convertToApp(setting); + } + + const readSettingsPermission = permissions.find((perm) => perm.name === 'server-setting.read'); + if (!readSettingsPermission) { + this.orch.debugLog(`The app ${appId} has no server-setting.read permission.`); + return null; + } + + const readSettings = readSettingsPermission as IReadSettingPermission; + // If the setting is in the hiddenSettings list (defined within the permission), then it can bypass the hidden flag. + // If not, then it must be a non-hidden setting. This is to allow apps to read hidden settings if they have the permission to do so. + const setting = readSettings.hiddenSettings?.includes(id) ? await Settings.findOneById(id) : await Settings.findOneNotHiddenById(id); + + if (!setting) { + this.orch.debugLog(`The setting ${id} is not found.`); + return null; + } + + return this.orch.getConverters()?.get('settings').convertToApp(setting); + } + + protected async updateOne(setting: ISetting & { id: string }, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating the setting ${setting.id} .`); + + if (!(await this.isReadableById(setting.id, appId))) { + throw new Error(`The setting "${setting.id}" is not readable.`); + } + + if ( + ( + await updateAuditedByApp({ + _id: appId, + })(Settings.updateValueById, setting.id, setting.value) + ).modifiedCount + ) { + void notifyOnSettingChangedById(setting.id); + } + } + + protected async incrementValue(id: string, value: number, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is incrementing the value of the setting ${id}.`); + + if (!(await this.isReadableById(id, appId))) { + throw new Error(`The setting "${id}" is not readable.`); + } + + const setting = await Settings.incrementValueById(id, value, { returnDocument: 'after' }); + if (setting) { + void notifyOnSettingChanged(setting); + } + } +} diff --git a/apps/meteor/app/apps/server/bridges/thread.ts b/apps/meteor/app/apps/server/bridges/thread.ts new file mode 100644 index 0000000..7857005 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/thread.ts @@ -0,0 +1,15 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { ThreadBridge } from '@rocket.chat/apps/dist/server/bridges/ThreadBridge'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +export class AppThreadBridge extends ThreadBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getById(threadID: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the thread: "${threadID}"`); + + return this.orch.getConverters()?.get('threads').convertById(threadID); + } +} diff --git a/apps/meteor/app/apps/server/bridges/uiInteraction.ts b/apps/meteor/app/apps/server/bridges/uiInteraction.ts new file mode 100644 index 0000000..69172f9 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/uiInteraction.ts @@ -0,0 +1,24 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps/dist/server/bridges/UiInteractionBridge'; +import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { api } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +export class UiInteractionBridge extends AppsEngineUiInteractionBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async notifyUser(user: IUser, interaction: IUIKitInteraction, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is sending an interaction to user.`); + + const app = this.orch.getManager()?.getOneById(appId); + + if (!app) { + throw new Error('Invalid app provided'); + } + + void api.broadcast('notify.uiInteraction', user.id, interaction as UiKit.ServerInteraction); + } +} diff --git a/apps/meteor/app/apps/server/bridges/uploads.ts b/apps/meteor/app/apps/server/bridges/uploads.ts new file mode 100644 index 0000000..0637e29 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/uploads.ts @@ -0,0 +1,65 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UploadBridge } from '@rocket.chat/apps/dist/server/bridges/UploadBridge'; +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; + +import { determineFileType } from '../../../../ee/lib/misc/determineFileType'; +import { FileUpload } from '../../../../server/lib/media/file-upload'; +import { sendFileMessage } from '../../../../server/meteor-methods/messages/sendFileMessage'; +import { sendFileLivechatMessage } from '../../../../server/meteor-methods/omnichannel/sendFileLivechatMessage'; + +const getUploadDetails = (details: IUploadDetails): Partial => { + if (details.visitorToken) { + const { userId, ...result } = details; + return result; + } + return details; +}; +export class AppUploadBridge extends UploadBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getById(id: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the upload: "${id}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('uploads').convertById(id); + return promise as Promise; + } + + protected async getBuffer(upload: IUpload, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the upload: "${upload.id}"`); + + const rocketChatUpload = this.orch.getConverters()?.get('uploads').convertToRocketChat(upload); + + const result = await FileUpload.getBuffer(rocketChatUpload); + + if (!(result instanceof Buffer)) { + throw new Error('Unknown error'); + } + + return result; + } + + protected async createUpload(details: IUploadDetails, buffer: Buffer, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating an upload "${details.name}"`); + + if (!details.userId && !details.visitorToken) { + throw new Error('Missing user to perform the upload operation'); + } + + const fileStore = FileUpload.getStore('Uploads'); + + details.type = determineFileType(buffer, details.name); + + const uploadedFile = await fileStore.insert(getUploadDetails(details), buffer); + this.orch.debugLog(`The App ${appId} has created an upload`, uploadedFile); + if (details.visitorToken) { + await sendFileLivechatMessage({ roomId: details.rid, visitorToken: details.visitorToken, file: uploadedFile }); + } else { + await sendFileMessage(details.userId, { roomId: details.rid, file: uploadedFile }); + } + return this.orch.getConverters()?.get('uploads').convertToApp(uploadedFile); + } +} diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts new file mode 100644 index 0000000..4c345c9 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/users.ts @@ -0,0 +1,219 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { UserBridge } from '@rocket.chat/apps/dist/server/bridges/UserBridge'; +import type { IUserCreationOptions, IUser, UserType } from '@rocket.chat/apps-engine/definition/users'; +import { Presence } from '@rocket.chat/core-services'; +import type { PresenceSource, UserStatus } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { notifyOnUserChange, notifyOnUserChangeById } from '../../../../server/lib/notifyListener'; +import { checkUsernameAvailability } from '../../../../server/lib/users/checkUsernameAvailability'; +import { deleteUser } from '../../../../server/lib/users/deleteUser'; +import { getUserCreatedByApp } from '../../../../server/lib/users/getUserCreatedByApp'; +import { setStatusText } from '../../../../server/lib/users/setStatusText'; +import { setUserActiveStatus } from '../../../../server/lib/users/setUserActiveStatus'; +import { setUserAvatar } from '../../../../server/lib/users/setUserAvatar'; + +export class AppUserBridge extends UserBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getById(userId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the userId: "${userId}"`); + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('users').convertById(userId); + return promise as Promise; + } + + protected async getByUsername(username: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the username: "${username}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('users').convertByUsername(username); + return promise as Promise; + } + + protected async getAppUser(appId?: string): Promise { + this.orch.debugLog(`The App ${appId} is getting its assigned user`); + + if (!appId) { + return; + } + + const user = await Users.findOneByAppId(appId); + + return this.orch.getConverters()?.get('users').convertToApp(user); + } + + protected async getBySipExtension(extension: string, _appId: string): Promise { + const user = await Users.findOneByFreeSwitchExtension(extension); + + return this.orch.getConverters()?.get('users').convertToApp(user); + } + + /** + * Deletes all bot or app users created by the App. + * @param appId the App's ID. + * @param type the type of the user to be deleted. + * @returns true if any user was deleted, false otherwise. + */ + protected async deleteUsersCreatedByApp(appId: string, type: UserType.APP | UserType.BOT): Promise { + this.orch.debugLog(`The App ${appId} is deleting all bot users`); + + const appUsers = await getUserCreatedByApp(appId, type); + if (appUsers.length) { + this.orch.debugLog(`The App ${appId} is deleting ${appUsers.length} users`); + await Promise.all(appUsers.map((appUser) => deleteUser(appUser._id))); + return true; + } + return false; + } + + protected async create(userDescriptor: Partial, appId: string, options?: IUserCreationOptions): Promise { + this.orch.debugLog(`The App ${appId} is requesting to create a new user.`); + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const user = this.orch + .getConverters() + ?.get('users') + .convertToRocketChat(userDescriptor as IUser); + + if (!user._id) { + user._id = Random.id(); + } + + if (!user.createdAt) { + user.createdAt = new Date(); + } + + switch (user.type) { + case 'bot': + case 'app': + if (!(await checkUsernameAvailability(user.username as string))) { + throw new Error(`The username "${user.username}" is already being used. Rename or remove the user using it to install this App`); + } + + await Users.insertOne(user); + + if (options?.avatarUrl) { + await setUserAvatar(user, options.avatarUrl, '', 'local'); + } + + break; + + default: + throw new Error('Creating normal users is currently not supported'); + } + + void notifyOnUserChangeById({ clientAction: 'inserted', id: user._id }); + + return user._id; + } + + protected async remove(user: IUser & { id: string }, appId: string): Promise { + this.orch.debugLog(`The App's user is being removed: ${appId}`); + + // It's actually not a problem if there is no App user to delete - just means we don't need to do anything more. + if (!user) { + return true; + } + + try { + await deleteUser(user.id); + } catch (err) { + throw new Error(`Errors occurred while deleting an app user: ${err}`); + } + + return true; + } + + protected async update(user: IUser & { id: string }, fields: Partial, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a user`); + + if (!user) { + throw new Error('User not provided'); + } + + const { status, statusText, ...updateFields } = fields; + + if (status) { + await Presence.setStatus(user.id, status as UserStatus, statusText); + } else if (typeof statusText === 'string') { + await setStatusText( + { + _id: user.id, + username: user.username, + name: user.name, + status: user.status as UserStatus, + roles: user.roles, + statusText: user.statusText, + }, + statusText, + ); + } + + if (!Object.keys(updateFields).length) { + return true; + } + + await Users.updateOne({ _id: user.id }, { $set: updateFields as any }); + + void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: updateFields }); + + return true; + } + + protected async deactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is deactivating a user.`); + + if (!userId) { + throw new Error('Invalid user id'); + } + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const convertedUser: IUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId); + const { id: uid } = convertedUser as IUser; + + await setUserActiveStatus(uid, false, confirmRelinquish); + + return true; + } + + protected async setActiveState( + userId: IUser['id'], + state: Pick, + appId: string, + ): Promise { + this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`); + + await Presence.setActiveState(userId, { + statusDefault: state.statusDefault as UserStatus, + statusText: state.statusText, + statusSource: state.statusSource as PresenceSource, + ...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }), + ...(state.statusId && { statusId: state.statusId }), + }); + } + + protected async endActiveState(userId: IUser['id'], appId: string, statusId?: string): Promise { + this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`); + + await Presence.endActiveState(userId, statusId); + } + + protected async getActiveUserCount(): Promise { + return Users.getActiveLocalUserCount(); + } + + protected async getUserUnreadMessageCount(uid: string): Promise { + return Subscriptions.getBadgeCount(uid); + } + + protected async getUserRoomIds(userId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the room ids for the user: "${userId}"`); + + const subscriptions = await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray(); + + return subscriptions.map((subscription) => subscription.rid); + } +} diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts new file mode 100644 index 0000000..56f70e3 --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -0,0 +1,76 @@ +import type { IAppServerOrchestrator } from '@rocket.chat/apps'; +import { VideoConferenceBridge } from '@rocket.chat/apps/dist/server/bridges/VideoConferenceBridge'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { AppVideoConference, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import { VideoConf } from '@rocket.chat/core-services'; + +import { videoConfProviders } from '../../../../server/lib/videoConfProviders'; +import type { AppVideoConferencesConverter } from '../converters/videoConferences'; + +export class AppVideoConferenceBridge extends VideoConferenceBridge { + constructor(private readonly orch: IAppServerOrchestrator) { + super(); + } + + protected async getById(callId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the video conference byId: "${callId}"`); + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const promise: Promise = this.orch.getConverters()?.get('videoConferences').convertById(callId); + return promise as Promise; + } + + protected async create(call: AppVideoConference, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a video conference.`); + + return ( + await VideoConf.create({ + type: 'videoconference', + ...call, + }) + ).callId; + } + + protected async update(call: VideoConference, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a video conference.`); + + const oldData = call._id && (await VideoConf.getUnfiltered(call._id)); + if (!oldData) { + throw new Error('A video conference must exist to update.'); + } + + const data = (this.orch.getConverters()?.get('videoConferences') as AppVideoConferencesConverter).convertAppVideoConference(call); + await VideoConf.setProviderData(call._id, data.providerData); + + for (const { _id, ts } of data.users) { + if (oldData.users.find((user) => user._id === _id)) { + continue; + } + + await VideoConf.addUser(call._id, _id, ts); + } + + if (data.endedBy && data.endedBy._id !== oldData.endedBy?._id) { + await VideoConf.setEndedBy(call._id, data.endedBy._id); + } else if (data.endedAt) { + await VideoConf.setEndedAt(call._id, data.endedAt); + } + + if (data.status > oldData.status) { + await VideoConf.setStatus(call._id, data.status); + } + + if (data.discussionRid !== oldData.discussionRid) { + await VideoConf.assignDiscussionToConference(call._id, data.discussionRid); + } + } + + protected async registerProvider(info: IVideoConfProvider, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is registering a video conference provider.`); + videoConfProviders.registerProvider(info.name, info.capabilities || {}, appId); + } + + protected async unRegisterProvider(info: IVideoConfProvider): Promise { + videoConfProviders.unRegisterProvider(info.name); + } +} diff --git a/apps/meteor/app/apps/server/converters/cachedFunction.ts b/apps/meteor/app/apps/server/converters/cachedFunction.ts new file mode 100644 index 0000000..3310574 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/cachedFunction.ts @@ -0,0 +1,17 @@ +export const cachedFunction = any>(fn: F) => { + const cache = new Map(); + + return ((...args) => { + const cacheKey = JSON.stringify(args); + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) as ReturnType; + } + + const result = fn(...args); + + cache.set(cacheKey, result); + + return result; + }) as F; +}; diff --git a/apps/meteor/app/apps/server/converters/codecs/contacts.ts b/apps/meteor/app/apps/server/converters/codecs/contacts.ts new file mode 100644 index 0000000..73ac52b --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/contacts.ts @@ -0,0 +1,103 @@ +import type { IAppsLivechatContact } from '@rocket.chat/apps'; +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { mappedDecodeAsync } from './mappedData'; + +/** + * Rocket.Chat `ILivechatContact` <-> Apps-Engine `IAppsLivechatContact`. + * + * `decode` (Rocket.Chat -> Apps-Engine) is a straight clone, mirroring the legacy `convertContact`. + * `encode` (Apps-Engine -> Rocket.Chat) maps every attribute individually via a nested field map so + * no extra data coming from the app leaks through — mirroring the legacy `convertAppContact`. + */ +export const ContactCodec = z.codec(z.custom(), z.custom(), { + decode: (contact): IAppsLivechatContact => structuredClone(contact) as unknown as IAppsLivechatContact, + encode: (contact): Promise => { + const map = { + _id: '_id', + _updatedAt: '_updatedAt', + name: 'name', + phones: { + from: 'phones', + list: true, + map: { + phoneNumber: 'phoneNumber', + }, + }, + emails: { + from: 'emails', + list: true, + map: { + address: 'address', + }, + }, + contactManager: 'contactManager', + unknown: 'unknown', + conflictingFields: { + from: 'conflictingFields', + list: true, + map: { + field: 'field', + value: 'value', + }, + }, + customFields: 'customFields', + channels: { + from: 'channels', + list: true, + map: { + name: 'name', + verified: 'verified', + visitor: { + from: 'visitor', + map: { + visitorId: 'visitorId', + source: { + from: 'source', + map: { + type: 'type', + id: 'id', + }, + }, + }, + }, + blocked: 'blocked', + field: 'field', + value: 'value', + verifiedAt: 'verifiedAt', + details: { + from: 'details', + map: { + type: 'type', + id: 'id', + alias: 'alias', + label: 'label', + sidebarIcon: 'sidebarIcon', + defaultIcon: 'defaultIcon', + destination: 'destination', + }, + }, + lastChat: { + from: 'lastChat', + map: { + _id: '_id', + ts: 'ts', + }, + }, + }, + }, + createdAt: 'createdAt', + lastChat: { + from: 'lastChat', + map: { + _id: '_id', + ts: 'ts', + }, + }, + importIds: 'importIds', + }; + + return mappedDecodeAsync(contact, map, { dropUnmapped: true }); + }, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/departments.ts b/apps/meteor/app/apps/server/converters/codecs/departments.ts new file mode 100644 index 0000000..d89f62c --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/departments.ts @@ -0,0 +1,56 @@ +import type { IAppsDepartment } from '@rocket.chat/apps'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { mappedDecode } from './mappedData'; + +/** + * Rocket.Chat `ILivechatDepartment` <-> Apps-Engine `IAppsDepartment`. + * + * `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy + * `convertDepartment`). `encode` mirrors `convertAppDepartment`: the inverse rename written + * unconditionally, then `_unmappedProperties_` merged on top. + */ +export const DepartmentCodec = z.codec(z.custom(), z.custom(), { + decode: mappedDecode({ + id: '_id', + name: 'name', + email: 'email', + updatedAt: '_updatedAt', + enabled: 'enabled', + numberOfAgents: 'numAgents', + showOnOfflineForm: 'showOnOfflineForm', + description: 'description', + offlineMessageChannelName: 'offlineMessageChannelName', + requestTagBeforeClosingChat: 'requestTagBeforeClosingChat', + chatClosingTags: 'chatClosingTags', + abandonedRoomsCloseCustomMessage: 'abandonedRoomsCloseCustomMessage', + waitingQueueMessage: 'waitingQueueMessage', + departmentsAllowedToForward: 'departmentsAllowedToForward', + showOnRegistration: 'showOnRegistration', + }) as unknown as (department: ILivechatDepartment) => IAppsDepartment, + encode: (department): ILivechatDepartment => { + const newDepartment = { + _id: department.id, + name: department.name, + email: department.email, + _updatedAt: department.updatedAt, + enabled: department.enabled, + numAgents: department.numberOfAgents, + showOnOfflineForm: department.showOnOfflineForm, + showOnRegistration: department.showOnRegistration, + description: department.description, + offlineMessageChannelName: department.offlineMessageChannelName, + requestTagBeforeClosingChat: department.requestTagBeforeClosingChat, + chatClosingTags: department.chatClosingTags, + abandonedRoomsCloseCustomMessage: department.abandonedRoomsCloseCustomMessage, + waitingQueueMessage: department.waitingQueueMessage, + departmentsAllowedToForward: department.departmentsAllowedToForward, + }; + + return Object.assign( + newDepartment, + (department as { _unmappedProperties_?: Record })._unmappedProperties_, + ) as unknown as ILivechatDepartment; + }, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/enums.ts b/apps/meteor/app/apps/server/converters/codecs/enums.ts new file mode 100644 index 0000000..346fc74 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/enums.ts @@ -0,0 +1,123 @@ +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; +import * as z from 'zod'; + +/** + * Bidirectional enum mappings between Rocket.Chat's stored string values and the + * Apps-Engine enums, expressed as Zod codecs (`decode`: Rocket.Chat -> Apps-Engine, + * `encode`: Apps-Engine -> Rocket.Chat). + * + * These are behaviour-preserving replacements for the private `_convert*ToApp`/`_convert*ToEnum` + * helpers currently living inside the converter classes. The `decode` transforms reproduce the + * exact fallback semantics of those helpers (including passing unknown values through, upper-cased + * where the legacy code did so). The `encode` transforms mirror the reverse converters, which today + * assign the Apps-Engine value straight back onto the Rocket.Chat document without translation. + * + * Note: the contextual `console.warn` calls that depend on data unavailable to a pure enum mapping + * (e.g. the affected user's id/username in the status-connection warning) are intentionally left in + * the converter layer and will be wired back in as each converter is migrated. + */ + +/** + * Rocket.Chat `IUser['type']` <-> Apps-Engine `UserType`. + * Mirrors `AppUsersConverter._convertUserTypeToEnum`. + */ +export const UserTypeCodec = z.codec(z.any(), z.any(), { + decode: (type): string => { + switch (type) { + case 'user': + return UserType.USER; + case 'bot': + return UserType.BOT; + case 'app': + return UserType.APP; + case '': + case undefined: + return UserType.UNKNOWN; + default: + console.warn(`A new user type has been added that the Apps don't know about? "${type}"`); + return type.toUpperCase(); + } + }, + // The reverse converter assigns `user.type` straight back onto the document. + encode: (type): string => type, +}); + +/** + * Rocket.Chat `IUser['statusConnection']` <-> Apps-Engine `UserStatusConnection`. + * Mirrors the value mapping of `AppUsersConverter._convertStatusConnectionToEnum`. The legacy + * `console.warn` (which includes the affected user's id/username) stays in the converter, since + * that context is not available to a standalone codec. + */ +export const UserStatusConnectionCodec = z.codec(z.any(), z.any(), { + decode: (status): string => { + switch (status) { + case 'offline': + return UserStatusConnection.OFFLINE; + case 'online': + return UserStatusConnection.ONLINE; + case 'away': + return UserStatusConnection.AWAY; + case 'busy': + return UserStatusConnection.BUSY; + case undefined: + // This is needed for Livechat guests and Rocket.Cat user. + return UserStatusConnection.UNDEFINED; + default: + return !status ? UserStatusConnection.OFFLINE : status.toUpperCase(); + } + }, + encode: (status): string => status, +}); + +/** + * Rocket.Chat `IRoom['t']` <-> Apps-Engine `RoomType`. + * Mirrors `AppRoomsConverter._convertTypeToApp`. Unknown type characters pass through unchanged. + */ +export const RoomTypeCodec = z.codec(z.any(), z.any(), { + decode: (typeChar): string => { + switch (typeChar) { + case 'c': + return RoomType.CHANNEL; + case 'p': + return RoomType.PRIVATE_GROUP; + case 'd': + return RoomType.DIRECT_MESSAGE; + case 'l': + return RoomType.LIVE_CHAT; + default: + return typeChar; + } + }, + // `RoomType` values are identical to the stored `t` characters, so the reverse is an identity. + encode: (type): string => type, +}); + +/** + * Rocket.Chat `ISetting['type']` <-> Apps-Engine `SettingType`. + * Mirrors `AppSettingsConverter._convertTypeToApp`. Unknown types pass through unchanged. + */ +export const SettingTypeCodec = z.codec(z.any(), z.any(), { + decode: (type): string => { + switch (type) { + case 'boolean': + return SettingType.BOOLEAN; + case 'code': + return SettingType.CODE; + case 'color': + return SettingType.COLOR; + case 'font': + return SettingType.FONT; + case 'int': + return SettingType.NUMBER; + case 'select': + return SettingType.SELECT; + case 'string': + return SettingType.STRING; + default: + return type; + } + }, + encode: (type): string => type, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/index.ts b/apps/meteor/app/apps/server/converters/codecs/index.ts new file mode 100644 index 0000000..009db88 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/index.ts @@ -0,0 +1,9 @@ +export { RoomTypeCodec, SettingTypeCodec, UserStatusConnectionCodec, UserTypeCodec } from './enums'; +export { createMappedCodec, mappedDecode, mappedEncode } from './mappedData'; +export type { FieldMap } from './mappedData'; +export { SettingCodec } from './settings'; +export { RoleCodec } from './roles'; +export { VideoConferenceCodec } from './videoConferences'; +export { VisitorCodec } from './visitors'; +export { DepartmentCodec } from './departments'; +export { UserCodec } from './users'; diff --git a/apps/meteor/app/apps/server/converters/codecs/mappedData.ts b/apps/meteor/app/apps/server/converters/codecs/mappedData.ts new file mode 100644 index 0000000..7aeab98 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/mappedData.ts @@ -0,0 +1,181 @@ +import * as z from 'zod'; + +type Loose = Record; + +/** + * A declarative field map from Apps-Engine (target) property names to Rocket.Chat (source) property + * names. Values are constrained to the keys of `Source`, so mapping a renamed or misspelled source + * field is a compile error. + */ +export type FieldMap = Record>; + +/** + * The Rocket.Chat -> Apps-Engine result of decoding `Source` through `Map`: each target property + * holds its source value (optional, since it is only set when the source value is defined), and + * everything the map did not name is collected under `_unmappedProperties_`. + */ +export type Decoded> = { + -readonly [Target in keyof Map]?: Source[Map[Target]]; +} & { + _unmappedProperties_: Omit; +}; + +/** + * Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the "rename these + * fields, bucket the rest" behaviour of the original mapping helper: the input is deep-cloned (so the + * app can never mutate the stored document), each mapped source field is copied to its target name + * when defined, and every remaining property is collected into `_unmappedProperties_`. + */ +export function mappedDecode = FieldMap>( + fieldMap: Map, +): (data: Source) => Decoded { + const entries = Object.entries(fieldMap) as [string, string][]; + + return (data: Source): Decoded => { + const clone: Loose = structuredClone(data); + const result: Loose = {}; + + for (const [target, sourceKey] of entries) { + if (typeof clone[sourceKey] !== 'undefined') { + result[target] = clone[sourceKey]; + } + + delete clone[sourceKey]; + } + + result._unmappedProperties_ = clone; + + return result as Decoded; + }; +} + +/** + * The Apps-Engine -> Rocket.Chat inverse of {@link mappedDecode}: target fields are copied back to + * their source names when defined and `_unmappedProperties_` is merged onto the result. Because both + * the renamed keys and the bucket originate from `Source`, the result is a `Partial`. + * + * This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse direction has + * defaults, conditional fields or asymmetric mappings provide their own `encode`. + */ +export function mappedEncode = FieldMap>( + fieldMap: Map, +): (app: Decoded) => Partial { + const entries = Object.entries(fieldMap) as [string, string][]; + + return (app: Decoded): Partial => { + const { _unmappedProperties_ = {}, ...rest } = app as Loose; + const result: Loose = {}; + + for (const [target, sourceKey] of entries) { + if (typeof rest[target] !== 'undefined') { + result[sourceKey] = rest[target]; + } + } + + return { ...result, ..._unmappedProperties_ } as Partial; + }; +} + +/** + * A map whose entries are one of the three forms the original mapping helper supports: a source + * property name (string), a function that derives the target value from the (cloned) source data, + * or a nested `{ from, map, list }` descriptor for sub-objects and arrays. + * + * Unlike {@link FieldMap}, this is intentionally loosely typed: its consumers (rooms, messages, + * uploads) map many optional/livechat-only fields that are not part of the base document types. + */ +export type AsyncFieldMap = Record< + string, + string | ((data: Record) => unknown | Promise) | { from: string; map?: AsyncFieldMap; list?: boolean } +>; + +/** + * Rocket.Chat -> Apps-Engine transform for a map mixing string renames, (possibly async) derived-value + * functions and nested `{ from, map, list }` descriptors, reproducing the original mapping helper + * exactly: + * + * - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete` fields + * they consume) and the app can never mutate the stored document; + * - string entries copy the source field to the target name when defined and always drop the source + * key from the bucket; + * - function entries receive the clone and set the target only when they return a defined value + * (functions are responsible for deleting any source keys they consume); + * - nested entries recurse when they carry a `map` (producing a `_unmappedProperties_` bucket at each + * level) or copy the cloned source value verbatim when they do not, and `list` entries map arrays + * element-by-element (or wrap a lone value into a single-element array); + * - absent (`undefined`/`null`) nested sources are skipped, so the target stays unset; + * - everything left in the clone becomes `_unmappedProperties_`. + * + * The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's + * function/nested entries in ways that are not worth expressing in the type system. + * + * `dropUnmapped` omits the `_unmappedProperties_` bucket at the root and every nested level, for + * callers (e.g. contacts) that map every attribute individually and must not let extra app data leak + * through. It defaults to `false`, preserving the "bucket the rest" behaviour for other callers. + */ +export async function mappedDecodeAsync( + data: Loose, + map: AsyncFieldMap, + options: { dropUnmapped?: boolean } = {}, +): Promise { + const clone: Loose = structuredClone(data); + const result: Loose = {}; + + for (const [to, from] of Object.entries(map)) { + if (typeof from === 'function') { + const value = await from(clone); + + if (typeof value !== 'undefined') { + result[to] = value; + } + } else if (typeof from === 'string') { + if (typeof clone[from] !== 'undefined') { + result[to] = clone[from]; + } + + delete clone[from]; + } else { + const { from: fromName } = from; + + if (from.list) { + if (Array.isArray(clone[fromName])) { + if ('map' in from && from.map) { + result[to] = await Promise.all( + clone[fromName].map((item: Loose) => mappedDecodeAsync(item, from.map as AsyncFieldMap, options)), + ); + } else { + result[to] = [...clone[fromName]]; + } + } else if (clone[fromName] !== undefined && clone[fromName] !== null) { + result[to] = [clone[fromName]]; + } + } else if (clone[fromName] !== undefined && clone[fromName] !== null) { + result[to] = from.map + ? await mappedDecodeAsync(clone[fromName], from.map as AsyncFieldMap, options) + : structuredClone(clone[fromName]); + } + + delete clone[fromName]; + } + } + + if (!options.dropUnmapped) { + result._unmappedProperties_ = clone; + } + + return result as Result; +} + +/** + * Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}. + * `decode` produces {@link Decoded}; `encode` is its inverse. + * + * The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving); + * schemas can be tightened later without changing the transform logic. + */ +export function createMappedCodec = FieldMap>(fieldMap: Map) { + return z.codec(z.custom(), z.custom>(), { + decode: mappedDecode(fieldMap), + encode: (app) => mappedEncode(fieldMap)(app) as Source, + }); +} diff --git a/apps/meteor/app/apps/server/converters/codecs/roles.ts b/apps/meteor/app/apps/server/converters/codecs/roles.ts new file mode 100644 index 0000000..df9f3eb --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/roles.ts @@ -0,0 +1,19 @@ +import type { IRole } from '@rocket.chat/core-typings'; + +import { createMappedCodec } from './mappedData'; + +/** + * Rocket.Chat `IRole` <-> Apps-Engine role. + * + * A pure field-rename with an `_unmappedProperties_` bucket, so it is expressed directly via + * {@link createMappedCodec}. Keys are the Apps-Engine names, values the Rocket.Chat names (checked + * against `keyof IRole`). + */ +export const RoleCodec = createMappedCodec({ + id: '_id', + name: 'name', + description: 'description', + mandatory2fa: 'mandatory2fa', + protected: 'protected', + scope: 'scope', +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/rooms.ts b/apps/meteor/app/apps/server/converters/codecs/rooms.ts new file mode 100644 index 0000000..9951119 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/rooms.ts @@ -0,0 +1,462 @@ +import type { IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsRoomRaw } from '@rocket.chat/apps'; +import { secureFieldsMapper } from '@rocket.chat/apps/dist/lib/SecureFields'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatDepartment, Users, LivechatContacts } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { RoomTypeCodec } from './enums'; +import { mappedDecodeAsync } from './mappedData'; + +// The stored room documents carry many optional/livechat-only fields that are not part of the base +// `IRoom` union, so the transform inputs are treated as loose records (as the legacy converter did). +type RoomData = Record; + +const mapUserLookup = (user: any) => + user && { + _id: user._id ?? user.id, + ...(user.username && { username: user.username }), + ...(user.name && { name: user.name }), + }; + +/** + * Rocket.Chat -> Apps-Engine "raw" room transform (no cross-converter resolution). Mirrors the + * legacy `convertRoomRaw`. + */ +export async function decodeRoomRaw(room: IRoom): Promise { + const map = { + id: '_id', + displayName: 'fname', + slugifiedName: 'name', + members: 'members', + userIds: 'uids', + usernames: 'usernames', + messageCount: 'msgs', + createdAt: 'ts', + updatedAt: '_updatedAt', + closedAt: 'closedAt', + lastModifiedAt: 'lm', + customFields: 'customFields', + livechatData: 'livechatData', + isWaitingResponse: 'waitingResponse', + isOpen: 'open', + description: 'description', + source: 'source', + closer: 'closer', + teamId: 'teamId', + isTeamMain: 'teamMain', + isDefault: 'default', + isReadOnly: 'ro', + contactId: 'contactId', + departmentId: 'departmentId', + parentRoomId: 'prid', + isFederated: 'federated', + federation: 'federation', + visitor: (data: RoomData) => { + const { v } = data; + if (!v) { + return undefined; + } + + delete data.v; + + const { _id: id, ...rest } = v; + + return { + id, + ...rest, + }; + }, + displaySystemMessages: (data: RoomData) => { + const { sysMes } = data; + delete data.sysMes; + return typeof sysMes === 'undefined' ? true : sysMes; + }, + type: (data: RoomData) => { + const result = z.decode(RoomTypeCodec, data.t); + delete data.t; + return result; + }, + creator: (data: RoomData) => { + if (!data.u) { + return undefined; + } + const creator = mapUserLookup(data.u); + delete data.u; + return creator; + }, + closedBy: (data: RoomData) => { + if (!data.closedBy) { + return undefined; + } + const { closedBy } = data; + delete data.closedBy; + return mapUserLookup(closedBy); + }, + servedBy: (data: RoomData) => { + if (!data.servedBy) { + return undefined; + } + const { servedBy } = data; + delete data.servedBy; + return mapUserLookup(servedBy); + }, + responseBy: (data: RoomData) => { + if (!data.responseBy) { + return undefined; + } + const { responseBy } = data; + delete data.responseBy; + return mapUserLookup(responseBy); + }, + }; + + return mappedDecodeAsync(room, map); +} + +async function getCreator(user: string | undefined) { + if (!user) { + return; + } + + const creator = await Users.findOneById(user, { projection: { _id: 1, username: 1, name: 1 } }); + if (!creator) { + return; + } + + return { + _id: creator._id, + username: creator.username, + name: creator.name, + }; +} + +async function getVisitor({ visitor: roomVisitor, visitorChannelInfo }: any) { + if (!roomVisitor) { + return; + } + + const visitor = await LivechatVisitors.findOneEnabledById(roomVisitor.id); + if (!visitor) { + return; + } + + const { lastMessageTs, phone } = visitorChannelInfo || {}; + + return { + _id: visitor._id, + username: visitor.username, + token: visitor.token, + status: visitor.status || 'online', + ...(visitor.activity?.length && { activity: visitor.activity }), + ...(lastMessageTs && { lastMessageTs }), + ...(phone && { phone }), + }; +} + +async function getUserIdAndUsername(userObj: any) { + if (!userObj?.id) { + return; + } + + const user = await Users.findOneById(userObj.id, { projection: { _id: 1, username: 1 } }); + if (!user) { + return; + } + + return { + _id: user._id, + username: user.username, + }; +} + +async function getRoomCloser(room: any, v: any) { + if (!room.closedBy) { + return; + } + + if (room.closer === 'user') { + const user = await Users.findOneById(room.closedBy.id, { projection: { _id: 1, username: 1 } }); + if (!user) { + return; + } + + return { + _id: user._id, + username: user.username, + }; + } + + if (room.closer === 'visitor' && v) { + return { + _id: v._id, + username: v.username, + }; + } +} + +// TODO do we really need this? +async function getContactId({ contact }: any) { + if (!contact?._id) { + return; + } + const contactFromDb = await LivechatContacts.findOneEnabledById(contact._id, { projection: { _id: 1 } }); + return contactFromDb?._id; +} + +// TODO do we really need this? +async function getDepartment({ department }: any) { + if (!department) { + return; + } + const dept = await LivechatDepartment.findOneById(department.id, { projection: { _id: 1 } }); + return dept?._id; +} + +/** + * Apps-Engine -> Rocket.Chat room transform. Mirrors the legacy `convertAppRoom`, including the + * `isPartial` behaviour (skip `_unmappedProperties_` merge). Cross-converter independent — it reads + * the models directly. + */ +export async function appRoomToRocketChat(room: any, isPartial = false): Promise> { + const u = await getCreator(room.creator?.id); + + const v = await getVisitor(room); + + const departmentId = await getDepartment(room); + + const servedBy = await getUserIdAndUsername(room.servedBy); + + const closedBy = await getRoomCloser(room, v); + + const contactId = await getContactId(room); + + const newRoom = { + ...(room.id && { _id: room.id }), + ...(typeof room.type !== 'undefined' && { t: room.type }), + ...(typeof room.createdAt !== 'undefined' && { ts: room.createdAt }), + ...(typeof room.messageCount !== 'undefined' && { msgs: room.messageCount || 0 }), + ...(typeof room.updatedAt !== 'undefined' && { _updatedAt: room.updatedAt }), + ...(room.displayName && { fname: room.displayName }), + ...(room.type !== 'd' && room.slugifiedName && { name: room.slugifiedName }), + ...(room.members && { members: room.members }), + ...(typeof room.isDefault !== 'undefined' && { default: room.isDefault }), + ...(typeof room.isReadOnly !== 'undefined' && { ro: room.isReadOnly }), + ...(typeof room.displaySystemMessages !== 'undefined' && { sysMes: room.displaySystemMessages }), + ...(u && { u }), + ...(v && { v }), + ...(departmentId && { departmentId }), + ...(servedBy && { servedBy }), + ...(closedBy && { closedBy }), + ...(room.userIds && { uids: room.userIds }), + ...(typeof room.isWaitingResponse !== 'undefined' && { waitingResponse: !!room.isWaitingResponse }), + ...(typeof room.isOpen !== 'undefined' && { open: !!room.isOpen }), + ...(room.closedAt && { closedAt: room.closedAt }), + ...(room.lastModifiedAt && { lm: room.lastModifiedAt }), + ...(room.customFields && { customFields: room.customFields }), + ...(room.livechatData && { livechatData: room.livechatData }), + ...(typeof room.isFederated !== 'undefined' && { federated: room.isFederated }), + ...(typeof room.federation !== 'undefined' && { federation: room.federation }), + ...(typeof room.parentRoom !== 'undefined' && { prid: room.parentRoom.id }), + ...(contactId && { contactId }), + ...(room._USERNAMES && { _USERNAMES: room._USERNAMES }), + ...(room.source && { + source: { + ...room.source, + }, + }), + }; + + if (!isPartial) { + Object.assign(newRoom, room._unmappedProperties_); + } + + return newRoom as unknown as Partial; +} + +/** + * Rocket.Chat `IRoom` <-> Apps-Engine room. Cross-converter dependent, so built via a factory closing + * over the orchestrator. `decode` mirrors `convertRoom` (resolves creator/visitor/contact/department/ + * closedBy/servedBy/responseBy/parentRoom through their converters, plus secure fields). `encode` + * mirrors the non-partial `convertAppRoom`; the class handles the `isPartial` variant. + */ +export function createRoomCodec(orch: IAppServerOrchestrator) { + return z.codec(z.custom(), z.custom(), { + decode: async (originalRoom): Promise => { + const map = { + id: '_id', + displayName: 'fname', + slugifiedName: 'name', + members: 'members', + userIds: 'uids', + messageCount: 'msgs', + createdAt: 'ts', + updatedAt: '_updatedAt', + closedAt: 'closedAt', + lastModifiedAt: 'lm', + customFields: 'customFields', + livechatData: 'livechatData', + isWaitingResponse: 'waitingResponse', + isOpen: 'open', + _USERNAMES: '_USERNAMES', + description: 'description', + source: 'source', + closer: 'closer', + teamId: 'teamId', + isTeamMain: 'teamMain', + isFederated: 'federated', + federation: 'federation', + isDefault: (room: RoomData) => { + const result = !!room.default; + delete room.default; + return result; + }, + isReadOnly: (room: RoomData) => { + const result = !!room.ro; + delete room.ro; + return result; + }, + displaySystemMessages: (room: RoomData) => { + const { sysMes } = room; + + if (typeof sysMes === 'undefined') { + return true; + } + + delete room.sysMes; + return sysMes; + }, + type: (room: RoomData) => { + const result = z.decode(RoomTypeCodec, room.t); + delete room.t; + return result; + }, + creator: async (room: RoomData) => { + const { u } = room; + + if (!u) { + return undefined; + } + + delete room.u; + + return orch.getConverters().get('users').convertById(u._id); + }, + visitor: (room: RoomData) => { + const { v } = room; + + if (!v) { + return undefined; + } + + return orch.getConverters().get('visitors').convertById(v._id); + }, + contact: (room: RoomData) => { + const { contactId } = room; + + if (!contactId) { + return undefined; + } + + return orch.getConverters().get('contacts').convertById(contactId); + }, + // Note: room.v is not just visitor, it also contains channel related visitor data + // so we need to pass this data to the converter + // So suppose you have a contact whom we're contacting using SMS via 2 phone no's, + // let's call X and Y. Then if the contact sends a message using X phone number, + // then room.v.phoneNo would be X and correspondingly we'll store the timestamp of + // the last message from this visitor from X phone no on room.v.lastMessageTs + visitorChannelInfo: (room: RoomData) => { + const { v } = room; + + if (!v) { + return undefined; + } + + const { lastMessageTs, phone } = v; + + return { + ...(phone && { phone }), + ...(lastMessageTs && { lastMessageTs }), + }; + }, + department: async (room: RoomData) => { + const { departmentId } = room; + + if (!departmentId) { + return undefined; + } + + delete room.departmentId; + + return orch.getConverters().get('departments').convertById(departmentId); + }, + closedBy: async (room: RoomData) => { + const { closedBy } = room; + + if (!closedBy) { + return undefined; + } + + delete room.closedBy; + if ((originalRoom as RoomData).closer === 'user') { + return orch.getConverters().get('users').convertById(closedBy._id); + } + + return orch.getConverters().get('visitors').convertById(closedBy._id); + }, + servedBy: async (room: RoomData) => { + const { servedBy } = room; + + if (!servedBy) { + return undefined; + } + + delete room.servedBy; + + return orch.getConverters().get('users').convertById(servedBy._id); + }, + responseBy: async (room: RoomData) => { + const { responseBy } = room; + + if (!responseBy) { + return undefined; + } + + delete room.responseBy; + + return orch.getConverters().get('users').convertById(responseBy._id); + }, + parentRoom: async (room: RoomData) => { + const { prid } = room; + + if (!prid) { + return undefined; + } + + delete room.prid; + + return orch.getConverters().get('rooms').convertById(prid); + }, + ...secureFieldsMapper((room: RoomData) => { + if (!room.abacAttributes) { + return undefined; + } + + const value = [ + { + permission: 'abac.read', + name: 'abacAttributes', + value: room.abacAttributes, + }, + ]; + + delete room.abacAttributes; + return value; + }), + }; + + return mappedDecodeAsync(originalRoom, map); + }, + encode: (room): Promise => appRoomToRocketChat(room, false) as unknown as Promise, + }); +} diff --git a/apps/meteor/app/apps/server/converters/codecs/settings.ts b/apps/meteor/app/apps/server/converters/codecs/settings.ts new file mode 100644 index 0000000..71bb1ce --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/settings.ts @@ -0,0 +1,36 @@ +import type { IAppsSetting } from '@rocket.chat/apps'; +import type { ISetting } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { SettingTypeCodec } from './enums'; + +/** + * Rocket.Chat `ISetting` -> Apps-Engine `IAppsSetting`. + * + * `decode` reproduces `AppSettingsConverter.convertToApp` (id/date renames + `SettingType` mapping). + * The setting converter is intentionally one-directional, matching the legacy converter which had no + * `convertToRocketChat`: an Apps-Engine setting cannot be turned back into a valid `ISetting` (required + * fields such as `env`, `blocked` and `sorter` are not part of `IAppsSetting`), so `encode` is not + * supported and throws instead of emitting a lossy, invalid document. Endpoints are typed with + * `z.custom` so no runtime validation is added on the decode path. + */ +export const SettingCodec = z.codec(z.custom(), z.custom(), { + decode: (setting): IAppsSetting => + ({ + id: setting._id, + type: z.decode(SettingTypeCodec, setting.type), + packageValue: setting.packageValue, + values: setting.values, + value: setting.value, + public: setting.public, + hidden: setting.hidden, + group: setting.group, + i18nLabel: setting.i18nLabel, + i18nDescription: setting.i18nDescription, + createdAt: setting.ts, + updatedAt: setting._updatedAt, + }) as unknown as IAppsSetting, + encode: (): ISetting => { + throw new Error('SettingCodec: converting an Apps-Engine setting back to a Rocket.Chat setting is not supported'); + }, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/uploads.ts b/apps/meteor/app/apps/server/converters/codecs/uploads.ts new file mode 100644 index 0000000..6991a66 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/uploads.ts @@ -0,0 +1,97 @@ +import type { IAppServerOrchestrator, IAppsUpload } from '@rocket.chat/apps'; +import type { IUpload } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { mappedDecodeAsync } from './mappedData'; + +/** + * Rocket.Chat `IUpload` <-> Apps-Engine `IAppsUpload`. + * + * Cross-converter dependent, so it is built via a factory closing over the orchestrator. `decode` + * (async) mirrors the legacy `convertToApp`: a field rename plus room/user/visitor resolved through + * their converters, with an `_unmappedProperties_` bucket. `encode` mirrors `convertToRocketChat`. + */ +export function createUploadsCodec(orch: IAppServerOrchestrator) { + return z.codec(z.custom(), z.custom(), { + decode: async (upload): Promise => { + const map = { + id: '_id', + name: 'name', + size: 'size', + type: 'type', + store: 'store', + description: 'description', + complete: 'complete', + uploading: 'uploading', + extension: 'extension', + progress: 'progress', + etag: 'etag', + path: 'path', + token: 'token', + url: 'url', + updatedAt: '_updatedAt', + uploadedAt: 'uploadedAt', + room: async (upload: Record) => { + const result = await orch + .getConverters() + .get('rooms') + .convertById(upload.rid as string); + delete upload.rid; + return result; + }, + user: async (upload: Record) => { + if (!upload.userId) { + return undefined; + } + + const result = await orch.getConverters().get('users').convertById(upload.userId); + delete upload.userId; + return result; + }, + visitor: async (upload: Record) => { + const { visitorToken } = upload; + if (!visitorToken) { + return undefined; + } + + const result = await orch.getConverters().get('visitors').convertByToken(visitorToken); + delete upload.visitorToken; + return result; + }, + }; + + return mappedDecodeAsync(upload, map); + }, + encode: (upload): IUpload => { + const { id: userId } = upload.user || {}; + const { token: visitorToken } = upload.visitor || {}; + const { id: rid } = upload.room || {}; + + const newUpload = { + _id: upload.id, + name: upload.name, + size: upload.size, + type: upload.type, + extension: upload.extension, + description: (upload as { description?: string }).description, + store: upload.store, + etag: upload.etag, + complete: upload.complete, + uploading: upload.uploading, + progress: upload.progress, + token: upload.token, + url: upload.url, + _updatedAt: upload.updatedAt, + uploadedAt: upload.uploadedAt, + rid, + userId, + visitorToken, + }; + + return Object.assign( + newUpload, + (upload as { _unmappedProperties_?: Record })._unmappedProperties_, + ) as unknown as IUpload; + }, + }); +} diff --git a/apps/meteor/app/apps/server/converters/codecs/users.ts b/apps/meteor/app/apps/server/converters/codecs/users.ts new file mode 100644 index 0000000..9e5c8e3 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/users.ts @@ -0,0 +1,77 @@ +import type { IAppsUser } from '@rocket.chat/apps'; +import type { IUser } from '@rocket.chat/core-typings'; +import { removeEmpty } from '@rocket.chat/tools'; +import * as z from 'zod'; + +import { UserStatusConnectionCodec, UserTypeCodec } from './enums'; + +const VALID_STATUS_CONNECTIONS = new Set(['offline', 'online', 'away', 'busy']); + +/** + * Rocket.Chat `IUser` <-> Apps-Engine `IAppsUser`. + * + * Both directions are bespoke (no `_unmappedProperties_` bucket): `decode` mirrors `convertToApp` + * and `encode` mirrors `convertToRocketChat`. Enum fields go through the shared enum codecs. The + * contextual "invalid status" warning stays here (rather than in `UserStatusConnectionCodec`) + * because it needs the user's id/username. On the `encode` side `utcOffset` falls back to the legacy + * misspelled `utfOffset` property, so app users that only carry the historical typo still convert. + */ +export const UserCodec = z.codec(z.custom(), z.custom(), { + decode: (user): IAppsUser => { + const { statusConnection } = user; + if (typeof statusConnection !== 'undefined' && !VALID_STATUS_CONNECTIONS.has(statusConnection)) { + console.warn( + `The user ${user.username} (${user._id}) does not have a valid status (offline, online, away, or busy). It is currently: "${statusConnection}"`, + ); + } + + return { + id: user._id, + username: user.username, + emails: user.emails, + type: z.decode(UserTypeCodec, user.type), + isEnabled: user.active, + name: user.name, + roles: user.roles, + bio: user.bio, + status: user.status, + statusText: user.statusText, + statusConnection: z.decode(UserStatusConnectionCodec, statusConnection), + utcOffset: user.utcOffset, + createdAt: user.createdAt, + updatedAt: user._updatedAt, + lastLoginAt: user.lastLogin, + appId: (user as { appId?: string }).appId, + customFields: user.customFields, + isFederated: user.federated, + federation: user.federation, + sipExtension: user.freeSwitchExtension, + settings: { + preferences: { + ...(user?.settings?.preferences?.language && { language: user.settings.preferences.language }), + }, + }, + } as unknown as IAppsUser; + }, + encode: (user): IUser => + removeEmpty({ + _id: user.id, + username: user.username, + emails: user.emails, + type: z.encode(UserTypeCodec, user.type), + active: user.isEnabled, + name: user.name, + roles: user.roles, + bio: user.bio, + status: user.status, + statusConnection: z.encode(UserStatusConnectionCodec, user.statusConnection), + utcOffset: user.utcOffset ?? (user as { utfOffset?: number }).utfOffset, + createdAt: user.createdAt, + _updatedAt: user.updatedAt, + lastLogin: user.lastLoginAt, + appId: user.appId, + federated: user.isFederated, + federation: user.federation, + freeSwitchExtension: user.sipExtension, + }) as unknown as IUser, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/videoConferences.ts b/apps/meteor/app/apps/server/converters/codecs/videoConferences.ts new file mode 100644 index 0000000..0694982 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/videoConferences.ts @@ -0,0 +1,14 @@ +import type { AppsVideoConference } from '@rocket.chat/apps'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +/** + * Rocket.Chat `VideoConference` <-> Apps-Engine `AppsVideoConference`. + * + * Both directions are a shallow pass-through clone, matching the legacy converter. Endpoints are + * typed with `z.custom` so no runtime validation is added yet. + */ +export const VideoConferenceCodec = z.codec(z.custom(), z.custom(), { + decode: (call): AppsVideoConference => ({ ...call }) as unknown as AppsVideoConference, + encode: (call): VideoConference => ({ ...call }) as unknown as VideoConference, +}); diff --git a/apps/meteor/app/apps/server/converters/codecs/visitors.ts b/apps/meteor/app/apps/server/converters/codecs/visitors.ts new file mode 100644 index 0000000..6130e29 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/codecs/visitors.ts @@ -0,0 +1,49 @@ +import type { IAppsVisitor } from '@rocket.chat/apps'; +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { mappedDecode } from './mappedData'; + +/** + * Rocket.Chat `ILivechatVisitor` <-> Apps-Engine `IAppsVisitor`. + * + * `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy + * `convertVisitor`). `encode` is bespoke: it mirrors `convertAppVisitor`, which defaults `status`, + * includes `visitorEmails`/`department`/`externalIds` only when truthy, deliberately does **not** + * write `_updatedAt`/`activity` back, and merges `_unmappedProperties_`. + */ +export const VisitorCodec = z.codec(z.custom(), z.custom(), { + decode: mappedDecode({ + id: '_id', + username: 'username', + name: 'name', + department: 'department', + updatedAt: '_updatedAt', + token: 'token', + phone: 'phone', + visitorEmails: 'visitorEmails', + livechatData: 'livechatData', + status: 'status', + activity: 'activity', + externalIds: 'externalIds', + }) as unknown as (visitor: ILivechatVisitor) => IAppsVisitor, + encode: (visitor): ILivechatVisitor => { + const newVisitor = { + _id: visitor.id, + username: visitor.username, + name: visitor.name, + token: visitor.token, + phone: visitor.phone, + livechatData: visitor.livechatData, + status: visitor.status || 'online', + ...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }), + ...(visitor.department && { department: visitor.department }), + ...(visitor.externalIds && { externalIds: visitor.externalIds }), + }; + + return Object.assign( + newVisitor, + (visitor as { _unmappedProperties_?: Record })._unmappedProperties_, + ) as unknown as ILivechatVisitor; + }, +}); diff --git a/apps/meteor/app/apps/server/converters/contacts.ts b/apps/meteor/app/apps/server/converters/contacts.ts new file mode 100644 index 0000000..d75833d --- /dev/null +++ b/apps/meteor/app/apps/server/converters/contacts.ts @@ -0,0 +1,41 @@ +import type { IAppContactsConverter, IAppsLivechatContact } from '@rocket.chat/apps'; +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { ContactCodec } from './codecs/contacts'; + +export class AppContactsConverter implements IAppContactsConverter { + async convertById(contactId: ILivechatContact['_id']): Promise { + const contact = await LivechatContacts.findOneEnabledById(contactId); + if (!contact) { + return; + } + + return this.convertContact(contact); + } + + async convertContact(contact: undefined | null): Promise; + + async convertContact(contact: ILivechatContact): Promise; + + async convertContact(contact: ILivechatContact | undefined | null): Promise { + if (!contact) { + return; + } + + return z.decodeAsync(ContactCodec, contact); + } + + convertAppContact(contact: undefined | null): Promise; + + convertAppContact(contact: IAppsLivechatContact): Promise; + + async convertAppContact(contact: IAppsLivechatContact | undefined | null): Promise { + if (!contact) { + return; + } + + return z.encodeAsync(ContactCodec, contact); + } +} diff --git a/apps/meteor/app/apps/server/converters/convertMessageFiles.ts b/apps/meteor/app/apps/server/converters/convertMessageFiles.ts new file mode 100644 index 0000000..d62ecd6 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/convertMessageFiles.ts @@ -0,0 +1,23 @@ +import type { IMessage as AppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage } from '@rocket.chat/core-typings'; + +export async function convertMessageFiles( + files: IMessage['files'], + attachments: IMessage['attachments'], +): Promise { + return files?.map((file) => { + if (!file || file.typeGroup) { + return file; + } + + // Thumbnails from older messages did not have any identification but we can extrapolate this information from other data + if (files.length === 2 && attachments?.length === 1 && file === files[1]) { + return { + ...file, + typeGroup: 'thumb', + }; + } + + return file; + }); +} diff --git a/apps/meteor/app/apps/server/converters/departments.ts b/apps/meteor/app/apps/server/converters/departments.ts new file mode 100644 index 0000000..3b2ba01 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/departments.ts @@ -0,0 +1,46 @@ +import type { IAppDepartmentsConverter, IAppServerOrchestrator, IAppsDepartment } from '@rocket.chat/apps'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { LivechatDepartment } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { DepartmentCodec } from './codecs'; + +export class AppDepartmentsConverter implements IAppDepartmentsConverter { + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(id: ILivechatDepartment['_id']): Promise { + const department = await LivechatDepartment.findOneById(id); + + return this.convertDepartment(department); + } + + async convertDepartment(department: undefined | null): Promise; + + async convertDepartment(department: ILivechatDepartment): Promise; + + async convertDepartment(department: ILivechatDepartment | undefined | null): Promise; + + async convertDepartment(department: ILivechatDepartment | undefined | null): Promise { + if (!department) { + return undefined; + } + + return z.decode(DepartmentCodec, department); + } + + convertAppDepartment(department: undefined | null): undefined; + + convertAppDepartment(department: IAppsDepartment): ILivechatDepartment; + + convertAppDepartment(department: IAppsDepartment | undefined | null): ILivechatDepartment | undefined; + + convertAppDepartment(department: IAppsDepartment | undefined | null): ILivechatDepartment | undefined { + if (!department) { + return undefined; + } + + return z.encode(DepartmentCodec, department); + } +} diff --git a/apps/meteor/app/apps/server/converters/index.ts b/apps/meteor/app/apps/server/converters/index.ts new file mode 100644 index 0000000..af64888 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/index.ts @@ -0,0 +1,23 @@ +import { AppContactsConverter } from './contacts'; +import { AppDepartmentsConverter } from './departments'; +import { AppMessagesConverter } from './messages'; +import { AppRolesConverter } from './roles'; +import { AppRoomsConverter } from './rooms'; +import { AppSettingsConverter } from './settings'; +import { AppUploadsConverter } from './uploads'; +import { AppUsersConverter } from './users'; +import { AppVideoConferencesConverter } from './videoConferences'; +import { AppVisitorsConverter } from './visitors'; + +export { + AppContactsConverter, + AppMessagesConverter, + AppRoomsConverter, + AppSettingsConverter, + AppUsersConverter, + AppVideoConferencesConverter, + AppDepartmentsConverter, + AppUploadsConverter, + AppVisitorsConverter, + AppRolesConverter, +}; diff --git a/apps/meteor/app/apps/server/converters/messages.ts b/apps/meteor/app/apps/server/converters/messages.ts new file mode 100644 index 0000000..ce1d5d7 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/messages.ts @@ -0,0 +1,378 @@ +import type { IAppMessagesConverter, IAppServerOrchestrator, IAppsMessage, IAppsMesssageRaw } from '@rocket.chat/apps'; +import { isMessageFromVisitor } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { removeEmpty } from '@rocket.chat/tools'; + +import { cachedFunction } from './cachedFunction'; +import { mappedDecodeAsync } from './codecs/mappedData'; +import { convertMessageFiles } from './convertMessageFiles'; + +// The stored message documents and the app-side payloads carry many optional/dynamic fields that are +// awkward to express against the base `IMessage`/`IAppsMessage` types. The legacy converter accessed +// them dynamically; until this converter is migrated to a codec we treat the transform inputs as +// loosely-typed records to preserve that behaviour verbatim. +type MessageData = Record; + +export class AppMessagesConverter implements IAppMessagesConverter { + private mem = new WeakMap>(); + + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(msgId: IMessage['_id']): Promise { + const msg = await Messages.findOneById(msgId); + + return this.convertMessage(msg); + } + + convertMessageRaw(msgObj: IMessage): Promise; + + convertMessageRaw(msgObj: IMessage | undefined | null): Promise; + + async convertMessageRaw(msgObj: IMessage | undefined | null): Promise { + if (!msgObj) { + return undefined; + } + + const { attachments, ...message } = msgObj; + const getAttachments = async () => this._convertAttachmentsToApp(attachments, (msgObj as MessageData).file); + + const map = { + id: '_id', + threadId: 'tmid', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + file: 'file', + files: 'files', + customFields: 'customFields', + groupable: 'groupable', + token: 'token', + blocks: 'blocks', + roomId: 'rid', + editor: 'editedBy', + attachments: getAttachments, + sender: 'u', + threadMsgCount: 'tcount', + type: 't', + } as const; + + return mappedDecodeAsync(message, map); + } + + convertMessage(msgObj: undefined | null): Promise; + + convertMessage(msgObj: IMessage, cacheObj?: object): Promise; + + convertMessage(msgObj: IMessage | undefined | null, cacheObj?: object): Promise; + + async convertMessage( + msgObj: IMessage | undefined | null, + cacheObj: object | undefined = msgObj ?? undefined, + ): Promise { + if (!msgObj) { + return undefined; + } + + const cache = + this.mem.get(cacheObj as object) ?? + new Map([ + ['room', cachedFunction(this.orch.getConverters().get('rooms').convertById.bind(this.orch.getConverters().get('rooms')))], + [ + 'user.convertById', + cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users'))), + ], + [ + 'user.convertToApp', + cachedFunction(this.orch.getConverters().get('users').convertToApp.bind(this.orch.getConverters().get('users'))), + ], + ]); + + this.mem.set(cacheObj as object, cache); + + const { attachments, file: mainFile } = msgObj as MessageData; + + const map = { + id: '_id', + threadId: 'tmid', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + file: 'file', + customFields: 'customFields', + groupable: 'groupable', + token: 'token', + blocks: 'blocks', + type: 't', + files: async (message: MessageData) => convertMessageFiles(message.files, attachments), + room: async (message: MessageData) => { + const result = await cache.get('room')(message.rid); + delete message.rid; + return result; + }, + editor: async (message: MessageData) => { + const { editedBy } = message; + delete message.editedBy; + + if (!editedBy) { + return undefined; + } + + return cache.get('user.convertById')(editedBy._id); + }, + attachments: async (message: MessageData) => { + const result = await this._convertAttachmentsToApp(message.attachments, mainFile); + delete message.attachments; + return result; + }, + sender: async (message: MessageData) => { + if (!message.u?._id) { + return undefined; + } + + // Keep a reference to the original sender before it is deleted below, so the fallback still has it. + const sender = message.u; + + // When the message contains token, means the message is from the visitor(omnichannel) + const user = await (isMessageFromVisitor(msgObj) + ? cache.get('user.convertToApp')(sender) + : cache.get('user.convertById')(sender._id)); + + delete message.u; + + /** + * Old System Messages from visitor doesn't have the `token` field, to not return + * `sender` as undefined, so we need to add this fallback here. + */ + + return user || cache.get('user.convertToApp')(sender); + }, + } as const; + + return mappedDecodeAsync(msgObj, map); + } + + convertAppMessage(message: undefined | null): Promise; + + convertAppMessage(message: IAppsMessage): Promise; + + convertAppMessage(message: IAppsMessage, isPartial: boolean): Promise>; + + async convertAppMessage(message: any, isPartial = false): Promise | undefined> { + if (!message) { + return undefined; + } + + let rid; + if (message.room?.id) { + const room = await Rooms.findOneById(message.room.id, { projection: { _id: 1 } }); + rid = room?._id; + } + + if (!rid && !isPartial) { + throw new Error('Invalid room provided on the message.'); + } + + let u; + if (message.sender?.id) { + const user = await Users.findOneById(message.sender.id); + + if (user) { + u = { + _id: user._id, + username: user.username, + name: user.name, + }; + } else { + u = { + _id: message.sender.id, + username: message.sender.username, + name: message.sender.name, + }; + } + } + + let editedBy; + if (message.editor) { + const editor = await Users.findOneById(message.editor.id); + // Fall back to the editor data carried on the app payload when the user no longer exists, + // mirroring the sender handling above instead of dereferencing a possibly-null lookup. + editedBy = editor + ? { + _id: editor._id, + username: editor.username, + } + : { + _id: message.editor.id, + username: message.editor.username, + }; + } + + const attachments = this._convertAppAttachments(message.attachments); + + let _id = message.id; + let ts = message.createdAt; + + if (!isPartial) { + if (!message.id) { + _id = Random.id(); + } + + if (!message.createdAt) { + ts = new Date(); + } + } + + const newMessage: MessageData = { + _id, + ...('threadId' in message && { tmid: message.threadId }), + rid, + u, + msg: message.text, + ts, + _updatedAt: message.updatedAt, + ...(editedBy && { editedBy }), + ...('editedAt' in message && { editedAt: message.editedAt }), + ...('emoji' in message && { emoji: message.emoji }), + ...('avatarUrl' in message && { avatar: message.avatarUrl }), + ...('alias' in message && { alias: message.alias }), + ...('customFields' in message && { customFields: message.customFields }), + ...('groupable' in message && { groupable: message.groupable }), + ...(attachments && { attachments }), + ...('reactions' in message && { reactions: message.reactions }), + ...('parseUrls' in message && { parseUrls: message.parseUrls }), + ...('blocks' in message && { blocks: message.blocks }), + ...('token' in message && { token: message.token }), + }; + + if (isPartial) { + Object.entries(newMessage).forEach(([key, value]) => { + if (typeof value === 'undefined') { + delete newMessage[key]; + } + }); + } else { + Object.assign(newMessage, message._unmappedProperties_); + } + + return newMessage as unknown as Partial; + } + + _convertAppAttachments(attachments: any) { + if (typeof attachments === 'undefined' || !Array.isArray(attachments)) { + return undefined; + } + + return attachments.map((attachment) => + removeEmpty({ + collapsed: attachment.collapsed, + color: attachment.color, + text: attachment.text, + ts: attachment.timestamp ? attachment.timestamp.toJSON() : attachment.timestamp, + message_link: attachment.timestampLink, + thumb_url: attachment.thumbnailUrl, + author_name: attachment.author ? attachment.author.name : undefined, + author_link: attachment.author ? attachment.author.link : undefined, + author_icon: attachment.author ? attachment.author.icon : undefined, + title: attachment.title ? attachment.title.value : undefined, + title_link: attachment.title ? attachment.title.link : undefined, + title_link_download: attachment.title ? attachment.title.displayDownloadLink : undefined, + image_dimensions: attachment.imageDimensions, + image_preview: attachment.imagePreview, + image_url: attachment.imageUrl, + image_type: attachment.imageType, + image_size: attachment.imageSize, + audio_url: attachment.audioUrl, + audio_type: attachment.audioType, + audio_size: attachment.audioSize, + video_url: attachment.videoUrl, + video_type: attachment.videoType, + video_size: attachment.videoSize, + fields: attachment.fields, + button_alignment: attachment.actionButtonsAlignment, + actions: attachment.actions, + type: attachment.type, + description: attachment.description, + ...attachment._unmappedProperties_, + }), + ); + } + + async _convertAttachmentsToApp(attachments: any, mainFile: any) { + if (typeof attachments === 'undefined' || !Array.isArray(attachments)) { + return undefined; + } + + const map = { + collapsed: 'collapsed', + color: 'color', + text: 'text', + timestampLink: 'message_link', + thumbnailUrl: 'thumb_url', + imageDimensions: 'image_dimensions', + imagePreview: 'image_preview', + imageUrl: 'image_url', + imageType: 'image_type', + imageSize: 'image_size', + audioUrl: 'audio_url', + audioType: 'audio_type', + audioSize: 'audio_size', + videoUrl: 'video_url', + videoType: 'video_type', + videoSize: 'video_size', + fields: 'fields', + actionButtonsAlignment: 'button_alignment', + actions: 'actions', + type: 'type', + description: 'description', + author: (attachment: MessageData) => { + const { author_name: name, author_link: link, author_icon: icon } = attachment; + + delete attachment.author_name; + delete attachment.author_link; + delete attachment.author_icon; + + return { name, link, icon }; + }, + title: (attachment: MessageData) => { + const { title: value, title_link: link, title_link_download: displayDownloadLink } = attachment; + + delete attachment.title; + delete attachment.title_link; + delete attachment.title_link_download; + + return { value, link, displayDownloadLink }; + }, + timestamp: (attachment: MessageData) => { + const result = new Date(attachment.ts); + delete attachment.ts; + return result; + }, + fileId: (attachment: MessageData) => { + // If the attachment is missing the fileId, but there's only one file in the message, use that file's ID + if (!attachment.fileId && attachment.type === 'file' && mainFile?._id && attachments.length === 1) { + return mainFile._id; + } + + return attachment.fileId; + }, + } as const; + + return Promise.all(attachments.map(async (attachment) => mappedDecodeAsync(attachment, map))); + } +} diff --git a/apps/meteor/app/apps/server/converters/roles.ts b/apps/meteor/app/apps/server/converters/roles.ts new file mode 100644 index 0000000..1e3fb92 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/roles.ts @@ -0,0 +1,22 @@ +import type { IAppRolesConverter } from '@rocket.chat/apps'; +import type { IRole as AppsEngineRole } from '@rocket.chat/apps-engine/definition/roles'; +import type { IRole } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { RoleCodec } from './codecs'; + +export class AppRolesConverter implements IAppRolesConverter { + async convertById(roleId: string): Promise { + const role = await Roles.findOneById(roleId); + + if (!role) { + return; + } + return this.convertRole(role); + } + + async convertRole(role: IRole): Promise { + return z.decode(RoleCodec, role) as unknown as AppsEngineRole; + } +} diff --git a/apps/meteor/app/apps/server/converters/rooms.ts b/apps/meteor/app/apps/server/converters/rooms.ts new file mode 100644 index 0000000..1fc4eeb --- /dev/null +++ b/apps/meteor/app/apps/server/converters/rooms.ts @@ -0,0 +1,73 @@ +import type { IAppRoomsConverter, IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsRoomRaw } from '@rocket.chat/apps'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { appRoomToRocketChat, createRoomCodec, decodeRoomRaw } from './codecs/rooms'; + +export class AppRoomsConverter implements IAppRoomsConverter { + private readonly codec: ReturnType; + + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + this.codec = createRoomCodec(orch); + } + + async convertById(roomId: IRoom['_id']): Promise { + const room = await Rooms.findOneById(roomId); + + return this.convertRoom(room); + } + + async convertByName(roomName: IRoom['name']): Promise { + const room = await Rooms.findOneByName(roomName as string); + + return this.convertRoom(room); + } + + convertRoomRaw(room: IRoom): Promise; + + convertRoomRaw(room: IRoom | undefined | null): Promise; + + convertRoomRaw(room: IRoom | undefined | null): Promise { + if (!room) { + return Promise.resolve(undefined); + } + + return decodeRoomRaw(room); + } + + async convertAppRoom(room: undefined | null): Promise; + + async convertAppRoom(room: IAppsRoom): Promise; + + async convertAppRoom(room: IAppsRoom, isPartial: boolean): Promise>; + + async convertAppRoom(room: IAppsRoom | undefined | null, isPartial?: boolean): Promise | undefined>; + + async convertAppRoom(room: IAppsRoom | undefined | null, isPartial = false): Promise | undefined> { + if (!room) { + return undefined; + } + + if (isPartial) { + return appRoomToRocketChat(room, true); + } + + return z.encodeAsync(this.codec, room); + } + + convertRoom(originalRoom: undefined | null): Promise; + + convertRoom(originalRoom: IRoom): Promise; + + convertRoom(originalRoom: IRoom | undefined | null): Promise; + + convertRoom(originalRoom: IRoom | undefined | null): Promise { + if (!originalRoom) { + return Promise.resolve(undefined); + } + + return z.decodeAsync(this.codec, originalRoom); + } +} diff --git a/apps/meteor/app/apps/server/converters/settings.ts b/apps/meteor/app/apps/server/converters/settings.ts new file mode 100644 index 0000000..a052d9e --- /dev/null +++ b/apps/meteor/app/apps/server/converters/settings.ts @@ -0,0 +1,22 @@ +import type { IAppServerOrchestrator, IAppSettingsConverter, IAppsSetting } from '@rocket.chat/apps'; +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { SettingCodec } from './codecs'; + +export class AppSettingsConverter implements IAppSettingsConverter { + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(settingId: ISetting['_id']): Promise { + const setting = await Settings.findOneById(settingId); + + return this.convertToApp(setting as ISetting); + } + + convertToApp(setting: ISetting): IAppsSetting { + return z.decode(SettingCodec, setting); + } +} diff --git a/apps/meteor/app/apps/server/converters/threads.ts b/apps/meteor/app/apps/server/converters/threads.ts new file mode 100644 index 0000000..e828c5f --- /dev/null +++ b/apps/meteor/app/apps/server/converters/threads.ts @@ -0,0 +1,209 @@ +import type { + IAppRoomsConverter, + IAppServerOrchestrator, + IAppThreadsConverter, + IAppUsersConverter, + IAppsMessage, + IAppsUser, +} from '@rocket.chat/apps'; +import type { IMessage as AppsEngineMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { isEditedMessage, isFileAttachment } from '@rocket.chat/core-typings'; +import type { IUser, IMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; + +import { cachedFunction } from './cachedFunction'; +import { mappedDecodeAsync, type AsyncFieldMap } from './codecs/mappedData'; +import { convertMessageFiles } from './convertMessageFiles'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +interface Orchestrator { + rooms: () => { + convertById: IAppRoomsConverter['convertById']; + }; + users: () => { + convertById: IAppUsersConverter['convertById']; + convertToApp: IAppUsersConverter['convertToApp']; + }; +} + +export class AppThreadsConverter implements IAppThreadsConverter { + constructor(private readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(threadId: string) { + const query = { + $or: [ + { + _id: threadId, + }, + { + tmid: threadId, + }, + ], + }; + + const mainMessage = await Messages.findOneById(threadId); + + if (!mainMessage) { + return []; + } + + const replies = await Messages.find(query).toArray(); + + const room = await this.orch.getConverters().get('rooms').convertById(mainMessage.rid); + + if (!room) { + return []; + } + + const convertToApp = cachedFunction(this.orch.getConverters().get('users').convertToApp.bind(this.orch.getConverters().get('users'))); + + const convertUserById = cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users'))); + + return Promise.all([mainMessage, ...replies].map((msg) => this.convertMessage(msg, room, convertUserById, convertToApp))); + } + + async convertMessage( + msgObj: IMessage, + room: IRoom, + convertUserById: ReturnType['convertById'], + convertToApp: ReturnType['convertToApp'], + ): Promise { + const { attachments, file: mainFile } = msgObj; + + const map = { + id: '_id', + threadId: 'tmid', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + file: 'file', + customFields: 'customFields', + groupable: 'groupable', + token: 'token', + blocks: 'blocks', + room: () => room, + editor: async (message: IMessage) => { + if (!isEditedMessage(message)) { + return undefined; + } + + const { editedBy } = message; + + return convertUserById(editedBy._id); + }, + attachments: async (message: IMessage): Promise => { + if (!message.attachments) { + return undefined; + } + const result = await this._convertAttachmentsToApp(message.attachments, mainFile); + delete message.attachments; + return result; + }, + sender: async (message: IMessage): Promise => { + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + if (!message.u?._id) { + return undefined as unknown as IAppsUser; + } + + let user: IAppsUser | undefined = await convertUserById(message.u._id); + + // When the sender of the message is a Guest (livechat) and not a user + if (!user) { + user = await convertToApp(message.u as unknown as IUser); + } + + return user; + }, + files: async (message: IMessage) => convertMessageFiles(message.files, attachments), + } as const; + + // #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed. + const msgData = { + ...msgObj, + reactions: msgObj.reactions as unknown as AppsEngineMessage['reactions'], + } as IMessage & { reactions?: AppsEngineMessage['reactions'] }; + + return mappedDecodeAsync(msgData, map as unknown as AsyncFieldMap); + } + + async _convertAttachmentsToApp( + attachments: NonNullable, + mainFile: IMessage['file'], + ): Promise> { + const map = { + collapsed: 'collapsed', + color: 'color', + text: 'text', + timestampLink: 'message_link', + thumbnailUrl: 'thumb_url', + imageDimensions: 'image_dimensions', + imagePreview: 'image_preview', + imageUrl: 'image_url', + imageType: 'image_type', + imageSize: 'image_size', + audioUrl: 'audio_url', + audioType: 'audio_type', + audioSize: 'audio_size', + videoUrl: 'video_url', + videoType: 'video_type', + videoSize: 'video_size', + fields: 'fields', + actionButtonsAlignment: 'button_alignment', + actions: 'actions', + type: 'type', + description: 'description', + author: (attachment: NonNullable[number]): IMessageAttachment['author'] => { + if (!('author_name' in attachment)) { + return; + } + + const { author_name: name, author_link: link, author_icon: icon } = attachment; + + delete attachment.author_name; + delete attachment.author_link; + delete attachment.author_icon; + + return { name, link, icon }; + }, + title: (attachment: NonNullable[number]) => { + const { title: value, title_link: link, title_link_download: displayDownloadLink } = attachment; + + delete attachment.title; + delete attachment.title_link; + delete attachment.title_link_download; + + return { value, link, displayDownloadLink }; + }, + timestamp: (attachment: NonNullable[number]) => { + const result = attachment.ts ? new Date(attachment.ts) : undefined; + delete attachment.ts; + return result; + }, + fileId: (attachment: NonNullable[number]) => { + if ('fileId' in attachment && attachment.fileId) { + return attachment.fileId; + } + + // If the attachment is missing the fileId, but there's only one file in the message, use that file's ID + if (isFileAttachment(attachment) && mainFile?._id && attachments?.length === 1) { + return mainFile._id; + } + + return undefined; + }, + } as const; + + return Promise.all( + attachments.map(async (attachment) => mappedDecodeAsync(attachment, map as unknown as AsyncFieldMap)), + ) as unknown as Promise>; + } +} diff --git a/apps/meteor/app/apps/server/converters/uploads.ts b/apps/meteor/app/apps/server/converters/uploads.ts new file mode 100644 index 0000000..8b62fe1 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/uploads.ts @@ -0,0 +1,49 @@ +import type { IAppServerOrchestrator, IAppUploadsConverter, IAppsUpload } from '@rocket.chat/apps'; +import type { IUpload } from '@rocket.chat/core-typings'; +import { Uploads } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { createUploadsCodec } from './codecs/uploads'; + +export class AppUploadsConverter implements IAppUploadsConverter { + private readonly codec: ReturnType; + + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + this.codec = createUploadsCodec(orch); + } + + async convertById(id: string): Promise { + const upload = await Uploads.findOneById(id); + + return this.convertToApp(upload); + } + + async convertToApp(upload: undefined | null): Promise; + + async convertToApp(upload: IUpload): Promise; + + async convertToApp(upload: IUpload | undefined | null): Promise; + + async convertToApp(upload: IUpload | undefined | null): Promise { + if (!upload) { + return undefined; + } + + return z.decodeAsync(this.codec, upload); + } + + convertToRocketChat(upload: undefined | null): undefined; + + convertToRocketChat(upload: IAppsUpload): IUpload; + + convertToRocketChat(upload: IAppsUpload | undefined | null): IUpload | undefined; + + convertToRocketChat(upload: IAppsUpload | undefined | null): IUpload | undefined { + if (!upload) { + return undefined; + } + + return z.encode(this.codec, upload); + } +} diff --git a/apps/meteor/app/apps/server/converters/users.ts b/apps/meteor/app/apps/server/converters/users.ts new file mode 100644 index 0000000..a76868f --- /dev/null +++ b/apps/meteor/app/apps/server/converters/users.ts @@ -0,0 +1,56 @@ +import type { IAppServerOrchestrator, IAppUsersConverter, IAppsUser } from '@rocket.chat/apps'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { UserCodec } from './codecs'; + +export class AppUsersConverter implements IAppUsersConverter { + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(userId: IUser['_id']): Promise { + const user = await Users.findOneById(userId); + + return this.convertToApp(user); + } + + async convertByUsername(username: IUser['username']): Promise { + if (!username) { + return undefined; + } + + const user = await Users.findOneByUsername(username); + + return this.convertToApp(user); + } + + convertToApp(user: undefined | null): undefined; + + convertToApp(user: IUser): IAppsUser; + + convertToApp(user: IUser | undefined | null): IAppsUser | undefined; + + convertToApp(user: IUser | undefined | null): IAppsUser | undefined { + if (!user) { + return undefined; + } + + return z.decode(UserCodec, user); + } + + convertToRocketChat(user: undefined | null): undefined; + + convertToRocketChat(user: IAppsUser): IUser; + + convertToRocketChat(user: IAppsUser | undefined | null): IUser | undefined; + + convertToRocketChat(user: IAppsUser | undefined | null): IUser | undefined { + if (!user) { + return undefined; + } + + return z.encode(UserCodec, user); + } +} diff --git a/apps/meteor/app/apps/server/converters/videoConferences.ts b/apps/meteor/app/apps/server/converters/videoConferences.ts new file mode 100644 index 0000000..d5adb39 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/videoConferences.ts @@ -0,0 +1,32 @@ +import type { IAppVideoConferencesConverter, AppsVideoConference } from '@rocket.chat/apps'; +import { VideoConf } from '@rocket.chat/core-services'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import * as z from 'zod'; + +import { VideoConferenceCodec } from './codecs'; + +export class AppVideoConferencesConverter implements IAppVideoConferencesConverter { + async convertById(callId: string): Promise { + const call = await VideoConf.getUnfiltered(callId); + + return this.convertVideoConference(call); + } + + convertVideoConference(call: undefined | null): undefined; + + convertVideoConference(call: VideoConference): AppsVideoConference; + + convertVideoConference(call: VideoConference | undefined | null): AppsVideoConference | undefined; + + convertVideoConference(call: VideoConference | undefined | null): AppsVideoConference | undefined { + if (!call) { + return; + } + + return z.decode(VideoConferenceCodec, call); + } + + convertAppVideoConference(call: AppsVideoConference): VideoConference { + return z.encode(VideoConferenceCodec, call); + } +} diff --git a/apps/meteor/app/apps/server/converters/visitors.ts b/apps/meteor/app/apps/server/converters/visitors.ts new file mode 100644 index 0000000..e721c3f --- /dev/null +++ b/apps/meteor/app/apps/server/converters/visitors.ts @@ -0,0 +1,53 @@ +import type { IAppServerOrchestrator, IAppVisitorsConverter, IAppsVisitor } from '@rocket.chat/apps'; +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; +import * as z from 'zod'; + +import { VisitorCodec } from './codecs'; + +// TODO: check if functions from this converter can be async +export class AppVisitorsConverter implements IAppVisitorsConverter { + constructor(protected readonly orch: IAppServerOrchestrator) { + this.orch = orch; + } + + async convertById(id: ILivechatVisitor['_id']): Promise { + const visitor = await LivechatVisitors.findOneEnabledById(id); + + return this.convertVisitor(visitor); + } + + async convertByToken(token: string): Promise { + const visitor = await LivechatVisitors.getVisitorByToken(token); + + return this.convertVisitor(visitor); + } + + async convertVisitor(visitor: undefined | null): Promise; + + async convertVisitor(visitor: ILivechatVisitor): Promise; + + async convertVisitor(visitor: ILivechatVisitor | undefined | null): Promise; + + async convertVisitor(visitor: ILivechatVisitor | undefined | null): Promise { + if (!visitor) { + return undefined; + } + + return z.decode(VisitorCodec, visitor); + } + + convertAppVisitor(visitor: undefined | null): undefined; + + convertAppVisitor(visitor: IAppsVisitor): ILivechatVisitor; + + convertAppVisitor(visitor: IAppsVisitor | undefined | null): ILivechatVisitor | undefined; + + convertAppVisitor(visitor: IAppsVisitor | undefined | null): ILivechatVisitor | undefined { + if (!visitor) { + return undefined; + } + + return z.encode(VisitorCodec, visitor); + } +} diff --git a/apps/meteor/app/authorization/lib/AuthorizationUtils.ts b/apps/meteor/app/authorization/lib/AuthorizationUtils.ts new file mode 100644 index 0000000..d51de55 --- /dev/null +++ b/apps/meteor/app/authorization/lib/AuthorizationUtils.ts @@ -0,0 +1,58 @@ +const restrictedRolePermissions = new Map>(); + +export const AuthorizationUtils = class { + static addRolePermissionWhiteList(roleId: string, list: string[]): void { + if (!roleId) { + throw new Error('invalid-param'); + } + + if (!list) { + throw new Error('invalid-param'); + } + + if (!restrictedRolePermissions.has(roleId)) { + restrictedRolePermissions.set(roleId, new Set()); + } + + const rules = restrictedRolePermissions.get(roleId); + + for (const permissionId of list) { + rules?.add(permissionId); + } + } + + static isPermissionRestrictedForRole(permissionId: string, roleId: string): boolean { + if (!roleId || !permissionId) { + throw new Error('invalid-param'); + } + + if (!restrictedRolePermissions.has(roleId)) { + return false; + } + + const rules = restrictedRolePermissions.get(roleId); + if (!rules?.size) { + return false; + } + + return !rules.has(permissionId); + } + + static isPermissionRestrictedForRoleList(permissionId: string, roleList: string[]): boolean { + if (!roleList || !permissionId) { + throw new Error('invalid-param'); + } + + for (const roleId of roleList) { + if (this.isPermissionRestrictedForRole(permissionId, roleId)) { + return true; + } + } + + return false; + } + + static hasRestrictionsToRole(roleId: string): boolean { + return restrictedRolePermissions.has(roleId); + } +}; diff --git a/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts new file mode 100644 index 0000000..1b689e1 --- /dev/null +++ b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts @@ -0,0 +1,101 @@ +import type { IPermission, IRole, IUser } from '@rocket.chat/core-typings'; + +import { AuthorizationUtils } from './AuthorizationUtils'; + +export type AuthorizationDeps = { + /** The currently logged-in user id, or undefined. */ + getCurrentUserId: () => IUser['_id'] | undefined; + /** The role ids assigned to a given user (Users scope). */ + getUserRoles: (userId: IUser['_id']) => IRole['_id'][] | undefined; + /** Lookup a permission by id. */ + getPermission: (permissionId: IPermission['_id']) => IPermission | undefined; + /** The scope of a role; defaults to 'Users' when the role is unknown. */ + getRoleScope: (roleId: IRole['_id']) => IRole['scope'] | undefined; + /** Whether a subscription scoped to `rid` grants `roleId`. */ + hasSubscriptionRole: (rid: string, roleId: IRole['_id']) => boolean; + /** Whether the permissions cache is hydrated; otherwise checks short-circuit to false. */ + isReady: () => boolean; +}; + +export type AuthorizationFunctions = { + hasRole: (userId: IUser['_id'], roleId: IRole['_id'], scope?: string) => boolean; + hasAllPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + hasAtLeastOnePermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string) => boolean; + /** Alias of hasAllPermission, kept for parity with the previous API. */ + hasPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + userHasAllPermission: ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + ) => boolean; +}; + +/** + * Pure factory for the client-side authorization helpers. All store access is + * threaded through the {@link AuthorizationDeps} accessors, so the returned + * functions are testable in isolation and reusable across any state backend. + */ +export const createAuthorizationFunctions = (deps: AuthorizationDeps): AuthorizationFunctions => { + const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: string): boolean => { + const roleScope = deps.getRoleScope(roleId) ?? 'Users'; + switch (roleScope) { + case 'Subscriptions': + if (!scope) return false; + return deps.hasSubscriptionRole(scope, roleId); + case 'Users': + return deps.getUserRoles(userId)?.includes(roleId) ?? false; + default: + return false; + } + }; + + const checkPermissions = ( + permissionIds: IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + scopedRoles: IRole['_id'][] | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + ): boolean => { + const userRoles = deps.getUserRoles(userId); + return quantifier.call(permissionIds, (permissionId) => { + if (userRoles && AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { + return false; + } + const roles = deps.getPermission(permissionId)?.roles ?? []; + return roles.some((roleId) => { + if (scopedRoles?.includes(roleId)) return true; + return hasRole(userId, roleId, scope); + }); + }); + }; + + const validatePermissions = ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + userId: IUser['_id'] | undefined, + scopedRoles?: IRole['_id'][], + ): boolean => { + if (!userId) return false; + if (!deps.isReady()) return false; + const ids = ([] as IPermission['_id'][]).concat(permissions); + return checkPermissions(ids, scope, userId, scopedRoles, quantifier); + }; + + const hasAllPermission: AuthorizationFunctions['hasAllPermission'] = (permissions, scope, scopedRoles) => + validatePermissions(permissions, scope, Array.prototype.every, deps.getCurrentUserId(), scopedRoles); + + const hasAtLeastOnePermission: AuthorizationFunctions['hasAtLeastOnePermission'] = (permissions, scope) => + validatePermissions(permissions, scope, Array.prototype.some, deps.getCurrentUserId()); + + const userHasAllPermission: AuthorizationFunctions['userHasAllPermission'] = (permissions, scope, userId) => + validatePermissions(permissions, scope, Array.prototype.every, userId); + + return { + hasRole, + hasAllPermission, + hasAtLeastOnePermission, + hasPermission: hasAllPermission, + userHasAllPermission, + }; +}; diff --git a/apps/meteor/app/authorization/lib/index.ts b/apps/meteor/app/authorization/lib/index.ts new file mode 100644 index 0000000..c8e5e7b --- /dev/null +++ b/apps/meteor/app/authorization/lib/index.ts @@ -0,0 +1,14 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +export const getSettingPermissionId = function (settingId: ISetting['_id']) { + // setting-based permissions + return `change-setting-${settingId}`; +}; + +export const CONSTANTS = { + SETTINGS_LEVEL: 'settings', +} as const; + +export const confirmationRequiredPermissions = ['access-permissions']; + +export { AuthorizationUtils } from './AuthorizationUtils'; diff --git a/apps/meteor/app/emoji-native/lib/generateEmojiData.spec.ts b/apps/meteor/app/emoji-native/lib/generateEmojiData.spec.ts new file mode 100644 index 0000000..e8e4d4d --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/generateEmojiData.spec.ts @@ -0,0 +1,196 @@ +import type { Emoji } from 'emojibase'; +import data from 'emojibase-data/en/data.json'; +import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json'; +import joypixelsShortcodes from 'emojibase-data/en/shortcodes/joypixels.json'; + +import type { EmojiEntry } from './generateEmojiData'; +import { getEmojiData } from './generateEmojiData'; +import { legacyEmojioneMap } from './legacyEmojioneMap'; +import { shortnameToUnicode } from './shortnameToUnicode'; + +const asArray = (entry: string | string[] | undefined): string[] => { + if (!entry) return []; + return Array.isArray(entry) ? entry : [entry]; +}; + +/** + * The emoji we expose shortcodes for, derived independently from `generateEmojiData` so this suite + * fails if the two ever drift apart. + */ +const emittedEmojis = (() => { + const emojis = new Map(); + + for (const emojiData of data as Emoji[]) { + if (emojiData.group === 2) continue; + + const { hexcode } = emojiData; + const codepoint = hexcode.includes('-') ? NaN : parseInt(hexcode, 16); + const isRegional = codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff; + if (emojiData.group === undefined && !isRegional) continue; + + emojis.set(hexcode, emojiData.emoji); + for (const skin of emojiData.skins ?? []) { + if (!skin.tone) continue; + emojis.set(skin.hexcode, skin.emoji); + } + } + + return emojis; +})(); + +describe('native emoji shortcodes', () => { + const { emojiList, emojisByCategory } = getEmojiData(); + + const unicodeOf = (shortcode: string) => emojiList[`:${shortcode}:`]?.unicode; + + // Shortcodes both presets define, pointing at different emoji. Reaction keys and message + // shortcodes stored before the native emoji migration — and everything the mobile clients + // send today — use the joypixels/emojione meaning, so these must not follow emojibase. + describe.each([ + ['up', '🆙', 'arrow_up_small', '🔼'], + ['cat', '🐱', 'cat2', '🐈️'], + ['dog', '🐶', 'dog2', '🐕️'], + ['o', '⭕️', 'o2', '🅾️'], + ['point_up', '☝️', 'point_up_2', '👆️'], + ['sunglasses', '😎', 'dark_sunglasses', '🕶️'], + ['pencil', '📝', 'pencil2', '✏️'], + ['train', '🚋', 'train2', '🚆'], + ['whale', '🐳', 'whale2', '🐋'], + ['umbrella', '☔️', 'umbrella2', '☂️'], + ['computer', '💻️', 'desktop_computer', '🖥️'], + ['satellite', '📡', 'satellite_orbital', '🛰️'], + ['tiger', '🐯', 'tiger2', '🐅'], + ['no', '🇳🇴', 'thumbsdown', '👎️'], + ['om', '🇴🇲', 'om_symbol', '🕉️'], + ['sunny', '☀️', 'white_sun_small_cloud', '🌤️'], + ['japan', '🗾', 'flag_jp', '🇯🇵'], + ])('contested shortcode :%s:', (contested, expected, displacedShortcode, displaced) => { + it(`resolves to ${expected}, not ${displaced}`, () => { + expect(unicodeOf(contested)).toBe(expected); + }); + + it(`leaves ${displaced} reachable as :${displacedShortcode}:`, () => { + expect(unicodeOf(displacedShortcode)).toBe(displaced); + }); + }); + + it('falls back to emojibase for emoji joypixels never named', () => { + // joypixels stopped shipping, so everything added to Unicode since then only has an + // emojibase name — without the fallback these emoji disappear from the picker entirely + expect(unicodeOf('fingerprint')).toBe('🫆'); + expect(unicodeOf('face_with_eye_bags')).toBe('🫩'); + expect(unicodeOf('leafless_tree')).toBe('🪾'); + expect(unicodeOf('root_vegetable')).toBe('🫜'); + }); + + it('only backfills legacy shortcodes the presets do not provide', () => { + // the map is hand-generated; if a preset already names a shortcode, the entry is dead weight + // and, worse, hides which name is authoritative + const redundant = Object.keys(legacyEmojioneMap).filter((shortcode) => emojiList[`:${shortcode}:`]); + + expect(redundant).toEqual([]); + }); + + it('resolves the legacy shortcodes that survived', () => { + expect(shortnameToUnicode(':bride_with_veil:')).toBe('👰'); + expect(shortnameToUnicode(':bride_with_veil_tone3:')).toBe('👰🏽'); + expect(shortnameToUnicode(':digit_zero:')).toBe('0️⃣'); + expect(shortnameToUnicode(':tone3:')).toBe('🏽'); + }); + + it('keeps joypixels aliases of contested emoji working', () => { + expect(unicodeOf('point_up_2')).toBe('👆️'); + expect(unicodeOf('cat_face')).toBe('🐱'); + expect(unicodeOf('tram_car')).toBe('🚋'); + }); + + it('applies the same precedence to skin tone variants', () => { + expect(unicodeOf('point_up_tone1')).toBe('☝🏻'); + expect(unicodeOf('point_up_2_tone1')).toBe('👆🏻'); + }); + + it('resolves contested shortcodes to unicode for shortnameToUnicode consumers', () => { + expect(shortnameToUnicode(':up:')).toBe('🆙'); + expect(shortnameToUnicode(':cat:')).toBe('🐱'); + expect(shortnameToUnicode(':o:')).toBe('⭕️'); + }); + + it('gives joypixels precedence for every shortcode the two presets contest', () => { + const contested: string[] = []; + + for (const [hexcode, emoji] of emittedEmojis) { + for (const shortcode of asArray((joypixelsShortcodes as Record)[hexcode])) { + const entry = emojiList[`:${shortcode}:`]; + if (entry && entry.unicode !== emoji) { + contested.push(`:${shortcode}: is ${entry.unicode}, joypixels says ${emoji}`); + } + } + } + + expect(contested).toEqual([]); + }); + + it('never strands an emoji without a shortcode', () => { + const stranded: string[] = []; + const reachable = new Set(Object.values(emojiList).map((entry) => entry.unicode)); + + for (const [hexcode, emoji] of emittedEmojis) { + const hadShortcode = + asArray((emojibaseShortcodes as Record)[hexcode]).length > 0 || + asArray((joypixelsShortcodes as Record)[hexcode]).length > 0; + + if (hadShortcode && !reachable.has(emoji)) { + stranded.push(`${emoji} (${hexcode})`); + } + } + + expect(stranded).toEqual([]); + }); + + it('keeps every picker entry resolvable', () => { + const unresolvable = Object.values(emojisByCategory) + .flat() + .filter((shortcode) => !emojiList[`:${shortcode}:`]); + + expect(unresolvable).toEqual([]); + }); + + it('keeps every picker entry pointing at its own emoji', () => { + // a picker entry whose name resolves to a *different* emoji would insert the wrong emoji + const misdirected = Object.values(emojisByCategory) + .flat() + .filter((shortcode) => emojiList[`:${shortcode}:`]?.name !== shortcode); + + expect(misdirected).toEqual([]); + }); + + it('registers the primary shortcode before any of its aliases', () => { + // picker search and unicodeToShortcodeMap both take the first registered name for an entry, + // so an alias registered ahead of the primary surfaces the wrong name to users + const firstSeen = new Map(); + for (const [shortcode, entry] of Object.entries(emojiList)) { + if (!firstSeen.has(entry)) firstSeen.set(entry, shortcode); + } + + const outOfOrder = Object.values(emojisByCategory) + .flat() + .filter((shortcode) => firstSeen.get(emojiList[`:${shortcode}:`]) !== `:${shortcode}:`); + + expect(outOfOrder).toEqual([]); + }); + + it('never lists a shortname that resolves to another emoji', () => { + // emoji picker search reads `shortnames`, so a stale alias surfaces the wrong result + const stale: string[] = []; + + for (const entry of Object.values(emojiList)) { + for (const shortname of entry.shortnames) { + if (emojiList[shortname] !== entry) { + stale.push(`${entry.unicode} lists ${shortname}, which resolves to ${emojiList[shortname]?.unicode}`); + } + } + } + + expect(stale).toEqual([]); + }); +}); diff --git a/apps/meteor/app/emoji-native/lib/generateEmojiData.ts b/apps/meteor/app/emoji-native/lib/generateEmojiData.ts new file mode 100644 index 0000000..33c8da8 --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/generateEmojiData.ts @@ -0,0 +1,152 @@ +import data from 'emojibase-data/en/data.json'; +import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json'; +import joypixelsShortcodes from 'emojibase-data/en/shortcodes/joypixels.json'; + +// Map emojibase group numbers to our category keys +const groupToCategory: Record = { + 0: 'people', // smileys & emotion + 1: 'people', // people & body + 2: 'people', // component - skin tones etc (we skip these) + 3: 'nature', // animals & nature + 4: 'food', // food & drink + 5: 'travel', // travel & places + 6: 'activity', // activities + 7: 'objects', // objects + 8: 'symbols', // symbols + 9: 'flags', // flags +}; + +export type EmojiEntry = { + name: string; + uc_base: string; + uc_output: string; + uc_match: string; + uc_greedy: string; + shortnames: string[]; + category: string; + emojiPackage: string; + unicode: string; +}; + +function hexFromEmoji(emoji: string): string { + return [...emoji].map((cp) => cp.codePointAt(0)!.toString(16)).join('-'); +} + +function isRegionalIndicator(hexcode: string): boolean { + if (hexcode.includes('-')) return false; + const cp = parseInt(hexcode, 16); + return cp >= 0x1f1e6 && cp <= 0x1f1ff; +} + +function getShortcodes(hexcode: string): string[] { + const entry = + (joypixelsShortcodes as Record)[hexcode] ?? + (emojibaseShortcodes as Record)[hexcode]; + if (!entry) return []; + return Array.isArray(entry) ? entry : [entry]; +} + +function buildEmojiData() { + const emojiList: Record = {}; + const emojisByCategory: Record = { + people: [], + nature: [], + food: [], + activity: [], + travel: [], + objects: [], + symbols: [], + flags: [], + }; + const toneList: Record = {}; + const bareAliases: [string, string][] = []; + + for (const emojiData of data) { + // Skip component group (skin tones, hair styles) + if (emojiData.group === 2) continue; + + const isRegional = isRegionalIndicator(emojiData.hexcode); + const category = groupToCategory[emojiData.group ?? -1] ?? (isRegional ? 'flags' : undefined); + if (!category) continue; + + const codes = getShortcodes(emojiData.hexcode); + if (codes.length === 0) continue; + + const primaryShortcode = codes[0]; + const altShortcodes = codes.slice(1).map((s) => `:${s}:`); + const hex = hexFromEmoji(emojiData.emoji); + const bare = emojiData.emoji.replace(/\uFE0F/g, ''); + + const entry: EmojiEntry = { + name: primaryShortcode, + uc_base: hex, + uc_output: hex, + uc_match: hex, + uc_greedy: hex, + shortnames: altShortcodes, + category, + emojiPackage: 'native', + unicode: emojiData.emoji, + }; + + const key = `:${primaryShortcode}:`; + emojiList[key] = entry; + + if (emojiData.type === 1 && bare !== emojiData.emoji) { + bareAliases.push([bare, key]); + } + + // Only add to category if it's NOT a skin tone variant + if (!emojiData.tone && !isRegional) { + emojisByCategory[category].push(primaryShortcode); + } + + // Register alternate shortcodes + for (const alt of altShortcodes) { + emojiList[alt] = entry; + } + + // Track skin tone support + if (emojiData.skins && emojiData.skins.length > 0) { + toneList[primaryShortcode] = 1; + + // Register skin tone variants + for (const skin of emojiData.skins) { + if (!skin.tone) continue; + + const tones = Array.isArray(skin.tone) ? skin.tone : [skin.tone]; + const skinHex = hexFromEmoji(skin.emoji); + + const skinEntry: EmojiEntry = { + name: primaryShortcode, + uc_base: skinHex, + uc_output: skinHex, + uc_match: skinHex, + uc_greedy: skinHex, + shortnames: [], + category, + emojiPackage: 'native', + unicode: skin.emoji, + }; + + const toneShortcodes = getShortcodes(skin.hexcode); + const fallbackShortcode = `${primaryShortcode}_tone${tones.join('-')}`; + for (const code of [...toneShortcodes, fallbackShortcode]) { + emojiList[`:${code}:`] = skinEntry; + } + } + } + } + + return { emojiList, emojisByCategory, toneList, bareAliases }; +} + +// Build once and cache +let _cached: ReturnType | null = null; + +export function getEmojiData() { + if (!_cached) { + _cached = buildEmojiData(); + } + return _cached; +} diff --git a/apps/meteor/app/emoji-native/lib/getEmojiConfig.spec.ts b/apps/meteor/app/emoji-native/lib/getEmojiConfig.spec.ts new file mode 100644 index 0000000..5ab30f7 --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/getEmojiConfig.spec.ts @@ -0,0 +1,95 @@ +import { getEmojiConfig } from './getEmojiConfig'; +import type { EmojiPackages } from '../../emoji/lib/rocketchat'; + +const buildEmojiPackages = (ascii: boolean): EmojiPackages => + ({ + packages: { native: { ascii } }, + list: {}, + }) as unknown as EmojiPackages; + +describe('native emoji render', () => { + it('converts ascii emoticons when the ascii flag is enabled', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render('hello :)')).toBe('hello 🙂'); + }); + + it('converts HTML-escaped ascii emoticons', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render('hello >:(')).toBe('hello 😠'); + expect(render('hello <3')).toBe('hello ❤'); + }); + + it('keeps ascii emoticons literal when the ascii flag is disabled', () => { + const { render } = getEmojiConfig(buildEmojiPackages(false)); + + expect(render('hello :)')).toBe('hello :)'); + }); + + it('converts ascii emoticons adjacent to HTML tags', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render('

hello :D

')).toBe('

hello 😄

'); + expect(render('

:)

')).toBe('

🙂

'); + }); + + it('does not convert ascii sequences inside words or URLs', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render('https://rocket.chat')).toBe('https://rocket.chat'); + expect(render('hello:)')).toBe('hello:)'); + }); + + it('does not convert ascii sequences inside HTML tags', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render('rocket')).toBe('rocket'); + }); + + it('still renders shortcodes when the ascii flag is enabled', () => { + const { render } = getEmojiConfig(buildEmojiPackages(true)); + + expect(render(':smiley:')).toContain(''); + }); + + it('renders bare (non-VS16) emoji-default characters', () => { + const { render } = getEmojiConfig(buildEmojiPackages(false)); + + expect(render('🐕')).toBe('🐕'); + expect(render('🐶')).toBe('🐶'); + expect(render('⭐')).toBe('⭐'); + expect(render('👍')).toBe('👍'); + }); + + it('resolves the VS16-qualified form to the same shortcode', () => { + const { render } = getEmojiConfig(buildEmojiPackages(false)); + + expect(render('🐕\u{FE0F}')).toBe('🐕\u{FE0F}'); + expect(render('⭐\u{FE0F}')).toBe('⭐\u{FE0F}'); + }); + + it('only treats text-default symbols as emoji when VS16 is present', () => { + const { render } = getEmojiConfig(buildEmojiPackages(false)); + + expect(render('©')).toBe('©'); + expect(render('™')).toBe('™'); + expect(render('©\u{FE0F}')).toBe('©\u{FE0F}'); + }); + + it('renders shortcodes contested with emojibase using their emojione meaning', () => { + const { render } = getEmojiConfig(buildEmojiPackages(false)); + + expect(render(':up:')).toBe('🆙'); + expect(render(':arrow_up_small:')).toBe('🔼'); + expect(render(':cat:')).toBe('🐱'); + expect(render(':cat2:')).toBe('🐈️'); + }); + + it('renders picker entries for contested shortcodes', () => { + const { renderPicker } = getEmojiConfig(buildEmojiPackages(false)); + + expect(renderPicker(':up:')).toBe('🆙'); + expect(renderPicker(':cat2:')).toBe('🐈️'); + }); +}); diff --git a/apps/meteor/app/emoji-native/lib/getEmojiConfig.ts b/apps/meteor/app/emoji-native/lib/getEmojiConfig.ts new file mode 100644 index 0000000..f078924 --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/getEmojiConfig.ts @@ -0,0 +1,147 @@ +import { asciiList } from '@rocket.chat/message-parser'; +import { escapeHTML, escapeRegExp, unescapeHTML } from '@rocket.chat/tools'; + +import type { EmojiEntry } from './generateEmojiData'; +import { getEmojiData } from './generateEmojiData'; +import { legacyEmojioneMap } from './legacyEmojioneMap'; +import { shortnameToUnicode } from './shortnameToUnicode'; +import type { EmojiPackages } from '../../emoji/lib/rocketchat'; + +const emojiCategories = [ + { key: 'people', i18n: 'Smileys_and_People' }, + { key: 'nature', i18n: 'Animals_and_Nature' }, + { key: 'food', i18n: 'Food_and_Drink' }, + { key: 'activity', i18n: 'Activity' }, + { key: 'travel', i18n: 'Travel_and_Places' }, + { key: 'objects', i18n: 'Objects' }, + { key: 'symbols', i18n: 'Symbols' }, + { key: 'flags', i18n: 'Flags' }, +]; + +// Build a reverse mapping from unicode emoji to shortcode for detection +let unicodeToShortcodeMap: Map | null = null; +let emojiRegex: RegExp | null = null; + +function getUnicodeToShortcodeMap(): Map { + if (!unicodeToShortcodeMap) { + const { emojiList, bareAliases } = getEmojiData(); + unicodeToShortcodeMap = new Map(); + + for (const [shortcode, entry] of Object.entries(emojiList)) { + const emojiEntry = entry; + if (emojiEntry.unicode && !unicodeToShortcodeMap.has(emojiEntry.unicode)) { + unicodeToShortcodeMap.set(emojiEntry.unicode, shortcode); + } + } + + for (const [bare, shortcode] of bareAliases) { + if (!unicodeToShortcodeMap.has(bare)) { + unicodeToShortcodeMap.set(bare, shortcode); + } + } + } + return unicodeToShortcodeMap; +} + +function getEmojiRegex(): RegExp { + if (!emojiRegex) { + const unicodeMap = getUnicodeToShortcodeMap(); + // Sort emojis by length (longest first) to match multi-codepoint emojis correctly + const unicodeEmojis = [...unicodeMap.keys()].sort((a, b) => b.length - a.length); + const unicodePattern = unicodeEmojis.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + // Combine shortcode and unicode emoji patterns + emojiRegex = new RegExp(`(:([a-zA-Z0-9_+-]+):)|(${unicodePattern})`, 'g'); + } + return emojiRegex; +} + +// HTML-escaped variants are needed because the renderer receives escaped HTML (e.g. `>:(` arrives as `>:(`) +const asciiPattern = [...new Set(Object.keys(asciiList).flatMap((ascii) => [escapeHTML(ascii), ascii]))] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'); + +// `>` and `<` count as boundaries so emoticons touching tags still convert (e.g. `

hello :D

`) +const asciiRegex = new RegExp( + `]*>.*?
|]*>.*?|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|(?<=^|\\s|>)(${asciiPattern})(?=\\s|$|[!,.?<])`, + 'g', +); + +function renderAsciiEmoji(text: string): string { + return text.replace(asciiRegex, (entire, ascii) => { + if (ascii === undefined) { + return entire; + } + + const unescaped = unescapeHTML(ascii); + const unicode = asciiList[unescaped]; + if (!unicode) { + return entire; + } + + return `${unicode}`; + }); +} + +function renderEmoji(text: string, emojiPackages: EmojiPackages): string { + const { emojiList } = getEmojiData(); + const unicodeMap = getUnicodeToShortcodeMap(); + const pattern = getEmojiRegex(); + + const rendered = text.replace(pattern, (match, shortcodeGroup, shortcodeName, unicodeGroup) => { + // If it's a shortcode pattern (:emoji:) + if (shortcodeGroup) { + if (emojiPackages.list[`:${shortcodeName}:`]?.emojiPackage === 'emojiCustom') { + return match; // Don't render custom emojis as native + } + const key = `:${shortcodeName}:`; + const emoji = emojiList[key] as EmojiEntry | undefined; + if (emoji?.unicode) { + return `${emoji.unicode}`; + } + + // Fallback to legacy emojione shortcodes for backward compatibility + const legacy = legacyEmojioneMap[shortcodeName]; + if (legacy) { + return `${legacy}`; + } + + return match; + } + + // If it's a unicode emoji character (unicodeGroup will be defined) + if (unicodeGroup) { + const shortcode = unicodeMap.get(unicodeGroup); + if (shortcode) { + return `${unicodeGroup}`; + } + } + + return match; + }); + + return emojiPackages.packages.native?.ascii ? renderAsciiEmoji(rendered) : rendered; +} + +function renderPicker(emojiToRender: string): string | undefined { + const { emojiList } = getEmojiData(); + const emoji = emojiList[emojiToRender] as EmojiEntry | undefined; + if (!emoji?.unicode) return undefined; + + return `${emoji.unicode}`; +} + +export const getEmojiConfig = (emojiPackages: EmojiPackages) => { + const { emojiList, emojisByCategory, toneList } = getEmojiData(); + + return { + emojiList, + emojisByCategory, + emojiCategories, + toneList, + render: (text: string) => renderEmoji(text, emojiPackages), + renderPicker, + sprites: false, + shortnameToUnicode, + }; +}; diff --git a/apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts b/apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts new file mode 100644 index 0000000..9c0b687 --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts @@ -0,0 +1,34 @@ +// Auto-generated legacy emojione shortcode mapping +// These shortcodes existed in emojione but are absent from the joypixels preset that +// `generateEmojiData` reads, so they are backfilled here to keep old stored messages and +// reactions resolving. `digit_*`, `pound_symbol` and `asterisk_symbol` were themselves +// hand-patched into emojione by Rocket.Chat, and `tone1`-`tone5` name skin-tone components, +// which are not emoji in their own right. + +const legacyEmojioneMapData: Record = { + bride_with_veil_tone1: '👰🏻', + bride_with_veil_tone2: '👰🏼', + bride_with_veil_tone3: '👰🏽', + bride_with_veil_tone4: '👰🏾', + bride_with_veil_tone5: '👰🏿', + bride_with_veil: '👰', + digit_zero: '0️⃣', + digit_one: '1️⃣', + digit_two: '2️⃣', + digit_three: '3️⃣', + digit_four: '4️⃣', + digit_five: '5️⃣', + digit_six: '6️⃣', + digit_seven: '7️⃣', + digit_eight: '8️⃣', + digit_nine: '9️⃣', + pound_symbol: '#️⃣', + asterisk_symbol: '*️⃣', + tone1: '🏻', + tone2: '🏼', + tone3: '🏽', + tone4: '🏾', + tone5: '🏿', +}; + +export const legacyEmojioneMap: Record = Object.assign(Object.create(null), legacyEmojioneMapData); diff --git a/apps/meteor/app/emoji-native/lib/shortnameToUnicode.ts b/apps/meteor/app/emoji-native/lib/shortnameToUnicode.ts new file mode 100644 index 0000000..f836adb --- /dev/null +++ b/apps/meteor/app/emoji-native/lib/shortnameToUnicode.ts @@ -0,0 +1,19 @@ +import type { EmojiEntry } from './generateEmojiData'; +import { getEmojiData } from './generateEmojiData'; +import { legacyEmojioneMap } from './legacyEmojioneMap'; + +export function shortnameToUnicode(text: string): string { + const { emojiList } = getEmojiData(); + + return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match, shortcode) => { + const key = `:${shortcode}:`; + const emoji = emojiList[key] as EmojiEntry | undefined; + if (emoji?.unicode) return emoji.unicode; + + // Fallback to legacy emojione shortcodes for backward compatibility + const legacy = legacyEmojioneMap[shortcode]; + if (legacy) return legacy; + + return match; + }); +} diff --git a/apps/meteor/app/emoji/lib/rocketchat.ts b/apps/meteor/app/emoji/lib/rocketchat.ts new file mode 100644 index 0000000..07c5df1 --- /dev/null +++ b/apps/meteor/app/emoji/lib/rocketchat.ts @@ -0,0 +1,46 @@ +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +type EmojiPackage = { + emojiCategories: Array<{ key: string; i18n: TranslationKey }>; + categoryIndex?: number; + emojisByCategory: Record; + toneList: Record; + render: (message: string) => string; + renderPicker: (emojiToRender: string) => string | undefined; + sprites?: unknown; + ascii?: boolean; + list?: string[]; + _regexpSignature?: string | null; + _regexp?: RegExp | null; +}; + +export type EmojiPackages = { + packages: Record; + list: Record< + string, + | { + name?: string; + category: string; + emojiPackage: string; + shortnames: string[]; + uc_base: string; + uc_greedy: string; + uc_match: string; + uc_output: string; + aliases?: string[]; + aliasOf?: undefined; + extension?: string; + etag?: string; + unicode?: string; + } + | { + name?: undefined; + emojiPackage: string; + aliasOf: string; + extension?: undefined; + aliases?: undefined; + shortnames?: undefined; + etag?: string; + } + >; +}; diff --git a/apps/meteor/app/importer/lib/ImporterProgressStep.ts b/apps/meteor/app/importer/lib/ImporterProgressStep.ts new file mode 100644 index 0000000..5e7ea1b --- /dev/null +++ b/apps/meteor/app/importer/lib/ImporterProgressStep.ts @@ -0,0 +1,53 @@ +import type { IImportProgress } from '@rocket.chat/core-typings'; + +/** The progress step that an importer is at. */ +export const ProgressStep = Object.freeze({ + NEW: 'importer_new', + UPLOADING: 'importer_uploading', + DOWNLOADING_FILE: 'importer_downloading_file', + FILE_LOADED: 'importer_file_loaded', + + PREPARING_STARTED: 'importer_preparing_started', + PREPARING_USERS: 'importer_preparing_users', + PREPARING_CHANNELS: 'importer_preparing_channels', + PREPARING_MESSAGES: 'importer_preparing_messages', + PREPARING_CONTACTS: 'importer_preparing_contacts', + + USER_SELECTION: 'importer_user_selection', + + IMPORTING_STARTED: 'importer_importing_started', + IMPORTING_USERS: 'importer_importing_users', + IMPORTING_CHANNELS: 'importer_importing_channels', + IMPORTING_MESSAGES: 'importer_importing_messages', + IMPORTING_CONTACTS: 'importer_importing_contacts', + IMPORTING_FILES: 'importer_importing_files', + FINISHING: 'importer_finishing', + + DONE: 'importer_done', + ERROR: 'importer_import_failed', + CANCELLED: 'importer_import_cancelled', +} satisfies Record); + +export const ImportWaitingStates: IImportProgress['step'][] = [ProgressStep.NEW, ProgressStep.UPLOADING, ProgressStep.DOWNLOADING_FILE]; + +export const ImportFileReadyStates: IImportProgress['step'][] = [ProgressStep.FILE_LOADED]; + +export const ImportPreparingStartedStates: IImportProgress['step'][] = [ + ProgressStep.PREPARING_STARTED, + ProgressStep.PREPARING_USERS, + ProgressStep.PREPARING_CHANNELS, + ProgressStep.PREPARING_MESSAGES, + ProgressStep.PREPARING_CONTACTS, +]; + +export const ImportingStartedStates: IImportProgress['step'][] = [ + ProgressStep.IMPORTING_STARTED, + ProgressStep.IMPORTING_USERS, + ProgressStep.IMPORTING_CHANNELS, + ProgressStep.IMPORTING_MESSAGES, + ProgressStep.IMPORTING_CONTACTS, + ProgressStep.IMPORTING_FILES, + ProgressStep.FINISHING, +]; + +export const ImportingErrorStates: IImportProgress['step'][] = [ProgressStep.ERROR, ProgressStep.CANCELLED]; diff --git a/apps/meteor/app/integrations/lib/outgoingEvents.ts b/apps/meteor/app/integrations/lib/outgoingEvents.ts new file mode 100644 index 0000000..17da4f8 --- /dev/null +++ b/apps/meteor/app/integrations/lib/outgoingEvents.ts @@ -0,0 +1,71 @@ +import type { OutgoingIntegrationEvent } from '@rocket.chat/core-typings'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +export const outgoingEvents: Record< + OutgoingIntegrationEvent, + { label: TranslationKey; value: OutgoingIntegrationEvent; use: { channel: boolean; triggerWords: boolean; targetRoom: boolean } } +> = { + sendMessage: { + label: 'Integrations_Outgoing_Type_SendMessage', + value: 'sendMessage', + use: { + channel: true, + triggerWords: true, + targetRoom: false, + }, + }, + fileUploaded: { + label: 'Integrations_Outgoing_Type_FileUploaded', + value: 'fileUploaded', + use: { + channel: true, + triggerWords: false, + targetRoom: false, + }, + }, + roomArchived: { + label: 'Integrations_Outgoing_Type_RoomArchived', + value: 'roomArchived', + use: { + channel: false, + triggerWords: false, + targetRoom: false, + }, + }, + roomCreated: { + label: 'Integrations_Outgoing_Type_RoomCreated', + value: 'roomCreated', + use: { + channel: false, + triggerWords: false, + targetRoom: false, + }, + }, + roomJoined: { + label: 'Integrations_Outgoing_Type_RoomJoined', + value: 'roomJoined', + use: { + channel: true, + triggerWords: false, + targetRoom: false, + }, + }, + roomLeft: { + label: 'Integrations_Outgoing_Type_RoomLeft', + value: 'roomLeft', + use: { + channel: true, + triggerWords: false, + targetRoom: false, + }, + }, + userCreated: { + label: 'Integrations_Outgoing_Type_UserCreated', + value: 'userCreated', + use: { + channel: false, + triggerWords: false, + targetRoom: true, + }, + }, +} as const; diff --git a/apps/meteor/app/lib/lib/MessageTypes.ts b/apps/meteor/app/lib/lib/MessageTypes.ts new file mode 100644 index 0000000..1d82685 --- /dev/null +++ b/apps/meteor/app/lib/lib/MessageTypes.ts @@ -0,0 +1,152 @@ +import type { MessageTypesValues as MessageTypesValuesType } from '@rocket.chat/core-typings'; + +export const MessageTypesValues: Array<{ key: MessageTypesValuesType; i18nLabel: string }> = [ + { + key: 'uj', // user joined + i18nLabel: 'Message_HideType_uj', + }, + { + key: 'ujt', // user joined team + i18nLabel: 'Message_HideType_ujt', + }, + { + key: 'ul', // user left + i18nLabel: 'Message_HideType_ul', + }, + { + key: 'ult', // user left team + i18nLabel: 'Message_HideType_ult', + }, + { + key: 'ru', // user removed + i18nLabel: 'Message_HideType_ru', + }, + { + key: 'removed-user-from-team', + i18nLabel: 'Message_HideType_removed_user_from_team', + }, + { + key: 'au', // added user + i18nLabel: 'Message_HideType_au', + }, + { + key: 'ui', // user invited to room + i18nLabel: 'Message_HideType_ui', + }, + { + key: 'uir', // user rejected invitation to room + i18nLabel: 'Message_HideType_uir', + }, + { + key: 'added-user-to-team', + i18nLabel: 'Message_HideType_added_user_to_team', + }, + { + key: 'mute_unmute', + i18nLabel: 'Message_HideType_mute_unmute', + }, + { + key: 'user-banned', + i18nLabel: 'Message_HideType_user_banned', + }, + { + key: 'user-unbanned', + i18nLabel: 'Message_HideType_user_unbanned', + }, + { + key: 'r', // room name changed + i18nLabel: 'Message_HideType_r', + }, + { + key: 'ut', // user joined conversation + i18nLabel: 'Message_HideType_ut', + }, + { + key: 'wm', // welcome + i18nLabel: 'Message_HideType_wm', + }, + { + key: 'rm', // message removed + i18nLabel: 'Message_HideType_rm', + }, + { + key: 'subscription-role-added', + i18nLabel: 'Message_HideType_subscription_role_added', + }, + { + key: 'subscription-role-removed', + i18nLabel: 'Message_HideType_subscription_role_removed', + }, + { + key: 'room-archived', + i18nLabel: 'Message_HideType_room_archived', + }, + { + key: 'room-unarchived', + i18nLabel: 'Message_HideType_room_unarchived', + }, + { + key: 'room_changed_privacy', + i18nLabel: 'Message_HideType_room_changed_privacy', + }, + { + key: 'room_changed_avatar', + i18nLabel: 'Message_HideType_room_changed_avatar', + }, + { + key: 'room_changed_topic', + i18nLabel: 'Message_HideType_room_changed_topic', + }, + { + key: 'room_e2e_enabled', + i18nLabel: 'Message_HideType_room_enabled_encryption', + }, + { + key: 'room_e2e_disabled', + i18nLabel: 'Message_HideType_room_disabled_encryption', + }, + { + key: 'room-removed-read-only', + i18nLabel: 'Message_HideType_room_removed_read_only', + }, + { + key: 'room-set-read-only', + i18nLabel: 'Message_HideType_room_set_read_only', + }, + { + key: 'room-disallowed-reacting', + i18nLabel: 'Message_HideType_room_disallowed_reacting', + }, + { + key: 'room-allowed-reacting', + i18nLabel: 'Message_HideType_room_allowed_reacting', + }, + { + key: 'user-added-room-to-team', + i18nLabel: 'Message_HideType_user_added_room_to_team', + }, + { + key: 'user-converted-to-channel', + i18nLabel: 'Message_HideType_user_converted_to_channel', + }, + { + key: 'user-converted-to-team', + i18nLabel: 'Message_HideType_user_converted_to_team', + }, + { + key: 'user-deleted-room-from-team', + i18nLabel: 'Message_HideType_user_deleted_room_from_team', + }, + { + key: 'user-removed-room-from-team', + i18nLabel: 'Message_HideType_user_removed_room_from_team', + }, + { + key: 'room_changed_announcement', + i18nLabel: 'Message_HideType_changed_announcement', + }, + { + key: 'room_changed_description', + i18nLabel: 'Message_HideType_changed_description', + }, +]; diff --git a/apps/meteor/app/livechat/lib/inquiries.ts b/apps/meteor/app/livechat/lib/inquiries.ts new file mode 100644 index 0000000..488151a --- /dev/null +++ b/apps/meteor/app/livechat/lib/inquiries.ts @@ -0,0 +1,33 @@ +import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings'; + +type SortOrder = 1 | -1; + +type ReturnType = + | { + priorityWeight: SortOrder; + ts: SortOrder; + _updatedAt: SortOrder; + } + | { + estimatedWaitingTimeQueue: SortOrder; + ts: SortOrder; + _updatedAt: SortOrder; + } + | { + ts: SortOrder; + _updatedAt: SortOrder; + }; + +export const getOmniChatSortQuery = ( + sortByMechanism: OmnichannelSortingMechanismSettingType = OmnichannelSortingMechanismSettingType.Timestamp, +): ReturnType => { + switch (sortByMechanism) { + case OmnichannelSortingMechanismSettingType.Priority: + return { priorityWeight: 1, ts: 1, _updatedAt: -1 }; + case OmnichannelSortingMechanismSettingType.SLAs: + return { estimatedWaitingTimeQueue: 1, ts: 1, _updatedAt: -1 }; + case OmnichannelSortingMechanismSettingType.Timestamp: + default: + return { ts: 1, _updatedAt: -1 }; + } +}; diff --git a/apps/meteor/app/livechat/lib/isSameChannel.ts b/apps/meteor/app/livechat/lib/isSameChannel.ts new file mode 100644 index 0000000..bdc6516 --- /dev/null +++ b/apps/meteor/app/livechat/lib/isSameChannel.ts @@ -0,0 +1,20 @@ +import type { ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings'; + +export function isSameChannel(channel1: ILivechatContactVisitorAssociation, channel2: ILivechatContactVisitorAssociation): boolean { + if (!channel1 || !channel2) { + return false; + } + + if (channel1.visitorId !== channel2.visitorId) { + return false; + } + if (channel1.source.type !== channel2.source.type) { + return false; + } + + if ((channel1.source.id || channel2.source.id) && channel1.source.id !== channel2.source.id) { + return false; + } + + return true; +} diff --git a/apps/meteor/app/markdown/lib/hljs.js b/apps/meteor/app/markdown/lib/hljs.js new file mode 100644 index 0000000..1acac14 --- /dev/null +++ b/apps/meteor/app/markdown/lib/hljs.js @@ -0,0 +1,353 @@ +import hljs from 'highlight.js/lib/core'; +import clean from 'highlight.js/lib/languages/clean'; +import javascript from 'highlight.js/lib/languages/javascript'; +import markdown from 'highlight.js/lib/languages/markdown'; + +hljs.registerLanguage('markdown', markdown); +hljs.registerLanguage('clean', clean); +hljs.registerLanguage('javascript', javascript); + +export const register = async (lang) => { + switch (lang) { + case 'onec': + return hljs.registerLanguage('onec', (await import('highlight.js/lib/languages/1c')).default); + case 'abnf': + return hljs.registerLanguage('abnf', (await import('highlight.js/lib/languages/abnf')).default); + case 'accesslog': + return hljs.registerLanguage('accesslog', (await import('highlight.js/lib/languages/accesslog')).default); + case 'actionscript': + return hljs.registerLanguage('actionscript', (await import('highlight.js/lib/languages/actionscript')).default); + case 'ada': + return hljs.registerLanguage('ada', (await import('highlight.js/lib/languages/ada')).default); + case 'apache': + return hljs.registerLanguage('apache', (await import('highlight.js/lib/languages/apache')).default); + case 'applescript': + return hljs.registerLanguage('applescript', (await import('highlight.js/lib/languages/applescript')).default); + case 'arduino': + return hljs.registerLanguage('arduino', (await import('highlight.js/lib/languages/arduino')).default); + case 'armasm': + return hljs.registerLanguage('armasm', (await import('highlight.js/lib/languages/armasm')).default); + case 'asciidoc': + return hljs.registerLanguage('asciidoc', (await import('highlight.js/lib/languages/asciidoc')).default); + case 'aspectj': + return hljs.registerLanguage('aspectj', (await import('highlight.js/lib/languages/aspectj')).default); + case 'autohotkey': + return hljs.registerLanguage('autohotkey', (await import('highlight.js/lib/languages/autohotkey')).default); + case 'autoit': + return hljs.registerLanguage('autoit', (await import('highlight.js/lib/languages/autoit')).default); + case 'avrasm': + return hljs.registerLanguage('avrasm', (await import('highlight.js/lib/languages/avrasm')).default); + case 'awk': + return hljs.registerLanguage('awk', (await import('highlight.js/lib/languages/awk')).default); + case 'axapta': + return hljs.registerLanguage('axapta', (await import('highlight.js/lib/languages/axapta')).default); + case 'bash': + return hljs.registerLanguage('bash', (await import('highlight.js/lib/languages/bash')).default); + case 'basic': + return hljs.registerLanguage('basic', (await import('highlight.js/lib/languages/basic')).default); + case 'bnf': + return hljs.registerLanguage('bnf', (await import('highlight.js/lib/languages/bnf')).default); + case 'brainfuck': + return hljs.registerLanguage('brainfuck', (await import('highlight.js/lib/languages/brainfuck')).default); + case 'cal': + return hljs.registerLanguage('cal', (await import('highlight.js/lib/languages/cal')).default); + case 'capnproto': + return hljs.registerLanguage('capnproto', (await import('highlight.js/lib/languages/capnproto')).default); + case 'ceylon': + return hljs.registerLanguage('ceylon', (await import('highlight.js/lib/languages/ceylon')).default); + case 'clean': + return hljs.registerLanguage('clean', (await import('highlight.js/lib/languages/clean')).default); + case 'clojure': + return hljs.registerLanguage('clojure', (await import('highlight.js/lib/languages/clojure')).default); + case 'clojure-repl': + return hljs.registerLanguage('clojure-repl', (await import('highlight.js/lib/languages/clojure-repl')).default); + case 'cmake': + return hljs.registerLanguage('cmake', (await import('highlight.js/lib/languages/cmake')).default); + case 'coffeescript': + return hljs.registerLanguage('coffeescript', (await import('highlight.js/lib/languages/coffeescript')).default); + case 'coq': + return hljs.registerLanguage('coq', (await import('highlight.js/lib/languages/coq')).default); + case 'cos': + return hljs.registerLanguage('cos', (await import('highlight.js/lib/languages/cos')).default); + case 'cpp': + return hljs.registerLanguage('cpp', (await import('highlight.js/lib/languages/cpp')).default); + case 'crmsh': + return hljs.registerLanguage('crmsh', (await import('highlight.js/lib/languages/crmsh')).default); + case 'crystal': + return hljs.registerLanguage('crystal', (await import('highlight.js/lib/languages/crystal')).default); + case 'csp': + return hljs.registerLanguage('csp', (await import('highlight.js/lib/languages/csp')).default); + case 'css': + return hljs.registerLanguage('css', (await import('highlight.js/lib/languages/css')).default); + case 'd': + return hljs.registerLanguage('d', (await import('highlight.js/lib/languages/d')).default); + case 'dart': + return hljs.registerLanguage('dart', (await import('highlight.js/lib/languages/dart')).default); + case 'delphi': + return hljs.registerLanguage('delphi', (await import('highlight.js/lib/languages/delphi')).default); + case 'diff': + return hljs.registerLanguage('diff', (await import('highlight.js/lib/languages/diff')).default); + case 'django': + return hljs.registerLanguage('django', (await import('highlight.js/lib/languages/django')).default); + case 'dns': + return hljs.registerLanguage('dns', (await import('highlight.js/lib/languages/dns')).default); + case 'dockerfile': + return hljs.registerLanguage('dockerfile', (await import('highlight.js/lib/languages/dockerfile')).default); + case 'dos': + return hljs.registerLanguage('dos', (await import('highlight.js/lib/languages/dos')).default); + case 'dsconfig': + return hljs.registerLanguage('dsconfig', (await import('highlight.js/lib/languages/dsconfig')).default); + case 'dts': + return hljs.registerLanguage('dts', (await import('highlight.js/lib/languages/dts')).default); + case 'dust': + return hljs.registerLanguage('dust', (await import('highlight.js/lib/languages/dust')).default); + case 'ebnf': + return hljs.registerLanguage('ebnf', (await import('highlight.js/lib/languages/ebnf')).default); + case 'elixir': + return hljs.registerLanguage('elixir', (await import('highlight.js/lib/languages/elixir')).default); + case 'elm': + return hljs.registerLanguage('elm', (await import('highlight.js/lib/languages/elm')).default); + case 'erb': + return hljs.registerLanguage('erb', (await import('highlight.js/lib/languages/erb')).default); + case 'erlang': + return hljs.registerLanguage('erlang', (await import('highlight.js/lib/languages/erlang')).default); + case 'excel': + return hljs.registerLanguage('excel', (await import('highlight.js/lib/languages/excel')).default); + case 'fix': + return hljs.registerLanguage('fix', (await import('highlight.js/lib/languages/fix')).default); + case 'flix': + return hljs.registerLanguage('flix', (await import('highlight.js/lib/languages/flix')).default); + case 'fortran': + return hljs.registerLanguage('fortran', (await import('highlight.js/lib/languages/fortran')).default); + case 'fsharp': + return hljs.registerLanguage('fsharp', (await import('highlight.js/lib/languages/fsharp')).default); + case 'gams': + return hljs.registerLanguage('gams', (await import('highlight.js/lib/languages/gams')).default); + case 'gauss': + return hljs.registerLanguage('gauss', (await import('highlight.js/lib/languages/gauss')).default); + case 'gcode': + return hljs.registerLanguage('gcode', (await import('highlight.js/lib/languages/gcode')).default); + case 'gherkin': + return hljs.registerLanguage('gherkin', (await import('highlight.js/lib/languages/gherkin')).default); + case 'glsl': + return hljs.registerLanguage('glsl', (await import('highlight.js/lib/languages/glsl')).default); + case 'go': + return hljs.registerLanguage('go', (await import('highlight.js/lib/languages/go')).default); + case 'golo': + return hljs.registerLanguage('golo', (await import('highlight.js/lib/languages/golo')).default); + case 'gradle': + return hljs.registerLanguage('gradle', (await import('highlight.js/lib/languages/gradle')).default); + case 'groovy': + return hljs.registerLanguage('groovy', (await import('highlight.js/lib/languages/groovy')).default); + case 'haml': + return hljs.registerLanguage('haml', (await import('highlight.js/lib/languages/haml')).default); + case 'handlebars': + return hljs.registerLanguage('handlebars', (await import('highlight.js/lib/languages/handlebars')).default); + case 'haskell': + return hljs.registerLanguage('haskell', (await import('highlight.js/lib/languages/haskell')).default); + case 'haxe': + return hljs.registerLanguage('haxe', (await import('highlight.js/lib/languages/haxe')).default); + case 'hsp': + return hljs.registerLanguage('hsp', (await import('highlight.js/lib/languages/hsp')).default); + case 'http': + return hljs.registerLanguage('http', (await import('highlight.js/lib/languages/http')).default); + case 'hy': + return hljs.registerLanguage('hy', (await import('highlight.js/lib/languages/hy')).default); + case 'inform7': + return hljs.registerLanguage('inform7', (await import('highlight.js/lib/languages/inform7')).default); + case 'ini': + return hljs.registerLanguage('ini', (await import('highlight.js/lib/languages/ini')).default); + case 'irpf90': + return hljs.registerLanguage('irpf90', (await import('highlight.js/lib/languages/irpf90')).default); + case 'java': + return hljs.registerLanguage('java', (await import('highlight.js/lib/languages/java')).default); + case 'javascript': + return hljs.registerLanguage('javascript', (await import('highlight.js/lib/languages/javascript')).default); + case 'jboss-cli': + return hljs.registerLanguage('jboss-cli', (await import('highlight.js/lib/languages/jboss-cli')).default); + case 'json': + return hljs.registerLanguage('json', (await import('highlight.js/lib/languages/json')).default); + case 'julia': + return hljs.registerLanguage('julia', (await import('highlight.js/lib/languages/julia')).default); + case 'julia-repl': + return hljs.registerLanguage('julia-repl', (await import('highlight.js/lib/languages/julia-repl')).default); + case 'kotlin': + return hljs.registerLanguage('kotlin', (await import('highlight.js/lib/languages/kotlin')).default); + case 'lasso': + return hljs.registerLanguage('lasso', (await import('highlight.js/lib/languages/lasso')).default); + case 'ldif': + return hljs.registerLanguage('ldif', (await import('highlight.js/lib/languages/ldif')).default); + case 'leaf': + return hljs.registerLanguage('leaf', (await import('highlight.js/lib/languages/leaf')).default); + case 'less': + return hljs.registerLanguage('less', (await import('highlight.js/lib/languages/less')).default); + case 'lisp': + return hljs.registerLanguage('lisp', (await import('highlight.js/lib/languages/lisp')).default); + case 'livecodeserver': + return hljs.registerLanguage('livecodeserver', (await import('highlight.js/lib/languages/livecodeserver')).default); + case 'livescript': + return hljs.registerLanguage('livescript', (await import('highlight.js/lib/languages/livescript')).default); + case 'llvm': + return hljs.registerLanguage('llvm', (await import('highlight.js/lib/languages/llvm')).default); + case 'lsl': + return hljs.registerLanguage('lsl', (await import('highlight.js/lib/languages/lsl')).default); + case 'lua': + return hljs.registerLanguage('lua', (await import('highlight.js/lib/languages/lua')).default); + case 'makefile': + return hljs.registerLanguage('makefile', (await import('highlight.js/lib/languages/makefile')).default); + case 'markdown': + return hljs.registerLanguage('markdown', (await import('highlight.js/lib/languages/markdown')).default); + case 'mathematica': + return hljs.registerLanguage('mathematica', (await import('highlight.js/lib/languages/mathematica')).default); + case 'matlab': + return hljs.registerLanguage('matlab', (await import('highlight.js/lib/languages/matlab')).default); + case 'maxima': + return hljs.registerLanguage('maxima', (await import('highlight.js/lib/languages/maxima')).default); + case 'mel': + return hljs.registerLanguage('mel', (await import('highlight.js/lib/languages/mel')).default); + case 'mercury': + return hljs.registerLanguage('mercury', (await import('highlight.js/lib/languages/mercury')).default); + case 'mipsasm': + return hljs.registerLanguage('mipsasm', (await import('highlight.js/lib/languages/mipsasm')).default); + case 'mizar': + return hljs.registerLanguage('mizar', (await import('highlight.js/lib/languages/mizar')).default); + case 'perl': + return hljs.registerLanguage('perl', (await import('highlight.js/lib/languages/perl')).default); + case 'mojolicious': + return hljs.registerLanguage('mojolicious', (await import('highlight.js/lib/languages/mojolicious')).default); + case 'monkey': + return hljs.registerLanguage('monkey', (await import('highlight.js/lib/languages/monkey')).default); + case 'moonscript': + return hljs.registerLanguage('moonscript', (await import('highlight.js/lib/languages/moonscript')).default); + case 'n1ql': + return hljs.registerLanguage('n1ql', (await import('highlight.js/lib/languages/n1ql')).default); + case 'nginx': + return hljs.registerLanguage('nginx', (await import('highlight.js/lib/languages/nginx')).default); + case 'nix': + return hljs.registerLanguage('nix', (await import('highlight.js/lib/languages/nix')).default); + case 'nsis': + return hljs.registerLanguage('nsis', (await import('highlight.js/lib/languages/nsis')).default); + case 'objectivec': + return hljs.registerLanguage('objectivec', (await import('highlight.js/lib/languages/objectivec')).default); + case 'ocaml': + return hljs.registerLanguage('ocaml', (await import('highlight.js/lib/languages/ocaml')).default); + case 'openscad': + return hljs.registerLanguage('openscad', (await import('highlight.js/lib/languages/openscad')).default); + case 'oxygene': + return hljs.registerLanguage('oxygene', (await import('highlight.js/lib/languages/oxygene')).default); + case 'parser3': + return hljs.registerLanguage('parser3', (await import('highlight.js/lib/languages/parser3')).default); + case 'pf': + return hljs.registerLanguage('pf', (await import('highlight.js/lib/languages/pf')).default); + case 'php': + return hljs.registerLanguage('php', (await import('highlight.js/lib/languages/php')).default); + case 'pony': + return hljs.registerLanguage('pony', (await import('highlight.js/lib/languages/pony')).default); + case 'powershell': + return hljs.registerLanguage('powershell', (await import('highlight.js/lib/languages/powershell')).default); + case 'processing': + return hljs.registerLanguage('processing', (await import('highlight.js/lib/languages/processing')).default); + case 'profile': + return hljs.registerLanguage('profile', (await import('highlight.js/lib/languages/profile')).default); + case 'prolog': + return hljs.registerLanguage('prolog', (await import('highlight.js/lib/languages/prolog')).default); + case 'protobuf': + return hljs.registerLanguage('protobuf', (await import('highlight.js/lib/languages/protobuf')).default); + case 'puppet': + return hljs.registerLanguage('puppet', (await import('highlight.js/lib/languages/puppet')).default); + case 'purebasic': + return hljs.registerLanguage('purebasic', (await import('highlight.js/lib/languages/purebasic')).default); + case 'python': + return hljs.registerLanguage('python', (await import('highlight.js/lib/languages/python')).default); + case 'q': + return hljs.registerLanguage('q', (await import('highlight.js/lib/languages/q')).default); + case 'qml': + return hljs.registerLanguage('qml', (await import('highlight.js/lib/languages/qml')).default); + case 'r': + return hljs.registerLanguage('r', (await import('highlight.js/lib/languages/r')).default); + case 'rib': + return hljs.registerLanguage('rib', (await import('highlight.js/lib/languages/rib')).default); + case 'roboconf': + return hljs.registerLanguage('roboconf', (await import('highlight.js/lib/languages/roboconf')).default); + case 'rsl': + return hljs.registerLanguage('rsl', (await import('highlight.js/lib/languages/rsl')).default); + case 'ruleslanguage': + return hljs.registerLanguage('ruleslanguage', (await import('highlight.js/lib/languages/ruleslanguage')).default); + case 'rust': + return hljs.registerLanguage('rust', (await import('highlight.js/lib/languages/rust')).default); + case 'scala': + return hljs.registerLanguage('scala', (await import('highlight.js/lib/languages/scala')).default); + case 'scheme': + return hljs.registerLanguage('scheme', (await import('highlight.js/lib/languages/scheme')).default); + case 'scilab': + return hljs.registerLanguage('scilab', (await import('highlight.js/lib/languages/scilab')).default); + case 'scss': + return hljs.registerLanguage('scss', (await import('highlight.js/lib/languages/scss')).default); + case 'shell': + return hljs.registerLanguage('shell', (await import('highlight.js/lib/languages/shell')).default); + case 'smali': + return hljs.registerLanguage('smali', (await import('highlight.js/lib/languages/smali')).default); + case 'smalltalk': + return hljs.registerLanguage('smalltalk', (await import('highlight.js/lib/languages/smalltalk')).default); + case 'sml': + return hljs.registerLanguage('sml', (await import('highlight.js/lib/languages/sml')).default); + case 'sqf': + return hljs.registerLanguage('sqf', (await import('highlight.js/lib/languages/sqf')).default); + case 'sql': + return hljs.registerLanguage('sql', (await import('highlight.js/lib/languages/sql')).default); + case 'stan': + return hljs.registerLanguage('stan', (await import('highlight.js/lib/languages/stan')).default); + case 'stata': + return hljs.registerLanguage('stata', (await import('highlight.js/lib/languages/stata')).default); + case 'step21': + return hljs.registerLanguage('step21', (await import('highlight.js/lib/languages/step21')).default); + case 'stylus': + return hljs.registerLanguage('stylus', (await import('highlight.js/lib/languages/stylus')).default); + case 'subunit': + return hljs.registerLanguage('subunit', (await import('highlight.js/lib/languages/subunit')).default); + case 'swift': + return hljs.registerLanguage('swift', (await import('highlight.js/lib/languages/swift')).default); + case 'taggerscript': + return hljs.registerLanguage('taggerscript', (await import('highlight.js/lib/languages/taggerscript')).default); + case 'yaml': + return hljs.registerLanguage('yaml', (await import('highlight.js/lib/languages/yaml')).default); + case 'tap': + return hljs.registerLanguage('tap', (await import('highlight.js/lib/languages/tap')).default); + case 'tcl': + return hljs.registerLanguage('tcl', (await import('highlight.js/lib/languages/tcl')).default); + case 'thrift': + return hljs.registerLanguage('thrift', (await import('highlight.js/lib/languages/thrift')).default); + case 'tp': + return hljs.registerLanguage('tp', (await import('highlight.js/lib/languages/tp')).default); + case 'twig': + return hljs.registerLanguage('twig', (await import('highlight.js/lib/languages/twig')).default); + case 'typescript': + return hljs.registerLanguage('typescript', (await import('highlight.js/lib/languages/typescript')).default); + case 'vala': + return hljs.registerLanguage('vala', (await import('highlight.js/lib/languages/vala')).default); + case 'vbnet': + return hljs.registerLanguage('vbnet', (await import('highlight.js/lib/languages/vbnet')).default); + case 'vbscript': + return hljs.registerLanguage('vbscript', (await import('highlight.js/lib/languages/vbscript')).default); + case 'vbscript-html': + return hljs.registerLanguage('vbscript-html(', (await import('highlight.js/lib/languages/vbscript-html')).default); + case 'verilog': + return hljs.registerLanguage('verilog', (await import('highlight.js/lib/languages/verilog')).default); + case 'vhdl': + return hljs.registerLanguage('vhdl', (await import('highlight.js/lib/languages/vhdl')).default); + case 'vim': + return hljs.registerLanguage('vim', (await import('highlight.js/lib/languages/vim')).default); + case 'x86asm': + return hljs.registerLanguage('x86asm', (await import('highlight.js/lib/languages/x86asm')).default); + case 'xl': + return hljs.registerLanguage('xl', (await import('highlight.js/lib/languages/xl')).default); + case 'xquery': + return hljs.registerLanguage('xquery', (await import('highlight.js/lib/languages/xquery')).default); + case 'zephir': + return hljs.registerLanguage('zephir', (await import('highlight.js/lib/languages/zephir')).default); + default: + return hljs.registerLanguage('plaintext', (await import('highlight.js/lib/languages/plaintext')).default); + } +}; + +export default hljs; diff --git a/apps/meteor/app/markdown/lib/markdown.js b/apps/meteor/app/markdown/lib/markdown.js new file mode 100644 index 0000000..4f2f850 --- /dev/null +++ b/apps/meteor/app/markdown/lib/markdown.js @@ -0,0 +1,94 @@ +/* + * Markdown is a named function that will parse markdown syntax + * @param {Object} message - The message object + */ +import { escapeHTML } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { filtered } from './parser/filtered/filtered'; +import { code } from './parser/original/code'; +import { original } from './parser/original/original'; + +const parsers = { + original, + filtered, +}; + +class MarkdownClass { + parse(text) { + const message = { + html: escapeHTML(text), + }; + return this.mountTokensBack(this.parseMessageNotEscaped(message)).html; + } + + parseNotEscaped(text) { + const message = { + html: text, + }; + return this.mountTokensBack(this.parseMessageNotEscaped(message)).html; + } + + parseMessageNotEscaped(message) { + const options = { + rootUrl: Meteor.absoluteUrl(), + }; + + return parsers.original(message, options); + } + + mountTokensBackRecursively(message, tokenList, useHtml = true) { + const missingTokens = []; + + if (tokenList.length > 0) { + for (const { token, text, noHtml } of tokenList) { + if (message.html.indexOf(token) >= 0) { + message.html = message.html.replace(token, () => (useHtml ? text : noHtml)); // Uses lambda so doesn't need to escape $ + } else { + missingTokens.push({ token, text, noHtml }); + } + } + } + + // If there are tokens that were missing from the string, but the last iteration replaced at least one token, then go again + // this is done because one of the tokens may have been hidden by another one + if (missingTokens.length > 0 && missingTokens.length < tokenList.length) { + this.mountTokensBackRecursively(message, missingTokens, useHtml); + } + } + + mountTokensBack(message, useHtml = true) { + if (message.tokens) { + this.mountTokensBackRecursively(message, message.tokens, useHtml); + } + + return message; + } + + code(...args) { + return code(...args); + } + + /** @param {string} message */ + filterMarkdownFromMessage(message) { + return parsers.filtered(message); + } +} + +export const Markdown = new MarkdownClass(); + +/** @param {string} message */ +export const filterMarkdown = (message) => Markdown.filterMarkdownFromMessage(message); + +export const createMarkdownMessageRenderer = ({ ...options }) => { + const markedParser = parsers.marked; + return (message, useMarkedParser = false) => { + if (!message?.html?.trim()) { + return message; + } + + return useMarkedParser ? markedParser(message, options) : parsers.original(message, options); + }; +}; + +export const createMarkdownNotificationRenderer = () => (message) => parsers.filtered(message); diff --git a/apps/meteor/app/markdown/lib/parser/filtered/filtered.js b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js new file mode 100644 index 0000000..a295447 --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/filtered/filtered.js @@ -0,0 +1,53 @@ +import { getFilteredLinkRegexes } from '../linkRegexes'; + +const inlineCodeRegex = /`([^`\r\n]+)\`/gm; + +/** + * Filter markdown tags in message + * Use case: notifications + * @param {string} message + */ +export const filtered = ( + message, + options = { + supportSchemesForLink: 'http,https', + }, +) => { + const schemes = (options.supportSchemesForLink || 'http,https').split(',').join('|'); + const linkRegexes = getFilteredLinkRegexes(schemes); + + // Remove block code backticks + message = message.replace(/```/g, ''); + + // Remove inline code backticks + message = message.replace(inlineCodeRegex, (match) => match.substr(1, match.length - 2)); + + // Filter [text](url), ![alt_text](image_url) + message = message.replace(linkRegexes.link, (match, title) => title); + + // Filter + message = message.replace(linkRegexes.pipedLink, (match, title) => title); + + // Filter headings + message = message.replace( + /(^#{1,4}) (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, + '$2', + ); + + // Filter bold + message = message.replace(/(^|>|[ >_~`])\*{1,2}([^\*\r\n]+)\*{1,2}([<_~`]|\B|\b|$)/gm, '$1$2$3'); + + // Filter italics + message = message.replace(/(^|>|[ >*~`])\_{1,2}([^\_\r\n]+)\_{1,2}([<*~`]|\B|\b|$)/gm, '$1$2$3'); + + // Filter strike-through text + message = message.replace(/(^|>|[ >_*`])\~{1,2}([^~\r\n]+)\~{1,2}([<_*`]|\B|\b|$)/gm, '$1$2$3'); + + // Filter block quotes + message = message.replace(/(?:>){3}\n+([\s\S]*?)\n+(?:<){3}/g, '$1'); + + // Filter > quote + message = message.replace(/^>(.*)$/gm, '$1'); + + return message; +}; diff --git a/apps/meteor/app/markdown/lib/parser/linkRegexes.js b/apps/meteor/app/markdown/lib/parser/linkRegexes.js new file mode 100644 index 0000000..3333f0c --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/linkRegexes.js @@ -0,0 +1,20 @@ +import mem from 'mem'; + +// The link regexes only vary with the configured URL schemes, which come from +// a setting that rarely changes; cache the compiled regexes per schemes value +// instead of recompiling them for every message. +// +// The two parsers intentionally use different patterns: `filtered` only strips +// markup (no URL capture, URLs may contain `)` escapes) while `original` +// renders it (captures the URL, splits image from link). + +export const getMarkdownLinkRegexes = mem((schemes) => ({ + image: new RegExp(`!\\[([^\\]]+)\\]\\(((?:${schemes}):\\/\\/[^\\s]+)\\)`, 'gm'), + link: new RegExp(`\\[([^\\]]+)\\]\\(((?:${schemes}):\\/\\/[^\\s]+)\\)`, 'gm'), + pipedLink: new RegExp(`(?:<|<)((?:${schemes}):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)`, 'gm'), +})); + +export const getFilteredLinkRegexes = mem((schemes) => ({ + link: new RegExp(`!?\\[([^\\]]+)\\]\\((?:${schemes}):\\/\\/[^\\)]+\\)`, 'gm'), + pipedLink: new RegExp(`(?:<|<)(?:${schemes}):\\/\\/[^\\|]+\\|(.+?)(?=>|>)(?:>|>)`, 'gm'), +})); diff --git a/apps/meteor/app/markdown/lib/parser/original/code.js b/apps/meteor/app/markdown/lib/parser/original/code.js new file mode 100644 index 0000000..959324a --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/original/code.js @@ -0,0 +1,87 @@ +/* + * code() is a named function that will parse `inline code` and ```codeblock``` syntaxes + * @param {Object} message - The message object + */ +import { unescapeHTML } from '@rocket.chat/tools'; + +import { addAsToken } from './token'; +import hljs, { register } from '../../hljs'; + +const inlinecode = (message) => { + // Support `text` + message.html = message.html.replace(/\`([^`\r\n]+)\`([<_*~]|\B|\b|$)/gm, (match, p1, p2) => + addAsToken( + message, + `\`${p1}\`${p2}`, + 'inlinecode', + { noHtml: match }, + ), + ); +}; + +const codeblocks = (message) => { + // Count occurencies of ``` + const count = (message.html.match(/```/gm) || []).length; + + if (count) { + // Check if we need to add a final ``` + if (count % 2 > 0) { + message.html = `${message.html}\n\`\`\``; + message.msg = `${message.msg}\n\`\`\``; + } + + // Separate text in code blocks and non code blocks + const msgParts = message.html.split(/(^.*)(```(?:[a-zA-Z]+)?(?:(?:.|\r|\n)*?)```)(.*\n?)$/gm); + + for (let index = 0; index < msgParts.length; index++) { + // Verify if this part is code + const part = msgParts[index]; + const codeMatch = part.match(/^```[\r\n]*(.*[\r\n\ ]?)[\r\n]*([\s\S]*?)```+?$/); + + if (codeMatch) { + // Process highlight if this part is code + const singleLine = codeMatch[0].indexOf('\n') === -1; + const lang = !singleLine && Array.from(hljs.listLanguages()).includes(codeMatch[1].trim()) ? codeMatch[1].trim() : ''; + const emptyLanguage = lang === '' ? unescapeHTML(codeMatch[1] + codeMatch[2]) : unescapeHTML(codeMatch[2]); + const code = singleLine ? unescapeHTML(codeMatch[1]) : emptyLanguage; + + const result = (() => { + if (lang) { + try { + register(lang); + return hljs.highlight(lang, code); + } catch (error) { + console.error(error); + } + } + return hljs.highlightAuto(lang + code); + })(); + const token = addAsToken( + message, + `
\`\`\`
${result.value}
\`\`\`
`, + 'code', + { + noHtml: codeMatch[0], + highlight: true, + }, + ); + + msgParts[index] = token; + } else { + msgParts[index] = part; + } + } + + // Re-mount message + message.html = msgParts.join(''); + } +}; + +export const code = (message) => { + if (message.html?.trim()) { + codeblocks(message); + inlinecode(message); + } + + return message; +}; diff --git a/apps/meteor/app/markdown/lib/parser/original/markdown.js b/apps/meteor/app/markdown/lib/parser/original/markdown.js new file mode 100644 index 0000000..d17e12b --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/original/markdown.js @@ -0,0 +1,194 @@ +import { addAsToken, isToken, validateAllowedTokens } from './token'; +import { getMarkdownLinkRegexes } from '../linkRegexes'; + +const validateUrl = (url, message) => { + // Don't render markdown inside links + if (message?.tokens?.some((token) => url.includes(token.token))) { + return false; + } + + // Valid urls don't contain whitespaces + if (/\s/.test(url.trim())) { + return false; + } + + try { + new URL(url); + return true; + } catch (e) { + return false; + } +}; + +const endsWithWhitespace = (text) => text.substring(text.length - 1).match(/\s/); + +const getParseableMarkersCount = (start, end) => { + const usableMarkers = start.length > 1 ? 2 : 1; + return end.length - usableMarkers >= 0 ? usableMarkers : 1; +}; + +const getTextWrapper = (marker, tagName) => (textPrepend, wrappedText, textAppend) => + `${textPrepend}${marker}<${tagName}>${wrappedText}${marker}${textAppend}`; + +const getRegexReplacer = (replaceFunction, getRegex) => (marker, tagName) => { + const wrapper = getTextWrapper(marker, tagName); + // The regex only depends on the marker, so build it once when the parser is + // created instead of on every message. Safe with /g because + // String.prototype.replace resets lastIndex on each call. + const regex = getRegex(marker); + return (msg) => msg.replace(regex, (...args) => replaceFunction(wrapper, ...args)); +}; + +const getParserWithCustomMarker = getRegexReplacer( + (wrapper, match, p1, p2, p3) => { + if (endsWithWhitespace(p2)) { + return match; + } + const finalMarkerCount = getParseableMarkersCount(p1, p3); + return wrapper(p1.substring(finalMarkerCount), p2, p3.substring(finalMarkerCount)); + }, + (marker) => new RegExp(`(\\${marker}+(?!\\s))([^\\${marker}\\r\\n]+)(\\${marker}+)`, 'gm'), +); + +const parseBold = getParserWithCustomMarker('*', 'strong'); + +const parseStrike = getParserWithCustomMarker('~', 'strike'); + +const parseItalic = getRegexReplacer( + (wrapper, match, p1, p2, p3, p4, p5) => { + if (p1 || p5 || endsWithWhitespace(p3)) { + return match; + } + + const finalMarkerCount = getParseableMarkersCount(p2, p4); + return wrapper(p2.substring(finalMarkerCount), p3, p4.substring(finalMarkerCount)); + }, + () => new RegExp('([^\\r\\n\\s~*_]){0,1}(\\_+(?!\\s))([^\\_\\r\\n]+)(\\_+)([^\\r\\n\\s]){0,1}', 'gm'), +)('_', 'em'); + +const parseNotEscaped = (message, { supportSchemesForLink, headers, rootUrl }) => { + let msg = message.html; + if (!message.tokens) { + message.tokens = []; + } + + const schemes = (supportSchemesForLink || '').split(',').join('|'); + const linkRegexes = getMarkdownLinkRegexes(schemes); + + if (headers) { + // Support # Text for h1 + msg = msg.replace( + /^# (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, + '

$1

', + ); + + // Support # Text for h2 + msg = msg.replace( + /^## (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, + '

$1

', + ); + + // Support # Text for h3 + msg = msg.replace( + /^### (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, + '

$1

', + ); + + // Support # Text for h4 + msg = msg.replace( + /^#### (([\S\w\d-_\/\*\.,\\][ \u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?)+)/gm, + '

$1

', + ); + } + + // Support *text* to make bold + msg = parseBold(msg); + + // Support _text_ to make italics + msg = parseItalic(msg); + + // // Support ~text~ to strike through text + msg = parseStrike(msg); + + // Support for block quote + // >>> + // Text + // <<< + msg = msg.replace( + /(?:>){3}\n+([\s\S]*?)\n+(?:<){3}/g, + '
>>>$1<<<
', + ); + + // Support >Text for quote + msg = msg.replace( + /^>(.*)$/gm, + '
>$1
', + ); + + // Remove white-space around blockquote (prevent
). Because blockquote is block element. + msg = msg.replace( + /\s*
/gm, + '
', + ); + msg = msg.replace(/<\/blockquote>\s*/gm, '
'); + + // Remove new-line between blockquotes. + msg = msg.replace(/<\/blockquote>\n
{ + if (!validateUrl(url, message)) { + return match; + } + if (isToken(title) && !validateAllowedTokens(message, title, ['bold', 'italic', 'strike'])) { + return match; + } + url = encodeURI(url); + + const target = url.indexOf(rootUrl) === 0 ? '' : '_blank'; + return addAsToken( + message, + `
`, + 'link', + ); + }); + + // Support [Text](http://link) + msg = msg.replace(linkRegexes.link, (match, title, url) => { + if (!validateUrl(url, message)) { + return match; + } + if (isToken(title) && !validateAllowedTokens(message, title, ['bold', 'italic', 'strike'])) { + return match; + } + const target = url.indexOf(rootUrl) === 0 ? '' : '_blank'; + title = title.replace(/&/g, '&'); + + const escapedUrl = encodeURI(url); + + return addAsToken( + message, + `${title}`, + 'link', + ); + }); + + // Support + msg = msg.replace(linkRegexes.pipedLink, (match, url, title) => { + if (!validateUrl(url, message)) { + return match; + } + if (isToken(title) && !validateAllowedTokens(message, title, ['bold', 'italic', 'strike'])) { + return match; + } + url = encodeURI(url); + const target = url.indexOf(rootUrl) === 0 ? '' : '_blank'; + return addAsToken(message, `${title}`, 'link'); + }); + return msg; +}; + +export const markdown = (message, options) => { + message.html = parseNotEscaped(message, options); + return message; +}; diff --git a/apps/meteor/app/markdown/lib/parser/original/original.js b/apps/meteor/app/markdown/lib/parser/original/original.js new file mode 100644 index 0000000..c3a7ba1 --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/original/original.js @@ -0,0 +1,25 @@ +/* + * Markdown is a named function that will parse markdown syntax + * @param {Object} message - The message object + */ +import { code } from './code.js'; +import { markdown } from './markdown.js'; + +export const original = ( + message, + options = { + supportSchemesForLink: 'http,https', + headers: true, + }, +) => { + // Parse markdown code + message = code(message); + + // Parse markdown + message = markdown(message, options); + + // Replace linebreak to br + message.html = message.html.replace(/\n/gm, '
'); + + return message; +}; diff --git a/apps/meteor/app/markdown/lib/parser/original/token.ts b/apps/meteor/app/markdown/lib/parser/original/token.ts new file mode 100644 index 0000000..f7170b4 --- /dev/null +++ b/apps/meteor/app/markdown/lib/parser/original/token.ts @@ -0,0 +1,38 @@ +/* + * Markdown is a named function that will parse markdown syntax + * @param {String} msg - The message html + */ +import type { TokenType, TokenExtra } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; + +type MessageTokens = { + tokens?: { + token: string; + type: TokenType; + text: string; + extra?: TokenExtra; + }[]; +}; + +export const addAsToken = (message: MessageTokens, html: string, type: TokenType, extra?: TokenExtra): string => { + if (!message.tokens) { + message.tokens = []; + } + const token = `=!=${Random.id()}=!=`; + message.tokens.push({ + token, + type, + text: html, + ...(extra && { ...extra }), + }); + + return token; +}; + +export const isToken = (msg: string): boolean => /=!=[.a-z0-9]{17}=!=/gim.test(msg.trim()); + +export const validateAllowedTokens = (message: MessageTokens, id: string, desiredTokens: TokenType[]): boolean => { + const tokens: string[] = id.match(/=!=[.a-z0-9]{17}=!=/gim) || []; + const tokensFound = message.tokens?.filter(({ token }) => tokens.includes(token)) || []; + return tokensFound.length === 0 || tokensFound.every((token) => token.type && desiredTokens.includes(token.type)); +}; diff --git a/apps/meteor/app/mentions/lib/MentionsParser.ts b/apps/meteor/app/mentions/lib/MentionsParser.ts new file mode 100644 index 0000000..3703c63 --- /dev/null +++ b/apps/meteor/app/mentions/lib/MentionsParser.ts @@ -0,0 +1,164 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeHTML } from '@rocket.chat/tools'; + +export type MentionsParserArgs = { + pattern: () => string; + useRealName?: () => boolean; + me?: () => string; + roomTemplate?: (args: { prefix: string; reference: string; mention: string; channel?: any }) => string; + userTemplate?: (args: { prefix: string; className: string; mention: string; title?: string; label: string; type?: string }) => string; +}; + +const userTemplateDefault = ({ + prefix, + className, + mention, + title = '', + label, + type = 'username', +}: { + prefix: string; + className: string; + mention: string; + title?: string; + label?: string; + type?: string; +}) => `${prefix}${label}`; + +const roomTemplateDefault = ({ prefix, reference, mention }: { prefix: string; reference: string; mention: string }) => + `${prefix}${`#${mention}`}`; + +export class MentionsParser { + me: () => string; + + pattern: MentionsParserArgs['pattern']; + + userTemplate: (args: { prefix: string; className: string; mention: string; title?: string; label: string; type?: string }) => string; + + roomTemplate: (args: { prefix: string; reference: string; mention: string; channel?: any }) => string; + + useRealName: () => boolean; + + constructor({ pattern, useRealName, me, roomTemplate = roomTemplateDefault, userTemplate = userTemplateDefault }: MentionsParserArgs) { + this.pattern = pattern; + this.useRealName = useRealName || (() => false); + this.me = me || (() => ''); + this.userTemplate = userTemplate; + this.roomTemplate = roomTemplate; + } + + // The mention regexes only depend on the configured pattern (a setting that + // rarely changes), but these getters are hit multiple times per message; + // cache the compiled regexes and rebuild only when the pattern changes. + // Sharing the instances is safe because they're only used with + // String.prototype.replace/match, which reset lastIndex on each call. + private cachedPattern: string | undefined; + + private cachedUserMentionRegex: RegExp | undefined; + + private cachedChannelMentionRegex: RegExp | undefined; + + private updateRegexCache() { + const pattern = this.pattern(); + if (pattern !== this.cachedPattern || !this.cachedUserMentionRegex || !this.cachedChannelMentionRegex) { + this.cachedPattern = pattern; + this.cachedUserMentionRegex = new RegExp(`(^|\\s|>)@(${pattern}(@(${pattern}))?(:([0-9a-zA-Z-_.]+))?)`, 'gm'); + this.cachedChannelMentionRegex = new RegExp(`(^|\\s|>)#(${pattern}(@(${pattern}))?)`, 'gm'); + } + } + + get userMentionRegex(): RegExp { + this.updateRegexCache(); + return this.cachedUserMentionRegex as RegExp; + } + + get channelMentionRegex(): RegExp { + this.updateRegexCache(); + return this.cachedChannelMentionRegex as RegExp; + } + + replaceUsers = (msg: string, { mentions, temp }: IMessage, me: string) => + msg.replace(this.userMentionRegex, (match, prefix, mention) => { + const classNames = ['mention-link']; + + if (mention === 'all') { + classNames.push('mention-link--all'); + classNames.push('mention-link--group'); + } else if (mention === 'here') { + classNames.push('mention-link--here'); + classNames.push('mention-link--group'); + } else if (mention === me) { + classNames.push('mention-link--me'); + classNames.push('mention-link--user'); + } else { + classNames.push('mention-link--user'); + } + + const className = classNames.join(' '); + + if (mention === 'all' || mention === 'here') { + return this.userTemplate({ prefix, className, mention, label: mention, type: 'group' }); + } + + const filterUser = ({ username, type }: { username?: string; type?: string }) => (!type || type === 'user') && username === mention; + const filterTeam = ({ name, type }: { name?: string; type?: string }) => type === 'team' && name === mention; + + const [mentionObj] = (mentions || []).filter((m) => m && (filterUser(m) || filterTeam(m))); + + const label = temp + ? mention && escapeHTML(mention) + : mentionObj && escapeHTML((mentionObj.type === 'team' || this.useRealName() ? mentionObj.name : mentionObj.username) || ''); + + if (!label) { + return match; + } + + return this.userTemplate({ + prefix, + className, + mention, + label, + type: mentionObj?.type === 'team' ? 'team' : 'username', + title: this.useRealName() ? mention : label, + }); + }); + + replaceChannels = (msg: string, { temp, channels }: IMessage) => + msg.replace(/'/g, "'").replace(this.channelMentionRegex, (match, prefix, mention) => { + const channel = channels?.find(({ name }) => { + return name === mention; + }); + + if (!temp && !channel) { + return match; + } + + const reference = channel ? channel._id : mention; + return this.roomTemplate({ prefix, reference, channel, mention }); + }); + + getUserMentions(msg: string) { + // First remove the text inside md links + const str = msg.replace(/\[[^\]]*\]\([^)]+\)/g, ''); + // Then do the match + return (str.match(this.userMentionRegex) || []).map((match) => match.trim()); + } + + getChannelMentions(msg: string) { + // First remove the text inside md links + const str = msg.replace(/\[[^\]]*\]\([^)]+\)/g, ''); + // Then do the match + return (str.match(this.channelMentionRegex) || []).map((match) => match.trim()); + } + + parse(message: IMessage) { + let msg = message?.html || ''; + if (!msg.trim()) { + return message; + } + msg = this.replaceUsers(msg, message, this.me()); + msg = this.replaceChannels(msg, message); + message.html = msg; + return message; + } +} diff --git a/apps/meteor/app/theme/client/imports/components/emoji.css b/apps/meteor/app/theme/client/imports/components/emoji.css new file mode 100644 index 0000000..bdd6b39 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/components/emoji.css @@ -0,0 +1,81 @@ +/* Wrapper for Segoe UI Emoji correcting its metrics: it paints its ink low +relative to its metric midpoint (so vertical-align: middle sits glyphs ~2px +below the text center) and ~15% larger than the other platform emoji fonts. +Only exists on Windows, so other platforms skip it in the fallback list. */ +@font-face { + font-family: rc-emoji-segoe; + src: local('Segoe UI Emoji'); + ascent-override: 90%; + descent-override: 25%; + size-adjust: 85%; +} + +/* Global emoji styles */ +.emoji { + font-family: 'Apple Color Emoji', rc-emoji-segoe, 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', sans-serif; + font-size: 1.375rem; + + /* Must exceed the emoji fonts' ink height — ~1.13em on Noto Color Emoji and on + size-adjusted Segoe UI Emoji (rc-emoji-segoe) — otherwise the glyph paints outside + the line box and gets cut by overflow: hidden ancestors (message body, announcement + banner). */ + line-height: 1.2; + vertical-align: middle; + + /* custom emojis */ + &--custom { + height: 1.375rem; + width: 1.375rem; + overflow: hidden; + position: relative; + white-space: nowrap; + text-indent: 100%; + vertical-align: bottom; + display: inline-block; + margin: 0 .15em; + background-position: 50%; + background-repeat: no-repeat; + background-size: contain; + } +} + +/* emoji picker */ +.rcx-button .emoji { + font-size: 1.375rem; + line-height: 1.5rem; +} + +.rcx-message { + /* emojis on messages */ + &__emoji { + font-family: 'Apple Color Emoji', rc-emoji-segoe, 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', sans-serif; + + /* Fuselage fixes this box at 1.5rem inline-block, sized for image emojis. Native + glyphs on Segoe UI Emoji/Noto Color Emoji paint taller and wider than that box, and + the overhang gets clipped by the message body's overflow: hidden — so let the box + grow to the glyph. inline-block also aligns by geometric box while the announcement + banner's .emoji span (plain inline) aligns by font metrics; Segoe UI Emoji's + asymmetric metrics make those disagree by ~2px, leaving message glyphs off-center. + Use the same inline model everywhere. */ + &:not(&--custom) { + display: inline; + width: auto; + height: auto; + line-height: 1.2; + } + } +} + +.rcx-message-reactions { + &__emoji { + /* Can't be written as `&--custom` like the .rcx-message rule above: custom emojis here + carry the renderer's global `.emoji--custom` class, not a block modifier. */ + /* stylelint-disable-next-line selector-class-pattern */ + &:not(.emoji--custom) { + width: auto; + height: auto; + font-size: 1rem; + line-height: 1.2; + } + } +} diff --git a/apps/meteor/app/theme/client/imports/components/flex-nav.css b/apps/meteor/app/theme/client/imports/components/flex-nav.css new file mode 100644 index 0000000..6db8bf2 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/components/flex-nav.css @@ -0,0 +1,19 @@ +.flex-nav { + position: absolute; + z-index: 3; + top: 0; + left: 0; + width: 100%; + height: 100%; + transition: transform 0.15s cubic-bezier(0.5, 0, 0.1, 1); + background-color: var(--flex-nav-background); + will-change: transform; + + &.animated-hidden { + transform: translate3d(-100%, 0, 0); + } +} + +.rtl .flex-nav.animated-hidden { + transform: translate3d(200%, 0, 0); +} diff --git a/apps/meteor/app/theme/client/imports/components/loading.css b/apps/meteor/app/theme/client/imports/components/loading.css new file mode 100644 index 0000000..d321247 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/components/loading.css @@ -0,0 +1,54 @@ +.loading { + &__animation { + display: flex; + align-items: center; + justify-content: center; + } + + &__animation__bounce { + display: inline-block; + width: 1rem; + height: 1rem; + margin: 2px; + animation: loading-bouncedelay 1.4s infinite ease-in-out both; + animation-delay: -0.32s; + border-radius: 100%; + background-color: var(--loading-bounce-color-light); + + @media (prefers-color-scheme: dark) { + background: var(--loading-bounce-color-dark); + } + } + + &__animation__bounce--medium { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + } + + &__animation__bounce--large { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + } + + &__animation__bounce + &__animation__bounce { + animation-delay: -0.16s; + } + + &__animation__bounce + &__animation__bounce + &__animation__bounce { + animation-delay: 0s; + } +} + +@keyframes loading-bouncedelay { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} diff --git a/apps/meteor/app/theme/client/imports/general/base.css b/apps/meteor/app/theme/client/imports/general/base.css new file mode 100644 index 0000000..d333d03 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/base.css @@ -0,0 +1,133 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + overflow-y: auto; + height: 100%; + + &.noscroll { + overflow: hidden; + } +} + +body { + position: relative; + display: flex; + overflow: visible; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0; + font-family: var(--body-font-family); + font-size: var(--text-small-size); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:focus { + outline: 0; + outline-style: none; + outline-color: transparent; +} + +.clearfix { + clear: both; + + &::after { + display: table; + clear: both; + content: ''; + } +} + +button { + padding: 0; + cursor: pointer; + text-align: left; + text-transform: inherit; + color: inherit; + border-width: 0; + background: none; + font-style: inherit; +} + +#rocket-chat { + position: relative; + display: flex; + overflow: hidden; + flex: 1 1 auto; + height: 100%; + max-height: 100%; + align-items: stretch; + + &.animated-hidden { + visibility: hidden; + opacity: 0; + } +} + +.ps-scrollbar-y-rail { + background: transparent !important; +} + +.ps-scrollbar-y { + width: 4px !important; +} + +@media print { + #rocket-chat.menu-nav, + .messages-container .wrapper { + position: relative !important; /* 1 */ + } + + body { + height: auto !important; /* 1 */ + } + + .messages-container-main, + .messages-container-wrapper, + .main-content-flex { + flex: 1 0 auto !important; /* 1 */ + height: auto !important; /* 1 */ + } + + .rc-alerts { + display: none !important; /* 1 */ + } + + .rcx-box { + &--full.rcx-box { + overflow: visible !important /* 1 */; + height: auto !important /* 1 */; + } + } +} + +.gallery-item { + max-width: 100%; + cursor: pointer; +} + +/* + * Prevent iOS Safari from auto-zooming when a field gains focus: it zooms whenever the + * focused control's font-size is below 16px. Enforce a 16px minimum on text-entry controls + * on small screens — this preserves pinch-to-zoom, unlike disabling user-scalable in the + * viewport meta. `!important` is required to override the design-system default + * (.rcx-input-box is 0.875rem and fuselage.css is imported after this file). + * + * `-webkit-touch-callout` is implemented only by iOS WebKit, so it scopes this to the + * engine that has the behavior: every iOS browser, but no desktop or Android one. + */ +@supports (-webkit-touch-callout: none) { + @media (width <= 767px) { + input, + textarea, + select, + [contenteditable]:not([contenteditable='false']) { + font-size: 16px !important; + } + } +} diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css new file mode 100644 index 0000000..289e1c5 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -0,0 +1,250 @@ +.copyonly { + display: none; + width: 0; + height: 0; + user-select: none; + vertical-align: baseline; + font-size: 0; + -moz-box-orient: vertical; + + code & { + float: left; + } +} + +/* change to page-messages */ + +.messages-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + + &-wrapper { + display: flex; + flex: 1 1 auto; + height: 1%; + } + + &-main { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 50%; + } + + & .wrapper { + position: absolute; + top: 0; + left: 0; + overflow: hidden auto; + width: 100%; + height: 100%; + word-wrap: break-word; + -webkit-overflow-scrolling: touch; + } +} + +.messages-box { + position: relative; + overflow: hidden; + flex-grow: 1; + + & .wrapper.has-more-next { + padding-bottom: 24px; + } + + /* Overlay scrollbars overrides styles, so we need an important here */ + & .messages-list { + padding: 21px 0 10px !important; + } +} + +.highlight-text { + padding: 0 2px 2px; + color: var(--rcx-color-font-pure-white, #ffffff); + border-radius: var(--border-radius); + background-color: var(--rcx-color-badge-background-level-4, #f5455c); +} + +.inline-video { + width: 100%; + max-width: 480px; + height: auto; + max-height: 270px; +} + +.load-more { + position: relative; + height: 2rem; +} + +.rcx-message { + &.highlight { + animation: highlight 6s; + } +} + +.page-loading { + position: absolute; + inset: 0; + display: flex; + text-align: center; + background-color: var(--page-loading-background-light); + align-items: center; + justify-content: center; + + @media (prefers-color-scheme: dark) { + background: var(--page-loading-background-dark); + } +} + +/* FLEX-TAB and FLEX-TAB views */ +.main-content-flex { + display: flex; + height: 100%; + flex-grow: 1; +} + +.no-scroll { + overflow: hidden !important; +} + +code { + padding: 0.5em; + text-align: left; + vertical-align: middle; + white-space: pre-wrap; + word-wrap: break-word; + border-width: 1px; + border-radius: var(--border-radius); + font-family: Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 13px; + font-weight: 600; + direction: ltr; + unicode-bidi: embed; + + &.inline { + display: inline; + padding: 0.05rem 0.2rem; + line-height: 1.25rem; + } + + &.hljs { + overflow-y: hidden; + } +} + +pre { + display: inline-block; + width: 100%; +} + +blockquote { + position: relative; + display: block; + clear: both; + min-height: 20px; + padding-left: 10px; + + &::after { + display: table; + clear: both; + content: ''; + } + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 2px; + content: ' '; + } + + &:first-child::before { + border-radius: 2px 2px 0 0; + } + + &:last-child::before { + border-radius: 0 0 2px 2px; + } +} + +.new-room-highlight a { + animation: highlight 6s infinite; +} + +.room-not-found { + display: flex; + flex-direction: column; + font-size: 30px; + align-items: center; + justify-content: center; + + & div { + text-align: center; + line-height: 40px; + } + + & i { + padding-bottom: 30px; + font-size: 100px; + } +} + +.flex-tab-main-content { + position: relative; + z-index: 1; + overflow: auto; + flex-grow: 1; +} + +.code-colors { + color: var(--rcx-color-font-default, #1f2329); + border-color: var(--rcx-color-stroke-extra-light, #ebecef); + background-color: var(--rcx-color-surface-neutral, #e4e7ea); +} + +.code-mirror-box { + & .CodeMirror { /* stylelint-disable-line */ + border-width: var(--input-border-width); + border-color: var(--input-border-color); + border-radius: var(--input-border-radius); + } + + &.code-mirror-box-fullscreen { + & .CodeMirror { /* stylelint-disable-line */ + display: flex; + flex-direction: column; + flex-grow: 1; + + & .CodeMirror-scroll { /* stylelint-disable-line */ + flex-grow: 1; + } + } + } +} + +.embedded-view { + & .messages-container { + border-width: 0; + + & .messages-box { + margin-top: 0; + } + } +} + +@keyframes zoom-in { + 0% { + transform: scale3d(0.9, 0.9, 0.9); + opacity: 0; + } + + 50% { + opacity: 1; + } +} diff --git a/apps/meteor/app/theme/client/imports/general/react-root.css b/apps/meteor/app/theme/client/imports/general/react-root.css new file mode 100644 index 0000000..4cca0ea --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/react-root.css @@ -0,0 +1,10 @@ +#react-root { + position: relative; + display: flex; + overflow: visible; + flex-direction: column; + width: 100vw; + height: 100vh; + padding: 0; + block-size: -webkit-fill-available; +} diff --git a/apps/meteor/app/theme/client/imports/general/reset.css b/apps/meteor/app/theme/client/imports/general/reset.css new file mode 100644 index 0000000..3ab7367 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/reset.css @@ -0,0 +1,136 @@ +/** +* Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/) +* http://cssreset.com +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +button, +audio, +video { + margin: 0; + padding: 0; + vertical-align: baseline; + border: 0 solid; + + &::after, + &::before { + border: 0 solid; + } +} + +/* HTML5 display-role reset for older browsers */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} + +ol, +ul { + list-style: none; +} + +blockquote, +q { + quotes: none; +} + +blockquote::before, +blockquote::after, +q::before, +q::after { + content: ''; + content: none; +} + +table { + border-spacing: 0; + border-collapse: collapse; +} diff --git a/apps/meteor/app/theme/client/imports/general/theme_old.css b/apps/meteor/app/theme/client/imports/general/theme_old.css new file mode 100644 index 0000000..1cedb5d --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/theme_old.css @@ -0,0 +1,37 @@ +* { + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 50px; + background-color: var(--custom-scrollbar-color); + } + + &::-webkit-scrollbar-corner { + background-color: transparent; + } +} + +.content-background-color { + background-color: var(--content-background-color); +} + +.color-primary-font-color { + color: var(--primary-font-color); +} + +.secondary-font-color { + color: var(--secondary-font-color); +} + +input, +select, +textarea { + border-style: solid; + background-color: transparent; +} diff --git a/apps/meteor/app/theme/client/imports/general/variables.css b/apps/meteor/app/theme/client/imports/general/variables.css new file mode 100644 index 0000000..d3de497 --- /dev/null +++ b/apps/meteor/app/theme/client/imports/general/variables.css @@ -0,0 +1,85 @@ +:root { + /* + * Color palette + */ + --color-red: #f5455c; + --color-yellow: #ffd21f; + --color-green: #2de0a5; + + /* + * General Colors + */ + --color-gray: #9ea2a8; + --color-gray-medium: #cbced1; + --color-gray-lightest: #f2f3f5; + + /* Colors */ + --rc-color-error: var(--color-red); + --rc-color-alert: var(--color-yellow); + --rc-color-success: var(--color-green); + --rc-color-primary-light: var(--color-gray); + + /* Old Colors */ + --content-background-color: #ffffff; + --primary-font-color: #444444; + --primary-action-color: #1d74f5; + --secondary-font-color: #a0a0a0; + --component-color: #f2f3f5; + + /* Fonts */ + --body-font-family: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, oxygen, ubuntu, cantarell, 'Helvetica Neue', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Meiryo UI', arial, sans-serif; + + /* + * General + */ + --rc-status-online: var(--rc-color-success); + --rc-status-away: var(--rc-color-alert); + --rc-status-busy: var(--rc-color-error); + --rc-status-invisible: var(--color-gray-medium); + + /* + * General Typography + */ + --text-small-size: 0.875rem; + + /* + * Sidebar + */ + --sidebar-width: 17.5rem; + --sidebar-md-width: 20rem; + --sidebar-lg-width: 21rem; + --sidebar-default-padding: 24px; + --sidebar-small-default-padding: 16px; + + /* + * Rooms list + */ + --rooms-list-title-color: var(--rc-color-primary-light); + --rooms-list-title-text-size: 0.75rem; + --rooms-list-empty-text-color: var(--color-gray); + --rooms-list-empty-text-size: 0.75rem; + + /* + * Flex nav + */ + --flex-nav-background: var(--color-gray-lightest); + + /* + * Loading + */ + --page-loading-background-light: var(--rcx-color-surface-tint, #f7f8fa); + --page-loading-background-dark: var(--rcx-color-surface-tint, #1f2329); + --loading-bounce-color-light: var(--rcx-color-font-default, #2f343d); + --loading-bounce-color-dark: var(--rcx-color-font-default, #e4e7ea); + + /* + * Scrollbar + */ + --custom-scrollbar-color: var(--rcx-color-stroke-dark, #6C737A); +} + +.rcx-sidebar { + &--main { + --sidebar-background: var(--rcx-color-surface-tint, #262931); + } +} diff --git a/apps/meteor/app/theme/client/index.ts b/apps/meteor/app/theme/client/index.ts new file mode 100644 index 0000000..74f07de --- /dev/null +++ b/apps/meteor/app/theme/client/index.ts @@ -0,0 +1 @@ +import './main.css'; diff --git a/apps/meteor/app/theme/client/main.css b/apps/meteor/app/theme/client/main.css new file mode 100644 index 0000000..036ed32 --- /dev/null +++ b/apps/meteor/app/theme/client/main.css @@ -0,0 +1,16 @@ +/* General */ +@import url('imports/general/variables.css'); +@import url('imports/general/reset.css'); +@import url('imports/general/base_old.css'); +@import url('imports/general/base.css'); +@import url('imports/general/react-root.css'); + +/* Main */ +@import url('imports/components/flex-nav.css'); +@import url('imports/components/emoji.css'); +@import url('imports/components/loading.css'); + +/* Legacy theming */ +@import url('imports/general/theme_old.css'); +@import url('./rocketchat.font.css'); +@import url('../../../node_modules/@rocket.chat/fuselage/dist/fuselage.css'); diff --git a/apps/meteor/app/theme/client/rocketchat.font.css b/apps/meteor/app/theme/client/rocketchat.font.css new file mode 100644 index 0000000..92da53d --- /dev/null +++ b/apps/meteor/app/theme/client/rocketchat.font.css @@ -0,0 +1,7 @@ +@font-face { + font-family: RocketChat; + font-weight: 400; + font-style: normal; + font-display: block; + src: url('/fonts/rocketchat.woff2') format('woff2'); +} diff --git a/apps/meteor/app/utils/lib/getURL.ts b/apps/meteor/app/utils/lib/getURL.ts new file mode 100644 index 0000000..e286e44 --- /dev/null +++ b/apps/meteor/app/utils/lib/getURL.ts @@ -0,0 +1,109 @@ +import { escapeRegExp, isAbsoluteURL } from '@rocket.chat/tools'; + +import { ltrim, rtrim, trim } from '../../../lib/utils/stringUtils'; + +function getCloudUrl( + path: string, + // eslint-disable-next-line @typescript-eslint/naming-convention + _site_url: string, + cloudRoute: string, + cloudParams: Record = {}, + deeplinkUrl = '', +): string { + const cloudBaseUrl = deeplinkUrl.replace(/\/+$/, ''); + + const siteUrl = rtrim(_site_url, '/'); + + // Remove the protocol + const host = siteUrl.replace(/https?\:\/\//i, ''); + path = ltrim(path, '/'); + + Object.assign(cloudParams, { + host, + path, + }); + + if (siteUrl.includes('http://')) { + cloudParams.secure = 'no'; + } + + const params = Object.entries(cloudParams) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + + return `${cloudBaseUrl}/${cloudRoute}?${params}`; +} + +export const _getURL = ( + path: string, + + { cdn, full, cloud, cloud_route, cloud_params, _cdn_prefix, _root_url_path_prefix, _site_url }: Record, + deeplinkUrl?: string, +): string => { + if (isAbsoluteURL(path)) { + return path; + } + + const [_path, _query] = path.split('?'); + path = _path; + const query = _query ? `?${_query}` : ''; + + const siteUrl = rtrim(trim(_site_url || ''), '/'); + const cloudRoute = trim(cloud_route || ''); + const cdnPrefix = rtrim(trim(_cdn_prefix || ''), '/'); + const pathPrefix = rtrim(trim(_root_url_path_prefix || ''), '/'); + + const finalPath = ltrim(trim(path), '/'); + + const url = rtrim(`${pathPrefix}/${finalPath}`, '/') + query; + + if (cloud) { + const cloudParams = cloud_params || {}; + return getCloudUrl(url, siteUrl, cloudRoute, cloudParams, deeplinkUrl); + } + + if (cdn && cdnPrefix !== '') { + return cdnPrefix + url; + } + + if (full) { + return siteUrl.replace(new RegExp(`${escapeRegExp(pathPrefix)}$`), '') + url; + } + + return url; +}; + +export const getURLWithoutSettings = ( + path: string, + + { + cdn = true, + full = false, + cloud = false, + cloud_route = '', + cloud_params = {}, + }: { + cdn?: boolean; + full?: boolean; + cloud?: boolean; + cloud_route?: string; + cloud_params?: Record; + }, + cdnPrefix: string, + siteUrl: string, + cloudDeepLinkUrl?: string, +): string => + _getURL( + path, + { + cdn, + full, + cloud, + cloud_route, + cloud_params, + _cdn_prefix: cdnPrefix, + _root_url_path_prefix: __meteor_runtime_config__.ROOT_URL_PATH_PREFIX, + _site_url: siteUrl, + }, + cloudDeepLinkUrl, + ); diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts new file mode 100644 index 0000000..b4ff155 --- /dev/null +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -0,0 +1,6 @@ +import { addSprinfToI18n } from '@rocket.chat/i18n'; +import i18next from 'i18next'; +import sprintf from 'i18next-sprintf-postprocessor'; + +export const i18n = i18next.use(sprintf); +export const t = addSprinfToI18n(i18n.t.bind(i18n)); diff --git a/apps/meteor/app/utils/lib/mimeTypes.spec.ts b/apps/meteor/app/utils/lib/mimeTypes.spec.ts new file mode 100644 index 0000000..d0fbd43 --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; + +import { getExtension, getMimeType } from './mimeTypes'; + +const mimeTypeToExtension = { + 'text/plain': 'txt', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/png': 'png', + 'image/jpeg': 'jpeg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', + 'audio/wav': 'wav', + 'audio/wave': 'wav', + 'audio/aac': 'aac', + 'audio/x-aac': 'aac', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mpga', + 'audio/ogg': 'oga', + 'application/octet-stream': 'bin', +}; + +const extensionToMimeType = { + lst: 'text/plain', + txt: 'text/plain', + ico: 'image/x-icon', + png: 'image/png', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + tiff: 'image/tiff', + tif: 'image/tiff', + wav: 'audio/wav', + aac: 'audio/aac', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + oga: 'audio/ogg', + m4a: 'audio/mp4', + mpga: 'audio/mpeg', + mp4: 'video/mp4', + bin: 'application/octet-stream', +}; + +describe('mimeTypes', () => { + describe('getExtension', () => { + for (const [mimeType, extension] of Object.entries(mimeTypeToExtension)) { + it(`should return the correct extension ${extension} for the given mimeType ${mimeType}`, async () => { + expect(getExtension(mimeType)).to.be.eql(extension); + }); + } + + it('should return an empty string if the mimeType is not found', async () => { + expect(getExtension('application/unknown')).to.be.eql(''); + }); + }); + + describe('getMimeType', () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + it(`should return the correct mimeType ${mimeType} for the given fileName file.${extension} passing the correct mimeType`, async () => { + expect(getMimeType(mimeType, `file.${extension}`)).to.be.eql(mimeType); + }); + } + + it('should return the correct mimeType for the given fileName', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/unknown', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the correct mimeType for the given fileName when informed mimeType is application/octet-stream', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/octet-stream', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the mimeType if it is not application/octet-stream', async () => { + expect(getMimeType('audio/wav', 'file.wav')).to.be.eql('audio/wav'); + }); + + it('should return application/octet-stream if the mimeType is not found', async () => { + expect(getMimeType('application/octet-stream', 'file.unknown')).to.be.eql('application/octet-stream'); + }); + }); +}); diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts new file mode 100644 index 0000000..b08d1a0 --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -0,0 +1,31 @@ +import mime from 'mime-type/with-db'; + +mime.types.wav = 'audio/wav'; +mime.types.lst = 'text/plain'; +mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupOverwrite); +mime.define('audio/aac', { source: '', extensions: ['aac'] }, mime.dupOverwrite); + +const getExtension = (param: string): string => { + const extension = mime.extension(param); + + return !extension || typeof extension === 'boolean' ? '' : extension; +}; + +const getMimeTypeFromFileName = (fileName: string): string => { + const fileMimeType = mime.lookup(fileName); + return typeof fileMimeType === 'string' ? fileMimeType : 'application/octet-stream'; +}; + +const getMimeType = (mimetype: string, fileName: string): string => { + // If the extension from the mimetype is different from the file extension, the file + // extension may be wrong so use the informed mimetype + const extension = mime.extension(mimetype); + if (mimetype !== 'application/octet-stream' && extension && extension !== fileName.split('.').pop()) { + return mimetype; + } + + return getMimeTypeFromFileName(fileName); +}; + +export { mime, getExtension, getMimeType, getMimeTypeFromFileName }; diff --git a/apps/meteor/app/utils/lib/restrictions.ts b/apps/meteor/app/utils/lib/restrictions.ts new file mode 100644 index 0000000..bf859e5 --- /dev/null +++ b/apps/meteor/app/utils/lib/restrictions.ts @@ -0,0 +1,61 @@ +export const fileUploadMediaWhiteList = function (customWhiteList: string): string[] | undefined { + const mediaTypeWhiteList = customWhiteList; + + if (!mediaTypeWhiteList || mediaTypeWhiteList === '*') { + return; + } + return mediaTypeWhiteList.split(',').map((item) => { + return item.trim(); + }); +}; + +const fileUploadMediaBlackList = function (customBlackList: string): string[] | undefined { + const blacklist = customBlackList; + if (!blacklist) { + return; + } + + return blacklist.split(',').map((item) => item.trim()); +}; + +const isTypeOnList = function (type?: string, list?: string[]): boolean { + if (!type || !list) { + return false; + } + + if (list.includes(type)) { + return true; + } + const wildCardGlob = '/*'; + const wildcards = list.filter((item) => { + return item.indexOf(wildCardGlob) > 0; + }); + if (wildcards.includes(type.replace(/(\/.*)$/, wildCardGlob))) { + return true; + } + + return false; +}; + +export const fileUploadIsValidContentTypeFromSettings = function ( + type: string | undefined, + customWhiteList: string, + customBlackList: string, +): boolean { + const blackList = fileUploadMediaBlackList(customBlackList); + const whiteList = fileUploadMediaWhiteList(customWhiteList); + + if (blackList && type && isTypeOnList(type, blackList)) { + return false; + } + + if (whiteList) { + return isTypeOnList(type, whiteList); + } + + if (!whiteList) { + return true; + } + + return false; +}; diff --git a/apps/meteor/app/utils/rocketchat-supported-versions.info b/apps/meteor/app/utils/rocketchat-supported-versions.info new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/apps/meteor/app/utils/rocketchat-supported-versions.info @@ -0,0 +1,2 @@ +{ +} diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info new file mode 100644 index 0000000..8525f2b --- /dev/null +++ b/apps/meteor/app/utils/rocketchat.info @@ -0,0 +1,3 @@ +{ + "version": "8.9.0-develop" +} diff --git a/apps/meteor/client/README.md b/apps/meteor/client/README.md new file mode 100644 index 0000000..91a12ab --- /dev/null +++ b/apps/meteor/client/README.md @@ -0,0 +1,36 @@ +# How does the folder structure work in the frontend? + + +## The folder structure should follow rocketchat's html 'semantics' like: +``` + main -> room + -> header + ... + -> contextualbar + roominfo + ... + -> sidebar + -> Header + -> search + -> actions + ... + -> list + ... + -> footer + ... +``` +Each folder is composed by: +| File || +| -- | -- | +| index.jsx | | +| ContainerComponent.jsx | +| contexts | (optional) | +| providers | (optional) | +| components | (optional) | +| hooks | (optional) | +| libs | (optional) | + +We strongly suggest that you start developing your components/hooks/libs inside the folder where they will be directly used. If you ever find that you will use them in more than one place, then you should "promote" the code to an upper level, where it can be commonly accessed by all others + + +we have being moving code from blaze to react, but sometimes we will need render under blaze enviroment, to do that use `createTemplateForComponent` \ No newline at end of file diff --git a/apps/meteor/client/apps/RealAppsEngineUIHost.ts b/apps/meteor/client/apps/RealAppsEngineUIHost.ts new file mode 100644 index 0000000..890e522 --- /dev/null +++ b/apps/meteor/client/apps/RealAppsEngineUIHost.ts @@ -0,0 +1,69 @@ +import { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost'; +import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo'; +import type { IExternalComponentUserInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentUserInfo'; + +import { RoomManager } from '../lib/RoomManager'; +import { sdk } from '../lib/SDKClient'; +import { baseURI } from '../lib/baseURI'; +import { getUserAvatarURL } from '../lib/getUserAvatarURL'; +import { getUser } from '../lib/user'; +import { Rooms } from '../stores'; + +// FIXME: replace non-null assertions with proper error handling + +export class RealAppsEngineUIHost extends AppsEngineUIHost { + private _baseURL: string; + + constructor() { + super(); + + this._baseURL = baseURI.replace(/\/$/, ''); + } + + private getUserAvatarUrl(username: string) { + const avatarUrl = getUserAvatarURL(username)!; + + if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('data')) { + return `${this._baseURL}${avatarUrl}`; + } + + return avatarUrl; + } + + async getClientRoomInfo(): Promise { + const room = RoomManager.opened ? Rooms.state.get(RoomManager.opened) : undefined; + if (!room) { + throw new Error('Room not found'); + } + const { name: slugifiedName, _id: id } = room; + + let cachedMembers: IExternalComponentUserInfo[] = []; + try { + const { members } = await sdk.rest.get('/v1/groups.members', { roomId: id }); + + cachedMembers = members.map(({ _id, username }): IExternalComponentUserInfo => ({ + id: _id, + username: username!, + avatarUrl: this.getUserAvatarUrl(username!), + })); + } catch (error) { + console.warn(error); + } + + return { + id, + slugifiedName: slugifiedName!, + members: cachedMembers, + }; + } + + async getClientUserInfo(): Promise { + const { username, _id } = getUser()!; + + return { + id: _id, + username: username!, + avatarUrl: this.getUserAvatarUrl(username!) || '', + }; + } +} diff --git a/apps/meteor/client/apps/gameCenter/GameCenter.tsx b/apps/meteor/client/apps/gameCenter/GameCenter.tsx new file mode 100644 index 0000000..b424ad7 --- /dev/null +++ b/apps/meteor/client/apps/gameCenter/GameCenter.tsx @@ -0,0 +1,43 @@ +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { useRoomToolbox } from '@rocket.chat/ui-contexts'; +import { useState } from 'react'; +import type { MouseEvent } from 'react'; + +import GameCenterContainer from './GameCenterContainer'; +import GameCenterList from './GameCenterList'; +import { useExternalComponentsQuery } from './hooks/useExternalComponentsQuery'; +import { preventSyntheticEvent } from '../../lib/utils/preventSyntheticEvent'; + +export type IGame = IExternalComponent; + +const GameCenter = () => { + const [openedGame, setOpenedGame] = useState(); + + const { closeTab } = useRoomToolbox(); + + const result = useExternalComponentsQuery(); + + const handleClose = useStableCallback(() => closeTab()); + + const handleBack = useStableCallback((e: MouseEvent) => { + setOpenedGame(undefined); + preventSyntheticEvent(e); + }); + + if (!openedGame) { + return ( + + ); + } + + return ; +}; + +export default GameCenter; diff --git a/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx b/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx new file mode 100644 index 0000000..5c24674 --- /dev/null +++ b/apps/meteor/client/apps/gameCenter/GameCenterContainer.tsx @@ -0,0 +1,39 @@ +import { Avatar } from '@rocket.chat/fuselage'; +import { + ContextualbarTitle, + ContextualbarHeader, + ContextualbarBack, + ContextualbarContent, + ContextualbarClose, + ContextualbarDialog, +} from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import type { IGame } from './GameCenter'; + +interface IGameCenterContainerProps { + handleClose: () => void; + handleBack: (e: any) => void; + game: IGame; +} + +const GameCenterContainer = ({ handleClose, handleBack, game }: IGameCenterContainerProps) => { + const { t } = useTranslation(); + + return ( + + + {handleBack && } + + {game.name} + + {handleClose && } + + + ' + + '
'; + + chatWidget.style.position = 'fixed'; + chatWidget.style.width = widgetWidth; + chatWidget.style.height = widgetHeightClosed; + chatWidget.style.borderTopLeftRadius = '5px'; + chatWidget.style.borderTopRightRadius = '5px'; + chatWidget.style.bottom = '0'; + chatWidget.style.right = '50px'; + chatWidget.style.zIndex = '12345'; + + document.getElementsByTagName('body')[0].appendChild(chatWidget); + + widget = document.querySelector('.rocketchat-widget'); + iframe = document.getElementById('rocketchat-iframe'); + + w.addEventListener('message', function(msg) { + if (typeof msg.data === 'object' && msg.data.src !== undefined && msg.data.src === 'rocketchat') { + if (api[msg.data.fn] !== undefined && typeof api[msg.data.fn] === 'function') { + var args = [].concat(msg.data.args || []); + api[msg.data.fn].apply(null, args); + } + } + }, false); + + var mediaqueryresponse = function(mql) { + if (mql.matches) { + smallScreen = true; + chatWidget.style.left = '0'; + chatWidget.style.right = '0'; + chatWidget.style.width = '100%'; + } else { + chatWidget.style.left = 'auto'; + chatWidget.style.right = '50px'; + chatWidget.style.width = widgetWidth; + } + }; + + var mql = window.matchMedia('screen and (max-device-width: 480px)'); + mediaqueryresponse(mql); + mql.addListener(mediaqueryresponse); + + // track user navigation + trackNavigation(); + }; + + if (typeof w.initRocket !== 'undefined') { + console.warn('initRocket is now deprecated. Please update the livechat code.'); + init(w.initRocket[0]); + } + + if (typeof w.RocketChat.url !== 'undefined') { + init(w.RocketChat.url); + } + + var queue = w.RocketChat._; + + w.RocketChat = w.RocketChat._.push = function(c) { + c.call(w.RocketChat.livechat); + }; + + // exports + w.RocketChat.livechat = { + // methods + pageVisited: pageVisited, + setCustomField: setCustomField, + setTheme: setTheme, + setDepartment: setDepartment, + clearDepartment: clearDepartment, + setGuestToken: setGuestToken, + setGuestName: setGuestName, + setGuestEmail: setGuestEmail, + registerGuest: registerGuest, + + // callbacks + onChatMaximized: function(fn) { registerCallback('chat-maximized', fn); }, + onChatMinimized: function(fn) { registerCallback('chat-minimized', fn); }, + onChatStarted: function(fn) { registerCallback('chat-started', fn); }, + onChatEnded: function(fn) { registerCallback('chat-ended', fn); }, + onPrechatFormSubmit: function(fn) { registerCallback('pre-chat-form-submit', fn); }, + onOfflineFormSubmit: function(fn) { registerCallback('offline-form-submit', fn); } + }; + + // proccess queue + queue.forEach(function(c) { + c.call(w.RocketChat.livechat); + }); +}(window)); diff --git a/apps/meteor/packages/rocketchat-livechat/package.js b/apps/meteor/packages/rocketchat-livechat/package.js new file mode 100644 index 0000000..73a6799 --- /dev/null +++ b/apps/meteor/packages/rocketchat-livechat/package.js @@ -0,0 +1,19 @@ +Package.describe({ + name: 'rocketchat:livechat', + version: '0.0.1', + summary: 'Livechat plugin for Rocket.Chat', +}); + +Package.registerBuildPlugin({ + name: 'Livechat', + use: ['ecmascript'], + sources: ['plugin/build-livechat.js'], + npmDependencies: { + 'uglify-js': '2.8.29', + }, +}); + +Package.onUse(function (api) { + // livechat app + api.addAssets('assets/demo.html', 'client'); +}); diff --git a/apps/meteor/packages/rocketchat-livechat/plugin/build-livechat.js b/apps/meteor/packages/rocketchat-livechat/plugin/build-livechat.js new file mode 100644 index 0000000..acdc9d2 --- /dev/null +++ b/apps/meteor/packages/rocketchat-livechat/plugin/build-livechat.js @@ -0,0 +1,23 @@ +import path from 'path'; +import { execSync } from 'child_process'; +import fs from 'fs'; + +import UglifyJS from 'uglify-js'; + +const livechatSource = path.resolve('packages', 'rocketchat-livechat', 'assets', 'rocket-livechat.js'); +const livechatTarget = path.resolve('packages', 'rocketchat-livechat', 'assets', 'rocketchat-livechat.min.js'); + +fs.writeFileSync(livechatTarget, UglifyJS.minify(livechatSource).code); + +const packagePath = path.join(path.resolve('.'), 'packages', 'rocketchat-livechat'); +const pluginPath = path.join(packagePath, 'plugin'); + +const options = { + env: process.env, +}; + +if (process.platform === 'win32') { + execSync(`${pluginPath}/build.bat`, options); +} else { + execSync(`sh ${pluginPath}/build.sh`, options); +} diff --git a/apps/meteor/packages/rocketchat-livechat/plugin/build.bat b/apps/meteor/packages/rocketchat-livechat/plugin/build.bat new file mode 100644 index 0000000..bf51ac5 --- /dev/null +++ b/apps/meteor/packages/rocketchat-livechat/plugin/build.bat @@ -0,0 +1,21 @@ +@echo off + +SET NODE_ENV="production" +cd packages/rocketchat-livechat/.app +call meteor npm install --production +call meteor build --headless --directory .meteor/build/ + +SET LIVECHAT_DIR="../../../public/livechat" +SET BUILD_DIR=".meteor/build/bundle/programs/web.browser" + +mkdir ..\public +del /q /s ..\public\* + +rmdir /q /s %LIVECHAT_DIR% +mkdir %LIVECHAT_DIR% + +xcopy /y %BUILD_DIR%\*.css %LIVECHAT_DIR%\livechat.css* +xcopy /y %BUILD_DIR%\*.js %LIVECHAT_DIR%\livechat.js* +xcopy /y %BUILD_DIR%\head.html ..\public\head.html* + +rmdir /s /q .meteor\build\ diff --git a/apps/meteor/packages/rocketchat-livechat/plugin/build.sh b/apps/meteor/packages/rocketchat-livechat/plugin/build.sh new file mode 100644 index 0000000..a824858 --- /dev/null +++ b/apps/meteor/packages/rocketchat-livechat/plugin/build.sh @@ -0,0 +1,27 @@ +# set -x +export NODE_ENV="production" +export LIVECHAT_DIR="./public/livechat" +export LIVECHAT_ASSETS_DIR="./private/livechat" + +ROOT=$(pwd) + +rm -rf $LIVECHAT_DIR +mkdir -p $LIVECHAT_DIR + +rm -rf $LIVECHAT_ASSETS_DIR +mkdir $LIVECHAT_ASSETS_DIR + +#NEW LIVECHAT# +echo "Installing Livechat ${LATEST_LIVECHAT_VERSION}..." +cd $LIVECHAT_DIR + +cp -a $ROOT/../../packages/livechat/dist/. ./ +# Sourcemaps remain in packages/livechat/dist for local debugging / upload +# to error tracking, but should not be baked into the Meteor bundle and +# Docker image (they account for ~360 MB of the shipped widget). +find . -name '*.map' -delete +# change to lowercase so all injected junk from rocket.chat is not sent: https://github.com/meteorhacks/meteor-inject-initial/blob/master/lib/inject-core.js#L10 +# this is not harmful since doctype is case-insesitive: https://www.w3.org/TR/html5/syntax.html#the-doctype +meteor node -e 'fs.writeFileSync("index.html", fs.readFileSync("index.html").toString().replace(" 0) { + Mongo.setConnectionOptions(mongoConnectionOptions); +} + +process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; + +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +if ((process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api')) { + Email.sendAsync = async function _sendAsync(options) { + console.log('Email.sendAsync', options); + }; +} else if (process.env.NODE_ENV !== 'development') { + // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured + const stream = new PassThrough(); + stream.on('data', () => {}); + stream.on('end', () => {}); + + const { sendAsync } = Email; + Email.sendAsync = function _sendAsync(options) { + return sendAsync.call(this, { stream, ...options }); + }; +} diff --git a/apps/meteor/packages/rocketchat-postcss/.gitignore b/apps/meteor/packages/rocketchat-postcss/.gitignore new file mode 100644 index 0000000..6450084 --- /dev/null +++ b/apps/meteor/packages/rocketchat-postcss/.gitignore @@ -0,0 +1 @@ +/.npm diff --git a/apps/meteor/packages/rocketchat-postcss/build.js b/apps/meteor/packages/rocketchat-postcss/build.js new file mode 100644 index 0000000..125fe76 --- /dev/null +++ b/apps/meteor/packages/rocketchat-postcss/build.js @@ -0,0 +1,180 @@ +import { CssTools } from 'meteor/minifier-css'; +import postcss from 'postcss'; +import postcssrc from 'postcss-load-config'; +import { SourceMapConsumer, SourceMapGenerator } from 'source-map'; + +let loaded = false; +let postcssConfigPlugins = []; +let postcssConfigParser = null; +let postcssConfigExcludedPackages = []; + +const loadPostcssConfig = async () => { + if (loaded) { + return; + } + + try { + const config = await postcssrc({ meteor: true }); + postcssConfigPlugins = config.plugins || []; + postcssConfigParser = config.options.parser || null; + postcssConfigExcludedPackages = config.options.excludedPackages || []; + } catch (error) { + if (error.message.indexOf('No PostCSS Config found') < 0) { + throw error; + } + } finally { + loaded = true; + } +}; + +const isImportFile = ({ _source: { url } }) => /\.import\.css$/.test(url) || /(?:^|\/)imports\//.test(url); + +const isInExcludedPackages = (pathInBundle) => + postcssConfigExcludedPackages.some((packageName) => pathInBundle.indexOf(`packages/${packageName.replace(':', '_')}/`) > -1); + +const handleFileError = (file, error) => { + if (error.name === 'CssSyntaxError') { + file.error({ + message: error.message, + line: error.line, + column: error.column, + }); + return; + } + + if (error.reason) { + file.error({ + message: error.reason, + line: error.line, + column: error.column, + }); + return; + } + + file.error({ message: error.message }); +}; + +const getAbstractSyntaxTree = async (file) => { + const filename = file.getPathInBundle(); + + if (isInExcludedPackages(filename)) { + return Object.assign( + CssTools.parseCss(file.getContentsAsString(), { + source: filename, + position: true, + }), + { filename }, + ); + } + + try { + const postcssResult = await postcss(postcssConfigPlugins).process(file.getContentsAsString(), { + from: process.cwd() + file._source.url, + parser: postcssConfigParser, + }); + + postcssResult.warnings().forEach((warn) => { + process.stderr.write(warn.toString()); + }); + + return Object.assign( + CssTools.parseCss(postcssResult.css, { + source: filename, + position: true, + }), + { filename }, + ); + } catch (error) { + if (error.name === 'CssSyntaxError') { + error.message = `${error.message}\n\nCss Syntax Error.\n\n${error.message}${error.showSourceCode()}`; + } + + handleFileError(file, error); + + return { + type: 'stylesheet', + stylesheet: { + rules: [], + }, + filename, + }; + } +}; + +const mergeCssFiles = async (files) => { + const cssAsts = await Promise.all(files.map(getAbstractSyntaxTree)); + + const mergedCssAst = CssTools.mergeCssAsts(cssAsts, (filename, msg) => { + console.warn(`${filename}: warn: ${msg}`); + }); + + const { code, map } = CssTools.stringifyCss(mergedCssAst, { + sourcemap: true, + inputSourcemaps: false, + }); + + if (!code) { + return { + code: '', + }; + } + + const mapFilenameToFile = files.reduce( + (obj, file) => ({ + ...obj, + [file.getPathInBundle()]: file, + }), + {}, + ); + + map.sourcesContent = map.sources.map((filename) => mapFilenameToFile[filename].getContentsAsString()); + + // yes, this await is needed + const consumer = await new SourceMapConsumer(map); + + const newMap = SourceMapGenerator.fromSourceMap(consumer); + + consumer.destroy(); + + files + .filter((file) => file.getSourceMap()) + .forEach((file) => { + newMap.applySourceMap(new SourceMapConsumer(file.getSourceMap()), file.getPathInBundle()); + }); + + return { + code, + sourceMap: newMap.toString(), + }; +}; + +const processFilesForBundle = async (files = [], { minifyMode }) => { + if (!files.length) { + return; + } + + await loadPostcssConfig(); + + const filesToMerge = files.filter((file) => !isImportFile(file)); + + const { code, sourceMap } = await mergeCssFiles(filesToMerge); + + if (minifyMode === 'development') { + files[0].addStylesheet({ + data: code, + sourceMap, + path: 'merged-stylesheets.css', + }); + return; + } + + const minifiedFiles = await CssTools.minifyCssAsync(code); + + minifiedFiles.forEach((data) => { + files[0].addStylesheet({ data }); + }); +}; + +Plugin.registerMinifier({ extensions: ['css'] }, () => ({ + processFilesForBundle, +})); diff --git a/apps/meteor/packages/rocketchat-postcss/package.js b/apps/meteor/packages/rocketchat-postcss/package.js new file mode 100644 index 0000000..f0e3a9d --- /dev/null +++ b/apps/meteor/packages/rocketchat-postcss/package.js @@ -0,0 +1,15 @@ +Package.describe({ + name: 'rocketchat:postcss', + version: '1.0.0', + summary: 'CSS post-processing with PostCSS', +}); + +Package.registerBuildPlugin({ + name: 'postcss', + use: ['ecmascript', 'minifier-css'], + sources: ['build.js'], +}); + +Package.onUse((api) => { + api.use('isobuild:minifier-plugin@1.0.0'); +}); diff --git a/apps/meteor/packages/rocketchat-version/package.js b/apps/meteor/packages/rocketchat-version/package.js new file mode 100644 index 0000000..47d2af1 --- /dev/null +++ b/apps/meteor/packages/rocketchat-version/package.js @@ -0,0 +1,16 @@ +Package.describe({ + name: 'rocketchat:version', + summary: '', + version: '1.0.0', +}); + +Package.registerBuildPlugin({ + name: 'compileVersion', + use: ['ecmascript'], + sources: ['plugin/compile-version.js'], +}); + +Package.onUse(function (api) { + api.use('ecmascript'); + api.use('isobuild:compiler-plugin@1.0.0'); +}); diff --git a/apps/meteor/packages/rocketchat-version/plugin/compile-version.js b/apps/meteor/packages/rocketchat-version/plugin/compile-version.js new file mode 100644 index 0000000..c0c261b --- /dev/null +++ b/apps/meteor/packages/rocketchat-version/plugin/compile-version.js @@ -0,0 +1,168 @@ +import { exec } from 'child_process'; +import os from 'os'; +import util from 'util'; +import path from 'path'; +import fs from 'fs'; +import https from 'https'; + +const execAsync = util.promisify(exec); + +class VersionCompiler { + async processFilesForTarget(files) { + const processVersionFile = async function (file) { + const data = await new Promise((resolve, reject) => { + const currentVersion = + JSON.parse(fs.readFileSync(path.resolve(process.cwd(), './package.json'), { encoding: 'utf8' }))?.version || ''; + + const type = currentVersion.includes('-rc.') ? 'candidate' : currentVersion.includes('-develop') ? 'develop' : 'stable'; + + const url = `https://releases.rocket.chat/v2/server/supportedVersions?includeDraftType=${type}&includeDraftTag=${currentVersion}`; + + function handleError(err) { + console.error('Error getting supported versions', err); + // TODO remove this when we are ready to fail + if (process.env.NODE_ENV !== 'development') { + reject(err); + return; + } + resolve({}); + } + + console.log('Getting supported versions from', url); + + https + .get(url, function (response) { + let data = ''; + response.on('data', function (chunk) { + data += chunk; + }); + response.on('end', async function () { + const supportedVersions = JSON.parse(data); + if (!supportedVersions?.signed) { + return handleError(new Error(`Invalid supportedVersions result:\n URL: ${url} \n RESULT: ${data}`)); + } + + // check if timestamp is inside 1 hour within build + if (Math.abs(new Date().getTime() - new Date(supportedVersions.timestamp).getTime()) > 1000 * 60 * 60) { + return handleError(new Error(`Invalid supportedVersions timestamp:\n URL: ${url} \n RESULT: ${data}`)); + } + + for await (const version of supportedVersions.versions) { + // check if expiration is after the first rocket.chat release + if (new Date(version.expiration) < new Date('2019-04-01T00:00:00.000Z')) { + return handleError(new Error(`Invalid supportedVersions expiration:\n URL: ${url} \n RESULT: ${data}`)); + } + } + + resolve(supportedVersions); + }); + response.on('error', function (err) { + handleError(err); + }); + }) + .end(); + }); + + file.addJavaScript({ + data: `exports.supportedVersions = ${JSON.stringify(data)}`, + path: `${file.getPathInPackage()}.js`, + }); + }; + + const processFile = async function (file) { + let output = JSON.parse(file.getContentsAsString()); + output.build = { + date: new Date().toISOString(), + nodeVersion: process.version, + arch: process.arch, + platform: process.platform, + osRelease: os.release(), + totalMemory: os.totalmem(), + freeMemory: os.freemem(), + cpus: os.cpus().length, + }; + + output.marketplaceApiVersion = require('@rocket.chat/apps-engine/package.json').version.replace(/^[^0-9]/g, ''); + const minimumClientVersions = + JSON.parse(fs.readFileSync(path.resolve(process.cwd(), './package.json'), { encoding: 'utf8' }))?.rocketchat + ?.minimumClientVersions || {}; + try { + const result = await execAsync("git log --pretty=format:'%H%n%ad%n%an%n%s' -n 1"); + const data = result.stdout.split('\n'); + output.commit = { + hash: data.shift(), + date: data.shift(), + author: data.shift(), + subject: data.join('\n'), + }; + } catch (e) { + if (process.env.NODE_ENV !== 'development') { + throw e; + } + // no git + } + + try { + const tags = await execAsync('git describe --abbrev=0 --tags'); + if (output.commit) { + output.commit.tag = tags.stdout.replace('\n', ''); + } + } catch (e) { + // no tags + } + + try { + const branch = await execAsync('git rev-parse --abbrev-ref HEAD'); + if (output.commit) { + output.commit.branch = branch.stdout.replace('\n', ''); + } + } catch (e) { + if (process.env.NODE_ENV !== 'development') { + throw e; + } + + // no branch + } + + file.addJavaScript({ + data: `exports.Info = ${JSON.stringify(output, null, 4)}; + exports.minimumClientVersions = ${JSON.stringify(minimumClientVersions, null, 4)};`, + path: `${file.getPathInPackage()}.js`, + }); + }; + + for await (const file of files) { + console.log('Processing file', file.getDisplayPath()); + switch (true) { + case file.getDisplayPath().endsWith('rocketchat.info'): { + await processFile(file); + break; + } + case file.getDisplayPath().endsWith('rocketchat-supported-versions.info'): { + if (process.env.NODE_ENV === 'development') { + file.addJavaScript({ + data: `exports.supportedVersions = {}`, + path: `${file.getPathInPackage()}.js`, + }); + break; + } + await processVersionFile(file); + break; + } + default: { + throw new Error(`Unexpected file ${file.getDisplayPath()}`); + } + } + console.log('Processed file', file.getDisplayPath()); + } + } +} + +Plugin.registerCompiler( + { + extensions: ['info'], + }, + function () { + return new VersionCompiler(); + }, +); diff --git a/apps/meteor/playwright-federation.config.ts b/apps/meteor/playwright-federation.config.ts new file mode 100644 index 0000000..d182052 --- /dev/null +++ b/apps/meteor/playwright-federation.config.ts @@ -0,0 +1,29 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +export default { + globalSetup: require.resolve('./tests/e2e/federation/config/global-setup.ts'), + use: { + headless: true, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + channel: 'chrome', + launchOptions: { + // force GPU hardware acceleration + // (even in headless mode) + args: [ + '--use-gl=egl', + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + '--use-file-for-fake-video-capture=tests/e2e/federation/files/video_mock_for_webcam.y4m', + '--use-file-for-fake-audio-capture=tests/e2e/federation/files/audio_mock.wav', + ], + }, + }, + outputDir: 'tests/e2e/.playwright', + reporter: 'list', + testDir: 'tests/e2e/federation', + workers: 1, + retries: 2, + timeout: 60 * 2000, +} as PlaywrightTestConfig; diff --git a/apps/meteor/playwright.config.ts b/apps/meteor/playwright.config.ts new file mode 100644 index 0000000..2326797 --- /dev/null +++ b/apps/meteor/playwright.config.ts @@ -0,0 +1,73 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +import * as constants from './tests/e2e/config/constants'; + +export default { + globalSetup: require.resolve('./tests/e2e/config/global-setup.ts'), + use: { + channel: 'chromium', + headless: true, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + baseURL: constants.BASE_URL, + screenshot: process.env.CI ? 'off' : 'only-on-failure', + video: process.env.CI ? 'off' : 'retain-on-failure', + launchOptions: { + // force GPU hardware acceleration + // (even in headless mode) + args: ['--use-gl=egl', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'], + }, + permissions: ['microphone'], + }, + outputDir: 'tests/e2e/.playwright', + reporter: [ + ['list'], + process.env.GITHUB_ACTIONS === 'true' && ['github'], + process.env.REPORTER_ROCKETCHAT_REPORT === 'true' && [ + './reporters/rocketchat.ts', + { + url: process.env.REPORTER_ROCKETCHAT_URL, + apiKey: process.env.REPORTER_ROCKETCHAT_API_KEY, + branch: process.env.REPORTER_ROCKETCHAT_BRANCH, + run: Number(process.env.REPORTER_ROCKETCHAT_RUN), + draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true', + headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA, + }, + ], + process.env.REPORTER_ROCKETCHAT_REPORT === 'true' && [ + './reporters/jira.ts', + { + url: `https://rocketchat.atlassian.net`, + apiKey: process.env.REPORTER_JIRA_ROCKETCHAT_API_KEY ?? process.env.JIRA_TOKEN, + branch: process.env.REPORTER_ROCKETCHAT_BRANCH, + run: Number(process.env.REPORTER_ROCKETCHAT_RUN), + headSha: process.env.REPORTER_ROCKETCHAT_HEAD_SHA, + author: process.env.REPORTER_ROCKETCHAT_AUTHOR, + run_url: process.env.REPORTER_ROCKETCHAT_RUN_URL, + pr: Number(process.env.REPORTER_ROCKETCHAT_PR), + draft: process.env.REPORTER_ROCKETCHAT_DRAFT === 'true', + }, + ], + [ + 'playwright-qase-reporter', + { + apiToken: `${process.env.QASE_API_TOKEN}`, + rootSuiteTitle: 'Rocket.chat automation', + projectCode: 'RC', + runComplete: true, + basePath: 'https://api.qase.io/v1', + logging: true, + uploadAttachments: false, + environmentId: '1', + }, + ], + ].filter(Boolean) as unknown as PlaywrightTestConfig['reporter'], + testDir: 'tests/e2e', + testIgnore: 'tests/e2e/federation/**', + workers: 1, + timeout: 60 * 1000, + globalTimeout: (process.env.IS_EE === 'true' ? 50 : 40) * 60 * 1000, + maxFailures: process.env.CI ? 5 : undefined, + // Retry on CI only. + retries: parseInt(String(process.env.PLAYWRIGHT_RETRIES)) || 0, +} as PlaywrightTestConfig; diff --git a/apps/meteor/playwright.prepare.mjs b/apps/meteor/playwright.prepare.mjs new file mode 100644 index 0000000..5f3a20f --- /dev/null +++ b/apps/meteor/playwright.prepare.mjs @@ -0,0 +1,9 @@ +// prepare.js + +import { readFileSync, writeFileSync } from 'fs'; +import path from 'path'; + +const modules = path.resolve(`node_modules`); +const destination = path.join(modules, 'playwright-core', 'lib', 'server', 'chromium', 'crNetworkManager.js'); +const buffer = readFileSync(destination); +writeFileSync(destination, buffer.toString().replace('cacheDisabled: true', 'cacheDisabled: false')); diff --git a/apps/meteor/private/avatars/rocketcat.png b/apps/meteor/private/avatars/rocketcat.png new file mode 100644 index 0000000..7eb7295 Binary files /dev/null and b/apps/meteor/private/avatars/rocketcat.png differ diff --git a/apps/meteor/private/client/imports/general/variables.css b/apps/meteor/private/client/imports/general/variables.css new file mode 120000 index 0000000..5593377 --- /dev/null +++ b/apps/meteor/private/client/imports/general/variables.css @@ -0,0 +1 @@ +../../../../app/theme/client/imports/general/variables.css \ No newline at end of file diff --git a/apps/meteor/private/i18n b/apps/meteor/private/i18n new file mode 120000 index 0000000..ed6082d --- /dev/null +++ b/apps/meteor/private/i18n @@ -0,0 +1 @@ +../../../packages/i18n/dist/resources \ No newline at end of file diff --git a/apps/meteor/private/moment-locales b/apps/meteor/private/moment-locales new file mode 120000 index 0000000..793f634 --- /dev/null +++ b/apps/meteor/private/moment-locales @@ -0,0 +1 @@ +../node_modules/moment/locale \ No newline at end of file diff --git a/apps/meteor/public/enc.js b/apps/meteor/public/enc.js new file mode 100644 index 0000000..b16ee27 --- /dev/null +++ b/apps/meteor/public/enc.js @@ -0,0 +1,136 @@ +self.addEventListener('install', function (event) { + event.waitUntil(self.skipWaiting()); // Activate worker immediately +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); // Become available to all pages +}); + +function base64Decode(string) { + string = atob(string); + const length = string.length, + buf = new ArrayBuffer(length), + bufView = new Uint8Array(buf); + for (var i = 0; i < string.length; i++) { + bufView[i] = string.charCodeAt(i); + } + return buf; +} + +function base64DecodeString(string) { + return atob(string); +} + +const decrypt = async (key, iv, file) => { + const ivArray = base64Decode(iv); + const cryptoKey = await crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']); + const result = await crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file); + + return result; +}; + +const getUrlParams = (url) => { + const urlObj = new URL(url, location.origin); + + const rawKey = urlObj.searchParams.get('key'); + if (!rawKey) { + throw new Error('Missing "key" query param'); + } + + + const k = base64DecodeString(decodeURIComponent(rawKey)); + + urlObj.searchParams.delete('key'); + + const { key, iv, name, type } = JSON.parse(k); + + const newUrl = urlObj.href.replace('/file-decrypt/', '/'); + + return { key, iv, url: newUrl, name, type }; +}; + +self.addEventListener('fetch', (event) => { + if (!event.request.url.includes('/file-decrypt/')) { + return; + } + + try { + const { url, key, iv, name, type } = getUrlParams(event.request.url); + + const requestToFetch = new Request(url, { + ...event.request, + mode: 'cors', + }); + + event.respondWith( + caches.match(requestToFetch).then((response) => { + if (response) { + return response; + } + + return fetch(requestToFetch) + .then(async (res) => { + const file = await res.arrayBuffer(); + + if (res.status !== 200 || file.byteLength === 0) { + console.error('Failed to fetch file', { req: requestToFetch, res }); + return res; + } + + const result = await decrypt(key, iv, file); + + const newHeaders = new Headers(res.headers); + newHeaders.set('Content-Disposition', 'inline; filename="' + name + '"'); + newHeaders.set('Content-Type', type); + + const response = new Response(result, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + + await caches.open('v1').then((cache) => { + cache.put(requestToFetch, response.clone()); + }); + + return response; + }) + .catch((error) => { + console.error('Fetching failed:', error); + + throw error; + }); + }), + ); + } catch (error) { + console.error(error); + throw error; + } +}); + +self.addEventListener('message', async (event) => { + if (event.data.type !== 'attachment-download') { + return; + } + + const requestToFetch = new Request(event.data.url); + + const { url, key, iv } = getUrlParams(event.data.url); + const res = (await caches.match(requestToFetch)) ?? (await fetch(url)); + + const file = await res.arrayBuffer(); + const result = await decrypt(key, iv, file); + event.source.postMessage({ + id: event.data.id, + type: 'attachment-download-result', + result, + }); + // .catch((error) => { + // console.error('Posting message failed:', error); + // event.source.postMessage({ + // id: event.data.id, + // type: 'attachment-download-result', + // error, + // }); + // }); +}); diff --git a/apps/meteor/public/favicon.ico b/apps/meteor/public/favicon.ico new file mode 100644 index 0000000..79ce5a4 Binary files /dev/null and b/apps/meteor/public/favicon.ico differ diff --git a/apps/meteor/public/fonts/InterVariable.woff2 b/apps/meteor/public/fonts/InterVariable.woff2 new file mode 120000 index 0000000..3efff30 --- /dev/null +++ b/apps/meteor/public/fonts/InterVariable.woff2 @@ -0,0 +1 @@ +../../node_modules/@rocket.chat/fuselage/dist/fonts/InterVariable.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_AMS-Regular.ttf b/apps/meteor/public/fonts/KaTeX_AMS-Regular.ttf new file mode 120000 index 0000000..c0d7d0f --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_AMS-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_AMS-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff b/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff new file mode 120000 index 0000000..da2b4ac --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_AMS-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff2 new file mode 120000 index 0000000..f060f6b --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_AMS-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_AMS-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.ttf b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.ttf new file mode 120000 index 0000000..89de95f --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff new file mode 120000 index 0000000..fcbf928 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff2 b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 120000 index 0000000..04bb067 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Bold.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.ttf new file mode 120000 index 0000000..08e2096 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff new file mode 120000 index 0000000..0ea54c0 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 120000 index 0000000..9db5c09 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Caligraphic-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.ttf b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.ttf new file mode 120000 index 0000000..a078ac3 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff new file mode 120000 index 0000000..d9f6f07 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff2 b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 120000 index 0000000..37e0c35 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Bold.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.ttf new file mode 120000 index 0000000..bbcdbe5 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff new file mode 120000 index 0000000..c760c9f --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 120000 index 0000000..bb257c3 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Fraktur-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Bold.ttf b/apps/meteor/public/fonts/KaTeX_Main-Bold.ttf new file mode 120000 index 0000000..ca7bf12 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Bold.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Bold.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Bold.woff b/apps/meteor/public/fonts/KaTeX_Main-Bold.woff new file mode 120000 index 0000000..4e0d94c --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Bold.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Bold.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Bold.woff2 b/apps/meteor/public/fonts/KaTeX_Main-Bold.woff2 new file mode 120000 index 0000000..575ec41 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Bold.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Bold.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.ttf b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.ttf new file mode 120000 index 0000000..385d6cb --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff new file mode 120000 index 0000000..653b4b8 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff2 b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 120000 index 0000000..c776492 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-BoldItalic.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Italic.ttf b/apps/meteor/public/fonts/KaTeX_Main-Italic.ttf new file mode 120000 index 0000000..dab1ac4 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Italic.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Italic.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Italic.woff b/apps/meteor/public/fonts/KaTeX_Main-Italic.woff new file mode 120000 index 0000000..c624c64 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Italic.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Italic.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Italic.woff2 b/apps/meteor/public/fonts/KaTeX_Main-Italic.woff2 new file mode 120000 index 0000000..56c5cf9 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Italic.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Italic.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Main-Regular.ttf new file mode 120000 index 0000000..51c9c58 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Regular.woff b/apps/meteor/public/fonts/KaTeX_Main-Regular.woff new file mode 120000 index 0000000..430f69e --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Main-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Main-Regular.woff2 new file mode 120000 index 0000000..9cf0ae8 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Main-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Main-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.ttf b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.ttf new file mode 120000 index 0000000..0084dff --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff new file mode 120000 index 0000000..5c92512 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff2 b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 120000 index 0000000..4f3364d --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-BoldItalic.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-Italic.ttf b/apps/meteor/public/fonts/KaTeX_Math-Italic.ttf new file mode 120000 index 0000000..ca650ee --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-Italic.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-Italic.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-Italic.woff b/apps/meteor/public/fonts/KaTeX_Math-Italic.woff new file mode 120000 index 0000000..8fb3423 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-Italic.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-Italic.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Math-Italic.woff2 b/apps/meteor/public/fonts/KaTeX_Math-Italic.woff2 new file mode 120000 index 0000000..b7a2cb7 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Math-Italic.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Math-Italic.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.ttf b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.ttf new file mode 120000 index 0000000..7cfc095 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff new file mode 120000 index 0000000..e12b417 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff2 b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 120000 index 0000000..452ded6 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Bold.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.ttf b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.ttf new file mode 120000 index 0000000..5517457 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff new file mode 120000 index 0000000..dd3f6e7 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff2 b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 120000 index 0000000..a7c8314 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Italic.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.ttf b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.ttf new file mode 120000 index 0000000..ffffbc3 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff new file mode 120000 index 0000000..eb750ff --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 120000 index 0000000..5468686 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_SansSerif-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Script-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Script-Regular.ttf new file mode 120000 index 0000000..1f240ee --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Script-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Script-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Script-Regular.woff b/apps/meteor/public/fonts/KaTeX_Script-Regular.woff new file mode 120000 index 0000000..85861d1 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Script-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Script-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Script-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Script-Regular.woff2 new file mode 120000 index 0000000..143ab69 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Script-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Script-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size1-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Size1-Regular.ttf new file mode 120000 index 0000000..1f5870a --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size1-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size1-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff b/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff new file mode 120000 index 0000000..1a5f7d2 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size1-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff2 new file mode 120000 index 0000000..b2946f6 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size1-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size1-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size2-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Size2-Regular.ttf new file mode 120000 index 0000000..31d4cf7 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size2-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size2-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff b/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff new file mode 120000 index 0000000..2e37277 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size2-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff2 new file mode 120000 index 0000000..5eb3553 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size2-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size2-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size3-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Size3-Regular.ttf new file mode 120000 index 0000000..40a7c3e --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size3-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size3-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff b/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff new file mode 120000 index 0000000..97e35a9 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size3-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff2 new file mode 120000 index 0000000..d425f15 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size3-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size3-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size4-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Size4-Regular.ttf new file mode 120000 index 0000000..1ba0962 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size4-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size4-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff b/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff new file mode 120000 index 0000000..32d318b --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size4-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff2 new file mode 120000 index 0000000..f568234 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Size4-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Size4-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.ttf b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.ttf new file mode 120000 index 0000000..ef603b9 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.ttf @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.ttf \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff new file mode 120000 index 0000000..e2a22d4 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.woff \ No newline at end of file diff --git a/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff2 b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 120000 index 0000000..d82a8c6 --- /dev/null +++ b/apps/meteor/public/fonts/KaTeX_Typewriter-Regular.woff2 @@ -0,0 +1 @@ +../../node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.woff2 \ No newline at end of file diff --git a/apps/meteor/public/fonts/NotoSansGeorgian-Regular.ttf b/apps/meteor/public/fonts/NotoSansGeorgian-Regular.ttf new file mode 100644 index 0000000..8f3e75d Binary files /dev/null and b/apps/meteor/public/fonts/NotoSansGeorgian-Regular.ttf differ diff --git a/apps/meteor/public/fonts/NotoSansHebrew-Regular.ttf b/apps/meteor/public/fonts/NotoSansHebrew-Regular.ttf new file mode 100644 index 0000000..f795633 Binary files /dev/null and b/apps/meteor/public/fonts/NotoSansHebrew-Regular.ttf differ diff --git a/apps/meteor/public/fonts/NotoSansLao-Regular.ttf b/apps/meteor/public/fonts/NotoSansLao-Regular.ttf new file mode 100644 index 0000000..7485ca1 Binary files /dev/null and b/apps/meteor/public/fonts/NotoSansLao-Regular.ttf differ diff --git a/apps/meteor/public/fonts/NotoSansTamil-Regular.ttf b/apps/meteor/public/fonts/NotoSansTamil-Regular.ttf new file mode 100644 index 0000000..0400d8f Binary files /dev/null and b/apps/meteor/public/fonts/NotoSansTamil-Regular.ttf differ diff --git a/apps/meteor/public/fonts/NotoSansThai-Regular.ttf b/apps/meteor/public/fonts/NotoSansThai-Regular.ttf new file mode 100644 index 0000000..f17b134 Binary files /dev/null and b/apps/meteor/public/fonts/NotoSansThai-Regular.ttf differ diff --git a/apps/meteor/public/fonts/rocketchat.woff2 b/apps/meteor/public/fonts/rocketchat.woff2 new file mode 120000 index 0000000..1ef2c05 --- /dev/null +++ b/apps/meteor/public/fonts/rocketchat.woff2 @@ -0,0 +1 @@ +../../node_modules/@rocket.chat/icons/dist/font/rocketchat.woff2 \ No newline at end of file diff --git a/apps/meteor/public/images/404.svg b/apps/meteor/public/images/404.svg new file mode 100644 index 0000000..329621b --- /dev/null +++ b/apps/meteor/public/images/404.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/meteor/public/images/browserconfig.xml b/apps/meteor/public/images/browserconfig.xml new file mode 100644 index 0000000..7078a0d --- /dev/null +++ b/apps/meteor/public/images/browserconfig.xml @@ -0,0 +1,13 @@ + + + + + + + + + + #db2323 + + + diff --git a/apps/meteor/public/images/conf-call-config.svg b/apps/meteor/public/images/conf-call-config.svg new file mode 100644 index 0000000..8fa2616 --- /dev/null +++ b/apps/meteor/public/images/conf-call-config.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/meteor/public/images/custom-categories.png b/apps/meteor/public/images/custom-categories.png new file mode 100644 index 0000000..734037c Binary files /dev/null and b/apps/meteor/public/images/custom-categories.png differ diff --git a/apps/meteor/public/images/device-management.png b/apps/meteor/public/images/device-management.png new file mode 100644 index 0000000..e696c23 Binary files /dev/null and b/apps/meteor/public/images/device-management.png differ diff --git a/apps/meteor/public/images/engagement.png b/apps/meteor/public/images/engagement.png new file mode 100644 index 0000000..2a60e01 Binary files /dev/null and b/apps/meteor/public/images/engagement.png differ diff --git a/apps/meteor/public/images/featurePreview/enhanced-navigation.png b/apps/meteor/public/images/featurePreview/enhanced-navigation.png new file mode 100644 index 0000000..4240326 Binary files /dev/null and b/apps/meteor/public/images/featurePreview/enhanced-navigation.png differ diff --git a/apps/meteor/public/images/featurePreview/secondary-sidebar.png b/apps/meteor/public/images/featurePreview/secondary-sidebar.png new file mode 100644 index 0000000..fa231c9 Binary files /dev/null and b/apps/meteor/public/images/featurePreview/secondary-sidebar.png differ diff --git a/apps/meteor/public/images/gallery-skin.svg b/apps/meteor/public/images/gallery-skin.svg new file mode 100644 index 0000000..17bb456 --- /dev/null +++ b/apps/meteor/public/images/gallery-skin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/meteor/public/images/integration-attachment-example.png b/apps/meteor/public/images/integration-attachment-example.png new file mode 100644 index 0000000..471b599 Binary files /dev/null and b/apps/meteor/public/images/integration-attachment-example.png differ diff --git a/apps/meteor/public/images/join-chat.svg b/apps/meteor/public/images/join-chat.svg new file mode 100644 index 0000000..7faac52 --- /dev/null +++ b/apps/meteor/public/images/join-chat.svg @@ -0,0 +1 @@ +Rocket.ChatRocket.ChatJOIN CHATJOIN CHAT \ No newline at end of file diff --git a/apps/meteor/public/images/logo/1024x1024.png b/apps/meteor/public/images/logo/1024x1024.png new file mode 100644 index 0000000..3e54150 Binary files /dev/null and b/apps/meteor/public/images/logo/1024x1024.png differ diff --git a/apps/meteor/public/images/logo/android-chrome-192x192.png b/apps/meteor/public/images/logo/android-chrome-192x192.png new file mode 100644 index 0000000..34d4670 Binary files /dev/null and b/apps/meteor/public/images/logo/android-chrome-192x192.png differ diff --git a/apps/meteor/public/images/logo/android-chrome-512x512.png b/apps/meteor/public/images/logo/android-chrome-512x512.png new file mode 100644 index 0000000..d03b30b Binary files /dev/null and b/apps/meteor/public/images/logo/android-chrome-512x512.png differ diff --git a/apps/meteor/public/images/logo/apple-touch-icon-precomposed.png b/apps/meteor/public/images/logo/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..7ffd842 Binary files /dev/null and b/apps/meteor/public/images/logo/apple-touch-icon-precomposed.png differ diff --git a/apps/meteor/public/images/logo/apple-touch-icon.png b/apps/meteor/public/images/logo/apple-touch-icon.png new file mode 100644 index 0000000..201997e Binary files /dev/null and b/apps/meteor/public/images/logo/apple-touch-icon.png differ diff --git a/apps/meteor/public/images/logo/favicon-16x16.png b/apps/meteor/public/images/logo/favicon-16x16.png new file mode 100644 index 0000000..0ba4748 Binary files /dev/null and b/apps/meteor/public/images/logo/favicon-16x16.png differ diff --git a/apps/meteor/public/images/logo/favicon-32x32.png b/apps/meteor/public/images/logo/favicon-32x32.png new file mode 100644 index 0000000..a5b325f Binary files /dev/null and b/apps/meteor/public/images/logo/favicon-32x32.png differ diff --git a/apps/meteor/public/images/logo/icon.svg b/apps/meteor/public/images/logo/icon.svg new file mode 100644 index 0000000..8c1fd74 --- /dev/null +++ b/apps/meteor/public/images/logo/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/meteor/public/images/logo/logo.png b/apps/meteor/public/images/logo/logo.png new file mode 100644 index 0000000..e97eb5b Binary files /dev/null and b/apps/meteor/public/images/logo/logo.png differ diff --git a/apps/meteor/public/images/logo/logo.svg b/apps/meteor/public/images/logo/logo.svg new file mode 100644 index 0000000..681fc26 --- /dev/null +++ b/apps/meteor/public/images/logo/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/meteor/public/images/logo/logo_dark.png b/apps/meteor/public/images/logo/logo_dark.png new file mode 100644 index 0000000..e97eb5b Binary files /dev/null and b/apps/meteor/public/images/logo/logo_dark.png differ diff --git a/apps/meteor/public/images/logo/logo_dark.svg b/apps/meteor/public/images/logo/logo_dark.svg new file mode 100644 index 0000000..681fc26 --- /dev/null +++ b/apps/meteor/public/images/logo/logo_dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/meteor/public/images/logo/mstile-144x144.png b/apps/meteor/public/images/logo/mstile-144x144.png new file mode 100644 index 0000000..1cb7eca Binary files /dev/null and b/apps/meteor/public/images/logo/mstile-144x144.png differ diff --git a/apps/meteor/public/images/logo/mstile-150x150.png b/apps/meteor/public/images/logo/mstile-150x150.png new file mode 100644 index 0000000..3665605 Binary files /dev/null and b/apps/meteor/public/images/logo/mstile-150x150.png differ diff --git a/apps/meteor/public/images/logo/mstile-310x150.png b/apps/meteor/public/images/logo/mstile-310x150.png new file mode 100644 index 0000000..b3d9d96 Binary files /dev/null and b/apps/meteor/public/images/logo/mstile-310x150.png differ diff --git a/apps/meteor/public/images/logo/mstile-310x310.png b/apps/meteor/public/images/logo/mstile-310x310.png new file mode 100644 index 0000000..44f0270 Binary files /dev/null and b/apps/meteor/public/images/logo/mstile-310x310.png differ diff --git a/apps/meteor/public/images/logo/mstile-70x70.png b/apps/meteor/public/images/logo/mstile-70x70.png new file mode 100644 index 0000000..970912e Binary files /dev/null and b/apps/meteor/public/images/logo/mstile-70x70.png differ diff --git a/apps/meteor/public/images/logo/safari-pinned-tab.svg b/apps/meteor/public/images/logo/safari-pinned-tab.svg new file mode 100644 index 0000000..e905f46 --- /dev/null +++ b/apps/meteor/public/images/logo/safari-pinned-tab.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/meteor/public/images/manifest.json b/apps/meteor/public/images/manifest.json new file mode 100644 index 0000000..b846cad --- /dev/null +++ b/apps/meteor/public/images/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "Rocket.Chat", + "short_name": "Rocket.Chat", + "background_color": "#fff", + "icons": [ + { + "src": "/assets/favicon_192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/favicon_512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "/home?homescreen", + "display": "standalone" +} diff --git a/apps/meteor/public/images/single-contact-id-upsell.png b/apps/meteor/public/images/single-contact-id-upsell.png new file mode 100644 index 0000000..c7fb7d3 Binary files /dev/null and b/apps/meteor/public/images/single-contact-id-upsell.png differ diff --git a/apps/meteor/public/manifest.webapp b/apps/meteor/public/manifest.webapp new file mode 100644 index 0000000..9744b1d --- /dev/null +++ b/apps/meteor/public/manifest.webapp @@ -0,0 +1,17 @@ +{ + "name": "RocketChat", + "description": "The Ultimate Open Source WebChat Platform", + "launch_path": "/", + "icons": { + "192": "/assets/favicon_192.png", + "512": "/assets/favicon_512.png" + }, + "developer": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "fullscreen": "true", + "chrome": { + "navigation": false + } +} diff --git a/apps/meteor/public/sounds/beep.mp3 b/apps/meteor/public/sounds/beep.mp3 new file mode 100644 index 0000000..cd3a3ec Binary files /dev/null and b/apps/meteor/public/sounds/beep.mp3 differ diff --git a/apps/meteor/public/sounds/call-ended.mp3 b/apps/meteor/public/sounds/call-ended.mp3 new file mode 100644 index 0000000..df756fc Binary files /dev/null and b/apps/meteor/public/sounds/call-ended.mp3 differ diff --git a/apps/meteor/public/sounds/chelle.mp3 b/apps/meteor/public/sounds/chelle.mp3 new file mode 100644 index 0000000..52c1665 Binary files /dev/null and b/apps/meteor/public/sounds/chelle.mp3 differ diff --git a/apps/meteor/public/sounds/chime.mp3 b/apps/meteor/public/sounds/chime.mp3 new file mode 100644 index 0000000..a874282 Binary files /dev/null and b/apps/meteor/public/sounds/chime.mp3 differ diff --git a/apps/meteor/public/sounds/ding.mp3 b/apps/meteor/public/sounds/ding.mp3 new file mode 100644 index 0000000..53fd051 Binary files /dev/null and b/apps/meteor/public/sounds/ding.mp3 differ diff --git a/apps/meteor/public/sounds/door.mp3 b/apps/meteor/public/sounds/door.mp3 new file mode 100644 index 0000000..fedb4d9 Binary files /dev/null and b/apps/meteor/public/sounds/door.mp3 differ diff --git a/apps/meteor/public/sounds/droplet.mp3 b/apps/meteor/public/sounds/droplet.mp3 new file mode 100644 index 0000000..aa9e970 Binary files /dev/null and b/apps/meteor/public/sounds/droplet.mp3 differ diff --git a/apps/meteor/public/sounds/highbell.mp3 b/apps/meteor/public/sounds/highbell.mp3 new file mode 100644 index 0000000..c5be35b Binary files /dev/null and b/apps/meteor/public/sounds/highbell.mp3 differ diff --git a/apps/meteor/public/sounds/outbound-call-ringing.mp3 b/apps/meteor/public/sounds/outbound-call-ringing.mp3 new file mode 100644 index 0000000..a67e889 Binary files /dev/null and b/apps/meteor/public/sounds/outbound-call-ringing.mp3 differ diff --git a/apps/meteor/public/sounds/seasons.mp3 b/apps/meteor/public/sounds/seasons.mp3 new file mode 100644 index 0000000..62cafb0 Binary files /dev/null and b/apps/meteor/public/sounds/seasons.mp3 differ diff --git a/apps/meteor/public/sounds/telephone.mp3 b/apps/meteor/public/sounds/telephone.mp3 new file mode 100644 index 0000000..61deeaf Binary files /dev/null and b/apps/meteor/public/sounds/telephone.mp3 differ diff --git a/apps/meteor/public/voice-call-popup.html b/apps/meteor/public/voice-call-popup.html new file mode 120000 index 0000000..b800ef4 --- /dev/null +++ b/apps/meteor/public/voice-call-popup.html @@ -0,0 +1 @@ +../node_modules/@rocket.chat/ui-voip/dist/voice-call-popup.html \ No newline at end of file diff --git a/apps/meteor/public/workers/index.js b/apps/meteor/public/workers/index.js new file mode 100644 index 0000000..88df0d8 --- /dev/null +++ b/apps/meteor/public/workers/index.js @@ -0,0 +1,108 @@ +const HTMLToCache = '/'; +const version = 'MSW V0.3'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(version).then((cache) => { + cache.add(HTMLToCache).then(self.skipWaiting()); + }), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((cacheNames) => + Promise.all( + cacheNames.map((cacheName) => { + if (version !== cacheName) return caches.delete(cacheName); + }), + ), + ) + .then(self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const requestToFetch = event.request.clone(); + event.respondWith( + caches.match(event.request.clone()).then((cached) => { + // We don't return cached HTML (except if fetch failed) + if (cached) { + const resourceType = cached.headers.get('content-type'); + // We only return non css/js/html cached response e.g images + if (!hasHash(event.request.url) && !/text\/html/.test(resourceType)) { + return cached; + } + + // If the CSS/JS didn't change since it's been cached, return the cached version + if (hasHash(event.request.url) && hasSameHash(event.request.url, cached.url)) { + return cached; + } + } + return fetch(requestToFetch) + .then((response) => { + const clonedResponse = response.clone(); + const contentType = clonedResponse.headers.get('content-type'); + + if ( + !clonedResponse || + clonedResponse.status !== 200 || + clonedResponse.type !== 'basic' || + /\/sockjs\//.test(event.request.url) + ) { + return response; + } + + if (/html/.test(contentType)) { + caches.open(version).then((cache) => cache.put(HTMLToCache, clonedResponse)); + } else { + // Delete old version of a file + if (hasHash(event.request.url)) { + caches.open(version).then((cache) => + cache.keys().then((keys) => + keys.forEach((asset) => { + if (new RegExp(removeHash(event.request.url)).test(removeHash(asset.url))) { + cache.delete(asset); + } + }), + ), + ); + } + + caches.open(version).then((cache) => cache.put(event.request, clonedResponse)); + } + return response; + }) + .catch(() => { + if (hasHash(event.request.url)) return caches.match(event.request.url); + // If the request URL hasn't been served from cache and isn't sockjs we suppose it's HTML + else if (!/\/sockjs\//.test(event.request.url)) return caches.match(HTMLToCache); + // Only for sockjs + return new Response('No connection to the server', { + status: 503, + statusText: 'No connection to the server', + headers: new Headers({ 'Content-Type': 'text/plain' }), + }); + }); + }), + ); +}); + +function removeHash(element) { + if (typeof element === 'string') return element.split('?hash=')[0]; +} + +function hasHash(element) { + if (typeof element === 'string') return /\?hash=.*/.test(element); +} + +function hasSameHash(firstUrl, secondUrl) { + if (typeof firstUrl === 'string' && typeof secondUrl === 'string') { + return /\?hash=(.*)/.exec(firstUrl)[1] === /\?hash=(.*)/.exec(secondUrl)[1]; + } +} + +// Service worker created by Ilan Schemoul alias NitroBAY as a specific Service Worker for Meteor +// Please see https://github.com/NitroBAY/meteor-service-worker for the official project source diff --git a/apps/meteor/public/workers/mp3-encoder/index.js b/apps/meteor/public/workers/mp3-encoder/index.js new file mode 120000 index 0000000..3d78b5a --- /dev/null +++ b/apps/meteor/public/workers/mp3-encoder/index.js @@ -0,0 +1 @@ +../../../node_modules/@rocket.chat/mp3-encoder/dist/index.js \ No newline at end of file diff --git a/apps/meteor/public/workers/mp3-encoder/index.js.map b/apps/meteor/public/workers/mp3-encoder/index.js.map new file mode 120000 index 0000000..4a2b2c4 --- /dev/null +++ b/apps/meteor/public/workers/mp3-encoder/index.js.map @@ -0,0 +1 @@ +../../../node_modules/@rocket.chat/mp3-encoder/dist/index.js.map \ No newline at end of file diff --git a/apps/meteor/publish_packages.sh b/apps/meteor/publish_packages.sh new file mode 100755 index 0000000..073ce1c --- /dev/null +++ b/apps/meteor/publish_packages.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -x +set -euvo pipefail +IFS=$'\n\t' + +for d in packages/* ; do + echo "$d" + cd $d + meteor publish + cd ../../ +done diff --git a/apps/meteor/reporters/jira.ts b/apps/meteor/reporters/jira.ts new file mode 100644 index 0000000..1691981 --- /dev/null +++ b/apps/meteor/reporters/jira.ts @@ -0,0 +1,268 @@ +import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter'; +import fetch from 'node-fetch'; + +const LOG = '[JIRA reporter]'; + +const escapeJqlTextSearch = (value: string): string => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + +export const createJiraSearchParams = (summary: string) => + new URLSearchParams({ + jql: `project = FLAKY AND summary ~ '"${escapeJqlTextSearch(summary)}"'`, + fields: 'summary', + }); + +/** Jira REST API v3 expects `description` as Atlassian Document Format, not a plain string. */ +const EMPTY_ADF_DESCRIPTION = { + type: 'doc', + version: 1, + content: [] as const, +}; + +type JiraCommentDetails = { + run: number; + author: string; + pr: number; + testUrl: string; + runUrl: string; +}; + +export const createJiraCommentBody = ({ run, author, pr, testUrl, runUrl }: JiraCommentDetails) => ({ + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: `Test run ${run} failed` }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: `author: ${author}` }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: `PR: ${pr}` }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: testUrl, marks: [{ type: 'link', attrs: { href: testUrl } }] }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: runUrl, marks: [{ type: 'link', attrs: { href: runUrl } }] }], + }, + ], +}); + +class JIRAReporter implements Reporter { + private url: string; + + private apiKey: string; + + private branch: string; + + private draft: boolean; + + private run: number; + + private headSha: string; + + private author: string; + + private run_url: string; + + private pr: number; + + constructor(options: { + url: string; + apiKey: string; + branch: string; + draft: boolean; + run: number; + headSha: string; + author: string; + run_url: string; + pr: number; + }) { + this.url = options.url; + this.apiKey = options.apiKey; + this.branch = options.branch; + this.draft = options.draft; + this.run = options.run; + this.headSha = options.headSha; + this.author = options.author; + this.run_url = options.run_url; + this.pr = options.pr; + } + + private static async ensureJiraOk(response: Awaited>, context: string): Promise { + if (response.ok) { + return; + } + const text = await response.text(); + const preview = text.length > 800 ? `${text.slice(0, 800)}...` : text; + throw new Error(`${LOG} ${context}: HTTP ${response.status} ${response.statusText}. Body: ${preview}`); + } + + private async postComment(issue: string, test: TestCase, payload: { run: number; headSha: string }): Promise { + const { location } = test; + const testUrl = `https://github.com/RocketChat/Rocket.Chat/blob/${payload.headSha}${location.file.replace( + '/home/runner/work/Rocket.Chat/Rocket.Chat', + '', + )}#L${location.line}`; + + const commentRes = await fetch(`${this.url}/rest/api/3/issue/${issue}/comment`, { + method: 'POST', + body: JSON.stringify({ + body: createJiraCommentBody({ + run: payload.run, + author: this.author, + pr: this.pr, + testUrl, + runUrl: this.run_url, + }), + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${this.apiKey}`, + }, + }); + await JIRAReporter.ensureJiraOk(commentRes, `comment on ${issue}`); + } + + async onTestEnd(test: TestCase, result: TestResult) { + try { + await this._onTestEnd(test, result); + } catch (error) { + console.error(`${LOG} Error sending test result to JIRA`, error); + } + } + + private async _onTestEnd(test: TestCase, result: TestResult) { + if (process.env.REPORTER_ROCKETCHAT_REPORT !== 'true') { + return; + } + + if (this.draft === true) { + return; + } + + if (result.status === 'passed' || result.status === 'skipped') { + return; + } + + if (test.expectedStatus === 'failed') { + return; + } + + const payload = { + name: test.title, + status: result.status, + duration: result.duration, + branch: this.branch, + draft: this.draft, + run: this.run, + headSha: this.headSha, + }; + + console.log(`${LOG} preparing notification for flaky/unexpected failure: ${JSON.stringify(payload)}`); + + // first search and check if there is an existing issue + const search = await fetch(`${this.url}/rest/api/3/search/jql?${createJiraSearchParams(payload.name)}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${this.apiKey}`, + }, + }); + + await JIRAReporter.ensureJiraOk(search, 'search for existing issue'); + + const { issues } = JSON.parse(await search.text()) as { + issues: { key: string; fields: { summary: string } }[]; + }; + + console.log(`${LOG} JQL search returned ${issues.length} candidate issue(s) (exact summary match is applied next).`); + + const existing = issues.find((issue) => issue.fields.summary === payload.name); + + if (existing) { + console.log(`${LOG} exact summary match on ${existing.key}; no new issue will be created (comment / label only).`); + + if (this.pr === 0) { + const labelRes = await fetch(`${this.url}/rest/api/3/issue/${existing.key}`, { + method: 'PUT', + body: JSON.stringify({ + update: { + labels: [ + { + add: 'flaky_Develop', + }, + ], + }, + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${this.apiKey}`, + }, + }); + await JIRAReporter.ensureJiraOk(labelRes, `add label flaky_Develop on ${existing.key}`); + console.log(`${LOG} label update OK for ${existing.key}`); + } + + await this.postComment(existing.key, test, payload); + console.log(`${LOG} comment posted on ${existing.key} for run ${payload.run}.`); + return; + } + + console.log(`${LOG} no issue with identical summary; creating new FLAKY issue.`); + + const data: { + fields: { + summary: string; + description: typeof EMPTY_ADF_DESCRIPTION; + issuetype: { + name: string; + }; + project: { + key: string; + }; + }; + } = { + fields: { + summary: payload.name, + description: EMPTY_ADF_DESCRIPTION, + issuetype: { + name: 'Tech Debt', + }, + project: { + key: 'FLAKY', + }, + ...(this.pr === 0 && { labels: ['flaky_Develop'] }), + }, + }; + + const responseIssue = await fetch(`${this.url}/rest/api/3/issue`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${this.apiKey}`, + }, + }); + + await JIRAReporter.ensureJiraOk(responseIssue, 'create issue'); + + const created = JSON.parse(await responseIssue.text()) as { key?: string }; + const issue = created.key; + if (!issue) { + throw new Error(`${LOG} create issue response had no key: ${JSON.stringify(created)}`); + } + + console.log(`${LOG} created issue ${issue}.`); + + await this.postComment(issue, test, payload); + console.log(`${LOG} comment posted on ${issue}; done for run ${payload.run}.`); + } +} + +export default JIRAReporter; diff --git a/apps/meteor/reporters/rocketchat.ts b/apps/meteor/reporters/rocketchat.ts new file mode 100644 index 0000000..82d8b43 --- /dev/null +++ b/apps/meteor/reporters/rocketchat.ts @@ -0,0 +1,59 @@ +import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter'; +import fetch from 'node-fetch'; + +class RocketChatReporter implements Reporter { + private url: string; + + private apiKey: string; + + private branch: string; + + private draft: boolean; + + private run: number; + + constructor(options: { url: string; apiKey: string; branch: string; draft: boolean; run: number }) { + this.url = options.url; + this.apiKey = options.apiKey; + this.branch = options.branch; + this.draft = options.draft; + this.run = options.run; + } + + async onTestEnd(test: TestCase, result: TestResult) { + if (process.env.REPORTER_ROCKETCHAT_REPORT !== 'true') { + console.log('REPORTER_ROCKETCHAT_REPORT is not true, skipping', { + draft: this.draft, + branch: this.branch, + }); + return; + } + const payload = { + name: test.title, + status: result.status, + duration: result.duration, + branch: this.branch, + draft: this.draft, + run: this.run, + }; + + try { + const res = await fetch(this.url, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': this.apiKey, + }, + }); + + if (!res.ok) { + console.error('Error sending test result to Rocket.Chat', JSON.stringify(payload), res); + } + } catch (error) { + console.error('Unknown error while sending test result to Rocket.Chat', JSON.stringify(payload), error); + } + } +} + +export default RocketChatReporter; diff --git a/apps/meteor/scalingo.json b/apps/meteor/scalingo.json new file mode 100644 index 0000000..48cf28c --- /dev/null +++ b/apps/meteor/scalingo.json @@ -0,0 +1,14 @@ +{ + "name": "Rocket.Chat", + "repository": "https://github.com/RocketChat/Rocket.Chat", + "description": "Have your own open-source Slack-like online chat, built with Meteor.", + "logo": "https://raw.githubusercontent.com/RocketChat/Rocket.Chat/master/public/images/logo/512x512.png?v=3", + "website": "https://rocket.chat", + "addons": ["scalingo-mongodb"], + "env": { + "DEPLOY_PLATFORM": { + "description": "Platform name", + "value": "scalingo" + } + } +} diff --git a/apps/meteor/server/api/ApiClass.spec.ts b/apps/meteor/server/api/ApiClass.spec.ts new file mode 100644 index 0000000..4edc2dd --- /dev/null +++ b/apps/meteor/server/api/ApiClass.spec.ts @@ -0,0 +1,61 @@ +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import type { ValidateFunction } from 'ajv'; + +import type { APIClass, ExtractRoutesFromAPI } from './ApiClass'; + +type Expect = T; +type ShallowEqual = X extends Y ? (Y extends X ? true : false) : false; + +type API = APIClass< + '/v1', + { + method: 'GET'; + path: '/v1/endpoint.test'; + response: { + 200: ValidateFunction< + PaginatedResult<{ + sounds: string[]; + }> + >; + }; + query: ValidateFunction<{ + query: string; + }>; + authRequired: true; + } +>; + +it('Should return the expected type', () => { + type test = Expect< + ShallowEqual< + ReturnType['/v1/endpoint.test']['GET']>, + { + count: number; + offset: number; + total: number; + sounds: string[]; + } + > + >; + true as test; +}); + +describe('ExtractRoutesFromAPI', () => { + it('Should extract correct function signature when query is not present', () => { + type APIWithNeverQuery = APIClass< + '/v1', + { + method: 'GET'; + path: '/v1/endpoint.test'; + response: { + 200: ValidateFunction; + }; + authRequired: true; + } + >; + type ExpectedFunctionSignature = Expect< + ShallowEqual['/v1/endpoint.test']['GET'], () => unknown> + >; + true as ExpectedFunctionSignature; + }); +}); diff --git a/apps/meteor/server/api/ApiClass.ts b/apps/meteor/server/api/ApiClass.ts new file mode 100644 index 0000000..bf52506 --- /dev/null +++ b/apps/meteor/server/api/ApiClass.ts @@ -0,0 +1,1245 @@ +import type { IMethodConnection, IUser } from '@rocket.chat/core-typings'; +import type { Route, Router } from '@rocket.chat/http-router'; +import { License } from '@rocket.chat/license'; +import { Logger } from '@rocket.chat/logger'; +import { Sessions, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; +import type { ValidateFunction } from 'ajv'; +import { Accounts } from 'meteor/accounts-base'; +import { DDP } from 'meteor/ddp'; +// eslint-disable-next-line import-x/no-duplicates +import { DDPCommon } from 'meteor/ddp-common'; +import { Meteor } from 'meteor/meteor'; +import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; +// eslint-disable-next-line import-x/no-duplicates +import { RateLimiter } from 'meteor/rate-limit'; +import _ from 'underscore'; + +import { canBypassRateLimit, checkPermissions, parseDeprecation } from './api.helpers'; +import type { + FailureResult, + ForbiddenResult, + InnerAction, + InternalError, + NotFoundResult, + Operations, + Options, + RateLimiterOptions, + SuccessResult, + TypedThis, + TypedAction, + TypedOptions, + UnauthorizedResult, + RedirectStatusCodes, + RedirectResult, + UnavailableResult, + GenericRouteExecutionContext, + TooManyRequestsResult, + SuccessStatusCodes, +} from './definition'; +import { getUserInfo } from './lib/getUserInfo'; +import { parseJsonQuery } from './lib/parseJsonQuery'; +import type { APIActionContext } from './router'; +import { RocketChatAPIRouter } from './router'; +import { isObject } from '../../lib/utils/isObject'; +import { checkCodeForUser } from '../lib/2fa/code'; +import { getNestedProp } from '../lib/getNestedProp'; +import { notifyOnUserChangeAsync } from '../lib/notifyListener'; +import { shouldBreakInVersion } from '../lib/shouldBreakInVersion'; +import { authenticationMiddlewareForHono } from './v1/middlewares/authenticationHono'; +import { permissionsMiddleware } from './v1/middlewares/permissions'; +import { license } from '../../ee/server/api/v1/middlewares/license'; +import { getDefaultUserFields } from '../lib/utils/functions/getDefaultUserFields'; +import { settings } from '../settings'; + +const logger = new Logger('API'); + +// We have some breaking changes planned to the API. +// To avoid conflicts or missing something during the period we are adopting a 'feature flag approach' +// TODO: MAJOR check if this is still needed +export const applyBreakingChanges = shouldBreakInVersion('9.0.0'); +type MinimalRoute = { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; +} & TypedOptions; + +export type Prettify = { + [K in keyof T]: T[K]; +} & unknown; + +type ExtractValidation = T extends ValidateFunction ? TSchema : never; + +type UnionToIntersection = (U extends any ? (x: U) => any : never) extends (x: infer I) => any ? I : never; + +export type ExtractRoutesFromAPI = Prettify< + UnionToIntersection< + T extends APIClass ? (TOperations extends MinimalRoute ? Prettify> : never) : never + > +>; + +type ConvertToRoute = { + [K in TRoute['path']]: { + [K2 in Extract['method']]: K2 extends 'GET' | 'DELETE' + ? ( + ...args: [ExtractValidation['query']>] extends [never] + ? [params?: never] + : [params: ExtractValidation['query']>] + ) => ExtractValidation['response'][200]> + : K2 extends 'POST' | 'PUT' + ? ( + params: ExtractValidation['body']>, + ) => ExtractValidation< + 200 extends keyof Extract['response'] + ? Extract['response'][200] + : 201 extends keyof Extract['response'] + ? Extract['response'][201] + : never + > + : never; + }; +}; + +interface IAPIProperties { + useDefaultAuth: boolean; + prettyJson: boolean; + version?: string; + enableCors?: boolean; + apiPath?: string; +} + +interface IAPIDefaultFieldsToExclude { + avatarOrigin: number; + emails: number; + phone: number; + statusConnection: number; + createdAt: number; + lastLogin: number; + services: number; + requirePasswordChange: number; + requirePasswordChangeReason: number; + roles: number; + statusDefault: number; + _updatedAt: number; + settings: number; + inviteToken: number; +} + +export const defaultRateLimiterOptions: RateLimiterOptions = { + numRequestsAllowed: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), +}; +const rateLimiterDictionary: Record< + string, + { + rateLimiter: RateLimiter; + options: RateLimiterOptions; + } +> = {}; + +export const generateConnection = ( + ipAddress: string, + httpHeaders: Record, +): { + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; +} => ({ + id: Random.id(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + httpHeaders, + clientAddress: ipAddress, +}); + +export class APIClass = Record> { + public typedRoutes: Record> = {}; + + protected apiPath?: string; + + readonly version?: string; + + private _routes: { path: string; options: Options; endpoints: Record }[] = []; + + public authMethods: ((routeContext: APIActionContext) => Promise)[]; + + protected helperMethods: Map any> = new Map(); + + public fieldSeparator: string; + + public defaultFieldsToExclude: { + joinCode: number; + members: number; + importIds: number; + e2e: number; + }; + + public defaultLimitedUserFieldsToExclude: IAPIDefaultFieldsToExclude; + + public limitedUserFieldsToExclude: IAPIDefaultFieldsToExclude; + + public limitedUserFieldsToExcludeIfIsPrivilegedUser: { + services: number; + inviteToken: number; + }; + + readonly router: Router; + + constructor({ useDefaultAuth, ...properties }: IAPIProperties) { + this.version = properties.version; + + this.apiPath = [properties.apiPath, properties.version].filter(Boolean).join('/').replaceAll('//', '/'); + this.authMethods = []; + this.fieldSeparator = '.'; + this.defaultFieldsToExclude = { + joinCode: 0, + members: 0, + importIds: 0, + e2e: 0, + }; + this.defaultLimitedUserFieldsToExclude = { + avatarOrigin: 0, + emails: 0, + phone: 0, + statusConnection: 0, + createdAt: 0, + lastLogin: 0, + services: 0, + requirePasswordChange: 0, + requirePasswordChangeReason: 0, + roles: 0, + statusDefault: 0, + _updatedAt: 0, + settings: 0, + inviteToken: 0, + }; + this.limitedUserFieldsToExclude = this.defaultLimitedUserFieldsToExclude; + this.limitedUserFieldsToExcludeIfIsPrivilegedUser = { + services: 0, + inviteToken: 0, + }; + this.router = new RocketChatAPIRouter(`/${this.apiPath}`.replace(/\/$/, '').replaceAll('//', '/')); + + if (useDefaultAuth) { + this._initAuth(); + } + } + + public setLimitedCustomFields(customFields: string[]): void { + const nonPublicFieds = customFields.reduce( + (acc, customField) => { + acc[`customFields.${customField}`] = 0; + return acc; + }, + {} as Record, + ); + this.limitedUserFieldsToExclude = { + ...this.defaultLimitedUserFieldsToExclude, + ...nonPublicFieds, + }; + } + + async parseJsonQuery(routeContext: GenericRouteExecutionContext) { + return parseJsonQuery(routeContext); + } + + public addAuthMethod(func: (routeContext: APIActionContext) => Promise): void { + this.authMethods.push(func); + } + + protected shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean { + const { rateLimiterOptions } = options; + return ( + (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && + Boolean(this.version) && + !process.env.TEST_MODE && + Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS) + ); + } + + public success(): SuccessResult; + + public success(result: T, statusCode?: SuccessStatusCodes): SuccessResult; + + public success(result: T = {} as T, statusCode: SuccessStatusCodes = 200): SuccessResult { + if (isObject(result)) { + (result as Record).success = true; + } + + const finalResult = { + statusCode, + body: result, + } as SuccessResult; + + return finalResult; + } + + public redirect(code: C, result: T): RedirectResult { + return { + statusCode: code, + body: result, + }; + } + + public failure(): FailureResult; + + public failure(result?: T): FailureResult; + + public failure( + result?: T, + errorType?: TErrorType, + stack?: TStack, + error?: { details: TErrorDetails }, + ): FailureResult; + + public failure( + result?: T, + errorType?: TErrorType, + stack?: TStack, + error?: { details: TErrorDetails }, + ): FailureResult { + const response: { + statusCode: 400; + body: any & { message?: string; errorType?: string; stack?: string; success?: boolean; details?: Record | string }; + } = { statusCode: 400, body: result }; + + if (isObject(result)) { + response.body.success = false; + } else { + response.body = { + success: false, + error: result, + stack, + }; + + if (errorType) { + response.body.errorType = errorType; + } + + if (error && typeof error === 'object' && 'details' in error && error?.details) { + try { + response.body.details = JSON.parse(error.details as unknown as string); + } catch (e) { + response.body.details = error.details; + } + } + } + + return response; + } + + public notFound(msg?: string): NotFoundResult { + return { + statusCode: 404, + body: { + success: false, + error: msg || 'Resource not found', + }, + }; + } + + public internalError(): InternalError; + + public internalError(msg: T): InternalError; + + public internalError(msg?: T): InternalError { + return { + statusCode: 500, + body: { + success: false, + error: msg || 'Internal server error', + }, + }; + } + + public unavailable(msg?: T): UnavailableResult { + return { + statusCode: 503, + body: { + success: false, + error: msg || 'Service Unavailable', + }, + }; + } + + public unauthorized(): UnauthorizedResult; + + public unauthorized(msg: T): UnauthorizedResult; + + public unauthorized(msg?: T): UnauthorizedResult { + return { + statusCode: 401, + body: { + success: false, + error: msg || 'unauthorized', + }, + }; + } + + public forbidden(): ForbiddenResult; + + public forbidden(msg: T): ForbiddenResult; + + public forbidden(msg?: T): ForbiddenResult { + return { + statusCode: 403, + body: { + success: false, + // TODO: MAJOR remove 'unauthorized' in favor of 'forbidden' + // because of reasons beyond my control we were used to send `unauthorized` to 403 cases, to avoid a breaking change we just adapted here + // but thanks to the semver check tests should break as soon we bump to a new version + error: msg || (applyBreakingChanges ? 'forbidden' : 'unauthorized'), + }, + }; + } + + public tooManyRequests(msg?: T): TooManyRequestsResult { + return { + statusCode: 429, + body: { + success: false, + error: msg || 'Too many requests', + }, + }; + } + + protected getRateLimiter(route: string): { rateLimiter: RateLimiter; options: RateLimiterOptions } { + return rateLimiterDictionary[route]; + } + + protected async shouldVerifyRateLimit(route: string, userId?: string): Promise { + return ( + rateLimiterDictionary.hasOwnProperty(route) && + settings.get('API_Enable_Rate_Limiter') === true && + (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true) && + !(userId && (await canBypassRateLimit(userId, rateLimiterDictionary[route].options.bypassPermissions))) + ); + } + + protected async enforceRateLimit( + objectForRateLimitMatch: RateLimiterOptionsToCheck, + _: any, + response: Response, + userId?: string, + ): Promise { + if (!(await this.shouldVerifyRateLimit(objectForRateLimitMatch.route, userId))) { + return; + } + + rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); + const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); + const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); + response.headers.set( + 'X-RateLimit-Limit', + String(rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed ?? ''), + ); + response.headers.set('X-RateLimit-Remaining', String(attemptResult.numInvocationsLeft)); + response.headers.set('X-RateLimit-Reset', String(new Date().getTime() + attemptResult.timeToReset)); + + if (!attemptResult.allowed) { + throw new Meteor.Error( + 'error-too-many-requests', + `Error, too many requests. Please slow down. You must wait ${timeToResetAttempsInSeconds} seconds before trying this endpoint again.`, + { + timeToReset: attemptResult.timeToReset, + seconds: timeToResetAttempsInSeconds, + }, + ); + } + } + + public registerRateLimiterForRoute({ + route, + rateLimiterOptions = defaultRateLimiterOptions, + methods, + }: { + route: string; + rateLimiterOptions?: RateLimiterOptions; + methods: string[]; + }): void { + if (!this.shouldAddRateLimitToRoute({ rateLimiterOptions })) { + return; + } + + this.addRateLimiterRuleForRoutes({ routes: [route], rateLimiterOptions, endpoints: methods }); + } + + public enforceRateLimitForRoute({ + route, + method, + request, + response, + requestIp, + userId, + }: { + route: string; + method: string; + request: Request; + response: Response; + requestIp: string; + userId?: string; + }): Promise { + return this.enforceRateLimit({ IPAddr: requestIp, route: this.getFullRouteName(route, method) }, request, response, userId); + } + + public reloadRoutesToRefreshRateLimiter(): void { + this._routes.forEach((route) => { + if (this.shouldAddRateLimitToRoute(route.options)) { + wrapExceptions(() => + this.addRateLimiterRuleForRoutes({ + routes: [route.path], + rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions, + endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'), + }), + ).catch((error) => { + console.error(error.message); + }); + } + }); + } + + protected addRateLimiterRuleForRoutes({ + routes, + rateLimiterOptions, + endpoints, + }: { + routes: string[]; + rateLimiterOptions: RateLimiterOptions | boolean; + endpoints: Record | string[]; + }): void { + if (typeof rateLimiterOptions !== 'object') { + throw new Meteor.Error('"rateLimiterOptions" must be an object'); + } + if (!rateLimiterOptions.numRequestsAllowed) { + throw new Meteor.Error(`You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint: ${routes}`); + } + if (!rateLimiterOptions.intervalTimeInMS) { + throw new Meteor.Error(`You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint: ${routes}`); + } + const addRateLimitRuleToEveryRoute = (routes: string[]) => { + routes.forEach((route) => { + rateLimiterDictionary[route] = { + rateLimiter: new RateLimiter(), + options: rateLimiterOptions, + }; + const rateLimitRule = { + IPAddr: (input: any) => input, + route, + }; + rateLimiterDictionary[route].rateLimiter.addRule( + rateLimitRule, + rateLimiterOptions.numRequestsAllowed as number, + rateLimiterOptions.intervalTimeInMS as number, + ); + }); + }; + routes.map((route) => this.namedRoutes(route, endpoints)).map(addRateLimitRuleToEveryRoute); + } + + public async processTwoFactor({ + userId, + request, + options, + connection, + }: { + userId: string; + request: Request; + options?: Options; + connection: IMethodConnection; + }): Promise { + if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) { + return false; + } + const code = request.headers.get('x-2fa-code') ? String(request.headers.get('x-2fa-code')) : undefined; + const method = request.headers.get('x-2fa-method') ? String(request.headers.get('x-2fa-method')) : undefined; + + await checkCodeForUser({ + user: userId, + code, + method, + options: options && 'twoFactorOptions' in options ? (options as Record).twoFactorOptions || {} : {}, + connection, + }); + + return true; + } + + public getFullRouteName(route: string, method: string): string { + return `/${this.apiPath || ''}/${route}${method}`; + } + + protected namedRoutes(route: string, endpoints: Record | string[]): string[] { + const routeActions: string[] = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints); + + return routeActions.map((action) => this.getFullRouteName(route, action)); + } + + private registerTypedRoutesLegacy( + method: Method, + subpath: TSubPathPattern, + options: TOptions, + ): void { + const { authRequired, validateParams } = options; + + const opt = { + authRequired, + ...(validateParams && + method.toLowerCase() === 'get' && + ('GET' in validateParams + ? { query: validateParams.GET } + : { + query: validateParams as ValidateFunction, + })), + + ...(validateParams && + method.toLowerCase() === 'post' && + ('POST' in validateParams ? { query: validateParams.POST } : { body: validateParams as ValidateFunction })), + + ...(validateParams && + method.toLowerCase() === 'put' && + ('PUT' in validateParams ? { query: validateParams.PUT } : { body: validateParams as ValidateFunction })), + ...(validateParams && + method.toLowerCase() === 'delete' && + ('DELETE' in validateParams ? { query: validateParams.DELETE } : { body: validateParams as ValidateFunction })), + + tags: ['Missing Documentation'], + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean' }, + error: { type: 'string' }, + }, + required: ['success'], + }), + }, + }; + + this.registerTypedRoutes(method, subpath, opt); + } + + private registerTypedRoutes< + TSubPathPattern extends string, + TOptions extends TypedOptions, + TPathPattern extends `${TBasePath}/${TSubPathPattern}`, + >(method: MinimalRoute['method'], subpath: TSubPathPattern, options: TOptions): void { + const path = `/${this.apiPath}/${subpath}`.replaceAll('//', '/') as TPathPattern; + this.typedRoutes = this.typedRoutes || {}; + this.typedRoutes[path] = this.typedRoutes[path] || {}; + const { query, authRequired, response, body, tags, ...rest } = options; + this.typedRoutes[path][method.toLowerCase()] = { + ...(response && { + responses: Object.fromEntries( + Object.entries(response).map(([status, schema]) => [ + status, + { + description: '', + content: { + 'application/json': 'schema' in schema ? { schema: schema.schema } : schema, + }, + }, + ]), + ), + }), + ...(query && { + parameters: [ + { + schema: query.schema, + in: 'query', + name: 'query', + required: true, + }, + ], + }), + ...(body && { + requestBody: { + required: true, + content: { + 'application/json': { schema: body.schema }, + }, + }, + }), + ...(authRequired && { + ...rest, + security: [ + { + userId: [], + authToken: [], + }, + ], + }), + tags, + }; + } + + private method( + method: MinimalRoute['method'], + subpath: TSubPathPattern, + options: TOptions, + action: TypedAction, + ): APIClass< + TBasePath, + | TOperations + | Prettify< + { + method: Method; + path: TPathPattern; + } & Omit + > + > { + this.addRoute([subpath], { tags: [], ...options, typed: true }, { [method.toLowerCase()]: { action } } as any); + this.registerTypedRoutes(method, subpath, options); + return this; + } + + get( + subpath: TSubPathPattern, + options: TOptions, + action: TypedAction, + ): APIClass< + TBasePath, + | TOperations + | Prettify< + { + method: 'GET'; + path: TPathPattern; + } & TOptions + > + > { + return this.method('GET', subpath, options, action); + } + + post( + subpath: TSubPathPattern, + options: TOptions, + action: TypedAction, + ): APIClass< + TBasePath, + | TOperations + | ({ + method: 'POST'; + path: TPathPattern; + } & Omit) + | Prettify< + { + method: 'POST'; + path: TPathPattern; + } & TOptions + > + > { + return this.method('POST', subpath, options, action); + } + + put( + subpath: TSubPathPattern, + options: TOptions, + action: TypedAction, + ): APIClass< + TBasePath, + | TOperations + | ({ + method: 'PUT'; + path: TPathPattern; + } & Omit) + | Prettify< + { + method: 'PUT'; + path: TPathPattern; + } & TOptions + > + > { + return this.method('PUT', subpath, options, action); + } + + delete( + subpath: TSubPathPattern, + options: TOptions, + action: TypedAction, + ): APIClass< + TBasePath, + | TOperations + | ({ + method: 'DELETE'; + path: TPathPattern; + } & Omit) + | Prettify< + { + method: 'DELETE'; + path: TPathPattern; + } & TOptions + > + > { + return this.method('DELETE', subpath, options, action); + } + + /** + * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). + */ + addRoute( + subpath: TSubPathPattern, + operations: Operations>, + ): void; + + /** + * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). + */ + addRoute>( + subpaths: TSubPathPattern[], + operations: Operations, + ): void; + + /** + * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). + */ + addRoute( + subpath: TSubPathPattern, + options: TOptions, + operations: Operations, TOptions>, + ): void; + + /** + * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). + */ + addRoute, TOptions extends Options>( + subpaths: TSubPathPattern[], + options: TOptions, + operations: Operations, + ): void; + + /** + * @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete). + */ + public addRoute< + TSubPathPattern extends string, + TPathPattern extends JoinPathPattern, + TOptions extends Options, + >(subpaths: TSubPathPattern[], options: TOptions, endpoints?: Operations): void { + // Note: required if the developer didn't provide options + if (endpoints === undefined) { + endpoints = options as unknown as Operations; + options = {} as TOptions; + } + + const operations = endpoints; + + checkPermissions(options); + + // Allow for more than one route using the same option and endpoints + if (!Array.isArray(subpaths)) { + subpaths = [subpaths]; + } + if (this.shouldAddRateLimitToRoute(options)) { + this.addRateLimiterRuleForRoutes({ + routes: subpaths, + rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions, + endpoints: operations as unknown as string[], + }); + } + subpaths.forEach((route) => { + // Note: This is required due to Restivus calling `addRoute` in the constructor of itself + Object.keys(operations).forEach((method) => { + const _options = { ...options }; + const { tags = ['Missing Documentation'] } = _options as Record; + + if (typeof operations[method as keyof Operations] === 'function') { + (operations as Record)[method] = { + action: operations[method as keyof Operations], + }; + } else { + const extraOptions: Record = { ...operations[method as keyof Operations] } as Record< + string, + any + >; + delete extraOptions.action; + Object.assign(_options, extraOptions); + } + // Add a try/catch for each endpoint + const originalAction = (operations[method as keyof Operations] as Record).action; + + if (options.deprecation && shouldBreakInVersion(options.deprecation.version)) { + throw new Meteor.Error('error-deprecated', `The endpoint ${route} should be removed`); + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const api = this; + (operations[method as keyof Operations] as Record).action = + async function _internalRouteActionHandler() { + this.queryOperations = options.queryOperations; + this.queryFields = options.queryFields; + this.logger = logger; + + const authToken = this.request.headers.get('x-auth-token'); + this.token = Accounts._hashLoginToken(String(authToken))!; + + const objectForRateLimitMatch = { + IPAddr: this.requestIp, + route: api.getFullRouteName(route, this.request.method.toLowerCase()), + }; + + let result; + + const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token }; + this.connection = connection; + + try { + if (options.deprecation) { + parseDeprecation(this, options.deprecation); + } + + await api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId); + + if (_options.validateParams) { + const requestMethod = this.request.method as Method; + const validatorFunc = + typeof _options.validateParams === 'function' ? _options.validateParams : _options.validateParams[requestMethod]; + + if (validatorFunc && !validatorFunc(requestMethod === 'GET' ? this.queryParams : this.bodyParams)) { + throw new Meteor.Error('invalid-params', validatorFunc.errors?.map((error: any) => error.message).join('\n ')); + } + } + if ( + this.userId && + (await api.processTwoFactor({ + userId: this.userId, + request: this.request, + options: _options, + connection: connection as unknown as IMethodConnection, + })) + ) { + this.twoFactorChecked = true; + } + + this.parseJsonQuery = () => api.parseJsonQuery(this); + + if (options.applyMeteorContext) { + const invocation = APIClass.createMeteorInvocation(connection, this.userId, this.token); + result = await invocation + .applyInvocation(() => originalAction.apply(this)) + .finally(() => invocation[Symbol.asyncDispose]()); + } else { + result = await originalAction.apply(this); + } + } catch (e: any) { + result = ((e: any) => { + switch (e.error) { + case 'error-too-many-requests': + return api.tooManyRequests(typeof e === 'string' ? e : e.message); + case 'unauthorized': + case 'error-unauthorized': + if (applyBreakingChanges) { + return api.unauthorized(typeof e === 'string' ? e : e.message); + } + return api.forbidden(typeof e === 'string' ? e : e.message); + case 'forbidden': + case 'error-forbidden': + if (applyBreakingChanges) { + return api.forbidden(typeof e === 'string' ? e : e.message); + } + return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e); + default: + return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e); + } + })(e); + } finally { + delete Accounts._accountData[connection.id]; + } + + return result; + } as InnerAction; + // Allow the endpoints to make usage of the logger which respects the user's settings + (operations[method as keyof Operations] as Record).logger = logger; + this.router[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete']( + `/${route}`.replaceAll('//', '/'), + { ..._options, tags } as TypedOptions, + authenticationMiddlewareForHono(this, { + authRequired: options.authRequired, + authOrAnonRequired: options.authOrAnonRequired, + userWithoutUsername: options.userWithoutUsername, + logger, + }), + permissionsMiddleware(_options as TypedOptions), + license(_options as TypedOptions, License), + (operations[method as keyof Operations] as Record).action, + ); + this._routes.push({ + path: route, + options: _options, + endpoints: operations[method as keyof Operations] as unknown as Record, + }); + + this.registerTypedRoutesLegacy(method as Method, route, { + ...options, + ...operations[method as keyof Operations], + }); + }); + }); + } + + public async authenticatedRoute(routeContext: APIActionContext): Promise { + const userId = routeContext.request.headers.get('x-user-id'); + const userToken = routeContext.request.headers.get('x-auth-token'); + + if (userId && userToken) { + return Users.findOne( + { + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(userToken), + '_id': userId, + }, + { + projection: getDefaultUserFields(), + }, + ); + } + + for (const method of this.authMethods) { + const user = await method(routeContext); + + if (user) { + return user; + } + } + + return null; + } + + public updateRateLimiterDictionaryForRoute(route: string, numRequestsAllowed: number, intervalTimeInMS?: number): void { + if (rateLimiterDictionary[route]) { + rateLimiterDictionary[route].options.numRequestsAllowed = + numRequestsAllowed ?? rateLimiterDictionary[route].options.numRequestsAllowed; + rateLimiterDictionary[route].options.intervalTimeInMS = intervalTimeInMS ?? rateLimiterDictionary[route].options.intervalTimeInMS; + this.reloadRoutesToRefreshRateLimiter(); + } + } + + protected _initAuth(): void { + const loginCompatibility = (bodyParams: Record, request: Request): Record => { + // Grab the username or email that the user is logging in with + const { user, username, email, password, code: bodyCode } = bodyParams; + let usernameToLDAPLogin = ''; + + if (password == null) { + return bodyParams; + } + + if (_.without(Object.keys(bodyParams), 'user', 'username', 'email', 'password', 'code').length > 0) { + return bodyParams; + } + + const code = bodyCode || request.headers.get('x-2fa-code'); + + const auth: Record = { + password, + }; + + if (typeof user === 'string') { + auth.user = user.includes('@') ? { email: user } : { username: user }; + usernameToLDAPLogin = user; + } else if (username) { + auth.user = { username }; + usernameToLDAPLogin = username; + } else if (email) { + auth.user = { email }; + usernameToLDAPLogin = email; + } + + if (auth.user == null) { + return bodyParams; + } + + if (auth.password.hashed) { + auth.password = { + digest: auth.password as { hashed: string }, + algorithm: 'sha-256', + }; + } + + const objectToLDAPLogin = { + ldap: true, + username: usernameToLDAPLogin, + ldapPass: auth.password, + ldapOptions: {}, + }; + if (settings.get('LDAP_Enable') && !code) { + return objectToLDAPLogin; + } + + if (code) { + return { + totp: { + code, + login: settings.get('LDAP_Enable') ? objectToLDAPLogin : auth, + }, + }; + } + + return auth; + }; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + (this as APIClass<'/v1'>).addRoute( + 'login', + { authRequired: false, userWithoutUsername: true }, + { + async post() { + const request = this.request as unknown as Request; + const args = loginCompatibility(this.bodyParams, request); + + const invocation = new DDPCommon.MethodInvocation({ + connection: generateConnection(this.requestIp || '', this.request.headers), + }); + + try { + const auth = await DDP._CurrentInvocation.withValue(invocation as any, async () => Meteor.callAsync('login', args)); + const user = await Users.findOne( + { + _id: auth.id, + }, + { + projection: getDefaultUserFields(), + }, + ); + + if (!user) { + return self.unauthorized(); + } + + return self.success({ + status: 'success', + data: { + userId: user._id, + authToken: auth.token, + me: await getUserInfo(user || ({} as IUser)), + }, + }); + } catch (error) { + if (!(error instanceof Meteor.Error)) { + return self.internalError(); + } + + const result = self.unauthorized(); + + if (!applyBreakingChanges) { + Object.assign(result.body, { + status: 'error', + error: error.error, + details: error.details, + message: error.reason || error.message, + ...(error.reason === 'User not found' && { + error: 'Unauthorized', + message: 'Unauthorized', + }), + }); + } + return result; + } + }, + }, + ); + + const logout = async function < + This extends TypedThis<{ + authRequired: true; + response: any; + }>, + >(this: This): Promise<{ status: string; data: { message: string } }> { + // Remove the given auth token from the user's account + const hashedToken = this.token; + const tokenLocation = 'services.resume.loginTokens.hashedToken'; + const index = tokenLocation?.lastIndexOf('.') || 0; + const tokenPath = tokenLocation?.substring(0, index) || ''; + const tokenFieldName = tokenLocation?.substring(index + 1) || ''; + const tokenToRemove: Record = {}; + tokenToRemove[tokenFieldName] = hashedToken; + const tokenRemovalQuery: Record = {}; + tokenRemovalQuery[tokenPath] = tokenToRemove; + await Users.updateOne( + { _id: this.userId }, + { + $pull: tokenRemovalQuery, + }, + ); + + await Sessions.logoutBySessionIdAndUserId({ loginToken: hashedToken, userId: this.userId }); + void notifyOnUserChangeAsync(async () => { + const userTokens = await Users.findOneById(this.userId, { projection: { [tokenPath]: 1 } }); + if (!userTokens) { + return; + } + + const diff = { [tokenPath]: getNestedProp(userTokens, tokenPath) }; + + return { clientAction: 'updated', id: this.userId, diff }; + }); + + const response = { + status: 'success', + data: { + message: "You've been logged out!", + }, + }; + + return response; + }; + + /* + Add a logout endpoint to the API + After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for + adding hook). + */ + return (this as APIClass<'/v1'>).addRoute<'/v1/logout', { authRequired: true }>( + 'logout' as any, + { + authRequired: true, + }, + { + async get() { + console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.'); + console.warn(' See https://github.com/kahmali/meteor-restivus/issues/100'); + return logout.call(this as any) as any; + }, + async post() { + return logout.call(this as any) as any; + }, + }, + ); + } + + static createMeteorInvocation( + connection: { + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; + }, + userId?: string, + token?: string, + ) { + const invocation = new DDPCommon.MethodInvocation({ + connection, + isSimulation: false, + userId, + }); + + Accounts._accountData[connection.id] = { + connection, + }; + if (token) { + Accounts._setAccountData(connection.id, 'loginToken', token); + } + + return { + invocation, + applyInvocation: Promise>(action: F): ReturnType => { + return DDP._CurrentInvocation.withValue(invocation as any, async () => action()) as ReturnType; + }, + [Symbol.asyncDispose]() { + return Promise.resolve(); + }, + }; + } +} diff --git a/apps/meteor/server/api/README.md b/apps/meteor/server/api/README.md new file mode 100644 index 0000000..2c80d6c --- /dev/null +++ b/apps/meteor/server/api/README.md @@ -0,0 +1,139 @@ +# API Development Guidelines + +## Creating Automatic OpenAPI Specifications + +When developing new endpoints or modifying existing ones, follow these guidelines to ensure automatic OpenAPI specification generation and request/response validation. + +### Using Status Codes and AJV Schema + +1. **Status Codes**: Ensure that each response from your endpoint includes the appropriate HTTP status code. +2. **AJV Schema Validation**: Use the AJV schema to validate the response structure. This ensures that the response adheres to the defined schema. +3. **Query and Body Parameters**: Use the new `query` and `body` parameters to validate the inputs to your endpoints. This ensures that the incoming request data adheres to the defined schema. + +### Steps to Follow + +1. **Define the Schema**: Create a schema for your endpoint response using AJV. This schema will be used to validate the response structure. +2. **Optional Input Validation**: Optionally, you can validate your input parameters (query and body) using AJV schemas. This helps ensure that the incoming request data adheres to the defined schema. +3. **Validation in Test Environments**: When an endpoint is created or changed, the response will be validated in TEST environments. If the response does not match the schema, an error will be thrown. + +### Deprecation Notice + +The `addRoute` method is now deprecated. Instead, use the following methods available inside the API instance: + +- `.get` +- `.post` +- `.put` +- `.delete` + +These methods provide a more structured and clear approach to defining your API endpoints. + +The `validateParams` property is now deprecated. Instead, use the `query` and `body` properties to validate the inputs to your endpoints. This change provides a more structured and clear approach to input validation. + +### Example + +Here is an example of how to define an endpoint using the new methods and schema validation: + +```typescript +import { API } from 'path/to/api'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +API.v1 + .get( + 'endpoint-name', + { + authRequired: true, + query: ajv.compile({ + type: 'object', + properties: { + param1: { type: 'string' }, + param2: { type: 'number' }, + }, + required: ['param1'], + }), + body: ajv.compile({ + type: 'object', + properties: { + field1: { type: 'string' }, + field2: { type: 'boolean' }, + }, + required: ['field1'], + }), + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of sounds returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of sounds that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of sounds that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { + type: 'string', + }, + prop1: { + type: 'number', + }, + prop2: { + type: 'string', + }, + prop3: { + type: 'string', + }, + }, + required: ['_id', 'prop1', 'prop2', 'prop3'], + }, + }, + }, + }), + 401: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + error: { + type: 'string', + }, + status: { + type: 'string', + nullable: true, + }, + message: { + type: 'string', + nullable: true, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'error'], + }), + }, + }, + + async function action() { + const result = await anyLogic(); + return API.v1.success(result); + }, + ) +``` + +By following these guidelines, you ensure that your API endpoints are well-documented, validated, and maintainable. \ No newline at end of file diff --git a/apps/meteor/server/api/api.helpers.ts b/apps/meteor/server/api/api.helpers.ts new file mode 100644 index 0000000..3c0208a --- /dev/null +++ b/apps/meteor/server/api/api.helpers.ts @@ -0,0 +1,118 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import type { ActionThis } from './definition'; +import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../lib/authorization/hasPermission'; +import type { DeprecationLoggerNextPlannedVersion } from '../lib/deprecationWarningLogger'; +import { apiDeprecationLogger } from '../lib/deprecationWarningLogger'; + +type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | '*'; +export type PermissionsPayload = { + [key in RequestMethod]?: { + operation: 'hasAll' | 'hasAny'; + permissions: string[]; + }; +}; + +type PermissionsPayloadLight = { + [key in RequestMethod]?: string[]; +}; + +type PermissionsRequiredKey = string[] | PermissionsPayload | PermissionsPayloadLight; + +const isLegacyPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is string[] => { + return Array.isArray(permissionsPayload); +}; + +const isLightPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is PermissionsPayloadLight => { + return ( + typeof permissionsPayload === 'object' && + Object.keys(permissionsPayload).some((key) => ['GET', 'POST', 'PUT', 'DELETE', '*'].includes(key.toUpperCase())) && + Object.values(permissionsPayload).every((value) => Array.isArray(value)) + ); +}; + +const isPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is PermissionsPayload => { + return ( + typeof permissionsPayload === 'object' && + Object.keys(permissionsPayload).some((key) => ['GET', 'POST', 'PUT', 'DELETE', '*'].includes(key.toUpperCase())) && + Object.values(permissionsPayload).every((value) => typeof value === 'object' && value.operation && value.permissions) + ); +}; + +export async function canBypassRateLimit(userId: IUser['_id'], bypassPermissions: string[] = []): Promise { + return hasAtLeastOnePermissionAsync(userId, ['api-bypass-rate-limit', ...bypassPermissions]); +} + +export async function checkPermissionsForInvocation( + userId: IUser['_id'], + permissionsPayload: PermissionsPayload, + requestMethod: RequestMethod, +): Promise { + const permissions = permissionsPayload[requestMethod] || permissionsPayload['*']; + + if (!permissions) { + // how we reached here in the first place? + return false; + } + + if (permissions.permissions.length === 0) { + // You can pass an empty array of permissions to allow access to the method + return true; + } + + if (permissions.operation === 'hasAll') { + return hasAllPermissionAsync(userId, permissions.permissions); + } + + if (permissions.operation === 'hasAny') { + return hasAtLeastOnePermissionAsync(userId, permissions.permissions); + } + + return false; +} + +// We'll assume options only contains permissionsRequired, as we don't care of the other elements +export function checkPermissions(options: { permissionsRequired?: PermissionsRequiredKey }) { + if (!options.permissionsRequired) { + return false; + } + + if (isPermissionsPayload(options.permissionsRequired)) { + // No modifications needed + return true; + } + + if (isLegacyPermissionsPayload(options.permissionsRequired)) { + options.permissionsRequired = { + '*': { + operation: 'hasAll', + permissions: options.permissionsRequired, + }, + }; + return true; + } + + if (isLightPermissionsPayload(options.permissionsRequired)) { + Object.keys(options.permissionsRequired).forEach((method) => { + const methodKey = method as RequestMethod; + // @ts-expect-error -- we know the type of the value but ts refuses to infer it + options.permissionsRequired[methodKey] = { + operation: 'hasAll', + // @ts-expect-error -- we know the type of the value but ts refuses to infer it + permissions: options.permissionsRequired[methodKey], + }; + }); + return true; + } + + // If reached here, options.permissionsRequired contained an invalid payload + return false; +} + +export function parseDeprecation( + methodThis: ActionThis, + { alternatives, version }: { version: DeprecationLoggerNextPlannedVersion; alternatives?: string[] }, +): void { + const infoMessage = alternatives?.length ? ` Please use the alternative(s): ${alternatives.join(',')}` : ''; + apiDeprecationLogger.endpoint(methodThis.route, version, methodThis.response, infoMessage); +} diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts new file mode 100644 index 0000000..400a2fb --- /dev/null +++ b/apps/meteor/server/api/api.ts @@ -0,0 +1,163 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import type express from 'express'; +import { WebApp } from 'meteor/webapp'; + +import { APIClass } from './ApiClass'; +import type { RateLimiterOptions } from './definition'; +import { type APIActionHandler, RocketChatAPIRouter } from './router'; +import { metrics } from '../lib/metrics'; +import { settings } from '../settings'; +import { cors } from './v1/middlewares/cors'; +import { experimentalWarningMiddleware } from './v1/middlewares/experimental'; +import { loggerMiddleware } from './v1/middlewares/logger'; +import { metricsMiddleware } from './v1/middlewares/metrics'; +import { remoteAddressMiddleware } from './v1/middlewares/remoteAddressMiddleware'; +import { tracerSpanMiddleware } from './v1/middlewares/tracer'; + +const logger = new Logger('API'); + +export type Prettify = { + [K in keyof T]: T[K]; +} & unknown; + +export type { RateLimiterOptions } from './definition'; + +export const defaultRateLimiterOptions: RateLimiterOptions = { + numRequestsAllowed: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), +}; + +const createApi = function _createApi(options: { version?: string; useDefaultAuth?: true } = {}): APIClass { + return new APIClass({ + apiPath: '', + useDefaultAuth: false, + prettyJson: process.env.NODE_ENV === 'development', + ...options, + }); +}; + +export const API: { + api: Router<'/api', any, APIActionHandler>; + v1: APIClass<'/v1'>; + experimental: APIClass<'/experimental'>; + default: APIClass; + ApiClass: typeof APIClass; + channels?: { + create: { + validate: (params: { + user: { value: string }; + name?: { key: string; value?: string }; + members?: { key: string; value?: string[] }; + customFields?: { key: string; value?: string }; + teams?: { key: string; value?: string[] }; + teamId?: { key: string; value?: string }; + }) => Promise; + execute: ( + userId: string, + params: { + name?: string; + members?: string[]; + customFields?: Record; + extraData?: Record; + readOnly?: boolean; + }, + ) => Promise<{ channel: IRoom }>; + }; + }; +} = { + ApiClass: APIClass, + api: new RocketChatAPIRouter('/api'), + v1: createApi({ + version: 'v1', + useDefaultAuth: true, + }), + experimental: createApi({ + version: 'experimental', + useDefaultAuth: true, + }), + default: createApi({}), +}; + +settings.watch('Accounts_CustomFields', (value) => { + if (!value) { + return API.v1?.setLimitedCustomFields([]); + } + try { + const customFields = JSON.parse(value); + const nonPublicCustomFields = Object.keys(customFields).filter((customFieldKey) => customFields[customFieldKey].public !== true); + API.v1.setLimitedCustomFields(nonPublicCustomFields); + } catch (error) { + console.warn('Invalid Custom Fields', error); + } +}); + +const reloadRoutesToRefreshRateLimiter = () => { + API.v1.reloadRoutesToRefreshRateLimiter(); + API.experimental.reloadRoutesToRefreshRateLimiter(); +}; + +settings.watch('API_Enable_Rate_Limiter_Limit_Time_Default', (value) => { + defaultRateLimiterOptions.intervalTimeInMS = value; + reloadRoutesToRefreshRateLimiter(); +}); + +settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => { + defaultRateLimiterOptions.numRequestsAllowed = value; + reloadRoutesToRefreshRateLimiter(); +}); + +export const startRestAPI = () => { + (WebApp.rawConnectHandlers as unknown as ReturnType).use( + API.api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/v1\//), + api: API.v1, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/experimental\//), + api: API.experimental, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) + .use( + // Catch-all sampler for the default router (`/api/info`, `/api/docs/json`) and for + // unmatched `/api/*` paths, which belong to none of the versioned prefixes above. + // Add any new versioned namespace to `excludePathRegex` as well, or it gets counted twice. + metricsMiddleware({ + excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//), + // `API.default` has no `version`; label it explicitly so the series is not blank. + api: { version: 'default' }, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) + .use(tracerSpanMiddleware) + .use(remoteAddressMiddleware) + .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) + .use(cors(settings)) + .use(loggerMiddleware(logger)) + .use(API.v1.router) + .use(API.experimental.router) + .use(API.default.router).router, + ); +}; + +export type ExtractApiClassEndpoints> = + TApi extends APIClass ? (TOperations extends { method: string } ? Prettify : never) : never; diff --git a/apps/meteor/server/api/default/info.ts b/apps/meteor/server/api/default/info.ts new file mode 100644 index 0000000..71d5703 --- /dev/null +++ b/apps/meteor/server/api/default/info.ts @@ -0,0 +1,28 @@ +import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; +import { ajv } from '@rocket.chat/rest-typings'; + +import { API } from '../api'; +import { getServerInfo } from '../lib/getServerInfo'; + +const infoResponseSchema = ajv.compile({ + type: 'object', + properties: { + version: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +API.default.get( + 'info', + { + authRequired: false, + response: { + 200: infoResponseSchema, + }, + }, + async function action() { + return API.v1.success(await getServerInfo(this.userId)); + }, +); diff --git a/apps/meteor/server/api/default/openApi.ts b/apps/meteor/server/api/default/openApi.ts new file mode 100644 index 0000000..35e9e55 --- /dev/null +++ b/apps/meteor/server/api/default/openApi.ts @@ -0,0 +1,115 @@ +import { schemas } from '@rocket.chat/core-typings'; +import type { Route } from '@rocket.chat/http-router'; +import { ajv, isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings'; +import express from 'express'; +import { WebApp } from 'meteor/webapp'; +import swaggerUi from 'swagger-ui-express'; + +import { settings } from '../../settings'; +import { API } from '../api'; +import { getTrimmedServerVersion } from '../lib/getTrimmedServerVersion'; + +const app = express(); + +const getTypedRoutes = ( + typedRoutes: Record>, + { withUndocumented = false }: { withUndocumented?: boolean } = {}, +): Record> => { + if (withUndocumented) { + return typedRoutes; + } + + return Object.entries(typedRoutes).reduce( + (acc, [path, methods]) => { + const filteredMethods = Object.entries(methods) + .filter(([_, options]) => !options?.tags?.includes('Missing Documentation')) + .reduce( + (acc, [method, options]) => { + acc[method] = options; + return acc; + }, + {} as Record, + ); + + if (Object.keys(filteredMethods).length > 0) { + acc[path] = filteredMethods; + } + + return acc; + }, + {} as Record>, + ); +}; + +const makeOpenAPIResponse = (paths: Record>) => ({ + openapi: '3.0.3', + info: { + title: 'Rocket.Chat API', + description: 'Rocket.Chat API', + version: getTrimmedServerVersion(), + }, + servers: [ + { + url: settings.get('Site_Url'), + }, + ], + components: { + securitySchemes: { + userId: { + type: 'apiKey', + in: 'header', + name: 'X-User-Id', + }, + authToken: { + type: 'apiKey', + in: 'header', + name: 'X-Auth-Token', + }, + }, + schemas: schemas.components.schemas, + }, + schemas: schemas.components.schemas, + paths, +}); + +const openApiResponseSchema = ajv.compile>({ + type: 'object', + properties: { + openapi: { type: 'string' }, + info: { type: 'object' }, + servers: { type: 'array' }, + components: { type: 'object' }, + paths: { type: 'object' }, + schemas: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['openapi', 'info', 'paths', 'success'], + additionalProperties: false, +}); + +API.default.get( + 'docs/json', + { + authRequired: false, + query: isOpenAPIJSONEndpoint, + response: { + 200: openApiResponseSchema, + }, + }, + function action() { + const { withUndocumented = false } = this.queryParams; + + return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented }))); + }, +); + +app.use( + '/api-docs', + swaggerUi.serve, + swaggerUi.setup(null, { + swaggerOptions: { + url: `${settings.get('Site_Url')}/api/docs/json`, + }, + }), +); +WebApp.connectHandlers.use(app); diff --git a/apps/meteor/server/api/definition.ts b/apps/meteor/server/api/definition.ts new file mode 100644 index 0000000..50a6057 --- /dev/null +++ b/apps/meteor/server/api/definition.ts @@ -0,0 +1,371 @@ +import type { IncomingMessage } from 'node:http'; + +import type { IUser, LicenseModule, RequiredField } from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; +import type { Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; +import type { ValidateFunction } from 'ajv'; + +import type { ITwoFactorOptions } from '../lib/2fa/code'; +import type { DeprecationLoggerNextPlannedVersion } from '../lib/deprecationWarningLogger'; + +export type SuccessStatusCodes = Exclude, Range<200>>; + +export type RedirectStatusCodes = Exclude, Range<300>>; + +export type AuthorizationStatusCodes = Exclude, Range<400>>; + +export type ErrorStatusCodes = Exclude, Range<500>>, 509>; + +export type SuccessResult = { + statusCode: TStatusCode; + body: T extends Record ? { success: true } & T : T; +}; + +export type FailureResult = { + statusCode: 400; + body: T extends object + ? { success: false } & T + : { + success: false; + error?: T; + stack?: TStack; + errorType?: TErrorType; + details?: TErrorDetails; + message?: string; + } & (undefined extends TErrorType ? object : { errorType: TErrorType }) & + (undefined extends TErrorDetails ? object : { details: TErrorDetails extends string ? unknown : TErrorDetails }); +}; + +export type RedirectResult = { + statusCode: TStatusCode; + body: T; +}; + +export type UnauthorizedResult = { + statusCode: 401; + body: { + success: false; + error: T | 'unauthorized'; + }; +}; + +export type ForbiddenResult = { + statusCode: 403; + body: { + success: false; + // TODO: MAJOR remove 'unauthorized' + error: T | 'forbidden' | 'unauthorized'; + }; +}; + +export type TooManyRequestsResult = { + statusCode: 429; + body: { + success: false; + error: T | 'Too many requests'; + }; +}; + +export type InternalError = { + statusCode: StatusCode; + body: { + error: T | D; + success: false; + }; +}; + +export type UnavailableResult = { + statusCode: StatusCode; + body: { + error: T | 'Service Unavailable'; + success: false; + }; +}; + +export type NotFoundResult = { + statusCode: 404; + body: { + success: false; + error: T; + }; +}; + +export type TOperation = 'hasAll' | 'hasAny'; +export type NonEnterpriseTwoFactorOptions = { + authRequired: true; + forceTwoFactorAuthenticationForNonEnterprise: true; + twoFactorRequired: true; + permissionsRequired?: string[] | { [key in Method]: string[] } | { [key in Method]: { operation: TOperation; permissions: string[] } }; + twoFactorOptions: ITwoFactorOptions; +}; + +export type RateLimiterOptions = { + numRequestsAllowed?: number; + intervalTimeInMS?: number; + bypassPermissions?: string[]; +}; + +export type Options = SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>; + +export type SharedOptions = ( + | { + permissionsRequired?: + | string[] + | ({ [key in TMethod]?: string[] } & { '*'?: string[] }) + | ({ [key in TMethod]?: { operation: TOperation; permissions: string[] } } & { + '*'?: { operation: TOperation; permissions: string[] }; + }); + authRequired?: boolean; + userWithoutUsername?: boolean; + forceTwoFactorAuthenticationForNonEnterprise?: boolean; + rateLimiterOptions?: RateLimiterOptions | boolean; + queryOperations?: string[]; + queryFields?: string[]; + } + | { + permissionsRequired?: + | string[] + | ({ [key in TMethod]?: string[] } & { '*'?: string[] }) + | ({ [key in TMethod]?: { operation: TOperation; permissions: string[] } } & { + '*'?: { operation: TOperation; permissions: string[] }; + }); + authRequired: true; + userWithoutUsername?: boolean; + twoFactorRequired: true; + twoFactorOptions?: ITwoFactorOptions; + rateLimiterOptions?: RateLimiterOptions | boolean; + + queryOperations?: string[]; + queryFields?: string[]; + } +) & { + /** + * @deprecated The `validateParams` option is deprecated. Use `query` and/OR `body` instead. + */ + validateParams?: ValidateFunction | { [key in TMethod]?: ValidateFunction }; + authOrAnonRequired?: true; + deprecation?: { + version: DeprecationLoggerNextPlannedVersion; + alternatives?: PathPattern[]; + }; + applyMeteorContext?: boolean; +}; + +export type GenericRouteExecutionContext = ActionThis; + +export type RouteExecutionContext = ActionThis< + TMethod, + TPathPattern, + TOptions +>; + +export type ActionThis = { + readonly logger: Logger; + route: string; + readonly requestIp: string; + urlParams: UrlParams; + readonly response: Response; + // TODO make it unsafe + readonly queryParams: TMethod extends 'GET' + ? TOptions extends { validateParams: ValidateFunction } + ? T + : TOptions extends { validateParams: { GET: ValidateFunction } } + ? T + : Partial> & { offset?: number; count?: number } + : Record; + // TODO make it unsafe + readonly bodyParams: TMethod extends 'GET' + ? Record + : TOptions extends { validateParams: ValidateFunction } + ? T + : TOptions extends { validateParams: infer V } + ? V extends { [key in TMethod]: ValidateFunction } + ? T + : Partial> + : // TODO remove the extra (optionals) params when all the endpoints that use these are typed correctly + Partial>; + readonly request: Request; + readonly incoming: IncomingMessage; + + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + readonly queryFields: TOptions extends { queryFields: infer T } ? T : never; + + readonly twoFactorChecked: boolean; + + parseJsonQuery(): Promise<{ + sort: Record; + /** + * @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + fields: Record; + /** + * @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + query: Record; + }>; + + readonly connection: { + token: string; + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; + }; +} & (TOptions extends { authRequired: true } + ? { + user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + userId: string; + readonly token: string; + } + : TOptions extends { authOrAnonRequired: true } + ? { + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + userId?: string; + readonly token?: string; + } + : { + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + userId?: string; + readonly token?: string; + }); + +export type ResultFor = ( + | SuccessResult> + | FailureResult + | UnauthorizedResult + | NotFoundResult + | { + statusCode: number; + body: unknown; + } +) & { + headers?: Record; +}; + +export type Action = + | ((this: ActionThis) => Promise>) + | ((this: ActionThis) => ResultFor); + +export type InnerAction = + Action extends (this: infer This) => infer Result + ? This extends ActionThis + ? (this: Mutable) => Result + : never + : never; + +type Mutable = { + -readonly [key in keyof Immutable]: Immutable[key]; +}; + +type Operation = + | Action + | ({ + action: Action; + } & { twoFactorRequired: boolean }); + +type ActionOperation = { + action: Action; +} & TEndpointOptions; + +export type Operations = { + [M in MethodOf as Lowercase]: Operation, TPathPattern, TOptions>; +}; + +export type ActionOperations = { + [M in MethodOf as Lowercase]: ActionOperation, TPathPattern, TOptions>; +}; + +type Range = Result['length'] extends N + ? Result[number] + : Range; + +type HTTPStatusCodes = SuccessStatusCodes | RedirectStatusCodes | AuthorizationStatusCodes | ErrorStatusCodes; + +export type TypedOptions = { + response: { + [K in HTTPStatusCodes]?: ValidateFunction; + }; + query?: ValidateFunction; + body?: ValidateFunction; + tags?: string[]; + typed?: boolean; + license?: LicenseModule[]; +} & SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>; + +export type TypedThis = { + readonly logger: Logger; + userId: TOptions['authRequired'] extends true ? string : string | undefined; + token: TOptions['authRequired'] extends true ? string : string | undefined; + queryParams: TOptions['query'] extends ValidateFunction ? Query : never; + urlParams: UrlParams extends Record ? UrlParams : never; + parseJsonQuery(): Promise<{ + sort: Record; + /** + * @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + fields: Record; + /** + * @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + query: Record; + }>; + bodyParams: TOptions['body'] extends ValidateFunction ? Body : never; + request: Request; + requestIp?: string; + route: string; + response: Response; + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + readonly queryFields: TOptions extends { queryFields: infer T } ? T : never; + readonly connection: { + token: string; + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; + }; + readonly twoFactorChecked: boolean; +} & (TOptions['authRequired'] extends true + ? { + user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + } + : { + user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; + }); + +type PromiseOrValue = T | Promise; + +type InferResult = TResult extends ValidateFunction ? T : TResult; + +type InferNon200Result = + InferResult extends { + success: false; + error?: infer TError; + } + ? TError + : never; + +type Results = { + [K in keyof TResponse]: K extends SuccessStatusCodes + ? SuccessResult, K> + : K extends RedirectStatusCodes + ? RedirectResult, K> + : K extends 400 + ? FailureResult> + : K extends 401 + ? UnauthorizedResult> + : K extends 403 + ? ForbiddenResult> + : K extends 404 + ? NotFoundResult> + : K extends ErrorStatusCodes + ? InternalError, K> + : never; +}[keyof TResponse] & { + headers?: Record; +}; + +export type TypedAction< + TOptions extends TypedOptions, + TPath extends string = '', + TThis extends TypedThis = TypedThis, +> = (this: TThis, request: Request) => PromiseOrValue>; diff --git a/apps/meteor/server/api/experimental/rooms.setCategory.ts b/apps/meteor/server/api/experimental/rooms.setCategory.ts new file mode 100644 index 0000000..57646d3 --- /dev/null +++ b/apps/meteor/server/api/experimental/rooms.setCategory.ts @@ -0,0 +1,76 @@ +import { SIDEBAR_SYSTEM_GROUP_KEYS } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { + ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { notifyOnSubscriptionsChangedByRoomIdsAndUserId } from '../../lib/notifyListener'; +import { API } from '../api'; + +const isRoomsSetCategoryParamsPOST = ajv.compile<{ roomIds: string[]; category: string | null }>({ + type: 'object', + properties: { + roomIds: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + uniqueItems: true, + }, + category: { + type: 'string', + nullable: true, + not: { enum: [...SIDEBAR_SYSTEM_GROUP_KEYS] }, + }, + }, + required: ['roomIds', 'category'], + additionalProperties: false, +}); + +API.experimental.post( + 'rooms.setCategory', + { + authRequired: true, + license: ['experimental-enterprise-features'], + body: isRoomsSetCategoryParamsPOST, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomIds, category } = this.bodyParams; + const { userId } = this; + + const user = await Users.findOneById(userId, { projection: { 'settings.preferences.sidebarCategories': 1 } }); + if (!user) { + return API.experimental.failure('error-invalid-user'); + } + + if (category !== null) { + const categories: Array<{ _id: string }> = user.settings?.preferences?.sidebarCategories ?? []; + if (!categories.some((cat) => cat._id === category)) { + return API.experimental.failure('error-invalid-param', 'Category not found in user preferences.'); + } + } + + const { modifiedCount } = await Subscriptions.setCategoryByRoomIdsAndUserId(roomIds, userId, category); + + if (modifiedCount) { + void notifyOnSubscriptionsChangedByRoomIdsAndUserId(roomIds, userId); + } + + return API.experimental.success(); + }, +); diff --git a/apps/meteor/server/api/index.ts b/apps/meteor/server/api/index.ts new file mode 100644 index 0000000..c261880 --- /dev/null +++ b/apps/meteor/server/api/index.ts @@ -0,0 +1,57 @@ +import './validation/ajv'; +import './lib/composeRoomWithLastMessage'; +import './lib/getPaginationItems'; +import './lib/getUserFromParams'; +import './lib/getUserInfo'; +import './lib/isUserFromParams'; +import './lib/parseJsonQuery'; +import './default/info'; +import './v1/assets'; +import './v1/ai-search'; +import './v1/calendar'; +import './v1/call-history'; +import './v1/channels'; +import './v1/chat'; +import './v1/cloud'; +import './v1/commands'; +import './v1/e2e'; +import './v1/emoji-custom'; +import './v1/groups'; +import './v1/im'; +import './v1/integrations'; +import './v1/invites'; +import './v1/import'; +import './v1/ldap'; +import './v1/saml'; +import './v1/media-calls'; +import './v1/misc'; +import './v1/permissions'; +import './v1/presence'; +import './v1/push'; +import './v1/roles'; +import './v1/rooms'; +import './v1/settings'; +import './v1/setupWizard'; +import './v1/stats'; +import './v1/subscriptions'; +import './v1/users'; +import './v1/videoConference'; +import './v1/autotranslate'; +import './v1/webdav'; +import './v1/oauthapps'; +import './v1/custom-sounds'; +import './v1/custom-user-status'; +import './v1/instances'; +import './v1/banners'; +import './v1/email-inbox'; +import './v1/mailer'; +import './v1/teams'; +import './v1/moderation'; +import './v1/uploads'; +import './v1/twoFactorChallenges'; +import './v1/loginCode'; +import './experimental/rooms.setCategory'; +// This has to come last so all endpoints are registered before generating the OpenAPI documentation +import './default/openApi'; + +export { API, defaultRateLimiterOptions } from './api'; diff --git a/apps/meteor/server/api/lib/MultipartUploadHandler.ts b/apps/meteor/server/api/lib/MultipartUploadHandler.ts new file mode 100644 index 0000000..94eeb42 --- /dev/null +++ b/apps/meteor/server/api/lib/MultipartUploadHandler.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import { IncomingMessage } from 'node:http'; +import type { Stream, Transform } from 'node:stream'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { MeteorError } from '@rocket.chat/core-services'; +import { Random } from '@rocket.chat/random'; +import busboy, { type BusboyConfig } from 'busboy'; +import ExifTransformer from 'exif-be-gone'; + +import { getMimeType } from '../../../app/utils/lib/mimeTypes'; +import { UploadFS } from '../../ufs'; + +export type ParsedUpload = { + tempFilePath: string; + filename: string; + mimetype: string; + size: number; + fieldname: string; +}; + +export type ParseOptions = { + field: string; + maxSize?: number; + allowedMimeTypes?: string[]; + transforms?: Transform[]; // Optional transform pipeline (e.g., EXIF stripping) + fileOptional?: boolean; +}; + +export class MultipartUploadHandler { + static transforms = { + stripExif(): Transform { + return new ExifTransformer(); + }, + }; + + static async cleanup(tempFilePath: string): Promise { + try { + await fs.promises.unlink(tempFilePath); + } catch (error: any) { + console.warn(`[UploadService] Failed to cleanup temp file: ${tempFilePath}`, error); + } + } + + static async stripExifFromFile(tempFilePath: string): Promise { + const strippedPath = `${tempFilePath}.stripped`; + + try { + const writeStream = fs.createWriteStream(strippedPath); + + await pipeline(fs.createReadStream(tempFilePath), new ExifTransformer(), writeStream); + + await fs.promises.rename(strippedPath, tempFilePath); + + return writeStream.bytesWritten; + } catch (error) { + void this.cleanup(strippedPath); + + throw error; + } + } + + static async parseRequest( + request: IncomingMessage | Request, + options: ParseOptions, + ): Promise<{ file: ParsedUpload | null; fields: Record }> { + const limits: BusboyConfig['limits'] = { files: 1 }; + + if (options.maxSize && options.maxSize > 0) { + // We add an extra byte to the configured limit so we don't fail the upload + // of a file that is EXACTLY maxSize + limits.fileSize = options.maxSize + 1; + } + + const headers = + request instanceof IncomingMessage ? (request.headers as Record) : Object.fromEntries(request.headers.entries()); + + const bb = busboy({ + headers, + defParamCharset: 'utf8', + limits, + }); + + const fields: Record = {}; + let parsedFile: ParsedUpload | null = null; + let busboyFinished = false; + let filePendingCount = 0; + + const { promise, resolve, reject } = Promise.withResolvers<{ + file: ParsedUpload | null; + fields: Record; + }>(); + + const tryResolve = () => { + if (busboyFinished && filePendingCount < 1) { + if (!parsedFile && !options.fileOptional) { + return reject(new MeteorError('error-no-file', 'No file uploaded')); + } + resolve({ file: parsedFile, fields }); + } + }; + + bb.on('field', (fieldname: string, value: string) => { + fields[fieldname] = value; + }); + + bb.on('file', (fieldname, file, info) => { + const { filename, mimeType } = info; + + ++filePendingCount; + + if (options.field && fieldname !== options.field) { + file.resume(); + return reject(new MeteorError('invalid-field')); + } + + if (options.allowedMimeTypes && !options.allowedMimeTypes.includes(mimeType)) { + file.resume(); + return reject(new MeteorError('error-invalid-file-type', `File type ${mimeType} not allowed`)); + } + + const fileId = Random.id(); + const tempFilePath = UploadFS.getTempFilePath(fileId); + + const writeStream = fs.createWriteStream(tempFilePath); + + let currentStream: Stream = file; + if (options.transforms?.length) { + const fileDestroyer = file.destroy.bind(file); + for (const transform of options.transforms) { + transform.on('error', fileDestroyer); + currentStream = currentStream.pipe(transform); + } + } + + currentStream.pipe(writeStream); + + writeStream.on('finish', () => { + if (file.truncated) { + void this.cleanup(tempFilePath); + return reject(new MeteorError('error-file-too-large', 'File size exceeds the allowed limit')); + } + + parsedFile = { + tempFilePath, + filename, + mimetype: getMimeType(mimeType, filename), + size: writeStream.bytesWritten, + fieldname, + }; + + --filePendingCount; + + tryResolve(); + }); + + writeStream.on('error', (err) => { + file.destroy(); + void this.cleanup(tempFilePath); + reject(new MeteorError('error-file-upload', err.message)); + }); + + file.on('error', (err) => { + writeStream.destroy(); + void this.cleanup(tempFilePath); + reject(new MeteorError('error-file-upload', err.message)); + }); + }); + + bb.on('finish', () => { + busboyFinished = true; + tryResolve(); + }); + + bb.on('error', (err: any) => { + reject(new MeteorError('error-upload-failed', err.message)); + }); + + bb.on('filesLimit', () => { + reject(new MeteorError('error-too-many-files', 'Too many files in upload')); + }); + + bb.on('partsLimit', () => { + reject(new MeteorError('error-too-many-parts', 'Too many parts in upload')); + }); + + bb.on('fieldsLimit', () => { + reject(new MeteorError('error-too-many-fields', 'Too many fields in upload')); + }); + + if (request instanceof IncomingMessage) { + request.pipe(bb); + } else { + if (!request.body) { + return Promise.reject(new MeteorError('error-no-body', 'Request has no body')); + } + + const nodeStream = Readable.fromWeb(request.body as any); + nodeStream.pipe(bb); + } + + return promise; + } +} diff --git a/apps/meteor/server/api/lib/addUserToFileObj.ts b/apps/meteor/server/api/lib/addUserToFileObj.ts new file mode 100644 index 0000000..843d0d8 --- /dev/null +++ b/apps/meteor/server/api/lib/addUserToFileObj.ts @@ -0,0 +1,22 @@ +import type { IUpload, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +const isString = (value: unknown): value is string => typeof value === 'string'; + +export async function addUserToFileObj(files: IUpload[]): Promise<(IUpload & { user?: Pick })[]> { + const uids = files.map(({ userId }) => userId).filter(isString); + + const users = await Users.findByIds(uids, { projection: { name: 1, username: 1 } }).toArray(); + const usersById = new Map(users.map((user) => [user._id, user])); + + return files.map((file) => { + const user = file.userId ? usersById.get(file.userId) : undefined; + if (!user) { + return file; + } + return { + ...file, + user, + }; + }); +} diff --git a/apps/meteor/server/api/lib/cleanQuery.ts b/apps/meteor/server/api/lib/cleanQuery.ts new file mode 100644 index 0000000..ee63fa4 --- /dev/null +++ b/apps/meteor/server/api/lib/cleanQuery.ts @@ -0,0 +1,28 @@ +type Query = { [k: string]: any }; + +const denyList = ['constructor', '__proto__', 'prototype']; + +export const removeDangerousProps = (v: Query): Query => { + const query = Object.create(null); + for (const key in v) { + if (v.hasOwnProperty(key) && !denyList.includes(key)) { + query[key] = v[key]; + } + } + + return query; +}; +/* @deprecated */ +export function clean(v: Query, allowList: string[] = []): Query { + const typedParam = removeDangerousProps(v); + if (v instanceof Object) { + for (const key in typedParam) { + if (key.startsWith('$') && !allowList.includes(key)) { + delete typedParam[key]; + } else { + clean(typedParam[key], allowList); + } + } + } + return typedParam; +} diff --git a/apps/meteor/server/api/lib/composeRoomWithLastMessage.ts b/apps/meteor/server/api/lib/composeRoomWithLastMessage.ts new file mode 100644 index 0000000..022e21d --- /dev/null +++ b/apps/meteor/server/api/lib/composeRoomWithLastMessage.ts @@ -0,0 +1,11 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; + +export async function composeRoomWithLastMessage(room: IRoom, userId: string) { + if (room.lastMessage) { + const [lastMessage] = await normalizeMessagesForUser([room.lastMessage], userId); + room.lastMessage = lastMessage; + } + return room; +} diff --git a/apps/meteor/server/api/lib/emailInbox.ts b/apps/meteor/server/api/lib/emailInbox.ts new file mode 100644 index 0000000..9c06f39 --- /dev/null +++ b/apps/meteor/server/api/lib/emailInbox.ts @@ -0,0 +1,104 @@ +import type { IEmailInbox } from '@rocket.chat/core-typings'; +import { EmailInbox, Users } from '@rocket.chat/models'; +import type { DeleteResult, Filter, InsertOneResult, Sort } from 'mongodb'; + +import { notifyOnEmailInboxChanged } from '../../lib/notifyListener'; + +export const findEmailInboxes = async ({ + query = {}, + pagination: { offset, count, sort }, +}: { + query?: Filter; + pagination: { + offset: number; + count: number; + sort?: Sort; + }; +}): Promise<{ + emailInboxes: IEmailInbox[]; + total: number; + count: number; + offset: number; +}> => { + const { cursor, totalCount } = EmailInbox.findPaginated(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [emailInboxes, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + emailInboxes, + count: emailInboxes.length, + offset, + total, + }; +}; + +export const insertOneEmailInbox = async ( + userId: string, + emailInboxParams: Pick, +): Promise> => { + const obj = { + ...emailInboxParams, + _createdAt: new Date(), + _updatedAt: new Date(), + _createdBy: await Users.findOneById(userId, { projection: { username: 1 } }), + }; + + const response = await EmailInbox.create(obj); + + if (response.insertedId) { + void notifyOnEmailInboxChanged({ _id: response.insertedId, ...obj }, 'inserted'); + } + + return response; +}; + +export const updateEmailInbox = async ( + emailInboxParams: Pick, +): Promise | null> => { + const { _id, active, name, email, description, senderInfo, department, smtp, imap } = emailInboxParams; + + const updateEmailInbox = { + $set: { + active, + name, + email, + description, + senderInfo, + smtp, + imap, + _updatedAt: new Date(), + ...(department !== 'All' && { department }), + }, + ...(department === 'All' && { $unset: { department: 1 as const } }), + }; + + const updatedResponse = await EmailInbox.updateById(_id, updateEmailInbox); + + if (!updatedResponse) { + throw new Error('error-invalid-email-inbox'); + } + + void notifyOnEmailInboxChanged( + { + ...updatedResponse, + ...(department === 'All' && { department: undefined }), + }, + 'updated', + ); + + return updatedResponse; +}; + +export const removeEmailInbox = async (emailInboxId: IEmailInbox['_id']): Promise => { + const removeResponse = await EmailInbox.removeById(emailInboxId); + + if (removeResponse.deletedCount) { + void notifyOnEmailInboxChanged({ _id: emailInboxId }, 'removed'); + } + + return removeResponse; +}; diff --git a/apps/meteor/server/api/lib/emoji-custom.ts b/apps/meteor/server/api/lib/emoji-custom.ts new file mode 100644 index 0000000..d0b035f --- /dev/null +++ b/apps/meteor/server/api/lib/emoji-custom.ts @@ -0,0 +1,31 @@ +import type { IEmojiCustom } from '@rocket.chat/core-typings'; +import { EmojiCustom } from '@rocket.chat/models'; +import type { Filter, FindOptions } from 'mongodb'; + +export async function findEmojisCustom({ + query = {}, + pagination: { offset, count, sort }, +}: { + query: Filter; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}): Promise<{ + emojis: IEmojiCustom[]; + count: number; + offset: any; + total: number; +}> { + const { cursor, totalCount } = EmojiCustom.findPaginated(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [emojis, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + emojis, + count: emojis.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/lib/eraseTeam.spec.ts b/apps/meteor/server/api/lib/eraseTeam.spec.ts new file mode 100644 index 0000000..3508389 --- /dev/null +++ b/apps/meteor/server/api/lib/eraseTeam.spec.ts @@ -0,0 +1,292 @@ +import { expect } from 'chai'; +import proxyquireRaw from 'proxyquire'; +import * as sinon from 'sinon'; + +const proxyquire = proxyquireRaw.noCallThru(); + +type Stubbed = { [k: string]: any }; + +describe('eraseTeam (TypeScript) module', () => { + let sandbox: sinon.SinonSandbox; + let stubs: Stubbed; + let subject: any; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + stubs = { + 'Team': { + getMatchingTeamRooms: sandbox.stub().resolves([]), + unsetTeamIdOfRooms: sandbox.stub().resolves(), + removeAllMembersFromTeam: sandbox.stub().resolves(), + deleteById: sandbox.stub().resolves(), + }, + 'Users': { + findOneById: sandbox.stub().resolves(null), + }, + 'Rooms': { + findOneById: sandbox.stub().resolves(null), + }, + 'eraseRoomStub': sandbox.stub().resolves(true), + 'deleteRoomStub': sandbox.stub().resolves(), + '../../lib/logger/system': { + SystemLogger: { + error: sandbox.stub(), + }, + }, + '@rocket.chat/apps': { + AppEvents: { + IPreRoomDeletePrevent: 'IPreRoomDeletePrevent', + IPostRoomDeleted: 'IPostRoomDeleted', + }, + Apps: { + self: { + isLoaded: () => false, + triggerEvent: sandbox.stub().resolves(false), + }, + }, + }, + '@rocket.chat/models': { + Rooms: { + findOneById: (...args: any[]) => stubs.Rooms.findOneById(...args), + }, + Users: { + findOneById: (...args: any[]) => stubs.Users.findOneById(...args), + }, + }, + '@rocket.chat/core-services': { + MeteorError: (function () { + class MeteorError extends Error { + public error: string | undefined; + + public details: any; + + constructor(message?: string, error?: string, details?: any) { + super(message); + this.error = error; + this.details = details; + } + } + return MeteorError; + })(), + }, + }; + + subject = proxyquire('./eraseTeam', { + '@rocket.chat/apps': stubs['@rocket.chat/apps'], + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('eraseTeamShared', () => { + it('throws when user is undefined', async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await expect(subject.eraseTeamShared(undefined, { _id: 'team1', roomId: 'teamRoom' }, [], () => {})).to.be.rejected; + }); + + it('erases provided rooms (excluding team.roomId) and cleans up team', async () => { + const team = { _id: 'team-id', roomId: 'team-room' }; + const user = { _id: 'user-1', username: 'u' }; + stubs.Team.getMatchingTeamRooms.resolves(['room-1', 'room-2', team.roomId]); + stubs.Rooms.findOneById.withArgs(team.roomId).resolves({ _id: team.roomId }); + + const erased: Array<{ rid: string; user: any }> = []; + const eraseRoomFn = async (rid: string, user: any) => { + erased.push({ rid, user }); + }; + + await subject.eraseTeamShared(user, team, ['room-1', 'room-2', team.roomId], eraseRoomFn); + + expect(erased.some((r) => r.rid === 'room-1')).to.be.true; + expect(erased.some((r) => r.rid === 'room-2')).to.be.true; + sinon.assert.calledOnce(stubs.Team.unsetTeamIdOfRooms); + expect(erased.some((r) => r.rid === team.roomId)).to.be.true; + sinon.assert.calledOnce(stubs.Team.removeAllMembersFromTeam); + sinon.assert.calledOnce(stubs.Team.deleteById); + }); + + it('skips the main room but still deletes the team when the main room no longer exists', async () => { + const team = { _id: 'team-id', roomId: 'team-room' }; + const user = { _id: 'user-1', username: 'u' }; + stubs.Team.getMatchingTeamRooms.resolves([]); + stubs.Rooms.findOneById.withArgs(team.roomId).resolves(null); + + const eraseRoomFn = sandbox.stub().resolves(); + + await subject.eraseTeamShared(user, team, [], eraseRoomFn); + + sinon.assert.notCalled(eraseRoomFn); + sinon.assert.calledOnce(stubs.Team.unsetTeamIdOfRooms); + sinon.assert.calledOnce(stubs.Team.removeAllMembersFromTeam); + sinon.assert.calledOnce(stubs.Team.deleteById); + }); + }); + + describe('eraseTeam', () => { + it('calls eraseRoom for the team main room (via eraseTeamShared)', async () => { + const team = { _id: 't1', roomId: 't-room' }; + const user = { _id: 'u1', username: 'u', name: 'User' }; + stubs.Team.getMatchingTeamRooms.resolves([]); + stubs.Rooms.findOneById.withArgs(team.roomId).resolves({ _id: team.roomId }); + const { eraseRoomStub } = stubs; + eraseRoomStub.resolves(true); + + await subject.eraseTeam(user, team, []); + + sinon.assert.calledWith(eraseRoomStub, team.roomId, user); + }); + }); + + describe('eraseTeamOnRelinquishRoomOwnerships', () => { + it('returns successfully deleted room ids only', async () => { + const team = { _id: 't1', roomId: 't-room' }; + stubs.Team.getMatchingTeamRooms.resolves(['r1', 'r2']); + + stubs.Rooms.findOneById.withArgs('r1').resolves({ _id: 'r1', federated: false }); + stubs.Rooms.findOneById.withArgs('r2').resolves(null); + + stubs.deleteRoomStub.withArgs('r1').resolves(); + stubs.deleteRoomStub.withArgs('r2').rejects(new Error('boom')); + + const base = proxyquire('./eraseTeam', { + '@rocket.chat/apps': stubs['@rocket.chat/apps'], + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + + const result: string[] = await base.eraseTeamOnRelinquishRoomOwnerships(team, ['r1', 'r2']); + expect(result).to.be.an('array').that.includes('r1').and.not.includes('r2'); + }); + }); + + describe('eraseRoomLooseValidation', () => { + let baseModule: any; + + beforeEach(() => { + baseModule = proxyquire('./eraseTeam', { + '@rocket.chat/apps': stubs['@rocket.chat/apps'], + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + }); + + it('returns false when room not found', async () => { + stubs.Rooms.findOneById.resolves(null); + const res = await baseModule.eraseRoomLooseValidation('does-not-exist'); + expect(res).to.be.false; + }); + + it('returns false when room.federated is true', async () => { + stubs.Rooms.findOneById.resolves({ _id: 'r', federated: true }); + const res = await baseModule.eraseRoomLooseValidation('r'); + expect(res).to.be.false; + }); + + it('returns false when app pre-delete prevents deletion', async () => { + const listenerStub = sandbox.stub().resolves(true); + const AppsStub = { + AppEvents: stubs['@rocket.chat/apps'].AppEvents, + Apps: { + self: { + isLoaded: () => true, + triggerEvent: listenerStub, + }, + }, + }; + + const m = proxyquire('./eraseTeam', { + '@rocket.chat/apps': AppsStub, + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + + stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false }); + + const res = await m.eraseRoomLooseValidation('r'); + expect(listenerStub.calledOnce).to.be.true; + expect(res).to.be.false; + }); + + it('logs and returns false when deleteRoom throws', async () => { + stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false }); + stubs.deleteRoomStub.rejects(new Error('boom')); + + const m = proxyquire('./eraseTeam', { + '@rocket.chat/apps': stubs['@rocket.chat/apps'], + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + + const res = await m.eraseRoomLooseValidation('r'); + expect(res).to.be.false; + sinon.assert.calledOnce(stubs['../../lib/logger/system'].SystemLogger.error); + }); + + it('calls post-deleted event and returns true on success', async () => { + const roomEventStub = sandbox.stub().onFirstCall().resolves(false).onSecondCall().resolves(); + const AppsStub = { + AppEvents: stubs['@rocket.chat/apps'].AppEvents, + Apps: { + self: { + isLoaded: () => true, + triggerEvent: roomEventStub, + }, + }, + }; + + stubs.deleteRoomStub.resolves(); + const m = proxyquire('./eraseTeam', { + '@rocket.chat/apps': AppsStub, + '@rocket.chat/models': stubs['@rocket.chat/models'], + '../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub }, + '../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub }, + '../../lib/logger/system': stubs['../../lib/logger/system'], + '@rocket.chat/core-services': { + MeteorError: stubs['@rocket.chat/core-services'].MeteorError, + Team: stubs.Team, + }, + }); + + stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false }); + + const res = await m.eraseRoomLooseValidation('r'); + expect(res).to.be.true; + sinon.assert.calledTwice(roomEventStub); + }); + }); +}); diff --git a/apps/meteor/server/api/lib/eraseTeam.ts b/apps/meteor/server/api/lib/eraseTeam.ts new file mode 100644 index 0000000..06057ad --- /dev/null +++ b/apps/meteor/server/api/lib/eraseTeam.ts @@ -0,0 +1,98 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { MeteorError, Team } from '@rocket.chat/core-services'; +import type { IRoom, ITeam, IUser, AtLeast } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +import { eraseRoom } from '../../lib/eraseRoom'; +import { SystemLogger } from '../../lib/logger/system'; +import { deleteRoom } from '../../lib/rooms/deleteRoom'; + +type EraseRoomFnType = >(rid: string, user: T) => Promise; + +export const eraseTeamShared = async >( + user: T, + team: ITeam, + roomsToRemove: IRoom['_id'][] = [], + eraseRoomFn: EraseRoomFnType, +) => { + const rooms: string[] = roomsToRemove.length + ? (await Team.getMatchingTeamRooms(team._id, roomsToRemove)).filter((roomId) => roomId !== team.roomId) + : []; + + if (!user) { + throw new MeteorError('Invalid user provided for erasing team', 'error-invalid-user', { + method: 'eraseTeamShared', + }); + } + + // If we got a list of rooms to delete along with the team, remove them first + await Promise.all(rooms.map((room) => eraseRoomFn(room, user))); + + // Move every other room back to the workspace + await Team.unsetTeamIdOfRooms(user, team); + + const room = await Rooms.findOneById>(team.roomId, { projection: { _id: 1 } }); + if (room) { + await eraseRoomFn(team.roomId, user); + } + + // Delete all team memberships + await Team.removeAllMembersFromTeam(team._id); + + // And finally delete the team itself + await Team.deleteById(team._id); +}; + +export const eraseTeam = async (user: IUser, team: ITeam, roomsToRemove: IRoom['_id'][]) => { + await eraseTeamShared(user, team, roomsToRemove, async (rid, user) => { + return eraseRoom(rid, user); + }); +}; + +/** + * @param team + * @param roomsToRemove + * @returns deleted room ids + */ +export const eraseTeamOnRelinquishRoomOwnerships = async (team: ITeam, roomsToRemove: IRoom['_id'][] = []): Promise => { + const deletedRooms = new Set(); + await eraseTeamShared({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket.Cat' } as IUser, team, roomsToRemove, async (rid) => { + const isDeleted = await eraseRoomLooseValidation(rid); + if (isDeleted) { + deletedRooms.add(rid); + } + }); + return Array.from(deletedRooms); +}; + +export async function eraseRoomLooseValidation(rid: string): Promise { + const room = await Rooms.findOneById(rid); + + if (!room) { + return false; + } + + if (room.federated) { + return false; + } + + if (Apps.self?.isLoaded()) { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomDeletePrevent, room); + if (prevent) { + return false; + } + } + + try { + await deleteRoom(rid); + } catch (err) { + SystemLogger.error({ err }); + return false; + } + + if (Apps.self?.isLoaded()) { + void Apps.self?.triggerEvent(AppEvents.IPostRoomDeleted, room); + } + + return true; +} diff --git a/apps/meteor/server/api/lib/getInstanceList.ts b/apps/meteor/server/api/lib/getInstanceList.ts new file mode 100644 index 0000000..d63cb1e --- /dev/null +++ b/apps/meteor/server/api/lib/getInstanceList.ts @@ -0,0 +1,4 @@ +import { makeFunction } from '@rocket.chat/patch-injection'; +import type { BrokerNode } from 'moleculer'; + +export const getInstanceList = makeFunction(async (): Promise => []); diff --git a/apps/meteor/server/api/lib/getPaginationItems.ts b/apps/meteor/server/api/lib/getPaginationItems.ts new file mode 100644 index 0000000..544acd5 --- /dev/null +++ b/apps/meteor/server/api/lib/getPaginationItems.ts @@ -0,0 +1,37 @@ +// If the count query param is higher than the "API_Upper_Count_Limit" setting, then we limit that +// If the count query param isn't defined, then we set it to the "API_Default_Count" setting +// If the count is zero, then that means unlimited and is only allowed if the setting "API_Allow_Infinite_Count" is true +import { settings } from '../../settings'; + +export async function getPaginationItems(params: { offset?: string | number | null; count?: string | number | null }): Promise<{ + readonly offset: number; + readonly count: number; +}> { + const hardUpperLimitTest = settings.get('API_Upper_Count_Limit'); + const defaultCountTest = settings.get('API_Default_Count'); + + const hardUpperLimit = hardUpperLimitTest && hardUpperLimitTest <= 0 ? 100 : settings.get('API_Upper_Count_Limit'); + const defaultCount = defaultCountTest && defaultCountTest <= 0 ? 50 : settings.get('API_Default_Count'); + const offset = params.offset ? parseInt(String(params.offset || 0)) : 0; + let count = defaultCount; + + // Ensure count is an appropriate amount + if (params.count !== undefined && params.count !== null) { + count = parseInt(String(params.count || 0)); + } else { + count = defaultCount; + } + + if (count > hardUpperLimit) { + count = hardUpperLimit; + } + + if (count === 0 && !settings.get('API_Allow_Infinite_Count')) { + count = defaultCount; + } + + return { + offset, + count, + }; +} diff --git a/apps/meteor/server/api/lib/getServerInfo.spec.ts b/apps/meteor/server/api/lib/getServerInfo.spec.ts new file mode 100644 index 0000000..1b543e9 --- /dev/null +++ b/apps/meteor/server/api/lib/getServerInfo.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { describe, it, before } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasAllPermissionAsyncMock = sinon.stub(); +const getCachedSupportedVersionsTokenMock = sinon.stub(); + +// #ToDo: Fix those tests in a separate PR +describe.skip('#getServerInfo()', () => { + let getServerInfo: any; + + before(() => { + const { getServerInfo: importedGetServerInfo } = proxyquire.noCallThru().load('./getServerInfo', { + '../../../app/utils/rocketchat.info': { + Info: { + version: '3.0.1', + }, + }, + '../../lib/authorization/hasPermission': { + hasPermissionAsync: hasAllPermissionAsyncMock, + }, + '../../lib/cloud/supportedVersionsToken/supportedVersionsToken': { + getCachedSupportedVersionsToken: getCachedSupportedVersionsTokenMock, + }, + '../../settings': { + settings: new Map(), + }, + }); + + getServerInfo = importedGetServerInfo; + }); + + beforeEach(() => { + hasAllPermissionAsyncMock.reset(); + getCachedSupportedVersionsTokenMock.reset(); + }); + + it('should return only the version (without the patch info) when the user is not present', async () => { + expect(await getServerInfo(undefined)).to.be.eql({ version: '3.0' }); + }); + + it('should return only the version (without the patch info) when the user present but they dont have permission', async () => { + hasAllPermissionAsyncMock.resolves(false); + expect(await getServerInfo('userId')).to.be.eql({ version: '3.0' }); + }); + + it('should return the info object + the supportedVersions from the cloud when the request to the cloud was a success', async () => { + const signedJwt = 'signedJwt'; + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.resolves(signedJwt); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1', supportedVersions: signedJwt } }); + }); + + it('should return the info object ONLY from the cloud when the request to the cloud was NOT a success', async () => { + hasAllPermissionAsyncMock.resolves(true); + getCachedSupportedVersionsTokenMock.rejects(); + expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1' } }); + }); +}); diff --git a/apps/meteor/server/api/lib/getServerInfo.ts b/apps/meteor/server/api/lib/getServerInfo.ts new file mode 100644 index 0000000..e52ccd0 --- /dev/null +++ b/apps/meteor/server/api/lib/getServerInfo.ts @@ -0,0 +1,34 @@ +import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; + +import { getTrimmedServerVersion } from './getTrimmedServerVersion'; +import { Info, minimumClientVersions } from '../../../app/utils/rocketchat.info'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { getCachedSupportedVersionsToken, wrapPromise } from '../../lib/cloud/supportedVersionsToken/supportedVersionsToken'; +import { settings } from '../../settings'; + +export async function getServerInfo(userId?: string): Promise { + const hasPermissionToViewStatistics = userId && (await hasPermissionAsync(userId, 'view-statistics')); + const supportedVersionsToken = await wrapPromise(getCachedSupportedVersionsToken()); + const cloudWorkspaceId = settings.get('Cloud_Workspace_Id'); + + return { + workspaceUrl: License.getWorkspaceUrl(), + hashedWorkspaceUrl: License.getHashedWorkspaceUrl(), + version: getTrimmedServerVersion(), + ...(hasPermissionToViewStatistics && { + info: { + ...Info, + }, + version: Info.version, + }), + + minimumClientVersions, + ...(supportedVersionsToken.success && + supportedVersionsToken.result && { + supportedVersions: { signed: supportedVersionsToken.result }, + }), + + cloudWorkspaceId, + }; +} diff --git a/apps/meteor/server/api/lib/getTrimmedServerVersion.ts b/apps/meteor/server/api/lib/getTrimmedServerVersion.ts new file mode 100644 index 0000000..7ac4dcf --- /dev/null +++ b/apps/meteor/server/api/lib/getTrimmedServerVersion.ts @@ -0,0 +1,4 @@ +import { Info } from '../../../app/utils/rocketchat.info'; + +// Removes the patch version from the server version string +export const getTrimmedServerVersion = (): string => Info.version.replace(/(\d+\.\d+).*/, '$1'); diff --git a/apps/meteor/server/api/lib/getUploadFormData.spec.ts b/apps/meteor/server/api/lib/getUploadFormData.spec.ts new file mode 100644 index 0000000..bcca1cf --- /dev/null +++ b/apps/meteor/server/api/lib/getUploadFormData.spec.ts @@ -0,0 +1,196 @@ +import { ReadableStream } from 'stream/web'; + +import { expect } from 'chai'; + +import { getUploadFormData } from './getUploadFormData'; + +const createMockRequest = ( + fields: Record, + file?: { + fieldname: string; + filename: string; + content: string | Buffer; + mimetype?: string; + }, + options: { simulateError?: boolean } = {}, +): Request => { + const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'; + const parts: string[] = []; + + if (file) { + parts.push( + `--${boundary}`, + `Content-Disposition: form-data; name="${file.fieldname}"; filename="${file.filename}"`, + `Content-Type: ${file.mimetype || 'application/octet-stream'}`, + '', + file.content.toString(), + ); + } + + for (const [name, value] of Object.entries(fields)) { + parts.push(`--${boundary}`, `Content-Disposition: form-data; name="${name}"`, '', value); + } + + parts.push(`--${boundary}--`); + + const buffer = Buffer.from(parts.join('\r\n')); + + const mockRequest: any = { + headers: { + entries: () => [['content-type', `multipart/form-data; boundary=${boundary}`]], + }, + body: new ReadableStream({ + async pull(controller) { + if (options.simulateError) { + controller.error(new Error('aborted')); + return; + } + controller.enqueue(buffer); + controller.close(); + }, + }), + }; + + return mockRequest as Request & { headers: Record }; +}; + +describe('getUploadFormData', () => { + it('should successfully parse a single file upload and fields', async () => { + const mockRequest = createMockRequest( + { fieldName: 'fieldValue' }, + { + fieldname: 'fileField', + filename: 'test.txt', + content: 'Hello, this is a test file!', + mimetype: 'text/plain', + }, + ); + + const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' }); + + expect(result).to.deep.include({ + fieldname: 'fileField', + filename: 'test.txt', + mimetype: 'text/plain', + fields: { fieldName: 'fieldValue' }, + }); + + expect(result.fileBuffer).to.not.be.undefined; + expect(result.fileBuffer.toString()).to.equal('Hello, this is a test file!'); + }); + it('should parse a file upload with multiple additional fields', async () => { + const mockRequest = createMockRequest( + { + fieldName: 'fieldValue', + extraField1: 'extraValue1', + extraField2: 'extraValue2', + }, + { + fieldname: 'fileField', + filename: 'test_with_fields.txt', + content: 'This file has additional fields!', + mimetype: 'text/plain', + }, + ); + + const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' }); + + expect(result).to.deep.include({ + fieldname: 'fileField', + filename: 'test_with_fields.txt', + mimetype: 'text/plain', + fields: { + fieldName: 'fieldValue', + extraField1: 'extraValue1', + extraField2: 'extraValue2', + }, + }); + + expect(result.fileBuffer).to.not.be.undefined; + expect(result.fileBuffer.toString()).to.equal('This file has additional fields!'); + }); + + it('should handle a file upload when fileOptional is true', async () => { + const mockRequest = createMockRequest( + { fieldName: 'fieldValue' }, + { + fieldname: 'fileField', + filename: 'optional.txt', + content: 'This file is optional!', + mimetype: 'text/plain', + }, + ); + + const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true }); + + expect(result).to.deep.include({ + fieldname: 'fileField', + filename: 'optional.txt', + mimetype: 'text/plain', + fields: { fieldName: 'fieldValue' }, + }); + + expect(result.fileBuffer).to.not.be.undefined; + expect(result.fileBuffer?.toString()).to.equal('This file is optional!'); + }); + + it('should throw an error when no file is uploaded and fileOptional is false', async () => { + const mockRequest = createMockRequest({ fieldName: 'fieldValue' }); + + try { + await getUploadFormData({ request: mockRequest }, { fileOptional: false }); + throw new Error('Expected function to throw'); + } catch (error) { + expect((error as Error).message).to.equal('[No file uploaded]'); + } + }); + + it('should return fields without errors when no file is uploaded but fileOptional is true', async () => { + const mockRequest = createMockRequest({ fieldName: 'fieldValue' }); // No file + + const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true }); + + expect(result).to.deep.equal({ + fields: { fieldName: 'fieldValue' }, + file: undefined, + fileBuffer: undefined, + fieldname: undefined, + filename: undefined, + encoding: undefined, + mimetype: undefined, + }); + }); + + it('should reject an oversized file', async () => { + const mockRequest = createMockRequest( + {}, + { + fieldname: 'fileField', + filename: 'large.txt', + content: 'x'.repeat(1024 * 1024 * 2), // 2 MB file + mimetype: 'text/plain', + }, + ); + + try { + await getUploadFormData( + { request: mockRequest }, + { sizeLimit: 1024 * 1024 }, // 1 MB limit + ); + throw new Error('Expected function to throw'); + } catch (error) { + expect((error as Error).message).to.equal('[error-file-too-large]'); + } + }); + + it('should handle an aborted request stream', async () => { + const mockRequest = createMockRequest({}, undefined, { simulateError: true }); + + try { + await getUploadFormData({ request: mockRequest }, { fileOptional: true }); + throw new Error('Expected function to throw'); + } catch (error) { + expect((error as Error).message).to.equal('aborted'); + } + }); +}); diff --git a/apps/meteor/server/api/lib/getUploadFormData.ts b/apps/meteor/server/api/lib/getUploadFormData.ts new file mode 100644 index 0000000..02dc16e --- /dev/null +++ b/apps/meteor/server/api/lib/getUploadFormData.ts @@ -0,0 +1,175 @@ +import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; + +import { MeteorError } from '@rocket.chat/core-services'; +import type { ValidateFunction } from 'ajv'; +import busboy from 'busboy'; + +import { getMimeType } from '../../../app/utils/lib/mimeTypes'; + +type UploadResult = { + file: Readable & { truncated: boolean }; + fieldname: string; + filename: string; + encoding: string; + mimetype: string; + fileBuffer: Buffer; + fields: K; +}; + +type UploadResultWithOptionalFile = + | UploadResult + | ({ + [P in keyof Omit, 'fields'>]: undefined; + } & { + fields: K; + }); + +export async function getUploadFormData< + T extends string, + K extends Record = Record, + V extends ValidateFunction = ValidateFunction, +>( + { request }: { request: Request }, + options: { + field?: T; + validate?: V; + sizeLimit?: number; + fileOptional: true; + }, +): Promise>; + +export async function getUploadFormData< + T extends string, + K extends Record = Record, + V extends ValidateFunction = ValidateFunction, +>( + { request }: { request: Request }, + options?: { + field?: T; + validate?: V; + sizeLimit?: number; + fileOptional?: false | undefined; + }, +): Promise>; + +export async function getUploadFormData< + T extends string, + K extends Record = Record, + V extends ValidateFunction = ValidateFunction, +>( + { request }: { request: Request }, + options: { + field?: T; + validate?: V; + sizeLimit?: number; + fileOptional?: boolean; + } = {}, +): Promise> { + if (!(request.body instanceof ReadableStream)) { + return Promise.reject(new MeteorError('Invalid request body')); + } + + const limits = { + files: 1, + ...(options.sizeLimit && options.sizeLimit > -1 && { fileSize: options.sizeLimit }), + }; + + const bb = busboy({ headers: Object.fromEntries(request.headers.entries()), defParamCharset: 'utf8', limits }); + const fields = Object.create(null) as K; + + let uploadedFile: UploadResultWithOptionalFile | undefined = { + fields, + encoding: undefined, + filename: undefined, + fieldname: undefined, + mimetype: undefined, + fileBuffer: undefined, + file: undefined, + }; + + const { promise: resultPromise, resolve, reject } = Promise.withResolvers>(); + + function onField(fieldname: keyof K, value: K[keyof K]) { + fields[fieldname] = value; + } + + function onEnd() { + if (!uploadedFile) { + return reject(new MeteorError('No file or fields were uploaded')); + } + if (!options.fileOptional && !uploadedFile?.file) { + return reject(new MeteorError('No file uploaded')); + } + if (options.validate !== undefined && !options.validate(fields)) { + return reject( + new MeteorError(`Invalid fields ${(options.validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join(', ')}`), + ); + } + return resolve(uploadedFile); + } + + function onFile( + fieldname: string, + file: Readable & { truncated: boolean }, + { filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string }, + ) { + if (options.field && fieldname !== options.field) { + file.resume(); + return reject(new MeteorError('invalid-field')); + } + + const fileChunks: Uint8Array[] = []; + file.on('data', (chunk) => { + fileChunks.push(chunk); + }); + + file.on('end', () => { + if (file.truncated) { + fileChunks.length = 0; + return reject(new MeteorError('error-file-too-large')); + } + + uploadedFile = { + file, + filename, + encoding, + mimetype: getMimeType(mimetype, filename), + fieldname, + fields, + fileBuffer: Buffer.concat(fileChunks), + }; + }); + } + + function cleanup() { + bb.removeAllListeners(); + } + + bb.on('field', onField); + bb.on('file', onFile); + bb.on('close', cleanup); + bb.on('end', onEnd); + bb.on('finish', onEnd); + + bb.on('error', (err: Error) => { + reject(err); + }); + + bb.on('partsLimit', () => { + reject(); + }); + bb.on('filesLimit', () => { + reject('Just 1 file is allowed'); + }); + bb.on('fieldsLimit', () => { + reject(); + }); + + // Unclear why typescript complains that the ReadableStream from request.body is incompatible here + Readable.fromWeb(request.body satisfies ReadableStream) + .on('error', (err) => reject(err)) + .pipe(bb); + + return resultPromise; +} diff --git a/apps/meteor/server/api/lib/getUserFromParams.ts b/apps/meteor/server/api/lib/getUserFromParams.ts new file mode 100644 index 0000000..1ec636c --- /dev/null +++ b/apps/meteor/server/api/lib/getUserFromParams.ts @@ -0,0 +1,98 @@ +// Convenience method, almost need to turn it into a middleware of sorts +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +export async function getUserFromParams( + params: { + userId?: string; + username?: string; + user?: string; + }, + full?: T, +): Promise< + T extends true + ? IUser + : Pick +> { + let user; + + const projection = full + ? {} + : { username: 1, name: 1, status: 1, statusDefault: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1, roles: 1 }; + if (params.userId?.trim()) { + user = await Users.findOneById(params.userId, { projection }); + } else if (params.username?.trim()) { + user = await Users.findOneByUsernameIgnoringCase(params.username, { projection }); + } else if (params.user?.trim()) { + user = await Users.findOneByUsernameIgnoringCase(params.user, { projection }); + } else { + throw new Meteor.Error('error-user-param-not-provided', 'The required "userId" or "username" param was not provided'); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The required "userId" or "username" param provided does not match any users'); + } + + return user; +} + +export async function getUserListFromParams(params: { + userId?: string; + username?: string; + user?: string; + userIds?: string[]; + usernames?: string[]; +}): Promise[]> { + // if params.userId is provided, include it as well + const soleUser = params.userId || params.username || params.user; + let userListParam = params.userIds || params.usernames || []; + userListParam.push(soleUser || ''); + userListParam = userListParam.filter(Boolean); + + // deduplicate to avoid errors + userListParam = [...new Set(userListParam)]; + + if (!userListParam.length) { + throw new Meteor.Error('error-users-params-not-provided', 'Please provide "userId" or "username" or "userIds" or "usernames" as param'); + } + + if (params.userIds || params.userId) { + return Users.findByIds(userListParam, { projection: { username: 1 } }).toArray(); + } + + return Users.findByUsernamesIgnoringCase(userListParam, { projection: { username: 1 } }).toArray(); +} + +/** + * Resolves a list of usernames from the request params without requiring the users to + * already exist locally. `username`/`usernames`/`user` are passed through verbatim, while + * `userId`/`userIds` are resolved to their usernames via the database. + * + * Unlike `getUserListFromParams`, this does not drop usernames that have no local record yet + * — which is what federation invites rely on: the federated user record is created lazily + * inside `addUsersToRoomMethod`. + */ +export async function getUsernameListFromParams(params: { + userId?: string; + username?: string; + user?: string; + userIds?: string[]; + usernames?: string[]; +}): Promise { + const usernames = [...(params.usernames || []), params.username, params.user].filter(isTruthy); + const userIds = [...(params.userIds || []), params.userId].filter(isTruthy); + + const usernamesFromIds = userIds.length + ? (await Users.findByIds(userIds, { projection: { username: 1 } }).toArray()).map((u) => u.username).filter(isTruthy) + : []; + + const all = [...new Set([...usernames, ...usernamesFromIds])]; + + if (!all.length) { + throw new Meteor.Error('error-users-params-not-provided', 'Please provide "userId" or "username" or "userIds" or "usernames" as param'); + } + + return all; +} diff --git a/apps/meteor/server/api/lib/getUserInfo.spec.ts b/apps/meteor/server/api/lib/getUserInfo.spec.ts new file mode 100644 index 0000000..9716fcc --- /dev/null +++ b/apps/meteor/server/api/lib/getUserInfo.spec.ts @@ -0,0 +1,257 @@ +import { getUserInfo } from './getUserInfo'; +import type { CachedSettings } from '../../settings/CachedSettings'; + +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../app/utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + +jest.mock('@rocket.chat/models', () => ({ + Users: { + findOneById: jest.fn().mockResolvedValue({ + id: '123', + name: 'Test User', + emails: [{ address: 'test@example.com' }], + }), + }, +})); + +const settings = new Map(); + +jest.mock('../../settings', () => ({ + settings: { + getByRegexp(_id) { + return [...settings].filter(([key]) => key.match(_id)) as any; + }, + get(_id) { + return settings.get(_id) as any; + }, + set(record) { + settings.set(record._id, record.value); + }, + } satisfies Partial, +})); + +// @ts-expect-error __meteor_runtime_config__ is not defined in the type definitions +global.__meteor_runtime_config__ = { + ROOT_URL: 'http://localhost:3000', + ROOT_URL_PATH_PREFIX: '', +}; + +describe('getUserInfo', () => { + let user: Parameters[0]; + + beforeEach(() => { + settings.clear(); + settings.set('Site_Url', 'http://localhost:3000'); + user = { + _id: '123', + createdAt: new Date(), + roles: [], + type: 'user', + active: true, + _updatedAt: new Date(), + }; + }); + + it('should return user info', async () => { + const userInfo = await getUserInfo(user); + + expect(userInfo).toEqual( + expect.objectContaining({ + _id: '123', + type: 'user', + roles: [], + active: true, + _updatedAt: expect.any(Date), + createdAt: expect.any(Date), + email: undefined, + avatarUrl: 'http://localhost:3000/avatar/undefined', + settings: { + calendar: {}, + profile: {}, + preferences: {}, + }, + }), + ); + }); + + it("should NOT return user preferences when 'pullPreferences' param is false", async () => { + const userInfo = await getUserInfo(user, false); + expect(userInfo.settings).not.toHaveProperty('preferences'); + }); + + describe('email handling', () => { + it('should not include email if no emails are present', async () => { + user.emails = []; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + + it('should include email if one email is present and verified', async () => { + user.emails = [{ address: 'test@example.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toEqual('test@example.com'); + }); + + it('should not include email if one email is present and not verified', async () => { + user.emails = [{ address: 'test@example.com', verified: false }]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + + it('should include email if multiple emails are present and one is verified', async () => { + user.emails = [ + { address: 'test@example.com', verified: false }, + { address: 'test2@example.com', verified: true }, + ]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toEqual('test2@example.com'); + }); + + it('should not include email if multiple emails are present and none are verified', async () => { + user.emails = [ + { address: 'test@example.com', verified: false }, + { address: 'test2@example.com', verified: false }, + ]; + const userInfo = await getUserInfo(user); + expect(userInfo.email).toBe(undefined); + }); + }); + + describe('outlook calendar settings', () => { + beforeEach(() => { + settings.set('Outlook_Calendar_Enabled', true); + settings.set('Outlook_Calendar_Exchange_Url', 'https://outlook.office365.com/'); + settings.set('Outlook_Calendar_Outlook_Url', 'https://outlook.office365.com/owa/#calendar/view/month'); + settings.set('Outlook_Calendar_Url_Mapping', JSON.stringify({})); + user.emails = [{ address: 'test@example.com', verified: true }]; + }); + + it('should return empty calendar settings if Outlook is disabled', async () => { + settings.set('Outlook_Calendar_Enabled', false); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({}); + }); + + it('should return calendar settings with Outlook enabled and default URLs', async () => { + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar?.outlook).toEqual({ + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }); + }); + + it('should return calendar settings with Outlook enabled and domain mapping', async () => { + settings.set( + 'Outlook_Calendar_Url_Mapping', + JSON.stringify({ + 'example.com': { Exchange_Url: 'https://custom.exchange.com/', Outlook_Url: 'https://custom.outlook.com/' }, + }), + ); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://custom.exchange.com/', + Outlook_Url: 'https://custom.outlook.com/', + }, + }); + }); + + it('should return calendar settings with outlook disabled but enabled for specific domain', async () => { + settings.set('Outlook_Calendar_Enabled', false); + settings.set( + 'Outlook_Calendar_Url_Mapping', + JSON.stringify({ + 'specific.com': { Enabled: true, Exchange_Url: 'https://specific.exchange.com/', Outlook_Url: 'https://specific.outlook.com/' }, + }), + ); + user.emails = [{ address: 'me@specific.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://specific.exchange.com/', + Outlook_Url: 'https://specific.outlook.com/', + }, + }); + }); + + it('should return calendar settings with Outlook enabled and default mapping for unknown domain', async () => { + user.emails = [{ address: 'unknown@example.com', verified: true }]; + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }, + }); + }); + + it('should handle invalid JSON in Outlook_Calendar_Url_Mapping', async () => { + settings.set('Outlook_Calendar_Url_Mapping', 'invalid json'); + const userInfo = await getUserInfo(user); + expect(userInfo.settings?.calendar).toEqual({ + outlook: { + Enabled: true, + Exchange_Url: 'https://outlook.office365.com/', + Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month', + }, + }); + }); + }); + + describe('version update banner filtering', () => { + beforeEach(() => { + mockInfoVersion.mockReturnValue('7.5.0'); + }); + + it('should filter out versionUpdate banners for versions <= current installed', async () => { + user.banners = { + 'versionUpdate-6_2_0': { + id: 'versionUpdate-6_2_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toEqual({}); + }); + + it('should keep versionUpdate banners for versions > current installed', async () => { + user.banners = { + 'versionUpdate-8_0_0': { + id: 'versionUpdate-8_0_0', + priority: 10, + title: 'Update', + text: 'New version', + modifiers: [], + link: '', + read: false, + }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('versionUpdate-8_0_0'); + }); + + it('should keep non-versionUpdate banners unchanged', async () => { + user.banners = { + 'other-banner': { id: 'other-banner', priority: 10, title: 'Other', text: 'Other banner', modifiers: [], link: '', read: false }, + }; + const userInfo = await getUserInfo(user); + expect(userInfo.banners).toHaveProperty('other-banner'); + }); + }); +}); diff --git a/apps/meteor/server/api/lib/getUserInfo.ts b/apps/meteor/server/api/lib/getUserInfo.ts new file mode 100644 index 0000000..685555a --- /dev/null +++ b/apps/meteor/server/api/lib/getUserInfo.ts @@ -0,0 +1,129 @@ +import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; +import semver from 'semver'; + +import { Info } from '../../../app/utils/rocketchat.info'; +import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers'; +import { getURL } from '../../lib/utils/getURL'; +import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; +import { settings } from '../../settings'; + +const isVerifiedEmail = (me: IUser): false | IUserEmail | undefined => { + if (!me || !Array.isArray(me.emails)) { + return false; + } + + return me.emails.find((email) => email.verified); +}; + +const getPreferencesWithDefaults = async (me: IUser): Promise> => { + const defaultUserSettingPrefix = 'Accounts_Default_User_Preferences_'; + const allDefaultUserSettings = settings.getByRegexp(new RegExp(`^${defaultUserSettingPrefix}.*$`)); + + const accumulator: Record = {}; + for (const [key] of allDefaultUserSettings) { + const settingWithoutPrefix = key.replace(defaultUserSettingPrefix, ' ').trim(); + accumulator[settingWithoutPrefix] = await getUserPreference(me, settingWithoutPrefix); + } + + return accumulator; +}; + +const filterOutdatedVersionUpdateBanners = (banners: NonNullable): IUser['banners'] => { + return Object.fromEntries( + Object.entries(banners).filter(([id]) => { + if (!id.startsWith('versionUpdate-')) { + return true; + } + + const version = id.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + + return true; + }), + ); +}; + +/** + * Returns the user's calendar settings based on their email domain and the configured mapping. + * If the email is not provided or the domain is not found in the mapping, + * it returns the default Outlook calendar settings. + * @param email - The user's email object, which may contain the address and verification status. + * @returns The calendar settings for the user, including Outlook calendar settings if enabled. + */ +const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar => { + const calendarSettings: IUserCalendar = {}; + + const outlook = { + Enabled: settings.get('Outlook_Calendar_Enabled'), + Exchange_Url: settings.get('Outlook_Calendar_Exchange_Url'), + Outlook_Url: settings.get('Outlook_Calendar_Outlook_Url'), + }; + + const domain = email ? email.address.split('@').pop() : undefined; + const outlookCalendarUrlMapping = settings.get('Outlook_Calendar_Url_Mapping'); + + if (domain && outlookCalendarUrlMapping && outlookCalendarUrlMapping.includes(domain)) { + try { + const mappingObject = JSON.parse(outlookCalendarUrlMapping); + const mappedSettings = mappingObject[domain]; + if (mappedSettings) { + outlook.Enabled = mappedSettings.Enabled ?? outlook.Enabled; + outlook.Exchange_Url = mappedSettings.Exchange_Url ?? outlook.Exchange_Url; + outlook.Outlook_Url = mappedSettings.Outlook_Url ?? outlook.Outlook_Url; + } + } catch (error) { + console.error('Invalid Outlook Calendar URL Mapping JSON:', error); + } + } + + if (outlook.Enabled) { + calendarSettings.outlook = outlook; + } + + return calendarSettings; +}; + +export async function getUserInfo(me: IUser, pullPreferences = true): Promise { + const verifiedEmail = isVerifiedEmail(me); + + const { statusVisibilityDenied, ...savedPreferences } = me.settings?.preferences ?? {}; + + const preferences = pullPreferences + ? { + ...(await getPreferencesWithDefaults(me)), + ...savedPreferences, + ...(settings.get('Accounts_StatusVisibility_Enabled') && + statusVisibilityDenied?.length && { + statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames, + }), + } + : undefined; + + return { + ...me, + ...(me.banners && { banners: filterOutdatedVersionUpdateBanners(me.banners) }), + email: verifiedEmail ? verifiedEmail.address : undefined, + settings: { + profile: {}, + ...(preferences && { preferences }), + calendar: getUserCalendar(verifiedEmail), + }, + avatarUrl: getURL(`/avatar/${me.username}`, { cdn: false, full: true }), + isOAuthUser: isOAuthUser(me), + ...(me.services && { + services: { + ...(me.services.github && { github: me.services.github as Record }), + ...(me.services.gitlab && { gitlab: me.services.gitlab as Record }), + ...(me.services.email2fa?.enabled && { email2fa: { enabled: me.services.email2fa.enabled } }), + ...(me.services.totp?.enabled && { totp: { enabled: me.services.totp.enabled } }), + password: { + // The password hash shouldn't be leaked but the client may need to know if it exists. + exists: Boolean(me.services?.password?.bcrypt), + }, + }, + }), + // Cast needed: spread of full IUser produces a superset; runtime response schema validates the actual shape + } as IMeApiUser; +} diff --git a/apps/meteor/server/api/lib/integrations.ts b/apps/meteor/server/api/lib/integrations.ts new file mode 100644 index 0000000..2131265 --- /dev/null +++ b/apps/meteor/server/api/lib/integrations.ts @@ -0,0 +1,40 @@ +import type { IIntegration, IUser } from '@rocket.chat/core-typings'; +import { Integrations } from '@rocket.chat/models'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; + +const hasIntegrationsPermission = async (userId: string, integration: IIntegration): Promise => { + const type = integration.type === 'webhook-incoming' ? 'incoming' : 'outgoing'; + + if (await hasPermissionAsync(userId, `manage-${type}-integrations`)) { + return true; + } + + if (userId === integration._createdBy?._id) { + return hasPermissionAsync(userId, `manage-own-${type}-integrations`); + } + + return false; +}; + +export const findOneIntegration = async ({ + userId, + integrationId, + createdBy, +}: { + userId: string; + integrationId: string; + createdBy?: IUser['_id']; +}): Promise => { + const integration = await Integrations.findOneByIdAndCreatedByIfExists({ + _id: integrationId, + createdBy, + }); + if (!integration) { + throw new Error('The integration does not exists.'); + } + if (!(await hasIntegrationsPermission(userId, integration))) { + throw new Error('not-authorized'); + } + return integration; +}; diff --git a/apps/meteor/server/api/lib/isUserFromParams.ts b/apps/meteor/server/api/lib/isUserFromParams.ts new file mode 100644 index 0000000..5ee4e2b --- /dev/null +++ b/apps/meteor/server/api/lib/isUserFromParams.ts @@ -0,0 +1,14 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +export function isUserFromParams( + params: { userId?: string; username?: string; user?: string }, + loggedInUserId?: string, + loggedInUser?: IUser, +): boolean { + return Boolean( + (!params.userId && !params.username && !params.user) || + (params.userId && loggedInUserId === params.userId) || + (params.username && loggedInUser?.username === params.username) || + (params.user && loggedInUser?.username === params.user), + ); +} diff --git a/apps/meteor/server/api/lib/isValidQuery.ts b/apps/meteor/server/api/lib/isValidQuery.ts new file mode 100644 index 0000000..912c14c --- /dev/null +++ b/apps/meteor/server/api/lib/isValidQuery.ts @@ -0,0 +1,66 @@ +import { isRecord } from '@rocket.chat/tools'; + +import { removeDangerousProps } from './cleanQuery'; + +type Query = { [k: string]: any }; + +export const isValidQuery: { + (query: Query, allowedAttributes: string[], allowedOperations: string[]): boolean; + errors: string[]; +} = Object.assign( + (query: Query, allowedAttributes: string[], allowedOperations: string[]): boolean => { + isValidQuery.errors = []; + // query is an object with null prototype, so it wont be instance of Object + if (!isRecord(query)) { + throw new Error('query must be an object'); + } + + return verifyQuery(query, allowedAttributes, allowedOperations); + }, + { + errors: [], + }, +); + +const verifyQuery = (query: Query, allowedAttributes: string[], allowedOperations: string[], parent = ''): boolean => { + return Object.entries(removeDangerousProps({ ...query })).every(([key, value]) => { + const path = parent ? `${parent}.${key}` : key; + if (key.startsWith('$')) { + if (!allowedOperations.includes(key)) { + isValidQuery.errors.push(`Invalid operation: ${key}`); + return false; + } + + if (Array.isArray(value)) { + return value.every((v) => verifyQuery(v, allowedAttributes, allowedOperations)); + } + + if (isRecord(value)) { + return verifyQuery(value, allowedAttributes, allowedOperations, path); + } + + // handles primitive values (strings, numbers, booleans, etc.) + return true; + } + + if ( + !allowedAttributes.some((allowedAttribute) => { + if (allowedAttribute.endsWith('.*')) { + return path.startsWith(allowedAttribute.replace('.*', '')); + } + if (allowedAttribute.endsWith('*') && !allowedAttribute.endsWith('.*')) { + return true; + } + return path === allowedAttribute; + }) + ) { + isValidQuery.errors.push(`Invalid attribute: ${path}`); + return false; + } + + if (isRecord(value)) { + return verifyQuery(value, allowedAttributes, allowedOperations, path); + } + return true; + }); +}; diff --git a/apps/meteor/server/api/lib/isWidget.ts b/apps/meteor/server/api/lib/isWidget.ts new file mode 100644 index 0000000..258820f --- /dev/null +++ b/apps/meteor/server/api/lib/isWidget.ts @@ -0,0 +1,8 @@ +import { parse } from 'cookie'; + +export const isWidget = (headers: Headers): boolean => { + const { rc_room_type: roomType, rc_is_widget: isWidget } = parse(headers.get('cookie') || ''); + + const isLivechatRoom = roomType && roomType === 'l'; + return !!(isLivechatRoom && isWidget === 't'); +}; diff --git a/apps/meteor/server/api/lib/logMethodCallError.spec.ts b/apps/meteor/server/api/lib/logMethodCallError.spec.ts new file mode 100644 index 0000000..8ebc140 --- /dev/null +++ b/apps/meteor/server/api/lib/logMethodCallError.spec.ts @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import { describe, it, before, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const debugMock = sinon.stub(); +const errorMock = sinon.stub(); +const meteorDebugMock = sinon.stub(); +const settingsGetMock = sinon.stub(); + +describe('logMethodCallError', () => { + let logMethodCallError: (method: string, err: unknown) => void; + + before(() => { + ({ logMethodCallError } = proxyquire.noCallThru().load('./logMethodCallError', { + 'meteor/meteor': { + Meteor: { _debug: meteorDebugMock }, + }, + '../../lib/logger/system': { + SystemLogger: { debug: debugMock, error: errorMock }, + }, + '../../settings': { + settings: { get: settingsGetMock }, + }, + })); + }); + + beforeEach(() => { + debugMock.reset(); + errorMock.reset(); + meteorDebugMock.reset(); + settingsGetMock.reset(); + settingsGetMock.withArgs('Log_Level').returns('2'); + }); + + it('should not report client-safe errors as exceptions', () => { + const err = { isClientSafe: true, error: 'error-invalid-user' }; + + logMethodCallError('loadHistory', err); + + expect(meteorDebugMock.called).to.be.false; + expect(errorMock.called).to.be.false; + expect(debugMock.calledOnce).to.be.true; + expect(debugMock.calledWithExactly({ msg: 'Expected error while invoking method', err, method: 'loadHistory' })).to.be.true; + }); + + it('should not report meteor errors as exceptions', () => { + const err = { meteorError: { error: 'totp-required' } }; + + logMethodCallError('login', err); + + expect(meteorDebugMock.called).to.be.false; + expect(errorMock.called).to.be.false; + expect(debugMock.calledOnce).to.be.true; + expect(debugMock.calledWithExactly({ msg: 'Expected error while invoking method', err, method: 'login' })).to.be.true; + }); + + it('should report unexpected errors as exceptions when Log_Level is 2', () => { + const err = new Error('Match error: Expected string, got number'); + + logMethodCallError('loadHistory', err); + + expect(debugMock.called).to.be.false; + expect(errorMock.calledOnce).to.be.true; + expect(errorMock.calledWithExactly({ msg: 'Exception while invoking method', err, method: 'loadHistory' })).to.be.true; + expect(meteorDebugMock.calledOnce).to.be.true; + expect(meteorDebugMock.calledWithExactly('Exception while invoking method loadHistory', err)).to.be.true; + }); + + it('should report unexpected errors without notifying the exceptions channel when Log_Level is not 2', () => { + settingsGetMock.withArgs('Log_Level').returns('0'); + + const err = new Error('Match error: Expected string, got number'); + + logMethodCallError('loadHistory', err); + + expect(debugMock.called).to.be.false; + expect(errorMock.calledOnce).to.be.true; + expect(errorMock.calledWithExactly({ msg: 'Exception while invoking method', err, method: 'loadHistory' })).to.be.true; + expect(meteorDebugMock.called).to.be.false; + }); +}); diff --git a/apps/meteor/server/api/lib/logMethodCallError.ts b/apps/meteor/server/api/lib/logMethodCallError.ts new file mode 100644 index 0000000..bdab945 --- /dev/null +++ b/apps/meteor/server/api/lib/logMethodCallError.ts @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +export function logMethodCallError(method: string, err: unknown): void { + if ((err as any)?.isClientSafe || (err as any)?.meteorError) { + SystemLogger.debug({ msg: 'Expected error while invoking method', err, method }); + return; + } + + SystemLogger.error({ msg: 'Exception while invoking method', err, method }); + + if (settings.get('Log_Level') === '2') { + Meteor._debug(`Exception while invoking method ${method}`, err); + } +} diff --git a/apps/meteor/server/api/lib/maybeMigrateLivechatRoom.ts b/apps/meteor/server/api/lib/maybeMigrateLivechatRoom.ts new file mode 100644 index 0000000..958e05f --- /dev/null +++ b/apps/meteor/server/api/lib/maybeMigrateLivechatRoom.ts @@ -0,0 +1,40 @@ +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +import { projectionAllowsAttribute } from './projectionAllowsAttribute'; +import { migrateVisitorIfMissingContact } from '../../lib/omnichannel/contacts/migrateVisitorIfMissingContact'; + +/** + * If the room is a livechat room and it doesn't yet have a contact, trigger the migration for its visitor and source + * The migration will create/use a contact and assign it to every room that matches this visitorId and source. + **/ +export async function maybeMigrateLivechatRoom( + room: IOmnichannelRoom | null, + options: FindOptions = {}, +): Promise { + if (!room || !isOmnichannelRoom(room)) { + return room; + } + + // Already migrated + if (room.contactId) { + return room; + } + + // If the query options specify that contactId is not needed, then do not trigger the migration + if (!projectionAllowsAttribute('contactId', options)) { + return room; + } + + const contactId = await migrateVisitorIfMissingContact(room.v._id, room.source); + + // Did not migrate + if (!contactId) { + return room; + } + + // Load the room again with the same options so it can be reloaded with the contactId in place + return LivechatRooms.findOneById(room._id, options); +} diff --git a/apps/meteor/server/api/lib/messages.ts b/apps/meteor/server/api/lib/messages.ts new file mode 100644 index 0000000..ac71826 --- /dev/null +++ b/apps/meteor/server/api/lib/messages.ts @@ -0,0 +1,121 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Messages, Users } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom'; + +export async function findMentionedMessages({ + uid, + roomId, + pagination: { offset, count, sort }, +}: { + uid: string; + roomId: string; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}): Promise<{ + messages: IMessage[]; + count: number; + offset: number; + total: number; +}> { + const room = await Rooms.findOneById(roomId); + if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) { + throw new Error('error-not-allowed'); + } + const user = await Users.findOneById>(uid, { projection: { username: 1 } }); + if (!user) { + throw new Error('invalid-user'); + } + + const { cursor, totalCount } = Messages.findPaginatedVisibleByMentionAndRoomId(user.username, roomId, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + messages, + count: messages.length, + offset, + total, + }; +} + +export async function findStarredMessages({ + uid, + roomId, + pagination: { offset, count, sort }, +}: { + uid: string; + roomId: string; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}): Promise<{ + messages: IMessage[]; + count: number; + offset: number; + total: number; +}> { + const room = await Rooms.findOneById(roomId); + if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) { + throw new Error('error-not-allowed'); + } + const user = await Users.findOneById>(uid, { projection: { username: 1 } }); + if (!user) { + throw new Error('invalid-user'); + } + + const { cursor, totalCount } = Messages.findStarredByUserAtRoom(uid, roomId, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + messages, + count: messages.length, + offset, + total, + }; +} + +export async function findDiscussionsFromRoom({ + uid, + roomId, + text, + pagination: { offset, count, sort }, +}: { + uid: string; + roomId: string; + text: string; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}): Promise<{ + messages: IMessage[]; + count: number; + offset: number; + total: number; +}> { + const room = await Rooms.findOneById(roomId); + + if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) { + throw new Error('error-not-allowed'); + } + + const { cursor, totalCount } = await Messages.findDiscussionsByRoomAndText(roomId, text, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + messages, + count: messages.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/lib/parseJsonQuery.ts b/apps/meteor/server/api/lib/parseJsonQuery.ts new file mode 100644 index 0000000..00389a3 --- /dev/null +++ b/apps/meteor/server/api/lib/parseJsonQuery.ts @@ -0,0 +1,159 @@ +import ejson from 'ejson'; +import { Meteor } from 'meteor/meteor'; + +import { clean } from './cleanQuery'; +import { isValidQuery } from './isValidQuery'; +import { isPlainObject } from '../../../lib/utils/isPlainObject'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { apiDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { API } from '../api'; +import type { GenericRouteExecutionContext } from '../definition'; + +const pathAllowConf = { + '/api/v1/users.list': ['$or', '$regex', '$and'], + 'def': ['$or', '$and', '$regex'], +}; + +export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise<{ + sort: Record; + /** + * @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + fields: Record; + /** + * @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable. + */ + query: Record; +}> { + const { userId = '', response, route, logger } = api; + const isUsersRoute = route.includes('/v1/users.'); + const canViewFullOtherUserInfo = isUsersRoute && (await hasPermissionAsync(userId, 'view-full-other-user-info')); + const params = isPlainObject(api.queryParams) ? api.queryParams : {}; + const queryFields = Array.isArray(api.queryFields) ? (api.queryFields as string[]) : []; + const queryOperations = Array.isArray(api.queryOperations) ? (api.queryOperations as string[]) : []; + + let sort; + if (typeof params?.sort === 'string') { + try { + sort = JSON.parse(params.sort); + Object.entries(sort).forEach(([key, value]) => { + if (value !== 1 && value !== -1) { + throw new Meteor.Error('error-invalid-sort-parameter', `Invalid sort parameter: ${key}`, { + helperMethod: 'parseJsonQuery', + }); + } + }); + } catch (e) { + logger.warn({ + msg: 'Invalid sort parameter provided', + sort: params.sort, + err: e, + }); + throw new Meteor.Error('error-invalid-sort', `Invalid sort parameter provided: \"${params.sort}\"`, { + helperMethod: 'parseJsonQuery', + }); + } + } + + const isUnsafeQueryParamsAllowed = process.env.ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS?.toUpperCase() === 'TRUE'; + const messageGenerator = ({ endpoint, version, parameter }: { endpoint: string; version: string; parameter: string }): string => + `The usage of the "${parameter}" parameter in endpoint "${endpoint}" breaks the security of the API and can lead to data exposure. It has been deprecated and will be removed in the version ${version}.`; + + let fields: Record | undefined; + if (typeof params?.fields === 'string' && isUnsafeQueryParamsAllowed) { + try { + apiDeprecationLogger.parameter(route, 'fields', '9.0.0', response, messageGenerator); + fields = JSON.parse(params.fields) as Record; + Object.entries(fields).forEach(([key, value]) => { + if (value !== 1 && value !== 0) { + throw new Meteor.Error('error-invalid-sort-parameter', `Invalid fields parameter: ${key}`, { + helperMethod: 'parseJsonQuery', + }); + } + }); + } catch (e) { + logger.warn({ + msg: 'Invalid fields parameter provided', + fields: params.fields, + err: e, + }); + throw new Meteor.Error('error-invalid-fields', `Invalid fields parameter provided: \"${params.fields}\"`, { + helperMethod: 'parseJsonQuery', + }); + } + } + + // Verify the user's selected fields only contains ones which their role allows + if (typeof fields === 'object') { + let nonSelectableFields = Object.keys(API.v1.defaultFieldsToExclude); + if (isUsersRoute) { + nonSelectableFields = nonSelectableFields.concat( + Object.keys(canViewFullOtherUserInfo ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser : API.v1.limitedUserFieldsToExclude), + ); + } + + Object.keys(fields).forEach((k) => { + if (nonSelectableFields.includes(k) || nonSelectableFields.includes(k.split(API.v1.fieldSeparator)[0])) { + fields && delete fields[k as keyof typeof fields]; + } + }); + } + + // Limit the fields by default + fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude); + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { + fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser); + } else { + fields = Object.assign(fields, API.v1.limitedUserFieldsToExclude); + } + } + + let query: Record = {}; + if (typeof params?.query === 'string' && isUnsafeQueryParamsAllowed) { + apiDeprecationLogger.parameter(route, 'query', '9.0.0', response, messageGenerator); + try { + query = ejson.parse(params.query); + query = clean(query, pathAllowConf.def); + } catch (e) { + logger.warn({ + msg: 'Invalid query parameter provided', + query: params.query, + err: e, + }); + throw new Meteor.Error('error-invalid-query', `Invalid query parameter provided: \"${params.query}\"`, { + helperMethod: 'parseJsonQuery', + }); + } + } + + // Verify the user has permission to query the fields they are + if (typeof query === 'object') { + let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude); + + if (isUsersRoute) { + if (canViewFullOtherUserInfo) { + nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser)); + } else { + nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExclude)); + } + } + + const containsQueryFields = queryFields.length > 0; + if (containsQueryFields && !isValidQuery(query, queryFields, queryOperations ?? pathAllowConf.def)) { + throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); + } + + Object.keys(query).forEach((k) => { + if (nonQueryableFields.includes(k) || nonQueryableFields.includes(k.split(API.v1.fieldSeparator)[0])) { + query && delete query[k]; + } + }); + } + + return { + sort, + fields, + query, + }; +} diff --git a/apps/meteor/server/api/lib/projectionAllowsAttribute.spec.ts b/apps/meteor/server/api/lib/projectionAllowsAttribute.spec.ts new file mode 100644 index 0000000..a637f9e --- /dev/null +++ b/apps/meteor/server/api/lib/projectionAllowsAttribute.spec.ts @@ -0,0 +1,29 @@ +import { expect } from 'chai'; + +import { projectionAllowsAttribute } from './projectionAllowsAttribute'; + +describe('projectionAllowsAttribute', () => { + it('should return true if there are no options', () => { + expect(projectionAllowsAttribute('attributeName')).to.be.equal(true); + }); + + it('should return true if there is no projection', () => { + expect(projectionAllowsAttribute('attributeName', {})).to.be.equal(true); + }); + + it('should return true if the field is projected', () => { + expect(projectionAllowsAttribute('attributeName', { projection: { attributeName: 1 } })).to.be.equal(true); + }); + + it('should return false if the field is disallowed by projection', () => { + expect(projectionAllowsAttribute('attributeName', { projection: { attributeName: 0 } })).to.be.equal(false); + }); + + it('should return false if the field is not projected and others are', () => { + expect(projectionAllowsAttribute('attributeName', { projection: { anotherAttribute: 1 } })).to.be.equal(false); + }); + + it('should return true if the field is not projected and others are disallowed', () => { + expect(projectionAllowsAttribute('attributeName', { projection: { anotherAttribute: 0 } })).to.be.equal(true); + }); +}); diff --git a/apps/meteor/server/api/lib/projectionAllowsAttribute.ts b/apps/meteor/server/api/lib/projectionAllowsAttribute.ts new file mode 100644 index 0000000..a71f6da --- /dev/null +++ b/apps/meteor/server/api/lib/projectionAllowsAttribute.ts @@ -0,0 +1,19 @@ +import type { IRocketChatRecord } from '@rocket.chat/core-typings'; +import type { FindOptions } from 'mongodb'; + +export function projectionAllowsAttribute(attributeName: string, options?: FindOptions): boolean { + if (!options?.projection) { + return true; + } + + if (attributeName in options.projection) { + return Boolean(options.projection[attributeName]); + } + + const projectingAllowedFields = Object.values(options.projection).some((value) => Boolean(value)); + + // If the attribute is not on the projection list, return the opposite of the values in the projection. aka: + // if the projection is specifying blocked fields, then this field is allowed; + // if the projection is specifying allowed fields, then this field is blocked; + return !projectingAllowedFields; +} diff --git a/apps/meteor/server/api/lib/queryFiltersStatus.spec.ts b/apps/meteor/server/api/lib/queryFiltersStatus.spec.ts new file mode 100644 index 0000000..6b7967d --- /dev/null +++ b/apps/meteor/server/api/lib/queryFiltersStatus.spec.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai'; + +import { queryFiltersStatus } from './queryFiltersStatus'; + +describe('queryFiltersStatus', () => { + it('should return false for a query without status fields', () => { + expect(queryFiltersStatus({ username: { $regex: 'ana' } })).to.be.equal(false); + }); + + it('should return true for a top level status filter', () => { + expect(queryFiltersStatus({ status: 'online' })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $or', () => { + expect(queryFiltersStatus({ $or: [{ username: { $regex: '' } }, { status: 'online' }] })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $and inside $or', () => { + expect(queryFiltersStatus({ $or: [{ $and: [{ statusText: { $regex: 'lunch' } }] }] })).to.be.equal(true); + }); + + it('should return true for any redacted status field', () => { + for (const field of ['statusText', 'statusSource', 'statusExpiresAt', 'statusDefault', 'statusConnection']) { + expect(queryFiltersStatus({ [field]: { $exists: true } })).to.be.equal(true); + } + }); + + it('should return false for null and primitive values', () => { + expect(queryFiltersStatus(null)).to.be.equal(false); + expect(queryFiltersStatus('status')).to.be.equal(false); + expect(queryFiltersStatus(undefined)).to.be.equal(false); + }); +}); diff --git a/apps/meteor/server/api/lib/queryFiltersStatus.ts b/apps/meteor/server/api/lib/queryFiltersStatus.ts new file mode 100644 index 0000000..d7c68da --- /dev/null +++ b/apps/meteor/server/api/lib/queryFiltersStatus.ts @@ -0,0 +1,11 @@ +export function queryFiltersStatus(query: unknown): boolean { + if (Array.isArray(query)) { + return query.some(queryFiltersStatus); + } + + if (query === null || typeof query !== 'object') { + return false; + } + + return Object.entries(query).some(([key, value]) => key.startsWith('status') || queryFiltersStatus(value)); +} diff --git a/apps/meteor/server/api/lib/rooms.ts b/apps/meteor/server/api/lib/rooms.ts new file mode 100644 index 0000000..341f539 --- /dev/null +++ b/apps/meteor/server/api/lib/rooms.ts @@ -0,0 +1,202 @@ +import type { IRoom, IRoomAbacRedaction, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import type { FindOptions, Sort } from 'mongodb'; + +import { scopeAdminRoomsForAbac } from './scopeAdminRoomsForAbac'; +import { adminFields } from '../../../lib/rooms/adminFields'; +import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { stripABACManagedFieldsForAdmin } from '../../lib/authorization/isABACManagedRoom'; + +export async function findAdminRooms({ + uid, + filter, + types = [], + pagination: { offset, count, sort }, +}: { + uid: string; + filter: string; + types: Array; + pagination: { offset: number; count: number; sort: Sort }; +}): Promise<{ + rooms: Array & IRoomAbacRedaction>; + count: number; + offset: number; + total: number; +}> { + if (!(await hasPermissionAsync(uid, 'view-room-administration'))) { + throw new Error('error-not-authorized'); + } + const name = filter?.trim(); + const discussion = types?.includes('discussions'); + const includeTeams = types?.includes('teams'); + const typesToRemove = ['discussions', 'teams']; + const showTypes = Array.isArray(types) ? types.filter((type): type is RoomType => !typesToRemove.includes(type)) : []; + const options: FindOptions = { + projection: adminFields, + skip: offset, + limit: count, + }; + + const result = Rooms.findByNameOrFnameContainingAndTypes(name, showTypes, discussion, includeTeams, options); + + const { cursor, totalCount } = result; + + const [rooms, total] = await Promise.all([ + cursor + .sort(sort || { default: -1, name: 1 }) + .map(stripABACManagedFieldsForAdmin) + .toArray(), + totalCount, + ]); + + return { + rooms: await scopeAdminRoomsForAbac(rooms, uid), + count: rooms.length, + offset, + total, + }; +} + +export async function findAdminRoom({ + uid, + rid, +}: { + uid: string; + rid: string; +}): Promise<(Pick & IRoomAbacRedaction) | null> { + if (!(await hasPermissionAsync(uid, 'view-room-administration'))) { + throw new Error('error-not-authorized'); + } + + const room = await Rooms.findOneById(rid, { projection: adminFields }); + if (!room) { + return null; + } + + const [scoped] = await scopeAdminRoomsForAbac([stripABACManagedFieldsForAdmin(room)], uid); + return scoped ?? null; +} + +export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{ + items: IRoom[]; +}> { + const options: FindOptions = { + projection: { + _id: 1, + fname: 1, + name: 1, + t: 1, + avatarETag: 1, + encrypted: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const userRoomsIds = (await Subscriptions.findByUserId(uid, { projection: { rid: 1 } }).toArray()).map( + (item: Pick) => item.rid, + ); + + const rooms = await Rooms.findRoomsWithoutDiscussionsByRoomIds(selector.name, userRoomsIds, options).toArray(); + + return { + items: rooms, + }; +} + +export async function findAdminRoomsAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{ + items: IRoom[]; +}> { + if (!(await hasAtLeastOnePermissionAsync(uid, ['view-room-administration', 'can-audit']))) { + throw new Error('error-not-authorized'); + } + const options: FindOptions = { + projection: { + _id: 1, + fname: 1, + name: 1, + t: 1, + avatarETag: 1, + encrypted: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const rooms = await Rooms.findRoomsByNameOrFnameStarting(selector.name, options).toArray(); + + return { + items: rooms, + }; +} + +export async function findChannelAndPrivateAutocompleteWithPagination({ + uid, + selector, + pagination: { offset, count, sort }, +}: { + uid: string; + selector: { name: string }; + pagination: { offset: number; count: number; sort: Sort }; +}): Promise<{ + items: IRoom[]; + total: number; +}> { + const userRoomsIds = (await Subscriptions.findByUserId(uid, { projection: { rid: 1 } }).toArray()).map( + (item: Pick) => item.rid, + ); + + const options: FindOptions = { + projection: { + _id: 1, + fname: 1, + name: 1, + t: 1, + avatarETag: 1, + }, + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }; + + const { cursor, totalCount } = Rooms.findPaginatedRoomsWithoutDiscussionsByRoomIds(selector.name, userRoomsIds, options); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + items: rooms, + total, + }; +} + +export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name?: string }): Promise<{ + items: IRoom[]; +}> { + const options: FindOptions = { + projection: { + _id: 1, + fname: 1, + name: 1, + t: 1, + avatarETag: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const userRooms = ( + (await Subscriptions.findByUserIdAndRoles(uid, ['owner'], { projection: { rid: 1 } }).toArray()) as Pick[] + ).map((item) => item.rid); + + const rooms = await Rooms.findChannelAndGroupListWithoutTeamsByNameStartingByOwner(name, userRooms, options).toArray(); + + return { + items: rooms, + }; +} diff --git a/apps/meteor/server/api/lib/scopeAdminRoomsForAbac.ts b/apps/meteor/server/api/lib/scopeAdminRoomsForAbac.ts new file mode 100644 index 0000000..d87d7ce --- /dev/null +++ b/apps/meteor/server/api/lib/scopeAdminRoomsForAbac.ts @@ -0,0 +1,7 @@ +import type { IRoom, IRoomAbacRedaction, RoomAdminFieldsType } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const scopeAdminRoomsForAbac = makeFunction( + async (rooms: Pick[], _uid: string): Promise & IRoomAbacRedaction>> => + rooms, +); diff --git a/apps/meteor/server/api/lib/users.ts b/apps/meteor/server/api/lib/users.ts new file mode 100644 index 0000000..1eefde1 --- /dev/null +++ b/apps/meteor/server/api/lib/users.ts @@ -0,0 +1,242 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users, Subscriptions } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { Mongo } from 'meteor/mongo'; +import type { Filter, FindOptions, RootFilterOperators } from 'mongodb'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { settings } from '../../settings'; + +type UserAutoComplete = Required>; + +export async function findUsersToAutocomplete({ + uid, + selector, +}: { + uid: string; + selector: { exceptions: Required['username'][]; conditions: Filter; term: string }; +}): Promise<{ + items: UserAutoComplete[]; +}> { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + const exceptions = selector.exceptions || []; + const conditions = selector.conditions || {}; + const options: FindOptions & { limit: number } = { + projection: { + name: 1, + username: 1, + nickname: 1, + status: 1, + avatarETag: 1, + freeSwitchExtension: 1, + }, + sort: { + username: 1, + }, + limit: 10, + }; + + // Search on DMs first, to list known users before others. + const contacts = await Subscriptions.findConnectedUsersExcept(uid, selector.term, exceptions, searchFields, conditions, 10, 'd'); + if (contacts.length >= options.limit) { + return { items: contacts as UserAutoComplete[] }; + } + + options.limit -= contacts.length; + contacts.forEach(({ username }) => exceptions.push(username)); + + if (!(await hasPermissionAsync(uid, 'view-outside-room'))) { + const users = await Subscriptions.findConnectedUsersExcept(uid, selector.term, exceptions, searchFields, conditions, 10); + return { items: contacts.concat(users) as UserAutoComplete[] }; + } + + const users = await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions( + new RegExp(escapeRegExp(selector.term), 'i'), + exceptions, + conditions, + options, + ).toArray(); + + return { + items: (contacts as UserAutoComplete[]).concat(users), + }; +} + +/** + * Returns a new query object with the inclusive fields only + */ +export function getInclusiveFields(query: Record): Record { + const newQuery = Object.create(null); + + for (const [key, value] of Object.entries(query)) { + if (value === 1) { + newQuery[key] = value; + } + } + + return newQuery; +} + +/** + * get the default fields if **fields** are empty (`{}`) or `undefined`/`null` + * @param fields the fields from parsed jsonQuery + */ +export function getNonEmptyFields(fields: Record): Record { + const defaultFields = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + } as const; + + if (!fields || Object.keys(fields).length === 0) { + return defaultFields; + } + + return { ...defaultFields, ...fields }; +} + +/** + * get the default query if **query** is empty (`{}`) or `undefined`/`null` + * @param query the query from parsed jsonQuery + */ +export function getNonEmptyQuery(query: Mongo.Query | undefined | null, canSeeAllUserInfo?: boolean): Mongo.Query { + const defaultQuery: Mongo.Query = { + $or: [{ username: { $regex: '', $options: 'i' } }, { name: { $regex: '', $options: 'i' } }], + }; + + if (canSeeAllUserInfo) { + defaultQuery.$or?.push({ 'emails.address': { $regex: '', $options: 'i' } }); + } + + if (!query || Object.keys(query).length === 0) { + return defaultQuery; + } + + return { ...defaultQuery, ...query }; +} + +type FindPaginatedUsersByStatusProps = { + uid: string; + offset: number; + count: number; + sort: Record; + status?: 'active' | 'deactivated'; + roles?: string[] | null; + searchTerm?: string; + hasLoggedIn?: boolean; + type?: string; + inactiveReason?: ('deactivated' | 'pending_approval' | 'idle_too_long')[]; +}; + +export async function findPaginatedUsersByStatus({ + uid, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, + inactiveReason, +}: FindPaginatedUsersByStatusProps) { + const actualSort: Record = sort || { username: 1 }; + if (sort?.status) { + actualSort.active = sort.status; + } + const match: Filter> = {}; + switch (status) { + case 'active': + match.active = true; + break; + case 'deactivated': + match.active = false; + break; + } + + if (hasLoggedIn !== undefined) { + match.lastLogin = { $exists: hasLoggedIn }; + } + + if (type) { + match.type = type; + } + + const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 1, + federated: 1, + freeSwitchExtension: 1, + }; + + if (searchTerm?.trim()) { + match.$or = [ + ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), + { + username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + { + name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + ]; + } + if (roles?.length && !roles.includes('all')) { + match.roles = { $in: roles }; + } + + if (inactiveReason) { + const inactiveReasonCondition = { + $or: [ + { inactiveReason: { $in: inactiveReason } }, + // This condition is to make it backward compatible with the old behavior + // The deactivated users not having the inactiveReason field should be returned as well + ...(inactiveReason.includes('deactivated') || inactiveReason.includes('idle_too_long') + ? [{ inactiveReason: { $exists: false } }] + : []), + ], + }; + + if (match.$or) { + match.$and = [{ $or: match.$or }, inactiveReasonCondition]; + delete match.$or; + } else { + Object.assign(match, inactiveReasonCondition); + } + } + + const { cursor, totalCount } = Users.findPaginated( + { + ...match, + }, + { + sort: actualSort, + skip: offset, + limit: count, + projection, + allowDiskUse: true, + }, + ); + const [users, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + users, + count: users.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/lib/webdav.ts b/apps/meteor/server/api/lib/webdav.ts new file mode 100644 index 0000000..e6fcfb3 --- /dev/null +++ b/apps/meteor/server/api/lib/webdav.ts @@ -0,0 +1,13 @@ +import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; +import { WebdavAccounts } from '@rocket.chat/models'; + +export async function findWebdavAccountsByUserId({ uid }: { uid: string }): Promise { + return WebdavAccounts.findWithUserId(uid, { + projection: { + _id: 1, + username: 1, + serverURL: 1, + name: 1, + }, + }).toArray(); +} diff --git a/apps/meteor/server/api/router.spec.ts b/apps/meteor/server/api/router.spec.ts new file mode 100644 index 0000000..5a0ab0c --- /dev/null +++ b/apps/meteor/server/api/router.spec.ts @@ -0,0 +1,56 @@ +import Ajv from 'ajv'; +import express from 'express'; +import request from 'supertest'; + +import type { APIActionContext } from './router'; +import { RocketChatAPIRouter } from './router'; + +describe('Router use method', () => { + it('should parse nested query params into object for GET requests', async () => { + const ajv = new Ajv(); + const app = express(); + + const isTestQueryParams = ajv.compile({ + type: 'object', + properties: { + outerProperty: { type: 'object', properties: { innerProperty: { type: 'string' } } }, + }, + additionalProperties: false, + required: ['outerProperty'], + }); + + const api = new RocketChatAPIRouter('/api').get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + outerProperty: { type: 'object', properties: { innerProperty: { type: 'string' } } }, + }, + additionalProperties: false, + required: ['outerProperty'], + }), + }, + query: isTestQueryParams, + }, + async function action(this: APIActionContext) { + const { outerProperty } = this.queryParams; + return { + statusCode: 200, + body: { + outerProperty, + }, + }; + }, + ); + + app.use(api.router); + + const response1 = await request(app).get('/api/test?outerProperty[innerProperty]=test'); + + expect(response1.statusCode).toBe(200); + expect(response1.body).toHaveProperty('outerProperty'); + expect(response1.body.outerProperty).toHaveProperty('innerProperty', 'test'); + }); +}); diff --git a/apps/meteor/server/api/router.ts b/apps/meteor/server/api/router.ts new file mode 100644 index 0000000..cf957a0 --- /dev/null +++ b/apps/meteor/server/api/router.ts @@ -0,0 +1,81 @@ +import type { IncomingMessage } from 'node:http'; + +import type { IUser } from '@rocket.chat/core-typings'; +import type { ResponseSchema } from '@rocket.chat/http-router'; +import { Router } from '@rocket.chat/http-router'; +import { type Logger } from '@rocket.chat/logger'; +import type { Context } from 'hono'; + +import type { TypedOptions } from './definition'; + +export type HonoContext = Context<{ + Bindings: { incoming: IncomingMessage }; + Variables: { + remoteAddress: string; + bodyParams: Record; + queryParams: Record; + user?: IUser | null; + }; +}>; + +export type APIActionContext = { + requestIp: string; + urlParams: Record; + queryParams: Record; + bodyParams: Record; + request: Request; + path: string; + response: any; + route: string; + incoming: IncomingMessage; + logger: Logger; +}; + +export type APIActionHandler = (this: APIActionContext, request: Request) => Promise>; + +export class RocketChatAPIRouter< + TBasePath extends string, + TOperations extends { + [x: string]: unknown; + } = NonNullable, +> extends Router { + protected override convertActionToHandler( + action: APIActionHandler, + logger: Logger, + ): (c: HonoContext) => Promise> { + return async (c: HonoContext): Promise> => { + const { req } = c; + + const request = req.raw.clone(); + + const context = convertHonoContextToApiActionContext(c, { logger }); + + return action.apply(context, [request]); + }; + } +} + +export const convertHonoContextToApiActionContext = ( + c: HonoContext, + options: { + logger: Logger; + }, +): APIActionContext => { + const user = c.get('user'); + return { + requestIp: c.get('remoteAddress'), + urlParams: c.req.param(), + queryParams: c.get('queryParams'), + bodyParams: c.get('bodyParams'), + request: c.req.raw, + path: c.req.path, + response: c.res, + route: c.req.routePath, + incoming: c.env.incoming, + logger: options.logger, + ...(user && { user, userId: user._id }), + }; +}; + +export type ExtractRouterEndpoints> = + TRoute extends RocketChatAPIRouter ? TOperations : never; diff --git a/apps/meteor/server/api/v1/ai-search.ts b/apps/meteor/server/api/v1/ai-search.ts new file mode 100644 index 0000000..39290d1 --- /dev/null +++ b/apps/meteor/server/api/v1/ai-search.ts @@ -0,0 +1,385 @@ +import { AI_SEARCH_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS, MAX_SEARCH_FILTER_VALUES } from '@rocket.chat/ai-search'; +import { AISearch } from '@rocket.chat/core-services'; +import { isBannedSubscription, type IRoom } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; +import { + ajv, + isSearchAnswerProps, + isAISearchProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { AISearchResult, SearchAnswer } from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { getSettingPermissionId } from '../../../app/authorization/lib'; +import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { API } from '../api'; + +const aiSearchResponseSchema = ajv.compile<{ + intelligent: AISearchResult[]; + meta: { + intelligentSearchEnabled: boolean; + intelligentSearchConfigured: boolean; + answerGenerationConfigured: boolean; + }; +}>({ + type: 'object', + properties: { + intelligent: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + msgId: { type: 'string' }, + text: { type: 'string' }, + score: { type: 'number' }, + room: { + type: 'object', + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string' }, + fname: { type: 'string' }, + }, + required: ['_id', 't'], + additionalProperties: true, + }, + }, + required: ['_id', 'text'], + additionalProperties: true, + }, + }, + meta: { + type: 'object', + properties: { + intelligentSearchEnabled: { type: 'boolean' }, + intelligentSearchConfigured: { type: 'boolean' }, + answerGenerationConfigured: { type: 'boolean' }, + }, + required: ['intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['intelligent', 'meta', 'success'], + additionalProperties: false, +}); + +const aiModelsResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + label: { type: 'string' }, + }, + required: ['key', 'label'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const searchAnswerResponseSchema = ajv.compile<{ + answer: string; + provider: { name: string; model: string }; +}>({ + type: 'object', + properties: { + answer: { type: 'string' }, + provider: { + type: 'object', + properties: { + name: { type: 'string' }, + model: { type: 'string' }, + }, + required: ['name', 'model'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['answer', 'provider', 'success'], + additionalProperties: false, +}); + +const parseCommaList = (value: string | undefined): string[] => { + if (!value) { + return []; + } + + const items: string[] = []; + for (const item of value.split(',')) { + const normalizedItem = item.trim(); + if (normalizedItem) { + items.push(normalizedItem); + if (items.length === MAX_SEARCH_FILTER_VALUES) { + break; + } + } + } + return items; +}; + +const parseQueryDate = (value: string | undefined): Date | undefined => (value ? new Date(value) : undefined); + +const getRoomMap = async (roomIds: string[]): Promise>> => { + if (!roomIds.length) { + return new Map(); + } + + const rooms = await Rooms.findByIds([...new Set(roomIds)], { + projection: { _id: 1, t: 1, name: 1, fname: 1 }, + }).toArray(); + + return new Map(rooms.map((room) => [room._id, room])); +}; + +const getSubscribedRoomIds = async (userId: string, roomIds: string[]): Promise> => { + const uniqueRoomIdSet = new Set(); + for (const roomId of roomIds) { + if (roomId) { + uniqueRoomIdSet.add(roomId); + } + } + const uniqueRoomIds = [...uniqueRoomIdSet]; + if (!uniqueRoomIds.length) { + return new Set(); + } + + const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, { + projection: { rid: 1, status: 1 }, + }).toArray(); + + const subscribedRoomIds = new Set(); + for (const subscription of subscriptions) { + if (!isBannedSubscription(subscription)) { + subscribedRoomIds.add(subscription.rid); + } + } + return subscribedRoomIds; +}; + +const getSearchAnswerMessagesForUser = async (userId: string, messages: SearchAnswer['messages']) => { + const messageIds: string[] = []; + const messageIdSet = new Set(); + const scoreByMessageId = new Map(); + const clampScore = (score: number | undefined): number | undefined => + typeof score === 'number' && Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : undefined; + for (const { _id, score } of messages) { + if (!_id) { + continue; + } + if (!messageIdSet.has(_id)) { + messageIdSet.add(_id); + messageIds.push(_id); + } + scoreByMessageId.set(_id, clampScore(score)); + } + if (!messageIds.length) { + throw new Meteor.Error('error-invalid-search-answer-sources'); + } + + const docs = await Messages.findVisibleByIds(messageIds, { + projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 }, + }).toArray(); + const subscribedRoomIds = await getSubscribedRoomIds( + userId, + docs.map((message) => message.rid), + ); + if (docs.length !== messageIds.length || docs.some((message) => !subscribedRoomIds.has(message.rid))) { + throw new Meteor.Error('error-invalid-search-answer-sources'); + } + + const normalizedDocs = await normalizeMessagesForUser(docs, userId); + const docsById = new Map(normalizedDocs.map((message) => [message._id, message])); + const rooms = await getRoomMap(normalizedDocs.map((message) => message.rid)); + + const answerMessages = []; + for (const messageId of messageIds) { + const message = docsById.get(messageId); + if (message?.msg) { + const score = scoreByMessageId.get(message._id); + const room = rooms.get(message.rid); + answerMessages.push({ + text: message.msg, + username: message.u?.username, + roomName: room?.fname || room?.name, + ts: message.ts?.toISOString(), + ...(Number.isFinite(score) && { score }), + }); + } + } + + if (!answerMessages.length) { + throw new Meteor.Error('error-invalid-search-answer-sources'); + } + + return answerMessages; +}; + +API.v1.get( + 'ai.search', + { + authRequired: true, + query: isAISearchProps, + rateLimiterOptions: { + numRequestsAllowed: 120, + intervalTimeInMS: 60000, + }, + response: { + 200: aiSearchResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const query = this.queryParams.query.trim(); + const requestedIntelligentCount = this.queryParams.intelligentCount ?? AI_SEARCH_PAGE_SIZE; + const intelligentLimit = Math.min(Math.max(Math.floor(requestedIntelligentCount), 1), MAX_INTELLIGENT_SEARCH_RESULTS); + const rid = this.queryParams.rid || undefined; + const rids = parseCommaList(this.queryParams.rids); + const roomNames = parseCommaList(this.queryParams.roomNames); + const fromUsername = this.queryParams.fromUsername || undefined; + const fromUsernames = parseCommaList(this.queryParams.fromUsernames); + const startDate = parseQueryDate(this.queryParams.startDate); + const endDate = parseQueryDate(this.queryParams.endDate); + const aiSearchStatus = await AISearch.status().catch((error) => { + this.logger.warn({ msg: 'AI search status unavailable', err: error }); + + return { + hasIntelligentSearchLicense: false, + intelligentSearchEnabled: false, + intelligentSearchConfigured: false, + answerGenerationConfigured: false, + }; + }); + let intelligentResults: AISearchResult[] = []; + if ( + aiSearchStatus.hasIntelligentSearchLicense && + aiSearchStatus.intelligentSearchEnabled && + aiSearchStatus.intelligentSearchConfigured + ) { + try { + intelligentResults = await AISearch.search({ + query, + userId: this.userId, + filters: { + rid, + rids, + roomNames, + fromUsername, + fromUsernames, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + }, + limit: intelligentLimit, + }); + } catch (error) { + this.logger.warn({ msg: 'AI search request failed', err: error }); + intelligentResults = []; + } + } + + return API.v1.success({ + intelligent: intelligentResults, + meta: { + intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled, + intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured, + answerGenerationConfigured: aiSearchStatus.answerGenerationConfigured, + }, + }); + }, +); + +API.v1.get( + 'ai.llm.models', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + response: { + 200: aiModelsResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const canAccessAllSettings = await hasAtLeastOnePermissionAsync(this.userId, ['view-privileged-setting', 'edit-privileged-setting']); + const canAccessModelSetting = + canAccessAllSettings || + (await hasAllPermissionAsync(this.userId, ['manage-selected-settings', getSettingPermissionId('AI_LLM_OpenAI_Model')])); + + if (!canAccessModelSetting) { + return API.v1.forbidden(); + } + + return API.v1.success({ + data: await AISearch.models(), + }); + }, +); + +API.v1.post( + 'ai.search.answer', + { + authRequired: true, + body: isSearchAnswerProps, + rateLimiterOptions: { + numRequestsAllowed: 10, + intervalTimeInMS: 60000, + }, + response: { + 200: searchAnswerResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query, messages } = this.bodyParams; + + // gate before any DB work so users cannot drive message lookups for an unlicensed feature + const aiSearchStatus = await AISearch.status(); + if ( + !aiSearchStatus.hasIntelligentSearchLicense || + !aiSearchStatus.intelligentSearchEnabled || + !aiSearchStatus.answerGenerationConfigured + ) { + throw new Meteor.Error('error-ai-not-enabled'); + } + + const answerMessages = await getSearchAnswerMessagesForUser(this.userId, messages); + try { + const answer = await AISearch.answer({ + query, + messages: answerMessages, + }); + + return API.v1.success(answer); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message === 'error-ai-not-enabled') { + throw new Meteor.Error('error-ai-not-enabled'); + } + if (message === 'error-ai-provider-not-configured') { + throw new Meteor.Error('error-ai-provider-not-configured'); + } + if (message === 'error-ai-provider-empty-response') { + throw new Meteor.Error('error-ai-provider-empty-response'); + } + throw new Meteor.Error('error-ai-provider-request-failed'); + } + }, +); diff --git a/apps/meteor/server/api/v1/assets.ts b/apps/meteor/server/api/v1/assets.ts new file mode 100644 index 0000000..071850f --- /dev/null +++ b/apps/meteor/server/api/v1/assets.ts @@ -0,0 +1,115 @@ +import { Settings } from '@rocket.chat/models'; +import { + ajv, + isAssetsUnsetAssetProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { RocketChatAssets, refreshClients } from '../../lib/media/assets'; +import { notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { settings } from '../../settings'; +import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates'; +import { API } from '../api'; +import { getUploadFormData } from '../lib/getUploadFormData'; + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'assets.setAsset', + { + authRequired: true, + permissionsRequired: ['manage-assets'], + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const asset = await getUploadFormData( + { + request: this.request, + }, + { field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') }, + ); + + const { fileBuffer, fields, filename, mimetype } = asset; + + const { refreshAllClients, assetName: customName } = fields; + + const assetName = customName || filename; + const assetsKeys = Object.keys(RocketChatAssets.assets); + + const isValidAsset = assetsKeys.includes(assetName); + if (!isValidAsset) { + throw new Error('Invalid asset'); + } + + const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName); + + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.updateValueById, key, value); + + if (modifiedCount) { + void notifyOnSettingChangedById(key); + } + + if (refreshAllClients) { + await refreshClients(this.userId); + } + + return API.v1.success(); + }, +); + +API.v1.post( + 'assets.unsetAsset', + { + authRequired: true, + body: isAssetsUnsetAssetProps, + permissionsRequired: ['manage-assets'], + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { assetName, refreshAllClients } = this.bodyParams; + const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName); + if (!isValidAsset) { + throw Error('Invalid asset'); + } + + const { key, value } = await RocketChatAssets.unsetAsset(assetName); + + const { modifiedCount } = await updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.updateValueById, key, value); + + if (modifiedCount) { + void notifyOnSettingChangedById(key); + } + + if (refreshAllClients) { + await refreshClients(this.userId); + } + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/autotranslate.ts b/apps/meteor/server/api/v1/autotranslate.ts new file mode 100644 index 0000000..d614d77 --- /dev/null +++ b/apps/meteor/server/api/v1/autotranslate.ts @@ -0,0 +1,165 @@ +import type { IMessage, ISupportedLanguage } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { + ajv, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + isAutotranslateSaveSettingsParamsPOST, + isAutotranslateGetSupportedLanguagesParamsGET, +} from '@rocket.chat/rest-typings'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { getSupportedLanguages } from '../../lib/autotranslate/functions/getSupportedLanguages'; +import { saveAutoTranslateSettings } from '../../lib/autotranslate/functions/saveSettings'; +import { translateMessage } from '../../lib/autotranslate/functions/translateMessage'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type AutotranslateTranslateMessageParamsPOST = { + messageId: string; + targetLanguage?: string; +}; + +const AutotranslateTranslateMessageParamsPostSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + }, + targetLanguage: { + type: 'string', + nullable: true, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const isAutotranslateTranslateMessageParamsPOST = ajv.compile( + AutotranslateTranslateMessageParamsPostSchema, +); + +const autotranslateEndpoints = API.v1 + .get( + 'autotranslate.getSupportedLanguages', + { + authRequired: true, + query: isAutotranslateGetSupportedLanguagesParamsGET, + response: { + 200: ajv.compile<{ languages: ISupportedLanguage[] }>({ + type: 'object', + properties: { + languages: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['languages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!settings.get('AutoTranslate_Enabled')) { + return API.v1.failure('AutoTranslate is disabled.'); + } + const { targetLanguage } = this.queryParams; + const languages = await getSupportedLanguages(this.userId, targetLanguage); + + return API.v1.success({ languages: languages || [] }); + }, + ) + .post( + 'autotranslate.saveSettings', + { + authRequired: true, + body: isAutotranslateSaveSettingsParamsPOST, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, field, value, defaultLanguage } = this.bodyParams; + if (!settings.get('AutoTranslate_Enabled')) { + return API.v1.failure('AutoTranslate is disabled.'); + } + + // ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both + if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') { + return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.'); + } + + if (field === 'autoTranslateLanguage' && (typeof value !== 'string' || !Number.isNaN(Number.parseInt(value)))) { + return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.'); + } + + await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), { + defaultLanguage: defaultLanguage || '', + }); + + return API.v1.success(); + }, + ) + .post( + 'autotranslate.translateMessage', + { + authRequired: true, + body: isAutotranslateTranslateMessageParamsPOST, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { messageId, targetLanguage } = this.bodyParams; + if (!settings.get('AutoTranslate_Enabled')) { + return API.v1.failure('AutoTranslate is disabled.'); + } + if (!messageId) { + return API.v1.failure('The bodyParam "messageId" is required.'); + } + const message = await Messages.findOneById(messageId); + if (!message) { + return API.v1.failure('Message not found.'); + } + + const room = await Rooms.findOneById(message.rid); + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + const translatedMessage = await translateMessage(targetLanguage, message); + + if (!translatedMessage) { + return API.v1.failure('Failed to translate message.'); + } + + return API.v1.success({ message: translatedMessage }); + }, + ); + +type AutotranslateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends AutotranslateEndpoints {} +} diff --git a/apps/meteor/server/api/v1/banners.ts b/apps/meteor/server/api/v1/banners.ts new file mode 100644 index 0000000..46b32c8 --- /dev/null +++ b/apps/meteor/server/api/v1/banners.ts @@ -0,0 +1,204 @@ +import { Banner } from '@rocket.chat/core-services'; +import type { IBanner } from '@rocket.chat/core-typings'; +import { + ajv, + isBannersDismissProps, + isBannersProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { API } from '../api'; + +const bannersResponseSchema = ajv.compile<{ banners: IBanner[] }>({ + type: 'object', + properties: { + banners: { type: 'array', items: { $ref: '#/components/schemas/IBanner' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['banners', 'success'], + additionalProperties: false, +}); + +const dismissResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +/** + * @openapi + * /api/v1/banners/{id}: + * get: + * description: Gets the banner to be shown to the authenticated user + * security: + * $ref: '#/security/authenticated' + * parameters: + * - name: platform + * in: query + * description: The platform rendering the banner + * required: true + * schema: + * type: string + * enum: [web, mobile] + * example: web + * - name: id + * in: path + * description: The id of the banner + * required: true + * schema: + * type: string + * example: ByehQjC44FwMeiLbX + * responses: + * 200: + * description: | + * A collection with a single banner matching the criteria; an empty + * collection otherwise + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiSuccessV1' + * - type: object + * properties: + * banners: + * type: array + * items: + * $ref: '#/components/schemas/IBanner' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.get( + 'banners/:id', + { + authRequired: true, + query: isBannersProps, + response: { + 200: bannersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { platform } = this.queryParams; + const { id } = this.urlParams; + + const banners = await Banner.getBannersForUser(this.userId, platform, id); + + return API.v1.success({ banners }); + }, +); + +/** + * @openapi + * /api/v1/banners: + * get: + * description: Gets the banners to be shown to the authenticated user + * security: + * $ref: '#/security/authenticated' + * parameters: + * - name: platform + * in: query + * description: The platform rendering the banner + * required: true + * schema: + * type: string + * enum: [web, mobile] + * example: web + * responses: + * 200: + * description: The banners matching the criteria + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiSuccessV1' + * - type: object + * properties: + * banners: + * type: array + * items: + * $ref: '#/components/schemas/IBanner' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.get( + 'banners', + { + authRequired: true, + query: isBannersProps, + response: { + 200: bannersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { platform } = this.queryParams; + + const banners = await Banner.getBannersForUser(this.userId, platform); + + return API.v1.success({ banners }); + }, +); + +/** + * @openapi + * /api/v1/banners.dismiss: + * post: + * description: Dismisses a banner + * security: + * $ref: '#/security/authenticated' + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * bannerId: + * type: string + * example: | + * { + * "bannerId": "ByehQjC44FwMeiLbX" + * } + * responses: + * 200: + * description: The banners matching the criteria + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.post( + 'banners.dismiss', + { + authRequired: true, + body: isBannersDismissProps, + response: { + 200: dismissResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { bannerId } = this.bodyParams; + + await Banner.dismiss(this.userId, bannerId); + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/calendar.ts b/apps/meteor/server/api/v1/calendar.ts new file mode 100644 index 0000000..8209bba --- /dev/null +++ b/apps/meteor/server/api/v1/calendar.ts @@ -0,0 +1,224 @@ +import { Calendar } from '@rocket.chat/core-services'; +import type { ICalendarEvent } from '@rocket.chat/core-typings'; +import { + ajv, + isCalendarEventListProps, + isCalendarEventCreateProps, + isCalendarEventImportProps, + isCalendarEventInfoProps, + isCalendarEventUpdateProps, + isCalendarEventDeleteProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { API } from '../api'; + +const successWithDataSchema = ajv.compile<{ data: ICalendarEvent[] }>({ + type: 'object', + properties: { + data: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const successWithEventSchema = ajv.compile<{ event: ICalendarEvent }>({ + type: 'object', + properties: { + event: { $ref: '#/components/schemas/ICalendarEvent' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['event', 'success'], + additionalProperties: false, +}); + +const successWithIdSchema = ajv.compile<{ id: ICalendarEvent['_id'] }>({ + type: 'object', + properties: { + id: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['id', 'success'], + additionalProperties: false, +}); + +const successSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( + 'calendar-events.list', + { + authRequired: true, + query: isCalendarEventListProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: successWithDataSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { date } = this.queryParams; + + const data = await Calendar.list(userId, new Date(date)); + + return API.v1.success({ data }); + }, +); + +API.v1.get( + 'calendar-events.info', + { + authRequired: true, + query: isCalendarEventInfoProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: successWithEventSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { id } = this.queryParams; + + const event = await Calendar.get(id); + + if (event?.uid !== userId) { + return API.v1.failure(); + } + + return API.v1.success({ event }); + }, +); + +API.v1.post( + 'calendar-events.create', + { + authRequired: true, + body: isCalendarEventCreateProps, + response: { + 200: successWithIdSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId: uid } = this; + const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const id = await Calendar.create({ + uid, + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + externalId, + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success({ id }); + }, +); + +API.v1.post( + 'calendar-events.import', + { + authRequired: true, + body: isCalendarEventImportProps, + response: { + 200: successWithIdSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId: uid } = this; + const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const id = await Calendar.import({ + uid, + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + externalId, + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success({ id }); + }, +); + +API.v1.post( + 'calendar-events.update', + { + authRequired: true, + body: isCalendarEventUpdateProps, + response: { + 200: successSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { eventId, startTime, endTime, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams; + + const event = await Calendar.get(eventId); + + if (event?.uid !== userId) { + throw new Error('invalid-calendar-event'); + } + + await Calendar.update(eventId, { + startTime: new Date(startTime), + ...(endTime && { endTime: new Date(endTime) }), + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + ...(typeof busy === 'boolean' && { busy }), + }); + + return API.v1.success(); + }, +); + +API.v1.post( + 'calendar-events.delete', + { + authRequired: true, + body: isCalendarEventDeleteProps, + response: { + 200: successSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + const { eventId } = this.bodyParams; + + const event = await Calendar.get(eventId); + + if (event?.uid !== userId) { + throw new Error('invalid-calendar-event'); + } + + await Calendar.delete(eventId); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/call-history.ts b/apps/meteor/server/api/v1/call-history.ts new file mode 100644 index 0000000..a4f751b --- /dev/null +++ b/apps/meteor/server/api/v1/call-history.ts @@ -0,0 +1,234 @@ +import { CallHistory as CallHistoryService } from '@rocket.chat/core-services'; +import type { CallHistoryItem, CallHistoryItemState, IMediaCall } from '@rocket.chat/core-typings'; +import { CallHistory, MediaCalls } from '@rocket.chat/models'; +import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { ensureArray } from '../../../lib/utils/arrayUtils'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +type CallHistoryList = PaginatedRequest<{ + filter?: string; + direction?: CallHistoryItem['direction']; + state?: CallHistoryItemState[]; +}>; + +const CallHistoryListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + sort: { + type: 'string', + }, + filter: { + type: 'string', + }, + direction: { + type: 'string', + enum: ['inbound', 'outbound'], + }, + state: { + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItemState', + }, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isCallHistoryListProps = ajvQuery.compile(CallHistoryListSchema); + +const callHistoryListEndpoints = API.v1.get( + 'call-history.list', + { + response: { + 200: ajv.compile< + PaginatedResult<{ + items: CallHistoryItem[]; + }> + >({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of history items returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of history items that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of history items that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + items: { + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItem', + }, + }, + }, + required: ['count', 'offset', 'total', 'items', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + query: isCallHistoryListProps, + authRequired: true, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort } = await this.parseJsonQuery(); + + const { direction, state, filter } = this.queryParams; + + const searchTerm = typeof filter === 'string' && filter.trim(); + + const { items, total } = await CallHistoryService.search( + this.userId, + { + ...(searchTerm && { searchTerm }), + ...(direction && { direction }), + ...(state && { inStates: ensureArray(state) }), + }, + { count, offset, sort }, + ); + + return API.v1.success({ + items, + count: items.length, + offset, + total, + }); + }, +); + +type CallHistoryListEndpoints = ExtractRoutesFromAPI; + +type CallHistoryInfo = { historyId: string } | { callId: string }; + +const CallHistoryInfoSchema = { + oneOf: [ + { + type: 'object', + properties: { + historyId: { + type: 'string', + nullable: false, + }, + }, + required: ['historyId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + }, + required: ['callId'], + additionalProperties: false, + }, + ], +}; + +export const isCallHistoryInfoProps = ajvQuery.compile(CallHistoryInfoSchema); + +const callHistoryInfoEndpoints = API.v1.get( + 'call-history.info', + { + response: { + 200: ajv.compile<{ + item: CallHistoryItem; + call?: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + item: { + $ref: '#/components/schemas/CallHistoryItem', + description: 'The requested call history item.', + }, + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + description: 'The call information for the requested call history item.', + nullable: true, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['item', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isCallHistoryInfoProps, + authRequired: true, + }, + async function action() { + const { historyId, callId } = this.queryParams as Record & typeof this.queryParams; + + if (!historyId && !callId) { + return API.v1.failure(); + } + + const item = await (historyId + ? CallHistory.findOneByIdAndUid(historyId, this.userId) + : CallHistory.findOneByCallIdAndUid(callId, this.userId)); + + if (!item) { + return API.v1.notFound(); + } + + if (item.type === 'media-call' && item.callId) { + const call = await MediaCalls.findOneById(item.callId); + if (call) { + return API.v1.success({ + item, + call, + }); + } + } + + return API.v1.success({ item }); + }, +); + +type CallHistoryInfoEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CallHistoryListEndpoints {} + + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CallHistoryInfoEndpoints {} +} diff --git a/apps/meteor/server/api/v1/channels.ts b/apps/meteor/server/api/v1/channels.ts new file mode 100644 index 0000000..ebd42f9 --- /dev/null +++ b/apps/meteor/server/api/v1/channels.ts @@ -0,0 +1,2171 @@ +import { Team, Room } from '@rocket.chat/core-services'; +import { + TeamType, + isRoomNativeFederated, + type IIntegration, + type IMessage, + type IRoom, + type ISubscription, + type ITeam, + type IUploadWithUser, + type IUser, + type RoomType, + type UserStatus, +} from '@rocket.chat/core-typings'; +import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; +import { + isChannelsAddAllProps, + isChannelsArchiveProps, + isChannelsHistoryProps, + isChannelsUnarchiveProps, + isChannelsRolesProps, + isChannelsJoinProps, + isChannelsKickProps, + isChannelsLeaveProps, + isChannelsMessagesProps, + isChannelsOpenProps, + isChannelsSetAnnouncementProps, + isChannelsGetAllUserMentionsByChannelProps, + isChannelsModeratorsProps, + isChannelsConvertToTeamProps, + isChannelsCreateProps, + isChannelsSetReadOnlyProps, + isChannelsDeleteProps, + isChannelsListProps, + isChannelsFilesListProps, + isChannelsOnlineProps, + ajv, + ajvQuery, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { hasAllPermissionAsync, hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { eraseRoom } from '../../lib/eraseRoom'; +import { findUsersOfRoom } from '../../lib/findUsersOfRoom'; +import { mountIntegrationQueryBasedOnPermissions } from '../../lib/integrations/lib/mountQueriesBasedOnPermission'; +import { openRoom } from '../../lib/openRoom'; +import { getUsersHiddenFrom, filterHiddenUsers, redactHiddenUsers } from '../../lib/statusVisibility/hiddenUsers'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { getChannelHistory } from '../../meteor-methods/messages/getChannelHistory'; +import { getUserMentionsByChannel } from '../../meteor-methods/messages/getUserMentionsByChannel'; +import { addAllUserToRoomFn } from '../../meteor-methods/rooms/addAllUserToRoom'; +import { addRoomLeader } from '../../meteor-methods/rooms/addRoomLeader'; +import { addRoomModerator } from '../../meteor-methods/rooms/addRoomModerator'; +import { addRoomOwner } from '../../meteor-methods/rooms/addRoomOwner'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; +import { executeArchiveRoom } from '../../meteor-methods/rooms/archiveRoom'; +import { createChannelMethod } from '../../meteor-methods/rooms/createChannel'; +import { executeGetRoomRoles } from '../../meteor-methods/rooms/getRoomRoles'; +import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom'; +import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom'; +import { removeRoomLeader } from '../../meteor-methods/rooms/removeRoomLeader'; +import { removeRoomModerator } from '../../meteor-methods/rooms/removeRoomModerator'; +import { removeRoomOwner } from '../../meteor-methods/rooms/removeRoomOwner'; +import { removeUserFromRoomMethod } from '../../meteor-methods/rooms/removeUserFromRoom'; +import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings'; +import { executeUnarchiveRoom } from '../../meteor-methods/rooms/unarchiveRoom'; +import { settings } from '../../settings'; +import { API } from '../api'; +import { addUserToFileObj } from '../lib/addUserToFileObj'; +import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUserFromParams, getUserListFromParams, getUsernameListFromParams } from '../lib/getUserFromParams'; + +// Returns the channel IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +async function findChannelByIdOrName({ + params, + checkedArchived = true, + userId, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + userId?: string; + checkedArchived?: boolean; +}): Promise { + const projection = { ...API.v1.defaultFieldsToExclude }; + + let room; + if ('roomId' in params) { + room = await Rooms.findOneById(params.roomId || '', { projection }); + } else if ('roomName' in params) { + room = await Rooms.findOneByName(params.roomName || '', { projection }); + } + + if (!room || (room.t !== 'c' && room.t !== 'l')) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel'); + } + + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The channel, ${room.name}, is archived`); + } + if (userId && room.lastMessage) { + const [lastMessage] = await normalizeMessagesForUser([room.lastMessage], userId); + room.lastMessage = lastMessage; + } + + return room; +} + +const channelResponseSchema = ajv.compile<{ channel: IRoom }>({ + type: 'object', + properties: { + channel: { $ref: '#/components/schemas/IRoom' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channel', 'success'], + additionalProperties: false, +}); + +// channels.info also serves omnichannel ('l') rooms (e.g. livechat test helpers call it), which lack the +// owner `u` that IRoom requires. Accept a full IRoom or any room-shaped object so both types validate. +const channelInfoResponseSchema = ajv.compile<{ channel: IRoom }>({ + type: 'object', + properties: { + channel: { + anyOf: [{ $ref: '#/components/schemas/IRoom' }, { type: 'object', required: ['_id', 't'], additionalProperties: true }], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channel', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic') => + ajv.compile({ + type: 'object', + properties: { + [field]: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: [field, 'success'], + additionalProperties: false, + }); + +const descriptionResponseSchema = stringFieldResponseSchema<{ description: string }>('description'); +const purposeResponseSchema = stringFieldResponseSchema<{ purpose: string }>('purpose'); +const topicResponseSchema = stringFieldResponseSchema<{ topic: string }>('topic'); + +const roomSettingBody = (field: string, fieldSchema: Record) => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + [field]: fieldSchema, + }, + required: [field], + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }); + +// Body validator for POSTs targeting a single room (roomId or roomName) with no other required field. +const roomTargetBody = () => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }); + +// Query validator for GETs targeting a single room (roomId or roomName). +const roomTargetQuery = ajvQuery.compile<{ roomId?: string; roomName?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const channelsListResponseSchema = ajv.compile<{ channels: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + channels: { type: 'array', items: { $ref: '#/components/schemas/IRoom' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['channels', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'channels.addAll', + { + authRequired: true, + body: isChannelsAddAllProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { activeUsersOnly, ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params, userId: this.userId }); + + await addAllUserToRoomFn(this.userId, findResult._id, activeUsersOnly === 'true' || activeUsersOnly === 1); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); + }, +); + +API.v1.post( + 'channels.archive', + { + authRequired: true, + body: isChannelsArchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + await executeArchiveRoom(this.userId, findResult._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.unarchive', + { + authRequired: true, + body: isChannelsUnarchiveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); + + if (!findResult.archived) { + return API.v1.failure(`The channel, ${findResult.name}, is not archived`); + } + + await executeUnarchiveRoom(this.userId, findResult._id); + + return API.v1.success(); + }, +); + +const channelsHistoryResponseSchema = ajv.compile<{ messages: IMessage[]; firstUnread?: IMessage; unreadNotLoaded?: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + firstUnread: { $ref: '#/components/schemas/IMessage' }, + unreadNotLoaded: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.history', + { + authRequired: true, + query: isChannelsHistoryProps, + response: { + 200: channelsHistoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { unreads, oldest, latest, showThreadMessages, inclusive, ...params } = this.queryParams; + const findResult = await findChannelByIdOrName({ + params, + checkedArchived: false, + }); + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const result = await getChannelHistory({ + rid: findResult._id, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }); + + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return API.v1.forbidden(); + } + + return API.v1.success(result); + }, +); + +const rolesResponseSchema = ajv.compile<{ + roles: { rid: string; u: { _id: string; username: string; name?: string }; roles: string[] }[]; +}>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id', 'username'], + additionalProperties: false, + }, + roles: { type: 'array', items: { type: 'string' } }, + }, + required: ['_id', 'rid', 'u', 'roles'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.roles', + { + authRequired: true, + query: isChannelsRolesProps, + response: { + 200: rolesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.queryParams }); + + const roles = await executeGetRoomRoles(findResult._id, this.user); + + return API.v1.success({ + roles, + }); + }, +); + +API.v1.post( + 'channels.join', + { + authRequired: true, + body: isChannelsJoinProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { joinCode, ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); + + await Room.join({ room: findResult, user: this.user, joinCode }); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); + }, +); + +API.v1.post( + 'channels.kick', + { + authRequired: true, + body: isChannelsKickProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { ...params /* userId */ } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); + + const user = await getUserFromParams(this.bodyParams); + if (!user?.username) { + return API.v1.failure('Invalid user'); + } + + await removeUserFromRoomMethod(this.userId, { rid: findResult._id, username: user.username }); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); + }, +); + +API.v1.post( + 'channels.leave', + { + authRequired: true, + body: isChannelsLeaveProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { ...params } = this.bodyParams; + const findResult = await findChannelByIdOrName({ params }); + + const user = await Users.findOneById(this.userId); + if (!user) { + return API.v1.failure('Invalid user'); + } + await leaveRoomMethod(user, findResult._id); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params, userId: this.userId }), + }); + }, +); + +const channelsMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.messages', + { + authRequired: true, + query: isChannelsMessagesProps, + permissionsRequired: ['view-c-room'], + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, mentionIds, starredIds, pinned } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const findResult = await findChannelByIdOrName({ + params: { roomId }, + checkedArchived: false, + }); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + ...query, + rid: findResult._id, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(String(pinned).toLowerCase() === 'true' ? { pinned: true } : {}), + _hidden: { $ne: true }, + }; + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + // Special check for the permissions + if ( + (await hasPermissionAsync(this.user, 'view-joined-room')) && + !(await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId, { projection: { _id: 1 } })) + ) { + return API.v1.forbidden(); + } + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }, +); + +API.v1.post( + 'channels.open', + { + authRequired: true, + body: isChannelsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { ...params } = this.bodyParams; + + const findResult = await findChannelByIdOrName({ + params, + checkedArchived: false, + }); + + const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${findResult.name}".`); + } + + if (sub.open) { + return API.v1.failure(`The channel, ${findResult.name}, is already open to the sender`); + } + + await openRoom(this.userId, findResult._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.setReadOnly', + { + authRequired: true, + body: isChannelsSetReadOnlyProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.ro === this.bodyParams.readOnly) { + return API.v1.failure('The channel read only setting is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'readOnly', this.bodyParams.readOnly); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + }, +); + +const announcementResponseSchema = ajv.compile<{ announcement?: string }>({ + type: 'object', + properties: { + announcement: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'channels.setAnnouncement', + { + authRequired: true, + body: isChannelsSetAnnouncementProps, + response: { + 200: announcementResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { announcement, ...params } = this.bodyParams; + + const findResult = await findChannelByIdOrName({ params }); + + await saveRoomSettings(this.userId, findResult._id, 'roomAnnouncement', announcement); + + return API.v1.success({ + announcement: this.bodyParams.announcement, + }); + }, +); + +const channelsMentionsResponseSchema = ajv.compile<{ mentions: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + mentions: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['mentions', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.getAllUserMentionsByChannel', + { + authRequired: true, + query: isChannelsGetAllUserMentionsByChannelProps, + response: { + 200: channelsMentionsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const mentions = await getUserMentionsByChannel(this.userId, roomId, { + sort: sort || { ts: 1 }, + skip: offset, + limit: count, + }); + + const allMentions = await getUserMentionsByChannel(this.userId, roomId, {}); + + return API.v1.success({ + mentions, + count: mentions.length, + offset, + total: allMentions.length, + }); + }, +); + +const moderatorsResponseSchema = ajv.compile<{ moderators: { _id: string; username?: string; name?: string }[] }>({ + type: 'object', + properties: { + moderators: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['moderators', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.moderators', + { + authRequired: true, + query: isChannelsModeratorsProps, + response: { + 200: moderatorsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { ...params } = this.queryParams; + + const findResult = await findChannelByIdOrName({ params }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + const moderators = await Subscriptions.findByRoomIdAndRoles(findResult._id, ['moderator'], { + projection: { u: 1, _id: 0 }, + }) + .map((sub) => sub.u) + .toArray(); + + return API.v1.success({ + moderators, + }); + }, +); + +API.v1.post( + 'channels.delete', + { + authRequired: true, + body: isChannelsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); + + await eraseRoom(room._id, this.user); + + return API.v1.success(); + }, +); + +const channelsConvertToTeamResponseSchema = ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { $ref: '#/components/schemas/ITeam' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'channels.convertToTeam', + { + authRequired: true, + body: isChannelsConvertToTeamProps, + permissionsRequired: ['create-team'], + response: { + 200: channelsConvertToTeamResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { channelId, channelName } = this.bodyParams; + + if (!channelId && !channelName) { + return API.v1.failure('The parameter "channelId" or "channelName" is required'); + } + + const room = await findChannelByIdOrName({ + params: channelId !== undefined ? { roomId: channelId } : { roomName: channelName }, + userId: this.userId, + }); + + if (!room) { + return API.v1.failure('Channel not found'); + } + + if (!(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room._id))) { + return API.v1.forbidden(); + } + + const subscriptions = await Subscriptions.findByRoomId(room._id, { + projection: { 'u._id': 1 }, + }); + + const members = (await subscriptions.toArray()).map((s: ISubscription) => s.u?._id); + + const teamData = { + team: { + name: room.name ?? '', + type: room.t === 'c' ? 0 : 1, + }, + members, + room: { + name: room.name, + id: room._id, + }, + }; + + const team = await Team.create(this.userId, teamData); + + return API.v1.success({ team }); + }, +); + +API.v1.post( + 'channels.addModerator', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await addRoomModerator(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.addOwner', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await addRoomOwner(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.close', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.bodyParams, + checkedArchived: false, + }); + + const sub = await Subscriptions.findOneByRoomIdAndUserId(findResult._id, this.userId); + + if (!sub) { + return API.v1.failure(`The user/callee is not in the channel "${findResult.name}.`); + } + + if (!sub.open) { + return API.v1.failure(`The channel, ${findResult.name}, is already closed to the sender`); + } + + await hideRoomMethod(this.userId, findResult._id); + + return API.v1.success(); + }, +); + +const countersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: Date | null; + msgs: number | null; + latest: Date | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: ['number', 'null'] }, + unreads: { type: ['number', 'null'] }, + unreadsFrom: { type: ['string', 'null'] }, + msgs: { type: ['number', 'null'] }, + latest: { type: ['string', 'null'] }, + userMentions: { type: ['number', 'null'] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.counters', + { + authRequired: true, + query: ajvQuery.compile<{ roomId?: string; roomName?: string; userId?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + userId: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }), + response: { + 200: countersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const access = await hasPermissionAsync(this.user, 'view-room-administration'); + const { userId } = this.queryParams; + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + + if (userId) { + if (!access) { + return API.v1.forbidden(); + } + user = userId; + } + const room = await findChannelByIdOrName({ + params: this.queryParams, + }); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user); + const lm = room.lm ? room.lm : room._updatedAt; + + if (subscription?.open) { + unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm); + unreadsFrom = subscription.ls || subscription.ts; + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInNonDMRoom(room._id); + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, +); + +async function createChannelValidator(params: { + user: { value: string }; + name?: { key: string; value?: string }; + members?: { key: string; value?: string[] }; + customFields?: { key: string; value?: string }; + teams?: { key: string; value?: string[] }; + teamId?: { key: string; value?: string }; +}) { + const teamId = params.teamId?.value; + + const team = teamId && (await Team.getInfoById(teamId)); + if ( + (!teamId && !(await hasPermissionAsync(params.user.value, 'create-c'))) || + (teamId && team && !(await hasPermissionAsync(params.user.value, 'create-team-channel', team.roomId))) + ) { + throw new Error('unauthorized'); + } + + if (!params.name?.value) { + throw new Error(`Param "${params.name?.key}" is required`); + } + + if (params.members?.value && !Array.isArray(params.members.value)) { + throw new Error(`Param "${params.members.key}" must be an array if provided`); + } + + if (params.customFields?.value && !(typeof params.customFields.value === 'object')) { + throw new Error(`Param "${params.customFields.key}" must be an object if provided`); + } + + if (params.teams?.value && !Array.isArray(params.teams.value)) { + throw new Error(`Param ${params.teams.key} must be an array`); + } +} + +async function createChannel( + userId: string, + params: { + name?: string; + members?: string[]; + customFields?: Record; + extraData?: Record; + readOnly?: boolean; + excludeSelf?: boolean; + }, +): Promise<{ channel: IRoom }> { + const readOnly = typeof params.readOnly !== 'undefined' ? params.readOnly : false; + const id = await createChannelMethod( + userId, + params.name || '', + params.members ? params.members : [], + readOnly, + params.customFields, + params.extraData, + params.excludeSelf, + ); + + return { + channel: await findChannelByIdOrName({ params: { roomId: id.rid }, userId }), + }; +} + +API.channels = { + create: { + validate: createChannelValidator, + execute: createChannel, + }, +}; + +API.v1.post( + 'channels.create', + { + authRequired: true, + body: isChannelsCreateProps, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, bodyParams } = this; + + try { + // create.validate throws a plain Error('unauthorized') on permission failure; the global + // wrapper would surface its message verbatim, so map it to forbidden() here to keep the + // stable `error: 'unauthorized'` (403) contract the clients/tests rely on. + await API.channels?.create.validate({ + user: { + value: userId, + }, + name: { + value: bodyParams.name, + key: 'name', + }, + members: { + value: bodyParams.members, + key: 'members', + }, + teams: { + value: bodyParams.teams, + key: 'teams', + }, + teamId: { + value: bodyParams.extraData?.teamId, + key: 'teamId', + }, + }); + } catch (e: any) { + if (e.message === 'unauthorized') { + return API.v1.forbidden(); + } + return API.v1.failure(e.message); + } + + if (bodyParams.teams) { + const canSeeAllTeams = await hasPermissionAsync(this.user, 'view-all-teams'); + const teams = await Team.listByNames(bodyParams.teams, { projection: { _id: 1 } }); + const teamMembers = []; + + for (const team of teams) { + const { records: members } = await Team.members(this.userId, team._id, canSeeAllTeams, { + offset: 0, + count: Number.MAX_SAFE_INTEGER, + }); + const uids = members.map((member) => member.user.username); + teamMembers.push(...uids); + } + + const membersToAdd = new Set([...teamMembers, ...(bodyParams.members || [])]); + bodyParams.members = [...membersToAdd].filter(Boolean) as string[]; + } + + const result = await API.channels?.create.execute(userId, bodyParams); + if (!result) { + return API.v1.failure('Failed to create channel'); + } + + return API.v1.success(result); + }, +); + +// Uploads accept a client-supplied `fields` projection and store `content: null` for non-e2ee files, +// so items are partial and cannot be validated against $ref IUploadWithUser (required, non-null shape). +// Validate the array container strictly; leave item props open (only _id is guaranteed). +const channelsFilesResponseSchema = ajv.compile<{ files: IUploadWithUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' } }, + required: ['_id'], + additionalProperties: true, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.files', + { + authRequired: true, + query: isChannelsFilesListProps, + response: { + 200: channelsFilesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; + + const findResult = await findChannelByIdOrName({ + params: { + ...(roomId ? { roomId } : {}), + ...(roomName ? { roomName } : {}), + }, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const filter = { + ...query, + rid: findResult._id, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = await Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }, +); + +const channelsGetIntegrationsQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + includeAllPublicChannels?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + includeAllPublicChannels: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const channelsGetIntegrationsResponseSchema = ajv.compile<{ + integrations: IIntegration[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + integrations: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.getIntegrations', + { + authRequired: true, + query: channelsGetIntegrationsQuery, + permissionsRequired: { + GET: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + }, + response: { + 200: channelsGetIntegrationsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + let includeAllPublicChannels = true; + if (typeof this.queryParams.includeAllPublicChannels !== 'undefined') { + includeAllPublicChannels = this.queryParams.includeAllPublicChannels === 'true'; + } + + let ourQuery: { channel: string | { $in: string[] } } = { + channel: `#${findResult.name}`, + }; + + if (includeAllPublicChannels) { + ourQuery.channel = { + $in: [ourQuery.channel as string, 'all_public_channels'], + }; + } + + const params = this.queryParams; + const { offset, count } = await getPaginationItems(params); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + + // Apply the user-supplied query first, then overlay the trusted filters so a crafted `query` + // cannot override the permission scope (mountIntegrationQueryBasedOnPermissions) or the channel filter. + ourQuery = Object.assign({}, query, ourQuery, await mountIntegrationQueryBasedOnPermissions(this.userId)); + + const { cursor, totalCount } = await Integrations.findPaginated(ourQuery, { + sort: sort || { _createdAt: 1 }, + skip: offset, + limit: count, + projection, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'channels.info', + { + authRequired: true, + query: roomTargetQuery, + response: { + 200: channelInfoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + userId: this.userId, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + return API.v1.success({ + channel: findResult, + }); + }, +); + +const channelsInviteBody = ajv.compile<({ roomId: string } | { roomName: string }) & { userId?: string; username?: string; user?: string }>( + { + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }, +); + +API.v1.post( + 'channels.invite', + { + authRequired: true, + body: channelsInviteBody, + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + // Federated rooms invite by raw username: the federated user record is created + // lazily inside addUsersToRoomMethod, so we must not require it to exist locally yet. + if (isRoomNativeFederated(findResult)) { + const users = await getUsernameListFromParams(this.bodyParams); + + await addUsersToRoomMethod(this.userId, { rid: findResult._id, users }, this.user); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + } + + const users = await getUserListFromParams(this.bodyParams); + + if (!users.length) { + return API.v1.failure('invalid-user-invite-list', 'Cannot invite if no users are provided'); + } + + await addUsersToRoomMethod(this.userId, { rid: findResult._id, users: users.map((u) => u.username).filter(isTruthy) }, this.user); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + }, +); + +API.v1.get( + 'channels.list', + { + authRequired: true, + permissionsRequired: { + GET: { permissions: ['view-c-room', 'view-joined-room'], operation: 'hasAny' }, + }, + query: isChannelsListProps, + response: { + 200: channelsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const hasPermissionToSeeAllPublicChannels = await hasPermissionAsync(this.user, 'view-c-room'); + + const { _id } = this.queryParams; + + const ourQuery: Filter = { + ...query, + ...(_id ? { _id } : {}), + t: 'c', + }; + + if (!hasPermissionToSeeAllPublicChannels) { + const roomIds = ( + await Subscriptions.findByUserIdAndType(this.userId, 'c', { + projection: { rid: 1 }, + }).toArray() + ).map((s) => s.rid); + ourQuery._id = { $in: roomIds }; + } + + // teams filter - I would love to have a way to apply this filter @ db level :( + const ids = (await Subscriptions.findByUserId(this.userId, { projection: { rid: 1 } }).toArray()).map( + (item: Record) => item.rid, + ); + + ourQuery.$or = [ + { + teamId: { + $exists: false, + }, + }, + { + teamId: { + $exists: true, + }, + _id: { + $in: ids, + }, + }, + ]; + + const { cursor, totalCount } = Rooms.findPaginated(ourQuery, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), + count: channels.length, + offset, + total, + }); + }, +); + +// list.joined lists the caller's joined channels and ignores any room target, but existing clients +// still pass roomId/roomName, so accept (and ignore) them alongside the standard pagination params. +const channelsListJoinedQuery = ajvQuery.compile<{ + _id?: string; + roomId?: string; + roomName?: string; + query?: string; + count?: number; + offset?: number; + sort?: string; + fields?: string; +}>({ + type: 'object', + properties: { + _id: { type: 'string' }, + roomId: { type: 'string' }, + roomName: { type: 'string' }, + query: { type: 'string' }, + count: { type: 'number' }, + offset: { type: 'number' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + }, + required: [], + additionalProperties: false, +}); + +API.v1.get( + 'channels.list.joined', + { + authRequired: true, + query: channelsListJoinedQuery, + response: { + 200: channelsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields } = await this.parseJsonQuery(); + + const subs = await Subscriptions.findByUserIdAndTypes(this.userId, ['c'], { projection: { rid: 1 } }).toArray(); + const rids = subs.map(({ rid }) => rid).filter(Boolean); + + if (rids.length === 0) { + return API.v1.success({ + channels: [], + offset, + count: 0, + total: 0, + }); + } + + const { cursor, totalCount } = Rooms.findPaginatedByTypeAndIds('c', rids, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [channels, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + channels: await Promise.all(channels.map((room) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: channels.length, + total, + }); + }, +); + +const channelsMembersQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + filter?: string; + status?: string[]; + offset?: number; + count?: number; + sort?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + filter: { type: 'string' }, + status: { type: 'array', items: { type: 'string' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +// findUsersOfRoom returns a fixed projection (not a full IUser), so validate against the projected +// shape rather than $ref IUser (which would require createdAt/roles/type/active and reject real data). +const channelsMembersResponseSchema = ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + nickname: { type: 'string' }, + status: { type: 'string' }, + avatarETag: { type: 'string' }, + federated: { type: 'boolean' }, + _updatedAt: { type: 'string' }, + }, + required: ['_id'], + additionalProperties: true, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.members', + { + authRequired: true, + query: channelsMembersQuery, + response: { + 200: channelsMembersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) { + return API.v1.forbidden(); + } + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const hidden = await getUsersHiddenFrom(this.userId); + + const { cursor, totalCount } = await findUsersOfRoom({ + rid: findResult._id, + ...(status && { status: status as UserStatus[] }), + hidden, + skip, + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + members: redactHiddenUsers(members, hidden), + count: members.length, + offset: skip, + total, + }); + }, +); + +const channelsOnlineResponseSchema = ajv.compile<{ online: Pick[] }>({ + type: 'object', + properties: { + online: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['online', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'channels.online', + { + authRequired: true, + query: isChannelsOnlineProps, + response: { + 200: channelsOnlineResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; + + if ((!query || Object.keys(query).length === 0) && !_id) { + return API.v1.failure('Invalid query'); + } + + const filter = { + ...query, + ...(_id ? { _id } : {}), + t: 'c', + }; + + const room = await Rooms.findOne(filter as Record); + if (!room) { + return API.v1.failure('Channel does not exists'); + } + + if (!(await canAccessRoomAsync(room, this.user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + const hidden = await getUsersHiddenFrom(this.userId); + + const online: Pick[] = filterHiddenUsers( + await Users.findUsersNotOffline({ + projection: { username: 1 }, + }).toArray(), + hidden, + ); + + const onlineInRoom = await Promise.all( + online.map(async (user) => { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { + projection: { _id: 1, username: 1 }, + }); + if (subscription) { + return { + _id: user._id, + username: user.username, + }; + } + }), + ); + + return API.v1.success({ + online: onlineInRoom.filter(isTruthy), + }); + }, +); + +API.v1.post( + 'channels.removeModerator', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomModerator(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.removeOwner', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomOwner(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.rename', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; name: string }>('name', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.name?.trim()) { + return API.v1.failure('The bodyParam "name" is required'); + } + + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.name === this.bodyParams.name) { + return API.v1.failure('The channel name is the same as what it would be renamed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'roomName', this.bodyParams.name); + + return API.v1.success({ + channel: await findChannelByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }), + }); + }, +); + +API.v1.post( + 'channels.setCustomFields', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; customFields: Record }>('customFields', { + type: 'object', + }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.customFields || !(typeof this.bodyParams.customFields === 'object')) { + return API.v1.failure('The bodyParam "customFields" is required with a type like object.'); + } + + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + await saveRoomSettings(this.userId, findResult._id, 'roomCustomFields', this.bodyParams.customFields); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + }, +); + +API.v1.post( + 'channels.setDefault', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; default: boolean | string }>('default', { type: ['boolean', 'string'] }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (typeof this.bodyParams.default === 'undefined') { + return API.v1.failure('The bodyParam "default" is required', 'error-channels-setdefault-is-same'); + } + + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.default === this.bodyParams.default) { + return API.v1.failure( + 'The channel default setting is the same as what it would be changed to.', + 'error-channels-setdefault-missing-default-param', + ); + } + + await saveRoomSettings( + this.userId, + findResult._id, + 'default', + ['true', '1'].includes(this.bodyParams.default.toString().toLowerCase()), + ); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + }, +); + +API.v1.post( + 'channels.setDescription', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; description: string }>('description', { type: 'string' }), + response: { + 200: descriptionResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.description === this.bodyParams.description) { + return API.v1.failure('The channel description is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.description || ''); + + return API.v1.success({ + description: this.bodyParams.description || '', + }); + }, +); + +API.v1.post( + 'channels.setPurpose', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; purpose: string }>('purpose', { type: 'string' }), + response: { + 200: purposeResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.description === this.bodyParams.purpose) { + return API.v1.failure('The channel purpose (description) is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'roomDescription', this.bodyParams.purpose || ''); + + return API.v1.success({ + purpose: this.bodyParams.purpose || '', + }); + }, +); + +API.v1.post( + 'channels.setTopic', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; topic: string }>('topic', { type: 'string' }), + response: { + 200: topicResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.topic === this.bodyParams.topic) { + return API.v1.failure('The channel topic is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'roomTopic', this.bodyParams.topic || ''); + + return API.v1.success({ + topic: this.bodyParams.topic || '', + }); + }, +); + +API.v1.post( + 'channels.setType', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; type: string }>('type', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.type?.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } + + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The channel type is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult._id, 'roomType', this.bodyParams.type as RoomType); + + const room = await Rooms.findOneById(findResult._id, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + return API.v1.failure('The channel does not exist'); + } + + return API.v1.success({ + channel: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'channels.addLeader', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await addRoomLeader(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.removeLeader', + { + authRequired: true, + body: isChannelsModeratorsProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomLeader(this.userId, findResult._id, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'channels.setJoinCode', + { + authRequired: true, + body: roomSettingBody<{ roomId?: string; roomName?: string; joinCode: string }>('joinCode', { type: 'string' }), + response: { + 200: channelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.joinCode?.trim()) { + return API.v1.failure('The bodyParam "joinCode" is required'); + } + + const findResult = await findChannelByIdOrName({ params: this.bodyParams }); + + await saveRoomSettings(this.userId, findResult._id, 'joinCode', this.bodyParams.joinCode); + + return API.v1.success({ + channel: await findChannelByIdOrName({ params: this.bodyParams, userId: this.userId }), + }); + }, +); + +const channelsAnonymousReadQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +API.v1.get( + 'channels.anonymousread', + { + authOrAnonRequired: true, + query: channelsAnonymousReadQuery, + response: { + 200: channelsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const ourQuery = Object.assign({}, query, { rid: findResult._id }); + + if (!settings.get('Accounts_AllowAnonymousRead')) { + throw new Meteor.Error('error-not-allowed', 'Enable "Allow Anonymous Read"', { + method: 'channels.anonymousread', + }); + } + + // Public rooms of private teams should be accessible only by team members + if (findResult.teamId) { + const team = await Team.getOneById(findResult.teamId); + if (team?.type === TeamType.PRIVATE) { + if (!this.userId || !(await canAccessRoomAsync(findResult, { _id: this.userId }))) { + return API.v1.notFound('Room not found'); + } + } + } + + const { cursor, totalCount } = await Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId || ''), + count: messages.length, + offset, + total, + }); + }, +); diff --git a/apps/meteor/server/api/v1/chat.ts b/apps/meteor/server/api/v1/chat.ts new file mode 100644 index 0000000..4a8b541 --- /dev/null +++ b/apps/meteor/server/api/v1/chat.ts @@ -0,0 +1,1528 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; +import { + ajv, + isChatReportMessageProps, + isChatGetURLPreviewProps, + isChatUpdateProps, + isChatGetThreadsListProps, + isChatDeleteProps, + isChatSyncMessagesProps, + isChatGetMessageProps, + isChatGetMessagesProps, + isChatPostMessageProps, + isChatSearchProps, + isChatSendMessageProps, + isChatIgnoreUserProps, + isChatGetPinnedMessagesProps, + isChatGetMentionedMessagesProps, + isChatReactProps, + isChatGetDeletedMessagesProps, + isChatSyncThreadsListProps, + isChatGetThreadMessagesProps, + isChatSyncThreadMessagesProps, + isChatGetStarredMessagesProps, + isChatGetDiscussionsProps, + validateBadRequestErrorResponse, + validateNotFoundErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { roomAccessAttributes } from '../../lib/authorization'; +import { canAccessRoomAsync, canAccessRoomIdAsync, canAccessRoomIdsAsync } from '../../lib/authorization/canAccessRoom'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; +import { deleteMessageValidatingPermission } from '../../lib/messages/deleteMessage'; +import { processWebhookMessage } from '../../lib/messages/processWebhookMessage'; +import { pinMessage, unpinMessage } from '../../lib/messaging/pins/pinMessage'; +import { executeSetReaction } from '../../lib/messaging/reactions/setReaction'; +import { starMessage } from '../../lib/messaging/stars/starMessage'; +import { readThread } from '../../lib/messaging/threads/functions'; +import { reportMessage } from '../../lib/moderation/reportMessage'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { followMessage } from '../../meteor-methods/messages/followMessage'; +import { getSingleMessage } from '../../meteor-methods/messages/getSingleMessage'; +import { messageSearch } from '../../meteor-methods/messages/messageSearch'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +import { unfollowMessage } from '../../meteor-methods/messages/unfollowMessage'; +import { executeUpdateMessage } from '../../meteor-methods/messages/updateMessage'; +import { ignoreUser } from '../../meteor-methods/users/ignoreUser'; +import { getMessageHistory } from '../../publications/messages'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { findDiscussionsFromRoom, findMentionedMessages, findStarredMessages } from '../lib/messages'; + +type ChatStarMessageLocal = { + messageId: IMessage['_id']; +}; + +type ChatUnstarMessageLocal = { + messageId: IMessage['_id']; +}; + +const ChatStarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const ChatUnstarMessageLocalSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +type ChatFollowMessageLocal = { + mid: string; +}; + +const ChatFollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +type ChatUnfollowMessageLocal = { + mid: string; +}; + +const ChatUnfollowMessageLocalSchema = { + type: 'object', + properties: { + mid: { + type: 'string', + minLength: 1, + }, + }, + required: ['mid'], + additionalProperties: false, +}; + +const isChatStarMessageLocalProps = ajv.compile(ChatStarMessageLocalSchema); + +const isChatUnstarMessageLocalProps = ajv.compile(ChatUnstarMessageLocalSchema); + +const isChatFollowMessageLocalProps = ajv.compile(ChatFollowMessageLocalSchema); + +const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); + +type ChatPinMessage = { + messageId: IMessage['_id']; +}; + +type ChatUnpinMessage = { + messageId: IMessage['_id']; +}; + +const ChatPinMessageSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const ChatUnpinMessageSchema = { + type: 'object', + properties: { + messageId: { + type: 'string', + minLength: 1, + }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +const isChatPinMessageProps = ajv.compile(ChatPinMessageSchema); + +const isChatUnpinMessageProps = ajv.compile(ChatUnpinMessageSchema); + +type ChatReadThread = { + tmid: IMessage['_id']; +}; + +const ChatReadThreadSchema = { + type: 'object', + properties: { + tmid: { + type: 'string', + minLength: 1, + }, + }, + required: ['tmid'], + additionalProperties: false, +}; + +const isChatReadThreadProps = ajv.compile(ChatReadThreadSchema); + +const chatEndpoints = API.v1 + .post( + 'chat.pinMessage', + { + authRequired: true, + body: isChatPinMessageProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + const pinnedMessage = await pinMessage(msg, this.userId); + + const [message] = await normalizeMessagesForUser([pinnedMessage], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .post( + 'chat.unPinMessage', + { + authRequired: true, + body: isChatUnpinMessageProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await unpinMessage(this.userId, msg); + + return API.v1.success(); + }, + ) + .post( + 'chat.readThread', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, + body: isChatReadThreadProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + + const { tmid } = this.bodyParams; + + const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); + if (!thread?.rid) { + throw new Meteor.Error('error-invalid-message', 'Invalid Message'); + } + + const [user, room] = await Promise.all([ + Users.findOneById(this.userId), + Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }), + ]); + + if (!room) { + throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist'); + } + + if (!user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + await callbacks.run('beforeReadMessages', room._id, user._id); + await readThread({ user, room, tmid }); + + return API.v1.success(); + }, + ) + .post( + 'chat.update', + { + authRequired: true, + body: isChatUpdateProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { bodyParams } = this; + + const msg = await Messages.findOneById(bodyParams.msgId); + + // Ensure the message exists + if (!msg) { + return API.v1.failure(`No message found with the id of "${bodyParams.msgId}".`); + } + + if (bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); + } + + const hasContent = 'content' in bodyParams; + + if (hasContent && msg.t !== 'e2e') { + return API.v1.failure('Only encrypted messages can have content updated.'); + } + + const updateData: Parameters = [ + this.userId, + hasContent + ? { + _id: msg._id, + rid: msg.rid, + content: bodyParams.content, + ...(bodyParams.e2eMentions && { e2eMentions: bodyParams.e2eMentions }), + } + : { + _id: msg._id, + rid: msg.rid, + msg: bodyParams.text, + ...(bodyParams.customFields && { customFields: bodyParams.customFields }), + }, + 'previewUrls' in bodyParams ? bodyParams.previewUrls : undefined, + ]; + + // Permission checks are already done in the updateMessage method, so no need to duplicate them + await applyAirGappedRestrictionsValidation(() => executeUpdateMessage(...updateData)); + + const updatedMessage = await Messages.findOneById(msg._id); + const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .post( + 'chat.starMessage', + { + authRequired: true, + body: isChatStarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: true, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unStarMessage', + { + authRequired: true, + body: isChatUnstarMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + await starMessage(this.user, { + _id: msg._id, + rid: msg.rid, + starred: false, + }); + + return API.v1.success(); + }, + ) + .post( + 'chat.followMessage', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 5000 }, + body: isChatFollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await followMessage(this.user, { mid }); + + return API.v1.success(); + }, + ) + .post( + 'chat.unfollowMessage', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 5000 }, + body: isChatUnfollowMessageLocalProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { mid } = this.bodyParams; + + if (!mid) { + throw new Meteor.Error('The required "mid" body param is missing.'); + } + + await unfollowMessage(this.user, { mid }); + + return API.v1.success(); + }, + ) + .post( + 'chat.react', + { + authRequired: true, + body: isChatReactProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.messageId); + + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); + } + + const emoji = 'emoji' in this.bodyParams ? this.bodyParams.emoji : (this.bodyParams as { reaction: string }).reaction; + + if (!emoji) { + throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); + } + + await executeSetReaction(this.userId, emoji, msg, this.bodyParams.shouldReact); + + return API.v1.success(); + }, + ) + .post( + 'chat.reportMessage', + { + authRequired: true, + body: isChatReportMessageProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { messageId, description } = this.bodyParams; + + await reportMessage(messageId, description, this.userId); + + return API.v1.success(); + }, + ) + .post( + 'chat.delete', + { + authRequired: true, + body: isChatDeleteProps, + response: { + 200: ajv.compile<{ _id?: string; ts?: string; message?: Pick }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + ts: { type: 'string' }, + message: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { type: 'object' }, + }, + required: ['_id', 'rid', 'u'], + additionalProperties: true, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // Deleting by fileId resolves the message that references the file and deletes it. + // An orphan upload (a file with no associated message) is not deletable through this + // endpoint and intentionally returns a failure below. + const msg = + 'fileId' in this.bodyParams + ? await Messages.getMessageByFileId(this.bodyParams.fileId) + : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); + + if (!msg) { + if ('fileId' in this.bodyParams) { + return API.v1.failure(`No message found with the file id: "${this.bodyParams.fileId}".`); + } + return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); + } + + if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); + } + + if (this.bodyParams.asUser && msg.u._id !== this.userId && !(await hasPermissionAsync(this.user, 'force-delete-message', msg.rid))) { + return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); + } + + const userId = this.bodyParams.asUser ? msg.u._id : this.userId; + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + + if (!user) { + return API.v1.failure('User not found'); + } + + await deleteMessageValidatingPermission(msg, user._id); + + return API.v1.success({ + _id: msg._id, + ts: Date.now().toString(), + message: { _id: msg._id, rid: msg.rid, u: msg.u }, + }); + }, + ) + .get( + 'chat.syncMessages', + { + authRequired: true, + query: isChatSyncMessagesProps, + response: { + 200: ajv.compile<{ + result: { + updated: IMessage[]; + deleted: { _id: string; _deletedAt: string }[]; + cursor?: { next: string | null; previous: string | null }; + }; + }>({ + type: 'object', + properties: { + result: { + type: 'object', + properties: { + updated: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + deleted: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + cursor: { + type: 'object', + properties: { + next: { type: ['string', 'null'] }, + previous: { type: ['string', 'null'] }, + }, + }, + }, + required: ['updated', 'deleted'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, lastUpdate, fromTs, count, next, previous, type } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); + } + + if (!lastUpdate && !type) { + throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); + } + + if (lastUpdate && isNaN(Date.parse(lastUpdate))) { + throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); + } + + const getMessagesQuery = { + ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), + ...(fromTs && { fromTs: new Date(fromTs) }), + ...(next && { next }), + ...(previous && { previous }), + ...(count && { count }), + ...(type && { type }), + }; + + const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success({ + result: { + updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], + deleted: + 'deleted' in result + ? result.deleted.map((msg) => ({ + _id: msg._id, + _deletedAt: + '_deletedAt' in msg && msg._deletedAt instanceof Date ? msg._deletedAt.toISOString() : new Date().toISOString(), + })) + : [], + cursor: 'cursor' in result ? result.cursor : undefined, + }, + }); + }, + ) + .get( + 'chat.getMessage', + { + authRequired: true, + query: isChatGetMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.queryParams.msgId) { + return API.v1.failure('The "msgId" query parameter must be provided.'); + } + + const msg = await getSingleMessage(this.userId, this.queryParams.msgId); + + if (!msg) { + return API.v1.failure(); + } + + const [message] = await normalizeMessagesForUser([msg], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .post( + 'chat.postMessage', + { + authRequired: true, + body: isChatPostMessageProps, + response: { + 200: ajv.compile<{ ts: number; channel: string; message: IMessage }>({ + type: 'object', + properties: { + ts: { type: 'number' }, + channel: { type: 'string' }, + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['ts', 'channel', 'message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { text, attachments } = this.bodyParams; + const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; + + if (text && text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + if (attachment.text && attachment.text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + } + } + + const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; + + if (!messageReturn?.message) { + return API.v1.failure('unknown-error'); + } + + const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); + + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message, + }); + }, + ) + .get( + 'chat.search', + { + authRequired: true, + query: isChatSearchProps, + response: { + 200: ajv.compile<{ messages: IMessage[] }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, searchText } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!roomId) { + throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); + } + + if (!searchText) { + throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); + } + + const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); + if (searchResult === false) { + return API.v1.failure(); + } + if (!searchResult.message) { + return API.v1.failure(); + } + const result = searchResult.message.docs; + + return API.v1.success({ + messages: await normalizeMessagesForUser(result, this.userId), + }); + }, + ) + // The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows + // for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to + // one channel whereas the other one allows for sending to more than one channel at a time. + .post( + 'chat.sendMessage', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 1000, bypassPermissions: ['send-many-messages'] }, + body: isChatSendMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (MessageTypes.isSystemMessage(this.bodyParams.message)) { + throw new Error("Cannot send system messages using 'chat.sendMessage'"); + } + + const sent = await applyAirGappedRestrictionsValidation(() => + executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), + ); + const [message] = await normalizeMessagesForUser([sent], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .get( + 'chat.ignoreUser', + { + authRequired: true, + query: isChatIgnoreUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, userId } = this.queryParams; + let { ignore = true } = this.queryParams; + + ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + + if (!rid?.trim()) { + throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + } + + if (!userId?.trim()) { + throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + } + + await ignoreUser(this.userId, { rid, userId, ignore }); + + return API.v1.success(); + }, + ) + .get( + 'chat.getDeletedMessages', + { + authRequired: true, + query: isChatGetDeletedMessagesProps, + response: { + 200: ajv.compile<{ messages: Pick[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: only _id is projected, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, since } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = Messages.trashFindPaginatedDeletedAfter( + new Date(since), + { rid: roomId }, + { + skip: offset, + limit: count, + projection: { _id: 1 }, + }, + ); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages, + count: messages.length, + offset, + total, + }); + }, + ) + .get( + 'chat.getPinnedMessages', + { + authRequired: true, + query: isChatGetPinnedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { cursor, totalCount } = Messages.findPaginatedPinnedByRoom(roomId, { + skip: offset, + limit: count, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }, + ) + .get( + 'chat.getThreadsList', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, + query: isChatGetThreadsListProps, + response: { + 200: ajv.compile<{ threads: IThreadMainMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + threads: { type: 'array', items: { type: 'object' } }, // relaxed: IThreadMainMessage not in OpenAPI schemas, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, type, text } = this.queryParams; + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); + + if (!room || !user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + const typeThread = { + _hidden: { $ne: true }, + ...(type === 'following' && { replies: { $in: [this.userId] } }), + ...(type === 'unread' && { _id: { $in: (await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id))?.tunread || [] } }), + msg: new RegExp(escapeRegExp(text || ''), 'i'), + }; + + const threadQuery = { ...query, ...typeThread, rid: room._id, tcount: { $exists: true } }; + const { cursor, totalCount } = await Messages.findPaginated(threadQuery, { + sort: sort || { tlm: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [threads, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + threads: await normalizeMessagesForUser(threads, this.userId), + count: threads.length, + offset, + total, + }); + }, + ) + .get( + 'chat.syncThreadsList', + { + authRequired: true, + query: isChatSyncThreadsListProps, + response: { + 200: ajv.compile<{ threads: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + threads: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid } = this.queryParams; + const { query, fields, sort } = await this.parseJsonQuery(); + const { updatedSince } = this.queryParams; + let updatedSinceDate; + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); + + if (!room || !user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + const threadQuery = Object.assign({}, query, { rid, tcount: { $exists: true } }); + return API.v1.success({ + threads: { + update: await Messages.find( + { ...threadQuery, _updatedAt: { $gt: updatedSinceDate } }, + { + sort, + projection: fields, + }, + ).toArray(), + remove: await Messages.trashFindDeletedAfter(updatedSinceDate, threadQuery, { + sort, + projection: fields, + }).toArray(), + }, + }); + }, + ) + .get( + 'chat.getThreadMessages', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, + query: isChatGetThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { tmid, aroundId } = this.queryParams; + const { query, fields, sort } = await this.parseJsonQuery(); + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + + const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); + if (!thread?.rid) { + throw new Meteor.Error('error-invalid-message', 'Invalid Message'); + } + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); + const room = await Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); + + if (!room || !user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + let resolvedOffset = offset; + let resolvedSort = sort || { ts: 1 }; + if (aroundId) { + resolvedSort = { ts: 1 }; + if (aroundId === tmid) { + resolvedOffset = 0; + } else { + const target = await Messages.findOneById(aroundId, { projection: { ts: 1, tmid: 1 } }); + if (target?.tmid !== tmid || !target.ts) { + throw new Meteor.Error('error-invalid-message', 'The provided "aroundId" does not belong to the thread'); + } + const before = await Messages.countDocuments({ ...query, tmid, _hidden: { $ne: true }, ts: { $lt: target.ts } }); + resolvedOffset = Math.max(0, before - Math.floor(count / 2)); + } + } + + const { cursor, totalCount } = Messages.findPaginated( + { ...query, tmid, _hidden: { $ne: true } }, + { + sort: resolvedSort, + skip: resolvedOffset, + limit: count, + projection: fields, + }, + ); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages, + count: messages.length, + offset: resolvedOffset, + total, + }); + }, + ) + .get( + 'chat.syncThreadMessages', + { + authRequired: true, + query: isChatSyncThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + messages: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { tmid } = this.queryParams; + const { query, fields, sort } = await this.parseJsonQuery(); + const { updatedSince } = this.queryParams; + let updatedSinceDate; + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); + } + + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); + if (!thread?.rid) { + throw new Meteor.Error('error-invalid-message', 'Invalid Message'); + } + // TODO: promise.all? this.user? + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); + const room = await Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); + + if (!room || !user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + return API.v1.success({ + messages: { + update: await Messages.find({ ...query, tmid, _updatedAt: { $gt: updatedSinceDate } }, { projection: fields, sort }).toArray(), + remove: await Messages.trashFindDeletedAfter(updatedSinceDate, { ...query, tmid }, { projection: fields, sort }).toArray(), + }, + }); + }, + ) + .get( + 'chat.getMentionedMessages', + { + authRequired: true, + query: isChatGetMentionedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { offset, count } = await getPaginationItems(this.queryParams); + + const messages = await findMentionedMessages({ + uid: this.userId, + roomId, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(messages); + }, + ) + .get( + 'chat.getStarredMessages', + { + authRequired: true, + query: isChatGetStarredMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { offset, count } = await getPaginationItems(this.queryParams); + + const messages = await findStarredMessages({ + uid: this.userId, + roomId, + pagination: { + offset, + count, + sort, + }, + }); + + messages.messages = await normalizeMessagesForUser(messages.messages, this.userId); + + return API.v1.success(messages); + }, + ) + .get( + 'chat.getDiscussions', + { + authRequired: true, + query: isChatGetDiscussionsProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'total', 'success'], + additionalProperties: true, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, text } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { offset, count } = await getPaginationItems(this.queryParams); + + const messages = await findDiscussionsFromRoom({ + uid: this.userId, + roomId, + text: text || '', + pagination: { + offset, + count, + sort, + }, + }); + return API.v1.success(messages); + }, + ) + .get( + 'chat.getURLPreview', + { + authRequired: true, + query: isChatGetURLPreviewProps, + response: { + 200: ajv.compile<{ urlPreview: object }>({ + type: 'object', + properties: { + urlPreview: { type: 'object' }, // relaxed: opaque preview shape, + success: { type: 'boolean', enum: [true] }, + }, + required: ['urlPreview', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, url } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { urlPreview } = await Message.parseOEmbedUrl(url); + urlPreview.ignoreParse = true; + + return API.v1.success({ urlPreview }); + }, + ) + .post( + 'chat.getMessages', + { + authRequired: true, + body: isChatGetMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[] }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { messageIds } = this.bodyParams; + + const messages = await Messages.findVisibleByIds(messageIds).toArray(); + if (!messages.length) { + return API.v1.notFound(); + } + + const rids = [...new Set(messages.map(({ rid }) => rid))]; + + // The batch spans rooms, so one unreadable room rejects the whole request. + if (!(await canAccessRoomIdsAsync(rids, this.user))) { + return API.v1.forbidden(); + } + + return API.v1.success({ messages: await normalizeMessagesForUser(messages, this.userId) }); + }, + ); + +export type ChatEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends ChatEndpoints {} +} diff --git a/apps/meteor/server/api/v1/cloud.ts b/apps/meteor/server/api/v1/cloud.ts new file mode 100644 index 0000000..d921ce0 --- /dev/null +++ b/apps/meteor/server/api/v1/cloud.ts @@ -0,0 +1,361 @@ +import type { CloudRegistrationIntentData, CloudConfirmationPollData, CloudRegistrationStatus } from '@rocket.chat/core-typings'; +import { + isCloudConfirmationPollProps, + isCloudConnectWorkspaceProps, + isCloudCreateRegistrationIntentProps, + isCloudManualRegisterProps, + ajv, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { CloudWorkspaceRegistrationError } from '../../../lib/errors/CloudWorkspaceRegistrationError'; +import { buildWorkspaceRegistrationData } from '../../lib/cloud/buildRegistrationData'; +import { connectWorkspace } from '../../lib/cloud/connectWorkspace'; +import { getCheckoutUrl } from '../../lib/cloud/getCheckoutUrl'; +import { getConfirmationPoll } from '../../lib/cloud/getConfirmationPoll'; +import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError } from '../../lib/cloud/getWorkspaceAccessToken'; +import { registerPreIntentWorkspaceWizard } from '../../lib/cloud/registerPreIntentWorkspaceWizard'; +import { removeLicense } from '../../lib/cloud/removeLicense'; +import { retrieveRegistrationStatus } from '../../lib/cloud/retrieveRegistrationStatus'; +import { saveRegistrationData, saveRegistrationDataManual } from '../../lib/cloud/saveRegistrationData'; +import { startRegisterWorkspaceSetupWizard } from '../../lib/cloud/startRegisterWorkspaceSetupWizard'; +import { syncWorkspace } from '../../lib/cloud/syncWorkspace'; +import { SystemLogger } from '../../lib/logger/system'; +import { API } from '../api'; + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const manualRegisterResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const createRegistrationIntentResponseSchema = ajv.compile<{ intentData: CloudRegistrationIntentData }>({ + type: 'object', + properties: { + intentData: { $ref: '#/components/schemas/CloudRegistrationIntentData' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['intentData', 'success'], + additionalProperties: false, +}); + +const registerPreIntentResponseSchema = ajv.compile<{ offline: boolean }>({ + type: 'object', + properties: { + offline: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['offline', 'success'], + additionalProperties: false, +}); + +const confirmationPollResponseSchema = ajv.compile<{ pollData: CloudConfirmationPollData }>({ + type: 'object', + properties: { + pollData: { + oneOf: [ + { $ref: '#/components/schemas/CloudConfirmationPollDataPending' }, + { $ref: '#/components/schemas/CloudConfirmationPollDataSuccess' }, + ], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['pollData', 'success'], + additionalProperties: false, +}); + +const registrationStatusResponseSchema = ajv.compile<{ registrationStatus: CloudRegistrationStatus }>({ + type: 'object', + properties: { + registrationStatus: { $ref: '#/components/schemas/CloudRegistrationStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['registrationStatus', 'success'], + additionalProperties: false, +}); + +const workspaceRegisterDataResponseSchema = ajv.compile<{ registerData: string }>({ + type: 'object', + properties: { + registerData: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['registerData', 'success'], + additionalProperties: false, +}); + +const checkoutUrlResponseSchema = ajv.compile<{ url: string }>({ + type: 'object', + properties: { + url: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['url', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'cloud.manualRegister', + { + authRequired: true, + permissionsRequired: ['register-on-cloud'], + body: isCloudManualRegisterProps, + response: { + 200: manualRegisterResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const registrationInfo = await retrieveRegistrationStatus(); + + if (registrationInfo.workspaceRegistered) { + return API.v1.failure('Workspace is already registered'); + } + + const settingsData = JSON.parse(Buffer.from(this.bodyParams.cloudBlob, 'base64').toString()); + + await saveRegistrationDataManual(settingsData); + + return API.v1.success(); + }, +); + +API.v1.post( + 'cloud.createRegistrationIntent', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + body: isCloudCreateRegistrationIntentProps, + response: { + 200: createRegistrationIntentResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const intentData = await startRegisterWorkspaceSetupWizard(this.bodyParams.resend, this.bodyParams.email); + + if (intentData) { + return API.v1.success({ intentData }); + } + + return API.v1.failure('Invalid query'); + }, +); + +API.v1.post( + 'cloud.registerPreIntent', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: registerPreIntentResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) }); + }, +); + +API.v1.get( + 'cloud.confirmationPoll', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + query: isCloudConfirmationPollProps, + response: { + 200: confirmationPollResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { deviceCode } = this.queryParams; + + const pollData = await getConfirmationPoll(deviceCode); + if (pollData) { + if ('successful' in pollData && pollData.successful) { + await saveRegistrationData(pollData.payload); + } + return API.v1.success({ pollData }); + } + + return API.v1.failure('Invalid query'); + }, +); + +API.v1.get( + 'cloud.registrationStatus', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: registrationStatusResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const registrationStatus = await retrieveRegistrationStatus(); + + return API.v1.success({ registrationStatus }); + }, +); + +API.v1.get( + 'cloud.workspaceRegisterData', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: workspaceRegisterDataResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const registrationData = await buildWorkspaceRegistrationData(undefined); + + return API.v1.success({ registerData: Buffer.from(JSON.stringify(registrationData)).toString('base64') }); + }, +); + +API.v1.post( + 'cloud.syncWorkspace', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 }, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { + await syncWorkspace(); + + return API.v1.success(); + } catch (error) { + return API.v1.failure('Error during workspace sync'); + } + }, +); + +API.v1.post( + 'cloud.removeLicense', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 }, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { + await removeLicense(); + return API.v1.success(); + } catch (error) { + switch (true) { + case error instanceof CloudWorkspaceRegistrationError: + case error instanceof CloudWorkspaceAccessTokenEmptyError: + case error instanceof CloudWorkspaceAccessTokenError: { + SystemLogger.info({ + msg: 'Manual license removal failed', + endpoint: 'cloud.removeLicense', + error, + }); + break; + } + default: { + SystemLogger.error({ + msg: 'Manual license removal failed', + endpoint: 'cloud.removeLicense', + error, + }); + break; + } + } + return API.v1.failure('License removal failed'); + } + }, +); + +/** + * Declaring endpoint here because we don't want this available to the sdk client + */ +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Endpoints { + '/v1/cloud.checkoutUrl': { + GET: () => { url: string }; + }; + } +} + +API.v1.post( + 'cloud.connectWorkspace', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + body: isCloudConnectWorkspaceProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const connected = await connectWorkspace(this.bodyParams.token); + if (!connected) { + return API.v1.failure('Failed to connect the workspace with Rocket.Chat Cloud'); + } + return API.v1.success(); + }, +); + +API.v1.get( + 'cloud.checkoutUrl', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + response: { + 200: checkoutUrlResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const checkoutUrl = await getCheckoutUrl(); + + if (!checkoutUrl.url) { + return API.v1.failure(); + } + + return API.v1.success({ url: checkoutUrl.url }); + }, +); diff --git a/apps/meteor/server/api/v1/commands.ts b/apps/meteor/server/api/v1/commands.ts new file mode 100644 index 0000000..c768266 --- /dev/null +++ b/apps/meteor/server/api/v1/commands.ts @@ -0,0 +1,521 @@ +import { Apps } from '@rocket.chat/apps'; +import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import objectPath from 'object-path'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSlashCommandPreview } from '../../meteor-methods/messages/executeSlashCommandPreview'; +import { getSlashCommandPreviews } from '../../meteor-methods/messages/getSlashCommandPreviews'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +type CommandsGetParams = { command: string }; + +const CommandsGetParamsSchema = { + type: 'object', + properties: { + command: { type: 'string' }, + }, + required: ['command'], + additionalProperties: false, +}; + +const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema); + +const commandsListResponseSchema = ajv.compile<{ + commands: SlashCommand[]; + appsLoaded: boolean; + offset: number; + count: number; + total: number; +}>({ + type: 'object', + properties: { + commands: { + type: 'array', + items: { $ref: '#/components/schemas/SlashCommand' }, + }, + appsLoaded: { type: 'boolean' }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); + +type CommandsListParams = { + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}; + +const isCommandsListParams = ajvQuery.compile({ + type: 'object', + properties: { + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + fields: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}); + +const commandsEndpoints = API.v1 + .get( + 'commands.get', + { + authRequired: true, + query: isCommandsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + command: Pick; + success: true; + }>({ + type: 'object', + properties: { + command: { + type: 'object', + properties: { + clientOnly: { type: 'boolean' }, + command: { type: 'string' }, + description: { type: 'string' }, + params: { type: 'string' }, + providesPreview: { type: 'boolean' }, + }, + required: ['command', 'providesPreview'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['command', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + const params = this.queryParams; + + const cmd = slashCommands.commands[params.command.toLowerCase()]; + + if (!cmd) { + return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); + } + + return API.v1.success({ + command: { + command: cmd.command, + description: cmd.description, + params: cmd.params, + clientOnly: cmd.clientOnly, + providesPreview: cmd.providesPreview, + }, + }); + }, + ) + .get( + 'commands.list', + { + authRequired: true, + query: isCommandsListParams, + response: { + 200: commandsListResponseSchema, + 202: commandsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!Apps.self?.isLoaded()) { + return API.v1.success( + { + commands: [], + appsLoaded: false as const, + offset: 0, + count: 0, + total: 0, + success: true as const, + }, + 202, + ); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + let commands = Object.values(slashCommands.commands); + + if (query?.command) { + commands = commands.filter((command) => command.command === query.command); + } + + const totalCount = commands.length; + + return API.v1.success({ + commands: processQueryOptionsOnResult(commands, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }), + appsLoaded: true as const, + offset, + count: commands.length, + total: totalCount, + }); + }, + ); + +/* @deprecated */ +const processQueryOptionsOnResult = , F extends keyof T>( + result: T[], + options: { + fields?: { + [key in F]?: 1 | 0; + }; + sort?: { + [key: string]: 1 | -1; + }; + limit?: number; + skip?: number; + } = {}, +): Pick[] => { + if (result === undefined || result === null) { + return []; + } + + if (Array.isArray(result)) { + if (options.sort) { + result = result.sort((a, b) => { + let r = 0; + for (const field in options.sort) { + if (options.sort.hasOwnProperty(field)) { + const direction = options.sort[field]; + let valueA; + let valueB; + if (field.indexOf('.') > -1) { + valueA = objectPath.get(a, field); + valueB = objectPath.get(b, field); + } else { + valueA = a[field]; + valueB = b[field]; + } + if (valueA > valueB) { + r = direction; + break; + } + if (valueA < valueB) { + r = -direction; + break; + } + } + } + return r; + }); + } + + if (typeof options.skip === 'number') { + result.splice(0, options.skip); + } + + if (typeof options.limit === 'number' && options.limit !== 0) { + result.splice(options.limit); + } + } + + const fieldsToRemove: F[] = []; + const fieldsToGet: F[] = []; + + if (options.fields) { + for (const field in Object.keys(options.fields)) { + if (options.fields.hasOwnProperty(field as F)) { + if (options.fields[field as F] === 0) { + fieldsToRemove.push(field as F); + } else if (options.fields[field as F] === 1) { + fieldsToGet.push(field as F); + } + } + } + } + + if (fieldsToGet.length > 0 && fieldsToGet.indexOf('_id' as F) === -1) { + fieldsToGet.push('_id' as F); + } + + const pickFields = (obj: T, fields: F[]): Pick => { + const picked: Partial = {}; + fields.forEach((field: F) => { + if (String(field).indexOf('.') !== -1) { + objectPath.set(picked, String(field), objectPath.get(obj, String(field))); + } else { + picked[field] = obj[field]; + } + }); + return picked as Pick; + }; + + if (fieldsToRemove.length > 0 && fieldsToGet.length > 0) { + console.warn("Can't mix remove and get fields"); + fieldsToRemove.splice(0, fieldsToRemove.length); + } + + if (fieldsToRemove.length > 0 || fieldsToGet.length > 0) { + return result.map((record) => { + if (fieldsToRemove.length > 0) { + return Object.fromEntries(Object.entries(record).filter(([key]) => !fieldsToRemove.includes(key as F))) as Pick; + } + + return pickFields(record, fieldsToGet); + }); + } + + return result; +}; + +const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); + +const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({ + type: 'object', + properties: { + result: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); + +const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record | null }>({ + type: 'object', + properties: { + preview: { type: 'object', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const isCommandsPreviewPostProps = ajv.compile<{ + command: string; + params?: string; + roomId: string; + tmid?: string; + triggerId?: string; + previewItem: SlashCommandPreviewItem; +}>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + previewItem: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] }, + value: { type: 'string' }, + }, + required: ['id', 'type', 'value'], + additionalProperties: false, + }, + }, + required: ['command', 'roomId', 'previewItem'], + additionalProperties: false, +}); + +const commandsPreviewPostResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' } +API.v1.post( + 'commands.run', + { + authRequired: true, + body: isCommandsRunProps, + response: { + 200: commandsRunResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; + + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } + + const params = body.params ? body.params : ''; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); + } + } + + const message = { + _id: Random.id(), + rid: body.roomId, + msg: `/${cmd} ${params}`, + ...(body.tmid && { tmid: body.tmid }), + }; + + const { triggerId } = body; + + const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); + + return API.v1.success({ result }); + }, +); + +// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' +API.v1.get( + 'commands.preview', + { + authRequired: true, + query: isCommandsPreviewGetProps, + response: { + 200: commandsPreviewGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const query = this.queryParams; + + const cmd = query.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { + return API.v1.forbidden(); + } + + const params = query.params ? query.params : ''; + + const preview = await getSlashCommandPreviews({ + cmd, + params, + msg: { rid: query.roomId }, + userId: this.userId, + }); + + return API.v1.success({ preview: preview ?? null }); + }, +); + +// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } +API.v1.post( + 'commands.preview', + { + authRequired: true, + body: isCommandsPreviewPostProps, + response: { + 200: commandsPreviewPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; + + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } + + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } + + const { params = '' } = body; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); + } + } + + const msg = { + rid: body.roomId, + ...(body.tmid && { tmid: body.tmid }), + }; + + await executeSlashCommandPreview( + { + cmd, + params, + msg, + triggerId: body.triggerId, + }, + body.previewItem, + this.userId, + ); + + return API.v1.success(); + }, +); + +export type CommandsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CommandsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/connection.d.ts b/apps/meteor/server/api/v1/connection.d.ts new file mode 100644 index 0000000..691a507 --- /dev/null +++ b/apps/meteor/server/api/v1/connection.d.ts @@ -0,0 +1,13 @@ +import type { IInstanceStatus } from '@rocket.chat/core-typings'; + +declare const connection: + | { + _id: string; + address: string; + currentStatus: IInstanceStatus['currentStatus']; + instanceRecord: IInstanceStatus['instanceRecord']; + broadcastAuth: boolean; + } + | undefined; + +export as namespace connection; diff --git a/apps/meteor/server/api/v1/custom-sounds.ts b/apps/meteor/server/api/v1/custom-sounds.ts new file mode 100644 index 0000000..7cc93f6 --- /dev/null +++ b/apps/meteor/server/api/v1/custom-sounds.ts @@ -0,0 +1,339 @@ +import type { ICustomSound } from '@rocket.chat/core-typings'; +import { CustomSounds } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { + isCustomSoundsGetOneProps, + isCustomSoundsListProps, + isCustomSoundsCreateProps, + isCustomSoundsDeleteProps, + isCustomSoundsUpdateProps, + ajv, + validateBadRequestErrorResponse, + validateNotFoundErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, + validateInternalErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { getExtension, getMimeTypeFromFileName } from '../../../app/utils/lib/mimeTypes'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../lib/constants'; +import { SystemLogger } from '../../lib/logger/system'; +import { deleteCustomSound } from '../../lib/media/custom-sounds/lib/deleteCustomSound'; +import { insertOrUpdateSound } from '../../lib/media/custom-sounds/lib/insertOrUpdateSound'; +import { uploadCustomSound } from '../../lib/media/custom-sounds/lib/uploadCustomSound'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; + +const createCustomSoundsResponse = ajv.compile<{ sound: Pick; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sound: { + type: 'object', + properties: { + _id: { + type: 'string', + description: 'The ID of the sound.', + }, + }, + required: ['_id'], + }, + }, + required: ['success', 'sound'], +}); + +const updateCustomSoundsResponse = ajv.compile<{ success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], +}); + +const deleteCustomSoundsResponse = ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], +}); + +const customSoundsEndpoints = API.v1 + .get( + 'custom-sounds.list', + { + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile< + PaginatedResult<{ + sounds: ICustomSound[]; + }> + >({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of sounds returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of sounds that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of sounds that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sounds: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomSound', + }, + }, + }, + required: ['count', 'offset', 'total', 'sounds', 'success'], + }), + }, + query: isCustomSoundsListProps, + authRequired: true, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + const { name } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), + }; + + const { cursor, totalCount } = CustomSounds.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + sounds, + count: sounds.length, + offset, + total, + }); + }, + ) + .get( + 'custom-sounds.getOne', + { + response: { + 200: ajv.compile<{ sound: ICustomSound; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + sound: { + $ref: '#/components/schemas/ICustomSound', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['sound', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isCustomSoundsGetOneProps, + authRequired: true, + }, + async function action() { + const { _id } = this.queryParams; + + const sound = await CustomSounds.findOneById(_id); + + if (!sound) { + return API.v1.notFound('Custom Sound not found.'); + } + + return API.v1.success({ sound }); + }, + ) + .post( + 'custom-sounds.create', + { + response: { + 200: createCustomSoundsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { fields, fileBuffer, filename } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsCreateProps, + }, + ); + + const computedMimeType = getMimeTypeFromFileName(filename); + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) { + return API.v1.failure('MIME type not allowed'); + } + + // We extract its extension from the name or use getExtension() as fallback. + const soundExtension = + (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType); + + try { + const _id = await insertOrUpdateSound({ + name: fields.name, + extension: soundExtension, + }); + await uploadCustomSound(fileBuffer, computedMimeType, { _id, name: fields.name, extension: soundExtension }); + return API.v1.success({ sound: { _id } }); + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + }, + ) + .post( + 'custom-sounds.update', + { + response: { + 200: updateCustomSoundsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { fields, fileBuffer, filename } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + fileOptional: true, + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsUpdateProps, + }, + ); + + let computedMimeType = ''; + if (fileBuffer) { + computedMimeType = getMimeTypeFromFileName(filename); + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) { + return API.v1.failure('MIME type not allowed'); + } + } + + const soundToUpdate = await CustomSounds.findOneById>(fields._id, { + projection: { _id: 1, name: 1, extension: 1 }, + }); + if (!soundToUpdate) { + return API.v1.failure('Custom Sound not found.'); + } + + // If new file, we'll extract its extension or use getExtension() as fallback. If there's no new file, we will keep the current extension. + const nextExtension = fileBuffer + ? (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType) + : soundToUpdate.extension; + + try { + await insertOrUpdateSound({ + _id: fields._id, + name: fields.name, + extension: nextExtension, + previousName: soundToUpdate.name, + previousExtension: soundToUpdate.extension, + }); + if (fileBuffer) { + await uploadCustomSound(fileBuffer, computedMimeType, { + _id: fields._id, + name: fields.name, + previousExtension: soundToUpdate.extension, + extension: nextExtension, + }); + } + return API.v1.success({}); + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + }, + ) + .post( + 'custom-sounds.delete', + { + response: { + 200: deleteCustomSoundsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + 500: validateInternalErrorResponse, + }, + authRequired: true, + body: isCustomSoundsDeleteProps, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { _id } = this.bodyParams; + + try { + await deleteCustomSound(_id); + + return API.v1.success(); + } catch (error: unknown) { + this.logger.error({ error }); + + if (error instanceof Meteor.Error && error.error === 'Custom_Sound_Error_Invalid_Sound') { + return API.v1.failure(error.error); + } + + return API.v1.internalError(); + } + }, + ); + +export type CustomSoundEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CustomSoundEndpoints {} +} diff --git a/apps/meteor/server/api/v1/custom-user-status.ts b/apps/meteor/server/api/v1/custom-user-status.ts new file mode 100644 index 0000000..1ac5fcb --- /dev/null +++ b/apps/meteor/server/api/v1/custom-user-status.ts @@ -0,0 +1,283 @@ +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; +import { CustomUserStatus } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { deleteCustomUserStatus } from '../../meteor-methods/users/deleteCustomUserStatus'; +import { insertOrUpdateUserStatus } from '../../meteor-methods/users/insertOrUpdateUserStatus'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>; + +const CustomUserStatusListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + _id: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +const isCustomUserStatusListProps = ajvQuery.compile(CustomUserStatusListSchema); + +const customUserStatusEndpoints = API.v1.get( + 'custom-user-status.list', + { + authRequired: true, + query: isCustomUserStatusListProps, + response: { + 200: ajv.compile< + PaginatedResult<{ + statuses: ICustomUserStatus[]; + }> + >({ + type: 'object', + properties: { + statuses: { + type: 'array', + items: { + $ref: '#/components/schemas/ICustomUserStatus', + }, + }, + count: { + type: 'number', + description: 'The number of custom user statuses returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of custom user statuses that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of custom user statuses that match the query.', + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'statuses', 'count', 'offset', 'total'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + const { name, _id } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), + ...(_id ? { _id } : {}), + }; + + const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + statuses, + count: statuses.length, + offset, + total, + }); + }, +); + +const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({ + type: 'object', + properties: { + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['name'], + additionalProperties: false, +}); + +const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'custom-user-status.create', + { + authRequired: true, + body: isCustomUserStatusCreateProps, + response: { + 200: customUserStatusCreateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const userStatusData = { + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; + + await insertOrUpdateUserStatus(this.userId, userStatusData); + + const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name); + if (!customUserStatus) { + throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status'); + } + + return API.v1.success({ + customUserStatus, + }); + }, +); + +const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({ + type: 'object', + properties: { + customUserStatusId: { type: 'string', minLength: 1 }, + }, + required: ['customUserStatusId'], + additionalProperties: false, +}); + +const customUserStatusDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'custom-user-status.delete', + { + authRequired: true, + body: isCustomUserStatusDeleteProps, + response: { + 200: customUserStatusDeleteResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { customUserStatusId } = this.bodyParams; + + await deleteCustomUserStatus(this.userId, customUserStatusId); + + return API.v1.success(); + }, +); + +const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['_id', 'name'], + additionalProperties: false, +}); + +const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'custom-user-status.update', + { + authRequired: true, + body: isCustomUserStatusUpdateProps, + response: { + 200: customUserStatusUpdateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userStatusData = { + _id: this.bodyParams._id, + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; + + const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); + + // Ensure the message exists + if (!customUserStatusToUpdate) { + return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); + } + + await insertOrUpdateUserStatus(this.userId, userStatusData); + + const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id); + + if (!customUserStatus) { + throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status'); + } + + return API.v1.success({ + customUserStatus, + }); + }, +); + +export type CustomUserStatusEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CustomUserStatusEndpoints {} +} diff --git a/apps/meteor/server/api/v1/e2e.ts b/apps/meteor/server/api/v1/e2e.ts new file mode 100644 index 0000000..3266cf4 --- /dev/null +++ b/apps/meteor/server/api/v1/e2e.ts @@ -0,0 +1,533 @@ +import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + ise2eSetUserPublicAndPrivateKeysParamsPOST, +} from '@rocket.chat/rest-typings'; +import ExpiryMap from 'expiry-map'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { handleSuggestedGroupKey } from '../../lib/e2e/functions/handleSuggestedGroupKey'; +import { provideUsersSuggestedGroupKeys } from '../../lib/e2e/functions/provideUsersSuggestedGroupKeys'; +import { resetRoomKey } from '../../lib/e2e/functions/resetRoomKey'; +import { getUsersOfRoomWithoutKeyMethod } from '../../meteor-methods/platform/getUsersOfRoomWithoutKey'; +import { requestSubscriptionKeysMethod } from '../../meteor-methods/platform/requestSubscriptionKeys'; +import { setRoomKeyIDMethod } from '../../meteor-methods/platform/setRoomKeyID'; +import { setUserPublicAndPrivateKeysMethod } from '../../meteor-methods/platform/setUserPublicAndPrivateKeys'; +import { updateGroupKey } from '../../meteor-methods/platform/updateGroupKey'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +// After 10s the room lock will expire, meaning that if for some reason the process never completed +// The next reset will be available 10s after +const LockMap = new ExpiryMap(10000); + +type E2eSetRoomKeyIdProps = { + rid: string; + keyID: string; +}; + +type e2eGetUsersOfRoomWithoutKeyParamsGET = { + rid: string; +}; + +type e2eUpdateGroupKeyParamsPOST = { + uid: string; + rid: string; + key: string; +}; + +type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] }; + +type E2EProvideUsersGroupKeyProps = { + usersSuggestedGroupKeys: Record; +}; + +type E2EResetRoomKeyProps = { + rid: string; + e2eKey: string; + e2eKeyId: string; +}; + +const E2eSetRoomKeyIdSchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + keyID: { + type: 'string', + }, + }, + required: ['rid', 'keyID'], + additionalProperties: false, +}; + +const e2eGetUsersOfRoomWithoutKeyParamsGETSchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['rid'], +}; + +const e2eUpdateGroupKeyParamsPOSTSchema = { + type: 'object', + properties: { + uid: { + type: 'string', + }, + rid: { + type: 'string', + }, + key: { + type: 'string', + }, + }, + additionalProperties: false, + required: ['uid', 'rid', 'key'], +}; + +const E2EFetchUsersWaitingForGroupKeySchema = { + type: 'object', + properties: { + roomIds: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['roomIds'], + additionalProperties: false, +}; + +const E2EProvideUsersGroupKeySchema = { + type: 'object', + properties: { + usersSuggestedGroupKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + key: { type: 'string' }, + oldKeys: { + type: 'array', + items: { + type: 'object', + properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } }, + }, + }, + }, + required: ['_id', 'key'], + additionalProperties: false, + }, + }, + }, + }, + required: ['usersSuggestedGroupKeys'], + additionalProperties: false, +}; + +const E2EResetRoomKeySchema = { + type: 'object', + properties: { + rid: { + type: 'string', + }, + e2eKey: { + type: 'string', + }, + e2eKeyId: { + type: 'string', + }, + }, + required: ['rid', 'e2eKey', 'e2eKeyId'], + additionalProperties: false, +}; + +const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); + +const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile( + e2eGetUsersOfRoomWithoutKeyParamsGETSchema, +); + +const ise2eUpdateGroupKeyParamsPOST = ajv.compile(e2eUpdateGroupKeyParamsPOSTSchema); + +const isE2EFetchUsersWaitingForGroupKeyProps = ajvQuery.compile( + E2EFetchUsersWaitingForGroupKeySchema, +); + +const isE2EProvideUsersGroupKeyProps = ajv.compile(E2EProvideUsersGroupKeySchema); + +const isE2EResetRoomKeyProps = ajv.compile(E2EResetRoomKeySchema); + +const e2eEndpoints = API.v1 + .post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + const { rid, keyID } = this.bodyParams; + + await setRoomKeyIDMethod(this.userId, rid, keyID); + + return API.v1.success(); + }, + ) + .post( + 'e2e.requestSubscriptionKeys', + { + authRequired: true, + response: { + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + await requestSubscriptionKeysMethod(this.userId); + + return API.v1.success(); + }, + ) + .get( + 'e2e.fetchMyKeys', + { + authRequired: true, + query: undefined, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ public_key?: string; private_key?: string }>({ + type: 'object', + properties: { + public_key: { type: 'string' }, + private_key: { type: 'string' }, + }, + }), + }, + }, + async function action() { + const result = await Users.fetchKeysByUserId(this.userId); + + return API.v1.success(result as { public_key?: string; private_key?: string }); + }, + ) + .get( + 'e2e.getUsersOfRoomWithoutKey', + { + authRequired: true, + query: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + users: Pick[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + e2e: { + type: 'object', + properties: { + private_key: { type: 'string' }, + public_key: { type: 'string' }, + }, + }, + }, + required: ['_id'], + }, + }, + }, + required: ['users'], + }), + }, + }, + + async function action() { + const { rid } = this.queryParams; + + const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid); + + return API.v1.success(result); + }, + ) + .post( + 'e2e.rejectSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + const { rid } = this.bodyParams; + + await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey'); + + return API.v1.success(); + }, + ) + .post( + 'e2e.acceptSuggestedGroupKey', + { + authRequired: true, + body: ise2eGetUsersOfRoomWithoutKeyParamsGET, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + const { rid } = this.bodyParams; + + await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey'); + + return API.v1.success(); + }, + ) + .get( + 'e2e.fetchUsersWaitingForGroupKey', + { + authRequired: true, + query: isE2EFetchUsersWaitingForGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + usersWaitingForE2EKeys: Record; + }>({ + type: 'object', + properties: { + usersWaitingForE2EKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + public_key: { type: 'string' }, + }, + required: ['_id', 'public_key'], + }, + }, + }, + }, + required: ['usersWaitingForE2EKeys'], + }), + }, + }, + + async function action() { + if (!settings.get('E2E_Enable')) { + return API.v1.success({ usersWaitingForE2EKeys: {} }); + } + + const { roomIds = [] } = this.queryParams; + const usersWaitingForE2EKeys = (await Subscriptions.findUsersWithPublicE2EKeyByRids(roomIds, this.userId).toArray()).reduce< + Record + >((acc, { rid, users }) => ({ [rid]: users, ...acc }), {}); + + return API.v1.success({ + usersWaitingForE2EKeys, + }); + }, + ) + + .post( + 'e2e.updateGroupKey', + { + authRequired: true, + body: ise2eUpdateGroupKeyParamsPOST, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + async function action() { + const { uid, rid, key } = this.bodyParams; + + await updateGroupKey(rid, uid, key, this.userId); + + return API.v1.success(); + }, + ) + .post( + 'e2e.provideUsersSuggestedGroupKeys', + { + authRequired: true, + body: isE2EProvideUsersGroupKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + if (!settings.get('E2E_Enable')) { + return API.v1.success(); + } + + await provideUsersSuggestedGroupKeys(this.userId, this.bodyParams.usersSuggestedGroupKeys); + + return API.v1.success(); + }, + ) + // This should have permissions + .post( + 'e2e.resetRoomKey', + { + authRequired: true, + body: isE2EResetRoomKeyProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + const { rid, e2eKey, e2eKeyId } = this.bodyParams; + if (!(await hasPermissionAsync(this.user, 'toggle-room-e2e-encryption', rid))) { + return API.v1.forbidden('error-not-allowed'); + } + if (LockMap.has(rid)) { + throw new Error('error-e2e-key-reset-in-progress'); + } + + LockMap.set(rid, true); + + if (!(await canAccessRoomIdAsync(rid, this.userId))) { + throw new Error('error-not-allowed'); + } + + try { + await resetRoomKey(rid, this.userId, e2eKey, e2eKeyId); + return API.v1.success(); + } catch (e) { + console.error(e); + return API.v1.failure('error-e2e-key-reset-failed'); + } finally { + LockMap.delete(rid); + } + }, + ) + .post( + 'e2e.setUserPublicAndPrivateKeys', + { + authRequired: true, + body: ise2eSetUserPublicAndPrivateKeysParamsPOST, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { public_key, private_key, force } = this.bodyParams; + + await setUserPublicAndPrivateKeysMethod(this.userId, { + public_key, + private_key, + force, + }); + + return API.v1.success(); + }, + ); + +/** + * @openapi + * /api/v1/e2e.setUserPublicAndPrivateKeys: + * post: + * description: Sets the end-to-end encryption keys for the authenticated user + * security: + * - autenticated: {} + * requestBody: + * description: A tuple containing the public and the private keys + * content: + * application/json: + * schema: + * type: object + * properties: + * public_key: + * type: string + * private_key: + * type: string + * force: + * type: boolean + * responses: + * 200: + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ + +type E2eEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends E2eEndpoints {} +} diff --git a/apps/meteor/server/api/v1/email-inbox.ts b/apps/meteor/server/api/v1/email-inbox.ts new file mode 100644 index 0000000..2146ffe --- /dev/null +++ b/apps/meteor/server/api/v1/email-inbox.ts @@ -0,0 +1,258 @@ +import type { IEmailInbox } from '@rocket.chat/core-typings'; +import { EmailInbox, Users } from '@rocket.chat/models'; +import { + ajv, + isEmailInboxList, + isEmailInbox, + isEmailInboxSearch, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { sendTestEmailToInbox } from '../../features/EmailInbox/EmailInbox_Outgoing'; +import { API } from '../api'; +import { findEmailInboxes, insertOneEmailInbox, removeEmailInbox, updateEmailInbox } from '../lib/emailInbox'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +const paginatedEmailInboxesResponseSchema = ajv.compile<{ emailInboxes: IEmailInbox[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + emailInboxes: { type: 'array', items: { $ref: '#/components/schemas/IEmailInbox' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emailInboxes', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'email-inbox.list', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + query: isEmailInboxList, + response: { + 200: paginatedEmailInboxesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + const emailInboxes = await findEmailInboxes({ query, pagination: { offset, count, sort } }); + + return API.v1.success(emailInboxes); + }, +); + +API.v1.post( + 'email-inbox', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + body: isEmailInbox, + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } }, + required: ['success', 'error'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; + const maxRetries = + 'maxRetries' in body.imap && typeof (body.imap as { maxRetries?: number }).maxRetries === 'number' + ? (body.imap as { maxRetries: number }).maxRetries + : 5; + const emailInboxParams = { + ...body, + imap: { + ...body.imap, + maxRetries, + }, + }; + + let _id: string; + + if (!emailInboxParams?._id) { + const { insertedId } = await insertOneEmailInbox(this.userId, emailInboxParams); + + if (!insertedId) { + return API.v1.failure('Failed to create email inbox'); + } + + _id = insertedId; + } else { + const emailInbox = await updateEmailInbox({ ...emailInboxParams, _id: emailInboxParams._id }); + + if (!emailInbox?._id) { + return API.v1.failure('Failed to update email inbox'); + } + + _id = emailInbox._id; + } + + return API.v1.success({ _id }); + }, +); + +API.v1.get( + 'email-inbox/:_id', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile({ + oneOf: [ + { + allOf: [ + { $ref: '#/components/schemas/IEmailInbox' }, + { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }, + ], + }, + { type: 'null' }, + ], + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } + const emailInbox = await EmailInbox.findOneById(_id); + + if (!emailInbox) { + return API.v1.notFound(); + } + + return API.v1.success(emailInbox); + }, +); + +API.v1.delete( + 'email-inbox/:_id', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } + + const { deletedCount } = await removeEmailInbox(_id); + + if (!deletedCount) { + return API.v1.notFound(); + } + + return API.v1.success({ _id }); + }, +); + +API.v1.get( + 'email-inbox.search', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + query: isEmailInboxSearch, + response: { + 200: ajv.compile<{ emailInbox: IEmailInbox | null }>({ + type: 'object', + properties: { + emailInbox: { oneOf: [{ $ref: '#/components/schemas/IEmailInbox' }, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emailInbox', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { email } = this.queryParams; + + const emailInbox = await EmailInbox.findByEmail(email); + + return API.v1.success({ emailInbox }); + }, +); + +API.v1.post( + 'email-inbox.send-test/:_id', + { + authRequired: true, + permissionsRequired: ['manage-email-inbox'], + response: { + 200: ajv.compile<{ _id: string }>({ + type: 'object', + properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['_id', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (!_id) { + throw new Error('error-invalid-param'); + } + const emailInbox = await EmailInbox.findOneById(_id); + + if (!emailInbox) { + return API.v1.notFound(); + } + + const user = await Users.findOneById(this.userId); + if (!user) { + return API.v1.notFound(); + } + + await sendTestEmailToInbox(emailInbox, user); + + return API.v1.success({ _id }); + }, +); diff --git a/apps/meteor/server/api/v1/emoji-custom.ts b/apps/meteor/server/api/v1/emoji-custom.ts new file mode 100644 index 0000000..9ce6b4a --- /dev/null +++ b/apps/meteor/server/api/v1/emoji-custom.ts @@ -0,0 +1,318 @@ +import { Media } from '@rocket.chat/core-services'; +import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import { EmojiCustom } from '@rocket.chat/models'; +import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import type { WithId } from 'mongodb'; + +import type { EmojiData } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji'; +import { insertOrUpdateEmoji } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji'; +import { uploadEmojiCustomWithBuffer } from '../../lib/media/emoji-custom/lib/uploadEmojiCustom'; +import { deleteEmojiCustom } from '../../meteor-methods/media/deleteEmojiCustom'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { findEmojisCustom } from '../lib/emoji-custom'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; + +const emojiDeleteBodySchema = ajv.compile({ + type: 'object', + properties: { emojiId: { type: 'string', minLength: 1 } }, + required: ['emojiId'], + additionalProperties: false, +}); + +const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + emojis: { + type: 'array', + items: { $ref: '#/components/schemas/IEmojiCustom' }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const emojiCustomDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined { + if (!paramValue) { + return undefined; + } + + const date = new Date(paramValue); + if (isNaN(date.getTime())) { + throw new Meteor.Error('error-roomId-param-invalid', `The "${paramName}" query parameter must be a valid date.`); + } + + return date; +} + +const emojiCustomListResponseSchema = ajv.compile<{ + emojis: { update: IEmojiCustom[]; remove: WithId>[] }; +}>({ + type: 'object', + properties: { + emojis: { + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, + remove: { type: 'array', items: { type: 'object' } }, + }, + required: ['update', 'remove'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'success'], + additionalProperties: false, +}); + +const emojiCustomCreateEndpoints = API.v1 + .get( + 'emoji-custom.list', + { + authRequired: true, + query: isEmojiCustomList, + response: { + 200: emojiCustomListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query } = await this.parseJsonQuery(); + const { updatedSince, _updatedAt, _id } = this.queryParams; + + const updatedSinceDate = validateDateParam('updatedSince', updatedSince); + const _updatedAtDate = validateDateParam('_updatedAt', _updatedAt); + + if (updatedSinceDate) { + const [update, remove] = await Promise.all([ + EmojiCustom.find({ + ...query, + ...(_id ? { _id } : {}), + ...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}), + _updatedAt: { $gt: updatedSinceDate }, + }).toArray(), + EmojiCustom.trashFindDeletedAfter(updatedSinceDate).toArray(), + ]); + + return API.v1.success({ + emojis: { + update, + remove, + }, + }); + } + + return API.v1.success({ + emojis: { + update: await EmojiCustom.find({ + ...query, + ...(_id ? { _id } : {}), + ...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}), + }).toArray(), + remove: [], + }, + }); + }, + ) + .get( + 'emoji-custom.all', + { + authRequired: true, + response: { + 200: emojiCustomAllResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + const { name } = this.queryParams; + + return API.v1.success( + await findEmojisCustom({ + query: name + ? { + name: { + $regex: escapeRegExp(name), + $options: 'i', + }, + } + : query, + pagination: { + offset, + count, + sort, + }, + }), + ); + }, + ) + .post( + 'emoji-custom.create', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const emoji = await getUploadFormData( + { + request: this.request, + }, + { + field: 'emoji', + sizeLimit: settings.get('FileUpload_MaxFileSize'), + }, + ); + + const { fields, fileBuffer, mimetype } = emoji; + + const isUploadable = await Media.isImage(fileBuffer); + if (!isUploadable) { + throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); + } + + const [, extension] = mimetype.split('/'); + fields.extension = extension; + + const emojiData = await insertOrUpdateEmoji(this.userId, { + ...fields, + newFile: true, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + }); + + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); + + return API.v1.success(); + }, + ) + .post( + 'emoji-custom.update', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const emoji = await getUploadFormData( + { + request: this.request, + }, + { field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize'), fileOptional: true }, + ); + + const { fields, fileBuffer, mimetype } = emoji; + + if (!fields._id) { + throw new Meteor.Error('The required "_id" query param is missing.'); + } + + const emojiToUpdate = await EmojiCustom.findOneById>(fields._id, { + projection: { name: 1, extension: 1 }, + }); + if (!emojiToUpdate) { + throw new Meteor.Error('Emoji not found.'); + } + + const emojiData: EmojiData = { + previousName: emojiToUpdate.name, + previousExtension: emojiToUpdate.extension, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + _id: fields._id, + newFile: false, + }; + + const isNewFile = fileBuffer?.length && !!mimetype; + if (isNewFile) { + emojiData.newFile = isNewFile; + const isUploadable = await Media.isImage(fileBuffer); + if (!isUploadable) { + throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); + } + + const [, extension] = mimetype.split('/'); + emojiData.extension = extension; + } else { + emojiData.extension = emojiToUpdate.extension; + } + + const updatedEmojiData = await insertOrUpdateEmoji(this.userId, emojiData); + if (isNewFile) { + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, updatedEmojiData); + } + return API.v1.success(); + }, + ) + .post( + 'emoji-custom.delete', + { + authRequired: true, + body: emojiDeleteBodySchema, + response: { + 200: emojiCustomDeleteResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { emojiId } = this.bodyParams; + if (!emojiId) { + return API.v1.failure('The "emojiId" params is required!'); + } + + await deleteEmojiCustom(this.userId, emojiId); + + return API.v1.success(); + }, + ); + +type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI; + +export type EmojiCustomEndpoints = EmojiCustomCreateEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends EmojiCustomCreateEndpoints {} +} diff --git a/apps/meteor/server/api/v1/groups.ts b/apps/meteor/server/api/v1/groups.ts new file mode 100644 index 0000000..b57f41f --- /dev/null +++ b/apps/meteor/server/api/v1/groups.ts @@ -0,0 +1,1901 @@ +import { Team, isMeteorError } from '@rocket.chat/core-services'; +import { + isRoomNativeFederated, + type IIntegration, + type IMessage, + type ITeam, + type IUploadWithUser, + type IUser, + type IRoom, + type RoomType, + type UserStatus, +} from '@rocket.chat/core-typings'; +import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; +import { + isGroupsConvertToTeamProps, + isGroupsCountersProps, + isGroupsCreateProps, + isGroupsDeleteProps, + isGroupsFilesProps, + isGroupsHistoryProps, + isGroupsInfoProps, + isGroupsInviteProps, + isGroupsListProps, + isGroupsModeratorsProps, + isGroupsOnlineProps, + isGroupsRolesProps, + ajv, + ajvQuery, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { isTruthy } from '@rocket.chat/tools'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; + +import { canAccessRoomAsync, roomAccessAttributes } from '../../lib/authorization'; +import { hasAllPermissionAsync, hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { eraseRoom } from '../../lib/eraseRoom'; +import { findUsersOfRoom } from '../../lib/findUsersOfRoom'; +import { mountIntegrationQueryBasedOnPermissions } from '../../lib/integrations/lib/mountQueriesBasedOnPermission'; +import { openRoom } from '../../lib/openRoom'; +import { getUsersHiddenFrom, filterHiddenUsers, redactHiddenUsers } from '../../lib/statusVisibility/hiddenUsers'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { getChannelHistory } from '../../meteor-methods/messages/getChannelHistory'; +import { addAllUserToRoomFn } from '../../meteor-methods/rooms/addAllUserToRoom'; +import { addRoomLeader } from '../../meteor-methods/rooms/addRoomLeader'; +import { addRoomModerator } from '../../meteor-methods/rooms/addRoomModerator'; +import { addRoomOwner } from '../../meteor-methods/rooms/addRoomOwner'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; +import { executeArchiveRoom } from '../../meteor-methods/rooms/archiveRoom'; +import { createPrivateGroupMethod } from '../../meteor-methods/rooms/createPrivateGroup'; +import { executeGetRoomRoles } from '../../meteor-methods/rooms/getRoomRoles'; +import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom'; +import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom'; +import { removeRoomLeader } from '../../meteor-methods/rooms/removeRoomLeader'; +import { removeRoomModerator } from '../../meteor-methods/rooms/removeRoomModerator'; +import { removeRoomOwner } from '../../meteor-methods/rooms/removeRoomOwner'; +import { removeUserFromRoomMethod } from '../../meteor-methods/rooms/removeUserFromRoom'; +import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings'; +import { executeUnarchiveRoom } from '../../meteor-methods/rooms/unarchiveRoom'; +import { API } from '../api'; +import { addUserToFileObj } from '../lib/addUserToFileObj'; +import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUserFromParams, getUserListFromParams, getUsernameListFromParams } from '../lib/getUserFromParams'; + +async function getRoomFromParams(params: { roomId?: string } | { roomName?: string }): Promise { + if ( + (!('roomId' in params) && !('roomName' in params)) || + ('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName) + ) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const roomOptions = { + projection: { + ...roomAccessAttributes, + t: 1, + ro: 1, + name: 1, + fname: 1, + prid: 1, + archived: 1, + broadcast: 1, + }, + }; + + const room = await (() => { + if ('roomId' in params) { + return Rooms.findOneById(params.roomId || '', roomOptions); + } + if ('roomName' in params) { + return Rooms.findOneByName(params.roomName || '', roomOptions); + } + })(); + + if (room?.t !== 'p') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return room; +} + +// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +async function findPrivateGroupByIdOrName({ + params, + checkedArchived = true, + userId, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + userId: string; + checkedArchived?: boolean; +}): Promise<{ + rid: string; + open: boolean; + ro: boolean; + t: string; + name: string; + broadcast: boolean; +}> { + const room = await getRoomFromParams(params); + + const user = await Users.findOneById(userId, { projection: { username: 1, roles: 1, abacAttributes: 1 } }); + + if (!room || !user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + // discussions have their names saved on `fname` property + const roomName = room.prid ? room.fname : room.name; + + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The private group, ${roomName}, is archived`); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, userId, { projection: { open: 1 } }); + + return { + rid: room._id, + open: Boolean(sub?.open), + ro: Boolean(room.ro), + t: room.t, + name: roomName ?? '', + broadcast: Boolean(room.broadcast), + }; +} + +const groupResponseSchema = ajv.compile<{ group: IRoom }>({ + type: 'object', + properties: { + group: { $ref: '#/components/schemas/IRoom' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['group', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +// Inline body validator: a room target (roomId or roomName) plus optional user/extra fields. +const roomTargetBody = (extra: Record> = {}, required: string[] = []) => + ajv.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + ...extra, + }, + required, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, + }); + +const roomUserBody = () => roomTargetBody({ userId: { type: 'string' }, username: { type: 'string' }, user: { type: 'string' } }); + +const stringFieldResponseSchema = (field: 'description' | 'purpose' | 'topic' | 'announcement') => + ajv.compile({ + type: 'object', + properties: { + [field]: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: [field, 'success'], + additionalProperties: false, + }); + +const descriptionResponseSchema = stringFieldResponseSchema<{ description: string }>('description'); +const purposeResponseSchema = stringFieldResponseSchema<{ purpose: string }>('purpose'); +const topicResponseSchema = stringFieldResponseSchema<{ topic: string }>('topic'); +const announcementResponseSchema = stringFieldResponseSchema<{ announcement: string }>('announcement'); + +API.v1.post( + 'groups.addAll', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; activeUsersOnly?: boolean | string | number }>({ + activeUsersOnly: { type: ['boolean', 'string', 'number'] }, + }), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { activeUsersOnly, ...params } = this.bodyParams; + const findResult = await findPrivateGroupByIdOrName({ + params, + userId: this.userId, + }); + + await addAllUserToRoomFn(this.userId, findResult.rid, activeUsersOnly === true || activeUsersOnly === 'true' || activeUsersOnly === 1); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.addModerator', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await getUserFromParams(this.bodyParams); + + await addRoomModerator(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.addOwner', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await getUserFromParams(this.bodyParams); + + await addRoomOwner(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.addLeader', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + const user = await getUserFromParams(this.bodyParams); + + await addRoomLeader(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +// Archives a private group only if it wasn't +API.v1.post( + 'groups.archive', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await executeArchiveRoom(this.userId, findResult.rid); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.close', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + checkedArchived: false, + }); + + if (!findResult.open) { + return API.v1.failure(`The private group, ${findResult.name}, is already closed to the sender`); + } + + await hideRoomMethod(this.userId, findResult.rid); + + return API.v1.success(); + }, +); + +const countersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: Date | null; + msgs: number | null; + latest: Date | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: ['number', 'null'] }, + unreads: { type: ['number', 'null'] }, + unreadsFrom: { type: ['string', 'null'] }, + msgs: { type: ['number', 'null'] }, + latest: { type: ['string', 'null'] }, + userMentions: { type: ['number', 'null'] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.counters', + { + authRequired: true, + query: isGroupsCountersProps, + response: { + 200: countersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const access = await hasPermissionAsync(this.user, 'view-room-administration'); + const params = this.queryParams; + let user = this.userId; + let room; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + + if (('roomId' in params && !params.roomId) || ('roomName' in params && !params.roomName)) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + if ('roomId' in params) { + room = await Rooms.findOneById(params.roomId || ''); + } else if ('roomName' in params) { + room = await Rooms.findOneByName(params.roomName || ''); + } + + if (room?.t !== 'p') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + if (room.archived) { + throw new Meteor.Error('error-room-archived', `The private group, ${room.name}, is archived`); + } + + if (params.userId) { + if (!access) { + return API.v1.forbidden(); + } + user = params.userId; + } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user); + const lm = room.lm ? room.lm : room._updatedAt; + + if (subscription?.open) { + unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls || subscription.ts, lm); + unreadsFrom = subscription.ls || subscription.ts; + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInNonDMRoom(room._id); + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }, +); + +// Create Private Group +API.v1.post( + 'groups.create', + { + authRequired: true, + body: isGroupsCreateProps, + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.name) { + return API.v1.failure('Body param "name" is required'); + } + + const readOnly = typeof this.bodyParams.readOnly !== 'undefined' ? this.bodyParams.readOnly : false; + + try { + const result = await createPrivateGroupMethod( + this.user, + this.bodyParams.name, + this.bodyParams.members ? this.bodyParams.members : [], + readOnly, + this.bodyParams.customFields, + this.bodyParams.extraData, + this.bodyParams.excludeSelf ?? false, + ); + + const room = await Rooms.findOneById(result.rid, { projection: API.v1.defaultFieldsToExclude }); + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + } catch (error: unknown) { + if (isMeteorError(error) && error.reason === 'error-not-allowed') { + return API.v1.forbidden(); + } + throw error; + } + }, +); + +API.v1.post( + 'groups.delete', + { + authRequired: true, + body: isGroupsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + checkedArchived: false, + }); + + await eraseRoom(findResult.rid, this.user); + + return API.v1.success(); + }, +); + +// Uploads accept a client-supplied `fields` projection and store `content: null` for non-e2ee files, +// so items are partial and cannot be validated against $ref IUploadWithUser (required, non-null shape). +// Validate the array container strictly; leave item props open (only _id is guaranteed). +const groupsFilesResponseSchema = ajv.compile<{ files: IUploadWithUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' } }, + required: ['_id'], + additionalProperties: true, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.files', + { + authRequired: true, + query: isGroupsFilesProps, + response: { + 200: groupsFilesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { typeGroup, name, roomId, roomName, onlyConfirmed } = this.queryParams; + + const findResult = await findPrivateGroupByIdOrName({ + params: roomId ? { roomId } : { roomName }, + userId: this.userId, + checkedArchived: false, + }); + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const filter = { + ...query, + rid: findResult.rid, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = await Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }, +); + +// Loose query validator (mirrors channels.getIntegrations): coerces query scalars and accepts pagination/parseJsonQuery params. +const groupsGetIntegrationsQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + includeAllPrivateGroups?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + includeAllPrivateGroups: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const groupsGetIntegrationsResponseSchema = ajv.compile<{ + integrations: IIntegration[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + integrations: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.getIntegrations', + { + authRequired: true, + query: groupsGetIntegrationsQuery, + permissionsRequired: { + GET: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + }, + response: { + 200: groupsGetIntegrationsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + checkedArchived: false, + }); + + let includeAllPrivateGroups = true; + if (this.queryParams.includeAllPrivateGroups) { + includeAllPrivateGroups = this.queryParams.includeAllPrivateGroups === 'true'; + } + + const channelsToSearch = [`#${findResult.name}`]; + if (includeAllPrivateGroups) { + channelsToSearch.push('all_private_groups'); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + + // query and channel filters cannot override the permission scope (mountIntegrationQueryBasedOnPermissions). + const ourQuery = Object.assign( + {}, + query, + { channel: { $in: channelsToSearch } }, + await mountIntegrationQueryBasedOnPermissions(this.userId), + ) as Filter; + const { cursor, totalCount } = await Integrations.findPaginated(ourQuery, { + sort: sort || { _createdAt: 1 }, + skip: offset, + limit: count, + projection, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + count: integrations.length, + offset, + total, + }); + }, +); + +const groupsHistoryResponseSchema = ajv.compile<{ messages: IMessage[]; firstUnread?: IMessage; unreadNotLoaded?: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + firstUnread: { $ref: '#/components/schemas/IMessage' }, + unreadNotLoaded: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.history', + { + authRequired: true, + query: isGroupsHistoryProps, + response: { + 200: groupsHistoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + checkedArchived: false, + }); + + let latestDate = new Date(); + if (this.queryParams.latest) { + latestDate = new Date(this.queryParams.latest); + } + + let oldestDate = undefined; + if (this.queryParams.oldest) { + oldestDate = new Date(this.queryParams.oldest); + } + + const inclusive = this.queryParams.inclusive === 'true'; + + let count = 20; + if (this.queryParams.count) { + count = parseInt(String(this.queryParams.count)); + } + + let offset = 0; + if (this.queryParams.offset) { + offset = parseInt(String(this.queryParams.offset)); + } + + const unreads = this.queryParams.unreads === 'true'; + + const showThreadMessages = this.queryParams.showThreadMessages !== 'false'; + + const result = await getChannelHistory({ + rid: findResult.rid, + fromUserId: this.userId, + latest: latestDate, + oldest: oldestDate, + inclusive, + offset, + count, + unreads, + showThreadMessages, + }); + + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return API.v1.forbidden(); + } + + return API.v1.success(result); + }, +); + +API.v1.get( + 'groups.info', + { + authRequired: true, + query: isGroupsInfoProps, + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + checkedArchived: false, + }); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.invite', + { + authRequired: true, + body: isGroupsInviteProps, + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const roomId = 'roomId' in this.bodyParams ? this.bodyParams.roomId : ''; + const roomName = 'roomName' in this.bodyParams ? this.bodyParams.roomName : ''; + const idOrName = roomId || roomName; + + if (!idOrName?.trim()) { + throw new Meteor.Error('error-room-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const groupRoom = await Rooms.findOneByIdOrName(idOrName); + const { _id: rid, t: type } = groupRoom || {}; + + if (!rid || type !== 'p') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + // Federated rooms invite by raw username: the federated user record is created + // lazily inside addUsersToRoomMethod, so we must not require it to exist locally yet. + if (groupRoom && isRoomNativeFederated(groupRoom)) { + const usernames = await getUsernameListFromParams(this.bodyParams); + + await addUsersToRoomMethod(this.userId, { rid, users: usernames }, this.user); + } else { + const users = await getUserListFromParams(this.bodyParams); + + if (!users.length) { + throw new Meteor.Error('error-empty-invite-list', 'Cannot invite if no valid users are provided'); + } + + await addUsersToRoomMethod(this.userId, { rid, users: users.map((u) => u.username).filter(isTruthy) }, this.user); + } + + const room = await Rooms.findOneById(rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.kick', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await getRoomFromParams(this.bodyParams); + + const user = await getUserFromParams(this.bodyParams); + if (!user?.username) { + return API.v1.failure('Invalid user'); + } + + await removeUserFromRoomMethod(this.userId, { rid: room._id, username: user.username }); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.leave', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await Users.findOneById(this.userId); + if (!user) { + return API.v1.failure('Invalid user'); + } + await leaveRoomMethod(user, findResult.rid); + + return API.v1.success(); + }, +); + +// List Private Groups a user has access to +const groupsListResponseSchema = ajv.compile<{ groups: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + groups: { type: 'array', items: { $ref: '#/components/schemas/IRoom' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['groups', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.list', + { + authRequired: true, + query: isGroupsListProps, + response: { + 200: groupsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields } = await this.parseJsonQuery(); + + const subs = await Subscriptions.findByUserIdAndTypes(this.userId, ['p'], { projection: { rid: 1 } }).toArray(); + const rids = subs.map(({ rid }) => rid).filter(Boolean); + + if (rids.length === 0) { + return API.v1.success({ + groups: [], + offset, + count: 0, + total: 0, + }); + } + + const { cursor, totalCount } = await Rooms.findPaginatedByTypeAndIds('p', rids, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [groups, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + groups: await Promise.all(groups.map((room) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: groups.length, + total, + }); + }, +); + +API.v1.get( + 'groups.listAll', + { + authRequired: true, + query: isGroupsListProps, + permissionsRequired: ['view-room-administration'], + response: { + 200: groupsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { t: 'p' as RoomType }); + + const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + groups: await Promise.all(rooms.map((room) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: rooms.length, + total, + }); + }, +); + +// Loose query validator (mirrors channels.members): coerces single status value to array and accepts filter/sort/pagination. +const groupsMembersQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + filter?: string; + status?: string[]; + offset?: number; + count?: number; + sort?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + filter: { type: 'string' }, + status: { type: 'array', items: { type: 'string' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + }, + anyOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +// findUsersOfRoom returns a fixed projection (not a full IUser), so validate against the projected +// shape rather than $ref IUser (which would require createdAt/roles/type/active and reject real data). +const groupsMembersResponseSchema = ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + nickname: { type: 'string' }, + status: { type: 'string' }, + avatarETag: { type: 'string' }, + federated: { type: 'boolean' }, + _updatedAt: { type: 'string' }, + }, + required: ['_id'], + additionalProperties: true, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.members', + { + authRequired: true, + query: groupsMembersQuery, + response: { + 200: groupsMembersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + }); + + if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult.rid))) { + return API.v1.forbidden(); + } + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + check( + this.queryParams, + Match.ObjectIncluding({ + status: Match.Maybe([String]), + filter: Match.Maybe(String), + }), + ); + + const { status, filter } = this.queryParams; + + const hidden = await getUsersHiddenFrom(this.userId); + + const { cursor, totalCount } = await findUsersOfRoom({ + rid: findResult.rid, + ...(status && { status: status as UserStatus[] }), + hidden, + skip, + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + members: redactHiddenUsers(members, hidden), + count: members.length, + offset: skip, + total, + }); + }, +); + +// Flat query shape so roomId/roomName both destructure and pinned stays a string; oneOf rejects passing both room params (400). +const groupsMessagesQuery = ajvQuery.compile<{ + roomId?: string; + roomName?: string; + mentionIds?: string; + starredIds?: string; + pinned?: string; + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + roomName: { type: 'string' }, + mentionIds: { type: 'string' }, + starredIds: { type: 'string' }, + pinned: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + query: { type: 'string' }, + fields: { type: 'string' }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}); + +const groupsMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.messages', + { + authRequired: true, + query: groupsMessagesQuery, + response: { + 200: groupsMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, roomName, mentionIds, starredIds, pinned } = this.queryParams; + + const findResult = await findPrivateGroupByIdOrName({ + params: { + ...(roomId && { roomId }), + ...(roomName && { roomName }), + }, + userId: this.userId, + }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + ...query, + rid: findResult.rid, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(pinned?.toLowerCase() === 'true' ? { pinned: true } : {}), + _hidden: { $ne: true }, + }; + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }, +); + +const groupsOnlineResponseSchema = ajv.compile<{ online: Pick[] }>({ + type: 'object', + properties: { + online: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['online', 'success'], + additionalProperties: false, +}); + +// TODO: CACHE: same as channels.online +API.v1.get( + 'groups.online', + { + authRequired: true, + query: isGroupsOnlineProps, + response: { + 200: groupsOnlineResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; + + if ((!query || Object.keys(query).length === 0) && !_id) { + return API.v1.failure('Invalid query'); + } + + const filter = { + ...query, + ...(_id ? { _id } : {}), + t: 'p', + }; + + const room = await Rooms.findOne(filter as Record); + if (!room) { + return API.v1.failure('Group does not exists'); + } + + if (!(await canAccessRoomAsync(room, this.user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + const hidden = await getUsersHiddenFrom(this.userId); + + const online: Pick[] = filterHiddenUsers( + await Users.findUsersNotOffline({ + projection: { + username: 1, + }, + }).toArray(), + hidden, + ); + + const onlineInRoom = await Promise.all( + online.map(async (user) => { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { + projection: { _id: 1, username: 1 }, + }); + if (subscription) { + return { + _id: user._id, + username: user.username, + }; + } + }), + ); + + return API.v1.success({ + online: onlineInRoom.filter(Boolean) as IUser[], + }); + }, +); + +API.v1.post( + 'groups.open', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + checkedArchived: false, + }); + + if (findResult.open) { + return API.v1.failure(`The private group, ${findResult.name}, is already open for the sender`); + } + + await openRoom(this.userId, findResult.rid); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.removeModerator', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomModerator(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.removeOwner', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomOwner(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.removeLeader', + { + authRequired: true, + body: roomUserBody<{ roomId?: string; roomName?: string; userId?: string; username?: string; user?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + const user = await getUserFromParams(this.bodyParams); + + await removeRoomLeader(this.userId, findResult.rid, user._id); + + return API.v1.success(); + }, +); + +API.v1.post( + 'groups.rename', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; name: string }>({ name: { type: 'string' } }, ['name']), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.name?.trim()) { + return API.v1.failure('The bodyParam "name" is required'); + } + + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomName', this.bodyParams.name); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.setCustomFields', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; customFields: Record }>( + { customFields: { type: 'object' } }, + ['customFields'], + ), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomCustomFields', this.bodyParams.customFields); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.setDescription', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; description: string }>({ description: { type: 'string' } }, ['description']), + response: { + 200: descriptionResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomDescription', this.bodyParams.description || ''); + + return API.v1.success({ + description: this.bodyParams.description || '', + }); + }, +); + +API.v1.post( + 'groups.setPurpose', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; purpose: string }>({ purpose: { type: 'string' } }, ['purpose']), + response: { + 200: purposeResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomDescription', this.bodyParams.purpose || ''); + + return API.v1.success({ + purpose: this.bodyParams.purpose || '', + }); + }, +); + +API.v1.post( + 'groups.setReadOnly', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; readOnly: boolean }>({ readOnly: { type: 'boolean' } }, ['readOnly']), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + if (findResult.ro === this.bodyParams.readOnly) { + return API.v1.failure('The private group read only setting is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult.rid, 'readOnly', this.bodyParams.readOnly); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.setTopic', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; topic: string }>({ topic: { type: 'string' } }, ['topic']), + response: { + 200: topicResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomTopic', this.bodyParams.topic || ''); + + return API.v1.success({ + topic: this.bodyParams.topic || '', + }); + }, +); + +API.v1.post( + 'groups.setType', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; type: string }>({ type: { type: 'string' } }, ['type']), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.bodyParams.type?.trim()) { + return API.v1.failure('The bodyParam "type" is required'); + } + + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + if (findResult.t === this.bodyParams.type) { + return API.v1.failure('The private group type is the same as what it would be changed to.'); + } + + await saveRoomSettings(this.userId, findResult.rid, 'roomType', this.bodyParams.type as RoomType); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +API.v1.post( + 'groups.setAnnouncement', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; announcement: string }>({ announcement: { type: 'string' } }, [ + 'announcement', + ]), + response: { + 200: announcementResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'roomAnnouncement', this.bodyParams.announcement || ''); + + return API.v1.success({ + announcement: this.bodyParams.announcement || '', + }); + }, +); + +API.v1.post( + 'groups.unarchive', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string }>(), + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + checkedArchived: false, + }); + + await executeUnarchiveRoom(this.userId, findResult.rid); + + return API.v1.success(); + }, +); + +const groupsRolesResponseSchema = ajv.compile<{ + roles: { rid: string; u: { _id: string; username: string; name?: string }; roles: string[] }[]; +}>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id', 'username'], + additionalProperties: false, + }, + roles: { type: 'array', items: { type: 'string' } }, + }, + required: ['_id', 'rid', 'u', 'roles'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.roles', + { + authRequired: true, + query: isGroupsRolesProps, + response: { + 200: groupsRolesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + }); + + const roles = await executeGetRoomRoles(findResult.rid, this.user); + + return API.v1.success({ + roles, + }); + }, +); + +const groupsModeratorsResponseSchema = ajv.compile<{ moderators: { _id: string; username?: string; name?: string }[] }>({ + type: 'object', + properties: { + moderators: { + type: 'array', + items: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' }, name: { type: 'string' } }, + required: ['_id'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['moderators', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'groups.moderators', + { + authRequired: true, + query: isGroupsModeratorsProps, + response: { + 200: groupsModeratorsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + }); + + const moderators = await Subscriptions.findByRoomIdAndRoles(findResult.rid, ['moderator'], { + projection: { u: 1, _id: 0 }, + }) + .map((sub) => sub.u) + .toArray(); + + return API.v1.success({ + moderators, + }); + }, +); + +API.v1.post( + 'groups.setEncrypted', + { + authRequired: true, + body: roomTargetBody<{ roomId?: string; roomName?: string; encrypted: boolean }>({ encrypted: { type: 'boolean' } }, ['encrypted']), + response: { + 200: groupResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { encrypted, ...params } = this.bodyParams; + + const findResult = await findPrivateGroupByIdOrName({ + params, + userId: this.userId, + }); + + await saveRoomSettings(this.userId, findResult.rid, 'encrypted', encrypted); + + const room = await Rooms.findOneById(findResult.rid, { projection: API.v1.defaultFieldsToExclude }); + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + }, +); + +const groupsConvertToTeamResponseSchema = ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { $ref: '#/components/schemas/ITeam' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'groups.convertToTeam', + { + authRequired: true, + body: isGroupsConvertToTeamProps, + response: { + 200: groupsConvertToTeamResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (('roomId' in this.bodyParams && !this.bodyParams.roomId) || ('roomName' in this.bodyParams && !this.bodyParams.roomName)) { + return API.v1.failure('The parameter "roomId" or "roomName" is required'); + } + + const room = await findPrivateGroupByIdOrName({ + params: this.bodyParams, + userId: this.userId, + }); + + if (!room) { + return API.v1.failure('Private group not found'); + } + + if (!(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room.rid))) { + return API.v1.forbidden(); + } + + const subscriptions = await Subscriptions.findByRoomId(room.rid, { + projection: { 'u._id': 1 }, + }).toArray(); + + const members = subscriptions.map((s) => s.u?._id); + + const teamData = { + team: { + name: room.name, + type: 1, + }, + members, + room: { + name: room.name, + id: room.rid, + }, + }; + + const team = await Team.create(this.userId, teamData); + + return API.v1.success({ team }); + }, +); diff --git a/apps/meteor/server/api/v1/im.ts b/apps/meteor/server/api/v1/im.ts new file mode 100644 index 0000000..de1a8d5 --- /dev/null +++ b/apps/meteor/server/api/v1/im.ts @@ -0,0 +1,1040 @@ +/** + * Docs: https://github.com/RocketChat/developer-docs/blob/master/reference/api/rest-api/endpoints/team-collaboration-endpoints/im-endpoints + */ +import type { IMessage, IRoom, ISubscription, IUser, UserStatus } from '@rocket.chat/core-typings'; +import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, + isDmBlockUserProps, + isDmFileProps, + isDmLeaveProps, + isDmMemberProps, + isDmMessagesProps, + isDmCreateProps, + isDmHistoryProps, +} from '@rocket.chat/rest-typings'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions } from 'mongodb'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { eraseRoom } from '../../lib/eraseRoom'; +import { openRoom } from '../../lib/openRoom'; +import { getRoomByNameOrIdWithOptionToJoin } from '../../lib/rooms/getRoomByNameOrIdWithOptionToJoin'; +import { effectiveStatusFilter } from '../../lib/statusVisibility/effectiveStatus'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { redactStatus } from '../../lib/statusVisibility/redactStatus'; +import { blockUserMethod } from '../../lib/users/blockUser'; +import { unblockUserMethod } from '../../lib/users/unblockUser'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage'; +import { getChannelHistory } from '../../meteor-methods/messages/getChannelHistory'; +import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom'; +import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom'; +import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import type { TypedAction } from '../definition'; +import { addUserToFileObj } from '../lib/addUserToFileObj'; +import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +const findDirectMessageRoom = async ( + keys: { roomId?: string; username?: string }, + uid: string, +): Promise<{ room: IRoom; subscription: ISubscription | null }> => { + const nameOrId = 'roomId' in keys ? keys.roomId : keys.username; + if (typeof nameOrId !== 'string') { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" or "username" is required'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'findDirectMessageRoom', + }); + } + + const room = await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId, + type: 'd', + }); + + if (!room || room?.t !== 'd') { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" param provided does not match any direct message'); + } + + const subscription = await Subscriptions.findOne({ 'rid': room._id, 'u._id': uid }); + + return { + room, + subscription, + }; +}; + +type DmDeleteProps = + | { + roomId: string; + } + | { + username: string; + }; + +type DmCloseProps = { + roomId: string; +}; + +const isDmDeleteProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}); + +const dmDeleteEndpointsProps = { + authRequired: true, + body: isDmDeleteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const DmClosePropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +const isDmCloseProps = ajv.compile(DmClosePropsSchema); + +const dmCloseEndpointsProps = { + authRequired: true, + body: isDmCloseProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +}; + +const dmDeleteAction = (_path: Path): TypedAction => + async function action() { + const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); + + const canAccess = + (await canAccessRoomIdAsync(room._id, this.userId)) || (await hasPermissionAsync(this.user, 'view-room-administration')); + + if (!canAccess) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + await eraseRoom(room._id, this.user); + + return API.v1.success(); + }; + +const dmCloseAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'dm.close', + }); + } + let subscription; + + const roomExists = !!(await Rooms.findOneById(roomId)); + if (!roomExists) { + // even if the room doesn't exist, we should allow the user to close the subscription anyways + subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + } else { + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); + + subscription = subs; + } + + if (!subscription) { + return API.v1.failure(`The user is not subscribed to the room`); + } + + if (!subscription.open) { + return API.v1.failure(`The direct message room, is already closed to the sender`); + } + + await hideRoomMethod(this.userId, roomId); + + return API.v1.success(); + }; + +const isDmOpenProps = ajv.compile<{ roomId: string }>({ + type: 'object', + properties: { roomId: { type: 'string' } }, + required: ['roomId'], + additionalProperties: false, +}); + +const dmOpenEndpointsProps = { + authRequired: true, + body: isDmOpenProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const dmOpenAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); + + if (!subscription?.open) { + await openRoom(this.userId, room._id); + } + + return API.v1.success(); + }; + +const isDmSetTopicProps = ajv.compile<{ roomId: string; topic?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + topic: { type: 'string', nullable: true }, + }, + required: ['roomId'], + additionalProperties: false, +}); + +const dmSetTopicResponseSchema = ajv.compile<{ topic?: string }>({ + type: 'object', + properties: { + topic: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const dmSetTopicEndpointsProps = { + authRequired: true, + body: isDmSetTopicProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmSetTopicResponseSchema, + }, +}; + +const dmSetTopicAction = (_path: Path): TypedAction => + async function action() { + const { roomId, topic } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + // saveRoomTopic treats undefined and '' identically + await saveRoomSettings(this.userId, room._id, 'roomTopic', topic ?? ''); + + return API.v1.success({ + topic, + }); + }; + +type DmCountersProps = { + roomId: string; + userId?: string; +}; + +const isDmCountersProps = ajvQuery.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + userId: { type: 'string', nullable: true }, + }, + required: ['roomId'], + additionalProperties: false, +}); + +const dmCountersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: string | null; + msgs: number | null; + latest: string | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: 'number', nullable: true }, + unreads: { type: 'number', nullable: true }, + unreadsFrom: { type: 'string', nullable: true }, + msgs: { type: 'number', nullable: true }, + latest: { type: 'string', nullable: true }, + userMentions: { type: 'number', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); + +const dmCountersEndpointsProps = { + authRequired: true, + query: isDmCountersProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmCountersResponseSchema, + }, +}; + +const dmCountersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const access = await hasPermissionAsync(this.userId, 'view-room-administration'); + const { roomId, userId: ruserId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + let lm = null; + + if (ruserId) { + if (!access) { + return API.v1.forbidden(); + } + user = ruserId; + } + const canAccess = await canAccessRoomIdAsync(roomId, user); + + if (!canAccess) { + return API.v1.forbidden(); + } + + const { room, subscription } = await findDirectMessageRoom({ roomId }, user); + + lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); + + if (subscription) { + unreads = subscription.unread ?? null; + if (subscription.ls && room.msgs) { + unreadsFrom = new Date(subscription.ls).toISOString(); + } + userMentions = subscription.userMentions; + joined = true; + } + + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInDMRoom(room._id); + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }; + +const dmFilesResponseSchema = ajv.compile<{ files: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with addUserToFileObj transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const dmFilesEndpointsProps = { + authRequired: true, + query: isDmFileProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmFilesResponseSchema, + }, +}; + +const dmFilesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams; + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId); + + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const filter = { + ...query, + rid: room._id, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }; + +const dmMembersResponseSchema = ajv.compile<{ members: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser + subscription info + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const dmMembersEndpointsProps = { + authRequired: true, + query: isDmMemberProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMembersResponseSchema, + }, +}; + +const dmMembersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { room } = await findDirectMessageRoom(this.queryParams, this.userId); + + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + check( + this.queryParams, + Match.ObjectIncluding({ + status: Match.Maybe([String]), + filter: Match.Maybe(String), + }), + ); + const { status, filter } = this.queryParams; + + const hidden = await getUsersHiddenFrom(this.userId); + const extraQuery: Record = { + _id: { $in: room.uids }, + ...(status && effectiveStatusFilter(status as UserStatus[], hidden)), + }; + + const options: FindOptions = { + projection: { + _id: 1, + username: 1, + name: 1, + status: 1, + statusText: 1, + utcOffset: 1, + federated: 1, + freeSwitchExtension: 1, + }, + skip: offset, + limit: count, + sort: { + _updatedAt: -1, + username: sort?.username ? sort.username : 1, + }, + }; + + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter ?? '', [], options, searchFields, [extraQuery]); + + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + // find subscriptions of those users + const subs = await Subscriptions.findByRoomIdAndUserIds( + room._id, + members.map((member) => member._id), + { projection: { u: 1, status: 1, ts: 1, roles: 1 } }, + ).toArray(); + + const subsByUserId = new Map(subs.map((sub) => [sub.u._id, sub])); + + const membersWithSubscriptionInfo = members.map((member) => { + const sub = subsByUserId.get(member._id); + + const { u: _u, ...subscription } = sub || {}; + + return { + ...(hidden?.has(member._id) ? redactStatus(member) : member), + subscription, + }; + }); + + return API.v1.success({ + members: membersWithSubscriptionInfo, + count: members.length, + offset, + total, + }); + }; + +const dmMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const dmMessagesEndpointsProps = { + authRequired: true, + query: isDmMessagesProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMessagesResponseSchema, + }, +}; + +const dmMessagesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams as { + roomId?: string; + username?: string; + mentionIds?: string; + starredIds?: string; + pinned?: string; + }; + + const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); + + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + rid: room._id, + ...query, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(pinned?.toLowerCase() === 'true' && { pinned: true }), + _hidden: { $ne: true }, + }; + const sortObj = sort || { ts: -1 }; + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sortObj, + skip: offset, + limit: count, + ...(fields && { projection: fields }), + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }; + +const dmHistoryResponseSchema = ajv.compile>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: true, +}); + +const dmHistoryEndpointsProps = { + authRequired: true, + query: isDmHistoryProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmHistoryResponseSchema, + }, +}; + +const dmHistoryAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams); + const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const objectParams = { + rid: room._id, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }; + + const result = await getChannelHistory(objectParams); + + if (!result) { + return API.v1.forbidden(); + } + + return API.v1.success(result as Record); + }; + +const dmCreateResponseSchema = ajv.compile<{ room: IRoom & { rid: string } }>({ + type: 'object', + properties: { + room: { type: 'object' }, // relaxed: IRoom shape varies, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const paginatedMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); + +const paginatedImsResponseSchema = ajv.compile<{ ims: IRoom[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + ims: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with lastMessage compose + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['ims', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); + +const dmMessagesOthersEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; + +const dmMessagesOthersAction = (_name: Path): TypedAction => + async function action() { + if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/im.messages.others', + }); + } + + const { roomId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); + } + + const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } }); + if (!room || room?.t !== 'd') { + throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { rid: room._id }); + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); + + if (!msgs) { + throw new Meteor.Error('error-no-messages', 'No messages found'); + } + + return API.v1.success({ + messages: await normalizeMessagesForUser(msgs, this.userId), + offset, + count: msgs.length, + total, + }); + }; + +const dmListEndpointsProps = { + authRequired: true as const, + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, +}; + +const dmListAction = (_name: Path): TypedAction => + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort = { name: 1 }, fields } = await this.parseJsonQuery(); + + // TODO: CACHE: Add Breaking notice since we removed the query param + + const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) + .map((item) => item.rid) + .toArray(); + + const { cursor, totalCount } = Rooms.findPaginated( + { t: 'd', _id: { $in: subscriptions } }, + { + sort, + skip: offset, + limit: count, + projection: fields, + }, + ); + + const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: ims.length, + total, + }); + }; + +const dmListEveryoneEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; + +const dmListEveryoneAction = (_name: Path): TypedAction => + async function action() { + const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const { cursor, totalCount } = Rooms.findPaginated( + { ...query, t: 'd' }, + { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }, + ); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: rooms.length, + total, + }); + }; + +const dmCreateEndpointsProps = { + authRequired: true, + body: isDmCreateProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: dmCreateResponseSchema, + }, +} as const; + +const dmCreateAction = (_path: Path): TypedAction => + async function action() { + const users = + 'username' in this.bodyParams + ? [this.bodyParams.username] + : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); + + const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); + + return API.v1.success({ + room: { ...room, _id: room.rid }, + }); + }; + +const dmLeaveEndpointsProps = { + authRequired: true, + body: isDmLeaveProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const dmLeaveAction = (_path: Path): TypedAction => + async function action() { + const { room } = await findDirectMessageRoom( + 'roomId' in this.bodyParams ? { roomId: this.bodyParams.roomId } : { username: this.bodyParams.roomName }, + this.userId, + ); + + await leaveRoomMethod(this.user, room._id); + + return API.v1.success(); + }; + +const dmBlockUserEndpointsProps = { + authRequired: true, + body: isDmBlockUserProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const dmBlockUserAction = (_path: Path): TypedAction => + async function action() { + const { roomId, block } = this.bodyParams; + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const blocked = room.uids?.find((uid) => uid !== this.userId); + if (!blocked) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'im.blockUser' }); + } + + if (block) { + await blockUserMethod(this.userId, { rid: room._id, blocked }); + } else { + await unblockUserMethod(this.userId, { rid: room._id, blocked }); + } + + return API.v1.success(); + }; + +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) + .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close')) + .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close')) + .post('dm.create', dmCreateEndpointsProps, dmCreateAction('dm.create')) + .post('im.create', dmCreateEndpointsProps, dmCreateAction('im.create')) + .post('dm.open', dmOpenEndpointsProps, dmOpenAction('dm.open')) + .post('im.open', dmOpenEndpointsProps, dmOpenAction('im.open')) + .post('dm.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('dm.setTopic')) + .post('im.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('im.setTopic')) + .get('dm.counters', dmCountersEndpointsProps, dmCountersAction('dm.counters')) + .get('im.counters', dmCountersEndpointsProps, dmCountersAction('im.counters')) + .get('dm.files', dmFilesEndpointsProps, dmFilesAction('dm.files')) + .get('im.files', dmFilesEndpointsProps, dmFilesAction('im.files')) + .get('dm.members', dmMembersEndpointsProps, dmMembersAction('dm.members')) + .get('im.members', dmMembersEndpointsProps, dmMembersAction('im.members')) + .get('dm.messages', dmMessagesEndpointsProps, dmMessagesAction('dm.messages')) + .get('im.messages', dmMessagesEndpointsProps, dmMessagesAction('im.messages')) + .get('dm.history', dmHistoryEndpointsProps, dmHistoryAction('dm.history')) + .get('im.history', dmHistoryEndpointsProps, dmHistoryAction('im.history')) + .get('dm.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('dm.messages.others')) + .get('im.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('im.messages.others')) + .get('dm.list', dmListEndpointsProps, dmListAction('dm.list')) + .get('im.list', dmListEndpointsProps, dmListAction('im.list')) + .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone')) + .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')) + .post('im.leave', dmLeaveEndpointsProps, dmLeaveAction('im.leave')) + .post('dm.leave', dmLeaveEndpointsProps, dmLeaveAction('dm.leave')) + .post('im.blockUser', dmBlockUserEndpointsProps, dmBlockUserAction('im.blockUser')) + .post('dm.blockUser', dmBlockUserEndpointsProps, dmBlockUserAction('dm.blockUser')); + +export type DmEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends DmEndpoints {} +} diff --git a/apps/meteor/server/api/v1/import.ts b/apps/meteor/server/api/v1/import.ts new file mode 100644 index 0000000..6cb2b4d --- /dev/null +++ b/apps/meteor/server/api/v1/import.ts @@ -0,0 +1,393 @@ +import { Import } from '@rocket.chat/core-services'; +import type { IImport } from '@rocket.chat/core-typings'; +import { Imports } from '@rocket.chat/models'; +import { + ajv, + isUploadImportFileParamsPOST, + isDownloadPublicImportFileParamsPOST, + isStartImportParamsPOST, + isGetImportFileDataParamsGET, + isGetImportProgressParamsGET, + isGetLatestImportOperationsParamsGET, + isDownloadPendingFilesParamsPOST, + isDownloadPendingAvatarsParamsPOST, + isGetCurrentImportOperationParamsGET, + isImportersListParamsGET, + isImportAddUsersParamsPOST, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { Importers } from '../../lib/import'; +import { PendingAvatarImporter } from '../../lib/import/pending-avatars/PendingAvatarImporter'; +import { PendingFileImporter } from '../../lib/import/pending-files/PendingFileImporter'; +import { executeDownloadPublicImportFile } from '../../meteor-methods/import/downloadPublicImportFile'; +import { executeGetImportFileData } from '../../meteor-methods/import/getImportFileData'; +import { executeGetImportProgress } from '../../meteor-methods/import/getImportProgress'; +import { executeGetLatestImportOperations } from '../../meteor-methods/import/getLatestImportOperations'; +import { executeStartImport } from '../../meteor-methods/import/startImport'; +import { executeUploadImportFile } from '../../meteor-methods/import/uploadImportFile'; +import { API } from '../api'; + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const uploadImportFileResponseSchema = ajv.compile({ + type: 'object', + additionalProperties: true, +}); + +const countResponseSchema = ajv.compile<{ count: number }>({ + type: 'object', + properties: { + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['count', 'success'], + additionalProperties: false, +}); + +const operationResponseSchema = ajv.compile<{ operation: IImport | undefined }>({ + type: 'object', + properties: { + operation: { $ref: '#/components/schemas/IImport' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const importersListResponseSchema = ajv.compile>({ + type: 'array', + items: { type: 'object', properties: { key: { type: 'string' }, name: { type: 'string' } } }, +}); + +API.v1.post( + 'uploadImportFile', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isUploadImportFileParamsPOST, + response: { + 200: uploadImportFileResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { binaryContent, contentType, fileName, importerKey } = this.bodyParams; + + await executeUploadImportFile(this.userId, binaryContent, contentType, fileName, importerKey); + + return API.v1.success(); + }, +); + +API.v1.post( + 'downloadPublicImportFile', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isDownloadPublicImportFileParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { fileUrl, importerKey } = this.bodyParams; + await executeDownloadPublicImportFile(this.userId, fileUrl, importerKey); + + return API.v1.success(); + }, +); + +API.v1.post( + 'startImport', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isStartImportParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { input } = this.bodyParams; + + await executeStartImport({ input }, this.userId); + + return API.v1.success(); + }, +); + +API.v1.get( + 'getImportFileData', + { + authRequired: true, + permissionsRequired: ['run-import'], + query: isGetImportFileDataParamsGET, + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const result = await executeGetImportFileData(); + + return API.v1.success(typeof result === 'object' ? result : {}); + }, +); + +API.v1.get( + 'getImportProgress', + { + authRequired: true, + permissionsRequired: ['run-import'], + query: isGetImportProgressParamsGET, + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const result = await executeGetImportProgress(); + + return API.v1.success(typeof result === 'object' ? result : {}); + }, +); + +API.v1.get( + 'getLatestImportOperations', + { + authRequired: true, + permissionsRequired: ['view-import-operations'], + query: isGetLatestImportOperationsParamsGET, + response: { + 200: ajv.compile>({ + type: 'array', + items: { + type: 'object', + }, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const operations = await executeGetLatestImportOperations(); + + return API.v1.success(operations); + }, +); + +API.v1.post( + 'downloadPendingFiles', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isDownloadPendingFilesParamsPOST, + response: { + 200: countResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const importer = Importers.get('pending-files'); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles'); + } + + const operation = await Import.newOperation(this.userId, importer.name, importer.key); + const instance = new PendingFileImporter(importer, operation); + const count = await instance.prepareFileCount(); + + return API.v1.success({ + count, + }); + }, +); + +API.v1.post( + 'downloadPendingAvatars', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isDownloadPendingAvatarsParamsPOST, + response: { + 200: countResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const importer = Importers.get('pending-avatars'); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars'); + } + + const operation = await Import.newOperation(this.userId, importer.name, importer.key); + const instance = new PendingAvatarImporter(importer, operation); + const count = await instance.prepareFileCount(); + + return API.v1.success({ + count, + }); + }, +); + +API.v1.get( + 'getCurrentImportOperation', + { + authRequired: true, + permissionsRequired: ['run-import'], + query: isGetCurrentImportOperationParamsGET, + response: { + 200: operationResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const operation = await Imports.findLastImport(); + + return API.v1.success({ + operation, + }); + }, +); + +API.v1.get( + 'importers.list', + { + authRequired: true, + permissionsRequired: ['run-import'], + query: isImportersListParamsGET, + response: { + 200: importersListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const importers = Importers.getAllVisible().map(({ key, name }) => ({ key, name })); + + return API.v1.success(importers); + }, +); + +API.v1.post( + 'import.clear', + { + authRequired: true, + permissionsRequired: ['run-import'], + response: { + 200: successResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await Import.clear(); + + return API.v1.success(); + }, +); + +API.v1.post( + 'import.new', + { + authRequired: true, + permissionsRequired: ['run-import'], + response: { + 200: operationResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const operation = await Import.newOperation(this.userId, 'api', 'api'); + + return API.v1.success({ operation }); + }, +); + +API.v1.get( + 'import.status', + { + authRequired: true, + permissionsRequired: ['run-import'], + response: { + 200: ajv.compile>({ type: 'object', additionalProperties: true }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const status = await Import.status(); + + return API.v1.success(typeof status === 'object' ? status : {}); + }, +); + +API.v1.post( + 'import.addUsers', + { + authRequired: true, + permissionsRequired: ['run-import'], + body: isImportAddUsersParamsPOST, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { users } = this.bodyParams; + + await Import.addUsers(users); + + return API.v1.success(); + }, +); + +API.v1.post( + 'import.run', + { + authRequired: true, + permissionsRequired: ['run-import'], + response: { + 200: successResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await Import.run(this.userId); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/instances.ts b/apps/meteor/server/api/v1/instances.ts new file mode 100644 index 0000000..48e0e6a --- /dev/null +++ b/apps/meteor/server/api/v1/instances.ts @@ -0,0 +1,94 @@ +import { InstanceStatus } from '@rocket.chat/models'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; + +import { isRunningMs } from '../../lib/isRunningMs'; +import { API } from '../api'; +import { getInstanceList } from '../lib/getInstanceList'; + +const getConnections = (() => { + if (isRunningMs()) { + return () => []; + } + + return () => getInstanceList(); +})(); + +API.v1.get( + 'instances.get', + { + authRequired: true, + permissionsRequired: ['view-statistics'], + response: { + 200: ajv.compile<{ + instances: { + address?: string; + currentStatus: { + connected: boolean; + lastHeartbeatTime?: number; + local?: boolean; + }; + instanceRecord: object; + broadcastAuth: boolean; + }[]; + success: true; + }>({ + type: 'object', + properties: { + instances: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string' }, + currentStatus: { + type: 'object', + properties: { + connected: { type: 'boolean' }, + lastHeartbeatTime: { type: 'number' }, + local: { type: 'boolean' }, + }, + required: ['connected'], + }, + instanceRecord: { type: 'object' }, + broadcastAuth: { type: 'boolean' }, + }, + required: ['currentStatus', 'instanceRecord', 'broadcastAuth'], + }, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['instances', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const instanceRecords = await InstanceStatus.find().toArray(); + + const connections = await getConnections(); + + const result = instanceRecords.map((instanceRecord) => { + const connection = connections.find((c) => c.id === instanceRecord._id); + + return { + address: connection?.ipList[0], + currentStatus: { + connected: connection?.available || false, + lastHeartbeatTime: connection?.lastHeartbeatTime, + local: connection?.local, + }, + instanceRecord, + broadcastAuth: true, + }; + }); + + return API.v1.success({ + instances: result, + }); + }, +); diff --git a/apps/meteor/server/api/v1/integrations.ts b/apps/meteor/server/api/v1/integrations.ts new file mode 100644 index 0000000..83d5019 --- /dev/null +++ b/apps/meteor/server/api/v1/integrations.ts @@ -0,0 +1,412 @@ +import type { IIntegration, IIntegrationHistory, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings'; +import { Integrations, IntegrationHistory } from '@rocket.chat/models'; +import { + ajv, + isIntegrationsCreateProps, + isIntegrationsHistoryProps, + isIntegrationsRemoveProps, + isIntegrationsGetProps, + isIntegrationsUpdateProps, + isIntegrationsListProps, + isIntegrationsClearHistoryProps, + isIntegrationsReplayProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; +import type { Filter } from 'mongodb'; + +import { clearIntegrationHistoryMethod, replayOutgoingIntegrationMethod } from '../../lib/integrations/functions/clearIntegrationHistory'; +import { + mountIntegrationHistoryQueryBasedOnPermissions, + mountIntegrationQueryBasedOnPermissions, +} from '../../lib/integrations/lib/mountQueriesBasedOnPermission'; +import { addIncomingIntegration } from '../../meteor-methods/integrations/incoming/addIncomingIntegration'; +import { deleteIncomingIntegration } from '../../meteor-methods/integrations/incoming/deleteIncomingIntegration'; +import { updateIncomingIntegration } from '../../meteor-methods/integrations/incoming/updateIncomingIntegration'; +import { addOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/addOutgoingIntegration'; +import { deleteOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/deleteOutgoingIntegration'; +import { updateOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/updateOutgoingIntegration'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { findOneIntegration } from '../lib/integrations'; + +const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({ + type: 'object', + properties: { + integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integration', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'integrations.create', + { + authRequired: true, + body: isIntegrationsCreateProps, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + switch (this.bodyParams.type) { + case 'webhook-outgoing': + return API.v1.success({ + integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration), + }); + case 'webhook-incoming': + return API.v1.success({ + integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, +); + +API.v1.get( + 'integrations.history', + { + authRequired: true, + query: isIntegrationsHistoryProps, + permissionsRequired: { + GET: { permissions: ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'], operation: 'hasAny' }, + }, + response: { + 200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({ + type: 'object', + properties: { + history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } }, + offset: { type: 'number' }, + items: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['history', 'offset', 'items', 'count', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, queryParams } = this; + + if (!queryParams.id || queryParams.id.trim() === '') { + return API.v1.failure('Invalid integration id.'); + } + + const { id } = queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query); + + const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, { + sort: sort || { _updatedAt: -1 }, + skip: offset, + limit: count, + projection, + }); + + const [history, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + history, + offset, + items: history.length, + count: history.length, + total, + }); + }, +); + +API.v1.get( + 'integrations.list', + { + authRequired: true, + query: isIntegrationsListProps, + permissionsRequired: { + GET: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + }, + response: { + 200: ajv.compile<{ integrations: IIntegration[]; offset: number; items: number; count: number; total: number }>({ + type: 'object', + properties: { + integrations: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, + }, + offset: { type: 'number' }, + items: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'offset', 'items', 'count', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { name, type } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), + ...(type ? { type } : {}), + }; + + const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter; + + const { cursor, totalCount } = Integrations.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + offset, + items: integrations.length, + count: integrations.length, + total, + }); + }, +); + +API.v1.post( + 'integrations.remove', + { + authRequired: true, + body: isIntegrationsRemoveProps, + permissionsRequired: { + POST: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + }, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + let integration: IIntegration | null = null; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (!bodyParams.target_url && !bodyParams.integrationId) { + return API.v1.failure('An integrationId or target_url needs to be provided.'); + } + + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + const outgoingId = integration._id; + + await deleteOutgoingIntegration(outgoingId, this.userId); + + return API.v1.success({ + integration, + }); + case 'webhook-incoming': + check( + bodyParams, + Match.ObjectIncluding({ + integrationId: String, + }), + ); + + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + const incomingId = integration._id; + await deleteIncomingIntegration(incomingId, this.userId); + + return API.v1.success({ + integration, + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, +); + +API.v1.get( + 'integrations.get', + { + authRequired: true, + query: isIntegrationsGetProps, + response: { + 200: ajv.compile<{ integration: IIntegration | null }>({ + type: 'object', + properties: { + integration: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integration', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { integrationId, createdBy } = this.queryParams; + if (!integrationId) { + return API.v1.failure('The query parameter "integrationId" is required.'); + } + + return API.v1.success({ + integration: await findOneIntegration({ + userId: this.userId, + integrationId, + createdBy, + }), + }); + }, +); + +API.v1.put( + 'integrations.update', + { + authRequired: true, + body: isIntegrationsUpdateProps, + response: { + 200: integrationSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + let integration: IIntegration | null = null; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateOutgoingIntegration(this.userId, integration._id, bodyParams as Parameters[2]); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + case 'webhook-incoming': + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateIncomingIntegration( + this.userId, + integration._id, + bodyParams as unknown as Parameters[2], + ); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, +); + +const voidIntegrationsResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'integrations.clearHistory', + { + authRequired: true, + body: isIntegrationsClearHistoryProps, + response: { + 200: voidIntegrationsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await clearIntegrationHistoryMethod(this.userId, this.bodyParams.integrationId); + return API.v1.success(); + }, +); + +API.v1.post( + 'integrations.replayOutgoing', + { + authRequired: true, + body: isIntegrationsReplayProps, + response: { + 200: voidIntegrationsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { integrationId, historyId } = this.bodyParams; + await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId }); + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/invites.ts b/apps/meteor/server/api/v1/invites.ts new file mode 100644 index 0000000..98475ee --- /dev/null +++ b/apps/meteor/server/api/v1/invites.ts @@ -0,0 +1,301 @@ +import type { IInvite } from '@rocket.chat/core-typings'; +import { + ajv, + isFindOrCreateInviteParams, + isUseInviteTokenProps, + isValidateInviteTokenProps, + isSendInvitationEmailParams, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { findOrCreateInvite } from '../../lib/rooms/invites/findOrCreateInvite'; +import { listInvites } from '../../lib/rooms/invites/listInvites'; +import { removeInvite } from '../../lib/rooms/invites/removeInvite'; +import { sendInvitationEmail } from '../../lib/rooms/invites/sendInvitationEmail'; +import { useInviteToken } from '../../lib/rooms/invites/useInviteToken'; +import { validateInviteToken } from '../../lib/rooms/invites/validateInviteToken'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +const removeInviteResponseSchema = ajv.compile({ + type: 'boolean', + enum: [true], +}); + +const useInviteTokenResponseSchema = ajv.compile({ + type: 'object', + properties: { + room: { + type: 'object', + properties: { + rid: { type: 'string' }, + prid: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + t: { type: 'string' }, + }, + required: ['rid', 't'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({ + type: 'object', + properties: { + valid: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['valid', 'success'], + additionalProperties: false, +}); + +const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const invites = API.v1 + .get( + 'listInvites', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'array', + items: { + $ref: '#/components/schemas/IInvite', + }, + }), + 401: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + error: { + type: 'string', + }, + status: { + type: 'string', + nullable: true, + }, + message: { + type: 'string', + nullable: true, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'error'], + }), + }, + }, + + async function () { + const result = await listInvites(this.userId); + return API.v1.success(result); + }, + ) + .post( + 'findOrCreateInvite', + { + authRequired: true, + body: isFindOrCreateInviteParams, + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + _id: { + type: 'string', + }, + rid: { + type: 'string', + }, + userId: { + type: 'string', + }, + createdAt: { + type: 'string', + }, + _updatedAt: { + type: 'string', + }, + expires: { + type: 'string', + nullable: true, + }, + url: { + type: 'string', + }, + maxUses: { + type: 'number', + }, + days: { + type: 'number', + }, + uses: { + type: 'number', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['_id', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'], + }), + 400: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + error: { + type: 'string', + }, + stack: { + type: 'string', + nullable: true, + }, + errorType: { + type: 'string', + }, + details: { + type: 'object', + nullable: true, + properties: { + rid: { + type: 'string', + }, + method: { + type: 'string', + }, + }, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'errorType', 'error'], + }), + 401: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + error: { + type: 'string', + }, + status: { + type: 'string', + nullable: true, + }, + message: { + type: 'string', + nullable: true, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'error'], + }), + }, + }, + + async function () { + const { rid, days, maxUses } = this.bodyParams; + + return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite); + }, + ) + .delete( + 'removeInvite/:_id', + { + authRequired: true, + response: { + 200: removeInviteResponseSchema, + 400: validateBadRequestErrorResponse, + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { _id } = this.urlParams; + return API.v1.success(await removeInvite(this.userId, { _id })); + }, + ) + .post( + 'useInviteToken', + { + authRequired: true, + body: isUseInviteTokenProps, + response: { + 200: useInviteTokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { token } = this.bodyParams; + // eslint-disable-next-line react-hooks/rules-of-hooks + return API.v1.success(await useInviteToken(this.userId, token)); + }, + ) + .post( + 'validateInviteToken', + { + authRequired: false, + body: isValidateInviteTokenProps, + response: { + 200: validateInviteTokenResponseSchema, + }, + }, + async function action() { + const { token } = this.bodyParams; + try { + return API.v1.success({ valid: Boolean(await validateInviteToken(token)) }); + } catch (_) { + return API.v1.success({ valid: false }); + } + }, + ) + .post( + 'sendInvitationEmail', + { + authRequired: true, + body: isSendInvitationEmailParams, + response: { + 200: sendInvitationEmailResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { emails } = this.bodyParams; + try { + return API.v1.success({ success: Boolean(await sendInvitationEmail(this.userId, emails)) }); + } catch (e: unknown) { + return API.v1.failure({ error: e instanceof Error ? e.message : String(e) }); + } + }, + ); + +type InvitesEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends InvitesEndpoints {} +} diff --git a/apps/meteor/server/api/v1/ldap.ts b/apps/meteor/server/api/v1/ldap.ts new file mode 100644 index 0000000..90f85af --- /dev/null +++ b/apps/meteor/server/api/v1/ldap.ts @@ -0,0 +1,86 @@ +import { LDAP } from '@rocket.chat/core-services'; +import { ajv, isLdapTestSearch, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; + +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; +import { API } from '../api'; + +const messageResponseSchema = { + type: 'object' as const, + properties: { + message: { type: 'string' as const }, + success: { + type: 'boolean' as const, + enum: [true] as const, + }, + }, + required: ['message', 'success'] as const, + additionalProperties: false, +}; + +API.v1.post( + 'ldap.testConnection', + { + authRequired: true, + permissionsRequired: ['test-admin-options'], + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { + await LDAP.testConnection(); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('Connection_failed'); + } + + return API.v1.success({ + message: 'LDAP_Connection_successful' as const, + }); + }, +); + +API.v1.post( + 'ldap.testSearch', + { + authRequired: true, + permissionsRequired: ['test-admin-options'], + body: isLdapTestSearch, + response: { + 200: ajv.compile<{ message: string; success: true }>(messageResponseSchema), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!this.userId) { + throw new Error('error-invalid-user'); + } + + if (settings.get('LDAP_Enable') !== true) { + throw new Error('LDAP_disabled'); + } + + try { + await LDAP.testSearch(this.bodyParams.username); + } catch (err) { + SystemLogger.error({ err }); + throw new Error('LDAP_search_failed'); + } + + return API.v1.success({ + message: 'LDAP_User_Found' as const, + }); + }, +); diff --git a/apps/meteor/server/api/v1/loginCode.ts b/apps/meteor/server/api/v1/loginCode.ts new file mode 100644 index 0000000..e681ebb --- /dev/null +++ b/apps/meteor/server/api/v1/loginCode.ts @@ -0,0 +1,71 @@ +import { LoginCodes } from '@rocket.chat/models'; +import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; +import { Accounts } from 'meteor/accounts-base'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +const loginCodeRedeemResponse = ajv.compile<{ loginToken: string; userId: string }>({ + type: 'object', + properties: { + loginToken: { type: 'string' }, + userId: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['loginToken', 'userId', 'success'], + additionalProperties: false, +}); + +type LoginCodeRedeemParams = { code: string }; + +const LoginCodeRedeemSchema: JSONSchemaType = { + type: 'object', + properties: { + code: { type: 'string', minLength: 64, maxLength: 64 }, + }, + required: ['code'], + additionalProperties: false, +}; + +const isLoginCodeRedeemParamsPOST = ajv.compile(LoginCodeRedeemSchema); + +const loginCodeEndpoints = API.v1.post( + 'loginCode.redeem', + { + authRequired: false, + body: isLoginCodeRedeemParamsPOST, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 10 }, + response: { + 200: loginCodeRedeemResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { code } = this.bodyParams; + + const loginCode = await LoginCodes.findOneNotExpiredByCodeAndDelete(code); + + if (!loginCode) { + return API.v1.failure('error-invalid-code'); + } + + const { userId } = loginCode; + + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, stampedToken); + + return API.v1.success({ + loginToken: stampedToken.token, + userId, + }); + }, +); + +type LoginCodeEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LoginCodeEndpoints {} +} diff --git a/apps/meteor/server/api/v1/mailer.ts b/apps/meteor/server/api/v1/mailer.ts new file mode 100644 index 0000000..226af5d --- /dev/null +++ b/apps/meteor/server/api/v1/mailer.ts @@ -0,0 +1,72 @@ +import { + ajv, + isMailerProps, + isMailerUnsubscribeProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { sendMail } from '../../lib/notifications/mail-messages/functions/sendMail'; +import { Mailer } from '../../lib/notifications/mail-messages/lib/Mailer'; +import { API } from '../api'; + +const mailerResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const mailerUnsubscribeResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'mailer', + { + authRequired: true, + body: isMailerProps, + permissionsRequired: ['send-mail'], + response: { + 200: mailerResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { from, subject, body, dryrun, query } = this.bodyParams; + + const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query }); + + return API.v1.success(result); + }, +); + +API.v1.post( + 'mailer.unsubscribe', + { + authRequired: true, + body: isMailerUnsubscribeProps, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 }, + response: { + 200: mailerUnsubscribeResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { _id, createdAt } = this.bodyParams; + + await Mailer.unsubscribe(_id, createdAt); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/media-calls.ts b/apps/meteor/server/api/v1/media-calls.ts new file mode 100644 index 0000000..0c544a7 --- /dev/null +++ b/apps/meteor/server/api/v1/media-calls.ts @@ -0,0 +1,277 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling'; +import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type MediaCallsAnswer = { + callId: string; + contractId: string; + + answer: CallAnswer; + + supportedFeatures?: CallFeature[]; +}; + +const MediaCallsAnswerSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + contractId: { + type: 'string', + }, + answer: { + type: 'string', + enum: callAnswerList, + }, + supportedFeatures: { + type: 'array', + items: { + type: 'string', + enum: callFeatureList, + }, + nullable: true, + }, + }, + required: ['callId', 'contractId', 'answer'], + additionalProperties: false, +}; + +export const isMediaCallsAnswerProps = ajv.compile(MediaCallsAnswerSchema); + +const mediaCallsAnswerEndpoints = API.v1.post( + 'media-calls.answer', + { + response: { + 200: ajv.compile<{ + call: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + description: 'The updated call information.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['call', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isMediaCallsAnswerProps, + authRequired: true, + }, + async function action() { + const call = await MediaCall.answerCall(this.userId, this.bodyParams); + + return API.v1.success({ + call, + }); + }, +); + +type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsAnswerEndpoints {} +} + +type MediaCallsStateSignalsParams = { + contractId: string; +}; + +const MediaCallsStateSignalsSchema: JSONSchemaType = { + type: 'object', + properties: { + contractId: { + type: 'string', + }, + }, + required: ['contractId'], + additionalProperties: false, +}; + +export const isMediaCallsStateSignalsProps = ajv.compile(MediaCallsStateSignalsSchema); + +const mediaCallsStateEndpoints = API.v1 + .get( + 'media-calls.state', + { + response: { + 200: ajv.compile<{ + calls: IMediaCall[]; + }>({ + additionalProperties: false, + type: 'object', + properties: { + calls: { + type: 'array', + items: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + }, + description: 'The list of active calls.', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', + }, + }, + required: ['calls', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + authRequired: true, + }, + async function action() { + const calls = await MediaCalls.findAllNotOverByUid(this.userId).toArray(); + + return API.v1.success({ + calls, + }); + }, + ) + .get( + 'media-calls.stateSignals', + { + response: { + 200: ajv.compile<{ + signals: ServerMediaCallSignal[]; + }>({ + additionalProperties: false, + type: 'object', + properties: { + signals: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + callId: { type: 'string' }, + }, + required: ['callId', 'type'], + additionalProperties: true, + }, + description: 'The list of signals that were already sent for the active calls.', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', + }, + }, + required: ['signals', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isMediaCallsStateSignalsProps, + authRequired: true, + }, + async function action() { + const { contractId } = this.queryParams; + const signals = await MediaCall.getUserStateSignals(this.userId, contractId); + + return API.v1.success({ + signals, + }); + }, + ); + +type MediaCallsStateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsStateEndpoints {} +} + +type MediaCallsInfoParams = { + callId: string; +}; + +const MediaCallsInfoSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isMediaCallsInfoProps = ajv.compile(MediaCallsInfoSchema); + +const mediaCallsInfoEndpoints = API.v1.get( + 'media-calls.info', + { + response: { + 200: ajv.compile<{ + call: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + }, + success: { + type: 'boolean', + description: 'Indicates the request was successful.', + }, + }, + required: ['call', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isMediaCallsInfoProps, + authRequired: true, + }, + async function action() { + const call = await MediaCalls.findOneById(this.queryParams.callId); + + if (!call?.uids.includes(this.userId)) { + return API.v1.notFound(); + } + + return API.v1.success({ + call, + }); + }, +); + +type MediaCallsInfoEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsInfoEndpoints {} +} diff --git a/apps/meteor/server/api/v1/middlewares/authentication.ts b/apps/meteor/server/api/v1/middlewares/authentication.ts new file mode 100644 index 0000000..b74ad1d --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/authentication.ts @@ -0,0 +1,73 @@ +import { hashLoginToken } from '@rocket.chat/account-utils'; +import { Authorization } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import type { Request, Response, NextFunction } from 'express'; + +import { oAuth2ServerAuth } from '../../../lib/auth/oauth2-server/oauth2-server'; + +type AuthenticationMiddlewareConfig = { + rejectUnauthorized?: boolean; + cookies?: boolean; +}; + +export function authenticationMiddleware( + config: AuthenticationMiddlewareConfig = { + rejectUnauthorized: true, + cookies: false, + }, +) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (config.cookies) { + req.headers['x-auth-token'] = req.cookies.rc_token ?? req.headers['x-auth-token']; + req.headers['x-user-id'] = req.cookies.rc_uid ?? req.headers['x-user-id']; + } + + const { 'x-user-id': userId, 'x-auth-token': authToken } = req.headers; + + if (userId && authToken) { + const user = await Users.findOneByIdAndLoginToken(userId as string, hashLoginToken(authToken as string)); + req.user = user || undefined; + } else { + const { authorization } = req.headers; + const accessToken = typeof req.query.access_token === 'string' ? req.query.access_token : undefined; + delete req.query.access_token; + req.user = await oAuth2ServerAuth({ authorization, accessToken }); + } + + if (config.rejectUnauthorized && !req.user) { + res.status(401).send('Unauthorized'); + return; + } + + req.userId = req?.user?._id; + + next(); + }; +} + +export function hasPermissionMiddleware( + permission: string, + { rejectUnauthorized } = { + rejectUnauthorized: true, + }, +) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.userId) { + if (rejectUnauthorized) { + res.status(401).send('Unauthorized'); + return; + } + req.unauthorized = true; + return next(); + } + + if (!(await Authorization.hasPermission(req.userId, permission))) { + if (rejectUnauthorized) { + res.status(403).send('Forbidden'); + return; + } + req.unauthorized = true; + } + next(); + }; +} diff --git a/apps/meteor/server/api/v1/middlewares/authenticationHono.ts b/apps/meteor/server/api/v1/middlewares/authenticationHono.ts new file mode 100644 index 0000000..883bb23 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/authenticationHono.ts @@ -0,0 +1,48 @@ +import { type IUser, type RequiredField } from '@rocket.chat/core-typings'; +import { type Logger } from '@rocket.chat/logger'; +import type { MiddlewareHandler } from 'hono'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { applyBreakingChanges, type APIClass } from '../../ApiClass'; +import { convertHonoContextToApiActionContext, type HonoContext } from '../../router'; + +const isUserWithUsername = (user: IUser | null): user is RequiredField => { + return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined; +}; + +export function authenticationMiddlewareForHono( + api: APIClass>, + options: { + authRequired?: boolean; + authOrAnonRequired?: boolean; + userWithoutUsername?: boolean; + logger: Logger; + }, +): MiddlewareHandler { + return async (c: HonoContext, next) => { + const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger })); + const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead'); + const shouldPreventUserRead = !user && options.authRequired; + + if (shouldPreventAnonymousRead || shouldPreventUserRead) { + const result = api.unauthorized('You must be logged in to do this.'); + // TODO: MAJOR + if (!applyBreakingChanges) { + Object.assign(result.body, { + status: 'error', + message: 'You must be logged in to do this.', + }); + } + + return c.json(result.body, result.statusCode); + } + + if (user && !options.userWithoutUsername && !isUserWithUsername(user)) { + throw new Meteor.Error('error-unauthorized', 'Users must have a username'); + } + + c.set('user', user); + return next(); + }; +} diff --git a/apps/meteor/server/api/v1/middlewares/cors.spec.ts b/apps/meteor/server/api/v1/middlewares/cors.spec.ts new file mode 100644 index 0000000..68e1014 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/cors.spec.ts @@ -0,0 +1,326 @@ +import { Router } from '@rocket.chat/http-router'; +import Ajv from 'ajv'; +import express from 'express'; +import request from 'supertest'; + +import { cors } from './cors'; +import { CachedSettings } from '../../../settings/CachedSettings'; + +describe('Cors middleware', () => { + it('should return allow-origin header for GET if CORS enabled', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: true, + } as any); + settings.set({ + _id: 'API_CORS_Origin', + value: '*', + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app).get('/api/test').set('origin', 'http://localhost'); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('message', 'CORS test successful'); + expect(response.headers).toHaveProperty('access-control-allow-origin', '*'); + expect(response.headers).not.toHaveProperty('access-control-allow-methods'); + expect(response.headers).toHaveProperty('access-control-allow-headers'); + }); + + it('should return allow-origin header for GET if CORS disabled', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: false, + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app).get('/api/test').set('origin', 'http://localhost'); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('message', 'CORS test successful'); + expect(response.headers).toHaveProperty('access-control-allow-origin', '*'); + expect(response.headers).not.toHaveProperty('access-control-allow-methods'); + expect(response.headers).toHaveProperty('access-control-allow-headers'); + }); + + it('should handle CORS if enabled to *', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: true, + } as any); + settings.set({ + _id: 'API_CORS_Origin', + value: '*', + } as any); + settings.set({ + _id: 'MCP_Enabled', + value: true, + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app) + .options('/api/test') + .set('origin', 'http://localhost') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('message', 'CORS test successful'); + expect(response.headers).toHaveProperty('access-control-allow-origin', '*'); + expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH'); + expect(response.headers).toHaveProperty( + 'access-control-allow-headers', + 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version', + ); + }); + + it('should handle CORS if enabled to specific origin', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: true, + } as any); + settings.set({ + _id: 'API_CORS_Origin', + value: 'http://localhost', + } as any); + settings.set({ + _id: 'MCP_Enabled', + value: true, + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app) + .options('/api/test') + .set('origin', 'http://localhost') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('message', 'CORS test successful'); + expect(response.headers).toHaveProperty('access-control-allow-origin', 'http://localhost'); + expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH'); + expect(response.headers).toHaveProperty( + 'access-control-allow-headers', + 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version', + ); + }); + + it('should not advertise the MCP protocol header while MCP is disabled', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ _id: 'API_Enable_CORS', value: true } as any); + settings.set({ _id: 'API_CORS_Origin', value: '*' } as any); + settings.set({ _id: 'MCP_Enabled', value: false } as any); + + api + .use(cors(settings)) + .get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200, body: {} })); + app.use(api.router); + + const response = await request(app).options('/api/test').set('origin', 'http://localhost').set('access-control-request-method', 'POST'); + + expect(response.statusCode).toBe(200); + expect(response.headers['access-control-allow-headers']).not.toContain('MCP-Protocol-Version'); + }); + + it('should not handle CORS if origin is not allowed', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: true, + } as any); + settings.set({ + _id: 'API_CORS_Origin', + value: 'http://localhost', + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app).options('/api/test').set('origin', 'http://example.com'); + + expect(response.statusCode).toBe(403); + }); + + it('should not handle CORS if disabled', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ + _id: 'API_Enable_CORS', + value: false, + } as any); + + api.use(cors(settings)).get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'CORS test successful', + }, + }; + }, + ); + + app.use(api.router); + + const response = await request(app).options('/api/test').set('origin', 'http://localhost'); + + expect(response.statusCode).toBe(405); + expect(response.text).toBe('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); + expect(response.headers).not.toHaveProperty('access-control-allow-origin'); + }); +}); diff --git a/apps/meteor/server/api/v1/middlewares/cors.ts b/apps/meteor/server/api/v1/middlewares/cors.ts new file mode 100644 index 0000000..1772a4d --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/cors.ts @@ -0,0 +1,62 @@ +import type { MiddlewareHandler } from 'hono'; + +import type { CachedSettings } from '../../../settings/CachedSettings'; + +const defaultHeaders = { + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH', + 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', +}; + +const getAllowedHeaders = (settings: CachedSettings): string => + `${defaultHeaders['Access-Control-Allow-Headers']}${settings.get('MCP_Enabled') ? ', MCP-Protocol-Version' : ''}`; + +export const cors = + (settings: CachedSettings): MiddlewareHandler => + async (c, next) => { + const { req, res } = c; + const allowedHeaders = getAllowedHeaders(settings); + if (req.method !== 'OPTIONS') { + res.headers.set('Access-Control-Allow-Origin', '*'); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); + + await next(); + return; + } + + // check if a pre-flight request + if (!req.header('access-control-request-method') && !req.header('origin')) { + await next(); + return; + } + + if (!settings.get('API_Enable_CORS')) { + return c.body('CORS not enabled. Go to "Admin > General > REST Api" to enable it.', 405); + } + + const CORSOriginSetting = String(settings.get('API_CORS_Origin')); + + if (CORSOriginSetting === '*') { + res.headers.set('Access-Control-Allow-Origin', '*'); + res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); + await next(); + return; + } + + const origins = CORSOriginSetting.trim() + .split(',') + .map((origin) => String(origin).trim().toLocaleLowerCase()); + + const originHeader = req.header('origin'); + + // if invalid origin reply without required CORS headers + if (!originHeader || !origins.includes(originHeader)) { + return c.body('Invalid origin', 403); + } + + res.headers.set('Vary', 'Origin'); + res.headers.set('Access-Control-Allow-Origin', originHeader); + res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); + await next(); + }; diff --git a/apps/meteor/server/api/v1/middlewares/experimental.spec.ts b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts new file mode 100644 index 0000000..4d44f66 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts @@ -0,0 +1,86 @@ +import { Router } from '@rocket.chat/http-router'; +import Ajv from 'ajv'; +import express from 'express'; +import request from 'supertest'; + +import { cors } from './cors'; +import { experimentalWarningMiddleware } from './experimental'; +import { CachedSettings } from '../../../settings/CachedSettings'; + +const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; + +const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => { + const ajv = new Ajv(); + const settings = new CachedSettings(); + settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any); + settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any); + + const route = (router: Router) => + router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ + statusCode: 200 as const, + body: {}, + })); + + const api = new Router('/api') + .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) + .use(cors(settings)) + .use(route(new Router('/v1'))) + .use(route(new Router('/experimental'))); + + const app = express(); + app.use(api.router); + return app; +}; + +const preflight = (app: express.Express, path: string, origin: string) => + request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET'); + +describe('Experimental middleware', () => { + it('should stamp the unstable signal headers on experimental responses', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test'); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should not stamp responses from other versions', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test'); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-experimental']).toBeUndefined(); + expect(res.headers.warning).toBeUndefined(); + }); + + it('should stamp 404s for unmatched experimental paths', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope'); + + expect(res.statusCode).toBe(404); + expect(res.headers['x-experimental']).toBe('true'); + }); + + // cors answers rejected preflights without calling next(), so these only carry the headers + // while the middleware stays registered ahead of it + it('should stamp preflight rejections when CORS is disabled', async () => { + const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example'); + + expect(res.statusCode).toBe(405); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should stamp preflight rejections from disallowed origins', async () => { + const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example'); + + expect(res.statusCode).toBe(403); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should not stamp preflight rejections from other versions', async () => { + const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example'); + + expect(res.statusCode).toBe(403); + expect(res.headers['x-experimental']).toBeUndefined(); + }); +}); diff --git a/apps/meteor/server/api/v1/middlewares/experimental.ts b/apps/meteor/server/api/v1/middlewares/experimental.ts new file mode 100644 index 0000000..c8e8029 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/experimental.ts @@ -0,0 +1,29 @@ +import type { MiddlewareHandler } from 'hono'; + +// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for +// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted +// along with the `Warning` header itself. +const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; + +/** + * Stamps every experimental response with the unstable signal headers. + * + * Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by + * router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a + * middleware living on `API.experimental.router` would never run for those responses. + * + * The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them + * into whatever response is produced later, so 404s and CORS rejections are covered too. + */ +export const experimentalWarningMiddleware = + ({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler => + async (c, next) => { + if (!basePathRegex.test(c.req.path)) { + return next(); + } + + c.res.headers.set('x-experimental', 'true'); + c.res.headers.set('Warning', WARNING_HEADER); + + await next(); + }; diff --git a/apps/meteor/server/api/v1/middlewares/logger.ts b/apps/meteor/server/api/v1/middlewares/logger.ts new file mode 100644 index 0000000..ecdabd4 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/logger.ts @@ -0,0 +1,37 @@ +import type { Logger } from '@rocket.chat/logger'; +import { censorUrl } from '@rocket.chat/tools'; +import type { MiddlewareHandler } from 'hono'; + +import { getRestPayload } from '../../../lib/logger/logPayloads'; + +export const loggerMiddleware = + (logger: Logger): MiddlewareHandler => + async (c, next) => { + const startTime = Date.now(); + + const log = logger.logger.child( + { + method: c.req.method, + url: censorUrl(c.req.url), + userId: c.req.header('x-user-id'), + userAgent: c.req.header('user-agent'), + length: c.req.header('content-length'), + host: c.req.header('host'), + referer: c.req.header('referer'), + remoteIP: c.get('remoteAddress'), + ...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && (await getRestPayload(c.req))), + }, + { + redact: [ + 'payload.password', // Potentially logged by v1/login + ], + }, + ); + + await next(); + + log.http({ + status: c.res.status, + responseTime: Date.now() - startTime, + }); + }; diff --git a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts new file mode 100644 index 0000000..76d33e0 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts @@ -0,0 +1,349 @@ +import { Router } from '@rocket.chat/http-router'; +import Ajv from 'ajv'; +import express from 'express'; +import request from 'supertest'; + +import { metricsMiddleware } from './metrics'; +import { CachedSettings } from '../../../settings/CachedSettings'; + +describe('Metrics middleware', () => { + it('should handle metrics', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + settings.set({ + _id: 'Prometheus_API_User_Agent', + value: true, + } as any); + + const summary = { + startTimer: jest.fn().mockImplementation(() => jest.fn()), + }; + + // Get the mock startTimer function + const mockEndTimer = jest.fn(); + summary.startTimer.mockReturnValue(mockEndTimer); + + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + + api + .use( + metricsMiddleware({ + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) + .get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'Metrics test successful', + }, + }; + }, + ); + app.use(api.router); + const response = await request(app).get('/api/test').set('user-agent', 'test'); + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('message', 'Metrics test successful'); + + expect(summary.startTimer).toHaveBeenCalledTimes(1); + expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: '/api/test' }); + }); + + it('should strip path from metrics', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + settings.set({ + _id: 'Prometheus_API_User_Agent', + value: true, + } as any); + + const summary = { + startTimer: jest.fn().mockImplementation(() => jest.fn()), + }; + + // Get the mock startTimer function + const mockEndTimer = jest.fn(); + summary.startTimer.mockReturnValue(mockEndTimer); + + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + + api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\//), + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) + .get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: 'Metrics test successful', + }, + }; + }, + ); + app.use(api.router); + const response = await request(app).get('/api/test').set('user-agent', 'test'); + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('message', 'Metrics test successful'); + + expect(summary.startTimer).toHaveBeenCalledTimes(1); + expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: 'test' }); + }); + + it('should decode path for method.call endpoints', async () => { + const ajv = new Ajv(); + const app = express(); + const settings = new CachedSettings(); + + const api = new Router('/api'); + + const summary = { + startTimer: jest.fn().mockImplementation(() => jest.fn()), + }; + + // Get the mock startTimer function + const mockEndTimer = jest.fn(); + summary.startTimer.mockReturnValue(mockEndTimer); + + const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) }; + const responseSizeHistogram = { observe: jest.fn() }; + const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() }; + + api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\//), + api: { version: 1 } as any, + settings, + endpointTimeSummary: summary as any, + endpointTimeHistogram: histogram as any, + responseSizeHistogram: responseSizeHistogram as any, + activeRequestsGauge: activeRequestsGauge as any, + }), + ) + .get( + '/method.call/:id', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => { + return { + statusCode: 200, + body: { + message: `Metrics test successful`, + }, + }; + }, + ); + app.use(api.router); + const response = await request(app).get('/api/method.call/get%3Aparam').set('user-agent', 'test'); + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('message', 'Metrics test successful'); + + expect(summary.startTimer).toHaveBeenCalledTimes(1); + expect(mockEndTimer).toHaveBeenCalledWith({ + status: 200, + method: 'get', + version: 1, + entrypoint: 'method.call/get:param', + }); + }); + + it('should only record requests matching its own base path', async () => { + const ajv = new Ajv(); + const app = express(); + const settings = new CachedSettings(); + + const makeMetrics = () => { + const endTimer = jest.fn(); + return { + endTimer, + summary: { startTimer: jest.fn().mockReturnValue(endTimer) }, + histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) }, + responseSizeHistogram: { observe: jest.fn() }, + activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() }, + }; + }; + + const v1Metrics = makeMetrics(); + const experimentalMetrics = makeMetrics(); + + const route = (router: Router) => + router.get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => ({ + statusCode: 200, + body: { message: 'Metrics test successful' }, + }), + ); + + const api = new Router('/api'); + + api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/v1\//), + api: { version: 'v1' } as any, + settings, + endpointTimeSummary: v1Metrics.summary as any, + endpointTimeHistogram: v1Metrics.histogram as any, + responseSizeHistogram: v1Metrics.responseSizeHistogram as any, + activeRequestsGauge: v1Metrics.activeRequestsGauge as any, + }), + ) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/experimental\//), + api: { version: 'experimental' } as any, + settings, + endpointTimeSummary: experimentalMetrics.summary as any, + endpointTimeHistogram: experimentalMetrics.histogram as any, + responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any, + activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any, + }), + ) + .use(route(new Router('/v1'))) + .use(route(new Router('/experimental'))); + + app.use(api.router); + + expect((await request(app).get('/api/v1/test')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' }); + expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled(); + expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled(); + + expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200); + + expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({ + status: 200, + method: 'get', + version: 'experimental', + entrypoint: 'test', + }); + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + }); + + it('should sample the default router and unmatched paths exactly once', async () => { + const ajv = new Ajv(); + const app = express(); + const settings = new CachedSettings(); + + const makeMetrics = () => { + const endTimer = jest.fn(); + return { + endTimer, + summary: { startTimer: jest.fn().mockReturnValue(endTimer) }, + histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) }, + responseSizeHistogram: { observe: jest.fn() }, + activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() }, + }; + }; + + const v1Metrics = makeMetrics(); + const defaultMetrics = makeMetrics(); + + const wire = (metrics: ReturnType, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) => + metricsMiddleware({ + ...extra, + api: { version }, + settings, + endpointTimeSummary: metrics.summary as any, + endpointTimeHistogram: metrics.histogram as any, + responseSizeHistogram: metrics.responseSizeHistogram as any, + activeRequestsGauge: metrics.activeRequestsGauge as any, + }); + + const api = new Router('/api') + .use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1')) + .use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default')); + + const route = (router: Router, subpath: string) => + router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} })); + + // the catch-all router is mounted first on purpose: the exclusion guard has to hold + // regardless of the order the versioned routers happen to be registered in + api.use(route(new Router(''), 'info')); + api.use(route(new Router('/v1'), '/test')); + + expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled(); + expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' }); + + defaultMetrics.endTimer.mockClear(); + + expect((await request(app).get('/api/v1/test')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call + expect(defaultMetrics.endTimer).not.toHaveBeenCalled(); + + expect((await request(app).get('/api/bogus')).statusCode).toBe(404); + + expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' }); + }); +}); diff --git a/apps/meteor/server/api/v1/middlewares/metrics.ts b/apps/meteor/server/api/v1/middlewares/metrics.ts new file mode 100644 index 0000000..613ee2d --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/metrics.ts @@ -0,0 +1,71 @@ +import type { MiddlewareHandler } from 'hono'; +import type { Gauge, Histogram, Summary } from 'prom-client'; + +import type { CachedSettings } from '../../../settings/CachedSettings'; + +export const metricsMiddleware = + ({ + basePathRegex, + excludePathRegex, + api, + settings, + endpointTimeSummary, + endpointTimeHistogram, + responseSizeHistogram, + activeRequestsGauge, + }: { + basePathRegex?: RegExp; + excludePathRegex?: RegExp; + api: { version?: string }; + settings: CachedSettings; + endpointTimeSummary: Summary; + endpointTimeHistogram: Histogram; + responseSizeHistogram: Histogram; + activeRequestsGauge: Gauge; + }): MiddlewareHandler => + async (c, next) => { + // Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so + // each one has to ignore the paths that belong to the others or a request gets sampled more than + // once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own. + if (basePathRegex && !basePathRegex.test(c.req.path)) { + return next(); + } + + if (excludePathRegex?.test(c.req.path)) { + return next(); + } + + const rocketchatRestApiEnd = endpointTimeSummary.startTimer(); + const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer(); + + const methodLabel = { method: c.req.method.toLowerCase() }; + activeRequestsGauge.inc(methodLabel); + + await next(); + + activeRequestsGauge.dec(methodLabel); + + const { method, path, routePath } = c.req; + + // get rid of the base path (i.e.: /api/v1/) + const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath; + + const histogramLabels = { + status: c.res.status, + method: method.toLowerCase(), + version: api.version, + entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint, + }; + + rocketchatRestApiEnd({ + ...histogramLabels, + ...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }), + }); + + rocketchatRestApiHistEnd(histogramLabels); + + const contentLength = parseInt(c.res.headers.get('content-length') || '0', 10); + if (contentLength > 0) { + responseSizeHistogram.observe(histogramLabels, contentLength); + } + }; diff --git a/apps/meteor/server/api/v1/middlewares/permissions.ts b/apps/meteor/server/api/v1/middlewares/permissions.ts new file mode 100644 index 0000000..4141232 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/permissions.ts @@ -0,0 +1,56 @@ +import { Logger } from '@rocket.chat/logger'; +import type { Method } from '@rocket.chat/rest-typings'; +import type { MiddlewareHandler } from 'hono'; + +import { applyBreakingChanges } from '../../ApiClass'; +import { API } from '../../api'; +import { type PermissionsPayload, checkPermissionsForInvocation } from '../../api.helpers'; +import type { TypedOptions } from '../../definition'; +import type { HonoContext } from '../../router'; + +const logger = new Logger('PermissionsMiddleware'); + +export const permissionsMiddleware = + (options: TypedOptions): MiddlewareHandler => + async (c: HonoContext, next) => { + if (!options.permissionsRequired) { + return next(); + } + + const user = c.get('user'); + + if (!user) { + if (applyBreakingChanges) { + const unauthorized = API.v1.unauthorized('You must be logged in to do this'); + return c.json(unauthorized.body, unauthorized.statusCode); + } + + const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(failure.body, failure.statusCode); + } + + let hasPermission: boolean; + try { + hasPermission = await checkPermissionsForInvocation( + user._id, + options.permissionsRequired as PermissionsPayload, + c.req.method as Method, + ); + } catch (e) { + logger.error({ msg: 'Error checking permissions for invocation', err: e }); + const error = API.v1.internalError(); + return c.json(error.body, error.statusCode); + } + + if (!hasPermission) { + if (applyBreakingChanges) { + const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(forbidden.body, forbidden.statusCode); + } + + const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]'); + return c.json(failure.body, failure.statusCode); + } + + return next(); + }; diff --git a/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.spec.ts b/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.spec.ts new file mode 100644 index 0000000..d0437b4 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.spec.ts @@ -0,0 +1,131 @@ +import type { Context } from 'hono'; + +import { remoteAddressMiddleware } from './remoteAddressMiddleware'; + +describe('remoteAddressMiddleware', () => { + it('should set remoteAddress from x-real-ip header', async () => { + const mockContext = { + req: { + header: (name: string) => (name === 'x-real-ip' ? '192.168.1.1' : undefined), + }, + env: { + server: { + incoming: { + socket: { + remoteAddress: '10.0.0.1', + }, + }, + }, + }, + set: jest.fn(), + } as unknown as Context; + + const next = jest.fn(); + + await remoteAddressMiddleware(mockContext, next); + + expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1'); + expect(next).toHaveBeenCalled(); + }); + + it('should set remoteAddress from socket.remoteAddress when no headers are present', async () => { + const mockContext = { + req: { + header: () => undefined, + }, + env: { + server: { + incoming: { + socket: { + remoteAddress: '10.0.0.1', + }, + }, + }, + }, + set: jest.fn(), + } as unknown as Context; + + const next = jest.fn(); + + await remoteAddressMiddleware(mockContext, next); + + expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1'); + expect(next).toHaveBeenCalled(); + }); + + it('should handle x-forwarded-for header with HTTP_FORWARDED_COUNT', async () => { + process.env.HTTP_FORWARDED_COUNT = '2'; + const mockContext = { + req: { + header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1, 172.16.0.1' : undefined), + }, + env: { + server: { + incoming: { + socket: { + remoteAddress: '10.0.0.1', + }, + }, + }, + }, + set: jest.fn(), + } as unknown as Context; + + const next = jest.fn(); + + await remoteAddressMiddleware(mockContext, next); + + expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1'); + expect(next).toHaveBeenCalled(); + }); + + it('should fallback to socket.remoteAddress when x-forwarded-for count is invalid', async () => { + process.env.HTTP_FORWARDED_COUNT = '5'; + const mockContext = { + req: { + header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1' : undefined), + }, + env: { + server: { + incoming: { + socket: { + remoteAddress: '10.0.0.1', + }, + }, + }, + }, + set: jest.fn(), + } as unknown as Context; + + const next = jest.fn(); + + await remoteAddressMiddleware(mockContext, next); + + expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1'); + expect(next).toHaveBeenCalled(); + }); + + it('should handle missing socket information', async () => { + const mockContext = { + req: { + header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1' : undefined), + }, + env: { + server: { + incoming: { + socket: {}, + connection: {}, + }, + }, + }, + set: jest.fn(), + } as unknown as Context; + + const next = jest.fn(); + + await remoteAddressMiddleware(mockContext, next); + + expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1'); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.ts b/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.ts new file mode 100644 index 0000000..a91bbaf --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/remoteAddressMiddleware.ts @@ -0,0 +1,40 @@ +import type { IncomingMessage } from 'node:http'; + +import type { Context, MiddlewareHandler } from 'hono'; + +type HttpBindings = { + incoming: IncomingMessage; +}; + +const getRemoteAddress = (c: Context) => { + const bindings = (c.env?.server ? c.env.server : c.env) as HttpBindings; + + const forwardedFor = c.req.header('x-forwarded-for'); + const socket = bindings.incoming.socket.remoteAddress || bindings.incoming.connection.remoteAddress; + const remoteAddress = c.req.header('x-real-ip') || socket; + + if (!socket) { + return remoteAddress || forwardedFor; + } + + const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0; + if (httpForwardedCount <= 0) { + return remoteAddress; + } + + if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') { + return remoteAddress; + } + + const forwardedForIPs = forwardedFor.split(',').map((ip) => ip.trim()); + if (httpForwardedCount > forwardedForIPs.length) { + return remoteAddress; + } + return forwardedForIPs[forwardedForIPs.length - httpForwardedCount]; +}; + +export const remoteAddressMiddleware: MiddlewareHandler = async function (c, next) { + const remoteAddress = getRemoteAddress(c); + c.set('remoteAddress', remoteAddress); + return next(); +}; diff --git a/apps/meteor/server/api/v1/middlewares/tracer.ts b/apps/meteor/server/api/v1/middlewares/tracer.ts new file mode 100644 index 0000000..bc3f037 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/tracer.ts @@ -0,0 +1,25 @@ +import { tracerSpan } from '@rocket.chat/tracing'; +import type { MiddlewareHandler } from 'hono'; + +export const tracerSpanMiddleware: MiddlewareHandler = async (c, next) => { + return tracerSpan( + `${c.req.method} ${c.req.url}`, + { + attributes: { + url: c.req.url, + // route: c.req.route?.path, + method: c.req.method, + userId: (c.req.raw.clone() as any).userId, // Assuming userId is attached to the request object + }, + }, + async (span) => { + if (span) { + c.header('X-Trace-Id', span.spanContext().traceId); + } + + await next(); + + span?.setAttribute('status', c.res.status); + }, + ); +}; diff --git a/apps/meteor/server/api/v1/misc.ts b/apps/meteor/server/api/v1/misc.ts new file mode 100644 index 0000000..d668d23 --- /dev/null +++ b/apps/meteor/server/api/v1/misc.ts @@ -0,0 +1,878 @@ +import crypto from 'node:crypto'; + +import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models'; +import { + ajv, + isShieldSvgProps, + isSpotlightProps, + parseSpotlightUsernames, + parseSpotlightType, + isDirectoryProps, + isFingerprintProps, + isMeteorCall, + meSuccessResponseSchema, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; +import { escapeHTML } from '@rocket.chat/tools'; +import EJSON from 'ejson'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { passwordPolicy } from '../../lib/auth/passwordPolicy'; +import { i18n } from '../../lib/i18n'; +import { notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { getBaseUserFields } from '../../lib/utils/functions/getBaseUserFields'; +import { isSMTPConfigured } from '../../lib/utils/functions/isSMTPConfigured'; +import { getURL } from '../../lib/utils/getURL'; +import { browseChannelsMethod } from '../../meteor-methods/rooms/browseChannels'; +import { spotlightMethod } from '../../publications/spotlight'; +import { settings } from '../../settings'; +import { resetAuditedSettingByUser, updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUserFromParams } from '../lib/getUserFromParams'; +import { getUserInfo } from '../lib/getUserInfo'; +import { logMethodCallError } from '../lib/logMethodCallError'; + +/** + * @openapi + * /api/v1/me: + * get: + * description: Gets user data of the authenticated user + * security: + * - authenticated: [] + * responses: + * 200: + * description: The user data of the authenticated user + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiSuccessV1' + * - type: object + * properties: + * name: + * type: string + * username: + * type: string + * nickname: + * type: string + * emails: + * type: array + * items: + * type: object + * properties: + * address: + * type: string + * verified: + * type: boolean + * email: + * type: string + * status: + * $ref: '#/components/schemas/UserStatus' + * statusDefault: + * $ref: '#/components/schemas/UserStatus' + * statusText: + * $ref: '#/components/schemas/UserStatus' + * statusConnection: + * $ref: '#/components/schemas/UserStatus' + * bio: + * type: string + * avatarOrigin: + * type: string + * enum: [none, local, upload, url] + * utcOffset: + * type: number + * language: + * type: string + * settings: + * type: object + * properties: + * preferences: + * type: object + * enableAutoAway: + * type: boolean + * idleTimeLimit: + * type: number + * roles: + * type: array + * active: + * type: boolean + * defaultRoom: + * type: string + * customFields: + * type: array + * requirePasswordChange: + * type: boolean + * requirePasswordChangeReason: + * type: string + * services: + * type: object + * properties: + * github: + * type: object + * gitlab: + * type: object + * password: + * type: object + * properties: + * exists: + * type: boolean + * totp: + * type: object + * properties: + * enabled: + * type: boolean + * email2fa: + * type: object + * properties: + * enabled: + * type: boolean + * statusLivechat: + * type: string + * enum: [available, 'not-available'] + * banners: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * title: + * type: string + * text: + * type: string + * textArguments: + * type: array + * items: {} + * modifiers: + * type: array + * items: + * type: string + * infoUrl: + * type: string + * oauth: + * type: object + * properties: + * authorizedClients: + * type: array + * items: + * type: string + * _updatedAt: + * type: string + * format: date-time + * avatarETag: + * type: string + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.get( + 'me', + { + authRequired: true, + userWithoutUsername: true, + response: { + 200: ajv.compile(meSuccessResponseSchema), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userFields = { ...getBaseUserFields(), services: 1 }; + const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; + + return API.v1.success(await getUserInfo(user)); + }, +); + +let onlineCache = 0; +let onlineCacheDate = 0; +const cacheInvalid = 60000; // 1 minute + +const shieldSvgResponseSchema = ajv.compile({ + type: 'string', + description: 'SVG image markup', +}); + +API.v1.get( + 'shield.svg', + { + authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: 60, + intervalTimeInMS: 60000, + }, + query: isShieldSvgProps, + response: { + 200: shieldSvgResponseSchema, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { type, icon } = this.queryParams; + let { channel, name } = this.queryParams; + if (!settings.get('API_Enable_Shields')) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/shield.svg', + }); + } + + const types = settings.get('API_Shield_Types'); + if ( + type && + types !== '*' && + !types + .split(',') + .map((t: string) => t.trim()) + .includes(type) + ) { + throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { + route: '/api/v1/shield.svg', + }); + } + const hideIcon = icon === 'false'; + if (hideIcon && !name?.trim()) { + return API.v1.failure('Name cannot be empty when icon is hidden'); + } + + let text; + let backgroundColor = '#4c1'; + switch (type) { + case 'online': + if (Date.now() - onlineCacheDate > cacheInvalid) { + onlineCache = await Users.countUsersNotOffline(); + onlineCacheDate = Date.now(); + } + + text = `${onlineCache} ${i18n.t('Online')}`; + break; + case 'channel': + if (!channel) { + return API.v1.failure('Shield channel is required for type "channel"'); + } + + text = `#${channel}`; + break; + case 'user': + if (settings.get('API_Shield_user_require_auth') && !this.user) { + return API.v1.failure('You must be logged in to do this.'); + } + const user = await getUserFromParams(this.queryParams); + + // Respect the server's choice for using their real names or not + if (user.name && settings.get('UI_Use_Real_Name')) { + text = `${user.name}`; + } else { + text = `@${user.username}`; + } + + switch (user.status) { + case 'online': + backgroundColor = '#1fb31f'; + break; + case 'away': + backgroundColor = '#dc9b01'; + break; + case 'busy': + backgroundColor = '#bc2031'; + break; + case 'offline': + backgroundColor = '#a5a1a1'; + } + break; + default: + text = i18n.t('Join_Chat').toUpperCase(); + } + + const iconSize = hideIcon ? 7 : 24; + const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; + const rightSize = text.length * 6 + 20; + const width = leftSize + rightSize; + const height = 20; + + channel = escapeHTML(channel); + text = escapeHTML(text); + name = escapeHTML(name); + + const svgBody = ` + + + + + + + + + + + + + + ${hideIcon ? '' : ``} + + ${ + name + ? `${name} + ${name}` + : '' + } + ${text} + ${text} + + + ` + .trim() + .replace(/\>[\s]+\<'); + + return { + statusCode: 200 as const, + body: svgBody, + headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, + }; + }, +); + +const spotlightResponseSchema = ajv.compile<{ + users: (Pick & + Partial> & { + nickname?: string; + outside?: boolean; + })[]; + rooms: Pick[]; +}>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + status: { type: 'string' }, + statusText: { type: 'string' }, + avatarETag: { type: 'string' }, + }, + required: ['_id', 'name', 'username'], + additionalProperties: true, + }, + }, + rooms: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + t: { type: 'string' }, + name: { type: 'string' }, + lastMessage: { $ref: '#/components/schemas/IMessage' }, + }, + required: ['_id', 't', 'name'], + additionalProperties: true, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'rooms', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'spotlight', + { + // DDP `spotlight` accepts anonymous calls (Accounts_AllowAnonymousRead). + // Keep parity so anonymous-user / embedded-layout flows can still + // resolve a public channel through the navbar search. + authRequired: false, + rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 }, + query: isSpotlightProps, + response: { + 200: spotlightResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { query, usernames, type, rid } = this.queryParams; + + const result = await spotlightMethod({ + text: query, + userId: this.userId, + usernames: parseSpotlightUsernames(usernames), + type: parseSpotlightType(type), + rid, + }); + + return API.v1.success(result); + }, +); + +const directoryResponseSchema = ajv.compile<{ + result: (IDirectoryUserResult | IDirectoryChannelResult)[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + result: { + type: 'array', + items: { + oneOf: [{ $ref: '#/components/schemas/IDirectoryUserResult' }, { $ref: '#/components/schemas/IDirectoryChannelResult' }], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'directory', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 }, + query: isDirectoryProps, + response: { + 200: directoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + const { text, type, workspace = 'local' } = this.queryParams; + + const filter = { + ...(query ? { ...query } : {}), + ...(text ? { text } : {}), + ...(type ? { type } : {}), + ...(workspace ? { workspace } : {}), + }; + + if (sort && Object.keys(sort).length > 1) { + return API.v1.failure('This method support only one "sort" parameter'); + } + const sortBy = sort ? Object.keys(sort)[0] : undefined; + const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc'; + + const user = await Users.findOneById(this.userId, { projection: { __rooms: 1, roles: 1 } }); + const result = await browseChannelsMethod( + { + ...filter, + sortBy, + sortDirection, + offset: Math.max(0, offset), + limit: Math.max(0, count), + }, + user, + ); + + if (!result) { + return API.v1.failure('Please verify the parameters'); + } + return API.v1.success({ + result: result.results as (IDirectoryUserResult | IDirectoryChannelResult)[], + count: result.results.length, + offset, + total: result.total, + }); + }, +); + +const pwGetPolicyResponseSchema = ajv.compile<{ enabled: boolean; policy: [string, Record?][] }>({ + type: 'object', + properties: { + enabled: { type: 'boolean' }, + policy: { type: 'array', items: { type: 'array' } }, + }, + additionalProperties: true, +}); + +API.v1.get( + 'pw.getPolicy', + { + authRequired: false, + response: { + 200: pwGetPolicyResponseSchema, + }, + }, + function action() { + return API.v1.success(passwordPolicy.getPasswordPolicy()); + }, +); + +/** + * @openapi + * /api/v1/stdout.queue: + * get: + * description: Retrieves last 1000 lines of server logs + * security: + * - authenticated: ['view-logs'] + * responses: + * 200: + * description: The user data of the authenticated user + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ApiSuccessV1' + * - type: object + * properties: + * queue: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * string: + * type: string + * ts: + * type: string + * format: date-time + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Endpoints { + 'method.call/:method': { + POST: (params: { method: string; args: any[] }) => any; + }; + 'method.callAnon/:method': { + POST: (params: { method: string; args: any[] }) => any; + }; + } +} + +const methodCallResponseSchema = ajv.compile<{ message: string }>({ + type: 'object', + properties: { message: { type: 'string' }, success: { type: 'boolean', enum: [true] } }, + required: ['message'], + additionalProperties: false, +}); + +const methodCallErrorResponseSchema = ajv.compile<{ message: string }>({ + type: 'object', + oneOf: [ + { + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [false] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }, + { + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + stack: { type: 'string' }, + details: { anyOf: [{ type: 'string' }, { type: 'object' }] }, + }, + required: ['success'], + additionalProperties: false, + }, + ], +}); + +const mountResult = ({ + id, + error, + result, +}: { + id: string; + error?: unknown; + result?: unknown; +}): { + message: string; +} => ({ + message: EJSON.stringify({ + msg: 'result', + id, + error: error as any, + result: result as any, + }), +}); + +// had to create two different endpoints for authenticated and non-authenticated calls +// because restivus does not provide 'this.userId' if 'authRequired: false' +API.v1.post( + 'method.call/:method', + { + authRequired: true, + userWithoutUsername: true, + rateLimiterOptions: false, + body: isMeteorCall, + applyMeteorContext: true, + response: { + 200: methodCallResponseSchema, + 400: methodCallErrorResponseSchema, + 401: validateUnauthorizedErrorResponse, + 429: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } }, + required: ['success'], + additionalProperties: true, + }), + }, + }, + async function action() { + check(this.bodyParams, { + message: String, + }); + + const data = EJSON.parse(this.bodyParams.message); + + const { method, params, id } = data; + + const connectionId = + this.token || + crypto + .createHash('sha256') + .update((this.requestIp ?? '') + this.user._id) + .digest('hex'); + + const rateLimiterInput = { + userId: this.userId, + clientAddress: this.requestIp, + type: 'method', + name: method, + connectionId, + }; + + try { + DDPRateLimiter._increment(rateLimiterInput); + const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { + timeToReset: rateLimitResult.timeToReset, + }); + } + + return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); + } catch (err) { + logMethodCallError(method, err); + + return API.v1.failure(mountResult({ id, error: err })); + } + }, +); + +API.v1.post( + 'method.callAnon/:method', + { + authRequired: false, + userWithoutUsername: true, + rateLimiterOptions: false, + body: isMeteorCall, + applyMeteorContext: true, + response: { + 200: methodCallResponseSchema, + 400: methodCallErrorResponseSchema, + }, + }, + async function action() { + check(this.bodyParams, { + message: String, + }); + + const data = EJSON.parse(this.bodyParams.message); + + const { method, params, id } = data; + + const connectionId = + this.token || + crypto + .createHash('sha256') + .update(this.requestIp ?? '') + .digest('hex'); + + const rateLimiterInput = { + userId: this.userId || undefined, + clientAddress: this.requestIp, + type: 'method', + name: method, + connectionId, + }; + + try { + DDPRateLimiter._increment(rateLimiterInput); + + const rateLimitResult = DDPRateLimiter._check(rateLimiterInput); + if (!rateLimitResult.allowed) { + throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), { + timeToReset: rateLimitResult.timeToReset, + }); + } + + return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) })); + } catch (err) { + logMethodCallError(method, err); + + return API.v1.failure(mountResult({ id, error: err })); + } + }, +); + +const smtpCheckResponseSchema = ajv.compile<{ isSMTPConfigured: boolean }>({ + type: 'object', + properties: { + isSMTPConfigured: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['isSMTPConfigured', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'smtp.check', + { + authRequired: true, + response: { + 200: smtpCheckResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + return API.v1.success({ isSMTPConfigured: isSMTPConfigured() }); + }, +); + +const fingerprintResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +/** + * @openapi + * /api/v1/fingerprint: + * post: + * description: Update Fingerprint definition as a new workspace or update of configuration + * security: + * $ref: '#/security/authenticated' + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * setDeploymentAs: + * type: string + * example: | + * { + * "setDeploymentAs": "new-workspace" + * } + * responses: + * 200: + * description: Workspace successfully configured + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.post( + 'fingerprint', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + body: isFingerprintProps, + response: { + 200: fingerprintResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + check(this.bodyParams, { + setDeploymentAs: String, + }); + + const settingsIds: string[] = []; + + if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await WorkspaceCredentials.removeAllCredentials(); + + settingsIds.push( + 'Cloud_Service_Agree_PrivacyTerms', + 'Cloud_Workspace_Id', + 'Cloud_Workspace_Name', + 'Cloud_Workspace_Client_Id', + 'Cloud_Workspace_Client_Secret', + 'Cloud_Workspace_Client_Secret_Expires_At', + 'Cloud_Workspace_Registration_Client_Uri', + 'Cloud_Workspace_PublicKey', + 'Cloud_Workspace_License', + 'Cloud_Workspace_Had_Trial', + 'uniqueID', + ); + } + + settingsIds.push('Deployment_FingerPrint_Verified'); + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + + const promises = settingsIds.map((settingId) => { + if (settingId === 'uniqueID') { + return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID()); + } + + if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') { + return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0)); + } + + if (settingId === 'Deployment_FingerPrint_Verified') { + return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true); + } + + return resetAuditedSettingByUser({ + _id: this.userId, + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + })(Settings.resetValueById, settingId); + }); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]); + } + }); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/moderation.ts b/apps/meteor/server/api/v1/moderation.ts new file mode 100644 index 0000000..5980d12 --- /dev/null +++ b/apps/meteor/server/api/v1/moderation.ts @@ -0,0 +1,654 @@ +import type { IModerationAudit, IModerationReport, IUser, IUserEmail, UserReport } from '@rocket.chat/core-typings'; +import { ModerationReports, Users } from '@rocket.chat/models'; +import { + ajv, + isReportHistoryProps, + isArchiveReportProps, + isReportInfoParams, + isGetUserReportsParams, + isModerationReportUserPost, + isModerationDeleteMsgHistoryParams, + isReportsByMsgIdParams, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; + +import { deleteReportedMessages } from '../../lib/moderation/deleteReportedMessages'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +type ReportMessage = Pick; + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a +// relaxed inline schema here that accepts `ts` as a string. +const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + reports: { + type: 'array', + items: { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + message: { type: 'string' }, + msgId: { type: 'string' }, + ts: { type: 'string' }, + rooms: { type: 'array', items: { type: 'object' } }, + roomIds: { type: 'array', items: { type: 'string' } }, + count: { type: 'number' }, + isUserDeleted: { type: 'boolean' }, + }, + required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a +// relaxed inline schema here that accepts `ts` as a string. +const paginatedUserReportsResponseSchema = ajv.compile<{ + reports: (Pick & { count: number })[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + reports: { + type: 'array', + items: { + type: 'object', + properties: { + reportedUser: { type: 'object' }, + ts: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['reportedUser', 'ts', 'count'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const reportedMessagesResponseSchema = ajv.compile<{ + user: Pick | null; + messages: Pick[]; + count: number; + total: number; + offset: number; +}>({ + type: 'object', + properties: { + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, + messages: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + total: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'messages', 'count', 'total', 'offset', 'success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a +// relaxed inline schema here that accepts `ts` as a string. +const reportsByUserIdResponseSchema = ajv.compile<{ + user: Pick | null; + reports: IModerationReport[]; + count: number; + total: number; + offset: number; +}>({ + type: 'object', + properties: { + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + avatarETag: { type: 'string' }, + active: { type: 'boolean' }, + roles: { type: 'array', items: { type: 'string' } }, + emails: { type: 'array', items: { type: 'object' } }, + createdAt: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, + reports: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + total: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'reports', 'count', 'total', 'offset', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a +// relaxed inline schema here that accepts `ts` as a string. +const reportInfoResponseSchema = ajv.compile<{ report: IModerationReport | null }>({ + type: 'object', + properties: { + report: { type: ['object', 'null'] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['report', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'moderation.reportsByUsers', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isReportHistoryProps, + response: { + 200: paginatedReportsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const escapedSelector = escapeRegExp(selector); + + const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'moderation.userReports', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isReportHistoryProps, + response: { + 200: paginatedUserReportsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const escapedSelector = escapeRegExp(selector); + + const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'moderation.user.reportedMessages', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isGetUserReportsParams, + response: { + 200: reportedMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, selector = '' } = this.queryParams; + + const { sort } = await this.parseJsonQuery(); + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById>(userId, { + projection: { _id: 1, username: 1, name: 1 }, + }); + + const escapedSelector = escapeRegExp(selector); + + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const uniqueMessages: ReportMessage[] = []; + const visited = new Set(); + for (const report of reports) { + if (visited.has(report.message._id)) { + continue; + } + visited.add(report.message._id); + uniqueMessages.push(report); + } + + return API.v1.success({ + user, + messages: uniqueMessages, + count: reports.length, + total, + offset, + }); + }, +); + +API.v1.get( + 'moderation.user.reportsByUserId', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isGetUserReportsParams, + response: { + 200: reportsByUserIdResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, selector = '' } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { + projection: { + _id: 1, + username: 1, + name: 1, + avatarETag: 1, + active: 1, + roles: 1, + emails: 1, + createdAt: 1, + }, + }); + + const escapedSelector = escapeRegExp(selector); + const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const emailSet = new Map(); + + reports.forEach((report) => { + const email = report.reportedUser?.emails?.[0]; + if (email) { + emailSet.set(email.address, email); + } + }); + if (user) { + user.emails = Array.from(emailSet.values()); + } + + return API.v1.success({ + user, + reports, + count: reports.length, + total, + offset, + }); + }, +); + +API.v1.post( + 'moderation.user.deleteReportedMessages', + { + authRequired: true, + permissionsRequired: ['manage-moderation-actions'], + body: isModerationDeleteMsgHistoryParams, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, reason } = this.bodyParams; + + const sanitizedReason = reason?.trim() ? reason : 'No reason provided'; + + const { user: moderator } = this; + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', { + offset, + count, + sort: { ts: -1 }, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + if (total === 0) { + return API.v1.failure('No reported messages found for this user.'); + } + + await deleteReportedMessages( + messages.map((message) => message.message), + moderator, + ); + + await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages'); + + return API.v1.success(); + }, +); + +API.v1.post( + 'moderation.dismissReports', + { + authRequired: true, + permissionsRequired: ['manage-moderation-actions'], + body: isArchiveReportProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, msgId, reason, action: actionParam } = this.bodyParams; + + if (userId) { + const report = await ModerationReports.findOne({ 'message.u._id': userId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); + if (!report) { + return API.v1.failure('no-reports-found'); + } + } + + if (msgId) { + const report = await ModerationReports.findOne({ 'message._id': msgId, '_hidden': { $ne: true } }, { projection: { _id: 1 } }); + if (!report) { + return API.v1.failure('no-reports-found'); + } + } + + const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + if (userId) { + await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action); + } else { + await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action); + } + + return API.v1.success(); + }, +); + +API.v1.post( + 'moderation.dismissUserReports', + { + authRequired: true, + permissionsRequired: ['manage-moderation-actions'], + body: isArchiveReportProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, reason, action: actionParam } = this.bodyParams; + + if (!userId) { + return API.v1.failure('error-user-id-param-not-provided'); + } + + const sanitizedReason: string = reason ?? 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); + + return API.v1.success(); + }, +); + +// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema. +// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time") +// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1". +// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB +// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a +// relaxed inline schema here that accepts `ts` as a string. +const reportsByMsgIdResponseSchema = ajv.compile<{ + reports: Pick[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + reports: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['reports', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'moderation.reports', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isReportsByMsgIdParams, + response: { + 200: reportsByMsgIdResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { msgId } = this.queryParams; + + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { selector = '' } = this.queryParams; + + const escapedSelector = escapeRegExp(selector); + + const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, escapedSelector, { count, sort, offset }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + reports, + count: reports.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'moderation.reportInfo', + { + authRequired: true, + permissionsRequired: ['view-moderation-console'], + query: isReportInfoParams, + response: { + 200: reportInfoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { reportId } = this.queryParams; + + const report = await ModerationReports.findOneById(reportId); + + if (!report) { + return API.v1.failure('error-report-not-found'); + } + + return API.v1.success({ report }); + }, +); + +API.v1.post( + 'moderation.reportUser', + { + authRequired: true, + body: isModerationReportUserPost, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId, description } = this.bodyParams; + + const { + user: { _id, name, username, createdAt }, + } = this; + + const reportedUser = await Users.findOneById(userId, { + projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 }, + }); + + if (!reportedUser) { + return API.v1.failure('Invalid user id provided.'); + } + + await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt }); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/oauthapps.ts b/apps/meteor/server/api/v1/oauthapps.ts new file mode 100644 index 0000000..312920d --- /dev/null +++ b/apps/meteor/server/api/v1/oauthapps.ts @@ -0,0 +1,296 @@ +import type { IOAuthApps } from '@rocket.chat/core-typings'; +import { OAuthApps } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { addOAuthApp } from '../../lib/auth/oauth2-server/addOAuthApp'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { deleteOAuthApp } from '../../meteor-methods/auth/deleteOAuthApp'; +import { updateOAuthApp } from '../../meteor-methods/auth/updateOAuthApp'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type DeleteOAuthAppParams = { + appId: string; +}; + +const DeleteOAuthAppParamsSchema = { + type: 'object', + properties: { + appId: { + type: 'string', + }, + }, + required: ['appId'], + additionalProperties: false, +}; + +const isDeleteOAuthAppParams = ajv.compile(DeleteOAuthAppParamsSchema); + +export type OauthAppsAddParams = { + name: string; + active: boolean; + redirectUri: string; +}; + +const OauthAppsAddParamsSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + active: { + type: 'boolean', + }, + redirectUri: { + type: 'string', + }, + }, + required: ['name', 'active', 'redirectUri'], + additionalProperties: false, +}; + +const isOauthAppsAddParams = ajv.compile(OauthAppsAddParamsSchema); + +type UpdateOAuthAppParams = { + appId: string; + name: string; + active: boolean; + clientId?: string | undefined; + clientSecret?: string | undefined; + redirectUri: string; +}; + +const UpdateOAuthAppParamsSchema = { + type: 'object', + properties: { + appId: { + type: 'string', + }, + name: { + type: 'string', + }, + active: { + type: 'boolean', + }, + redirectUri: { + type: 'string', + }, + }, + required: ['appId', 'name', 'active', 'redirectUri'], + additionalProperties: false, +}; + +const isUpdateOAuthAppParams = ajv.compile(UpdateOAuthAppParamsSchema); + +type OauthAppsGetParams = { clientId: string } | { _id: string }; + +const oauthAppsGetParamsSchema = { + oneOf: [ + { + type: 'object', + properties: { + _id: { + type: 'string', + }, + }, + required: ['_id'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + clientId: { + type: 'string', + }, + }, + required: ['clientId'], + additionalProperties: false, + }, + ], +}; + +const isOauthAppsGetParams = ajvQuery.compile(oauthAppsGetParamsSchema); + +const oauthAppsEndpoints = API.v1 + .get( + 'oauth-apps.list', + { + authRequired: true, + query: ajvQuery.compile<{ uid?: string }>({ + type: 'object', + properties: { + uid: { + type: 'string', + }, + }, + additionalProperties: false, + }), + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ oauthApps: IOAuthApps[] }>({ + type: 'object', + properties: { + oauthApps: { + type: 'array', + items: { + $ref: '#/components/schemas/IOAuthApps', + }, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['oauthApps', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + return API.v1.success({ + oauthApps: await OAuthApps.find().toArray(), + }); + }, + ) + .post( + 'oauth-apps.delete', + { + authRequired: true, + body: isDeleteOAuthAppParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ type: 'boolean' }), + }, + }, + + async function action() { + const { appId } = this.bodyParams; + + const result = await deleteOAuthApp(this.userId, appId); + + return API.v1.success(result); + }, + ) + .post( + 'oauth-apps.create', + { + authRequired: true, + body: isOauthAppsAddParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ application: IOAuthApps }>({ + type: 'object', + properties: { + application: { $ref: '#/components/schemas/IOAuthApps' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['application', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + const application = await addOAuthApp(this.bodyParams, this.userId); + + return API.v1.success({ application }); + }, + ) + .post( + 'oauth-apps.update', + { + authRequired: true, + body: isUpdateOAuthAppParams, + permissionsRequired: ['manage-oauth-apps'], + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + allOf: [ + { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] }, + { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }, + ], + }), + }, + }, + async function action() { + const { appId } = this.bodyParams; + + const result = await updateOAuthApp(this.userId, appId, this.bodyParams); + + return API.v1.success(result); + }, + ) + .get( + 'oauth-apps.get', + { + authRequired: true, + query: isOauthAppsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ oauthApp: IOAuthApps }>({ + type: 'object', + properties: { + oauthApp: { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['oauthApp', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + const isOAuthAppsManager = await hasPermissionAsync(this.user, 'manage-oauth-apps'); + + const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId( + this.queryParams, + !isOAuthAppsManager ? { projection: { clientSecret: 0 } } : {}, + ); + + if (!oauthApp) { + return API.v1.failure('OAuth app not found.'); + } + + return API.v1.success({ + oauthApp, + }); + }, + ); + +export type OauthAppsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends OauthAppsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/agent.ts b/apps/meteor/server/api/v1/omnichannel/agent.ts new file mode 100644 index 0000000..e52e8d4 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/agent.ts @@ -0,0 +1,170 @@ +import type { ILivechatAgent } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { + isGETAgentNextToken, + isPOSTLivechatAgentSaveInfoParams, + isPOSTLivechatAgentStatusProps, + POSTLivechatAgentSaveInfoSuccessResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findRoom, findGuest, findAgent, findOpenRoom } from './lib/livechat'; +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { hasRoleAsync } from '../../../lib/authorization/hasRole'; +import { RoutingManager } from '../../../lib/omnichannel/RoutingManager'; +import { getRequiredDepartment } from '../../../lib/omnichannel/departmentsLib'; +import { saveAgentInfo } from '../../../lib/omnichannel/omni-users'; +import { setUserStatusLivechat, allowAgentChangeServiceStatus } from '../../../lib/omnichannel/utils'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; + +API.v1.addRoute('livechat/agent.info/:rid/:token', { + async get() { + const visitor = await findGuest(this.urlParams.token); + if (!visitor) { + throw new Error('invalid-token'); + } + + const room = await findRoom(this.urlParams.token, this.urlParams.rid); + if (!room) { + throw new Error('invalid-room'); + } + + const agent = room?.servedBy && (await findAgent(room.servedBy._id)); + if (!agent) { + throw new Error('invalid-agent'); + } + + return API.v1.success({ agent }); + }, +}); + +API.v1.addRoute( + 'livechat/agent.next/:token', + { validateParams: isGETAgentNextToken }, + { + async get() { + const { token } = this.urlParams; + const room = await findOpenRoom(token, undefined, this.userId); + if (room) { + return API.v1.success(); + } + + let { department } = this.queryParams; + if (!department) { + const requireDepartment = await getRequiredDepartment(); + if (requireDepartment) { + department = requireDepartment._id; + } + } + + const agentData = await RoutingManager.getNextAgent(department); + if (!agentData) { + throw new Error('agent-not-found'); + } + + const agent = await findAgent(agentData.agentId); + if (!agent) { + throw new Error('invalid-agent'); + } + + return API.v1.success({ agent }); + }, + }, +); + +API.v1.addRoute( + 'livechat/agent.status', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatAgentStatusProps }, + { + async post() { + const { status, agentId: inputAgentId } = this.bodyParams; + + const agentId = inputAgentId || this.userId; + + const agent = await Users.findOneAgentById>(agentId, { + projection: { + status: 1, + statusLivechat: 1, + active: 1, + }, + }); + if (!agent) { + return API.v1.notFound('Agent not found'); + } + + if (!agent.active) { + return API.v1.failure('error-user-deactivated'); + } + + const newStatus: ILivechatAgentStatus = + status || + (agent.statusLivechat === ILivechatAgentStatus.AVAILABLE ? ILivechatAgentStatus.NOT_AVAILABLE : ILivechatAgentStatus.AVAILABLE); + if (newStatus === agent.statusLivechat) { + return API.v1.success({ status: agent.statusLivechat }); + } + + const canChangeStatus = await allowAgentChangeServiceStatus(newStatus, agentId); + + if (agentId !== this.userId) { + if (!(await hasPermissionAsync(this.user, 'manage-livechat-agents'))) { + return API.v1.forbidden(); + } + + // Silent fail for admins when BH is closed + // Next version we'll update this to return an error + // And update the FE accordingly + if (canChangeStatus) { + await setUserStatusLivechat(agentId, newStatus); + return API.v1.success({ status: newStatus }); + } + + return API.v1.success({ status: agent.statusLivechat }); + } + + if (!canChangeStatus) { + return API.v1.failure('error-business-hours-are-closed'); + } + + await setUserStatusLivechat(agentId, newStatus); + + return API.v1.success({ status: newStatus }); + }, + }, +); + +const livechatAgentsEndpoints = API.v1.post( + 'livechat/agents.saveInfo', + { + response: { + 200: POSTLivechatAgentSaveInfoSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-livechat-agents'], + body: isPOSTLivechatAgentSaveInfoParams, + }, + async function action() { + const { agentId, agentData, agentDepartments } = this.bodyParams; + + if (!(await hasRoleAsync(agentId, 'livechat-agent'))) { + return API.v1.failure('error-user-is-not-agent'); + } + + await saveAgentInfo(agentId, agentData, agentDepartments); + + return API.v1.success(); + }, +); + +type LivechatAgentsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatAgentsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts b/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts new file mode 100644 index 0000000..c842070 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/agentDepartments.ts @@ -0,0 +1,19 @@ +import { isGETLivechatAgentsAgentIdDepartmentsParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findAgentDepartments } from './lib/agents'; + +API.v1.addRoute( + 'livechat/agents/:agentId/departments', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatAgentsAgentIdDepartmentsParams }, + { + async get() { + const departments = await findAgentDepartments({ + enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true', + agentId: this.urlParams.agentId, + }); + + return API.v1.success(departments); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/appearance.ts b/apps/meteor/server/api/v1/omnichannel/appearance.ts new file mode 100644 index 0000000..b43f543 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/appearance.ts @@ -0,0 +1,140 @@ +import type { ISettingSelectOption } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; +import { isTruthy } from '@rocket.chat/tools'; + +import { API } from '../..'; +import { findAppearance } from './lib/appearance'; +import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; +import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates'; + +API.v1.addRoute( + 'livechat/appearance', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: { + POST: isPOSTLivechatAppearanceParams, + }, + }, + { + async get() { + const { appearance } = await findAppearance(); + + return API.v1.success({ + appearance, + }); + }, + async post() { + const settings = this.bodyParams; + + const validSettingList = [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Livechat_show_agent_info', + 'Livechat_show_agent_email', + 'Livechat_display_offline_form', + 'Livechat_offline_form_unavailable', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_email', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_registration_form', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_hide_watermark', + 'Livechat_background', + 'Livechat_widget_position', + 'Livechat_hide_system_messages', + 'Omnichannel_allow_visitors_to_close_conversation', + 'Livechat_hide_expand_chat', + ]; + + const valid = settings.every((setting) => validSettingList.includes(setting._id)); + + if (!valid) { + throw new Error('invalid-setting'); + } + + const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } }) + .map((dbSetting) => { + const setting = settings.find(({ _id }) => _id === dbSetting._id); + if (!setting || dbSetting.value === setting.value) { + return; + } + + if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) { + return; + } + + switch (dbSetting?.type) { + case 'boolean': + return { + _id: dbSetting._id, + value: setting.value === 'true' || setting.value === true, + }; + case 'int': + return { + _id: dbSetting._id, + value: coerceInt(setting.value), + }; + default: + return { + _id: dbSetting._id, + value: setting?.value, + }; + } + }) + .toArray(); + + const eligibleSettings = dbSettings.filter(isTruthy); + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username, + ip: this.requestIp, + useragent: this.request.headers.get('user-agent') || '', + }); + + const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(eligibleSettings[index]._id); + } + }); + + return API.v1.success(); + }, + }, +); + +function validateValues(values: string[], allowedValues: ISettingSelectOption[] = []): boolean { + return values.every((value) => allowedValues.some((allowedValue) => allowedValue.key === value)); +} + +function coerceInt(value: string | number | boolean | string[]): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'boolean') { + return 0; + } + + if (Array.isArray(value)) { + return 0; + } + + const parsedValue = parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + return 0; + } + + return parsedValue; +} diff --git a/apps/meteor/server/api/v1/omnichannel/businessHours.ts b/apps/meteor/server/api/v1/omnichannel/businessHours.ts new file mode 100644 index 0000000..b851a24 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/businessHours.ts @@ -0,0 +1,77 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { + isGETBusinessHourParams, + isPOSTLivechatBusinessHoursSaveParams, + isPOSTLivechatBusinessHoursRemoveParams, + POSTLivechatBusinessHoursRemoveSuccessResponse, + POSTLivechatBusinessHoursSaveSuccessResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { businessHourManager } from '../../../lib/omnichannel/business-hour'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; +import { findLivechatBusinessHour } from './lib/businessHours'; + +API.v1.addRoute( + 'livechat/business-hour', + { authRequired: true, permissionsRequired: ['view-livechat-business-hours'], validateParams: isGETBusinessHourParams }, + { + async get() { + const { _id, type } = this.queryParams; + const { businessHour } = await findLivechatBusinessHour(_id, type); + return API.v1.success({ + businessHour, + }); + }, + }, +); + +const livechatBusinessHoursEndpoints = API.v1 + .post( + 'livechat/business-hours.save', + { + response: { + 200: POSTLivechatBusinessHoursSaveSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + authRequired: true, + body: isPOSTLivechatBusinessHoursSaveParams, + }, + async function action() { + const params = this.bodyParams; + + // TODO: Remove typecasting after refactoring saveBusinessHour logic with proper type logic. See: CORE-1552 + const result = await businessHourManager.saveBusinessHour(params as unknown as ILivechatBusinessHour); + + return API.v1.success(result); + }, + ) + .post( + 'livechat/business-hours.remove', + { + response: { + 200: POSTLivechatBusinessHoursRemoveSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + authRequired: true, + body: isPOSTLivechatBusinessHoursRemoveParams, + }, + async function action() { + const { _id, type } = this.bodyParams; + + await businessHourManager.removeBusinessHourByIdAndType(_id, type); + + return API.v1.success(); + }, + ); + +type LivechatBusinessHoursEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatBusinessHoursEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/config.ts b/apps/meteor/server/api/v1/omnichannel/config.ts new file mode 100644 index 0000000..399b1f9 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/config.ts @@ -0,0 +1,67 @@ +import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings'; +import mem from 'mem'; + +import { API } from '../..'; +import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from './lib/livechat'; +import { RoutingManager } from '../../../lib/omnichannel/RoutingManager'; +import { online } from '../../../lib/omnichannel/service-status'; +import { settings as serverSettings } from '../../../settings'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; + +const cachedSettings = mem(settings, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 1000, + cacheKey: JSON.stringify, +}); + +API.v1.addRoute( + 'livechat/config', + { validateParams: isGETLivechatConfigParams }, + { + async get() { + const enabled = serverSettings.get('Livechat_enabled'); + + if (!enabled) { + return API.v1.success({ config: { enabled: false } }); + } + + const { token, department, businessUnit } = this.queryParams; + const [config, status, guest] = await Promise.all([ + cachedSettings({ businessUnit }), + online(department), + token ? findGuestWithoutActivity(token) : null, + ]); + + const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined; + const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null; + const extraInfoPromise = getExtraConfigInfo({ room }); + + const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]); + + return API.v1.success({ + config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) }, + }); + }, + }, +); + +const livechatConfigEndpoints = API.v1.get( + 'livechat/config/routing', + { + response: { + 200: GETLivechatConfigRouting, + }, + authRequired: true, + }, + async function action() { + const config = RoutingManager.getConfig(); + + return API.v1.success({ config }); + }, +); + +type LivechatConfigEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatConfigEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/contact.ts b/apps/meteor/server/api/v1/omnichannel/contact.ts new file mode 100644 index 0000000..5737820 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/contact.ts @@ -0,0 +1,268 @@ +import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { + isPOSTOmnichannelContactsProps, + isPOSTUpdateOmnichannelContactsProps, + isGETOmnichannelContactsProps, + isGETOmnichannelContactHistoryProps, + isGETOmnichannelContactsChannelsProps, + isGETOmnichannelContactsSearchProps, + isGETOmnichannelContactsCheckExistenceProps, + isPOSTOmnichannelContactsConflictsProps, + isPOSTOmnichannelContactDeleteProps, + POSTOmnichannelContactDeleteSuccessSchema, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp, removeEmpty } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { API } from '../..'; +import { createContact } from '../../../lib/omnichannel/contacts/createContact'; +import { disableContactById } from '../../../lib/omnichannel/contacts/disableContact'; +import { getContactChannelsGrouped } from '../../../lib/omnichannel/contacts/getContactChannelsGrouped'; +import { getContactHistory } from '../../../lib/omnichannel/contacts/getContactHistory'; +import { getContacts } from '../../../lib/omnichannel/contacts/getContacts'; +import { registerContact } from '../../../lib/omnichannel/contacts/registerContact'; +import { resolveContactConflicts } from '../../../lib/omnichannel/contacts/resolveContactConflicts'; +import { updateContact } from '../../../lib/omnichannel/contacts/updateContact'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'omnichannel/contact', + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, + { + async post() { + check(this.bodyParams, { + _id: Match.Maybe(String), + token: String, + name: String, + email: Match.Maybe(String), + phone: Match.Maybe(String), + username: Match.Maybe(String), + customFields: Match.Maybe(Object), + contactManager: Match.Maybe({ + username: String, + }), + }); + + const contact = await registerContact(this.bodyParams, this.userId); + + return API.v1.success({ contact }); + }, + async get() { + check(this.queryParams, { + contactId: String, + }); + + const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId); + + return API.v1.success({ contact }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contact.search', + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, + { + async get() { + check(this.queryParams, { + email: Match.Maybe(String), + phone: Match.Maybe(String), + custom: Match.Maybe(String), + }); + const { email, phone, custom } = this.queryParams; + + let customCF: { [k: string]: string } = {}; + try { + customCF = custom && JSON.parse(custom); + } catch (e) { + throw new Meteor.Error('error-invalid-params-custom'); + } + + if (!email && !phone && !Object.keys(customCF).length) { + throw new Meteor.Error('error-invalid-params'); + } + + const foundCF = await (async () => { + if (!custom) { + return {}; + } + + const cfIds = Object.keys(customCF); + + const customFields = await LivechatCustomField.findMatchingCustomFieldsByIds(cfIds, 'visitor', true, { + projection: { _id: 1 }, + }).toArray(); + + return Object.fromEntries(customFields.map(({ _id }) => [`livechatData.${_id}`, new RegExp(escapeRegExp(customCF[_id]), 'i')])); + })(); + + const contact = await LivechatVisitors.findOneByEmailAndPhoneAndCustomField(email, phone, foundCF); + return API.v1.success({ contact }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts', + { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, + { + async post() { + const contactId = await createContact({ ...this.bodyParams, unknown: false }); + + return API.v1.success({ contactId }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.update', + { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps }, + { + async post() { + const contact = await updateContact(removeEmpty(this.bodyParams)); + + return API.v1.success({ contact }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.conflicts', + { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTOmnichannelContactsConflictsProps }, + { + async post() { + const result = await resolveContactConflicts(removeEmpty(this.bodyParams)); + + return API.v1.success({ result }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.get', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps }, + { + async get() { + const { contactId } = this.queryParams; + + if (!contactId) { + return API.v1.notFound(); + } + + const contact = await LivechatContacts.findOneEnabledById(contactId); + + if (!contact) { + return API.v1.notFound(); + } + + return API.v1.success({ contact }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.search', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsSearchProps }, + { + async get() { + const query = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const result = await getContacts({ ...query, offset, count, sort }); + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.checkExistence', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsCheckExistenceProps }, + { + async get() { + const { contactId, email, phone } = this.queryParams; + + const contact = await LivechatContacts.countByContactInfo({ contactId, email, phone }); + + return API.v1.success({ exists: contact > 0 }); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.history', + { authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps }, + { + async get() { + const { contactId, source } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const history = await getContactHistory({ contactId, source, count, offset, sort }); + + return API.v1.success(history); + }, + }, +); + +API.v1.addRoute( + 'omnichannel/contacts.channels', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsChannelsProps }, + { + async get() { + const { contactId } = this.queryParams; + + const channels = await getContactChannelsGrouped(contactId); + + return API.v1.success({ channels }); + }, + }, +); + +const omnichannelContactsEndpoints = API.v1.post( + 'omnichannel/contacts.delete', + { + response: { + 200: POSTOmnichannelContactDeleteSuccessSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['delete-livechat-contact'], + body: isPOSTOmnichannelContactDeleteProps, + }, + async function action() { + const { contactId } = this.bodyParams; + + try { + await disableContactById(contactId); + return API.v1.success(); + } catch (error) { + if (!(error instanceof Error)) { + return API.v1.failure('error-invalid-contact'); + } + + return API.v1.failure(error.message); + } + }, +); + +type OmnichannelContactsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends OmnichannelContactsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/customField.ts b/apps/meteor/server/api/v1/omnichannel/customField.ts new file mode 100644 index 0000000..3db071f --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/customField.ts @@ -0,0 +1,166 @@ +import { LivechatCustomField } from '@rocket.chat/models'; +import { + isLivechatCustomFieldsProps, + isPOSTLivechatCustomFieldParams, + isPOSTLivechatCustomFieldsParams, + isPOSTLivechatRemoveCustomFields, + isPOSTLivechatSaveCustomFieldsParams, + POSTLivechatRemoveCustomFieldSuccess, + POSTLivechatSaveCustomFieldSuccess, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { setCustomFields, setMultipleCustomFields } from '../../../lib/omnichannel/custom-fields'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; +import { findLivechatCustomFields, findCustomFieldById } from './lib/customFields'; +import { findGuest } from './lib/livechat'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/custom.field', + { validateParams: isPOSTLivechatCustomFieldParams }, + { + async post() { + const { token, key, value, overwrite } = this.bodyParams; + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + await setCustomFields({ token, key, value, overwrite }); + + return API.v1.success({ field: { key, value, overwrite } }); + }, + }, +); + +API.v1.addRoute( + 'livechat/custom.fields', + { validateParams: isPOSTLivechatCustomFieldsParams }, + { + async post() { + const { token } = this.bodyParams; + const visitor = await findGuest(token); + if (!visitor) { + throw new Error('invalid-token'); + } + + const result = await setMultipleCustomFields({ visitor, customFields: this.bodyParams.customFields }); + + return API.v1.success({ + fields: result.map(({ key, value, overwrite }) => ({ Key: key, value, overwrite })), + }); + }, + }, +); + +API.v1.addRoute( + 'livechat/custom-fields', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatCustomFieldsProps }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { text } = this.queryParams; + + const customFields = await findLivechatCustomFields({ + text, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(customFields); + }, + }, +); + +API.v1.addRoute( + 'livechat/custom-fields/:_id', + { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + async get() { + const { customField } = await findCustomFieldById({ customFieldId: this.urlParams._id }); + + return API.v1.success({ + customField, + }); + }, + }, +); + +const livechatCustomFieldsEndpoints = API.v1 + .post( + 'livechat/custom-fields.save', + { + response: { + 200: POSTLivechatSaveCustomFieldSuccess, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action? + body: isPOSTLivechatSaveCustomFieldsParams, + }, + async function action() { + const { customFieldId, customFieldData } = this.bodyParams; + + if (customFieldId) { + const customField = await LivechatCustomField.findOneById(customFieldId); + if (!customField) { + return API.v1.failure('Custom Field Not found'); + } + } + + if (!customFieldId) { + const customField = await LivechatCustomField.findOneById(customFieldData.field); + if (customField) { + return API.v1.failure('Custom Field already exists'); + } + } + + const { field, label, scope, visibility, ...extraData } = customFieldData; + const result = await LivechatCustomField.createOrUpdateCustomField(customFieldId, field, label, scope, visibility, { + ...extraData, + }); + + return API.v1.success({ customField: result }); + }, + ) + .post( + 'livechat/custom-fields.delete', + { + response: { + 200: POSTLivechatRemoveCustomFieldSuccess, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action? + body: isPOSTLivechatRemoveCustomFields, + }, + async function action() { + const { customFieldId } = this.bodyParams; + + const result = await LivechatCustomField.removeById(customFieldId); + if (result.deletedCount === 0) { + return API.v1.failure('Custom field not found'); + } + + return API.v1.success(); + }, + ); + +type LivechatCustomFieldsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatCustomFieldsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/dashboards.ts b/apps/meteor/server/api/v1/omnichannel/dashboards.ts new file mode 100644 index 0000000..66a5e8d --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/dashboards.ts @@ -0,0 +1,296 @@ +import { OmnichannelAnalytics } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import { + isGETDashboardTotalizerParams, + isGETDashboardsAgentStatusParams, + isGETLivechatAnalyticsDashboardsChartDataParams, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + GETLivechatAnalyticsDashboardsChartDataSuccessSchema, +} from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { + getProductivityMetricsAsyncCached, + getConversationsMetricsAsyncCached, + getAgentsProductivityMetricsAsyncCached, + getChatsMetricsAsyncCached, + findAllChatsStatusAsyncCached, + findAllChatMetricsByAgentAsyncCached, + findAllAgentsStatusAsyncCached, + findAllChatMetricsByDepartmentAsyncCached, + findAllResponseTimeMetricsAsyncCached, +} from '../../../lib/omnichannel/analytics/dashboards'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; + +API.v1.addRoute( + 'livechat/analytics/dashboards/conversation-totalizers', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: isGETDashboardTotalizerParams, + }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + + const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + + const totalizers = await getConversationsMetricsAsyncCached({ start, end, departmentId, user }); + return API.v1.success(totalizers); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/agents-productivity-totalizers', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + + const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + + const totalizers = await getAgentsProductivityMetricsAsyncCached({ start, end, departmentId, user }); + return API.v1.success(totalizers); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/chats-totalizers', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + const startDate = new Date(start); + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + const endDate = new Date(end); + + const totalizers = await getChatsMetricsAsyncCached({ start: startDate, end: endDate, departmentId }); + return API.v1.success(totalizers); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/productivity-totalizers', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + + const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + + const totalizers = await getProductivityMetricsAsyncCached({ start, end, departmentId, user }); + + return API.v1.success(totalizers); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/charts/chats', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + const startDate = new Date(start); + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + const endDate = new Date(end); + + const result = await findAllChatsStatusAsyncCached({ start: startDate, end: endDate, departmentId }); + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/charts/chats-per-agent', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + const startDate = new Date(start); + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + const endDate = new Date(end); + const result = await findAllChatMetricsByAgentAsyncCached({ start: startDate, end: endDate, departmentId }); + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/charts/agents-status', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardsAgentStatusParams }, + { + async get() { + const { departmentId } = this.queryParams; + + const result = await findAllAgentsStatusAsyncCached({ departmentId }); + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/charts/chats-per-department', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + const startDate = new Date(start); + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + const endDate = new Date(end); + + const result = (await findAllChatMetricsByDepartmentAsyncCached({ start: startDate, end: endDate, departmentId })) as { + [k: string]: { open: number; closed: number }; + }; + + return API.v1.success(result); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/dashboards/charts/timings', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams }, + { + async get() { + const { start, end } = this.queryParams; + const { departmentId } = this.queryParams; + + if (isNaN(Date.parse(start))) { + return API.v1.failure('The "start" query parameter must be a valid date.'); + } + const startDate = new Date(start); + + if (isNaN(Date.parse(end))) { + return API.v1.failure('The "end" query parameter must be a valid date.'); + } + const endDate = new Date(end); + + const result = await findAllResponseTimeMetricsAsyncCached({ start: startDate, end: endDate, departmentId }); + + return API.v1.success(result); + }, + }, +); + +const livechatAnalyticsEndpoints = API.v1.get( + 'livechat/analytics/dashboards/charts-data', + { + response: { + 200: GETLivechatAnalyticsDashboardsChartDataSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + query: isGETLivechatAnalyticsDashboardsChartDataParams, + }, + async function action() { + const { chartName, start, end, departmentId } = this.queryParams; + + const chartData = await OmnichannelAnalytics.getAnalyticsChartData({ + daterange: { + from: start, + to: end, + }, + chartOptions: { + name: chartName, + }, + utcOffset: this.user.utcOffset, + executedBy: this.user._id, + departmentId, + }); + + if (!chartData) { + return API.v1.success({ + chartLabel: chartName, + dataLabels: [], + dataPoints: [], + }); + } + + return API.v1.success(chartData); + }, +); + +type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatAnalyticsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/departments.ts b/apps/meteor/server/api/v1/omnichannel/departments.ts new file mode 100644 index 0000000..bb55067 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/departments.ts @@ -0,0 +1,333 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core'; +import { isGETLivechatDepartmentProps, isPOSTLivechatDepartmentProps } from '@rocket.chat/rest-typings'; +import { + isLivechatDepartmentDepartmentIdAgentsGETProps, + isLivechatDepartmentDepartmentIdAgentsPOSTProps, +} from '@rocket.chat/rest-typings/src/v1/omnichannel'; +import { Match, check } from 'meteor/check'; + +import { API } from '../..'; +import { + findDepartments, + findDepartmentById, + findDepartmentsToAutocomplete, + findDepartmentsBetweenIds, + findDepartmentAgents, + findArchivedDepartments, +} from './lib/departments'; +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { + saveDepartment, + archiveDepartment, + unarchiveDepartment, + saveDepartmentAgents, + removeDepartment, +} from '../../../lib/omnichannel/departmentsLib'; +import { settings } from '../../../settings'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/department', + { + authRequired: true, + validateParams: { GET: isGETLivechatDepartmentProps, POST: isPOSTLivechatDepartmentProps }, + permissionsRequired: { + GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' }, + POST: { permissions: ['manage-livechat-departments'], operation: 'hasAll' }, + }, + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const { text, enabled, onlyMyDepartments, excludeDepartmentId, showArchived } = this.queryParams; + + const { departments, total } = await findDepartments({ + userId: this.userId, + text, + enabled: enabled === 'true', + showArchived: showArchived === 'true', + onlyMyDepartments: onlyMyDepartments === 'true', + excludeDepartmentId, + pagination: { + offset, + count, + // IMO, sort type shouldn't be record, but a generic of the model we're trying to sort + // or the form { [k: keyof T]: number | string } + sort: sort as any, + }, + }); + + return API.v1.success({ departments, count: departments.length, offset, total }); + }, + async post() { + check(this.bodyParams, { + department: Object, + agents: Match.Maybe(Array), + departmentUnit: Match.Maybe({ _id: Match.Optional(String) }), + }); + + const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {}; + const { departmentUnit } = this.bodyParams; + const department = await saveDepartment( + this.userId, + null, + this.bodyParams.department as ILivechatDepartment, + agents, + departmentUnit || {}, + ); + + if (department) { + return API.v1.success({ + department, + agents: await LivechatDepartmentAgents.find({ departmentId: department._id }).toArray(), + }); + } + + return API.v1.failure(); + }, + }, +); + +API.v1.addRoute( + 'livechat/department/isDepartmentCreationAvailable', + { + authRequired: true, + permissionsRequired: { + GET: { permissions: ['view-livechat-departments', 'manage-livechat-departments'], operation: 'hasAny' }, + }, + }, + { + async get() { + const available = await isDepartmentCreationAvailable(); + return API.v1.success({ isDepartmentCreationAvailable: available }); + }, + }, +); + +API.v1.addRoute( + 'livechat/department/:_id', + { + authRequired: true, + permissionsRequired: { + GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' }, + PUT: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' }, + DELETE: { permissions: ['manage-livechat-departments', 'remove-livechat-department'], operation: 'hasAny' }, + }, + }, + { + async get() { + check(this.urlParams, { + _id: String, + }); + + const { onlyMyDepartments } = this.queryParams; + + const { department, agents } = await findDepartmentById({ + userId: this.userId, + departmentId: this.urlParams._id, + includeAgents: this.queryParams.includeAgents && this.queryParams.includeAgents === 'true', + onlyMyDepartments: onlyMyDepartments === 'true', + }); + + // TODO: return 404 when department is not found + // Currently, FE relies on the fact that this endpoint returns an empty payload + // to show the "new" view. Returning 404 breaks it + + return API.v1.success({ department, agents }); + }, + async put() { + const permissionToSave = await hasPermissionAsync(this.user, 'manage-livechat-departments'); + const permissionToAddAgents = await hasPermissionAsync(this.user, 'add-livechat-department-agents'); + + check(this.bodyParams, { + department: Object, + agents: Match.Maybe(Array), + departmentUnit: Match.Maybe({ _id: Match.Optional(String) }), + }); + + const { _id } = this.urlParams; + const { department, agents, departmentUnit } = this.bodyParams; + + if (!permissionToSave) { + throw new Error('error-not-allowed'); + } + + const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {}; + await saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {}); + + return API.v1.success({ + department: await LivechatDepartment.findOneById(_id), + agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(), + }); + }, + async delete() { + check(this.urlParams, { + _id: String, + }); + + const isRemoveEnabled = settings.get('Omnichannel_enable_department_removal'); + + if (!isRemoveEnabled) { + return API.v1.failure('error-department-removal-disabled'); + } + + await removeDepartment(this.urlParams._id); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/departments/archived', + { + authRequired: true, + validateParams: { GET: isGETLivechatDepartmentProps }, + permissionsRequired: { + GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' }, + }, + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const { text, onlyMyDepartments, excludeDepartmentId } = this.queryParams; + + const { departments, total } = await findArchivedDepartments({ + userId: this.userId, + text, + onlyMyDepartments: onlyMyDepartments === 'true', + excludeDepartmentId, + pagination: { + offset, + count, + sort: sort as any, + }, + }); + + return API.v1.success({ departments, count: departments.length, offset, total }); + }, + }, +); + +API.v1.addRoute( + 'livechat/department/:_id/archive', + { + authRequired: true, + permissionsRequired: ['manage-livechat-departments'], + }, + { + async post() { + await archiveDepartment(this.urlParams._id); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/department/:_id/unarchive', + { + authRequired: true, + permissionsRequired: ['manage-livechat-departments'], + }, + { + async post() { + await unarchiveDepartment(this.urlParams._id); + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/department.autocomplete', + { authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } }, + { + async get() { + const { selector, onlyMyDepartments, showArchived } = this.queryParams; + if (!selector) { + return API.v1.failure("The 'selector' param is required"); + } + + const parsedSelector = JSON.parse(selector); + + if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) { + apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response); + } + + return API.v1.success( + await findDepartmentsToAutocomplete({ + uid: this.userId, + selector: parsedSelector, + onlyMyDepartments: onlyMyDepartments === 'true', + showArchived: showArchived === 'true', + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/department/:_id/agents', + { + authRequired: true, + permissionsRequired: { + GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' }, + POST: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' }, + }, + validateParams: { GET: isLivechatDepartmentDepartmentIdAgentsGETProps, POST: isLivechatDepartmentDepartmentIdAgentsPOSTProps }, + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const agents = await findDepartmentAgents({ + userId: this.userId, + departmentId: this.urlParams._id, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(agents); + }, + async post() { + await saveDepartmentAgents(this.urlParams._id, this.bodyParams); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/department.listByIds', + { authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } }, + { + async get() { + const { ids } = this.queryParams; + const { fields } = await this.parseJsonQuery(); + if (!ids) { + return API.v1.failure("The 'ids' param is required"); + } + if (!Array.isArray(ids)) { + return API.v1.failure("The 'ids' param must be an array"); + } + + return API.v1.success( + await findDepartmentsBetweenIds({ + ids, + fields, + }), + ); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/index.ts b/apps/meteor/server/api/v1/omnichannel/index.ts new file mode 100644 index 0000000..b5769af --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/index.ts @@ -0,0 +1,28 @@ +import './config'; +import './visitor'; +import './transcript'; +import './offlineMessage'; +import './pageVisited'; +import './agent'; +import './message'; +import './customField'; +import './room'; +import './transfer'; +import './contact'; +import './webhooks'; +import './integration'; +import './statistics'; +import './agentDepartments'; +import './departments'; +import './sms'; +import './users'; +import './upload'; +import './inquiries'; +import './rooms'; +import './appearance'; +import './triggers'; +import './integrations'; +import './visitors'; +import './dashboards'; +import './queue'; +import './businessHours'; diff --git a/apps/meteor/server/api/v1/omnichannel/inquiries.ts b/apps/meteor/server/api/v1/omnichannel/inquiries.ts new file mode 100644 index 0000000..f649a67 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/inquiries.ts @@ -0,0 +1,160 @@ +import { LivechatInquiryStatus } from '@rocket.chat/core-typings'; +import { LivechatInquiry, LivechatDepartment, Users, LivechatRooms } from '@rocket.chat/models'; +import { + isGETLivechatInquiriesListParams, + isPOSTLivechatInquiriesTakeParams, + isGETLivechatInquiriesQueuedForUserParams, + isGETLivechatInquiriesGetOneParams, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + isPOSTLivechatInquiriesReturnAsInquiry, + POSTLivechatInquiriesReturnAsInquirySuccessResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { API } from '../..'; +import { findInquiries, findOneInquiryByRoomId } from './lib/inquiries'; +import { returnRoomAsInquiry } from '../../../lib/omnichannel/rooms'; +import { takeInquiry } from '../../../lib/omnichannel/takeInquiry'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/inquiries.list', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETLivechatInquiriesListParams }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { department } = this.queryParams; + const ourQuery: { status: string; department?: string } = { status: 'queued' }; + if (department) { + const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); + if (departmentFromDB) { + ourQuery.department = departmentFromDB._id; + } + } + // @ts-expect-error - attachments... + const { cursor, totalCount } = LivechatInquiry.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: { + rid: 1, + name: 1, + ts: 1, + status: 1, + department: 1, + }, + }); + + const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + inquiries, + offset, + count: inquiries.length, + total, + }); + }, + }, +); + +API.v1.addRoute( + 'livechat/inquiries.take', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatInquiriesTakeParams }, + { + async post() { + if (this.bodyParams.userId && !(await Users.findOneById(this.bodyParams.userId, { projection: { _id: 1 } }))) { + return API.v1.failure('The user is invalid'); + } + return API.v1.success({ + inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options), + }); + }, + }, +); + +API.v1.addRoute( + 'livechat/inquiries.queuedForUser', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesQueuedForUserParams }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { department } = this.queryParams; + + return API.v1.success( + await findInquiries({ + userId: this.userId, + department, + status: LivechatInquiryStatus.QUEUED, + pagination: { + offset, + count, + sort, + }, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/inquiries.getOne', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesGetOneParams }, + { + async get() { + const { roomId } = this.queryParams; + + return API.v1.success( + await findOneInquiryByRoomId({ + roomId, + }), + ); + }, + }, +); + +const livechatInquiriesEndpoints = API.v1.post( + 'livechat/inquiries.returnAsInquiry', + { + response: { + 200: POSTLivechatInquiriesReturnAsInquirySuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['view-l-room'], + body: isPOSTLivechatInquiriesReturnAsInquiry, + }, + async function action() { + const { roomId, departmentId } = this.bodyParams; + + try { + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + return API.v1.failure('error-room-not-found'); + } + + const result = await returnRoomAsInquiry(room, departmentId); + + return API.v1.success({ result }); + } catch (error) { + if (error instanceof Meteor.Error && typeof error.error === 'string') { + return API.v1.failure(error.error as string); + } + + return API.v1.failure('error-returning-inquiry'); + } + }, +); + +type LivechatInquiriesEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatInquiriesEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/integration.ts b/apps/meteor/server/api/v1/omnichannel/integration.ts new file mode 100644 index 0000000..666ce24 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/integration.ts @@ -0,0 +1,81 @@ +import { Settings } from '@rocket.chat/models'; +import { isPOSTomnichannelIntegrations } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { trim } from '../../../../lib/utils/stringUtils'; +import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; +import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates'; + +API.v1.addRoute( + 'omnichannel/integrations', + { authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: { POST: isPOSTomnichannelIntegrations } }, + { + async post() { + const { + LivechatWebhookUrl, + LivechatSecretToken, + LivechatHttpTimeout, + LivechatWebhookOnStart, + LivechatWebhookOnClose, + LivechatWebhookOnChatTaken, + LivechatWebhookOnChatQueued, + LivechatWebhookOnForward, + LivechatWebhookOnOfflineMsg, + LivechatWebhookOnVisitorMessage, + LivechatWebhookOnAgentMessage, + } = this.bodyParams; + + const settingsIds = [ + typeof LivechatWebhookUrl !== 'undefined' && { + _id: 'Livechat_webhookUrl', + value: trim(String(LivechatWebhookUrl ?? '')), + }, + typeof LivechatSecretToken !== 'undefined' && { + _id: 'Livechat_secret_token', + value: trim(String(LivechatSecretToken ?? '')), + }, + typeof LivechatHttpTimeout !== 'undefined' && { + _id: 'Livechat_http_timeout', + value: Number(LivechatHttpTimeout ?? 0), + }, + typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart }, + typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose }, + typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken }, + typeof LivechatWebhookOnChatQueued !== 'undefined' && { + _id: 'Livechat_webhook_on_chat_queued', + value: !!LivechatWebhookOnChatQueued, + }, + typeof LivechatWebhookOnForward !== 'undefined' && { _id: 'Livechat_webhook_on_forward', value: !!LivechatWebhookOnForward }, + typeof LivechatWebhookOnOfflineMsg !== 'undefined' && { + _id: 'Livechat_webhook_on_offline_msg', + value: !!LivechatWebhookOnOfflineMsg, + }, + typeof LivechatWebhookOnVisitorMessage !== 'undefined' && { + _id: 'Livechat_webhook_on_visitor_message', + value: !!LivechatWebhookOnVisitorMessage, + }, + typeof LivechatWebhookOnAgentMessage !== 'undefined' && { + _id: 'Livechat_webhook_on_agent_message', + value: !!LivechatWebhookOnAgentMessage, + }, + ].filter(Boolean) as unknown as { _id: string; value: any }[]; + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username, + ip: this.requestIp, + useragent: this.request.headers.get('user-agent') || '', + }); + + const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]._id); + } + }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/integrations.ts b/apps/meteor/server/api/v1/omnichannel/integrations.ts new file mode 100644 index 0000000..78a885e --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/integrations.ts @@ -0,0 +1,12 @@ +import { API } from '../..'; +import { findIntegrationSettings } from './lib/integrations'; + +API.v1.addRoute( + 'livechat/integrations.settings', + { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, + { + async get() { + return API.v1.success(await findIntegrationSettings()); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/lib/agents.ts b/apps/meteor/server/api/v1/omnichannel/lib/agents.ts new file mode 100644 index 0000000..2dbcce8 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/agents.ts @@ -0,0 +1,14 @@ +import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { LivechatDepartmentAgents } from '@rocket.chat/models'; + +export async function findAgentDepartments({ + enabledDepartmentsOnly, + agentId, +}: { + enabledDepartmentsOnly?: boolean; + agentId: string; +}): Promise<{ departments: (ILivechatDepartmentAgents & { departmentName: string })[] }> { + return { + departments: await LivechatDepartmentAgents.findDepartmentsOfAgent(agentId, enabledDepartmentsOnly).toArray(), + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/appearance.ts b/apps/meteor/server/api/v1/omnichannel/lib/appearance.ts new file mode 100644 index 0000000..c93db42 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/appearance.ts @@ -0,0 +1,40 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +export async function findAppearance(): Promise<{ appearance: ISetting[] }> { + const query = { + _id: { + $in: [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Livechat_show_agent_info', + 'Livechat_show_agent_email', + 'Livechat_display_offline_form', + 'Livechat_offline_form_unavailable', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_email', + 'Livechat_conversation_finished_message', + 'Livechat_registration_form', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_conversation_finished_text', + 'Livechat_hide_watermark', + 'Livechat_background', + 'Livechat_widget_position', + 'Livechat_hide_system_messages', + 'Omnichannel_allow_visitors_to_close_conversation', + 'Livechat_hide_expand_chat', + ], + }, + }; + + return { + appearance: await Settings.find(query).toArray(), + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/businessHours.ts b/apps/meteor/server/api/v1/omnichannel/lib/businessHours.ts new file mode 100644 index 0000000..aea70b7 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/businessHours.ts @@ -0,0 +1,9 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; + +import { businessHourManager } from '../../../../lib/omnichannel/business-hour'; + +export async function findLivechatBusinessHour(id?: string, type?: string): Promise> { + return { + businessHour: (await businessHourManager.getBusinessHour(id, type)) as ILivechatBusinessHour, + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts b/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts new file mode 100644 index 0000000..b58e11d --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts @@ -0,0 +1,61 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { LivechatCustomField, LivechatVisitors, LivechatRooms } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { UpdateResult, Document } from 'mongodb'; + +export async function findLivechatCustomFields({ + text, + pagination: { offset, count, sort }, +}: { + text?: string; + pagination: { offset: number; count: number; sort: Record }; +}): Promise }>> { + const query = { + ...(text && { + $or: [{ label: new RegExp(escapeRegExp(text), 'i') }, { _id: new RegExp(escapeRegExp(text), 'i') }], + }), + }; + + const { cursor, totalCount } = LivechatCustomField.findPaginated(query, { + sort: sort || { label: 1 }, + skip: offset, + limit: count, + }); + + const [customFields, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + customFields, + count: customFields.length, + offset, + total, + }; +} + +export async function findCustomFieldById({ + customFieldId, +}: { + customFieldId: string; +}): Promise<{ customField: ILivechatCustomField | null }> { + return { + customField: await LivechatCustomField.findOneById(customFieldId), + }; +} + +export async function setCustomField( + token: string, + key: string, + value: string, + overwrite = true, +): Promise { + const customField = await LivechatCustomField.findOneById(key); + if (customField) { + if (customField.scope === 'room') { + return LivechatRooms.updateDataByToken(token, key, value, overwrite); + } + return LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite); + } + + return true; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/departments.ts b/apps/meteor/server/api/v1/omnichannel/lib/departments.ts new file mode 100644 index 0000000..f18deed --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/departments.ts @@ -0,0 +1,197 @@ +import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { Document, Filter, FilterOperators, FindOptions } from 'mongodb'; + +import { hasPermissionAsync } from '../../../../lib/authorization/hasPermission'; + +type Pagination = { pagination: { offset: number; count: number; sort: FindOptions['sort'] } }; +type FindDepartmentParams = { + userId: string; + onlyMyDepartments?: boolean; + text?: string; + enabled?: boolean; + excludeDepartmentId?: string; + showArchived?: boolean; +} & Pagination; +type FindDepartmentByIdParams = { + userId: string; + departmentId: string; + includeAgents?: boolean; + onlyMyDepartments?: boolean; +}; +type FindDepartmentToAutocompleteParams = { + uid: string; + selector: { + exceptions: string[]; + conditions: Filter; + term: string; + }; + onlyMyDepartments?: boolean; + showArchived?: boolean; +}; +type FindDepartmentAgentsParams = { + userId: string; + departmentId: string; +} & Pagination; + +export async function findDepartments({ + userId, + onlyMyDepartments = false, + text, + enabled, + excludeDepartmentId, + showArchived = false, + pagination: { offset, count, sort }, +}: FindDepartmentParams): Promise> { + let query: FilterOperators = { + $or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }], + ...(!showArchived && { archived: { $ne: !showArchived } }), + ...(enabled && { enabled: Boolean(enabled) }), + ...(text && { name: new RegExp(escapeRegExp(text), 'i') }), + ...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }), + }; + + if (onlyMyDepartments) { + query = await applyDepartmentRestrictions(query, userId); + } + + const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [departments, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + departments, + count: departments.length, + offset, + total, + }; +} + +export async function findArchivedDepartments({ + userId, + onlyMyDepartments = false, + text, + excludeDepartmentId, + pagination: { offset, count, sort }, +}: FindDepartmentParams): Promise> { + let query: FilterOperators = { + $or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }], + archived: { $eq: true }, + ...(text && { name: new RegExp(escapeRegExp(text), 'i') }), + ...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }), + }; + + if (onlyMyDepartments) { + query = await applyDepartmentRestrictions(query, userId); + } + + const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const [departments, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + departments, + count: departments.length, + offset, + total, + }; +} + +export async function findDepartmentById({ + userId, + departmentId, + includeAgents = true, + onlyMyDepartments = false, +}: FindDepartmentByIdParams): Promise<{ + department: ILivechatDepartment | null; + agents?: ILivechatDepartmentAgents[]; +}> { + const canViewLivechatDepartments = includeAgents && (await hasPermissionAsync(userId, 'view-livechat-departments')); + + let query: FilterOperators = { _id: departmentId }; + + if (onlyMyDepartments) { + query = await applyDepartmentRestrictions(query, userId); + } + + const result = { + department: await LivechatDepartment.findOne(query), + ...(includeAgents && + canViewLivechatDepartments && { + agents: await LivechatDepartmentAgents.findByDepartmentId(departmentId).toArray(), + }), + }; + + return result; +} + +export async function findDepartmentsToAutocomplete({ + uid, + selector, + onlyMyDepartments = false, + showArchived = false, +}: FindDepartmentToAutocompleteParams): Promise<{ items: Pick[] }> { + const { exceptions = [] } = selector; + let { conditions = {} } = selector; + + if (onlyMyDepartments) { + conditions = await applyDepartmentRestrictions(conditions, uid); + } + + const conditionsWithArchived = { archived: { $ne: !showArchived }, ...conditions }; + + const items = await LivechatDepartment.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditionsWithArchived, { + projection: { + _id: 1, + name: 1, + }, + sort: { + name: 1, + }, + }).toArray(); + return { + items, + }; +} + +export async function findDepartmentAgents({ + departmentId, + pagination: { offset, count, sort }, +}: FindDepartmentAgentsParams): Promise> { + const { cursor, totalCount } = LivechatDepartmentAgents.findAgentsByDepartmentId(departmentId, { + sort: sort || { username: 1 }, + skip: offset, + limit: count, + }); + + const [agents, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + agents, + count: agents.length, + offset, + total, + }; +} + +export async function findDepartmentsBetweenIds({ + ids, + fields, +}: { + ids: string[]; + fields: Record; +}): Promise<{ departments: ILivechatDepartment[] }> { + const departments = await LivechatDepartment.findInIds(ids, { projection: fields }).toArray(); + return { departments }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts b/apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts new file mode 100644 index 0000000..8972bc8 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts @@ -0,0 +1,90 @@ +import { LivechatInquiryStatus } from '@rocket.chat/core-typings'; +import type { ILivechatInquiryRecord, IRoom, IUser } from '@rocket.chat/core-typings'; +import { LivechatDepartmentAgents, LivechatDepartment, LivechatInquiry } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import type { Filter } from 'mongodb'; + +import { getOmniChatSortQuery } from '../../../../../app/livechat/lib/inquiries'; +import { getInquirySortMechanismSetting } from '../../../../lib/omnichannel/settings'; + +const agentDepartments = async (userId: IUser['_id']): Promise => { + const agentDepartments = (await LivechatDepartmentAgents.findByAgentId(userId, { projection: { departmentId: 1 } }).toArray()).map( + ({ departmentId }) => departmentId, + ); + return (await LivechatDepartment.findEnabledInIds(agentDepartments, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); +}; + +const applyDepartmentRestrictions = async ( + userId: IUser['_id'], + filterDepartment?: string, +): Promise<{ $in: string[] } | { $exists: false } | string> => { + const allowedDepartments = await agentDepartments(userId); + if (allowedDepartments && Array.isArray(allowedDepartments) && allowedDepartments.length > 0) { + if (!filterDepartment) { + return { $in: allowedDepartments }; + } + + if (!allowedDepartments.includes(filterDepartment)) { + throw new Error('error-not-authorized'); + } + return filterDepartment; + } + + return { $exists: false }; +}; + +export async function findInquiries({ + userId, + department: filterDepartment, + status, + pagination: { offset, count, sort }, +}: { + userId: IUser['_id']; + department?: string; + status?: LivechatInquiryStatus; + pagination: { offset: number; count: number; sort: Record }; +}): Promise }>> { + const department = await applyDepartmentRestrictions(userId, filterDepartment); + const defaultSort = getOmniChatSortQuery(getInquirySortMechanismSetting()); + const options = { + limit: count, + skip: offset, + sort: { ...sort, ...defaultSort }, + }; + + const filter: Filter = { + // V in Enum only works for numeric enums + ...(status && Object.values(LivechatInquiryStatus).includes(status) && { status }), + $or: [ + // Cases where this user is the default agent + { + 'defaultAgent': { $exists: true }, + 'defaultAgent.agentId': userId, + }, + // Cases with no default agent assigned yet, AND either: + // - belongs to one of user's departments, or + // - has no department (public queue) + { + defaultAgent: { $exists: false }, + $or: [...(department ? [{ department }] : []), { department: { $exists: false } }], + }, + ], + }; + + const { cursor, totalCount } = LivechatInquiry.findPaginated(filter, options); + + const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + inquiries, + count: inquiries.length, + offset, + total, + }; +} + +export async function findOneInquiryByRoomId({ roomId }: { roomId: IRoom['_id'] }): Promise<{ inquiry: ILivechatInquiryRecord | null }> { + return { + inquiry: await LivechatInquiry.findOneByRoomId(roomId, {}), + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/integrations.ts b/apps/meteor/server/api/v1/omnichannel/lib/integrations.ts new file mode 100644 index 0000000..629c3f0 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/integrations.ts @@ -0,0 +1,20 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +export async function findIntegrationSettings(): Promise<{ settings: ISetting[] }> { + const settings = await Settings.findByIds([ + 'Livechat_webhookUrl', + 'Livechat_secret_token', + 'Livechat_http_timeout', + 'Livechat_webhook_on_start', + 'Livechat_webhook_on_close', + 'Livechat_webhook_on_chat_taken', + 'Livechat_webhook_on_chat_queued', + 'Livechat_webhook_on_forward', + 'Livechat_webhook_on_offline_msg', + 'Livechat_webhook_on_visitor_message', + 'Livechat_webhook_on_agent_message', + ]).toArray(); + + return { settings }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/livechat.ts b/apps/meteor/server/api/v1/omnichannel/lib/livechat.ts new file mode 100644 index 0000000..c161ea8 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/livechat.ts @@ -0,0 +1,198 @@ +import type { ILivechatAgent, ILivechatDepartment, ILivechatTrigger, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../../lib/callbacks'; +import { normalizeAgent } from '../../../../lib/omnichannel/Helper'; +import { getInitSettings } from '../../../../lib/omnichannel/settings'; + +async function findTriggers(): Promise[]> { + const triggers = await LivechatTrigger.findEnabled().toArray(); + const hasLicense = License.hasModule('livechat-enterprise'); + const premiumActions = ['use-external-service']; + + return triggers + .filter(({ actions }) => hasLicense || actions.some((c) => !premiumActions.includes(c.name))) + .map(({ _id, actions, conditions, runOnce }) => ({ + _id, + actions, + conditions, + runOnce, + })); +} + +export type CheckUnitsFromUser = { + userId?: string; + businessUnit?: string; +}; + +export const checkUnitsFromUser = makeFunction(async (_params: CheckUnitsFromUser): Promise => undefined); + +async function findDepartments( + businessUnit?: string, + userId?: string, +): Promise[]> { + // TODO: check this function usage + await checkUnitsFromUser({ userId, businessUnit }); + + return LivechatDepartment.findEnabledWithAgentsAndBusinessUnit< + Pick + >(businessUnit, { + _id: 1, + name: 1, + showOnRegistration: 1, + showOnOfflineForm: 1, + departmentsAllowedToForward: 1, + }).toArray(); +} + +export function findGuest(token: string): Promise { + return LivechatVisitors.getVisitorByToken(token); +} + +export function findGuestWithoutActivity( + token: string, +): Promise | null> { + return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } }); +} + +export async function findRoom(token: string, rid?: string): Promise { + const fields = { + t: 1, + departmentId: 1, + servedBy: 1, + open: 1, + v: 1, + ts: 1, + }; + + if (!rid) { + return LivechatRooms.findOneByVisitorToken(token, fields); + } + + return LivechatRooms.findOneByIdAndVisitorToken(rid, token, fields); +} + +export async function findOpenRoom(token: string, departmentId?: string, callerId?: string): Promise { + const options = { + projection: { + departmentId: 1, + servedBy: 1, + open: 1, + }, + }; + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: callerId }); + const rooms = departmentId + ? await LivechatRooms.findOpenByVisitorTokenAndDepartmentId(token, departmentId, options, extraQuery).toArray() + : await LivechatRooms.findOpenByVisitorToken(token, options, extraQuery).toArray(); + if (rooms && rooms.length > 0) { + return rooms[0]; + } +} + +export async function findAgent(agentId?: string): Promise { + return normalizeAgent(agentId); +} + +export function normalizeHttpHeaderData(headers: Headers = new Headers()): { + httpHeaders: Record; +} { + const httpHeaders = Object.fromEntries(headers.entries()); + return { httpHeaders }; +} + +export async function settings({ businessUnit = '', userId }: { businessUnit?: string; userId?: string } = {}): Promise< + Record +> { + const [initSettings, triggers, departments, emojis] = await Promise.all([ + getInitSettings(), + findTriggers(), + findDepartments(businessUnit, userId), + EmojiCustom.find().toArray(), + ]); + const sound = `${Meteor.absoluteUrl()}sounds/chime.mp3`; + return { + enabled: initSettings.Livechat_enabled, + settings: { + registrationForm: initSettings.Livechat_registration_form, + allowSwitchingDepartments: initSettings.Livechat_allow_switching_departments, + nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form, + emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form, + displayOfflineForm: initSettings.Livechat_display_offline_form, + videoCall: initSettings.Omnichannel_call_provider === 'default-provider', + fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled, + language: initSettings.Language, + transcript: initSettings.Livechat_enable_transcript, + historyMonitorType: initSettings.Livechat_history_monitor_type, + forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent, + showConnecting: initSettings.Livechat_Show_Connecting, + agentHiddenInfo: initSettings.Livechat_show_agent_info === false, + clearLocalStorageWhenChatEnded: initSettings.Livechat_clear_local_storage_when_chat_ended, + limitTextLength: + initSettings.Livechat_enable_message_character_limit && + (initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize), + hiddenSystemMessages: initSettings.Livechat_hide_system_messages, + livechatLogo: initSettings.Assets_livechat_widget_logo, + hideWatermark: initSettings.Livechat_hide_watermark || false, + visitorsCanCloseChat: initSettings.Omnichannel_allow_visitors_to_close_conversation, + }, + theme: { + title: initSettings.Livechat_title, + color: initSettings.Livechat_title_color, + offlineTitle: initSettings.Livechat_offline_title, + offlineColor: initSettings.Livechat_offline_title_color, + position: initSettings.Livechat_widget_position || 'right', + background: initSettings.Livechat_background, + hideExpandChat: initSettings.Livechat_hide_expand_chat, + actionLinks: { + jitsi: [ + { icon: 'icon-videocam', i18nLabel: 'Accept' }, + { icon: 'icon-cancel', i18nLabel: 'Decline' }, + ], + }, + }, + messages: { + offlineMessage: initSettings.Livechat_offline_message, + offlineSuccessMessage: initSettings.Livechat_offline_success_message, + offlineUnavailableMessage: initSettings.Livechat_offline_form_unavailable, + conversationFinishedMessage: initSettings.Livechat_conversation_finished_message, + conversationFinishedText: initSettings.Livechat_conversation_finished_text, + transcriptMessage: initSettings.Livechat_transcript_message, + registrationFormMessage: initSettings.Livechat_registration_form_message, + dataProcessingConsentText: initSettings.Livechat_data_processing_consent_text, + }, + survey: { + items: ['satisfaction', 'agentKnowledge', 'agentResposiveness', 'agentFriendliness'], + values: ['1', '2', '3', '4', '5'], + }, + triggers, + departments, + resources: { + sound, + emojis, + }, + }; +} + +export const getExtraConfigInfo = makeFunction( + async (options: { + room?: IOmnichannelRoom; + }): Promise<{ + queueInfo?: unknown; + customFields?: { + options?: string[] | undefined; + _id: string; + label: string; + regexp: string | undefined; + required: boolean; + type: string | undefined; + defaultValue: string | null; + }[]; + room?: IOmnichannelRoom; + }> => options, +); + +export const onCheckRoomParams = makeFunction((params: any) => params); diff --git a/apps/meteor/server/api/v1/omnichannel/lib/queue.ts b/apps/meteor/server/api/v1/omnichannel/lib/queue.ts new file mode 100644 index 0000000..c15401c --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/queue.ts @@ -0,0 +1,46 @@ +import { LivechatRooms } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; + +export async function findQueueMetrics({ + agentId, + includeOfflineAgents, + departmentId, + pagination: { offset, count, sort }, +}: { + agentId?: string; + includeOfflineAgents?: boolean; + departmentId?: string; + pagination: { offset: number; count: number; sort: Record }; +}): Promise< + PaginatedResult<{ + queue: Array<{ + _id: string; + user: { _id: string; userId: string; username: string; status: string }; + department: { _id: string; name: string }; + chats: number; + }>; + }> +> { + const result = await LivechatRooms.getQueueMetrics({ + departmentId, + agentId, + includeOfflineAgents, + options: { + sort: sort || { chats: -1 }, + offset, + count, + }, + }); + + const { + sortedResults: queue, + totalCount: [{ total } = { total: 0 }], + } = result[0]; + + return { + queue, + count: queue.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/rooms.ts b/apps/meteor/server/api/v1/omnichannel/lib/rooms.ts new file mode 100644 index 0000000..6c758f5 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/rooms.ts @@ -0,0 +1,92 @@ +import type { ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; + +import { callbacks } from '../../../../lib/callbacks'; + +export async function findRooms({ + agents, + roomName, + departmentId, + open, + createdAt, + closedAt, + tags, + customFields, + onhold, + queued, + units, + query, + options: { offset, count, fields, sort }, + callerId, +}: { + agents?: Array; + roomName?: string; + departmentId?: string; + open?: boolean; + createdAt?: { + start?: string | undefined; + end?: string | undefined; + }; + closedAt?: { + start?: string | undefined; + end?: string | undefined; + }; + tags?: Array; + customFields?: Record; + onhold?: string | boolean; + queued?: string | boolean; + units?: Array; + query?: Record; + options: { offset: number; count: number; fields: Record; sort: Record }; + callerId: string; +}): Promise }>> { + const extraQueryBase = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId }); + const extraQuery = { ...extraQueryBase, ...(query || {}) }; + const { cursor, totalCount } = LivechatRooms.findRoomsWithCriteria({ + agents, + roomName, + departmentId, + open, + createdAt, + closedAt, + tags, + customFields, + onhold: ['t', 'true', '1'].includes(`${onhold}`), + queued: ['t', 'true', '1'].includes(`${queued}`), + options: { + sort: sort || { ts: -1 }, + offset, + count, + fields, + }, + extraQuery, + }); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + const isRoomWithDepartmentId = (depId: string | undefined): depId is string => !!depId; + + const departmentsIds = [...new Set(rooms.map((room) => room.departmentId).filter(isRoomWithDepartmentId))]; + if (departmentsIds.length) { + const departments = await LivechatDepartment.findInIds(departmentsIds, { + projection: { name: 1 }, + }).toArray(); + + rooms.forEach((room: IOmnichannelRoom & { department?: Pick }) => { + if (!room.departmentId) { + return; + } + const department = departments.find((dept) => dept._id === room.departmentId); + if (department) { + room.department = department; + } + }); + } + return { + rooms, + count: rooms.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts b/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts new file mode 100644 index 0000000..bcaba34 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts @@ -0,0 +1,34 @@ +import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; + +const normalizeTransferHistory = ({ transferData }: IOmnichannelSystemMessage): IOmnichannelSystemMessage['transferData'] => transferData; + +const removeNulls = (value: S | undefined): value is S => value != null; + +export async function findLivechatTransferHistory({ + rid, + pagination: { offset, count, sort }, +}: { + rid: string; + pagination: { offset: number; count: number; sort: Record }; +}): Promise> { + const { cursor, totalCount } = Messages.findPaginated( + { rid, t: 'livechat_transfer_history' }, + { + projection: { transferData: 1 }, + sort: sort || { ts: 1 }, + skip: offset, + limit: count, + }, + ); + + const [history, total] = await Promise.all([cursor.map(normalizeTransferHistory).toArray(), totalCount]); + + return { + history: history.filter(removeNulls), + count: history.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts b/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts new file mode 100644 index 0000000..6650df3 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts @@ -0,0 +1,35 @@ +import type { ILivechatTrigger } from '@rocket.chat/core-typings'; +import { LivechatTrigger } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; + +export async function findTriggers({ + pagination: { offset, count, sort }, +}: { + pagination: { offset: number; count: number; sort: Record }; +}): Promise }>> { + const { cursor, totalCount } = LivechatTrigger.findPaginated( + {}, + { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }, + ); + + const [triggers, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + triggers, + count: triggers.length, + offset, + total, + }; +} + +export async function findTriggerById({ triggerId }: { triggerId: string }): Promise { + return LivechatTrigger.findOneById(triggerId); +} + +export async function deleteTrigger({ triggerId }: { triggerId: string }): Promise { + await LivechatTrigger.removeById(triggerId); +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/users.ts b/apps/meteor/server/api/v1/omnichannel/lib/users.ts new file mode 100644 index 0000000..9b75a6a --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/users.ts @@ -0,0 +1,118 @@ +import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { FilterOperators } from 'mongodb'; + +/** + * @param {IRole['_id']} role the role id + * @param {string} text + * @param {any} pagination + */ +async function findUsers({ + role, + text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, + pagination: { offset, count, sort }, +}: { + role: IRole['_id']; + text?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; + pagination: { offset: number; count: number; sort: any }; +}): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> { + const query: FilterOperators = {}; + const orConditions: FilterOperators['$or'] = []; + if (text) { + const filterReg = new RegExp(escapeRegExp(text), 'i'); + orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] }); + } + + if (onlyAvailable) { + query.statusLivechat = 'available'; + } + + if (excludeId) { + query._id = { $ne: excludeId }; + } + + if (!showIdleAgents) { + orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] }); + } + + if (orConditions.length) { + query.$and = orConditions; + } + + const [ + { + sortedResults, + totalCount: [{ total } = { total: 0 }], + }, + ] = await Users.findAgentsWithDepartments(role, query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: { + username: 1, + name: 1, + status: 1, + statusLivechat: 1, + emails: 1, + livechat: 1, + }, + }); + + return { + users: sortedResults, + count: sortedResults.length, + offset, + total, + }; +} +export async function findAgents({ + text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, + pagination: { offset, count, sort }, +}: { + text?: string; + onlyAvailable: boolean; + excludeId?: string; + showIdleAgents?: boolean; + pagination: { offset: number; count: number; sort: any }; +}): Promise> { + return findUsers({ + role: 'livechat-agent', + text, + onlyAvailable, + excludeId, + showIdleAgents, + pagination: { + offset, + count, + sort, + }, + }); +} + +export async function findManagers({ + text, + pagination: { offset, count, sort }, +}: { + text?: string; + pagination: { offset: number; count: number; sort: any }; +}): Promise> { + return findUsers({ + role: 'livechat-manager', + text, + pagination: { + offset, + count, + sort, + }, + }); +} diff --git a/apps/meteor/server/api/v1/omnichannel/lib/visitors.ts b/apps/meteor/server/api/v1/omnichannel/lib/visitors.ts new file mode 100644 index 0000000..5aff4cf --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/lib/visitors.ts @@ -0,0 +1,216 @@ +import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser, IVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Messages, LivechatRooms, LivechatCustomField } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +import { canAccessRoomAsync } from '../../../../lib/authorization/canAccessRoom'; +import { callbacks } from '../../../../lib/callbacks'; + +export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { + const visitor = await LivechatVisitors.findOneEnabledById(visitorId, { projection: { token: 0 } }); + if (!visitor) { + throw new Error('visitor-not-found'); + } + + return { + visitor, + }; +} + +export async function findVisitedPages({ + roomId, + pagination: { offset, count, sort }, +}: { + roomId: IRoom['_id']; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}) { + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + throw new Error('invalid-room'); + } + const { cursor, totalCount } = Messages.findPaginatedByRoomIdAndType(room._id, 'livechat_navigation_history', { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const [pages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + pages, + count: pages.length, + offset, + total, + }; +} + +export async function findChatHistory({ + userId, + roomId, + visitorId, + pagination: { offset, count, sort }, +}: { + userId: IUser['_id']; + roomId: IRoom['_id']; + visitorId: IVisitor['_id']; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}) { + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + throw new Error('invalid-room'); + } + if (!(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Error('error-not-allowed'); + } + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId }); + const { cursor, totalCount } = LivechatRooms.findPaginatedByVisitorId( + visitorId, + { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }, + extraQuery, + ); + + const [history, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + history, + count: history.length, + offset, + total, + }; +} + +export async function searchChats({ + userId, + roomId, + visitorId, + searchText, + closedChatsOnly, + servedChatsOnly: served, + source, + pagination: { offset, count, sort }, +}: { + userId: IUser['_id']; + roomId: IRoom['_id']; + visitorId: IVisitor['_id']; + searchText?: string; + closedChatsOnly?: string; + servedChatsOnly?: string; + source?: string; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}) { + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + throw new Error('invalid-room'); + } + + if (!(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Error('error-not-allowed'); + } + + const options = { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }; + + const [total] = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({ + visitorId, + open: closedChatsOnly !== 'true', + served: served === 'true', + searchText, + onlyCount: true, + source, + }).toArray(); + const cursor = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({ + visitorId, + open: closedChatsOnly !== 'true', + served: served === 'true', + searchText, + options, + source, + }); + + const history = await cursor.toArray(); + + return { + history, + count: history.length, + offset, + total: total?.count ?? 0, + }; +} + +export async function findVisitorsToAutocomplete({ + selector, +}: { + selector: { + exceptions?: ILivechatVisitor['_id'][]; + conditions?: Record; + term: string; + }; +}) { + const { exceptions = [], conditions = {} } = selector; + + const options: FindOptions = { + projection: { + _id: 1, + name: 1, + username: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const items = await LivechatVisitors.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditions, options).toArray(); + return { + items, + }; +} + +export async function findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({ + emailOrPhone, + nameOrUsername, + pagination: { offset, count, sort }, +}: { + emailOrPhone?: string; + nameOrUsername?: RegExp; + pagination: { offset: number; count: number; sort: FindOptions['sort'] }; +}) { + const allowedCF = await LivechatCustomField.findMatchingCustomFields('visitor', true, { projection: { _id: 1 } }) + .map((cf) => cf._id) + .toArray(); + + const { cursor, totalCount } = await LivechatVisitors.findPaginatedVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField( + emailOrPhone, + nameOrUsername, + allowedCF, + { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: { + username: 1, + name: 1, + phone: 1, + livechatData: 1, + visitorEmails: 1, + lastChat: 1, + }, + }, + ); + + const [visitors, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + visitors, + count: visitors.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/api/v1/omnichannel/message.ts b/apps/meteor/server/api/v1/omnichannel/message.ts new file mode 100644 index 0000000..5ed2676 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/message.ts @@ -0,0 +1,316 @@ +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { Random } from '@rocket.chat/random'; +import { + isPOSTLivechatMessageParams, + isGETLivechatMessageIdParams, + isPUTLivechatMessageIdParams, + isDELETELivechatMessageIdParams, + isGETLivechatMessagesHistoryRidParams, + isGETLivechatMessagesParams, +} from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findGuest, findRoom, normalizeHttpHeaderData } from './lib/livechat'; +import { callbacks } from '../../../lib/callbacks'; +import { loadMessageHistory } from '../../../lib/messages/loadMessageHistory'; +import { updateMessage, deleteMessage, sendMessage } from '../../../lib/omnichannel/messages'; +import { normalizeMessageFileUpload } from '../../../lib/utils/functions/normalizeMessageFileUpload'; +import { settings } from '../../../settings'; +import { getPaginationItems } from '../../lib/getPaginationItems'; +import { isWidget } from '../../lib/isWidget'; + +API.v1.addRoute( + 'livechat/message', + { validateParams: isPOSTLivechatMessageParams }, + { + async post() { + const { token, rid, agent, msg } = this.bodyParams; + + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + if (!room.open) { + throw new Error('room-closed'); + } + + if ( + settings.get('Livechat_enable_message_character_limit') && + msg.length > parseInt(settings.get('Livechat_message_character_limit')) + ) { + throw new Error('message-length-exceeds-character-limit'); + } + + const _id = this.bodyParams._id || Random.id(); + + const messageToSend = { + guest, + message: { + _id, + rid, + msg, + token, + }, + agent, + roomInfo: { + source: { + type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, + }, + }, + }; + + const result = await sendMessage(messageToSend); + if (result) { + const message = await Messages.findOneById(_id); + if (!message) { + return API.v1.failure(); + } + return API.v1.success({ message }); + } + + return API.v1.failure(); + }, + }, +); + +API.v1.addRoute( + 'livechat/message/:_id', + { validateParams: { GET: isGETLivechatMessageIdParams, PUT: isPUTLivechatMessageIdParams, DELETE: isDELETELivechatMessageIdParams } }, + { + async get() { + const { token, rid } = this.queryParams; + const { _id } = this.urlParams; + + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + let message = await Messages.findOneByRoomIdAndMessageId(rid, _id); + if (!message) { + throw new Error('invalid-message'); + } + + if (message.file) { + message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; + } + + if (!message) { + throw new Error('invalid-message'); + } + + return API.v1.success({ message }); + }, + + async put() { + const { token, rid } = this.bodyParams; + const { _id } = this.urlParams; + + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + // TODO: projection + const msg = await Messages.findOneById(_id); + if (!msg) { + throw new Error('invalid-message'); + } + + const result = await updateMessage({ + guest, + message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid }, + }); + if (!result) { + return API.v1.failure(); + } + + let message = await Messages.findOneById(_id); + if (!message) { + return API.v1.failure(); + } + + if (message?.file) { + message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; + } + + if (!message) { + throw new Error('invalid-message'); + } + + return API.v1.success({ message }); + }, + async delete() { + const { token, rid } = this.bodyParams; + const { _id } = this.urlParams; + + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + const message = await Messages.findOneById(_id); + if (!message) { + throw new Error('invalid-message'); + } + + const result = await deleteMessage({ guest, message }); + if (result) { + return API.v1.success({ + message: { + _id, + ts: new Date().toISOString(), + }, + }); + } + + return API.v1.failure(); + }, + }, +); + +API.v1.addRoute( + 'livechat/messages.history/:rid', + { validateParams: isGETLivechatMessagesHistoryRidParams }, + { + async get() { + const { offset } = await getPaginationItems(this.queryParams); + const { token } = this.queryParams; + const { rid } = this.urlParams; + + if (!token) { + throw new Error('error-token-param-not-provided'); + } + + const guest = await findGuest(token); + if (!guest) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + let ls = undefined; + if (this.queryParams.ls) { + ls = new Date(this.queryParams.ls); + } + + let end = undefined; + if (this.queryParams.end) { + end = new Date(this.queryParams.end); + } + + let limit = 20; + if (this.queryParams.limit) { + limit = parseInt(`${this.queryParams.limit}`, 10); + } + + const history = await loadMessageHistory({ + userId: guest._id, + rid, + end, + limit, + ls, + offset, + }); + + const messages = await Promise.all(history.messages.map((message) => normalizeMessageFileUpload(message))); + + return API.v1.success({ messages }); + }, + }, +); + +API.v1.addRoute( + 'livechat/messages', + { authRequired: true, validateParams: isGETLivechatMessagesParams }, + { + async post() { + const visitorToken = this.bodyParams.visitor.token; + + let visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {}); + let rid: string; + if (visitor) { + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId }); + const room = await LivechatRooms.findOneOpenByVisitorToken(visitorToken, { projection: { _id: 1 } }, extraQuery); + rid = room?._id ?? Random.id(); + } else { + rid = Random.id(); + + const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor; + + if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + guest.connectionData = normalizeHttpHeaderData(this.request.headers); + } + + visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); + if (!visitor) { + throw new Error('error-livechat-visitor-registration'); + } + } + + const guest = visitor; + if (!guest) { + throw new Error('error-invalid-token'); + } + + const sentMessages = await Promise.all( + this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => { + const messageToSend = { + guest, + message: { + _id: Random.id(), + rid, + token: visitorToken, + msg: message.msg, + }, + roomInfo: { + source: { + type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, + }, + }, + }; + + const sentMessage = await sendMessage(messageToSend); + return { + username: sentMessage.u.username, + msg: sentMessage.msg, + ts: sentMessage.ts, + }; + }), + ); + + return API.v1.success({ + messages: sentMessages, + }); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts b/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts new file mode 100644 index 0000000..6de0390 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/offlineMessage.ts @@ -0,0 +1,24 @@ +import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { i18n } from '../../../lib/i18n'; +import { sendOfflineMessage } from '../../../lib/omnichannel/messages'; + +API.v1.addRoute( + 'livechat/offline.message', + { + validateParams: isPOSTLivechatOfflineMessageParams, + rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 5000 }, + }, + { + async post() { + const { name, email, message, department, host } = this.bodyParams; + try { + await sendOfflineMessage({ name, email, message, department, host }); + return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') }); + } catch (e) { + return API.v1.failure(i18n.t('Error_sending_livechat_offline_message')); + } + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/pageVisited.ts b/apps/meteor/server/api/v1/omnichannel/pageVisited.ts new file mode 100644 index 0000000..84296cb --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/pageVisited.ts @@ -0,0 +1,23 @@ +import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; +import { isPOSTLivechatPageVisitedParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { savePageHistory } from '../../../lib/omnichannel/tracking'; + +API.v1.addRoute( + 'livechat/page.visited', + { validateParams: isPOSTLivechatPageVisitedParams }, + { + async post() { + const { token, rid, pageInfo } = this.bodyParams; + + const message = await savePageHistory(token, rid, pageInfo); + if (!message) { + return API.v1.success(); + } + + const { msg, navigation } = message as IOmnichannelSystemMessage; + return API.v1.success({ page: { msg, navigation } }); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/queue.ts b/apps/meteor/server/api/v1/omnichannel/queue.ts new file mode 100644 index 0000000..6ed9540 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/queue.ts @@ -0,0 +1,29 @@ +import { isGETLivechatQueueParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findQueueMetrics } from './lib/queue'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/queue', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatQueueParams }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { agentId, includeOfflineAgents, departmentId } = this.queryParams; + const users = await findQueueMetrics({ + agentId, + includeOfflineAgents: includeOfflineAgents === 'true', + departmentId, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(users); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/room.ts b/apps/meteor/server/api/v1/omnichannel/room.ts new file mode 100644 index 0000000..9f46399 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/room.ts @@ -0,0 +1,541 @@ +import { Omnichannel } from '@rocket.chat/core-services'; +import type { + ILivechatAgent, + IOmnichannelInquiryExtraData, + IOmnichannelRoom, + IUser, + SelectedAgent, + TransferByData, + TransferData, +} from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users, LivechatRooms } from '@rocket.chat/models'; +import { + isLiveChatRoomForwardProps, + isPOSTLivechatRoomCloseParams, + isPOSTLivechatRoomSurveyParams, + isLiveChatRoomJoinProps, + isLiveChatRoomSaveInfoProps, + isPOSTLivechatRoomCloseByUserParams, + isPOSTLivechatRoomsCloseAll, + isPOSTLivechatRoomsCloseAllSuccessResponse, + POSTLivechatRemoveRoomSuccess, + isPOSTLivechatRemoveRoomParams, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + ajv, +} from '@rocket.chat/rest-typings'; +import { isPOSTLivechatVisitorDepartmentTransferParams } from '@rocket.chat/rest-typings/src/v1/omnichannel'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { API } from '../..'; +import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from './lib/livechat'; +import { canAccessRoomAsync } from '../../../lib/authorization'; +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { callbacks } from '../../../lib/callbacks'; +import { i18n } from '../../../lib/i18n'; +import { normalizeTransferredByData } from '../../../lib/omnichannel/Helper'; +import { closeLivechatRoom } from '../../../lib/omnichannel/closeLivechatRoom'; +import { closeRoom } from '../../../lib/omnichannel/closeRoom'; +import { saveGuest } from '../../../lib/omnichannel/guests'; +import type { CloseRoomParams } from '../../../lib/omnichannel/localTypes'; +import { livechatLogger } from '../../../lib/omnichannel/logger'; +import { createRoom, removeOmnichannelRoom, saveRoomInfo } from '../../../lib/omnichannel/rooms'; +import { transfer } from '../../../lib/omnichannel/transfer'; +import { addUserToRoom } from '../../../lib/rooms/addUserToRoom'; +import { settings as rcSettings } from '../../../settings'; +import type { ExtractRoutesFromAPI } from '../../ApiClass'; +import { isWidget } from '../../lib/isWidget'; + +const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj); + +API.v1.addRoute( + 'livechat/room', + { + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + }, + { + async get() { + // I'll temporary use check for validation, as validateParams doesnt support what's being done here + const extraCheckParams = onCheckRoomParams({ + token: String, + rid: Match.Maybe(String), + agentId: Match.Maybe(String), + }); + + check(this.queryParams, extraCheckParams); + + const { token, rid, agentId, ...extraParams } = this.queryParams; + + const guest = token && (await findGuest(token)); + if (!guest) { + throw new Error('invalid-token'); + } + + if (!rid) { + const room = await LivechatRooms.findOneOpenByVisitorToken(token, {}); + if (room) { + return API.v1.success({ room, newRoom: false }); + } + + let agent: SelectedAgent | undefined; + const agentObj = agentId && (await findAgent(agentId)); + if (agentObj) { + if (isAgentWithInfo(agentObj)) { + const { username = undefined } = agentObj; + agent = { agentId, username }; + } else { + agent = { agentId }; + } + } + + const roomInfo = { + source: { + ...(isWidget(this.request.headers) + ? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.get('host')! } + : { type: OmnichannelSourceType.API }), + }, + }; + + const newRoom = await createRoom({ + visitor: guest, + roomInfo, + agent, + extraData: extraParams as IOmnichannelInquiryExtraData, + }); + + return API.v1.success({ + room: newRoom, + newRoom: true, + }); + } + + const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(rid, token, {}); + if (!froom) { + throw new Error('invalid-room'); + } + + return API.v1.success({ room: froom, newRoom: false }); + }, + }, +); + +// Note: use this route if a visitor is closing a room +// If a RC user(like eg agent) is closing a room, use the `livechat/room.closeByUser` route +API.v1.addRoute( + 'livechat/room.close', + { validateParams: isPOSTLivechatRoomCloseParams }, + { + async post() { + const { rid, token } = this.bodyParams; + + if (!rcSettings.get('Omnichannel_allow_visitors_to_close_conversation')) { + throw new Error('error-not-allowed-to-close-conversation'); + } + + const visitor = await findGuest(token); + if (!visitor) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + if (!room.open) { + throw new Error('room-closed'); + } + + const language = rcSettings.get('Language') || 'en'; + const comment = i18n.t('Closed_by_visitor', { lng: language }); + + const options: CloseRoomParams['options'] = {}; + if (room.servedBy) { + const servingAgent: Pick | null = + await Users.findOneById(room.servedBy._id, { + projection: { + name: 1, + username: 1, + utcOffset: 1, + settings: 1, + language: 1, + }, + }); + + if (servingAgent?.settings?.preferences?.omnichannelTranscriptPDF) { + options.pdfTranscript = { + requestedBy: servingAgent._id, + }; + } + + // We'll send the transcript by email only if the setting is disabled (that means, we're not asking the user if he wants to receive the transcript by email) + // And the agent has the preference enabled to send the transcript by email and the visitor has an email address + // When Livechat_enable_transcript is enabled, the email will be sent via livechat/transcript route + if ( + !rcSettings.get('Livechat_enable_transcript') && + servingAgent?.settings?.preferences?.omnichannelTranscriptEmail && + visitor.visitorEmails?.length && + visitor.visitorEmails?.[0]?.address + ) { + const visitorEmail = visitor.visitorEmails?.[0]?.address; + + const language = servingAgent.language || rcSettings.get('Language') || 'en'; + const t = i18n.getFixedT(language); + const subject = t('Transcript_of_your_livechat_conversation'); + + options.emailTranscript = { + sendToVisitor: true, + requestData: { + email: visitorEmail, + requestedAt: new Date(), + requestedBy: servingAgent, + subject, + }, + }; + } + } + + await closeRoom({ visitor, room, comment, options }); + + return API.v1.success({ rid, comment }); + }, + }, +); + +API.v1.addRoute( + 'livechat/room.closeByUser', + { + validateParams: isPOSTLivechatRoomCloseByUserParams, + authRequired: true, + permissionsRequired: ['close-livechat-room'], + }, + { + async post() { + const { rid, comment, tags, generateTranscriptPdf, transcriptEmail, forceClose } = this.bodyParams; + + const allowForceClose = rcSettings.get('Omnichannel_allow_force_close_conversations'); + const isForceClosing = allowForceClose && forceClose; + + if (isForceClosing) { + livechatLogger.warn({ msg: 'Force closing a conversation', user: this.userId, room: rid }); + } + + await closeLivechatRoom(this.user, rid, { + comment, + tags, + generateTranscriptPdf, + transcriptEmail, + forceClose: isForceClosing, + }); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/room.survey', + { validateParams: isPOSTLivechatRoomSurveyParams }, + { + async post() { + const { rid, token, data } = this.bodyParams; + + const visitor = await findGuest(token); + if (!visitor) { + throw new Error('invalid-token'); + } + + const room = await findRoom(token, rid); + if (!room) { + throw new Error('invalid-room'); + } + + const config = await settings(); + if (!config.survey?.items || !config.survey.values) { + throw new Error('invalid-livechat-config'); + } + + const updateData: { [k: string]: string } = {}; + for (const item of data) { + if ((config.survey.items.includes(item.name) && config.survey.values.includes(item.value)) || item.name === 'additionalFeedback') { + updateData[item.name] = item.value; + } + } + + if (Object.keys(updateData).length === 0) { + throw new Error('invalid-data'); + } + + if (!(await LivechatRooms.updateSurveyFeedbackById(room._id, updateData))) { + return API.v1.failure(); + } + + return API.v1.success({ rid, data: updateData }); + }, + }, +); + +API.v1.addRoute( + 'livechat/room.forward', + { authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps }, + { + async post() { + const transferData = this.bodyParams as typeof this.bodyParams & { + transferredBy: TransferByData; + transferredTo?: { _id: string; username?: string; name?: string }; + }; + + const room = await LivechatRooms.findOneById(this.bodyParams.roomId); + if (room?.t !== 'l') { + throw new Error('error-invalid-room'); + } + + if (!room.open) { + throw new Error('This_conversation_is_already_closed'); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + throw new Error('error-invalid-visitor'); + } + + transferData.transferredBy = normalizeTransferredByData(this.user, room); + if (transferData.userId) { + const userToTransfer = await Users.findOneById(transferData.userId); + if (userToTransfer) { + transferData.transferredTo = { + _id: userToTransfer._id, + username: userToTransfer.username, + name: userToTransfer.name, + }; + } + } + + const chatForwardedResult = await transfer(room, guest, transferData); + if (!chatForwardedResult) { + throw new Error('error-forwarding-chat'); + } + + return API.v1.success(); + }, + }, +); + +const livechatVisitorDepartmentTransfer = API.v1.post( + 'livechat/visitor/department.transfer', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + body: isPOSTLivechatVisitorDepartmentTransferParams, + }, + async function action() { + const { rid, token, department } = this.bodyParams; + + const visitor = await findGuest(token); + if (!visitor) { + return API.v1.failure('invalid-token'); + } + const room = await LivechatRooms.findOneById(rid); + + if (room?.t !== 'l') { + return API.v1.failure('error-invalid-room'); + } + + if (!room.open) { + return API.v1.failure('This_conversation_is_already_closed'); + } + + // As this is a visitor endpoint, we should not show the mac limit error + if (!(await Omnichannel.isWithinMACLimit(room))) { + return API.v1.failure('error-transefing-chat'); + } + + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + return API.v1.failure('error-invalid-visitor'); + } + + const transferredBy = normalizeTransferredByData( + { _id: guest._id, username: guest.username, name: guest.name, userType: 'visitor' }, + room, + ); + + const transferData: TransferData = { transferredBy, departmentId: department }; + + const chatForwardedResult = await transfer(room, guest, transferData); + if (!chatForwardedResult) { + return API.v1.failure('error-transfering-chat'); + } + + return API.v1.success(); + }, +); + +type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI; +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatAnalyticsEndpoints {} +} + +API.v1.addRoute( + 'livechat/room.join', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomJoinProps }, + { + async get() { + const { roomId } = this.queryParams; + + const { user } = this; + + if (!user) { + throw new Error('error-invalid-user'); + } + + const room = await LivechatRooms.findOneById(roomId); + + if (!room) { + throw new Error('error-invalid-room'); + } + + if (!room.open) { + throw new Error('room-closed'); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Error('error-not-allowed'); + } + + await addUserToRoom(roomId, user); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/room.saveInfo', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomSaveInfoProps }, + { + async post() { + const { roomData, guestData } = this.bodyParams; + const room = await LivechatRooms.findOneById(roomData._id); + if (!room || !isOmnichannelRoom(room)) { + throw new Error('error-invalid-room'); + } + + if ( + (!room.servedBy || room.servedBy._id !== this.userId) && + !(await hasPermissionAsync(this.user, 'save-others-livechat-room-info')) + ) { + return API.v1.forbidden(); + } + + if (room.sms) { + delete guestData.phone; + } + + // We want this both operations to be concurrent, so we have to go with Promise.allSettled + const result = await Promise.allSettled([saveGuest(guestData, this.userId), saveRoomInfo(roomData)]); + + const firstError = result.find((item) => item.status === 'rejected'); + if (firstError) { + throw new Error(firstError.reason.error); + } + + await callbacks.run('livechat.saveInfo', await LivechatRooms.findOneById(roomData._id), { + user: this.user, + oldRoom: room, + }); + + return API.v1.success(); + }, + }, +); + +const livechatRoomsEndpoints = API.v1 + .post( + 'livechat/rooms.delete', + { + response: { + 200: POSTLivechatRemoveRoomSuccess, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['remove-closed-livechat-room'], + body: isPOSTLivechatRemoveRoomParams, + }, + async function action() { + const { roomId } = this.bodyParams; + + try { + await removeOmnichannelRoom(roomId); + return API.v1.success(); + } catch (error: unknown) { + if (error instanceof Meteor.Error) { + return API.v1.failure(error.reason); + } + + return API.v1.failure('error-removing-room'); + } + }, + ) + .post( + 'livechat/rooms.removeAllClosedRooms', + { + response: { + 200: isPOSTLivechatRoomsCloseAllSuccessResponse, + }, + authRequired: true, + permissionsRequired: ['remove-closed-livechat-rooms'], + body: isPOSTLivechatRoomsCloseAll, + }, + async function action() { + livechatLogger.info({ msg: 'User is removing all closed rooms', userId: this.userId }); + + const params = this.bodyParams; + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId }); + const promises: Promise[] = []; + await LivechatRooms.findClosedRooms(params?.departmentIds, {}, extraQuery).forEach(({ _id }: IOmnichannelRoom) => { + promises.push(removeOmnichannelRoom(_id)); + }); + await Promise.all(promises); + + livechatLogger.info({ msg: 'User removed closed rooms', userId: this.userId, removedRooms: promises.length }); + return API.v1.success({ removedRooms: promises.length }); + }, + ); + +type LivechatRoomsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatRoomsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/omnichannel/rooms.ts b/apps/meteor/server/api/v1/omnichannel/rooms.ts new file mode 100644 index 0000000..8306255 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/rooms.ts @@ -0,0 +1,92 @@ +import { LivechatRooms } from '@rocket.chat/models'; +import { isGETLivechatRoomsParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findRooms } from './lib/rooms'; +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +const validateDateParams = (property: string, date?: string) => { + let parsedDate: { start?: string; end?: string } | undefined = undefined; + if (date) { + parsedDate = JSON.parse(date) as { start?: string; end?: string }; + } + + if (parsedDate?.start && isNaN(Date.parse(parsedDate.start))) { + throw new Error(`The "${property}.start" query parameter must be a valid date.`); + } + if (parsedDate?.end && isNaN(Date.parse(parsedDate.end))) { + throw new Error(`The "${property}.end" query parameter must be a valid date.`); + } + return parsedDate; +}; + +const isBoolean = (value?: string | boolean): boolean => value === 'true' || value === 'false' || typeof value === 'boolean'; + +API.v1.addRoute( + 'livechat/rooms', + { authRequired: true, validateParams: isGETLivechatRoomsParams }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { agents, departmentId, open, tags, roomName, onhold, queued, units } = this.queryParams; + const { createdAt, customFields, closedAt } = this.queryParams; + + const createdAtParam = validateDateParams('createdAt', createdAt); + const closedAtParam = validateDateParams('closedAt', closedAt); + + const hasAdminAccess = await hasPermissionAsync(this.user, 'view-livechat-rooms'); + const hasAgentAccess = (await hasPermissionAsync(this.user, 'view-l-room')) && agents?.includes(this.userId) && agents?.length === 1; + if (!hasAdminAccess && !hasAgentAccess) { + return API.v1.forbidden(); + } + + let parsedCf: { [key: string]: string } | undefined = undefined; + if (customFields) { + try { + const parsedCustomFields = JSON.parse(customFields) as { [key: string]: string }; + if (typeof parsedCustomFields !== 'object' || Array.isArray(parsedCustomFields) || parsedCustomFields === null) { + throw new Error('Invalid custom fields'); + } + + // Model's already checking for the keys, so we don't need to do it here. + parsedCf = parsedCustomFields; + } catch (e) { + throw new Error('The "customFields" query parameter must be a valid JSON.'); + } + } + + return API.v1.success( + await findRooms({ + agents, + roomName, + departmentId, + ...(isBoolean(open) && { open: open === true || open === 'true' }), + createdAt: createdAtParam, + closedAt: closedAtParam, + tags, + customFields: parsedCf, + onhold, + queued, + units, + query, + options: { offset, count, sort, fields }, + callerId: this.userId, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/rooms/filters', + { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + async get() { + return API.v1.success({ + filters: (await LivechatRooms.findAvailableSources().toArray())[0].fullTypes, + }); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/sms.ts b/apps/meteor/server/api/v1/omnichannel/sms.ts new file mode 100644 index 0000000..c6a823d --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/sms.ts @@ -0,0 +1,286 @@ +import { OmnichannelIntegration } from '@rocket.chat/core-services'; +import type { + ILivechatVisitor, + IUpload, + MessageAttachment, + ServiceData, + FileAttachmentProps, + IOmnichannelRoomInfo, +} from '@rocket.chat/core-typings'; +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { Random } from '@rocket.chat/random'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Meteor } from 'meteor/meteor'; + +import { API } from '../..'; +import { setCustomField } from './lib/customFields'; +import { getFileExtension } from '../../../../lib/utils/getFileExtension'; +import { FileUpload } from '../../../lib/media/file-upload'; +import type { ILivechatMessage } from '../../../lib/omnichannel/localTypes'; +import { sendMessage } from '../../../lib/omnichannel/messages'; +import { createRoom } from '../../../lib/omnichannel/rooms'; +import { settings } from '../../../settings'; + +const logger = new Logger('SMS'); + +const getUploadFile = async (details: Omit, fileUrl: string) => { + const response = await fetch(fileUrl, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + + const content = Buffer.from(await response.arrayBuffer()); + + const contentSize = content.length; + + if (response.status !== 200 || contentSize === 0) { + throw new Meteor.Error('error-invalid-file-uploaded', 'Invalid file uploaded'); + } + + const fileStore = FileUpload.getStore('Uploads'); + + return fileStore.insert({ ...details, size: contentSize }, content); +}; + +const defineDepartment = async (idOrName?: string) => { + if (!idOrName || idOrName === '') { + return; + } + + const department = await LivechatDepartment.findOneByIdOrName(idOrName, { projection: { _id: 1 } }); + return department?._id; +}; + +const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { + const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber); + let data: { token: string; department?: string } = { + token: visitor?.token || Random.id(), + }; + + if (!visitor) { + data = Object.assign(data, { + username: smsNumber.replace(/[^0-9]/g, ''), + phone: { + number: smsNumber, + }, + }); + } + + if (targetDepartment) { + data.department = targetDepartment; + } + + const livechatVisitor = await registerGuest(data, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); + + if (!livechatVisitor) { + throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); + } + + return livechatVisitor; +}; + +const normalizeLocationSharing = (payload: ServiceData) => { + const { extra: { fromLatitude: latitude, fromLongitude: longitude } = {} } = payload; + if (!latitude || !longitude) { + return; + } + + return { + type: 'Point', + coordinates: [parseFloat(longitude), parseFloat(latitude)], + }; +}; + +// @ts-expect-error - this is an special endpoint that requires the return to not be wrapped as regular returns +API.v1.addRoute('livechat/sms-incoming/:service', { + async post() { + const { service } = this.urlParams; + if (!(await OmnichannelIntegration.isConfiguredSmsService(service))) { + return API.v1.failure('Invalid service'); + } + + const smsDepartment = settings.get('SMS_Default_Omnichannel_Department'); + const SMSService = await OmnichannelIntegration.getSmsService(service); + + if (!(await SMSService.validateRequest(this.request.clone(), this.bodyParams))) { + return API.v1.failure('Invalid request'); + } + + const sms = SMSService.parse(this.bodyParams); + const { department } = this.queryParams; + let targetDepartment = await defineDepartment(department || smsDepartment); + if (!targetDepartment) { + targetDepartment = await defineDepartment(smsDepartment); + } + + const visitor = await defineVisitor(sms.from, targetDepartment); + if (!visitor) { + return API.v1.success(SMSService.error(new Error('Invalid visitor'))); + } + + const roomInfo: IOmnichannelRoomInfo = { + sms: { + from: sms.to, + }, + source: { + type: OmnichannelSourceType.SMS, + alias: service, + destination: sms.to, + }, + }; + + const { token } = visitor; + const room = + (await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS)) ?? + (await createRoom({ + visitor, + roomInfo, + })); + const location = normalizeLocationSharing(sms); + const rid = room?._id; + + let file: ILivechatMessage['file']; + const attachments: (MessageAttachment | undefined)[] = []; + + const [media] = sms?.media || []; + if (media) { + const { url: smsUrl, contentType } = media; + const details = { + name: 'Upload File', + type: contentType, + rid, + visitorToken: token, + }; + + try { + const uploadedFile = await getUploadFile(details, smsUrl); + file = { _id: uploadedFile._id, name: uploadedFile.name || 'file', type: uploadedFile.type }; + const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || 'file')}`); + + const fileType = file.type as string; + + if (/^image\/.+/.test(fileType)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + image_url: fileUrl, + image_type: fileType, + image_size: file.size, + }; + + if (file.identify?.size) { + attachment.image_dimensions = file?.identify.size; + } + + attachments.push(attachment); + } else if (/^audio\/.+/.test(fileType)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + audio_url: fileUrl, + audio_type: fileType, + audio_size: file.size, + title_link_download: true, + }; + + attachments.push(attachment); + } else if (/^video\/.+/.test(fileType)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + video_url: fileUrl, + video_type: fileType, + video_size: file.size as number, + title_link_download: true, + }; + + attachments.push(attachment); + } else { + const attachment = { + title: file.name, + type: 'file', + description: file.description, + format: getFileExtension(file.name), + title_link: fileUrl, + title_link_download: true, + size: file.size as number, + }; + + attachments.push(attachment); + } + } catch (err) { + logger.error({ msg: 'Attachment upload failed', err }); + const attachment = { + title: 'Attachment upload failed', + type: 'file', + description: 'An attachment was received, but upload to server failed', + fields: [ + { + title: 'User upload failed', + value: 'An attachment was received, but upload to server failed', + short: true, + }, + ], + color: 'yellow', + }; + + attachments.push(attachment); + } + } + + const messageToSend: { + guest: ILivechatVisitor; + message: ILivechatMessage; + roomInfo: IOmnichannelRoomInfo; + } = { + guest: visitor, + roomInfo, + message: { + _id: Random.id(), + rid, + token, + msg: sms.body, + ...(location && { location }), + ...(attachments && { attachments: attachments.filter((a: any): a is MessageAttachment => !!a) }), + ...(file && { file }), + }, + }; + + try { + await sendMessage(messageToSend); + const msg = SMSService.response(); + setImmediate(async () => { + if (sms.extra) { + if (sms.extra.fromCountry) { + await setCustomField(messageToSend.message.token, 'country', sms.extra.fromCountry); + } + if (sms.extra.fromState) { + await setCustomField(messageToSend.message.token, 'state', sms.extra.fromState); + } + if (sms.extra.fromCity) { + await setCustomField(messageToSend.message.token, 'city', sms.extra.fromCity); + } + if (sms.extra.fromZip) { + await setCustomField(messageToSend.message.token, 'zip', sms.extra.fromZip); + } + } + }); + + return msg; + } catch (e: any) { + return SMSService.error(e); + } + }, +}); diff --git a/apps/meteor/server/api/v1/omnichannel/statistics.ts b/apps/meteor/server/api/v1/omnichannel/statistics.ts new file mode 100644 index 0000000..e3d6e3c --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/statistics.ts @@ -0,0 +1,67 @@ +import { Users } from '@rocket.chat/models'; +import { isLivechatAnalyticsAgentOverviewProps, isLivechatAnalyticsOverviewProps } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { getAgentOverviewDataCached, getAnalyticsOverviewDataCached } from '../../../lib/omnichannel/AnalyticsTyped'; +import { settings } from '../../../settings'; + +API.v1.addRoute( + 'livechat/analytics/agent-overview', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: isLivechatAnalyticsAgentOverviewProps, + }, + { + async get() { + const { name, departmentId, from, to } = this.queryParams; + + if (!name) { + throw new Error('invalid-chart-name'); + } + + const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } }); + return API.v1.success( + await getAgentOverviewDataCached({ + departmentId, + utcOffset: user?.utcOffset || 0, + daterange: { from, to }, + chartOptions: { name }, + executedBy: this.userId, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/analytics/overview', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: isLivechatAnalyticsOverviewProps, + }, + { + async get() { + const { name, departmentId, from, to } = this.queryParams; + + if (!name) { + throw new Error('invalid-chart-name'); + } + + const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1, language: 1 } }); + const language = user?.language || settings.get('Language') || 'en'; + + return API.v1.success( + await getAnalyticsOverviewDataCached({ + departmentId, + utcOffset: user?.utcOffset || 0, + daterange: { from, to }, + analyticsOptions: { name }, + language, + executedBy: this.userId, + }), + ); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/transcript.ts b/apps/meteor/server/api/v1/omnichannel/transcript.ts new file mode 100644 index 0000000..f64b9ed --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/transcript.ts @@ -0,0 +1,73 @@ +import { Omnichannel } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Users } from '@rocket.chat/models'; +import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { i18n } from '../../../lib/i18n'; +import { sendTranscript, requestTranscript } from '../../../lib/omnichannel/sendTranscript'; + +API.v1.addRoute( + 'livechat/transcript', + { validateParams: isPOSTLivechatTranscriptParams }, + { + async post() { + const { token, rid, email } = this.bodyParams; + if (!(await sendTranscript({ token, rid, email }))) { + return API.v1.failure({ message: i18n.t('Error_sending_livechat_transcript') }); + } + + return API.v1.success({ message: i18n.t('Livechat_transcript_sent') }); + }, + }, +); + +API.v1.addRoute( + 'livechat/transcript/:rid', + { + authRequired: true, + permissionsRequired: ['send-omnichannel-chat-transcript'], + validateParams: { + POST: isPOSTLivechatTranscriptRequestParams, + }, + }, + { + async delete() { + const { rid } = this.urlParams; + const room = await LivechatRooms.findOneById>(rid, { + projection: { open: 1, transcriptRequest: 1, v: 1 }, + }); + + if (!room?.open) { + throw new Error('error-invalid-room'); + } + if (!room.transcriptRequest) { + throw new Error('error-transcript-not-requested'); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid); + + return API.v1.success(); + }, + async post() { + const { rid } = this.urlParams; + const { email, subject } = this.bodyParams; + + const user = await Users.findOneById(this.userId, { + projection: { _id: 1, username: 1, name: 1, utcOffset: 1 }, + }); + + if (!user) { + throw new Error('error-invalid-user'); + } + + await requestTranscript({ rid, email, subject, user }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/transfer.ts b/apps/meteor/server/api/v1/omnichannel/transfer.ts new file mode 100644 index 0000000..2364bf5 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/transfer.ts @@ -0,0 +1,33 @@ +import { LivechatRooms } from '@rocket.chat/models'; + +import { API } from '../..'; +import { findLivechatTransferHistory } from './lib/transfer'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/transfer.history/:rid', + { authRequired: true, permissionsRequired: ['view-livechat-rooms'] }, + { + async get() { + const { rid } = this.urlParams; + + const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1 } }); + if (!room) { + throw new Error('invalid-room'); + } + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const history = await findLivechatTransferHistory({ + rid, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(history); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/triggers.ts b/apps/meteor/server/api/v1/omnichannel/triggers.ts new file mode 100644 index 0000000..ffe71dc --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/triggers.ts @@ -0,0 +1,68 @@ +import { LivechatTrigger } from '@rocket.chat/models'; +import { isGETLivechatTriggersParams, isPOSTLivechatTriggersParams } from '@rocket.chat/rest-typings'; + +import { API } from '../..'; +import { findTriggers, findTriggerById, deleteTrigger } from './lib/triggers'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/triggers', + { + authRequired: true, + permissionsRequired: ['view-livechat-manager'], + validateParams: { + GET: isGETLivechatTriggersParams, + POST: isPOSTLivechatTriggersParams, + }, + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const triggers = await findTriggers({ + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(triggers); + }, + async post() { + const { _id, name, description, enabled, runOnce, conditions, actions } = this.bodyParams; + + if (_id) { + await LivechatTrigger.updateById(_id, { name, description, enabled, runOnce, conditions, actions }); + } else { + await LivechatTrigger.insertOne({ name, description, enabled, runOnce, conditions, actions }); + } + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'livechat/triggers/:_id', + { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, + { + async get() { + const trigger = await findTriggerById({ + triggerId: this.urlParams._id, + }); + + return API.v1.success({ + trigger, + }); + }, + async delete() { + await deleteTrigger({ + triggerId: this.urlParams._id, + }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/upload.ts b/apps/meteor/server/api/v1/omnichannel/upload.ts new file mode 100644 index 0000000..f1618cb --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/upload.ts @@ -0,0 +1,75 @@ +import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models'; + +import { API } from '../..'; +import { FileUpload } from '../../../lib/media/file-upload'; +import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions'; +import { sendFileLivechatMessage } from '../../../meteor-methods/omnichannel/sendFileLivechatMessage'; +import { settings } from '../../../settings'; +import { MultipartUploadHandler } from '../../lib/MultipartUploadHandler'; + +API.v1.addRoute('livechat/upload/:rid', { + async post() { + if (!this.request.headers.get('x-visitor-token')) { + return API.v1.forbidden(); + } + + const canUpload = settings.get('Livechat_fileupload_enabled') && settings.get('FileUpload_Enabled'); + + if (!canUpload) { + return API.v1.failure({ + reason: 'error-file-upload-disabled', + }); + } + + const visitorToken = this.request.headers.get('x-visitor-token'); + const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {}); + + if (!visitor) { + return API.v1.forbidden(); + } + + const room = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(this.urlParams.rid, visitorToken as string); + if (!room) { + return API.v1.forbidden(); + } + + const maxFileSize = settings.get('FileUpload_MaxFileSize') || 104857600; + + const { file, fields } = await MultipartUploadHandler.parseRequest(this.request, { + field: 'file', + maxSize: maxFileSize > -1 ? maxFileSize : undefined, + }); + + if (!file) { + return API.v1.failure({ + reason: 'error-no-file-uploaded', + }); + } + + if (!fileUploadIsValidContentType(file.mimetype)) { + return API.v1.failure({ + reason: 'error-type-not-allowed', + }); + } + + const fileStore = FileUpload.getStore('Uploads'); + + const details = { + name: file.filename, + size: file.size, + type: file.mimetype, + rid: this.urlParams.rid, + visitorToken, + }; + + const uploadedFile = await fileStore.insert(details, file.tempFilePath); + if (!uploadedFile) { + return API.v1.failure('Invalid file'); + } + + uploadedFile.description = fields.description; + + delete fields.description; + return API.v1.success(await sendFileLivechatMessage({ roomId: this.urlParams.rid, visitorToken, file: uploadedFile, msgData: fields })); + }, +}); diff --git a/apps/meteor/server/api/v1/omnichannel/users.ts b/apps/meteor/server/api/v1/omnichannel/users.ts new file mode 100644 index 0000000..2243290 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/users.ts @@ -0,0 +1,126 @@ +import { Users } from '@rocket.chat/models'; +import { isLivechatUsersManagerGETProps, isPOSTLivechatUsersTypeProps } from '@rocket.chat/rest-typings'; +import { check } from 'meteor/check'; + +import { API } from '../..'; +import { findAgents, findManagers } from './lib/users'; +import { hasAtLeastOnePermissionAsync } from '../../../lib/authorization/hasPermission'; +import { addManager, addAgent, removeAgent, removeManager } from '../../../lib/omnichannel/omni-users'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +const emptyStringArray: string[] = []; + +API.v1.addRoute( + 'livechat/users/:type', + { + authRequired: true, + permissionsRequired: { + 'POST': ['view-livechat-manager'], + '*': emptyStringArray, + }, + validateParams: { + GET: isLivechatUsersManagerGETProps, + POST: isPOSTLivechatUsersTypeProps, + }, + }, + { + async get() { + check(this.urlParams, { + type: String, + }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { text } = this.queryParams; + + if (this.urlParams.type === 'agent') { + if (!(await hasAtLeastOnePermissionAsync(this.user, ['transfer-livechat-guest', 'edit-omnichannel-contact']))) { + return API.v1.forbidden(); + } + + const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams; + return API.v1.success( + await findAgents({ + text, + onlyAvailable, + excludeId, + showIdleAgents, + pagination: { + offset, + count, + sort, + }, + }), + ); + } + if (this.urlParams.type === 'manager') { + if (!(await hasAtLeastOnePermissionAsync(this.user, ['view-livechat-manager']))) { + return API.v1.forbidden(); + } + + return API.v1.success( + await findManagers({ + text, + pagination: { + offset, + count, + sort, + }, + }), + ); + } + throw new Error('Invalid type'); + }, + async post() { + if (this.urlParams.type === 'agent') { + const user = await addAgent(this.bodyParams.username); + if (user) { + return API.v1.success({ user }); + } + } else if (this.urlParams.type === 'manager') { + const user = await addManager(this.bodyParams.username); + if (user) { + return API.v1.success({ user }); + } + } else { + throw new Error('Invalid type'); + } + + return API.v1.failure(); + }, + }, +); + +API.v1.addRoute( + 'livechat/users/:type/:_id', + { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, + { + async get() { + if (!['agent', 'manager'].includes(this.urlParams.type)) { + throw new Error('Invalid type'); + } + const role = this.urlParams.type === 'agent' ? 'livechat-agent' : 'livechat-manager'; + + const user = await Users.findOneByIdAndRole(this.urlParams._id, role, { + projection: { _id: 1, username: 1, name: 1, status: 1, statusLivechat: 1, emails: 1, livechat: 1 }, + }); + + // TODO: throw error instead of returning null + return API.v1.success({ user }); + }, + async delete() { + if (this.urlParams.type === 'agent') { + if (await removeAgent(this.urlParams._id)) { + return API.v1.success(); + } + } else if (this.urlParams.type === 'manager') { + if (await removeManager(this.urlParams._id)) { + return API.v1.success(); + } + } else { + throw new Error('Invalid type'); + } + + return API.v1.failure(); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/visitor.ts b/apps/meteor/server/api/v1/omnichannel/visitor.ts new file mode 100644 index 0000000..f19c36f --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/visitor.ts @@ -0,0 +1,206 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors as VisitorsRaw, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { API } from '../..'; +import { findGuest, normalizeHttpHeaderData } from './lib/livechat'; +import { callbacks } from '../../../lib/callbacks'; +import { setMultipleVisitorCustomFields } from '../../../lib/omnichannel/custom-fields'; +import { notifyGuestStatusChanged, removeContactsByVisitorId } from '../../../lib/omnichannel/guests'; +import { livechatLogger } from '../../../lib/omnichannel/logger'; +import { saveRoomInfo } from '../../../lib/omnichannel/rooms'; +import { settings } from '../../../settings'; + +API.v1.addRoute( + 'livechat/visitor', + { + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + }, + { + async post() { + check(this.bodyParams, { + visitor: Match.ObjectIncluding({ + token: String, + name: Match.Maybe(String), + email: Match.Maybe(String), + department: Match.Maybe(String), + phone: Match.Maybe(String), + username: Match.Maybe(String), + customFields: Match.Maybe([ + Match.ObjectIncluding({ + key: String, + value: String, + overwrite: Boolean, + }), + ]), + }), + }); + + const { customFields, id, token, name, email, department, phone, username, connectionData } = this.bodyParams.visitor; + + if (!token?.trim()) { + throw new Meteor.Error('error-invalid-token', 'Token cannot be empty', { method: 'livechat/visitor' }); + } + + const guest = { + token, + ...(id && { id }), + ...(name && { name }), + ...(email && { email }), + ...(department && { department }), + ...(username && { username }), + ...(connectionData && { connectionData }), + ...(phone && typeof phone === 'string' && { phone: { number: phone as string } }), + connectionData: normalizeHttpHeaderData(this.request.headers), + }; + + const visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); + if (!visitor) { + throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { + method: 'livechat/visitor', + }); + } + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId }); + // If it's updating an existing visitor, it must also update the roomInfo + const rooms = await LivechatRooms.findOpenByVisitorToken(visitor?.token, {}, extraQuery).toArray(); + await Promise.all( + rooms.map( + (room: IRoom) => + visitor && + saveRoomInfo(room, { + _id: visitor._id, + name: visitor.name, + phone: visitor.phone?.[0]?.phoneNumber, + livechatData: visitor.livechatData as { [k: string]: string }, + }), + ), + ); + + if (!Array.isArray(customFields) || !customFields.length) { + return API.v1.success({ visitor }); + } + + const result = await setMultipleVisitorCustomFields(visitor, customFields); + + if (!result) { + return API.v1.success({ visitor }); + } + + return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) }); + }, + }, +); + +API.v1.addRoute('livechat/visitor/:token', { + async get() { + check(this.urlParams, { + token: String, + }); + + const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {}); + + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + + return API.v1.success({ visitor }); + }, + async delete() { + check(this.urlParams, { + token: String, + }); + + const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {}); + if (!visitor) { + throw new Meteor.Error('invalid-token'); + } + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId }); + const rooms = await LivechatRooms.findOpenByVisitorToken( + this.urlParams.token, + { + projection: { + name: 1, + t: 1, + cl: 1, + u: 1, + usernames: 1, + servedBy: 1, + }, + }, + extraQuery, + ).toArray(); + + // if gdpr is enabled, bypass rooms check + if (rooms?.length && !settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + throw new Meteor.Error('visitor-has-open-rooms', 'Cannot remove visitors with opened rooms'); + } + + const { _id } = visitor; + try { + await removeContactsByVisitorId({ _id }); + return API.v1.success({ + visitor: { + _id, + ts: new Date().toISOString(), + }, + }); + } catch (e) { + livechatLogger.error({ msg: 'Error removing visitor', err: e }); + throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); + } + }, +}); + +API.v1.addRoute( + 'livechat/visitor/:token/room', + { authRequired: true, permissionsRequired: ['view-livechat-manager'] }, + { + async get() { + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId }); + const rooms = await LivechatRooms.findOpenByVisitorToken( + this.urlParams.token, + { + projection: { + name: 1, + t: 1, + cl: 1, + u: 1, + usernames: 1, + servedBy: 1, + }, + }, + extraQuery, + ).toArray(); + return API.v1.success({ rooms }); + }, + }, +); + +API.v1.addRoute('livechat/visitor.status', { + async post() { + check(this.bodyParams, { + token: String, + status: String, + }); + + const { token, status } = this.bodyParams; + + const guest = await findGuest(token); + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + await notifyGuestStatusChanged(token, status); + + return API.v1.success({ token, status }); + }, +}); diff --git a/apps/meteor/server/api/v1/omnichannel/visitors.ts b/apps/meteor/server/api/v1/omnichannel/visitors.ts new file mode 100644 index 0000000..3b48259 --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/visitors.ts @@ -0,0 +1,201 @@ +import { Messages, LivechatRooms } from '@rocket.chat/models'; +import { + isLivechatVisitorsInfoProps, + isGETLivechatVisitorsPagesVisitedRoomIdParams, + isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams, + isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams, + isGETLivechatVisitorsAutocompleteParams, + isLivechatRidMessagesProps, + isGETLivechatVisitorsSearch, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; + +import { API } from '../..'; +import { + findVisitorInfo, + findVisitedPages, + findChatHistory, + searchChats, + findVisitorsToAutocomplete, + findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField, +} from './lib/visitors'; +import { canAccessRoomAsync } from '../../../lib/authorization'; +import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { normalizeMessagesForUser } from '../../../lib/utils/lib/normalizeMessagesForUser'; +import { getPaginationItems } from '../../lib/getPaginationItems'; + +API.v1.addRoute( + 'livechat/visitors.info', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatVisitorsInfoProps }, + { + async get() { + const visitor = await findVisitorInfo({ visitorId: this.queryParams.visitorId }); + return API.v1.success(visitor); + }, + }, +); + +API.v1.addRoute( + 'livechat/visitors.pagesVisited/:roomId', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsPagesVisitedRoomIdParams }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const pages = await findVisitedPages({ + roomId: this.urlParams.roomId, + pagination: { + offset, + count, + sort, + }, + }); + return API.v1.success(pages); + }, + }, +); + +API.v1.addRoute( + 'livechat/visitors.chatHistory/room/:roomId/visitor/:visitorId', + { + authRequired: true, + permissionsRequired: ['view-l-room'], + validateParams: isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams, + }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const history = await findChatHistory({ + userId: this.userId, + roomId: this.urlParams.roomId, + visitorId: this.urlParams.visitorId, + pagination: { + offset, + count, + sort, + }, + }); + + return API.v1.success(history); + }, + }, +); + +API.v1.addRoute( + 'livechat/visitors.searchChats/room/:roomId/visitor/:visitorId', + { + authRequired: true, + permissionsRequired: ['view-l-room'], + validateParams: isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams, + }, + { + async get() { + const { roomId, visitorId } = this.urlParams; + const { searchText, closedChatsOnly, servedChatsOnly, source } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const history = await searchChats({ + userId: this.userId, + roomId, + visitorId, + searchText, + closedChatsOnly, + servedChatsOnly, + source, + pagination: { + offset, + count, + sort, + }, + }); + return API.v1.success(history); + }, + }, +); + +API.v1.addRoute( + 'livechat/visitors.autocomplete', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsAutocompleteParams }, + { + async get() { + const { selector } = this.queryParams; + const parsedSelector = JSON.parse(selector); + + if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) { + apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response); + } + + return API.v1.success( + await findVisitorsToAutocomplete({ + selector: parsedSelector, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/visitors.search', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsSearch }, + { + async get() { + const { term } = this.queryParams; + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const nameOrUsername = term ? new RegExp(escapeRegExp(term), 'i') : undefined; + + return API.v1.success( + await findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({ + emailOrPhone: term, + nameOrUsername, + pagination: { + offset, + count, + sort, + }, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'livechat/:rid/messages', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatRidMessagesProps }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { searchTerm } = this.queryParams; + + const room = await LivechatRooms.findOneById(this.urlParams.rid); + + if (!room) { + throw new Error('invalid-room'); + } + + if (!(await canAccessRoomAsync(room, this.user))) { + throw new Error('not-allowed'); + } + + const { cursor, totalCount } = Messages.findLivechatClosedMessages(this.urlParams.rid, searchTerm, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + offset, + count, + total, + }); + }, + }, +); diff --git a/apps/meteor/server/api/v1/omnichannel/webhooks.ts b/apps/meteor/server/api/v1/omnichannel/webhooks.ts new file mode 100644 index 0000000..d77910e --- /dev/null +++ b/apps/meteor/server/api/v1/omnichannel/webhooks.ts @@ -0,0 +1,95 @@ +import { Logger } from '@rocket.chat/logger'; +import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { API } from '../..'; +import { settings } from '../../../settings'; + +const logger = new Logger('WebhookTest'); + +API.v1.addRoute( + 'livechat/webhook.test', + { authRequired: true, permissionsRequired: ['view-livechat-webhooks'] }, + { + async post() { + const sampleData = { + type: 'LivechatSession', + _id: 'fasd6f5a4sd6f8a4sdf', + label: 'title', + topic: 'asiodojf', + createdAt: new Date(), + lastMessageAt: new Date(), + tags: ['tag1', 'tag2', 'tag3'], + customFields: { + productId: '123456', + }, + visitor: { + _id: '', + name: 'visitor name', + username: 'visitor-username', + department: 'department', + email: 'email@address.com', + phone: '192873192873', + ip: '123.456.7.89', + browser: 'Chrome', + os: 'Linux', + customFields: { + customerId: '123456', + }, + }, + agent: { + _id: 'asdf89as6df8', + username: 'agent.username', + name: 'Agent Name', + email: 'agent@email.com', + }, + messages: [ + { + username: 'visitor-username', + msg: 'message content', + ts: new Date(), + }, + { + username: 'agent.username', + agentId: 'asdf89as6df8', + msg: 'message content from agent', + ts: new Date(), + }, + ], + }; + const options = { + method: 'POST', + headers: { + 'X-RocketChat-Livechat-Token': settings.get('Livechat_secret_token'), + 'Accept': 'application/json', + }, + body: sampleData, + // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, + } as ExtendedFetchOptions; + + const webhookUrl = settings.get('Livechat_webhookUrl'); + + if (!webhookUrl) { + return API.v1.failure('Webhook_URL_not_set'); + } + + try { + logger.debug({ msg: 'Testing webhook', webhookUrl }); + const request = await fetch(webhookUrl, options); + const response = await request.text(); + + logger.debug({ msg: 'Webhook response', response }); + if (request.status === 200) { + return API.v1.success(); + } + + throw new Error('Invalid status code'); + } catch (error) { + logger.error({ msg: 'Error testing webhook', err: error }); + throw new Error('error-invalid-webhook-response'); + } + }, + }, +); diff --git a/apps/meteor/server/api/v1/permissions.ts b/apps/meteor/server/api/v1/permissions.ts new file mode 100644 index 0000000..abb5855 --- /dev/null +++ b/apps/meteor/server/api/v1/permissions.ts @@ -0,0 +1,253 @@ +import type { IPermission } from '@rocket.chat/core-typings'; +import { Permissions, Roles } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { addPermissionToRoleMethod, removeRoleFromPermissionMethod } from '../../lib/authorization/permissionRole'; +import { permissionsGetMethod } from '../../lib/authorization/streamer/permissions'; +import { notifyOnPermissionChangedById } from '../../lib/notifyListener'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type PermissionsListAllProps = { + updatedSince?: string; +}; + +type PermissionsUpdateProps = { + permissions: { _id: string; roles: string[] }[]; +}; + +const permissionListAllSchema = { + type: 'object', + properties: { + updatedSince: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +const permissionUpdatePropsSchema = { + type: 'object', + properties: { + permissions: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + roles: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + }, + additionalProperties: false, + required: ['_id', 'roles'], + }, + }, + }, + required: ['permissions'], + additionalProperties: false, +}; + +const isPermissionsListAll = ajvQuery.compile(permissionListAllSchema); + +const isBodyParamsValidPermissionUpdate = ajv.compile(permissionUpdatePropsSchema); + +type PermissionRolePayload = { permissionId: string; role: string }; + +const isPermissionRolePayload = ajv.compile({ + type: 'object', + properties: { + permissionId: { type: 'string', minLength: 1 }, + role: { type: 'string', minLength: 1 }, + }, + required: ['permissionId', 'role'], + additionalProperties: false, +}); + +const voidPermissionResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const permissionsEndpoints = API.v1 + .get( + 'permissions.listAll', + { + authRequired: true, + query: isPermissionsListAll, + response: { + 200: ajv.compile<{ + update: IPermission[]; + remove: IPermission[]; + }>({ + type: 'object', + properties: { + update: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + remove: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['update', 'remove', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate: Date | undefined; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } + updatedSinceDate = new Date(updatedSince); + } + + const result = (await permissionsGetMethod(updatedSinceDate)) as { + update: IPermission[]; + remove: IPermission[]; + }; + + if (Array.isArray(result)) { + return API.v1.success({ + update: result, + remove: [], + }); + } + + return API.v1.success(result); + }, + ) + .post( + 'permissions.update', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isBodyParamsValidPermissionUpdate, + response: { + 200: ajv.compile<{ + permissions: IPermission[]; + }>({ + type: 'object', + properties: { + permissions: { + type: 'array', + items: { $ref: '#/components/schemas/IPermission' }, + }, + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['permissions', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + if (!isBodyParamsValidPermissionUpdate(bodyParams)) { + return API.v1.failure('Invalid body params', 'error-invalid-body-params'); + } + + const permissionKeys = bodyParams.permissions.map(({ _id }) => _id); + const permissionsCount = await Permissions.countDocuments({ _id: { $in: permissionKeys } }); + + if (permissionsCount !== bodyParams.permissions.length) { + return API.v1.failure('Invalid permission', 'error-invalid-permission'); + } + + const roleKeys = [...new Set(bodyParams.permissions.flatMap((p) => p.roles))]; + + const rolesCount = await Roles.countDocuments({ _id: { $in: roleKeys } }); + + if (rolesCount !== roleKeys.length) { + return API.v1.failure('Invalid role', 'error-invalid-role'); + } + + for (const permission of bodyParams.permissions) { + await Permissions.setRoles(permission._id, permission.roles); + void notifyOnPermissionChangedById(permission._id); + } + + const result = (await permissionsGetMethod()) as IPermission[]; + + return API.v1.success({ + permissions: result, + }); + }, + ) + .post( + 'permissions.addRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isPermissionRolePayload, + response: { + 200: voidPermissionResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { permissionId, role } = this.bodyParams; + await addPermissionToRoleMethod(this.userId, permissionId, role); + return API.v1.success(); + }, + ) + .post( + 'permissions.removeRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isPermissionRolePayload, + response: { + 200: voidPermissionResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { permissionId, role } = this.bodyParams; + await removeRoleFromPermissionMethod(this.userId, permissionId, role); + return API.v1.success(); + }, + ); + +export type PermissionsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends PermissionsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/presence.ts b/apps/meteor/server/api/v1/presence.ts new file mode 100644 index 0000000..28e83ec --- /dev/null +++ b/apps/meteor/server/api/v1/presence.ts @@ -0,0 +1,63 @@ +import { Presence } from '@rocket.chat/core-services'; +import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; + +import { API } from '../api'; + +API.v1.get( + 'presence.getConnections', + { + authRequired: true, + permissionsRequired: ['manage-user-status'], + response: { + 200: ajv.compile<{ current: number; max: number; success: true }>({ + type: 'object', + properties: { + current: { type: 'number' }, + max: { type: 'number' }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['current', 'max', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const result = await Presence.getConnectionCount(); + + return API.v1.success(result); + }, +); + +API.v1.post( + 'presence.enableBroadcast', + { + authRequired: true, + permissionsRequired: ['manage-user-status'], + twoFactorRequired: true, + response: { + 200: ajv.compile<{ success: true }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await Presence.toggleBroadcast(true); + + return API.v1.success({}); + }, +); diff --git a/apps/meteor/server/api/v1/push.ts b/apps/meteor/server/api/v1/push.ts new file mode 100644 index 0000000..013a1e8 --- /dev/null +++ b/apps/meteor/server/api/v1/push.ts @@ -0,0 +1,402 @@ +import { Push } from '@rocket.chat/core-services'; +import { pushTokenTypes } from '@rocket.chat/core-typings'; +import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; +import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; +import { + ajv, + isPushGetProps, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom'; +import PushNotification from '../../lib/notifications/push-config/lib/PushNotification'; +import { executePushTest } from '../../lib/pushConfig'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import type { SuccessResult } from '../definition'; + +type PushTokenPOST = { + id?: string; + type: IPushTokenTypes; + value: string; + appName: string; + voipToken?: string; +}; + +const PushTokenPOSTSchema: JSONSchemaType = { + type: 'object', + properties: { + id: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + enum: pushTokenTypes, + }, + value: { + type: 'string', + minLength: 1, + }, + appName: { + type: 'string', + minLength: 1, + }, + voipToken: { + type: 'string', + nullable: true, + }, + }, + required: ['type', 'value', 'appName'], + additionalProperties: false, +}; + +export const isPushTokenPOSTProps = ajv.compile(PushTokenPOSTSchema); + +type PushTokenDELETE = { + token: string; +}; + +const PushTokenDELETESchema: JSONSchemaType = { + type: 'object', + properties: { + token: { + type: 'string', + minLength: 1, + }, + }, + required: ['token'], + additionalProperties: false, +}; + +export const isPushTokenDELETEProps = ajv.compile(PushTokenDELETESchema); + +type PushTokenResult = Pick; + +/** + * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in + */ +function cleanTokenResult(result: Omit): PushTokenResult { + const { _id, token, appName, userId, enabled, createdAt, _updatedAt, voipToken } = result; + + return { + _id, + token, + appName, + userId, + enabled, + createdAt, + _updatedAt, + voipToken, + }; +} + +const pushTokenEndpoints = API.v1 + .post( + 'push.token', + { + response: { + 200: ajv.compile['body']>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + result: { + type: 'object', + description: 'The updated token data for this device', + properties: { + _id: { + type: 'string', + }, + token: { + type: 'object', + properties: { + apn: { + type: 'string', + }, + gcm: { + type: 'string', + }, + }, + required: [], + additionalProperties: false, + }, + appName: { + type: 'string', + }, + userId: { + type: 'string', + nullable: true, + }, + enabled: { + type: 'boolean', + }, + createdAt: { + type: 'string', + }, + _updatedAt: { + type: 'string', + }, + voipToken: { + type: 'string', + nullable: true, + }, + }, + additionalProperties: false, + }, + }, + required: ['success', 'result'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + body: isPushTokenPOSTProps, + authRequired: true, + }, + async function action() { + const { id, type, value, appName, voipToken } = this.bodyParams; + + if (voipToken && !id) { + return API.v1.failure('voip-tokens-must-specify-device-id'); + } + + const rawToken = this.request.headers.get('x-auth-token'); + if (!rawToken) { + throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); + } + const authToken = Accounts._hashLoginToken(rawToken); + + const result = await Push.registerPushToken({ + ...(id && { _id: id }), + token: { [type]: value } as IPushToken['token'], + authToken, + appName, + userId: this.userId, + ...(voipToken && { voipToken }), + }); + + return API.v1.success({ result: cleanTokenResult(result) }); + }, + ) + .delete( + 'push.token', + { + response: { + 200: ajv.compile({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + }, + }, + required: ['success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isPushTokenDELETEProps, + authRequired: true, + }, + async function action() { + const { token } = this.bodyParams; + + const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId); + + if (removeResult.deletedCount === 0) { + return API.v1.notFound(); + } + + return API.v1.success(); + }, + ); + +const pushGetResponseSchema = ajv.compile<{ + data: { message: IMessage; notification: IPushNotificationConfig }; +}>({ + type: 'object', + properties: { + data: { + type: 'object', + properties: { + message: { type: 'object' }, + notification: { + type: 'object', + properties: { + from: { type: 'string' }, + title: { type: 'string' }, + text: { type: 'string' }, + userId: { type: 'string' }, + badge: { type: 'number', nullable: true }, + sound: { type: 'string', nullable: true }, + priority: { type: 'number', nullable: true }, + payload: { type: 'object', nullable: true }, + notId: { type: 'number', nullable: true }, + gcm: { + type: 'object', + properties: { + style: { type: 'string' }, + image: { type: 'string' }, + }, + required: ['style', 'image'], + nullable: true, + }, + apn: { + type: 'object', + properties: { + category: { type: 'string' }, + topicSuffix: { type: 'string', nullable: true }, + }, + required: ['category'], + nullable: true, + }, + }, + required: ['from', 'title', 'text', 'userId'], + }, + }, + required: ['message', 'notification'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const pushInfoResponseSchema = ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean }>({ + type: 'object', + properties: { + pushGatewayEnabled: { type: 'boolean' }, + defaultPushGateway: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'], + additionalProperties: false, +}); + +const pushGetInfoEndpoints = API.v1 + .get( + 'push.get', + { + authRequired: true, + query: isPushGetProps, + response: { + 200: pushGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { id } = this.queryParams; + + const receiver = await Users.findOneById(this.userId); + if (!receiver) { + throw new Error('error-user-not-found'); + } + + const message = await Messages.findOneById(id); + if (!message) { + throw new Error('error-message-not-found'); + } + + const room = await Rooms.findOneById(message.rid); + if (!room) { + throw new Error('error-room-not-found'); + } + + if (!(await canAccessRoomAsync(room, receiver))) { + throw new Error('error-not-allowed'); + } + + const data = await PushNotification.getNotificationForMessageId({ receiver, room, message }); + + return API.v1.success({ data }); + }, + ) + .get( + 'push.info', + { + authRequired: true, + response: { + 200: pushInfoResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; + const defaultPushGateway = settings.get('Push_gateway') === defaultGateway; + return API.v1.success({ + pushGatewayEnabled: Boolean(settings.get('Push_enable')), + defaultPushGateway, + }); + }, + ); + +const pushTestEndpoints = API.v1.post( + 'push.test', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 1000, + }, + permissionsRequired: ['test-push-notifications'], + body: ajv.compile({ type: 'object', additionalProperties: false }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ tokensCount: number; message: string; params: number[] }>({ + type: 'object', + properties: { + tokensCount: { type: 'integer' }, + // The admin "send a test push" setting renders the outcome straight from this + // response, so it carries the i18n key and its interpolation values. + message: { type: 'string' }, + params: { type: 'array', items: { type: 'integer' } }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['tokensCount', 'message', 'params', 'success'], + additionalProperties: false, + }), + }, + }, + + async function action() { + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(this.userId, this.user.username); + return API.v1.success({ tokensCount, message: 'Your_push_was_sent_to_s_devices', params: [tokensCount] }); + }, +); + +type PushTestEndpoints = ExtractRoutesFromAPI; + +type PushTokenEndpoints = ExtractRoutesFromAPI; + +type PushGetInfoEndpoints = ExtractRoutesFromAPI; + +type PushEndpoints = PushTestEndpoints & PushTokenEndpoints & PushGetInfoEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends PushEndpoints {} +} diff --git a/apps/meteor/server/api/v1/roles.ts b/apps/meteor/server/api/v1/roles.ts new file mode 100644 index 0000000..19b5aad --- /dev/null +++ b/apps/meteor/server/api/v1/roles.ts @@ -0,0 +1,370 @@ +import { api, Authorization } from '@rocket.chat/core-services'; +import type { IRole, IUserInRole } from '@rocket.chat/core-typings'; +import { Roles, Users } from '@rocket.chat/models'; +import { + ajv, + ajvQuery, + isRoleAddUserToRoleProps, + isRoleDeleteProps, + isRoleRemoveUserFromRoleProps, + isRolesGetUsersInRoleProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { getUsersInRolePaginated } from '../../lib/authorization/getUsersInRole'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { hasRoleAsync, hasAnyRoleAsync } from '../../lib/authorization/hasRole'; +import { notifyOnRoleChanged } from '../../lib/notifyListener'; +import { removeUserFromRolesAsync } from '../../lib/roles/removeUserFromRoles'; +import { addUserToRole } from '../../meteor-methods/auth/addUserToRole'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUserFromParams } from '../lib/getUserFromParams'; + +const rolesSyncQuerySchema = ajvQuery.compile<{ updatedSince?: string }>({ + type: 'object', + properties: { updatedSince: { type: 'string' } }, + additionalProperties: false, +}); + +const rolesRoutes = API.v1 + .get( + 'roles.list', + { + authRequired: true, + response: { + 200: ajv.compile<{ roles: Omit[] }>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + description: { type: 'string' }, + mandatory2fa: { type: 'boolean' }, + name: { type: 'string' }, + protected: { type: 'boolean' }, + scope: { type: 'string', enum: ['Users', 'Subscriptions'] }, + }, + required: ['_id', 'description', 'name', 'protected', 'scope'], + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const roles = await Roles.find({}, { projection: { _updatedAt: 0 } }).toArray(); + + return API.v1.success({ roles }); + }, + ) + .get( + 'roles.sync', + { + authRequired: true, + query: rolesSyncQuerySchema, + response: { + 200: ajv.compile<{ roles: { update: IRole[]; remove: IRole[] } }>({ + type: 'object', + properties: { + roles: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IRole' } }, + }, + required: ['update', 'remove'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['roles', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + if (updatedSince && Number.isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-invalid-param', 'updatedSince must be a valid date string'); + } + + return API.v1.success({ + roles: { + update: await Roles.findByUpdatedDate(new Date(updatedSince || 0)).toArray(), + remove: await Roles.trashFindDeletedAfter(new Date(updatedSince || 0)).toArray(), + }, + }); + }, + ) + .post( + 'roles.addUserToRole', + { + authRequired: true, + body: isRoleAddUserToRoleProps, + response: { + 200: ajv.compile<{ role: IRole }>({ + type: 'object', + properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } }, + required: ['role', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + const { roleId, roomId } = this.bodyParams; + + if (!roleId) { + return API.v1.failure('error-invalid-role-properties'); + } + + const role = await Roles.findOneById(roleId); + if (!role) { + return API.v1.failure('error-role-not-found', 'Role not found'); + } + + if (await hasRoleAsync(user._id, role._id, roomId)) { + throw new Meteor.Error('error-user-already-in-role', 'User already in role'); + } + + await addUserToRole(this.userId, role._id, user.username, roomId); + + return API.v1.success({ + role, + }); + }, + ) + .get( + 'roles.getUsersInRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + query: isRolesGetUsersInRoleProps, + response: { + 200: ajv.compile<{ users: IUserInRole[]; total: number }>({ + type: 'object', + properties: { + users: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, role } = this.queryParams; + const { offset, count = 50 } = await getPaginationItems(this.queryParams); + + const projection = { + name: 1, + username: 1, + emails: 1, + avatarETag: 1, + createdAt: 1, + _updatedAt: 1, + }; + + if (!role) { + throw new Meteor.Error('error-param-not-provided', 'Query param "role" is required'); + } + if (roomId && !(await hasPermissionAsync(this.user, 'view-other-user-channels'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const options = { projection: { _id: 1 } }; + const roleData = await Roles.findOneById>(role, options); + + if (!roleData) { + throw new Meteor.Error('error-invalid-roleId'); + } + + const { cursor, totalCount } = await getUsersInRolePaginated(roleData._id, roomId, { + limit: count, + sort: { username: 1 }, + skip: offset, + projection, + }); + + const [users, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ users, total }); + }, + ) + .post( + 'roles.delete', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isRoleDeleteProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + const role = await Roles.findOneByIdOrName(bodyParams.roleId); + + if (!role) { + throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); + } + + if (role.protected) { + throw new Meteor.Error('error-role-protected', 'Cannot delete a protected role'); + } + + if ((await Roles.countUsersInRole(role._id)) > 0) { + throw new Meteor.Error('error-role-in-use', "Cannot delete role because it's in use"); + } + + await Roles.removeById(role._id); + + void notifyOnRoleChanged(role, 'removed'); + + return API.v1.success(); + }, + ) + .post( + 'roles.removeUserFromRole', + { + authRequired: true, + permissionsRequired: ['access-permissions'], + body: isRoleRemoveUserFromRoleProps, + response: { + 200: ajv.compile<{ role: IRole }>({ + type: 'object', + properties: { role: { $ref: '#/components/schemas/IRole' }, success: { type: 'boolean', enum: [true] } }, + required: ['role', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + const { roleId, username, scope } = bodyParams; + + if (!roleId) { + return API.v1.failure('error-invalid-role-properties'); + } + + const user = await Users.findOneByUsername(username); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'There is no user with this username'); + } + + const role = await Roles.findOneById(roleId); + + if (!role) { + throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); + } + + if (!(await hasAnyRoleAsync(user._id, [role._id], scope))) { + throw new Meteor.Error('error-user-not-in-role', 'User is not in this role'); + } + + if (role._id === 'admin') { + const adminCount = await Roles.countUsersInRole('admin'); + if (adminCount === 1) { + throw new Meteor.Error('error-admin-required', 'You need to have at least one admin'); + } + } + + await removeUserFromRolesAsync(user._id, [role._id], scope); + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', { + type: 'removed', + _id: role._id, + u: { + _id: user._id, + username: user.username, + }, + scope, + }); + } + + return API.v1.success({ + role, + }); + }, + ) + .get( + 'roles.getUsersInPublicRoles', + { + authRequired: true, + response: { + 200: ajv.compile<{ + users: { + _id: string; + username: string; + roles: string[]; + }[]; + }>({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + roles: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'success'], + additionalProperties: false, + }), + }, + }, + async () => { + return API.v1.success({ + users: await Authorization.getUsersFromPublicRoles(), + }); + }, + ); + +type RolesEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends RolesEndpoints {} +} diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts new file mode 100644 index 0000000..83ec731 --- /dev/null +++ b/apps/meteor/server/api/v1/rooms.ts @@ -0,0 +1,1833 @@ +import { FederationMatrix, MeteorError, Room, Team } from '@rocket.chat/core-services'; +import { + type IRoom, + type IRoomAbacRedaction, + type IMessage, + type IUpload, + type RequiredField, + type RoomAdminFieldsType, + isPrivateRoom, + isPublicRoom, + type IUser, + type UserStatus, +} from '@rocket.chat/core-typings'; +import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; +import type { Notifications } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + isGETRoomsNameExists, + isRoomsImagesProps, + isRoomsMuteUnmuteUserProps, + isRoomsBanUserProps, + isRoomsUnbanUserProps, + isRoomsBannedUsersProps, + isRoomsExportProps, + isRoomsIsMemberProps, + isRoomsCleanHistoryProps, + isRoomsOpenProps, + isRoomsJoinProps, + isRoomsMembersOrderedByRoleProps, + isRoomsChangeArchivationStateProps, + isRoomsHideProps, + isRoomsInviteProps, + isRoomsCreateDiscussionProps, + isRoomsAdminRoomsProps, + isRoomsAutocompleteAdminRoomsPayload, + isRoomsAdminRoomsGetRoomProps, + isRoomsAutoCompleteChannelAndPrivateProps, + isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + isRoomsAutocompleteAvailableForTeamsProps, + isRoomsSaveRoomSettingsProps, + isRoomsHistoryProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, +} from '@rocket.chat/rest-typings'; +import { isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { adminFields } from '../../../lib/rooms/adminFields'; +import { omit } from '../../../lib/utils/omit'; +import { canAccessRoomAsync, canAccessRoomIdAsync, roomAccessAttributes } from '../../lib/authorization/canAccessRoom'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { stripABACManagedFieldsForAdmin } from '../../lib/authorization/isABACManagedRoom'; +import { banUserFromRoomMethod } from '../../lib/banUserFromRoom'; +import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; +import * as dataExport from '../../lib/dataExport'; +import { eraseRoom } from '../../lib/eraseRoom'; +import { findUsersOfRoomOrderedByRole } from '../../lib/findUsersOfRoomOrderedByRole'; +import { FileUpload } from '../../lib/media/file-upload'; +import { loadRoomHistory, type RoomHistoryResult } from '../../lib/messages/loadRoomHistory'; +import { notifyOnSubscriptionChanged } from '../../lib/notifyListener'; +import { openRoom } from '../../lib/openRoom'; +import type { RoomRoles } from '../../lib/roles/getRoomRoles'; +import { syncRolePrioritiesForRoomIfRequired } from '../../lib/rooms/syncRolePrioritiesForRoomIfRequired'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { unbanUserFromRoom } from '../../lib/unbanUserFromRoom'; +import { createDiscussion } from '../../meteor-methods/messages/createDiscussion'; +import { sendFileMessage } from '../../meteor-methods/messages/sendFileMessage'; +import { executeArchiveRoom } from '../../meteor-methods/rooms/archiveRoom'; +import { cleanRoomHistoryMethod } from '../../meteor-methods/rooms/cleanRoomHistory'; +import { executeGetRoomRoles } from '../../meteor-methods/rooms/getRoomRoles'; +import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom'; +import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom'; +import { muteUserInRoom } from '../../meteor-methods/rooms/muteUserInRoom'; +import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings'; +import { toggleFavoriteMethod } from '../../meteor-methods/rooms/toggleFavorite'; +import { executeUnarchiveRoom } from '../../meteor-methods/rooms/unarchiveRoom'; +import { unmuteUserInRoom } from '../../meteor-methods/rooms/unmuteUserInRoom'; +import { saveNotificationSettingsMethod } from '../../meteor-methods/users/saveNotificationSettings'; +import type { NotificationFieldType } from '../../meteor-methods/users/saveNotificationSettings'; +import { roomsGetMethod } from '../../publications/room'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { MultipartUploadHandler } from '../lib/MultipartUploadHandler'; +import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUserFromParams } from '../lib/getUserFromParams'; +import { + findAdminRoom, + findAdminRooms, + findAdminRoomsAutocomplete, + findChannelAndPrivateAutocomplete, + findChannelAndPrivateAutocompleteWithPagination, + findRoomsAvailableForTeams, +} from '../lib/rooms'; +import { scopeAdminRoomsForAbac } from '../lib/scopeAdminRoomsForAbac'; + +export async function findRoomByIdOrName({ + params, + checkedArchived = true, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + checkedArchived?: boolean; +}): Promise { + if ( + (!('roomId' in params) && !('roomName' in params)) || + ('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName) + ) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" or "roomName" is required'); + } + + const projection = { ...API.v1.defaultFieldsToExclude }; + + let room; + if ('roomId' in params) { + room = await Rooms.findOneById(params.roomId || '', { projection }); + } else if ('roomName' in params) { + room = await Rooms.findOneByName(params.roomName || '', { projection }); + } + + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel'); + } + if (checkedArchived && room.archived) { + throw new Meteor.Error('error-room-archived', `The channel, ${room.name}, is archived`); + } + + return room; +} + +API.v1.get( + 'rooms.nameExists', + { + authRequired: true, + query: isGETRoomsNameExists, + response: { + 200: ajv.compile<{ exists: boolean }>({ + type: 'object', + properties: { + exists: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['exists', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomName } = this.queryParams; + + const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } }); + + return API.v1.success({ exists: !!room }); + }, +); + +const roomDeleteEndpoint = API.v1.post( + 'rooms.delete', + { + authRequired: true, + body: ajv.compile<{ roomId: string }>({ + type: 'object', + properties: { + roomId: { + type: 'string', + description: 'The ID of the room to delete.', + }, + }, + required: ['roomId'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; + + const room = await Rooms.findOneById(roomId); + + if (!room) { + throw new MeteorError('error-invalid-room', 'Invalid room', { + method: 'eraseRoom', + }); + } + + if (room.teamMain) { + throw new Meteor.Error('error-cannot-delete-team-channel', 'Cannot delete a team channel', { + method: 'eraseRoom', + }); + } + + await eraseRoom(room, this.user); + + return API.v1.success(); + }, +); + +API.v1.get( + 'rooms.get', + { + authRequired: true, + response: { + 200: ajv.compile<{ update: IRoom[]; remove: IRoom[] }>({ + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + remove: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + success: { type: 'boolean', enum: [true] }, + }, + required: ['update', 'remove', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); + } + } + + let result = await roomsGetMethod(this.userId, updatedSinceDate); + + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } + + return API.v1.success({ + update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))), + remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))), + }); + }, +); + +API.v1.addRoute( + 'rooms.media/:rid', + { authRequired: true }, + { + async post() { + if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) { + return API.v1.forbidden(); + } + + const { file, fields } = await MultipartUploadHandler.parseRequest(this.incoming, { + field: 'file', + maxSize: settings.get('FileUpload_MaxFileSize'), + }); + + if (!file) { + throw new Meteor.Error('error-no-file-uploaded', 'No file was uploaded'); + } + + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 24); + + let content; + + if (fields.content) { + try { + content = JSON.parse(fields.content); + } catch (e) { + console.error(e); + throw new Meteor.Error('invalid-field-content'); + } + } + + const details = { + name: file.filename, + size: file.size, + type: file.mimetype, + rid: this.urlParams.rid, + userId: this.userId, + content, + expiresAt, + }; + + // TODO: In the future, we should isolate file receival from storage and post-processing. + const fileStore = FileUpload.getStore('Uploads'); + const uploadedFile = await fileStore.insert(details, file.tempFilePath); + + uploadedFile.path = FileUpload.getPath(`${uploadedFile._id}/${encodeURI(uploadedFile.name || '')}`); + + await Uploads.updateFileComplete(uploadedFile._id, this.userId, omit(uploadedFile, '_id')); + + return API.v1.success({ + file: { + _id: uploadedFile._id, + url: uploadedFile.path, + }, + }); + }, + }, +); + +API.v1.addRoute( + 'rooms.mediaConfirm/:rid/:fileId', + { authRequired: true }, + { + async post() { + if (!(await canAccessRoomIdAsync(this.urlParams.rid, this.userId))) { + return API.v1.forbidden(); + } + + const file = await Uploads.findOneByIdAndUserIdAndRoomId(this.urlParams.fileId, this.userId, this.urlParams.rid); + + if (!file) { + throw new Meteor.Error('invalid-file'); + } + + if ((this.bodyParams.description?.length ?? 0) > settings.get('Message_MaxAllowedSize')) { + throw new Meteor.Error('error-message-size-exceeded'); + } + + file.description = this.bodyParams.description; + delete this.bodyParams.description; + + if (this.bodyParams.fileName) { + file.name = this.bodyParams.fileName; + delete this.bodyParams.fileName; + } + + if (this.bodyParams.fileContent) { + file.content = this.bodyParams.fileContent; + delete this.bodyParams.fileContent; + } + + await applyAirGappedRestrictionsValidation(() => + sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }), + ); + + await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId); + + const message = await Messages.getMessageByFileIdAndUsername(file._id, this.userId); + + return API.v1.success({ + message, + }); + }, + }, +); + +const saveNotificationBodySchema = ajv.compile<{ + roomId: string; + notifications: Record; +}>({ + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + notifications: { + type: 'object', + minProperties: 1, + additionalProperties: { type: 'string' }, + }, + }, + required: ['roomId', 'notifications'], + additionalProperties: false, +}); + +const saveNotificationResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const roomsSaveNotificationEndpoint = API.v1.post( + 'rooms.saveNotification', + { + authRequired: true, + body: saveNotificationBodySchema, + response: { + 200: saveNotificationResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, notifications } = this.bodyParams; + + await Promise.all( + Object.entries(notifications as Notifications).map(async ([notificationKey, notificationValue]) => + saveNotificationSettingsMethod(this.userId, roomId, notificationKey as NotificationFieldType, notificationValue), + ), + ); + + return API.v1.success({ success: true }); + }, +); + +const saveDraftBodySchema = ajv.compile<{ rid: IRoom['_id']; draft: string; tmid?: IMessage['_id'] }>({ + type: 'object', + properties: { + rid: { type: 'string', minLength: 1 }, + draft: { type: 'string' }, + tmid: { type: 'string', minLength: 1, pattern: '^[^.$]+$' }, + }, + required: ['rid', 'draft'], + additionalProperties: false, +}); + +const saveDraftResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const roomsSaveDraftEndpoint = API.v1.post( + 'rooms.saveDraft', + { + authRequired: true, + body: saveDraftBodySchema, + response: { + 200: saveDraftResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, draft, tmid } = this.bodyParams; + + if (draft.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { + return API.v1.failure('error-message-size-exceeded'); + } + + const subscription = await Subscriptions.updateDraftByRoomIdAndUserId(rid, this.userId, draft || undefined, tmid); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription'); + } + + void notifyOnSubscriptionChanged(subscription); + + return API.v1.success(); + }, +); + +API.v1.post( + 'rooms.cleanHistory', + { + authRequired: true, + body: isRoomsCleanHistoryProps, + response: { + 200: ajv.compile<{ _id: string; count: number }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + const { _id } = room; + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); + } + + const { latest, oldest, inclusive = false, limit, excludePinned, filesOnly, ignoreThreads, ignoreDiscussion, users } = this.bodyParams; + + const count = await cleanRoomHistoryMethod(this.userId, { + roomId: _id, + latest: new Date(latest), + oldest: new Date(oldest), + inclusive, + limit, + excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false), + filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false), + ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false), + ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false), + fromUsers: users?.filter(isTruthy) || [], + }); + + return API.v1.success({ _id, count }); + }, +); + +API.v1.get( + 'rooms.info', + { + authRequired: true, + response: { + 200: ajv.compile<{ room: IRoom | null }>({ + type: 'object', + properties: { + room: { type: ['object', 'null'] }, + team: { type: 'object' }, + parent: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams, checkedArchived: false }); + const { fields } = await this.parseJsonQuery(); + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + + const discussionParent = + room.prid && + (await Rooms.findOneById>(room.prid, { + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, + })); + const { team, parentRoom } = await Team.getRoomInfo(room); + const parent = discussionParent || parentRoom; + + return API.v1.success({ + room: await Rooms.findOneByIdOrName(room._id, { projection: fields }), + ...(team && { team }), + ...(parent && { parent }), + }); + }, +); + +API.v1.post( + 'rooms.createDiscussion', + { + authRequired: true, + body: isRoomsCreateDiscussionProps, + response: { + 200: ajv.compile<{ discussion: IRoom }>({ + type: 'object', + properties: { + discussion: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussion', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; + + const discussion = await applyAirGappedRestrictionsValidation(() => + createDiscussion(this.userId, { + prid, + pmid, + t_name, + reply, + users: users?.filter(isTruthy) || [], + encrypted, + topic, + }), + ); + + return API.v1.success({ discussion }); + }, +); + +API.v1.get( + 'rooms.getDiscussions', + { + authRequired: true, + response: { + 200: ajv.compile<{ discussions: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + discussions: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussions', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + + const ourQuery = Object.assign(query, { prid: room._id }); + + const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { + sort: sort || { fname: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + discussions, + count: discussions.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'rooms.images', + { + authRequired: true, + query: isRoomsImagesProps, + response: { + 200: ajv.compile<{ files: IUpload[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with user transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const room = await Rooms.findOneById>(this.queryParams.roomId, { + projection: { t: 1, teamId: 1, prid: 1 }, + }); + + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.forbidden(); + } + + let initialImage: IUpload | null = null; + if (this.queryParams.startingFromId) { + initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + if (initialImage && initialImage.rid !== room._id) { + initialImage = null; + } + } + + const { offset, count } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { + skip: offset, + limit: count, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + // If the initial image was not returned in the query, insert it as the first element of the list + if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { + files.splice(0, 0, initialImage); + } + + return API.v1.success({ + files, + count, + offset, + total, + }); + }, +); + +API.v1.get( + 'rooms.adminRooms', + { + authRequired: true, + query: isRoomsAdminRoomsProps, + response: { + 200: ajv.compile<{ + rooms: Array & IRoomAbacRedaction>; + count: number; + offset: number; + total: number; + }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + optional ABAC redaction + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { types, filter } = this.queryParams; + + return API.v1.success( + await findAdminRooms({ + uid: this.userId, + filter: filter || '', + types: types ?? [], + pagination: { + offset, + count, + sort, + }, + }), + ); + }, +); + +API.v1.get( + 'rooms.autocomplete.adminRooms', + { + authRequired: true, + query: isRoomsAutocompleteAdminRoomsPayload, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findAdminRoomsAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, +); + +API.v1.get( + 'rooms.adminRooms.getRoom', + { + authRequired: true, + query: isRoomsAdminRoomsGetRoomProps, + response: { + 200: ajv.compile & IRoomAbacRedaction>({ + allOf: [ + { $ref: '#/components/schemas/IRoomAdmin' }, + { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + abacAttributesRedacted: { type: 'boolean' }, + }, + required: ['success'], + }, + ], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid } = this.queryParams; + const room = await findAdminRoom({ + uid: this.userId, + rid: rid || '', + }); + + if (!room) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + return API.v1.success(room); + }, +); + +API.v1.get( + 'rooms.autocomplete.channelAndPrivate', + { + authRequired: true, + query: isRoomsAutoCompleteChannelAndPrivateProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findChannelAndPrivateAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, +); + +API.v1.get( + 'rooms.autocomplete.channelAndPrivate.withPagination', + { + authRequired: true, + query: isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + response: { + 200: ajv.compile<{ items: IRoom[]; total: number }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + return API.v1.success( + await findChannelAndPrivateAutocompleteWithPagination({ + uid: this.userId, + selector: JSON.parse(selector), + pagination: { + offset, + count, + sort, + }, + }), + ); + }, +); + +API.v1.get( + 'rooms.autocomplete.availableForTeams', + { + authRequired: true, + query: isRoomsAutocompleteAvailableForTeamsProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name } = this.queryParams; + + return API.v1.success( + await findRoomsAvailableForTeams({ + uid: this.userId, + name, + }), + ); + }, +); + +API.v1.post( + 'rooms.saveRoomSettings', + { + authRequired: true, + body: isRoomsSaveRoomSettingsProps, + response: { + 200: ajv.compile<{ rid: string }>({ + type: 'object', + properties: { + rid: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rid', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, ...params } = this.bodyParams; + + const result = await saveRoomSettings(this.userId, rid, params); + + return API.v1.success({ rid: result.rid }); + }, +); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'rooms.changeArchivationState', + { + authRequired: true, + body: isRoomsChangeArchivationStateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, action } = this.bodyParams; + + if (action === 'archive') { + await executeArchiveRoom(this.userId, rid); + } else { + await executeUnarchiveRoom(this.userId, rid); + } + + return API.v1.success(); + }, +); + +API.v1.post( + 'rooms.export', + { + authRequired: true, + body: isRoomsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + missing: { type: 'array', items: { type: 'string' } }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, type } = this.bodyParams; + + if (!(await hasPermissionAsync(this.user, 'mail-messages', rid))) { + throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room'); + } + + const user = await Users.findOneById(this.userId); + + if (!user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } + + if (type === 'file') { + const { dateFrom, dateTo } = this.bodyParams; + const { format } = this.bodyParams; + + const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); + const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); + convertedDateTo.setDate(convertedDateTo.getDate() + 1); + + if (convertedDateFrom > convertedDateTo) { + throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date'); + } + + void dataExport.sendFile( + { + rid, + format, + dateFrom: convertedDateFrom, + dateTo: convertedDateTo, + }, + user, + ); + return API.v1.success(); + } + + if (type === 'email') { + const { toUsers, toEmails, subject, messages } = this.bodyParams; + + if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) { + throw new Meteor.Error('error-invalid-recipient'); + } + + const result = await dataExport.sendViaEmail( + { + rid, + toUsers: (toUsers as string[]) || [], + toEmails: toEmails || [], + subject: subject || '', + messages: messages || [], + language: user.language || 'en', + }, + user, + ); + + return API.v1.success(result); + } + + return API.v1.failure(); + }, +); + +API.v1.get( + 'rooms.isMember', + { + authRequired: true, + query: isRoomsIsMemberProps, + response: { + 200: ajv.compile<{ isMember: boolean }>({ + type: 'object', + properties: { + isMember: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['isMember', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const usernameOrUserId = 'userId' in this.queryParams ? this.queryParams.userId : this.queryParams.username; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }), + Users.findOneByIdOrUsername(usernameOrUserId), + ]); + + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } + + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.forbidden(); + }, +); + +API.v1.get( + 'rooms.membersOrderedByRole', + { + authRequired: true, + query: isRoomsMembersOrderedByRoleProps, + response: { + 200: ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser with role priority + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const findResult = await findRoomByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, this.user))) { + return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); + } + + if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { + return API.v1.failure('error-room-type-not-supported'); + } + + if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } + + // Ensures that role priorities for the specified room are synchronized correctly. + // This function acts as a soft migration. If the `roomRolePriorities` field + // for the room has already been created and is up-to-date, no updates will be performed. + // If not, it will synchronize the role priorities of the users of the room. + await syncRolePrioritiesForRoomIfRequired(findResult._id); + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult._id, + ...(status && { status: status as UserStatus[] }), + hidden: await getUsersHiddenFrom(this.userId), + skip, + limit, + filter, + sort, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, +); + +API.v1.post( + 'rooms.muteUser', + { + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, +); + +API.v1.post( + 'rooms.unmuteUser', + { + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, +); + +API.v1.post( + 'rooms.open', + { + authRequired: true, + body: isRoomsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; + + await openRoom(this.userId, roomId); + + return API.v1.success(); + }, +); + +API.v1.post( + 'rooms.join', + { + authRequired: true, + body: isRoomsJoinProps, + response: { + 200: ajv.compile<{ room: IRoom }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { joinCode, ...params } = this.bodyParams; + const room = await findRoomByIdOrName({ params }); + + await Room.join({ room, user: this.user, joinCode }); + + return API.v1.success({ + room: await findRoomByIdOrName({ params }), + }); + }, +); + +API.v1.post( + 'rooms.hide', + { + authRequired: true, + body: isRoomsHideProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + return API.v1.unauthorized(); + } + + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); + + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + const modCount = await hideRoomMethod(this.userId, roomId); + + if (!modCount) { + return API.v1.failure('error-room-already-hidden'); + } + + return API.v1.success(); + }, +); + +type RoomsFavorite = + | { + roomId: string; + favorite: boolean; + } + | { + roomName: string; + favorite: boolean; + }; + +type RoomsLeave = + | { + roomId: string; + } + | { + roomName: string; + }; + +const isRoomGetRolesPropsSchema = { + type: 'object', + properties: { + rid: { type: 'string' }, + }, + additionalProperties: false, + required: ['rid'], +}; + +const RoomsFavoriteSchema = { + anyOf: [ + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomName: { type: 'string' }, + }, + required: ['roomName', 'favorite'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + favorite: { type: 'boolean' }, + roomId: { type: 'string' }, + }, + required: ['roomId', 'favorite'], + additionalProperties: false, + }, + ], +}; + +const isRoomsLeavePropsSchema = { + anyOf: [ + { + type: 'object', + properties: { + roomId: { type: 'string' }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + roomName: { type: 'string' }, + }, + required: ['roomName'], + additionalProperties: false, + }, + ], +}; + +const isRoomsFavoriteProps = ajv.compile(RoomsFavoriteSchema); +const isRoomsLeaveProps = ajv.compile(isRoomsLeavePropsSchema); +const roomsBannedUsersResponseSchema = ajv.compile<{ + success: true; + bannedUsers: RequiredField, '_id' | 'username'>[]; + count: number; + offset: number; + total: number; +}>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + bannedUsers: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + }, + required: ['success', 'bannedUsers', 'count', 'offset', 'total'], + additionalProperties: false, +}); + +const roomsHistoryResponseSchema = ajv.compile({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + cursor: { + type: 'object', + properties: { + next: { type: 'string', nullable: true }, + previous: { type: 'string', nullable: true }, + }, + required: ['next', 'previous'], + additionalProperties: false, + }, + firstUnread: { $ref: '#/components/schemas/IMessage' }, + unreadNotLoaded: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'cursor', 'success'], + additionalProperties: false, +}); + +export const roomEndpoints = API.v1 + .get( + 'rooms.roles', + { + authRequired: true, + query: ajvQuery.compile<{ + rid: string; + }>(isRoomGetRolesPropsSchema), + response: { + 200: ajv.compile<{ + roles: RoomRoles[]; + }>({ + type: 'object', + properties: { + roles: { + type: 'array', + items: { + type: 'object', + properties: { + rid: { type: 'string' }, + u: { + type: 'object', + properties: { _id: { type: 'string' }, username: { type: 'string' } }, + required: ['_id', 'username'], + }, + roles: { type: 'array', items: { type: 'string' } }, + }, + required: ['rid', 'u', 'roles'], + }, + }, + }, + required: ['roles'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function () { + const { rid } = this.queryParams; + const roles = await executeGetRoomRoles(rid, this.user); + + return API.v1.success({ + roles, + }); + }, + ) + .get( + 'rooms.adminRooms.privateRooms', + { + authRequired: true, + permissionsRequired: ['view-room-administration'], + query: ajvQuery.compile<{ + filter?: string; + offset?: number; + count?: number; + sort?: string; + }>({ + type: 'object', + properties: { + filter: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + }, + additionalProperties: true, + }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + rooms: Array & IRoomAbacRedaction>; + count: number; + offset: number; + total: number; + }>({ + type: 'object', + properties: { + rooms: { + type: 'array', + items: { type: 'object' }, // relaxed: IRoom subset + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter } = this.queryParams; + + const name = (filter || '').trim(); + + const { cursor, totalCount } = Rooms.findPrivateRoomsAndTeamsPaginated(name, { + skip: offset, + limit: count, + sort: sort || { default: -1, name: 1 }, + projection: adminFields, + }); + + const [rooms, total] = await Promise.all([cursor.map(stripABACManagedFieldsForAdmin).toArray(), totalCount]); + + return API.v1.success({ + rooms: await scopeAdminRoomsForAbac(rooms, this.userId), + count: rooms.length, + offset, + total, + }); + }, + ) + .post( + 'rooms.invite', + { + authRequired: true, + body: isRoomsInviteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { roomId, action } = this.bodyParams; + + try { + await FederationMatrix.handleInvite(roomId, this.userId, action); + return API.v1.success(); + } catch (error) { + return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); + } + }, + ) + .post( + 'rooms.favorite', + { + authRequired: true, + body: isRoomsFavoriteProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { favorite } = this.bodyParams; + + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + await toggleFavoriteMethod(this.userId, room._id, favorite); + + return API.v1.success(); + }, + ) + .post( + 'rooms.leave', + { + authRequired: true, + body: isRoomsLeaveProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + + const user = await Users.findOneById(this.userId); + + if (!user) { + return API.v1.failure('error-invalid-user'); + } + + await leaveRoomMethod(user, room._id); + + return API.v1.success(); + }, + ) + .post( + 'rooms.banUser', + { + authRequired: true, + body: isRoomsBanUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await banUserFromRoomMethod(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, + ) + .post( + 'rooms.unbanUser', + { + authRequired: true, + body: isRoomsUnbanUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams, true); + + if (!user.username) { + return API.v1.failure('Invalid user'); + } + + await unbanUserFromRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + + return API.v1.success(); + }, + ) + .get( + 'rooms.bannedUsers', + { + authRequired: true, + query: isRoomsBannedUsersProps, + response: { + 200: roomsBannedUsersResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + return API.v1.unauthorized(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + + const { cursor, totalCount } = Subscriptions.findPaginated( + { rid: roomId, status: 'BANNED' as const }, + { sort: { ts: 1 }, skip: offset, limit: count, projection: { 'u._id': 1 } }, + ); + + const [bannedSubs, total] = await Promise.all([cursor.toArray(), totalCount]); + + const userIds = bannedSubs.map((sub) => sub.u._id); + const users = await Users.find, '_id' | 'username'>>( + { _id: { $in: userIds } }, + { projection: { username: 1, name: 1 } }, + ).toArray(); + + return API.v1.success({ + bannedUsers: users, + count: users.length, + offset, + total, + }); + }, + ) + .get( + 'rooms.history', + { + authOrAnonRequired: true, + query: isRoomsHistoryProps, + response: { + 200: roomsHistoryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { roomId, next, previous, aroundId, lastSeen, showThreadMessages = true } = this.queryParams; + + if ([next, previous, aroundId].filter(Boolean).length > 1) { + throw new MeteorError('error-cursor-conflict', 'Only one of "next", "previous" and "aroundId" can be provided'); + } + + // Defaults to 20 (matching the replaced DDP method) instead of API_Default_Count, but still + // honors the API_Upper_Count_Limit cap. + const { count } = await getPaginationItems({ count: this.queryParams.count ?? 20 }); + + const room = await Rooms.findOneById(roomId, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } }); + + if (!room) { + return API.v1.notFound(); + } + + // Also covers anonymous reads: the auth middleware already gated on Accounts_AllowAnonymousRead. + if (!(await canAccessRoomAsync(room, this.user))) { + return API.v1.forbidden(); + } + + if ( + this.userId && + room.t === 'c' && + !(await hasPermissionAsync(this.userId, 'preview-c-room')) && + !(await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } })) + ) { + return API.v1.forbidden(); + } + + let around: IMessage | undefined; + if (aroundId) { + const message = await Messages.findOneVisibleByRoomIdAndMessageId(roomId, aroundId); + if (!message) { + return API.v1.notFound(); + } + around = message; + } + + const result = await loadRoomHistory({ + userId: this.userId, + next, + previous, + around, + lastSeen: lastSeen ? new Date(lastSeen) : undefined, + count, + showThreadMessages, + room, + }); + + return API.v1.success(result); + }, + ); +type RoomEndpoints = ExtractRoutesFromAPI & + ExtractRoutesFromAPI & + ExtractRoutesFromAPI & + ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends RoomEndpoints {} +} diff --git a/apps/meteor/server/api/v1/saml.ts b/apps/meteor/server/api/v1/saml.ts new file mode 100644 index 0000000..b11fd93 --- /dev/null +++ b/apps/meteor/server/api/v1/saml.ts @@ -0,0 +1,82 @@ +import { + isSamlParseMetadata, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateSamlParseMetadataSuccessResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { FetchError, serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { parseIdpMetadata } from '../../lib/saml/lib/parsers/IdpMetadata'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +const samlEndpoints = API.v1.post( + 'saml.parseMetadata', + { + authRequired: true, + permissionsRequired: ['test-admin-options'], + body: isSamlParseMetadata, + response: { + 200: validateSamlParseMetadataSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { url } = this.bodyParams; + + let response; + try { + response = await fetch( + url, + { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + timeout: 20_000, + size: 1_000_000, + headers: { Accept: 'application/samlmetadata+xml, application/xml, text/xml' }, + }, + settings.get('Allow_Invalid_SelfSigned_Certs'), + ); + } catch (err) { + this.logger.error({ msg: 'Failed to fetch SAML IdP metadata', err }); + if (err instanceof Error && err.message === 'error-ssrf-validation-failed') { + return API.v1.failure('SAML_Metadata_url_blocked'); + } + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + if (!response.ok) { + response.body.resume(); + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + let xml: string; + try { + xml = await response.text(); + } catch (err) { + if (err instanceof FetchError && err.type === 'max-size') { + return API.v1.failure('SAML_Metadata_too_large'); + } + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + try { + const { warnings, ...values } = parseIdpMetadata(xml); + return API.v1.success({ ...values, warnings }); + } catch (err) { + this.logger.warn({ msg: 'Failed to parse SAML IdP metadata', err }); + return API.v1.failure('SAML_Metadata_invalid'); + } + }, +); + +export type SAMLEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type + interface Endpoints extends SAMLEndpoints {} +} diff --git a/apps/meteor/server/api/v1/settings.ts b/apps/meteor/server/api/v1/settings.ts new file mode 100644 index 0000000..a254056 --- /dev/null +++ b/apps/meteor/server/api/v1/settings.ts @@ -0,0 +1,536 @@ +import type { + FacebookOAuthConfiguration, + ISetting, + ISettingColor, + LoginServiceConfiguration, + TwitterOAuthConfiguration, + OAuthConfiguration, +} from '@rocket.chat/core-typings'; +import { isActionSettingWithEndpoint, isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models'; +import { + ajv, + isSettingsUpdatePropDefault, + isSettingsUpdatePropsActions, + isSettingsUpdatePropsColor, + isSettingsPublicWithPaginationProps, + isSettingsGetParams, + isSettingsBulkProps, + validateForbiddenErrorResponse, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions } from 'mongodb'; +import _ from 'underscore'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { refreshLoginServices } from '../../lib/refreshLoginServices'; +import { SettingValidationError, validateSettingRules } from '../../lib/settingValidationRules'; +import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; +import { addOAuthServiceMethod } from '../../meteor-methods/auth/addOAuthService'; +import { removeCustomOAuthSettings } from '../../meteor-methods/auth/removeOAuthService'; +import { SettingsEvents, settings } from '../../settings'; +import type { FetchedSetting } from '../../settings/SettingsRegistry'; +import { checkSettingValueBounds } from '../../settings/checkSettingValueBonds'; +import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates'; +import { saveSettingsBulk } from '../../settings/lib/saveSettingsBulk'; +import { setValue } from '../../settings/raw'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +async function fetchSettings( + query: Parameters[0], + sort: FindOptions['sort'], + offset: FindOptions['skip'], + count: FindOptions['limit'], + fields: FindOptions['projection'], +): Promise<{ settings: FetchedSetting[]; totalCount: number }> { + const { cursor, totalCount } = Settings.findPaginated(query || {}, { + sort: sort || { _id: 1 }, + skip: offset, + limit: count, + projection: { _id: 1, value: 1, enterprise: 1, invalidValue: 1, modules: 1, ...fields }, + }); + + const [settingsList, total] = await Promise.all([cursor.toArray(), totalCount]); + + SettingsEvents.emit('fetch-settings', settingsList); + return { settings: settingsList, totalCount: total }; +} + +const settingsPublicResponseSchema = ajv.compile<{ settings: FetchedSetting[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const settingsOAuthResponseSchema = ajv.compile<{ services: Partial[] }>({ + type: 'object', + properties: { + services: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['services', 'success'], + additionalProperties: false, +}); + +const addCustomOAuthBodySchema = ajv.compile<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, +}); + +const settingsListResponseSchema = ajv.compile<{ settings: FetchedSetting[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const settingByIdGetResponseSchema = ajv.compile>({ + type: 'object', + properties: { + _id: { type: 'string' }, + value: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'value', 'success'], + additionalProperties: false, +}); + +const settingByIdPostResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const settingsUpdateBodySchema = ajv.compile<{ value?: unknown; execute?: boolean; editor?: string }>({ + type: 'object', + properties: { + value: {}, + execute: { type: 'boolean' }, + editor: { type: 'string' }, + }, + additionalProperties: true, +}); + +const serviceConfigurationsResponseSchema = ajv.compile<{ configurations: LoginServiceConfiguration[] }>({ + type: 'object', + properties: { + configurations: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['configurations', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'settings.public', + { + authRequired: false, + query: isSettingsPublicWithPaginationProps, + response: { + 200: settingsPublicResponseSchema, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { _id } = this.queryParams; + + const parsedQueryId = typeof _id === 'string' && _id ? { _id: { $in: _id.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + ...query, + ...parsedQueryId, + hidden: { $ne: true }, + public: true, + }; + + const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); + + return API.v1.success({ + settings: settingsList, + count: settingsList.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'settings.oauth', + { + authRequired: false, + response: { + 200: settingsOAuthResponseSchema, + }, + }, + async function action() { + const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); + const isPassportFlowEnabled = settings.get('Accounts_OAuth_Use_Modern_Flow'); + + return API.v1.success({ + services: oAuthServicesEnabled.map((service) => { + if (!service) { + return service; + } + + // CAUTION: Never hide sign-in with apple button from mobile app. + if (service.service && ['apple'].includes(service.service)) { + return { ...service, hideButtonOnMobile: false }; + } + + if (service.service && ['cas', 'ldap'].includes(service.service)) { + return { ...service, hideButtonOnMobile: false }; + } + + if ( + (service as OAuthConfiguration).custom || + (service.service && (service.service === 'wordpress' || service.service === 'saml')) + ) { + return { ...service, hideButtonOnMobile: isPassportFlowEnabled }; + } + + return { + _id: service._id, + name: service.service, + clientId: + (service as FacebookOAuthConfiguration).appId || + (service as OAuthConfiguration).clientId || + (service as TwitterOAuthConfiguration).consumerKey, + buttonLabelText: service.buttonLabelText || '', + buttonColor: service.buttonColor || '', + buttonLabelColor: service.buttonLabelColor || '', + custom: false, + hideButtonOnMobile: isPassportFlowEnabled, + }; + }), + }); + }, +); + +API.v1.post( + 'settings.addCustomOAuth', + { + authRequired: true, + twoFactorRequired: true, + permissionsRequired: { + POST: { permissions: ['add-oauth-service'], operation: 'hasAll' }, + }, + body: addCustomOAuthBodySchema, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { name } = this.bodyParams; + if (!name?.trim()) { + throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); + } + + await addOAuthServiceMethod(this.userId, name); + + return API.v1.success(); + }, +); + +API.v1.post( + 'settings.removeCustomOAuth', + { + authRequired: true, + twoFactorRequired: true, + permissionsRequired: { + POST: { permissions: ['add-oauth-service'], operation: 'hasAll' }, + }, + body: addCustomOAuthBodySchema, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { name } = this.bodyParams; + if (!name?.trim()) { + throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); + } + + await removeCustomOAuthSettings(name); + + return API.v1.success(); + }, +); + +API.v1.post( + 'settings.refreshOAuthServices', + { + authRequired: true, + twoFactorRequired: true, + permissionsRequired: { + POST: { permissions: ['add-oauth-service'], operation: 'hasAll' }, + }, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await refreshLoginServices(); + return API.v1.success(); + }, +); + +API.v1.get( + 'settings', + { + authRequired: true, + query: isSettingsGetParams, + response: { + 200: settingsListResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { includeDefaults } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + let ourQuery: Parameters[0] = { + hidden: { $ne: true }, + }; + + if (!(await hasPermissionAsync(this.user, 'view-privileged-setting'))) { + ourQuery.public = true; + } + + ourQuery = Object.assign({}, query, ourQuery); + + if (includeDefaults) { + fields.packageValue = 1; + } + + const { settings: settingsList, totalCount: total } = await fetchSettings(ourQuery, sort, offset, count, fields); + + return API.v1.success({ + settings: settingsList, + count: settingsList.length, + offset, + total, + }); + }, +); + +API.v1.get( + 'settings/:_id', + { + authRequired: true, + permissionsRequired: { + GET: { permissions: ['view-privileged-setting'], operation: 'hasAll' }, + }, + response: { + 200: settingByIdGetResponseSchema, + 400: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [false] } }, + required: ['success'], + additionalProperties: true, + }), + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + const setting = await Settings.findOneNotHiddenById(_id); + if (!setting) { + return API.v1.failure(); + } + return API.v1.success(_.pick(setting, '_id', 'value')); + }, +); + +API.v1.post( + 'settings/:_id', + { + authRequired: true, + permissionsRequired: { + POST: { permissions: ['edit-privileged-setting'], operation: 'hasAll' }, + }, + twoFactorRequired: true, + body: settingsUpdateBodySchema, + response: { + 200: settingByIdPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { _id } = this.urlParams; + if (typeof _id !== 'string') { + throw new Meteor.Error('error-id-param-not-provided', 'The parameter "id" is required'); + } + + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return API.v1.forbidden('Custom scripts are disabled'); + } + + const setting = await Settings.findOneNotHiddenById(_id); + + if (!setting) { + return API.v1.failure(); + } + + const { bodyParams } = this; + + if ( + isSettingAction(setting) && + isSettingsUpdatePropsActions(bodyParams) && + bodyParams.execute && + !isActionSettingWithEndpoint(setting.value) + ) { + await Meteor.callAsync(setting.value); + return API.v1.success(); + } + + const auditSettingOperation = updateAuditedByUser({ + _id: this.userId, + username: this.user.username, + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + + if (isSettingColor(setting) && isSettingsUpdatePropsColor(bodyParams)) { + const updateOptionsPromise = Settings.updateOptionsById(_id, { editor: bodyParams.editor }); + const updateValuePromise = auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value); + + const [updateOptionsResult, updateValueResult] = await Promise.all([updateOptionsPromise, updateValuePromise]); + + if (updateOptionsResult.modifiedCount || updateValueResult.modifiedCount) { + await notifyOnSettingChangedById(_id); + } + + return API.v1.success(); + } + + if (isSettingsUpdatePropDefault(bodyParams)) { + // TODO(next major): unify both validations into one function with a common API error response + checkSettingValueBounds(setting, bodyParams.value); + + try { + validateSettingRules([{ _id, value: bodyParams.value }]); + } catch (error) { + if (error instanceof SettingValidationError) { + return API.v1.failure(error.message, 'error-setting-validation-failed'); + } + throw error; + } + + const { matchedCount } = await auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value); + + if (!matchedCount) { + return API.v1.failure(); + } + + const s = await Settings.findOneNotHiddenById(_id); + if (!s) { + return API.v1.failure(); + } + + settings.set(s); + setValue(_id, bodyParams.value); + + await notifyOnSettingChanged(s); + + return API.v1.success(); + } + + return API.v1.failure(); + }, +); + +API.v1.post( + 'settings', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: isSettingsBulkProps, + response: { + 200: settingByIdPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + try { + await saveSettingsBulk(this.userId, this.bodyParams.settings, { + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + } catch (error) { + if (error instanceof SettingValidationError) { + return API.v1.failure(error.message, 'error-setting-validation-failed'); + } + throw error; + } + + return API.v1.success(); + }, +); + +API.v1.get( + 'service.configurations', + { + authRequired: false, + response: { + 200: serviceConfigurationsResponseSchema, + }, + }, + async function action() { + return API.v1.success({ + configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), + }); + }, +); diff --git a/apps/meteor/server/api/v1/setupWizard.ts b/apps/meteor/server/api/v1/setupWizard.ts new file mode 100644 index 0000000..78f34dd --- /dev/null +++ b/apps/meteor/server/api/v1/setupWizard.ts @@ -0,0 +1,38 @@ +import { ajv, validateBadRequestErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings'; + +import type { SetupWizardParameters } from '../../lib/getSetupWizardParameters'; +import { getSetupWizardParameters, isSetupWizardCompleted } from '../../lib/getSetupWizardParameters'; +import { API } from '../api'; + +const setupWizardParametersResponseSchema = ajv.compile({ + type: 'object', + properties: { + settings: { type: 'array', items: { type: 'object' } }, + serverAlreadyRegistered: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['settings', 'serverAlreadyRegistered', 'success'], + additionalProperties: false, +}); + +// Unauthenticated on purpose: it feeds the setup wizard, which runs before the first admin exists. +// That exception only holds until the wizard is completed; after that the settings it returns are no longer public. +API.v1.get( + 'setupWizard.parameters', + { + authRequired: false, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 20 }, + response: { + 200: setupWizardParametersResponseSchema, + 400: validateBadRequestErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (isSetupWizardCompleted()) { + return API.v1.forbidden(); + } + + return API.v1.success(await getSetupWizardParameters()); + }, +); diff --git a/apps/meteor/server/api/v1/stats.ts b/apps/meteor/server/api/v1/stats.ts new file mode 100644 index 0000000..54f7c62 --- /dev/null +++ b/apps/meteor/server/api/v1/stats.ts @@ -0,0 +1,120 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { + ajv, + isTelemetryPayload, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { getStatistics, getLastStatistics } from '../../lib/statistics'; +import telemetryEvent from '../../lib/statistics/lib/telemetryEvents'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +const statisticsResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const statisticsListResponseSchema = ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + statistics: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['statistics', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const statisticsTelemetryResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.get( + 'statistics', + { + authRequired: true, + response: { + 200: statisticsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { refresh } = this.queryParams; + + return API.v1.success( + await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }), + ); + }, +); + +API.v1.get( + 'statistics.list', + { + authRequired: true, + response: { + 200: statisticsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + return API.v1.success( + await getStatistics({ + userId: this.userId, + query, + pagination: { + offset, + count, + sort, + fields, + }, + }), + ); + }, +); + +API.v1.post( + 'statistics.telemetry', + { + authRequired: true, + body: isTelemetryPayload, + response: { + 200: statisticsTelemetryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + function action() { + const { params } = this.bodyParams; + + params.forEach((event) => { + const { eventName, ...rest } = event; + void telemetryEvent.call(eventName, rest); + }); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/subscriptions.ts b/apps/meteor/server/api/v1/subscriptions.ts new file mode 100644 index 0000000..ff670ab --- /dev/null +++ b/apps/meteor/server/api/v1/subscriptions.ts @@ -0,0 +1,176 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { + ajv, + isSubscriptionsGetProps, + isSubscriptionsGetOneProps, + isSubscriptionsReadProps, + isSubscriptionsUnreadProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { unreadMessages } from '../../lib/messaging/unread/unreadMessages'; +import { readMessages } from '../../lib/readMessages'; +import { getSubscriptions } from '../../publications/subscription'; +import { API } from '../api'; + +const subscriptionsGetResponseSchema = ajv.compile<{ + update: ISubscription[]; + remove: (Pick & { _deletedAt: Date })[]; +}>({ + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/ISubscription' } }, + remove: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +API.v1.get( + 'subscriptions.get', + { + authRequired: true, + query: isSubscriptionsGetProps, + response: { + 200: subscriptionsGetResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate: Date | undefined; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); + } + updatedSinceDate = new Date(updatedSince); + } + + const result = await getSubscriptions(this.userId, updatedSinceDate); + + return API.v1.success( + Array.isArray(result) + ? { + update: result, + remove: [], + } + : result, + ); + }, +); + +const subscriptionsGetOneResponseSchema = ajv.compile<{ subscription: ISubscription | null }>({ + type: 'object', + properties: { + subscription: { oneOf: [{ $ref: '#/components/schemas/ISubscription' }, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['subscription', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'subscriptions.getOne', + { + authRequired: true, + query: isSubscriptionsGetOneProps, + response: { + 200: subscriptionsGetOneResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + + if (!roomId) { + return API.v1.failure("The 'roomId' param is required"); + } + + return API.v1.success({ + subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), + }); + }, +); + +/** + This API is suppose to mark any room as read. + + Method: POST + Route: api/v1/subscriptions.read + Params: + - rid: The rid of the room to be marked as read. + - roomId: Alternative for rid. + */ +const voidSuccessResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'subscriptions.read', + { + authRequired: true, + body: isSubscriptionsReadProps, + response: { + 200: voidSuccessResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { readThreads = false } = this.bodyParams; + const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; + + const room = await Rooms.findOneById(roomId); + if (!room) { + throw new Error('error-invalid-subscription'); + } + + await readMessages(room, this.userId, readThreads); + + return API.v1.success(); + }, +); + +API.v1.post( + 'subscriptions.unread', + { + authRequired: true, + body: isSubscriptionsUnreadProps, + response: { + 200: voidSuccessResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await unreadMessages( + this.userId, + 'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined, + 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, + ); + + return API.v1.success(); + }, +); diff --git a/apps/meteor/server/api/v1/teams.ts b/apps/meteor/server/api/v1/teams.ts new file mode 100644 index 0000000..180e951 --- /dev/null +++ b/apps/meteor/server/api/v1/teams.ts @@ -0,0 +1,826 @@ +import { Team } from '@rocket.chat/core-services'; +import type { ITeamAutocompleteResult } from '@rocket.chat/core-services'; +import type { ITeam, UserStatus } from '@rocket.chat/core-typings'; +import { Users, Rooms } from '@rocket.chat/models'; +import { + ajv, + isTeamsAddRoomsProps, + isTeamsAutocompleteProps, + isTeamsConvertToChannelProps, + isTeamsCreateProps, + isTeamsInfoProps, + isTeamsListRoomsProps, + isTeamsListRoomsOfUserProps, + isTeamsMembersProps, + isTeamsRemoveRoomProps, + isTeamsUpdateMemberProps, + isTeamsUpdateRoomProps, + isTeamsRemoveMemberProps, + isTeamsAddMembersProps, + isTeamsDeleteProps, + isTeamsLeaveProps, + isTeamsUpdateProps, + isTeamsListChildrenProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { hasPermissionAsync, hasAtLeastOnePermissionAsync, hasAllPermissionAsync } from '../../lib/authorization/hasPermission'; +import { eraseRoom } from '../../lib/eraseRoom'; +import { removeUserFromRoom } from '../../lib/rooms/removeUserFromRoom'; +import { effectiveStatusFilter } from '../../lib/statusVisibility/effectiveStatus'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { redactStatus } from '../../lib/statusVisibility/redactStatus'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { eraseTeam } from '../lib/eraseTeam'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +const paginatedTeamsResponseSchema = ajv.compile<{ teams: ITeam[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const teamsEndpoints = API.v1 + .get( + 'teams.list', + { + authRequired: true, + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + const { records, total } = await Team.list(this.userId, { offset, count }, { sort, query }); + + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); + }, + ) + .get( + 'teams.listAll', + { + authRequired: true, + permissionsRequired: ['view-all-teams'], + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + + const { records, total } = await Team.listAll({ offset, count }); + + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); + }, + ) + .post( + 'teams.create', + { + authRequired: true, + permissionsRequired: ['create-team'], + body: isTeamsCreateProps, + response: { + 200: ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { name, type, members, room, owner } = this.bodyParams; + + if (room?.id && !(await hasAllPermissionAsync(this.user, ['create-team', 'edit-room'], room.id))) { + return API.v1.forbidden(); + } + + const team = await Team.create(this.userId, { + team: { + name, + type, + }, + room: room as Parameters[1]['room'], + members, + owner, + }); + + return API.v1.success({ team }); + }, + ); + +const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => { + if ('teamId' in params && params.teamId) { + return Team.getOneById(params.teamId); + } + + if ('teamName' in params && params.teamName) { + return Team.getOneByName(params.teamName); + } + + return null; +}; + +API.v1.post( + 'teams.convertToChannel', + { + authRequired: true, + body: isTeamsConvertToChannelProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomsToRemove = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'convert-team', team.roomId))) { + return API.v1.forbidden(); + } + + const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); + + if (rooms.length) { + for (const room of rooms) { + await eraseRoom(room, this.user); + } + } + + await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]); + + await Team.deleteById(team._id); + + return API.v1.success(); + }, +); + +const roomResponseSchema = ajv.compile<{ room: object }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const roomsResponseSchema = ajv.compile<{ rooms: object[] }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'teams.addRooms', + { + authRequired: true, + body: isTeamsAddRoomsProps, + response: { + 200: roomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'move-room-to-team', team.roomId))) { + return API.v1.forbidden('error-no-permission-team-channel'); + } + + const { rooms } = this.bodyParams; + + const validRooms = await Team.addRooms(this.userId, rooms, team._id); + + return API.v1.success({ rooms: validRooms }); + }, +); + +API.v1.post( + 'teams.removeRoom', + { + authRequired: true, + body: isTeamsRemoveRoomProps, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'remove-team-channel', team.roomId))) { + return API.v1.forbidden(); + } + + const canRemoveAny = !!(await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId)); + + const { roomId } = this.bodyParams; + + const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); + + return API.v1.success({ room }); + }, +); + +API.v1.post( + 'teams.updateRoom', + { + authRequired: true, + body: isTeamsUpdateRoomProps, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, isDefault } = this.bodyParams; + + const team = await Team.getOneByRoomId(roomId); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'edit-team-channel', team.roomId))) { + return API.v1.forbidden(); + } + const canUpdateAny = !!(await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId)); + + if (settings.get('ABAC_Enabled') && isDefault) { + const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); + if (room?.abacAttributes?.length) { + return API.v1.failure('error-room-is-abac-managed'); + } + } + + const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); + + return API.v1.success({ room }); + }, +); + +const paginatedRoomsResponseSchema = ajv.compile<{ rooms: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'teams.listRooms', + { + authRequired: true, + query: isTeamsListRoomsProps, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { filter, type } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam: boolean = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId); + + const getAllRooms = await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId); + + const listFilter = { + name: filter ?? undefined, + isDefault: type === 'autoJoin', + getAllRooms, + allowPrivateTeam, + }; + + const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset, + }); + }, +); + +API.v1.get( + 'teams.listRoomsOfUser', + { + authRequired: true, + query: isTeamsListRoomsOfUserProps, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId); + + const { userId, canUserDelete } = this.queryParams; + + if (!(this.userId === userId || (await hasPermissionAsync(this.user, 'view-all-team-channels', team.roomId)))) { + return API.v1.forbidden(); + } + + const booleanCanUserDelete = canUserDelete === 'true'; + const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset: 0, + }); + }, +); + +const getTeamByIdOrNameOrParentRoom = async ( + params: { teamId: string } | { teamName: string } | { roomId: string }, +): Promise | null> => { + if ('teamId' in params && params.teamId) { + return Team.getOneById(params.teamId, { projection: { type: 1, roomId: 1 } }); + } + if ('teamName' in params && params.teamName) { + return Team.getOneByName(params.teamName, { projection: { type: 1, roomId: 1 } }); + } + if ('roomId' in params && params.roomId) { + return Team.getOneByRoomId(params.roomId, { projection: { type: 1, roomId: 1 } }); + } + return null; +}; + +// This should accept a teamId, filter (search by name on rooms collection) and sort/pagination +// should return a list of rooms/discussions from the team. the discussions will only be returned from the main room +API.v1.get( + 'teams.listChildren', + { + authRequired: true, + query: isTeamsListChildrenProps, + response: { + 200: ajv.compile<{ data: object[]; total: number; offset: number; count: number }>({ + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + offset: { type: 'number' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'total', 'offset', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter, type } = this.queryParams; + + const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); + if (!team) { + return API.v1.notFound(); + } + + const result = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); + + return API.v1.success({ data: result.data, total: result.total, offset, count }); + }, +); + +API.v1.get( + 'teams.members', + { + authRequired: true, + query: isTeamsMembersProps, + response: { + 200: ajv.compile<{ members: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'total', 'count', 'offset', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + + const { status, username, name } = this.queryParams; + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const canSeeAllMembers = await hasPermissionAsync(this.user, 'view-all-teams', team.roomId); + + const hidden = await getUsersHiddenFrom(this.userId); + + const query: Record = {}; + if (username) { + query.username = new RegExp(escapeRegExp(username), 'i'); + } + if (name) { + query.name = new RegExp(escapeRegExp(name), 'i'); + } + if (status) { + Object.assign(query, effectiveStatusFilter(status as UserStatus[], hidden)); + } + + const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); + + return API.v1.success({ + members: hidden + ? records.map((record) => (hidden.has(record.user._id) ? { ...record, user: redactStatus(record.user) } : record)) + : records, + total, + count: records.length, + offset, + }); + }, +); + +API.v1.post( + 'teams.addMembers', + { + authRequired: true, + body: isTeamsAddMembersProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + const { members } = bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasAtLeastOnePermissionAsync(this.user, ['add-team-member', 'edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } + + await Team.addMembers(this.userId, team._id, members); + + return API.v1.success(); + }, +); + +API.v1.post( + 'teams.updateMember', + { + authRequired: true, + body: isTeamsUpdateMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + const { member } = bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasAtLeastOnePermissionAsync(this.user, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } + + await Team.updateMember(team._id, member); + + return API.v1.success(); + }, +); + +API.v1.post( + 'teams.removeMember', + { + authRequired: true, + body: isTeamsRemoveMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + const { userId, rooms } = bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasAtLeastOnePermissionAsync(this.user, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } + + const user = await Users.findOneActiveById(userId, {}); + if (!user) { + return API.v1.failure('invalid-user'); + } + + if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) { + return API.v1.failure('could-not-remove-member'); + } + + if (rooms?.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + + await Promise.all( + roomsFromTeam.map((rid) => + removeUserFromRoom(rid, user, { + byUser: this.user, + }), + ), + ); + } + return API.v1.success(); + }, +); + +API.v1.post( + 'teams.leave', + { + authRequired: true, + body: isTeamsLeaveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rooms = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + await Team.removeMembers(this.userId, team._id, [ + { + userId: this.userId, + }, + ]); + + if (rooms.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user))); + } + + return API.v1.success(); + }, +); + +API.v1.get( + 'teams.info', + { + authRequired: true, + query: isTeamsInfoProps, + response: { + 200: ajv.compile<{ teamInfo: ITeam }>({ + type: 'object', + properties: { + teamInfo: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teamInfo', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const teamInfo = await getTeamByIdOrName(this.queryParams); + if (!teamInfo) { + return API.v1.failure('Team not found'); + } + + const room = await Rooms.findOneById(teamInfo.roomId); + + if (!room) { + return API.v1.failure('Room not found'); + } + + const canViewInfo = (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.user, 'view-all-teams')); + + if (!canViewInfo) { + return API.v1.forbidden(); + } + + return API.v1.success({ teamInfo }); + }, +); + +API.v1.post( + 'teams.delete', + { + authRequired: true, + body: isTeamsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomsToRemove = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'delete-team', team.roomId))) { + return API.v1.forbidden(); + } + + await eraseTeam(this.user, team, roomsToRemove); + + return API.v1.success(); + }, +); + +API.v1.get( + 'teams.autocomplete', + { + authRequired: true, + query: isTeamsAutocompleteProps, + response: { + 200: ajv.compile<{ teams: ITeamAutocompleteResult[] }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name } = this.queryParams; + + const teams = await Team.autocomplete(this.userId, name); + + return API.v1.success({ teams }); + }, +); + +API.v1.post( + 'teams.update', + { + authRequired: true, + body: isTeamsUpdateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { data } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasPermissionAsync(this.user, 'edit-team', team.roomId))) { + return API.v1.forbidden(); + } + + await Team.update(this.userId, team._id, data); + + return API.v1.success(); + }, +); + +export type TeamsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends TeamsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/twoFactorChallenges.ts b/apps/meteor/server/api/v1/twoFactorChallenges.ts new file mode 100644 index 0000000..db144dc --- /dev/null +++ b/apps/meteor/server/api/v1/twoFactorChallenges.ts @@ -0,0 +1,113 @@ +import type { IMethodConnection } from '@rocket.chat/core-typings'; +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { isTwoFactorChallengesSendEmailCodeParamsPOST, isTwoFactorChallengesVerifyChallengeParamsPOST } from '@rocket.chat/rest-typings'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { getUserForCheck, rememberAuthorizationByToken } from '../../lib/2fa/code'; +import { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../lib/oauth/twoFactorAuth'; +import { generateConnection } from '../ApiClass'; +import { API } from '../api'; + +API.v1.addRoute( + 'twoFactorChallenges.sendEmailCode', + { validateParams: isTwoFactorChallengesSendEmailCodeParamsPOST, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 } }, + { + async post() { + const { challengeId } = this.bodyParams; + + if (!challengeId) { + throw new Meteor.Error('error-parameter-required', 'challengeId is required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + if (challenge.method !== 'email') { + throw new Meteor.Error('error-invalid-challenge-method', 'invalid challenge method'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + await emailCheckForOAuth.sendEmailCode(user); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'twoFactorChallenges.verifyChallenge', + { + validateParams: isTwoFactorChallengesVerifyChallengeParamsPOST, + rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 }, + }, + { + async post() { + const { challengeId, code } = this.bodyParams; + + if (!challengeId || !code) { + throw new Meteor.Error('error-parameter-required', 'challengeId and code are required'); + } + + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + + if (!challenge) { + throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); + } + + const { userId } = challenge; + + const user = await getUserForCheck(userId); + + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const twoFAMethod = getTwoFAMethodForOAuth(challenge.method); + + const isCodeValid = await twoFAMethod.verifyEmailTwoFactorChallenge(user, challengeId, code); + + if (!isCodeValid) { + const tooManyAttempts = await twoFAMethod.maxFaildedAttemtpsReached(user); + if (tooManyAttempts) { + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached'); + } + return API.v1.failure('error-invalid-code', 'Invalid code'); + } + + const stampedToken = Accounts._generateStampedLoginToken(); + + await Accounts._insertLoginToken(user._id, stampedToken); + + const hashedToken = Accounts._hashLoginToken(stampedToken.token); + + const connection = { + ...generateConnection(this.requestIp, this.request.headers), + token: hashedToken, + } as unknown as IMethodConnection; + + // remember the 2FA authorization for the next requests + await rememberAuthorizationByToken(hashedToken, user._id, connection); + + return API.v1.success({ + loginToken: stampedToken.token, + userId: user._id, + }); + }, + }, +); diff --git a/apps/meteor/server/api/v1/uploads.ts b/apps/meteor/server/api/v1/uploads.ts new file mode 100644 index 0000000..c31beb0 --- /dev/null +++ b/apps/meteor/server/api/v1/uploads.ts @@ -0,0 +1,100 @@ +import { Upload } from '@rocket.chat/core-services'; +import type { IUpload } from '@rocket.chat/core-typings'; +import { Messages, Uploads, Users } from '@rocket.chat/models'; +import { + ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, +} from '@rocket.chat/rest-typings'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type UploadsDeleteResult = { + /** + * The list of files that were successfully removed; May include additional files such as image thumbnails + * */ + deletedFiles: IUpload['_id'][]; +}; + +type UploadsDeleteParams = { + fileId: string; +}; + +const uploadsDeleteParamsSchema = { + type: 'object', + properties: { + fileId: { + type: 'string', + }, + }, + required: ['fileId'], + additionalProperties: false, +}; + +export const isUploadsDeleteParams = ajv.compile(uploadsDeleteParamsSchema); + +const uploadsDeleteEndpoint = API.v1.post( + 'uploads.delete', + { + authRequired: true, + body: isUploadsDeleteParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + }, + deletedFiles: { + description: 'The list of files that were successfully removed. May include additional files such as image thumbnails', + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['deletedFiles'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { fileId } = this.bodyParams; + + const file = await Uploads.findOneById(fileId); + if (!file?.userId || !file.rid) { + return API.v1.notFound(); + } + + const msg = await Messages.getMessageByFileId(fileId); + + const user = await Users.findOneById(this.userId); + // Safeguard, can't really happen + if (!user) { + return API.v1.forbidden('forbidden'); + } + + if (!(await Upload.canDeleteFile(user, file, msg))) { + return API.v1.forbidden('forbidden'); + } + + const { deletedFiles } = await Upload.deleteFile(user, fileId, msg); + return API.v1.success({ + deletedFiles, + }); + }, +); + +type UploadsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends UploadsEndpoints {} +} diff --git a/apps/meteor/server/api/v1/users.ts b/apps/meteor/server/api/v1/users.ts new file mode 100644 index 0000000..dbdd9f1 --- /dev/null +++ b/apps/meteor/server/api/v1/users.ts @@ -0,0 +1,2337 @@ +import { MeteorError, Presence, Team } from '@rocket.chat/core-services'; +import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; +import { Users, Subscriptions, Sessions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models'; +import { + isUserCreateParamsPOST, + isUserSetActiveStatusParamsPOST, + isUserDeactivateIdleParamsPOST, + isUsersInfoParamsGetProps, + isUsersListStatusProps, + isUsersSendWelcomeEmailProps, + isUserRegisterParamsPOST, + isUserLogoutParamsPOST, + isUsersListTeamsProps, + isUsersAutocompleteProps, + isUsersSetAvatarProps, + isUsersUpdateParamsPOST, + isUsersUpdateOwnBasicInfoParamsPOST, + isUsersSetPreferencesParamsPOST, + isUsersCheckUsernameAvailabilityParamsGET, + isUsersSendConfirmationEmailParamsPOST, + isUsersListParamsGET, + isUsersPresenceParamsGET, + isUsersRequestDataDownloadParamsGET, + isUsersGetPresenceParamsGET, + isUsersGetStatusParamsGET, + ajv, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import { escapeRegExp, getLoginExpirationInMs } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; + +import { generatePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/generateToken'; +import { regeneratePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/regenerateToken'; +import { removePersonalAccessTokenOfUser } from '../../../imports/personal-access-tokens/server/api/methods/removeToken'; +import { runUserLogoutCleanUp } from '../../hooks/userLogoutCleanUp'; +import { getUserForCheck, emailCheck } from '../../lib/2fa/code'; +import { resetTOTP } from '../../lib/2fa/functions/resetTOTP'; +import { codesRemainingTotp, disableTotp, enableTotp, regenerateTotpCodes, validateTotpTempToken } from '../../lib/2fa/functions/totp'; +import { UserChangedAuditStore } from '../../lib/auditServerEvents/userChanged'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { i18n } from '../../lib/i18n'; +import { SystemLogger } from '../../lib/logger/system'; +import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../lib/notifyListener'; +import { resetUserE2EEncriptionKey } from '../../lib/resetUserE2EKey'; +import { validateNameChars } from '../../lib/shared/validateNameChars'; +import { getUsersHiddenFrom, filterHiddenUsers, redactHiddenUsers } from '../../lib/statusVisibility/hiddenUsers'; +import { redactStatus } from '../../lib/statusVisibility/redactStatus'; +import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers'; +import { checkEmailAvailability } from '../../lib/users/checkEmailAvailability'; +import { checkUsernameAvailability, checkUsernameAvailabilityWithValidation } from '../../lib/users/checkUsernameAvailability'; +import { deleteUser } from '../../lib/users/deleteUser'; +import { getAvatarSuggestionForUser } from '../../lib/users/getAvatarSuggestionForUser'; +import { getFullUserDataByUniqueSearchTerm, defaultFields, fullFields } from '../../lib/users/getFullUserData'; +import { generateUsernameSuggestion } from '../../lib/users/getUsernameSuggestion'; +import { runAfterVerifyEmail } from '../../lib/users/runAfterVerifyEmail'; +import { saveCustomFields } from '../../lib/users/saveCustomFields'; +import { saveCustomFieldsWithoutValidation } from '../../lib/users/saveCustomFieldsWithoutValidation'; +import { saveUser } from '../../lib/users/saveUser'; +import { sendWelcomeEmail } from '../../lib/users/saveUser/sendUserEmail'; +import { canEditExtension } from '../../lib/users/saveUser/validateUserEditing'; +import { setUserAvatar } from '../../lib/users/setUserAvatar'; +import { setUsernameWithValidation } from '../../lib/users/setUsername'; +import { validateCustomFields } from '../../lib/users/validateCustomFields'; +import { validateUsername } from '../../lib/users/validateUsername'; +import { verifyEmail } from '../../lib/users/verifyEmail'; +import { isSMTPConfigured } from '../../lib/utils/functions/isSMTPConfigured'; +import { getURL } from '../../lib/utils/getURL'; +import { generateAccessToken } from '../../meteor-methods/auth/createToken'; +import { sendConfirmationEmail } from '../../meteor-methods/auth/sendConfirmationEmail'; +import { sendForgotPasswordEmail } from '../../meteor-methods/auth/sendForgotPasswordEmail'; +import { requestDataDownload } from '../../meteor-methods/platform/requestDataDownload'; +import { deleteUserOwnAccount } from '../../meteor-methods/users/deleteUserOwnAccount'; +import { registerUser } from '../../meteor-methods/users/registerUser'; +import { resetAvatar } from '../../meteor-methods/users/resetAvatar'; +import { saveUserPreferences } from '../../meteor-methods/users/saveUserPreferences'; +import { executeSaveUserProfile } from '../../meteor-methods/users/saveUserProfile'; +import { executeSetUserActiveStatus } from '../../meteor-methods/users/setUserActiveStatus'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; +import { getUserFromParams } from '../lib/getUserFromParams'; +import { getUserInfo } from '../lib/getUserInfo'; +import { isUserFromParams } from '../lib/isUserFromParams'; +import { isValidQuery } from '../lib/isValidQuery'; +import { queryFiltersStatus } from '../lib/queryFiltersStatus'; +import { findPaginatedUsersByStatus, findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; + +API.v1.addRoute( + 'users.getAvatar', + { authRequired: true }, + { + async get() { + const user = await getUserFromParams(this.queryParams); + + const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); + this.response.headers.set('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, + }, +); + +const voidSuccessResponse = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +// user shape varies by projection and permissions — use $ref when IUser is available in typia +const userObjectResponse = ajv.compile<{ user: object }>({ + type: 'object', + properties: { + user: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, +}); + +API.v1.post( + 'users.update', + { + authRequired: true, + twoFactorRequired: true, + body: isUsersUpdateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + + if (userData.name && !validateNameChars(userData.name)) { + return API.v1.failure('Name contains invalid characters'); + } + const auditStore = new UserChangedAuditStore({ + _id: this.user._id, + ip: this.requestIp || '', + useragent: this.request.headers.get('user-agent') || '', + username: this.user.username, + }); + + await saveUser(this.userId, userData, { auditStore }); + + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); + } + + const { fields } = await this.parseJsonQuery(); + + const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } + + return API.v1.success({ user }); + }, +); + +API.v1 + .post( + 'users.updateOwnBasicInfo', + { + authRequired: true, + userWithoutUsername: true, + body: isUsersUpdateOwnBasicInfoParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + nickname: this.bodyParams.data.nickname, + bio: this.bodyParams.data.bio, + statusText: this.bodyParams.data.statusText, + statusType: this.bodyParams.data.statusType, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + if (userData.realname && !validateNameChars(userData.realname)) { + return API.v1.failure('Name contains invalid characters'); + } + + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that + const twoFactorOptions = !userData.typedPassword + ? null + : { + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; + + await executeSaveUserProfile.call(this, this.user, userData, this.bodyParams.customFields, twoFactorOptions); + + return API.v1.success({ + user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false), + }); + }, + ) + .post( + 'users.setPreferences', + { + authRequired: true, + body: isUsersSetPreferencesParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if ( + this.bodyParams.userId && + this.bodyParams.userId !== this.userId && + !(await hasPermissionAsync(this.user, 'edit-other-user-info')) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); + } + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + if (!(await Users.findOneById(userId))) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); + } + + const { statusVisibilityDenied: _ownBlockList, ...preferences } = this.bodyParams.data; + + await saveUserPreferences(userId === this.userId ? this.bodyParams.data : preferences, userId); + const user = await Users.findOneById(userId, { + projection: { + 'settings.preferences': 1, + 'language': 1, + }, + }); + + if (!user) { + return API.v1.failure('User not found'); + } + + const { statusVisibilityDenied, ...savedPreferences } = user.settings?.preferences ?? {}; + + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...savedPreferences, + ...(userId === this.userId && + settings.get('Accounts_StatusVisibility_Enabled') && + statusVisibilityDenied?.length && { + statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames, + }), + language: user.language, + }, + }, + }, + }); + }, + ) + .post( + 'users.setAvatar', + { + authRequired: true, + body: isUsersSetAvatarProps, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const canEditOtherUserAvatar = await hasPermissionAsync(this.user, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user = await (async (): Promise | undefined | null> => { + if (isUserFromParams(this.bodyParams, this.userId, this.user)) { + return Users.findOneById(this.userId); + } + if (canEditOtherUserAvatar) { + return getUserFromParams(this.bodyParams); + } + })(); + + if (!user) { + return API.v1.forbidden(); + } + + if (this.bodyParams.avatarUrl) { + await setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + return API.v1.success(); + } + + const image = await getUploadFormData( + { + request: this.request, + }, + { field: 'image', sizeLimit: settings.get('FileUpload_MaxFileSize') }, + ); + + if (!image) { + return API.v1.failure("The 'image' param is required"); + } + + const { fields, fileBuffer, mimetype } = image; + + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + if (fields.userId) { + user = await Users.findOneById>(fields.userId, { projection: { username: 1 } }); + } else if (fields.username) { + user = await Users.findOneByUsernameIgnoringCase>(fields.username, { + projection: { username: 1 }, + }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); + } + + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !(await hasPermissionAsync(this.user, 'edit-other-user-avatar'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + } + + // an avatar coming from an OAuth service suggestion carries the provider data URI (a string) in the + // image field; setUserAvatar parses it and records the provider name as the avatar origin. A regular + // upload has no service and is stored from the binary buffer. + if (typeof fields.service === 'string' && fields.service.length > 0) { + await setUserAvatar(user, fileBuffer.toString('utf8'), mimetype, fields.service); + return API.v1.success(); + } + + await setUserAvatar(user, fileBuffer, mimetype, 'rest'); + + return API.v1.success(); + }, + ) + .post( + 'users.create', + { + authRequired: true, + body: isUserCreateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // New change made by pull request #5152 + if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { + this.bodyParams.joinDefaultChannels = true; + } + + if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) { + return API.v1.failure('Name contains invalid characters'); + } + + if (this.bodyParams.customFields) { + validateCustomFields(this.bodyParams.customFields); + } + + if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) { + return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed'); + } + + const newUserId = await saveUser(this.userId, this.bodyParams); + const userId = typeof newUserId !== 'string' ? this.userId : newUserId; + + if (this.bodyParams.customFields) { + await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields); + } + + if (typeof this.bodyParams.active !== 'undefined') { + await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active); + } + + const { fields } = await this.parseJsonQuery(); + + const user = await Users.findOneById(userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } + + return API.v1.success({ user }); + }, + ); + +API.v1.post( + 'users.delete', + { + authRequired: true, + permissionsRequired: ['delete-user'], + body: ajv.compile<{ userId?: string; username?: string; user?: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + anyOf: [{ required: ['userId'] }, { required: ['username'] }, { required: ['user'] }], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ deletedRooms: string[] }>({ + type: 'object', + properties: { + deletedRooms: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['deletedRooms', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + const { confirmRelinquish = false } = this.bodyParams; + + const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId); + + return API.v1.success({ deletedRooms }); + }, +); + +API.v1.post( + 'users.deleteOwnAccount', + { + authRequired: true, + body: ajv.compile<{ password: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + password: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + required: ['password'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { confirmRelinquish = false } = this.bodyParams; + + await deleteUserOwnAccount(this.userId, this.bodyParams.password, confirmRelinquish); + + return API.v1.success(); + }, +); + +API.v1.post( + 'users.setActiveStatus', + { + authRequired: true, + body: isUserSetActiveStatusParamsPOST, + permissionsRequired: { + POST: { permissions: ['edit-other-user-active-status', 'manage-moderation-actions'], operation: 'hasAny' }, + }, + response: { + 200: ajv.compile<{ user: Pick }>({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + _id: { type: 'string' }, + active: { type: 'boolean' }, + }, + required: ['_id', 'active'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish); + + const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + return API.v1.success({ + user, + }); + }, +); + +API.v1.post( + 'users.deactivateIdle', + { + authRequired: true, + body: isUserDeactivateIdleParamsPOST, + permissionsRequired: ['edit-other-user-active-status'], + response: { + 200: ajv.compile<{ count: number }>({ + type: 'object', + properties: { + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { daysIdle, role = 'user' } = this.bodyParams; + + const lastLoggedIn = new Date(); + lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle); + + const ids = await Users.findActiveNotLoggedInAfterWithRole(lastLoggedIn, role, { projection: { _id: 1 } }) + .map(({ _id }: { _id: string }) => _id) + .toArray(); + + const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false); + + await Promise.all([ + OAuthAccessTokens.deleteByUserIds(ids), + OAuthRefreshTokens.deleteByUserIds(ids), + OAuthAuthCodes.deleteByUserIds(ids), + ]); + + ids.forEach((_id) => { + void notifyOnUserChange({ + clientAction: 'updated', + id: _id, + diff: { 'services.resume.loginTokens': [], 'active': false }, + }); + }); + + return API.v1.success({ + count, + }); + }, +); + +API.v1.get( + 'users.info', + { + authRequired: true, + query: isUsersInfoParamsGetProps, + response: { + // user shape varies by projection, permissions, and includeUserRooms + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const searchTerms: [string, 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension'] | false = + ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || + ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || + ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) || + ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']) || + ('freeSwitchExtension' in this.queryParams && + !!this.queryParams.freeSwitchExtension && [this.queryParams.freeSwitchExtension, 'freeSwitchExtension']); + + if (!searchTerms) { + return API.v1.failure('Invalid search query.'); + } + + const user = await getFullUserDataByUniqueSearchTerm(this.userId, ...searchTerms); + + if (!user) { + return API.v1.failure('User not found.'); + } + + const myself = user._id === this.userId; + + if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.user, 'view-other-user-channels')))) { + return API.v1.success({ + user: { + ...user, + rooms: await Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + federated: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).toArray(), + }, + }); + } + + return API.v1.success({ + user, + }); + }, +); + +// users.list accepts arbitrary query filter fields (name, username, etc.) +// that cannot be statically defined — keeping as addRoute until params are known +API.v1.addRoute( + 'users.list', + { + authRequired: true, + queryOperations: ['$or', '$and'], + permissionsRequired: ['view-d-room'], + query: isUsersListParamsGET, + }, + { + async get() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.user, 'view-outside-room')) + ) { + return API.v1.forbidden(); + } + const canViewFullOtherUserInfo = await hasPermissionAsync(this.user, 'view-full-other-user-info'); + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const nonEmptyFields = getNonEmptyFields(fields); + + const inclusiveFields = getInclusiveFields(nonEmptyFields); + + const inclusiveFieldsKeys = Object.keys(inclusiveFields); + + const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo); + + if ('email' in this.queryParams && this.queryParams.email) { + if (!canViewFullOtherUserInfo) { + return API.v1.forbidden(); + } + const escapedEmail = escapeRegExp(this.queryParams.email); + nonEmptyQuery['emails.address'] = { + $regex: `^${escapedEmail}$`, + $options: 'i', + }; + } + + // if user provided a query, validate it with their allowed operators + // otherwise we use the default query (with $regex and $options) + if ( + !isValidQuery( + nonEmptyQuery, + [ + ...inclusiveFieldsKeys, + inclusiveFieldsKeys.includes('emails') && 'emails.address.*', + inclusiveFieldsKeys.includes('username') && 'username.*', + inclusiveFieldsKeys.includes('name') && 'name.*', + inclusiveFieldsKeys.includes('type') && 'type.*', + inclusiveFieldsKeys.includes('customFields') && 'customFields.*', + ].filter(Boolean) as string[], + // At this point, we have already validated the user query not containing malicious fields + // On here we are using our own query so we can allow some extra fields + [...this.queryOperations, '$regex', '$options'], + ) + ) { + throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); + } + + const hidden = await getUsersHiddenFrom(this.userId); + + if (hidden && queryFiltersStatus(query)) { + nonEmptyQuery.$and = [...(nonEmptyQuery.$and ?? []), { _id: { $nin: [...hidden] } }]; + } + + const actualSort = sort || { username: 1 }; + + if (sort?.status) { + actualSort.active = sort.status; + } + + if (sort?.name) { + actualSort.nameInsensitive = sort.name; + } + + const limit = + count !== 0 + ? [ + { + $limit: count, + }, + ] + : []; + + const result = await Users.col + .aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([ + { + $match: nonEmptyQuery, + }, + { + $project: inclusiveFields, + }, + { + $addFields: { + nameInsensitive: { + $toLower: '$name', + }, + }, + }, + { + $facet: { + sortedResults: [ + { + $sort: actualSort, + }, + { + $skip: offset, + }, + ...limit, + ], + totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }], + }, + }, + ]) + .toArray(); + + const { + sortedResults: users, + totalCount: [{ total } = { total: 0 }], + } = result[0]; + + return API.v1.success({ + users: redactHiddenUsers(users, hidden), + count: users.length, + offset, + total, + }); + }, + }, +); + +API.v1.get( + 'users.listByStatus', + { + authRequired: true, + query: isUsersListStatusProps, + permissionsRequired: ['view-d-room'], + response: { + 200: ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.user, 'view-outside-room')) + ) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams; + + const result = await findPaginatedUsersByStatus({ + uid: this.userId, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, + inactiveReason, + }); + + const hidden = await getUsersHiddenFrom(this.userId); + + return API.v1.success({ + ...result, + users: redactHiddenUsers(result.users, hidden), + }); + }, +); + +API.v1.post( + 'users.sendWelcomeEmail', + { + authRequired: true, + body: isUsersSendWelcomeEmailProps, + permissionsRequired: ['send-mail'], + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { email } = this.bodyParams; + + if (!isSMTPConfigured()) { + throw new MeteorError('error-email-send-failed', 'SMTP is not configured', { + method: 'sendWelcomeEmail', + }); + } + + const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } }); + + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user', { + method: 'sendWelcomeEmail', + }); + } + + await sendWelcomeEmail({ ...user, email }); + + return API.v1.success(); + }, +); + +API.v1.post( + 'users.register', + { + authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, + }, + body: isUserRegisterParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { secret: secretURL, ...params } = this.bodyParams; + + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } + + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } + + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } + + if (!(await checkUsernameAvailability(this.bodyParams.username))) { + return API.v1.failure('Username is already in use'); + } + if (!(await checkEmailAvailability(this.bodyParams.email))) { + return API.v1.failure('Email already exists'); + } + if (this.bodyParams.customFields) { + try { + validateCustomFields(this.bodyParams.customFields); + } catch (e) { + return API.v1.failure(e); + } + } + + // Register the user + const userId = await registerUser({ + ...params, + ...(secretURL && { secretURL }), + }); + + if (typeof userId !== 'string') { + return API.v1.failure('Error creating user'); + } + + // Now set their username + const { fields } = await this.parseJsonQuery(); + await setUsernameWithValidation(userId, this.bodyParams.username); + + const user = await Users.findOneById(userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } + + if (this.bodyParams.customFields) { + await saveCustomFields(userId, this.bodyParams.customFields); + } + + return API.v1.success({ user }); + }, +); + +API.v1.post( + 'users.resetAvatar', + { + authRequired: true, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + await resetAvatar(this.userId, this.userId); + } else if ( + (await hasPermissionAsync(this.user, 'edit-other-user-avatar')) || + (await hasPermissionAsync(this.user, 'manage-moderation-actions')) + ) { + await resetAvatar(this.userId, user._id); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } + + return API.v1.success(); + }, +); + +const usersEndpoints = API.v1 + .post( + 'users.createToken', + { + authRequired: true, + body: ajv.compile<{ userId: string; secret: string }>({ + type: 'object', + properties: { + userId: { + type: 'string', + minLength: 1, + }, + secret: { + type: 'string', + minLength: 1, + }, + }, + required: ['userId', 'secret'], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ data: { userId: string; authToken: string } }>({ + type: 'object', + properties: { + data: { + type: 'object', + properties: { + userId: { + type: 'string', + minLength: 1, + }, + authToken: { + type: 'string', + minLength: 1, + }, + }, + required: ['userId', 'authToken'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['data', 'success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + const data = await generateAccessToken(user._id, this.bodyParams.secret, this.user); + + return API.v1.success({ data }); + }, + ) + .get( + 'users.getAvatarSuggestion', + { + authRequired: true, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + suggestions: Record< + string, + { + blob: string; + contentType: string; + service: string; + url: string; + } + >; + }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + suggestions: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + blob: { + type: 'string', + }, + contentType: { + type: 'string', + }, + service: { + type: 'string', + }, + url: { + type: 'string', + format: 'uri', + }, + }, + required: ['blob', 'contentType', 'service', 'url'], + additionalProperties: false, + }, + }, + }, + required: ['success', 'suggestions'], + additionalProperties: false, + }), + }, + }, + async function action() { + const suggestions = await getAvatarSuggestionForUser(this.user); + + return API.v1.success({ suggestions }); + }, + ); + +API.v1.get( + 'users.getPreferences', + { + authRequired: true, + response: { + 200: ajv.compile<{ preferences: Record }>({ + type: 'object', + properties: { + // preferences is a dynamic key-value object that varies per user + preferences: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['preferences', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await Users.findOneById(this.userId); + if (user?.settings) { + const { preferences = {} } = user?.settings; + preferences.language = user?.language; + + return API.v1.success({ + preferences, + }); + } + return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase()); + }, +); + +API.v1 + .post( + 'users.forgotPassword', + { + authRequired: false, + body: ajv.compile<{ email: string }>({ + type: 'object', + properties: { + email: { type: 'string' }, + }, + required: ['email'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const isPasswordResetEnabled = settings.get('Accounts_PasswordReset'); + + if (!isPasswordResetEnabled) { + return API.v1.failure('Password reset is not enabled'); + } + + await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase()); + return API.v1.success(); + }, + ) + .get( + 'users.getUsernameSuggestion', + { + authRequired: true, + userWithoutUsername: true, + response: { + 200: ajv.compile<{ result: string }>({ + type: 'object', + properties: { + result: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const result = await generateUsernameSuggestion(this.user); + + if (!result) { + return API.v1.failure('No username suggestion found'); + } + + return API.v1.success({ result }); + }, + ); + +const tokenNameBodySchema = ajv.compile<{ tokenName: string; bypassTwoFactor?: boolean }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + bypassTwoFactor: { type: 'boolean', nullable: true }, + }, + required: ['tokenName'], + additionalProperties: false, +}); + +const tokenResponseSchema = ajv.compile<{ token: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['token', 'success'], + additionalProperties: false, +}); + +API.v1 + .get( + 'users.checkUsernameAvailability', + { + authRequired: true, + query: isUsersCheckUsernameAvailabilityParamsGET, + response: { + 200: ajv.compile<{ result: boolean }>({ + type: 'object', + properties: { + result: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { username } = this.queryParams; + + const result = await checkUsernameAvailabilityWithValidation(this.userId, username); + + return API.v1.success({ result }); + }, + ) + .post( + 'users.generatePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: tokenNameBodySchema, + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { tokenName, bypassTwoFactor = false } = this.bodyParams; + const token = await generatePersonalAccessTokenOfUser({ tokenName, userId: this.userId, bypassTwoFactor }); + + return API.v1.success({ token }); + }, + ) + .post( + 'users.regeneratePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { tokenName } = this.bodyParams; + const token = await regeneratePersonalAccessTokenOfUser(tokenName, this.userId); + + return API.v1.success({ token }); + }, + ) + .get( + 'users.getPersonalAccessTokens', + { + authRequired: true, + permissionsRequired: ['create-personal-access-tokens'], + response: { + 200: ajv.compile<{ tokens: { name: string; createdAt: string; lastTokenPart: string; bypassTwoFactor: boolean }[] }>({ + type: 'object', + properties: { + tokens: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + createdAt: { type: 'string' }, + lastTokenPart: { type: 'string' }, + bypassTwoFactor: { type: 'boolean' }, + }, + required: ['name', 'createdAt', 'lastTokenPart', 'bypassTwoFactor'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['tokens', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = (await Users.getLoginTokensByUserId(this.userId).toArray())[0] as unknown as IUser | undefined; + + const isPersonalAccessToken = (loginToken: ILoginToken | IPersonalAccessToken): loginToken is IPersonalAccessToken => + 'type' in loginToken && loginToken.type === 'personalAccessToken'; + + return API.v1.success({ + tokens: + user?.services?.resume?.loginTokens?.filter(isPersonalAccessToken).map((loginToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt.toISOString(), + lastTokenPart: loginToken.lastTokenPart, + bypassTwoFactor: Boolean(loginToken.bypassTwoFactor), + })) || [], + }); + }, + ) + .post( + 'users.removePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await removePersonalAccessTokenOfUser(this.bodyParams.tokenName, this.userId); + + return API.v1.success(); + }, + ); + +API.v1 + .post( + 'users.2fa.enableEmail', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified); + if (hasUnverifiedEmail) { + throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA'); + } + + await Users.enableEmail2FAByUserId(this.userId); + + // When 2FA is enable we logout all other clients + const xAuthToken = this.request.headers.get('x-auth-token') as string; + if (!xAuthToken) { + return API.v1.success(); + } + + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new MeteorError('error-logging-out-other-clients', 'Error logging out other clients'); + } + + // TODO this can be optmized so places that care about loginTokens being removed are invoked directly + // instead of having to listen to every watch.users event + void notifyOnUserChangeAsync(async () => { + const user = await Users.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1, 'services.email2fa': 1 } }); + if (!user) { + return; + } + + return { + clientAction: 'updated', + id: this.user._id, + diff: { 'services.resume.loginTokens': user.services?.resume?.loginTokens, 'services.email2fa': user.services?.email2fa }, + }; + }); + + return API.v1.success(); + }, + ) + .post( + 'users.2fa.disableEmail', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await Users.disableEmail2FAByUserId(this.userId); + + void notifyOnUserChangeAsync(async () => { + const user = await Users.findOneById(this.userId, { projection: { 'services.email2fa': 1 } }); + if (!user) { + return; + } + + return { + clientAction: 'updated', + id: this.user._id, + diff: { 'services.email2fa': user.services?.email2fa }, + }; + }); + + return API.v1.success(); + }, + ) + .post( + 'users.2fa.sendEmailCode', + { + body: ajv.compile<{ emailOrUsername: string }>({ + type: 'object', + properties: { + emailOrUsername: { type: 'string' }, + }, + required: ['emailOrUsername'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { emailOrUsername } = this.bodyParams; + + const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername'; + const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id; + + if (!userId) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } + const user = await getUserForCheck(userId); + if (!user) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } + + await emailCheck.sendEmailCode(user); + + return API.v1.success(); + }, + ); + +API.v1.post( + 'users.sendConfirmationEmail', + { + authRequired: false, + body: isUsersSendConfirmationEmailParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + void sendConfirmationEmail(this.bodyParams.email).catch((err) => SystemLogger.error({ msg: 'sendConfirmationEmail failed', err })); + return API.v1.success(); + }, +); + +API.v1.get( + 'users.presence', + { + authRequired: true, + query: isUsersPresenceParamsGET, + response: { + 200: ajv.compile<{ users: object[]; full: boolean }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + full: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'full', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // if presence broadcast is disabled, return an empty array (all users are "offline") + if (settings.get('Presence_broadcast_disabled')) { + return API.v1.success({ + users: [], + full: true, + }); + } + + const { from, ids } = this.queryParams; + + const options = { + projection: { + username: 1, + name: 1, + status: 1, + utcOffset: 1, + statusText: 1, + statusSource: 1, + statusExpiresAt: 1, + avatarETag: 1, + }, + }; + + const hidden = await getUsersHiddenFrom(this.userId); + + if (ids) { + const requested = Array.isArray(ids) ? ids : ids.split(','); + const users = await Users.findPresenceUsersByIds(requested, options).toArray(); + + return API.v1.success({ + users: filterHiddenUsers(users, hidden), + full: false, + }); + } + + if (from) { + const ts = new Date(from); + const diff = (Date.now() - Number(ts)) / 1000 / 60; + + if (diff < 10) { + const users = await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(); + + return API.v1.success({ + users: filterHiddenUsers(users, hidden), + full: false, + }); + } + } + + const users = await Users.findUsersNotOffline(options).toArray(); + + return API.v1.success({ + users: filterHiddenUsers(users, hidden), + full: true, + }); + }, +); + +API.v1 + .get( + 'users.requestDataDownload', + { + authRequired: true, + query: isUsersRequestDataDownloadParamsGET, + response: { + 200: ajv.compile<{ + requested: boolean; + exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; + }>({ + type: 'object', + properties: { + requested: { type: 'boolean' }, + // IExportOperation has complex/dynamic shape not yet in typia + exportOperation: { type: 'object' }, + url: { type: 'string', nullable: true }, + pendingOperationsBeforeMyRequest: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['requested', 'exportOperation', 'pendingOperationsBeforeMyRequest', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { fullExport = false } = this.queryParams; + const result = await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' }); + + return API.v1.success({ + requested: Boolean(result.requested), + exportOperation: result.exportOperation, + url: result.url, + pendingOperationsBeforeMyRequest: result.pendingOperationsBeforeMyRequest, + }); + }, + ) + .post( + 'users.logoutOtherClients', + { + authRequired: true, + response: { + 200: ajv.compile<{ token: string; tokenExpires: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + tokenExpires: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['token', 'tokenExpires', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const xAuthToken = this.request.headers.get('x-auth-token') as string; + + if (!xAuthToken) { + throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); + } + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } + + const me = (await Users.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick; + + void notifyOnUserChange({ + clientAction: 'updated', + id: this.userId, + diff: { 'services.resume.loginTokens': me.services?.resume?.loginTokens }, + }); + + const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); + + const loginExp = settings.get('Accounts_LoginExpiration'); + + const tokenExpires = (token && 'when' in token && new Date(token.when.getTime() + getLoginExpirationInMs(loginExp))) || undefined; + + return API.v1.success({ + token: xAuthToken, + tokenExpires: tokenExpires?.toISOString() || '', + }); + }, + ); + +API.v1.get( + 'users.autocomplete', + { + authRequired: true, + query: isUsersAutocompleteProps, + response: { + 200: ajv.compile<{ items: object[] }>({ + type: 'object', + properties: { + // autocomplete items shape varies by permissions + items: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector: selectorRaw } = this.queryParams; + + const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw); + + try { + if (selector?.conditions) { + const canViewFullInfo = await hasPermissionAsync(this.user, 'view-full-other-user-info'); + const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields); + + if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) { + throw new Error('error-invalid-query'); + } + } + } catch (e) { + return API.v1.failure(e); + } + + const hidden = await getUsersHiddenFrom(this.userId); + + if (hidden && queryFiltersStatus(selector.conditions)) { + selector.conditions = { $and: [selector.conditions, { _id: { $nin: [...hidden] } }] }; + } + + const { items } = await findUsersToAutocomplete({ uid: this.userId, selector }); + + return API.v1.success({ items: redactHiddenUsers(items, hidden) }); + }, +); + +API.v1 + .post( + 'users.removeOtherTokens', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await Users.removeNonLoginTokensExcept(this.userId, this.token); + return API.v1.success(); + }, + ) + .post( + 'users.resetE2EKey', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + const user = await getUserFromParams(this.bodyParams); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } + + if (!(await hasPermissionAsync(this.user, 'edit-other-user-e2ee'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + if (!(await resetUserE2EEncriptionKey(user._id, true))) { + return API.v1.failure(); + } + + return API.v1.success(); + } + await resetUserE2EEncriptionKey(this.userId, false); + return API.v1.success(); + }, + ) + .post( + 'users.resetTOTP', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + if (!(await hasPermissionAsync(this.user, 'edit-other-user-totp'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) { + throw new Meteor.Error('error-two-factor-not-enabled', 'Two factor authentication is not enabled'); + } + + const user = await getUserFromParams(this.bodyParams); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } + + await resetTOTP(user._id, true); + + return API.v1.success(); + } + await resetTOTP(this.userId, false); + return API.v1.success(); + }, + ); + +API.v1 + .get( + 'users.listTeams', + { + authRequired: true, + query: isUsersListTeamsProps, + response: { + 200: ajv.compile<{ teams: unknown[] }>({ + type: 'object', + properties: { + teams: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + check( + this.queryParams, + Match.ObjectIncluding({ + userId: Match.Maybe(String), + }), + ); + + const { userId } = this.queryParams; + + // If the caller has permission to view all teams, there's no need to filter the teams + const adminId = (await hasPermissionAsync(this.user, 'view-all-teams')) ? undefined : this.userId; + + const teams = await Team.findBySubscribedUserIds(userId, adminId); + + return API.v1.success({ + teams, + }); + }, + ) + .post( + 'users.logout', + { + authRequired: true, + body: isUserLogoutParamsPOST, + response: { + 200: ajv.compile<{ message: string }>({ + type: 'object', + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const userId = this.bodyParams.userId || this.userId; + + if (userId !== this.userId && !(await hasPermissionAsync(this.user, 'logout-other-user'))) { + return API.v1.forbidden(); + } + + const user = await Users.findOneById(userId); + + // this method logs the user out automatically, if successful returns 1, otherwise 0 + if (!(await Users.unsetLoginTokens(userId))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } + + await Sessions.logoutAllByUserId(userId, this.userId); + + void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [] } }); + + if (user) { + await runUserLogoutCleanUp(user); + } + + return API.v1.success({ + message: `User ${userId} has been logged out!`, + }); + }, + ); + +const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const; + +API.v1 + .get( + 'users.getPresence', + { + authRequired: true, + query: isUsersGetPresenceParamsGET, + response: { + 200: ajv.compile<{ presence: UserStatus; connectionStatus?: string; lastLogin?: Date }>({ + type: 'object', + properties: { + presence: statusType, + connectionStatus: { type: 'string', nullable: true }, + lastLogin: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['presence', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (isUserFromParams(this.queryParams, this.userId, this.user)) { + const user = await Users.findOneById(this.userId); + return API.v1.success({ + presence: (user?.status || 'offline') as UserStatus, + connectionStatus: user?.statusConnection || 'offline', + ...(user?.lastLogin && { lastLogin: user?.lastLogin }), + }); + } + + const user = await getUserFromParams(this.queryParams); + const hidden = await getUsersHiddenFrom(this.userId); + + return API.v1.success({ + presence: (hidden?.has(user._id) ? 'offline' : user.status || 'offline') as UserStatus, + }); + }, + ) + .post( + 'users.setStatus', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ + status?: Exclude; + message?: string; + expiresAt?: string; + userId?: string; + username?: string; + user?: string; + }>({ + type: 'object', + properties: { + status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] }, + message: { type: 'string', nullable: true }, + expiresAt: { type: 'string', nullable: true }, + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + check( + this.bodyParams, + Match.OneOf( + Match.ObjectIncluding({ + status: Match.Maybe(String), + message: String, + }), + Match.ObjectIncluding({ + status: String, + message: Match.Maybe(String), + }), + ), + ); + + const user = await (async () => { + if (isUserFromParams(this.bodyParams, this.userId, this.user)) { + return Users.findOneById(this.userId); + } + if (await hasPermissionAsync(this.user, 'edit-other-user-info')) { + return getUserFromParams(this.bodyParams); + } + })(); + + if (!user) { + return API.v1.forbidden(); + } + + const { status, message, expiresAt } = this.bodyParams; + + if (message && !settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + const statusExpiresAt = expiresAt ? new Date(expiresAt) : undefined; + if (statusExpiresAt && Number.isNaN(statusExpiresAt.getTime())) { + throw new Meteor.Error('error-invalid-date', 'Invalid expiresAt date string', { + method: 'users.setStatus', + }); + } + + if (statusExpiresAt && statusExpiresAt.getTime() <= Date.now()) { + throw new Meteor.Error('error-invalid-date', 'expiresAt must be a future date', { + method: 'users.setStatus', + }); + } + + // If status is missing (message-only update), keep the user's chosen status (statusDefault), + // not the computed status — otherwise a transient auto-away/offline gets pinned as a manual claim. + const effectiveStatus = status || user.statusDefault || ('online' as UserStatus); + + if (effectiveStatus === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'users.setStatus', + }); + } + + await Presence.setStatus(user._id, effectiveStatus, message, statusExpiresAt); + + return API.v1.success(); + }, + ) + .get( + 'users.getStatus', + { + authRequired: true, + query: isUsersGetStatusParamsGET, + response: { + 200: ajv.compile<{ _id: string; status: string; connectionStatus?: string; statusSource?: string; statusExpiresAt?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + status: statusType, + connectionStatus: { type: 'string', nullable: true }, + statusSource: { type: 'string', nullable: true }, + statusExpiresAt: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'status', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (isUserFromParams(this.queryParams, this.userId, this.user)) { + return API.v1.success({ + _id: this.userId, + connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + ...(this.user.statusSource && { statusSource: this.user.statusSource }), + ...(this.user.statusExpiresAt && { statusExpiresAt: this.user.statusExpiresAt.toISOString() }), + }); + } + + const user = await getUserFromParams(this.queryParams); + const hidden = await getUsersHiddenFrom(this.userId); + const visible = hidden?.has(user._id) ? redactStatus(user) : user; + + return API.v1.success({ + _id: visible._id, + status: (visible.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + ...(visible.statusSource && { statusSource: visible.statusSource }), + ...(visible.statusExpiresAt && { statusExpiresAt: visible.statusExpiresAt.toISOString() }), + }); + }, + ); + +API.v1.post( + 'users.verifyEmail', + { + authRequired: false, + body: ajv.compile<{ token: string }>({ + type: 'object', + properties: { + token: { type: 'string', minLength: 1 }, + }, + required: ['token'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { token } = this.bodyParams; + + const user = await Users.findOneByEmailVerificationToken>(token); + if (!user) { + return API.v1.forbidden('Verify email link expired'); + } + + await verifyEmail(user, token); + + await runAfterVerifyEmail(user._id); + + return API.v1.success(); + }, +); +API.v1 + .post( + 'users.enableTotp', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + response: { + 200: ajv.compile<{ secret: string; url: string }>({ + type: 'object', + properties: { + secret: { type: 'string' }, + url: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['secret', 'url', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + return API.v1.success(await enableTotp(this.userId)); + }, + ) + .post( + 'users.disableTotp', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ code: string }>({ + type: 'object', + properties: { code: { type: 'string', minLength: 1 } }, + required: ['code'], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ disabled: boolean }>({ + type: 'object', + properties: { + disabled: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['disabled', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const disabled = await disableTotp(this.userId, this.bodyParams.code); + return API.v1.success({ disabled }); + }, + ) + .post( + 'users.validateTotp', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ code: string }>({ + type: 'object', + properties: { code: { type: 'string', minLength: 1 } }, + required: ['code'], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ codes: string[] }>({ + type: 'object', + properties: { + codes: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['codes', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const result = await validateTotpTempToken(this.userId, this.bodyParams.code, this.request.headers.get('x-auth-token') ?? undefined); + return API.v1.success(result); + }, + ) + .post( + 'users.regenerateTotpCodes', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ code: string }>({ + type: 'object', + properties: { code: { type: 'string', minLength: 1 } }, + required: ['code'], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ codes: string[] }>({ + type: 'object', + properties: { + codes: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['codes', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const result = await regenerateTotpCodes(this.userId, this.bodyParams.code); + if (!result) { + return API.v1.failure('invalid-totp'); + } + return API.v1.success(result); + }, + ) + .get( + 'users.totpCodesRemaining', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + response: { + 200: ajv.compile<{ remaining: number }>({ + type: 'object', + properties: { + remaining: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['remaining', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + return API.v1.success(await codesRemainingTotp(this.userId)); + }, + ); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { + const userRegisterRoute = '/api/v1/users.registerpost'; + + API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); +}); + +type UsersEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends UsersEndpoints {} +} diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts new file mode 100644 index 0000000..b13ee20 --- /dev/null +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -0,0 +1,345 @@ +import { VideoConf } from '@rocket.chat/core-services'; +import type { VideoConference, VideoConferenceCapabilities, VideoConferenceInstructions } from '@rocket.chat/core-typings'; +import { + ajv, + isVideoConfStartProps, + isVideoConfJoinProps, + isVideoConfCancelProps, + isVideoConfInfoProps, + isVideoConfListProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + +import { availabilityErrors } from '../../../lib/videoConference/constants'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { videoConfProviders } from '../../lib/videoConfProviders'; +import { API } from '../api'; +import { getPaginationItems } from '../lib/getPaginationItems'; + +const startResponseSchema = ajv.compile<{ data: VideoConferenceInstructions & { providerName: string } }>({ + type: 'object', + properties: { + data: { + allOf: [ + { + oneOf: [ + { $ref: '#/components/schemas/DirectCallInstructions' }, + { $ref: '#/components/schemas/ConferenceInstructions' }, + { $ref: '#/components/schemas/LivechatInstructions' }, + ], + }, + { type: 'object', properties: { providerName: { type: 'string' } }, required: ['providerName'] }, + ], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const joinResponseSchema = ajv.compile<{ url: string; providerName: string }>({ + type: 'object', + properties: { + url: { type: 'string' }, + providerName: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['url', 'providerName', 'success'], + additionalProperties: false, +}); + +const cancelResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const infoResponseSchema = ajv.compile({ + type: 'object', + properties: { + capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' }, + }, + additionalProperties: true, +}); + +const listResponseSchema = ajv.compile<{ data: VideoConference[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/IDirectVideoConference' }, + { $ref: '#/components/schemas/IGroupVideoConference' }, + { $ref: '#/components/schemas/ILivechatVideoConference' }, + { $ref: '#/components/schemas/IVoIPVideoConference' }, + ], + }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const providersResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({ + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { key: { type: 'string' }, label: { type: 'string' } }, + required: ['key', 'label'], + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const capabilitiesResponseSchema = ajv.compile<{ providerName: string; capabilities: VideoConferenceCapabilities }>({ + type: 'object', + properties: { + providerName: { type: 'string' }, + capabilities: { $ref: '#/components/schemas/VideoConferenceCapabilities' }, + }, + additionalProperties: true, +}); + +API.v1.post( + 'video-conference.start', + { + authRequired: true, + body: isVideoConfStartProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, + response: { + 200: startResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, title, allowRinging: requestRinging } = this.bodyParams; + const { userId } = this; + + if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { + return API.v1.forbidden('Not allowed'); + } + + try { + await canSendMessageAsync(roomId, { + uid: userId, + username: this.user.username, + type: this.user.type ?? 'user', + }); + } catch { + return API.v1.forbidden('Not allowed'); + } + + try { + const providerName = videoConfProviders.getActiveProvider(); + + if (!providerName) { + throw new Error(availabilityErrors.NOT_ACTIVE); + } + + const allowRinging = Boolean(requestRinging) && (await hasPermissionAsync(userId, 'videoconf-ring-users')); + + return API.v1.success({ + data: { + ...(await VideoConf.start(userId, roomId, { title, allowRinging })), + providerName, + }, + }); + } catch (e) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, roomId)); + } + }, +); + +API.v1.post( + 'video-conference.join', + { + authOrAnonRequired: true, + body: isVideoConfJoinProps, + rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 5000 }, + response: { + 200: joinResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, state } = this.bodyParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + let url: string | undefined; + + try { + url = await VideoConf.join(userId, callId, { + ...(state?.cam !== undefined ? { cam: state.cam } : {}), + ...(state?.mic !== undefined ? { mic: state.mic } : {}), + }); + } catch (e) { + if (userId) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, call.rid, call.providerName)); + } + } + + if (!url) { + return API.v1.failure('failed-to-get-url'); + } + + return API.v1.success({ + url, + providerName: call.providerName, + }); + }, +); + +API.v1.post( + 'video-conference.cancel', + { + authRequired: true, + body: isVideoConfCancelProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + await VideoConf.cancel(userId, callId); + return API.v1.success(); + }, +); + +API.v1.get( + 'video-conference.info', + { + authRequired: true, + query: isVideoConfInfoProps, + rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 3000 }, + response: { + 200: infoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.queryParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + const capabilities = await VideoConf.listProviderCapabilities(call.providerName); + + return API.v1.success({ + ...(call as VideoConference), + capabilities, + }); + }, +); + +API.v1.get( + 'video-conference.list', + { + authRequired: true, + query: isVideoConfListProps, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: listResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.queryParams; + const { userId } = this; + + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + return API.v1.failure('invalid-params'); + } + + const data = await VideoConf.list(roomId, { offset, count }); + + return API.v1.success(data); + }, +); + +API.v1.get( + 'video-conference.providers', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: providersResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const data = await VideoConf.listProviders(); + + return API.v1.success({ data }); + }, +); + +API.v1.get( + 'video-conference.capabilities', + { + authRequired: true, + rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 }, + response: { + 200: capabilitiesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const data = await VideoConf.listCapabilities(); + + return API.v1.success(data); + }, +); diff --git a/apps/meteor/server/api/v1/webdav.ts b/apps/meteor/server/api/v1/webdav.ts new file mode 100644 index 0000000..0a7fedf --- /dev/null +++ b/apps/meteor/server/api/v1/webdav.ts @@ -0,0 +1,141 @@ +import { api } from '@rocket.chat/core-services'; +import type { IWebdavAccount, IWebdavAccountIntegration } from '@rocket.chat/core-typings'; +import { WebdavAccounts } from '@rocket.chat/models'; +import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import type { DeleteResult } from 'mongodb'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { findWebdavAccountsByUserId } from '../lib/webdav'; + +const webdavGetMyAccountsEndpoints = API.v1.get( + 'webdav.getMyAccounts', + { + authRequired: true, + response: { + 200: ajv.compile<{ + accounts: IWebdavAccountIntegration[]; + }>({ + type: 'object', + properties: { + accounts: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { + type: 'string', + }, + serverURL: { + type: 'string', + }, + username: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + required: ['_id', 'serverURL', 'username', 'name'], + additionalProperties: false, + }, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success', 'accounts'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + return API.v1.success({ + accounts: await findWebdavAccountsByUserId({ uid: this.userId }), + }); + }, +); + +type POSTRemoveWebdavAccount = { + accountId: IWebdavAccount['_id']; +}; + +const POSTRemoveWebdavAccountSchema = { + type: 'object', + properties: { + accountId: { + type: 'string', + }, + }, + required: ['accountId'], + additionalProperties: false, +}; + +const isPOSTRemoveWebdavAccount = ajv.compile(POSTRemoveWebdavAccountSchema); + +const webdavRemoveAccountEndpoints = API.v1.post( + 'webdav.removeWebdavAccount', + { + authRequired: true, + validateParams: isPOSTRemoveWebdavAccount, + body: isPOSTRemoveWebdavAccount, + response: { + 200: ajv.compile<{ + result: DeleteResult; + }>({ + type: 'object', + properties: { + result: { + type: 'object', + properties: { + acknowledged: { + type: 'boolean', + }, + deletedCount: { + type: 'integer', + }, + }, + required: ['acknowledged', 'deletedCount'], + additionalProperties: false, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { accountId } = this.bodyParams; + + const removed = await WebdavAccounts.removeByUserAndId(accountId, this.userId); + if (removed) { + void api.broadcast('notify.webdav', this.userId, { + type: 'removed', + account: { _id: accountId }, + }); + } + + return API.v1.success({ result: removed }); + }, +); + +type WebdavGetMyAccountsEndpoints = ExtractRoutesFromAPI; + +type WebdavRemoveAccountEndpoints = ExtractRoutesFromAPI; + +export type WebdavEndpoints = WebdavGetMyAccountsEndpoints | WebdavRemoveAccountEndpoints; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends WebdavGetMyAccountsEndpoints {} + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends WebdavRemoveAccountEndpoints {} +} diff --git a/apps/meteor/server/api/validation/ajv.ts b/apps/meteor/server/api/validation/ajv.ts new file mode 100644 index 0000000..cd36785 --- /dev/null +++ b/apps/meteor/server/api/validation/ajv.ts @@ -0,0 +1,42 @@ +import { schemas } from '@rocket.chat/core-typings'; +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +const components = schemas.components?.schemas; +if (components) { + // Patch MessageAttachmentDefault to reject unknown properties so the oneOf + // discriminator works correctly (otherwise it matches every attachment). + const mad = components.MessageAttachmentDefault; + if (mad && typeof mad === 'object' && 'type' in mad) { + (mad as Record).additionalProperties = false; + } + + // Lock down the catch-all "plain file" attachment branch. typia emits the + // FileAttachmentProps union as `(type: 'file') & (video | image | audio | base)`; + // the base branch only requires `type: 'file'`, so it also matches image/video/audio + // payloads. That makes the MessageAttachment oneOf ambiguous — a single image + // attachment satisfies both its specific branch and the base branch, violating + // oneOf's "exactly one" rule and failing response validation for any message with a + // file or quoted-file attachment. Reject unknown props on the base branch so only + // genuine plain-file attachments match it. + for (const key in components) { + if (!Object.prototype.hasOwnProperty.call(components, key)) { + continue; + } + const schema = components[key] as { properties?: Record }; + const props = schema?.properties; + const typeEnum = (props?.type as { enum?: unknown[] } | undefined)?.enum; + const isFileBranch = Array.isArray(typeEnum) && typeEnum.length === 1 && typeEnum[0] === 'file'; + const hasMediaUrl = !!props && ('image_url' in props || 'video_url' in props || 'audio_url' in props); + if (isFileBranch && !hasMediaUrl) { + (schema as Record).additionalProperties = false; + } + } + + for (const key in components) { + if (Object.prototype.hasOwnProperty.call(components, key)) { + const uri = `#/components/schemas/${key}`; + ajv.addSchema(components[key], uri); + ajvQuery.addSchema(components[key], uri); + } + } +} diff --git a/apps/meteor/server/api/webhooks.spec.ts b/apps/meteor/server/api/webhooks.spec.ts new file mode 100644 index 0000000..9b01bba --- /dev/null +++ b/apps/meteor/server/api/webhooks.spec.ts @@ -0,0 +1,809 @@ +import express from 'express'; +import { WebApp } from 'meteor/webapp'; +import request from 'supertest'; + +const mockIntegrations = { + findOneByIdAndToken: jest.fn(), + findOneByUrl: jest.fn(), +}; + +const mockUsers = { + findOneById: jest.fn(), +}; + +const mockHasPermissionAsync = jest.fn(); +const mockProcessWebhookMessage = jest.fn(); +const mockAddOutgoingIntegration = jest.fn(); +const mockDeleteOutgoingIntegration = jest.fn(); + +const mockSettingsStore = new Map([ + ['API_Enable_Rate_Limiter_Limit_Calls_Default', 1], + ['API_Enable_Rate_Limiter_Limit_Time_Default', 60000], + ['API_Enable_Rate_Limiter', false], +]); + +const rateLimiterCounters = new Map(); + +class MockRateLimiter { + private numRequestsAllowed = Number.POSITIVE_INFINITY; + + private intervalTimeInMS = 0; + + private counters = rateLimiterCounters; + + addRule(_rule: unknown, numRequestsAllowed: number, intervalTimeInMS: number) { + this.numRequestsAllowed = numRequestsAllowed; + this.intervalTimeInMS = intervalTimeInMS; + } + + increment(match: Record) { + const key = JSON.stringify(match); + this.counters.set(key, (this.counters.get(key) ?? 0) + 1); + } + + async check(match: Record) { + const used = this.counters.get(JSON.stringify(match)) ?? 0; + return { + allowed: used <= this.numRequestsAllowed, + numInvocationsLeft: Math.max(0, this.numRequestsAllowed - used), + timeToReset: this.intervalTimeInMS, + }; + } +} + +jest.mock( + 'meteor/meteor', + () => ({ + Meteor: { + startup: (callback: () => void) => callback(), + Error: class MeteorError extends Error { + public details?: unknown; + + constructor( + public error: string, + public reason?: string, + details?: unknown, + ) { + super(reason ?? error); + this.details = details; + } + }, + }, + }), + { virtual: true }, +); +jest.mock('meteor/webapp', () => ({ WebApp: { rawConnectHandlers: { use: jest.fn() } } }), { virtual: true }); +jest.mock('meteor/rate-limit', () => ({ RateLimiter: MockRateLimiter }), { virtual: true }); +jest.mock( + 'meteor/accounts-base', + () => ({ Accounts: { _hashLoginToken: (token: string) => `hashed-${token}`, _accountData: {} as Record } }), + { virtual: true }, +); +jest.mock('meteor/ddp', () => ({ DDP: {} }), { virtual: true }); +jest.mock('meteor/ddp-common', () => ({ DDPCommon: {} }), { virtual: true }); + +jest.mock('../../app/utils/rocketchat.info', () => ({ Info: { version: '0.0.0' } })); + +jest.mock('../settings', () => ({ + settings: { + get: (id: string) => mockSettingsStore.get(id), + watch: jest.fn(), + watchMultiple: jest.fn(), + watchByRegex: jest.fn(), + getByRegexp: jest.fn(() => []), + }, +})); + +jest.mock('meteor/facts-base', () => ({ Facts: { incrementServerFact: () => undefined } }), { virtual: true }); + +jest.mock('@rocket.chat/models', () => ({ + Integrations: mockIntegrations, + Users: mockUsers, +})); + +jest.mock('meteor/email', () => ({ Email: {} }), { virtual: true }); +jest.mock('../lib/authorization/hasPermission', () => ({ hasPermissionAsync: mockHasPermissionAsync })); +jest.mock('../lib/messages/processWebhookMessage', () => ({ processWebhookMessage: mockProcessWebhookMessage })); +jest.mock('../meteor-methods/integrations/outgoing/addOutgoingIntegration', () => ({ + addOutgoingIntegration: mockAddOutgoingIntegration, +})); +jest.mock('../meteor-methods/integrations/outgoing/deleteOutgoingIntegration', () => ({ + deleteOutgoingIntegration: mockDeleteOutgoingIntegration, +})); + +const INTEGRATION = { + _id: 'integration-id', + name: 'my-integration', + enabled: true, + userId: 'assigned-user-id', + channel: ['#general'], + alias: 'alias', + avatar: 'avatar-url', + emoji: ':ghost:', + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + _updatedAt: new Date(0), +}; + +const ASSIGNED_USER = { _id: 'assigned-user-id', username: 'rocket.cat', name: 'Rocket Cat' }; + +const HOOK_URL = '/hooks/integration-id/the-token'; +const LEGACY_HOOK_URL = '/hooks/integration-id/legacy-user-id/the-token'; + +jest.requireActual('./api'); +jest.requireActual('./webhooks'); + +const app = express().use(jest.mocked(WebApp.rawConnectHandlers.use).mock.calls[0][0] as unknown as express.RequestHandler); + +describe('Webhooks API', () => { + beforeEach(() => { + jest.clearAllMocks(); + rateLimiterCounters.clear(); + mockSettingsStore.set('API_Enable_Rate_Limiter', false); + process.env.NODE_ENV = 'test'; + + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION }); + mockUsers.findOneById.mockResolvedValue({ ...ASSIGNED_USER }); + mockHasPermissionAsync.mockResolvedValue(true); + mockProcessWebhookMessage.mockResolvedValue([{ _id: 'message-id' }]); + }); + + describe('authentication and routing', () => { + it('accepts a POST for a valid integration id and token', async () => { + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ success: true }); + expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'the-token'); + }); + + it('accepts a GET for a valid integration id and token', async () => { + const response = await request(app).get(`${HOOK_URL}?text=hello`); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('accepts the legacy route carrying a userId segment', async () => { + const response = await request(app).post(LEGACY_HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(200); + expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'the-token'); + expect(mockProcessWebhookMessage).toHaveBeenCalledTimes(1); + }); + + it('url-decodes the token before looking the integration up', async () => { + await request(app).post('/hooks/integration-id/token%2520with%2520spaces').send({ text: 'hello' }); + + expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'token with spaces'); + }); + + // 500 is the current behavior and it is wrong: authenticatedRoute throws instead of returning null, so this never reaches the 401 path. + it('rejects an unknown integration id or token without processing anything', async () => { + mockIntegrations.findOneByIdAndToken.mockResolvedValue(null); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(500); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('rejects the request when the integration has no assigned user', async () => { + mockUsers.findOneById.mockResolvedValue(null); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(401); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('impersonates the user assigned to the integration', async () => { + mockUsers.findOneById.mockResolvedValue({ _id: 'other-user', username: 'other', name: 'Other' }); + + await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(mockUsers.findOneById).toHaveBeenCalledWith('assigned-user-id'); + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ _id: 'other-user' }), + expect.anything(), + ); + }); + }); + + describe('request body handling', () => { + it('forwards a plain JSON body untouched', async () => { + await request(app) + .post(HOOK_URL) + .send({ text: 'hello', attachments: [{ title: 't' }] }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', attachments: [{ title: 't' }] }), + expect.anything(), + expect.anything(), + ); + }); + + it('unwraps a url-encoded `payload` field holding JSON', async () => { + await request(app) + .post(HOOK_URL) + .type('form') + .send({ payload: JSON.stringify({ text: 'from payload' }) }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: 'from payload' }), + expect.anything(), + expect.anything(), + ); + }); + + it('keeps the original body when the url-encoded `payload` is not valid JSON', async () => { + await request(app).post(HOOK_URL).type('form').send({ payload: '{not json' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ payload: '{not json' }), + expect.anything(), + expect.anything(), + ); + }); + + it.each([ + ['an array', JSON.stringify([{ text: 'hello' }])], + ['a primitive', '42'], + ])('rejects a url-encoded `payload` holding %s', async (_label, payload) => { + const response = await request(app).post(HOOK_URL).type('form').send({ payload }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('Integration payload must be a JSON object, not an array or primitive'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('does not unwrap `payload` when the url-encoded body carries other fields', async () => { + await request(app) + .post(HOOK_URL) + .type('form') + .send({ payload: JSON.stringify({ text: 'from payload' }), token: 'abc' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ payload: JSON.stringify({ text: 'from payload' }), token: 'abc' }), + expect.anything(), + expect.anything(), + ); + }); + + it('does not unwrap `payload` when the content type is JSON', async () => { + await request(app) + .post(HOOK_URL) + .send({ payload: JSON.stringify({ text: 'from payload' }) }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ payload: JSON.stringify({ text: 'from payload' }) }), + expect.anything(), + expect.anything(), + ); + }); + + it('accepts an empty body without producing a message', async () => { + const response = await request(app).post(HOOK_URL); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ success: true }); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('discards a JSON body that is not an object', async () => { + const response = await request(app) + .post(HOOK_URL) + .send([{ text: 'hello' }]); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('still processes an empty body when the integration has scripting enabled', async () => { + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, scriptEnabled: true }); + + const response = await request(app).post(HOOK_URL); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).toHaveBeenCalledTimes(1); + }); + }); + + describe('integration execution', () => { + it('reports the service as unavailable when the integration is disabled', async () => { + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, enabled: false }); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(503); + expect(response.body).toEqual({ success: false, error: 'Service Unavailable' }); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('refuses to post when the assigned user lacks message-impersonate', async () => { + mockHasPermissionAsync.mockResolvedValue(false); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('error-user-lacks-message-impersonate-permission'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('sends the integration defaults and bot marker along with the message', async () => { + await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', bot: { i: 'integration-id' } }), + expect.objectContaining({ _id: 'assigned-user-id' }), + { channel: ['#general'], alias: 'alias', avatar: 'avatar-url', emoji: ':ghost:' }, + ); + }); + + it('falls back to empty defaults when the integration sets no alias, avatar or emoji', async () => { + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ + ...INTEGRATION, + alias: undefined, + avatar: undefined, + emoji: undefined, + }); + + await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + channel: ['#general'], + alias: '', + avatar: '', + emoji: '', + }); + }); + + it('refuses a `channel` override when overriding is disabled', async () => { + const response = await request(app).post(HOOK_URL).send({ text: 'hello', channel: '#other' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('overriding destination channel is disabled for this integration'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('refuses a `roomId` override when overriding is disabled', async () => { + const response = await request(app).post(HOOK_URL).send({ text: 'hello', roomId: 'room-id' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('overriding destination channel is disabled for this integration'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('forwards a `channel` override when the integration allows it', async () => { + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, overrideDestinationChannelEnabled: true }); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello', channel: '#other' }); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ channel: '#other' }), + expect.anything(), + expect.anything(), + ); + }); + + it('fails when message processing produces no message', async () => { + mockProcessWebhookMessage.mockResolvedValue([]); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('unknown-error'); + }); + + it('surfaces the error code when message processing rejects with one', async () => { + mockProcessWebhookMessage.mockRejectedValue(Object.assign(new Error('boom'), { error: 'error-invalid-room' })); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('error-invalid-room'); + }); + + it('surfaces the message when message processing rejects without an error code', async () => { + mockProcessWebhookMessage.mockRejectedValue(new Error('something exploded')); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('something exploded'); + }); + + it('reports an unknown error when message processing rejects with no detail', async () => { + mockProcessWebhookMessage.mockRejectedValue({}); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('Unknown error'); + }); + }); + + // The real engine caches compiled scripts per integration id and _updatedAt; reusing a pair silently runs the previous script. + describe('integration scripts', () => { + let scriptSeq = 0; + + const withScript = (body: string, overrides: Record = {}): void => { + scriptSeq += 1; + mockIntegrations.findOneByIdAndToken.mockResolvedValue({ + ...INTEGRATION, + _id: `integration-${scriptSeq}`, + _updatedAt: new Date(scriptSeq * 1000), + scriptEnabled: true, + script: '', + scriptCompiled: `class Script { process_incoming_request({ request }) { ${body} } }`, + ...overrides, + }); + }; + + it('posts the payload the script produced instead of the original one', async () => { + withScript("return { content: { text: 'rewritten by script' } };"); + + const response = await request(app).post(HOOK_URL).send({ text: 'original' }); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: 'rewritten by script' }), + expect.anything(), + expect.anything(), + ); + }); + + it('hands the script the request payload, raw content and context', async () => { + withScript( + 'return { content: { text: "x", seen: { content: request.content, content_raw: request.content_raw, url_params: request.url_params, pathname: request.url.pathname, query: request.url.query, host: request.headers.host, user: request.user } } };', + ); + + await request(app).post(`${HOOK_URL}?a=1`).set('host', 'example.com').send({ text: 'hello' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ + seen: { + content: { text: 'hello' }, + content_raw: JSON.stringify({ text: 'hello' }), + url_params: { integrationId: 'integration-id', token: 'the-token' }, + pathname: HOOK_URL, + query: { a: '1' }, + host: 'example.com', + user: { _id: 'assigned-user-id', name: 'Rocket Cat', username: 'rocket.cat' }, + }, + }), + expect.anything(), + expect.anything(), + ); + }); + + it('leaves the payload alone when the integration has no compiled script', async () => { + withScript('return { content: { text: "rewritten by script" } };', { scriptCompiled: undefined }); + + await request(app).post(HOOK_URL).send({ text: 'original' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: 'original' }), + expect.anything(), + expect.anything(), + ); + }); + + it('leaves the payload alone for a GET request, which carries no body', async () => { + withScript('return { content: { text: "rewritten by script" } };'); + + const response = await request(app).get(`${HOOK_URL}?text=hello`); + + expect(response.statusCode).toBe(200); + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + { bot: { i: `integration-${scriptSeq}` } }, + expect.anything(), + expect.anything(), + ); + }); + + it('accepts the request without posting when the script returns nothing', async () => { + withScript('return undefined;'); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ success: true }); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('propagates an error reported by the script', async () => { + withScript("return { error: 'script-said-no' };"); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('script-said-no'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('fails when the script returns no usable payload', async () => { + withScript('return { content: undefined };'); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('error-running-script'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('fails when running the script throws', async () => { + withScript("throw new Error('boom');"); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('error-running-script'); + expect(mockProcessWebhookMessage).not.toHaveBeenCalled(); + }); + + it('keeps the requested separateResponse when the script does not set one', async () => { + withScript("return { content: { text: 'from script' } };"); + + const response = await request(app).post(HOOK_URL).send({ text: 'original', separateResponse: true }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ responses: [{ _id: 'message-id' }], success: true }); + }); + + it('lets the script turn separateResponse off', async () => { + withScript("return { content: { text: 'from script', separateResponse: false } };"); + + const response = await request(app).post(HOOK_URL).send({ text: 'original', separateResponse: true }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + it('posts as the user the script returned', async () => { + withScript("return { content: { text: 'hello' }, user: { _id: 'script-user', username: 'script.user' } };"); + + await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(mockProcessWebhookMessage).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ _id: 'script-user' }), + expect.anything(), + ); + }); + + it('returns the custom response the script produced', async () => { + withScript("return { content: { text: 'hello' }, response: { custom: 'body' } };"); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ custom: 'body', success: true }); + }); + }); + + describe('separate responses', () => { + it('returns every delivery when at least one succeeded', async () => { + mockProcessWebhookMessage.mockResolvedValue([{ _id: 'ok' }, { error: 'error-invalid-room' }]); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello', separateResponse: true }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ responses: [{ _id: 'ok' }, { error: 'error-invalid-room' }], success: true }); + }); + + it('fails when every delivery failed', async () => { + mockProcessWebhookMessage.mockResolvedValue([{ error: 'error-invalid-room' }, { error: 'error-not-allowed' }]); + + const response = await request(app).post(HOOK_URL).send({ text: 'hello', separateResponse: true }); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + responses: [{ error: 'error-invalid-room' }, { error: 'error-not-allowed' }], + success: false, + }); + }); + }); + + // Only the legacy userId routes are reachable: :integrationId/:userId/:token is registered first and swallows the two-segment variants. + describe('sample and info endpoints', () => { + it('returns sample messages', async () => { + const response = await request(app).get('/hooks/sample/integration-id/legacy-user-id/the-token'); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[0]).toEqual( + expect.objectContaining({ channel_name: 'general', user_name: 'rocket.cat', text: 'Sample text 1', trigger_word: 'Sample' }), + ); + }); + + it('reports integration info', async () => { + const response = await request(app).get('/hooks/info/integration-id/legacy-user-id/the-token'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + }); + + describe('creating outgoing integrations', () => { + beforeEach(() => { + mockAddOutgoingIntegration.mockResolvedValue({ _id: 'created-id' }); + }); + + it('creates a channel integration, prefixing the channel name with #', async () => { + const response = await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ + event: 'newMessageOnChannel', + name: 'my hook', + target_url: 'https://example.com/hook', + data: { channel_name: 'general' }, + }); + + expect(response.statusCode).toBe(200); + expect(mockAddOutgoingIntegration).toHaveBeenCalledWith( + 'assigned-user-id', + expect.objectContaining({ channel: '#general', name: 'my hook', urls: ['https://example.com/hook'], enabled: true }), + ); + }); + + it('keeps a channel name that already starts with #', async () => { + await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ + event: 'newMessageOnChannel', + name: 'my hook', + target_url: 'https://example.com/hook', + data: { channel_name: '#general' }, + }); + + expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '#general' })); + }); + + it('creates a channel integration with no channel when none was given', async () => { + await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ event: 'newMessageOnChannel', name: 'my hook', target_url: 'https://example.com/hook' }); + + expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: undefined })); + }); + + it('creates a user integration, prefixing the username with @', async () => { + await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ + event: 'newMessageToUser', + name: 'my hook', + target_url: 'https://example.com/hook', + data: { username: 'rocket.cat' }, + }); + + expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '@rocket.cat' })); + }); + + it('keeps a username that already starts with @', async () => { + await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ + event: 'newMessageToUser', + name: 'my hook', + target_url: 'https://example.com/hook', + data: { username: '@rocket.cat' }, + }); + + expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '@rocket.cat' })); + }); + + it('refuses a user integration with no username', async () => { + const response = await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ event: 'newMessageToUser', name: 'my hook', target_url: 'https://example.com/hook' }); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('username-required'); + expect(mockAddOutgoingIntegration).not.toHaveBeenCalled(); + }); + + it('refuses an unsupported event', async () => { + const response = await request(app) + .post('/hooks/add/integration-id/legacy-user-id/the-token') + .send({ event: 'somethingElse', name: 'my hook', target_url: 'https://example.com/hook' }); + + expect(response.statusCode).toBe(400); + expect(mockAddOutgoingIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('removing outgoing integrations', () => { + it('removes the integration registered for the given url', async () => { + mockIntegrations.findOneByUrl.mockResolvedValue({ _id: 'outgoing-id' }); + + const response = await request(app) + .post('/hooks/remove/integration-id/legacy-user-id/the-token') + .send({ target_url: 'https://example.com/hook' }); + + expect(response.statusCode).toBe(200); + expect(mockIntegrations.findOneByUrl).toHaveBeenCalledWith('https://example.com/hook'); + expect(mockDeleteOutgoingIntegration).toHaveBeenCalledWith('outgoing-id', 'assigned-user-id'); + }); + + it('reports integration-not-found and deletes nothing when no integration matches the url', async () => { + mockIntegrations.findOneByUrl.mockResolvedValue(null); + + const response = await request(app) + .post('/hooks/remove/integration-id/legacy-user-id/the-token') + .send({ target_url: 'https://example.com/missing' }); + + expect(JSON.stringify(response.body)).toContain('integration-not-found'); + expect(mockDeleteOutgoingIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('rate limiting', () => { + const enableRateLimiter = (overrides: Record = {}): void => { + mockSettingsStore.set('API_Enable_Rate_Limiter', true); + Object.entries(overrides).forEach(([key, value]) => mockSettingsStore.set(key, value)); + }; + + const post = (url: string, text: string) => request(app).post(url).send({ text }); + + it('rejects requests over the limit', async () => { + enableRateLimiter(); + + const first = await post(HOOK_URL, 'a'); + const second = await post(HOOK_URL, 'b'); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(429); + expect(second.body.error).toMatch(/too many requests/i); + }); + + it('does not limit requests while the rate limiter is disabled', async () => { + const first = await post(HOOK_URL, 'a'); + const second = await post(HOOK_URL, 'b'); + const third = await post(HOOK_URL, 'c'); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + expect(third.statusCode).toBe(200); + }); + + it('does not limit requests in development unless the dev rate limiter is enabled', async () => { + process.env.NODE_ENV = 'development'; + enableRateLimiter({ API_Enable_Rate_Limiter_Dev: false }); + + const first = await post(HOOK_URL, 'a'); + const second = await post(HOOK_URL, 'b'); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + }); + + it('limits requests in development when the dev rate limiter is enabled', async () => { + process.env.NODE_ENV = 'development'; + enableRateLimiter({ API_Enable_Rate_Limiter_Dev: true }); + + const first = await post(HOOK_URL, 'a'); + const second = await post(HOOK_URL, 'b'); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(429); + }); + + it('gives each webhook url its own budget', async () => { + enableRateLimiter(); + + const firstOnA = await post('/hooks/integration-a/the-token', 'a'); + const firstOnB = await post('/hooks/integration-b/the-token', 'b'); + const firstOnLegacy = await post(LEGACY_HOOK_URL, 'c'); + const secondOnA = await post('/hooks/integration-a/the-token', 'd'); + + expect(firstOnA.statusCode).toBe(200); + expect(firstOnB.statusCode).toBe(200); + expect(firstOnLegacy.statusCode).toBe(200); + expect(secondOnA.statusCode).toBe(429); + }); + }); +}); diff --git a/apps/meteor/server/api/webhooks.ts b/apps/meteor/server/api/webhooks.ts new file mode 100644 index 0000000..700f2be --- /dev/null +++ b/apps/meteor/server/api/webhooks.ts @@ -0,0 +1,554 @@ +import type { IIncomingIntegration, IIntegration, IOutgoingIntegration, IUser, RequiredField } from '@rocket.chat/core-typings'; +import { Integrations, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { isIntegrationsHooksAddSchema, isIntegrationsHooksRemoveSchema } from '@rocket.chat/rest-typings'; +import type express from 'express'; +import { Meteor } from 'meteor/meteor'; +import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit'; +import { WebApp } from 'meteor/webapp'; +import _ from 'underscore'; + +import { APIClass } from './ApiClass'; +import type { RateLimiterOptions } from './api'; +import { API, defaultRateLimiterOptions } from './api'; +import type { FailureResult, GenericRouteExecutionContext, SuccessResult, UnavailableResult } from './definition'; +import type { APIActionContext } from './router'; +import { isPlainObject } from '../../lib/utils/isPlainObject'; +import { hasPermissionAsync } from '../lib/authorization/hasPermission'; +import { IsolatedVMScriptEngine } from '../lib/integrations/lib/isolated-vm/isolated-vm'; +import { metrics } from '../lib/metrics'; +import { settings } from '../settings'; +import { loggerMiddleware } from './v1/middlewares/logger'; +import { metricsMiddleware } from './v1/middlewares/metrics'; +import { tracerSpanMiddleware } from './v1/middlewares/tracer'; +import { incomingLogger, integrationLogger } from '../lib/integrations/logger'; +import type { WebhookResponseItem } from '../lib/messages/processWebhookMessage'; +import { processWebhookMessage } from '../lib/messages/processWebhookMessage'; +import { addOutgoingIntegration } from '../meteor-methods/integrations/outgoing/addOutgoingIntegration'; +import { deleteOutgoingIntegration } from '../meteor-methods/integrations/outgoing/deleteOutgoingIntegration'; + +const ivmEngine = new IsolatedVMScriptEngine(true); + +function getEngine(_integration: IIntegration): IsolatedVMScriptEngine { + return ivmEngine; +} + +type IntegrationOptions = { + event: string; + name: string; + target_url: string; + data?: { + channel_name?: string; + trigger_words?: string[]; + username?: string; + }; +}; + +type IntegrationThis = GenericRouteExecutionContext & { + request: Request & { + integration: IIncomingIntegration; + }; + user: RequiredField; +}; + +async function createIntegration(options: IntegrationOptions, user: IUser): Promise { + incomingLogger.info({ msg: 'Add integration', integration: options.name }); + incomingLogger.debug({ options }); + + switch (options.event) { + case 'newMessageOnChannel': + if (options.data == null) { + options.data = {}; + } + if (options.data.channel_name?.indexOf('#') === -1) { + options.data.channel_name = `#${options.data.channel_name}`; + } + return addOutgoingIntegration(user._id, { + username: 'rocket.cat', + urls: [options.target_url], + name: options.name, + channel: options.data.channel_name, + triggerWords: options.data.trigger_words, + type: 'webhook-outgoing', + event: 'sendMessage', + token: Random.id(24), + scriptEnabled: false, + script: '', + enabled: true, + _id: Random.id(), + _updatedAt: new Date(), + }); + case 'newMessageToUser': + if (options.data?.username?.indexOf('@') === -1) { + options.data.username = `@${options.data.username}`; + } + if (!options.data?.username) { + throw new Error('username-required'); + } + + return addOutgoingIntegration(user._id, { + username: 'rocket.cat', + urls: [options.target_url], + name: options.name, + channel: options.data.username, + triggerWords: options.data.trigger_words, + _id: '', + type: 'webhook-outgoing', + token: '', + scriptEnabled: false, + script: '', + enabled: false, + _updatedAt: new Date(), + event: 'sendMessage', + }); + } +} + +async function removeIntegration(options: { target_url: string }, user: IUser): Promise | FailureResult> { + incomingLogger.info('Remove integration'); + incomingLogger.debug({ options }); + + const integrationToRemove = await Integrations.findOneByUrl(options.target_url); + if (!integrationToRemove) { + return API.v1.failure('integration-not-found'); + } + + await deleteOutgoingIntegration(integrationToRemove._id, user._id); + + return API.v1.success(); +} + +/** + * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field + * with Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). + * This function unwraps it so integrations receive the parsed JSON directly. + */ +function getBodyParams(bodyParams: unknown, request: Request): Record { + if (!isPlainObject(bodyParams)) { + return {}; + } + + if ( + request.headers.get('content-type')?.startsWith('application/x-www-form-urlencoded') && + Object.keys(bodyParams).length === 1 && + typeof bodyParams.payload === 'string' + ) { + try { + const parsed = JSON.parse(bodyParams.payload); + + // Valid JSON must be an object, not an array or primitive + if (!isPlainObject(parsed)) { + throw new Error('Integration payload must be a JSON object, not an array or primitive'); + } + + return parsed; + } catch (err) { + // Invalid JSON -> return original bodyParams (backward compatibility) + if (err instanceof SyntaxError) { + return bodyParams; + } + throw err; + } + } + + return bodyParams; +} + +async function executeIntegrationRest( + this: IntegrationThis, +): Promise< + | SuccessResult | { responses: WebhookResponseItem[] } | undefined | void> + | FailureResult + | FailureResult<{ responses: WebhookResponseItem[] }> + | UnavailableResult +> { + incomingLogger.info({ msg: 'Post integration:', integration: this.request.integration.name }); + incomingLogger.debug({ urlParams: this.urlParams, bodyParams: this.bodyParams }); + + if (this.request.integration.enabled !== true) { + return API.v1.unavailable('Service Unavailable'); + } + + if (!(await hasPermissionAsync(this.user, 'message-impersonate'))) { + incomingLogger.error({ + msg: 'Error trying to execute integration', + integration: this.request.integration.name, + error: 'The assigned user from the integration must have the message-impersonate permission.', + }); + return API.v1.failure('error-user-lacks-message-impersonate-permission'); + } + + const defaultValues = { + channel: this.request.integration.channel, + alias: this.request.integration.alias || '', + avatar: this.request.integration.avatar || '', + emoji: this.request.integration.emoji || '', + }; + + const scriptEngine = getEngine(this.request.integration); + + let bodyParams: Record; + try { + bodyParams = getBodyParams(this.bodyParams, this.request); + } catch (err) { + return API.v1.failure(err instanceof Error ? err.message : String(err)); + } + + const separateResponse = bodyParams.separateResponse === true; + let scriptResponse: Record | undefined; + + if (scriptEngine.integrationHasValidScript(this.request.integration) && this.request.body) { + const buffers = []; + const reader = this.request.body.getReader(); + for (let result = await reader.read(); !result.done; result = await reader.read()) { + buffers.push(result.value); + } + const contentRaw = Buffer.concat(buffers).toString('utf8'); + const protocol = `${this.request.headers.get('x-forwarded-proto')}:` || 'http:'; + const url = new URL(this.request.url, `${protocol}//${this.request.headers.get('host')}`); + const query = isPlainObject(this.queryParams) ? this.queryParams : {}; + + const request = { + url: { + query, + hash: url.hash, + search: url.search, + pathname: url.pathname, + path: this.request.url, + }, + url_raw: this.request.url, + url_params: this.urlParams, + content: bodyParams, + content_raw: contentRaw, + headers: Object.fromEntries(this.request.headers.entries()), + body: bodyParams, + user: { + _id: this.user._id, + name: this.user.name || '', + username: this.user.username, + }, + }; + + const result = await scriptEngine.processIncomingRequest({ + integration: this.request.integration, + request, + }); + + try { + if (!result) { + incomingLogger.debug({ + msg: 'Process Incoming Request result of Trigger has no data', + integration: this.request.integration.name, + }); + return API.v1.success(); + } + if (result?.error) { + return API.v1.failure(result.error); + } + + bodyParams = result?.content; + + if (!('separateResponse' in bodyParams)) { + bodyParams.separateResponse = separateResponse; + } + + scriptResponse = result.response; + if (result.user) { + this.user = result.user; + } + + incomingLogger.debug({ + msg: 'Process Incoming Request result of Trigger', + integration: this.request.integration.name, + result: bodyParams, + }); + } catch (err) { + incomingLogger.error({ + msg: 'Error running Script in Trigger', + integration: this.request.integration.name, + script: this.request.integration.scriptCompiled, + err, + }); + return API.v1.failure('error-running-script'); + } + } + + if (!bodyParams || (_.isEmpty(bodyParams) && !this.request.integration.scriptEnabled)) { + return API.v1.success(); + } + + if ((bodyParams.channel || bodyParams.roomId) && !this.request.integration.overrideDestinationChannelEnabled) { + return API.v1.failure('overriding destination channel is disabled for this integration'); + } + + bodyParams.bot = { i: this.request.integration._id }; + + try { + const messageResponse = await processWebhookMessage(bodyParams, this.user, defaultValues); + if (_.isEmpty(messageResponse)) { + return API.v1.failure('unknown-error'); + } + + if (scriptResponse) { + incomingLogger.debug({ msg: 'response', response: scriptResponse }); + return API.v1.success(scriptResponse); + } + if (bodyParams.separateResponse) { + const allFailed = messageResponse.every((response) => 'error' in response && response.error); + if (allFailed) { + return API.v1.failure({ responses: messageResponse }); + } + return API.v1.success({ responses: messageResponse }); + } + return API.v1.success(); + } catch (err: any) { + incomingLogger.error({ msg: 'Error processing webhook message', err }); + return API.v1.failure(err?.error || err?.message || 'Unknown error'); + } +} + +type IntegrationSampleBody = { + token: string; + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + trigger_word: string; +}; + +function integrationSampleRest(): { statusCode: number; body: IntegrationSampleBody[] } { + incomingLogger.info('Sample Integration'); + return { + statusCode: 200, + body: [ + { + token: Random.id(24), + channel_id: Random.id(), + channel_name: 'general', + timestamp: new Date(), + user_id: Random.id(), + user_name: 'rocket.cat', + text: 'Sample text 1', + trigger_word: 'Sample', + }, + { + token: Random.id(24), + channel_id: Random.id(), + channel_name: 'general', + timestamp: new Date(), + user_id: Random.id(), + user_name: 'rocket.cat', + text: 'Sample text 2', + trigger_word: 'Sample', + }, + { + token: Random.id(24), + channel_id: Random.id(), + channel_name: 'general', + timestamp: new Date(), + user_id: Random.id(), + user_name: 'rocket.cat', + text: 'Sample text 3', + trigger_word: 'Sample', + }, + ], + }; +} + +function integrationInfoRest(): { statusCode: number; body: { success: boolean } } { + incomingLogger.info('Info integration'); + return { + statusCode: 200, + body: { + success: true, + }, + }; +} + +class WebHookAPI extends APIClass<'/hooks'> { + override async authenticatedRoute(routeContext: APIActionContext): Promise { + const { integrationId, token } = routeContext.urlParams; + const integration = await Integrations.findOneByIdAndToken(integrationId, decodeURIComponent(token)); + + if (!integration) { + incomingLogger.info({ msg: 'Invalid integration id or token', integrationId, token }); + + throw new Error('Invalid integration id or token provided.'); + } + + routeContext.request.headers.set('x-auth-token', token); + + const req = routeContext.request as Request & { integration?: IIncomingIntegration }; + req.integration = integration; + + return Users.findOneById(req.integration.userId); + } + + override shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean { + const { rateLimiterOptions } = options; + return ( + (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && + !process.env.TEST_MODE && + Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS) + ); + } + + override async shouldVerifyRateLimit(): Promise { + return ( + settings.get('API_Enable_Rate_Limiter') === true && + (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true) + ); + } + + override async enforceRateLimit( + objectForRateLimitMatch: RateLimiterOptionsToCheck, + request: Request, + response: Response, + userId: string, + ): Promise { + const { method, url } = request; + const route = url.replace(`/${this.apiPath}`, ''); + const nameRoute = this.getFullRouteName(route, method.toLowerCase()); + if (!this.getRateLimiter(nameRoute)) { + this.addRateLimiterRuleForRoutes({ + routes: [route], + rateLimiterOptions: defaultRateLimiterOptions, + endpoints: { + post: 'executeIntegrationRest', + get: 'executeIntegrationRest', + }, + }); + } + + const integrationForRateLimitMatch = objectForRateLimitMatch; + integrationForRateLimitMatch.route = nameRoute; + + await super.enforceRateLimit(integrationForRateLimitMatch, request, response, userId); + } +} + +const Api = new WebHookAPI({ + enableCors: true, + apiPath: 'hooks/', + useDefaultAuth: false, + prettyJson: process.env.NODE_ENV === 'development', +}); + +Api.router + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/hooks\//), + api: Api, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) + .use(tracerSpanMiddleware) + .use(loggerMiddleware(integrationLogger)); + +Api.addRoute( + ':integrationId/:userId/:token', + { authRequired: true }, + { + post: executeIntegrationRest, + get: executeIntegrationRest, + }, +); + +Api.addRoute( + ':integrationId/:token', + { authRequired: true }, + { + post: executeIntegrationRest, + get: executeIntegrationRest, + }, +); + +Api.addRoute( + 'sample/:integrationId/:userId/:token', + { authRequired: true }, + { + get: integrationSampleRest, + }, +); + +Api.addRoute( + 'sample/:integrationId/:token', + { authRequired: true }, + { + get: integrationSampleRest, + }, +); + +Api.addRoute( + 'info/:integrationId/:userId/:token', + { authRequired: true }, + { + get: integrationInfoRest, + }, +); + +Api.addRoute( + 'info/:integrationId/:token', + { authRequired: true }, + { + get: integrationInfoRest, + }, +); + +Api.addRoute( + 'add/:integrationId/:userId/:token', + { authRequired: true, validateParams: isIntegrationsHooksAddSchema }, + { + async post() { + const result = await createIntegration(this.bodyParams, this.user); + + return API.v1.success(result || {}); + }, + }, +); + +Api.addRoute( + 'add/:integrationId/:token', + { authRequired: true, validateParams: isIntegrationsHooksAddSchema }, + { + async post() { + const result = await createIntegration(this.bodyParams, this.user); + + return API.v1.success(result || {}); + }, + }, +); + +Api.addRoute( + 'remove/:integrationId/:userId/:token', + { authRequired: true, validateParams: isIntegrationsHooksRemoveSchema }, + { + async post() { + const result = await removeIntegration(this.bodyParams, this.user); + + return API.v1.success(result || {}); + }, + }, +); + +Api.addRoute( + 'remove/:integrationId/:token', + { authRequired: true, validateParams: isIntegrationsHooksRemoveSchema }, + { + async post() { + const result = await removeIntegration(this.bodyParams, this.user); + + return API.v1.success(result || {}); + }, + }, +); + +Meteor.startup(() => { + (WebApp.rawConnectHandlers as unknown as ReturnType).use(Api.router.router); +}); diff --git a/apps/meteor/server/bridges/irc/README.md b/apps/meteor/server/bridges/irc/README.md new file mode 100644 index 0000000..0d5457d --- /dev/null +++ b/apps/meteor/server/bridges/irc/README.md @@ -0,0 +1,69 @@ +##IRC Package for Rocket.Chat + +### Overview + +Internet Relay Chat (IRC) is a text-based group communication tool. Users join uniquely named channels, or rooms, for open discussion. IRC also supports private messages between individual users and file sharing capabilities. This package integrates these layers of functionality with Rocket.Chat. + +This package does not attempt to replace Rocket.Chat functionality nor operate as an IRC server for third-party client support. Rather, it utilizes Slack's similarities to IRC by allowing users to transparently access external IRC servers (e.g. Freenode) through Rocket.Chat's desktop and mobile app frontends. + + +### Development + +Core functionality is still in its infancy and needs improvement! If your objectives are broader than the stated scope of this project, please consider creating a separate package. Bugs and feature requests should be reported at [github.com/RocketChat/Rocket.Chat/issues](https://github.com/RocketChat/Rocket.Chat/issues) + +The most active conversation on this package can be found in [Issue 216](https://github.com/RocketChat/Rocket.Chat/issues/216). + + +### Build Notes + +It is strongly encouraged to not enable this package in a production environment. There is currently no strict user account oversight, meaning problematic interoperation with an IRC server may interfere with Rocket.Chat's authentication and room behavior. + +These *nix build notes are provided for development purposes only and do not include instructions on using SSL for encrypted authentication. Additionally, the following must be installed: + +* [Node.js](http://nodejs.org) +* [Meteor](http://www.meteor.com) +* [mongoDB](http://www.mongodb.com) + +#### Clone the Rocket.Chat repo via Git + + git clone https://github.com/RocketChat/Rocket.Chat.git + +#### Edit the package list + +Change to the top-level directory of your Rocket.Chat repo. Edit .meteor/packages and uncomment the following: + + #rocketchat:irc + +#### Export your environment variables +Modify as necessary. + + export ROOT_URL=http://localhost + export MONGO_URL=mongodb://localhost:27017/rocketchat + export PORT=3000 + +#### Compile the development environment + + meteor + +Once all dependencies are met and the compile has completed successfully, you can access your Rocket.Chat build via http://localhost:3000 + +You will then need to create a new account, go into the admin settings, choose IRC, enable it, and restart Rocket.Chat. This complexity will decrease once the original codebase is retooled. + +As you use Rocket.Chat, be sure to monitor the console terminal (or Rocket.Chat's web admin log) for IRC errors and activity. + + +### Changelog + +Please see the [commit history](https://github.com/RocketChat/Rocket.Chat/commits/develop/packages/rocketchat-irc) on GitHub for a full list of changes. + +Major functional changes: + +#### 0.0.2 + +* Remapped IRC username and real name to match Rocket.Chat's user accounts +* Added base support for config via web +* Performed package housecleaning for future growth + +#### 0.0.1 + + * Core concept, design, and functionality diff --git a/apps/meteor/server/bridges/irc/index.ts b/apps/meteor/server/bridges/irc/index.ts new file mode 100644 index 0000000..b4078b8 --- /dev/null +++ b/apps/meteor/server/bridges/irc/index.ts @@ -0,0 +1 @@ +import './methods/resetIrcConnection'; diff --git a/apps/meteor/server/bridges/irc/irc-bridge/index.js b/apps/meteor/server/bridges/irc/irc-bridge/index.js new file mode 100644 index 0000000..056b4dd --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/index.js @@ -0,0 +1,239 @@ +import { Logger } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; +import moment from 'moment'; +import Queue from 'queue-fifo'; + +import * as localCommandHandlers from './localHandlers'; +import * as peerCommandHandlers from './peerHandlers'; +import { withThrottling } from '../../../../lib/utils/highOrderFunctions'; +import { callbacks } from '../../../lib/callbacks'; +import { afterLeaveRoomCallback } from '../../../lib/callbacks/afterLeaveRoomCallback'; +import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback'; +import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; +import { updateAuditedBySystem } from '../../../settings/lib/auditedSettingUpdates'; +import * as servers from '../servers'; + +const logger = new Logger('IRC Bridge'); +const queueLogger = logger.section('Queue'); + +let removed = false; +const updateLastPing = withThrottling({ wait: 10_000 })(() => { + if (removed) { + return; + } + + void (async () => { + const updatedValue = await updateAuditedBySystem({ + reason: 'updateLastPing', + })(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(), { upsert: true }); + if (updatedValue.modifiedCount || updatedValue.upsertedCount) { + void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); + } + })(); +}); + +class Bridge { + constructor(config) { + // General + this.config = config; + + // Workaround for Rocket.Chat callbacks being called multiple times + this.loggedInUsers = []; + + // Server + const Server = servers[this.config.server.protocol]; + + this.server = new Server(this.config); + + this.setupPeerHandlers(); + this.setupLocalHandlers(); + + // Command queue + this.queue = new Queue(); + this.queueTimeout = 5; + } + + async init() { + this.initTime = new Date(); + removed = false; + this.loggedInUsers = []; + + const lastPing = await Settings.findOneById('IRC_Bridge_Last_Ping'); + if (lastPing) { + if (Math.abs(moment(lastPing.value).diff()) < 1000 * 30) { + this.log('Not trying to connect.'); + this.remove(); + return; + } + } + + this.log('Connecting.'); + updateLastPing(); + this.server.register(); + + this.server.on('registered', () => { + this.logQueue('Starting...'); + + this.runQueue(); + }); + } + + stop() { + this.server.disconnect(); + } + + remove() { + this.log('Removing current connection.'); + removed = true; + this.server = null; + this.removeLocalHandlers(); + } + + /** + * Log helper + */ + log(message) { + // TODO logger: debug? + logger.info(message); + } + + logQueue(message) { + // TODO logger: debug? + queueLogger.info(message); + } + + /** + * + * + * Queue + * + * + */ + onMessageReceived(from, command, ...parameters) { + this.queue.enqueue({ from, command, parameters }); + } + + async runQueue() { + if (!this.server) { + return; + } + + const lastResetTime = Settings.findOneById('IRC_Bridge_Reset_Time'); + if (lastResetTime && lastResetTime.value > this.initTime) { + this.stop(); + this.remove(); + return; + } + + updateLastPing(); + + // If it is empty, skip and keep the queue going + if (this.queue.isEmpty()) { + return setTimeout(this.runQueue.bind(this), this.queueTimeout); + } + + // Get the command + const item = this.queue.dequeue(); + + this.logQueue({ msg: 'Processing command from source', command: item.command, from: item.from }); + + // Handle the command accordingly + try { + // Handle the command accordingly + switch (item.from) { + case 'local': + if (!localCommandHandlers[item.command]) { + throw new Error(`Could not find handler for local:${item.command}`); + } + + await localCommandHandlers[item.command].apply(this, item.parameters); + break; + case 'peer': + if (!peerCommandHandlers[item.command]) { + throw new Error(`Could not find handler for peer:${item.command}`); + } + + await peerCommandHandlers[item.command].apply(this, item.parameters); + break; + } + } catch (e) { + this.logQueue(e); + } + + // Keep the queue going + setTimeout(this.runQueue.bind(this), this.queueTimeout); + } + + /** + * + * + * Peer + * + * + */ + setupPeerHandlers() { + this.server.on('peerCommand', (cmd) => { + this.onMessageReceived('peer', cmd.identifier, cmd.args); + }); + } + + /** + * + * + * Local + * + * + */ + setupLocalHandlers() { + // Auth + callbacks.add('afterValidateLogin', this.onMessageReceived.bind(this, 'local', 'onLogin'), callbacks.priority.LOW, 'irc-on-login'); + callbacks.add( + 'afterCreateUser', + this.onMessageReceived.bind(this, 'local', 'onCreateUser'), + callbacks.priority.LOW, + 'irc-on-create-user', + ); + // Joining rooms or channels + callbacks.add( + 'afterCreateChannel', + this.onMessageReceived.bind(this, 'local', 'onCreateRoom'), + callbacks.priority.LOW, + 'irc-on-create-channel', + ); + callbacks.add( + 'afterCreateRoom', + this.onMessageReceived.bind(this, 'local', 'onCreateRoom'), + callbacks.priority.LOW, + 'irc-on-create-room', + ); + callbacks.add('afterJoinRoom', this.onMessageReceived.bind(this, 'local', 'onJoinRoom'), callbacks.priority.LOW, 'irc-on-join-room'); + // Leaving rooms or channels + afterLeaveRoomCallback.add(this.onMessageReceived.bind(this, 'local', 'onLeaveRoom'), callbacks.priority.LOW, 'irc-on-leave-room'); + // Chatting + callbacks.add( + 'afterSaveMessage', + (message, { room }) => this.onMessageReceived('local', 'onSaveMessage', message, room), + callbacks.priority.LOW, + 'irc-on-save-message', + ); + // Leaving + afterLogoutCleanUpCallback.add(this.onMessageReceived.bind(this, 'local', 'onLogout'), callbacks.priority.LOW, 'irc-on-logout'); + } + + removeLocalHandlers() { + callbacks.remove('afterValidateLogin', 'irc-on-login'); + callbacks.remove('afterCreateUser', 'irc-on-create-user'); + callbacks.remove('afterCreateChannel', 'irc-on-create-channel'); + callbacks.remove('afterCreateRoom', 'irc-on-create-room'); + callbacks.remove('afterJoinRoom', 'irc-on-join-room'); + afterLeaveRoomCallback.remove('irc-on-leave-room'); + callbacks.remove('afterSaveMessage', 'irc-on-save-message'); + afterLogoutCleanUpCallback.remove('irc-on-logout'); + } + + sendCommand(command, parameters) { + this.server.emit('onReceiveFromLocal', command, parameters); + } +} + +export default Bridge; diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/index.ts b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/index.ts new file mode 100644 index 0000000..26f3fed --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/index.ts @@ -0,0 +1,9 @@ +import onCreateRoom from './onCreateRoom'; +import onCreateUser from './onCreateUser'; +import onJoinRoom from './onJoinRoom'; +import onLeaveRoom from './onLeaveRoom'; +import onLogin from './onLogin'; +import onLogout from './onLogout'; +import onSaveMessage from './onSaveMessage'; + +export { onCreateRoom, onJoinRoom, onLeaveRoom, onLogin, onLogout, onSaveMessage, onCreateUser }; diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateRoom.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateRoom.js new file mode 100644 index 0000000..f0417d3 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateRoom.js @@ -0,0 +1,13 @@ +import { Users } from '@rocket.chat/models'; + +export default async function handleOnCreateRoom(user, room) { + const users = await Users.findByRoomId(room._id); + + users.forEach((user) => { + if (user.profile?.irc?.fromIRC) { + this.sendCommand('joinChannel', { room, user }); + } else { + this.sendCommand('joinedChannel', { room, user }); + } + }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateUser.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateUser.js new file mode 100644 index 0000000..0a9c694 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onCreateUser.js @@ -0,0 +1,37 @@ +import { Rooms, Users } from '@rocket.chat/models'; + +export default async function handleOnCreateUser(newUser) { + if (!newUser) { + return this.log('Invalid handleOnCreateUser call'); + } + if (!newUser.username) { + return this.log('Invalid handleOnCreateUser call (Missing username)'); + } + if (this.loggedInUsers.indexOf(newUser._id) !== -1) { + return this.log('Duplicate handleOnCreateUser call'); + } + + this.loggedInUsers.push(newUser._id); + + await Users.updateOne( + { _id: newUser._id }, + { + $set: { + 'profile.irc.fromIRC': false, + 'profile.irc.username': `${newUser.username}-rkt`, + 'profile.irc.nick': `${newUser.username}-rkt`, + 'profile.irc.hostname': 'rocket.chat', + }, + }, + ); + + const user = await Users.findOne({ + _id: newUser._id, + }); + + this.sendCommand('registerUser', user); + + const rooms = await Rooms.findBySubscriptionUserId(user._id).toArray(); + + rooms.forEach((room) => this.sendCommand('joinedChannel', { room, user })); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onJoinRoom.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onJoinRoom.js new file mode 100644 index 0000000..eb280c1 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onJoinRoom.js @@ -0,0 +1,3 @@ +export default async function handleOnJoinRoom(user, room) { + this.sendCommand('joinedChannel', { room, user }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLeaveRoom.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLeaveRoom.js new file mode 100644 index 0000000..e40ce1a --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLeaveRoom.js @@ -0,0 +1,3 @@ +export default async function handleOnLeaveRoom(user, room) { + this.sendCommand('leftChannel', { room, user }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogin.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogin.js new file mode 100644 index 0000000..94b5aba --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogin.js @@ -0,0 +1,42 @@ +import { Rooms, Users } from '@rocket.chat/models'; + +export default async function handleOnLogin(login) { + if (login.user === null) { + return this.log('Invalid handleOnLogin call'); + } + if (!login.user.username) { + return this.log('Invalid handleOnLogin call (Missing username)'); + } + if (this.loggedInUsers.indexOf(login.user._id) !== -1) { + return this.log('Duplicate handleOnLogin call'); + } + + this.loggedInUsers.push(login.user._id); + + await Users.updateOne( + { _id: login.user._id }, + { + $set: { + 'profile.irc.fromIRC': false, + 'profile.irc.username': `${login.user.username}-rkt`, + 'profile.irc.nick': `${login.user.username}-rkt`, + 'profile.irc.hostname': 'rocket.chat', + }, + }, + ); + + const user = await Users.findOne({ + _id: login.user._id, + }); + + this.sendCommand('registerUser', user); + const rooms = await Rooms.findBySubscriptionUserId(user._id).toArray(); + + rooms.forEach((room) => { + if (room.t === 'd') { + return; + } + + this.sendCommand('joinedChannel', { room, user }); + }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogout.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogout.js new file mode 100644 index 0000000..3115326 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onLogout.js @@ -0,0 +1,7 @@ +import _ from 'underscore'; + +export default async function handleOnLogout(user) { + this.loggedInUsers = _.without(this.loggedInUsers, user._id); + + this.sendCommand('disconnected', { user }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onSaveMessage.js b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onSaveMessage.js new file mode 100644 index 0000000..2f4685b --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/localHandlers/onSaveMessage.js @@ -0,0 +1,36 @@ +import { Subscriptions, Users } from '@rocket.chat/models'; + +import { SystemLogger } from '../../../../lib/logger/system'; + +export default async function handleOnSaveMessage(message, to) { + let toIdentification = ''; + // Direct message + if (to.t === 'd') { + const subscriptions = Subscriptions.findByRoomId(to._id).toArray(); + for await (const subscription of subscriptions) { + if (subscription.u._id !== message.u._id) { + const userData = await Users.findOne({ username: subscription.u.username }); + if (userData) { + if (userData.profile && userData.profile.irc && userData.profile.irc.nick) { + toIdentification = userData.profile.irc.nick; + } else { + toIdentification = userData.username; + } + } else { + toIdentification = subscription.u.username; + } + } + } + + if (!toIdentification) { + SystemLogger.error('[irc][server] Target user not found'); + return; + } + } else { + toIdentification = `#${to.name}`; + } + + const user = await Users.findOne({ _id: message.u._id }); + + this.sendCommand('sentMessage', { to: toIdentification, user, message: message.msg }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/disconnected.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/disconnected.js new file mode 100644 index 0000000..3384454 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/disconnected.js @@ -0,0 +1,20 @@ +import { Users } from '@rocket.chat/models'; + +import { notifyOnUserChange } from '../../../../lib/notifyListener'; + +export default async function handleQUIT(args) { + const user = await Users.findOne({ + 'profile.irc.nick': args.nick, + }); + + await Users.updateOne( + { _id: user._id }, + { + $set: { + status: 'offline', + }, + }, + ); + + void notifyOnUserChange({ id: user._id, clientAction: 'updated', diff: { status: 'offline' } }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/index.ts b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/index.ts new file mode 100644 index 0000000..00397f6 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/index.ts @@ -0,0 +1,8 @@ +import disconnected from './disconnected'; +import joinedChannel from './joinedChannel'; +import leftChannel from './leftChannel'; +import nickChanged from './nickChanged'; +import sentMessage from './sentMessage'; +import userRegistered from './userRegistered'; + +export { disconnected, joinedChannel, leftChannel, nickChanged, sentMessage, userRegistered }; diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/joinedChannel.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/joinedChannel.js new file mode 100644 index 0000000..2641576 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/joinedChannel.js @@ -0,0 +1,28 @@ +import { Users, Rooms } from '@rocket.chat/models'; + +import { addUserToRoom } from '../../../../lib/rooms/addUserToRoom'; +import { createRoom } from '../../../../lib/rooms/createRoom'; + +// TODO doesn't seem to be used anywhere, remove +export default async function handleJoinedChannel(args) { + const user = await Users.findOne({ + 'profile.irc.nick': args.nick, + }); + + if (!user) { + throw new Error(`Could not find a user with nick ${args.nick}`); + } + + let room = await Rooms.findOneByName(args.roomName); + + if (!room) { + const createdRoom = await createRoom('c', args.roomName, user, []); + room = await Rooms.findOne({ _id: createdRoom.rid }); + + this.log(`${user.username} created room ${args.roomName}`); + } else { + await addUserToRoom(room._id, user); + + this.log(`${user.username} joined room ${room.name}`); + } +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/leftChannel.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/leftChannel.js new file mode 100644 index 0000000..0754b1d --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/leftChannel.js @@ -0,0 +1,22 @@ +import { Users, Rooms } from '@rocket.chat/models'; + +import { removeUserFromRoom } from '../../../../lib/rooms/removeUserFromRoom'; + +export default async function handleLeftChannel(args) { + const user = await Users.findOne({ + 'profile.irc.nick': args.nick, + }); + + if (!user) { + throw new Error(`Could not find a user with nick ${args.nick}`); + } + + const room = await Rooms.findOneByName(args.roomName); + + if (!room) { + throw new Error(`Could not find a room with name ${args.roomName}`); + } + + this.log(`${user.username} left room ${room.name}`); + await removeUserFromRoom(room._id, user); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/nickChanged.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/nickChanged.js new file mode 100644 index 0000000..6f7883e --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/nickChanged.js @@ -0,0 +1,28 @@ +import { Users } from '@rocket.chat/models'; + +import { notifyOnUserChange } from '../../../../lib/notifyListener'; + +export default async function handleNickChanged(args) { + const user = await Users.findOne({ + 'profile.irc.nick': args.nick, + }); + + if (!user) { + throw new Error(`Could not find an user with nick ${args.nick}`); + } + + this.log(`${user.username} changed nick: ${args.nick} -> ${args.newNick}`); + + // Update on the database + await Users.updateOne( + { _id: user._id }, + { + $set: { + 'name': args.newNick, + 'profile.irc.nick': args.newNick, + }, + }, + ); + + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { name: args.newNick } }); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/sentMessage.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/sentMessage.js new file mode 100644 index 0000000..9151eb2 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/sentMessage.js @@ -0,0 +1,58 @@ +import { Users, Rooms } from '@rocket.chat/models'; + +import { sendMessage } from '../../../../lib/messages/sendMessage'; +import { createDirectRoom } from '../../../../lib/rooms/createDirectRoom'; + +/* + * + * Get direct chat room helper + * + * + */ +const getDirectRoom = async (source, target) => { + const uids = [source._id, target._id]; + const { _id, ...extraData } = await createDirectRoom([source, target]); + + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uids); + if (room) { + return { + t: 'd', + ...room, + }; + } + + return { + _id, + t: 'd', + ...extraData, + }; +}; + +export default async function handleSentMessage(args) { + const user = await Users.findOne({ + 'profile.irc.nick': args.nick, + }); + + if (!user) { + throw new Error(`Could not find a user with nick ${args.nick}`); + } + + let room; + + if (args.roomName) { + room = await Rooms.findOneByName(args.roomName); + } else { + const recipientUser = await Users.findOne({ + 'profile.irc.nick': args.recipientNick, + }); + + room = await getDirectRoom(user, recipientUser); + } + + const message = { + msg: args.message, + ts: new Date(), + }; + + await sendMessage(user, message, room); +} diff --git a/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/userRegistered.js b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/userRegistered.js new file mode 100644 index 0000000..f429970 --- /dev/null +++ b/apps/meteor/server/bridges/irc/irc-bridge/peerHandlers/userRegistered.js @@ -0,0 +1,53 @@ +import { Users } from '@rocket.chat/models'; + +import { notifyOnUserChange } from '../../../../lib/notifyListener'; + +export default async function handleUserRegistered(args) { + // Check if there is an user with the given username + let user = await Users.findOne({ + 'profile.irc.username': args.username, + }); + + // If there is no user, create one... + if (!user) { + this.log(`Registering ${args.username} with nick: ${args.nick}`); + + const userToInsert = { + name: args.nick, + username: `${args.username}-irc`, + status: 'online', + utcOffset: 0, + active: true, + type: 'user', + profile: { + irc: { + fromIRC: true, + nick: args.nick, + username: args.username, + hostname: args.hostname, + }, + }, + }; + + user = await Users.create(userToInsert); + + void notifyOnUserChange({ id: user._id, clientAction: 'inserted', data: user }); + } else { + // ...otherwise, log the user in and update the information + this.log(`Logging in ${args.username} with nick: ${args.nick}`); + + await Users.updateOne( + { _id: user._id }, + { + $set: { + 'status': 'online', + 'profile.irc.nick': args.nick, + 'profile.irc.username': args.username, + 'profile.irc.hostname': args.hostname, + }, + }, + ); + + void notifyOnUserChange({ id: user._id, clientAction: 'updated', diff: { status: 'online' } }); + } +} diff --git a/apps/meteor/server/bridges/irc/methods/resetIrcConnection.ts b/apps/meteor/server/bridges/irc/methods/resetIrcConnection.ts new file mode 100644 index 0000000..b201f7f --- /dev/null +++ b/apps/meteor/server/bridges/irc/methods/resetIrcConnection.ts @@ -0,0 +1,91 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Settings } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { notifyOnSettingChangedById } from '../../../lib/notifyListener'; +import { settings } from '../../../settings'; +import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates'; +import Bridge from '../irc-bridge'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + resetIrcConnection(): { message: string; params: unknown[] }; + } +} + +Meteor.methods({ + async resetIrcConnection() { + const ircEnabled = Boolean(settings.get('IRC_Enabled')); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'resetIrcConnection' }); + } + + if (!(await hasPermissionAsync(uid, 'edit-privileged-setting'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'resetIrcConnection' }); + } + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: (await Meteor.userAsync())!.username!, + ip: this.connection?.clientAddress || '', + useragent: this.connection?.httpHeaders['user-agent'] || '', + }); + + const updatedLastPingValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Last_Ping', new Date(0), { + upsert: true, + }); + if (updatedLastPingValue.modifiedCount || updatedLastPingValue.upsertedCount) { + void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); + } + + const updatedResetTimeValue = await auditSettingOperation(Settings.updateValueById, 'IRC_Bridge_Reset_Time', new Date(), { + upsert: true, + }); + if (updatedResetTimeValue.modifiedCount || updatedResetTimeValue.upsertedCount) { + void notifyOnSettingChangedById('IRC_Bridge_Last_Ping'); + } + + if (!ircEnabled) { + return { + message: 'Connection_Closed', + params: [], + }; + } + + setTimeout(async () => { + // Normalize the config values + const config = { + server: { + protocol: settings.get('IRC_Protocol'), + host: settings.get('IRC_Host'), + port: settings.get('IRC_Port'), + name: settings.get('IRC_Name'), + description: settings.get('IRC_Description'), + }, + passwords: { + local: settings.get('IRC_Local_Password'), + peer: settings.get('IRC_Peer_Password'), + }, + }; + // TODO: is this the best way to do this? is this really necessary? + Meteor.ircBridge = new Bridge(config); + await Meteor.ircBridge.init(); + }, 300); + + return { + message: 'Connection_Reset', + params: [], + }; + }, +}); + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + let ircBridge: Bridge; + } +} diff --git a/apps/meteor/server/bridges/irc/servers/RFC2813/codes.js b/apps/meteor/server/bridges/irc/servers/RFC2813/codes.js new file mode 100644 index 0000000..a9f3d0e --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/RFC2813/codes.js @@ -0,0 +1,519 @@ +/** + * This file is part of https://github.com/martynsmith/node-irc + * by https://github.com/martynsmith + */ + +module.exports = { + '001': { + name: 'rpl_welcome', + type: 'reply', + }, + '002': { + name: 'rpl_yourhost', + type: 'reply', + }, + '003': { + name: 'rpl_created', + type: 'reply', + }, + '004': { + name: 'rpl_myinfo', + type: 'reply', + }, + '005': { + name: 'rpl_isupport', + type: 'reply', + }, + '200': { + name: 'rpl_tracelink', + type: 'reply', + }, + '201': { + name: 'rpl_traceconnecting', + type: 'reply', + }, + '202': { + name: 'rpl_tracehandshake', + type: 'reply', + }, + '203': { + name: 'rpl_traceunknown', + type: 'reply', + }, + '204': { + name: 'rpl_traceoperator', + type: 'reply', + }, + '205': { + name: 'rpl_traceuser', + type: 'reply', + }, + '206': { + name: 'rpl_traceserver', + type: 'reply', + }, + '208': { + name: 'rpl_tracenewtype', + type: 'reply', + }, + '211': { + name: 'rpl_statslinkinfo', + type: 'reply', + }, + '212': { + name: 'rpl_statscommands', + type: 'reply', + }, + '213': { + name: 'rpl_statscline', + type: 'reply', + }, + '214': { + name: 'rpl_statsnline', + type: 'reply', + }, + '215': { + name: 'rpl_statsiline', + type: 'reply', + }, + '216': { + name: 'rpl_statskline', + type: 'reply', + }, + '218': { + name: 'rpl_statsyline', + type: 'reply', + }, + '219': { + name: 'rpl_endofstats', + type: 'reply', + }, + '221': { + name: 'rpl_umodeis', + type: 'reply', + }, + '241': { + name: 'rpl_statslline', + type: 'reply', + }, + '242': { + name: 'rpl_statsuptime', + type: 'reply', + }, + '243': { + name: 'rpl_statsoline', + type: 'reply', + }, + '244': { + name: 'rpl_statshline', + type: 'reply', + }, + '250': { + name: 'rpl_statsconn', + type: 'reply', + }, + '251': { + name: 'rpl_luserclient', + type: 'reply', + }, + '252': { + name: 'rpl_luserop', + type: 'reply', + }, + '253': { + name: 'rpl_luserunknown', + type: 'reply', + }, + '254': { + name: 'rpl_luserchannels', + type: 'reply', + }, + '255': { + name: 'rpl_luserme', + type: 'reply', + }, + '256': { + name: 'rpl_adminme', + type: 'reply', + }, + '257': { + name: 'rpl_adminloc1', + type: 'reply', + }, + '258': { + name: 'rpl_adminloc2', + type: 'reply', + }, + '259': { + name: 'rpl_adminemail', + type: 'reply', + }, + '261': { + name: 'rpl_tracelog', + type: 'reply', + }, + '265': { + name: 'rpl_localusers', + type: 'reply', + }, + '266': { + name: 'rpl_globalusers', + type: 'reply', + }, + '300': { + name: 'rpl_none', + type: 'reply', + }, + '301': { + name: 'rpl_away', + type: 'reply', + }, + '302': { + name: 'rpl_userhost', + type: 'reply', + }, + '303': { + name: 'rpl_ison', + type: 'reply', + }, + '305': { + name: 'rpl_unaway', + type: 'reply', + }, + '306': { + name: 'rpl_nowaway', + type: 'reply', + }, + '311': { + name: 'rpl_whoisuser', + type: 'reply', + }, + '312': { + name: 'rpl_whoisserver', + type: 'reply', + }, + '313': { + name: 'rpl_whoisoperator', + type: 'reply', + }, + '314': { + name: 'rpl_whowasuser', + type: 'reply', + }, + '315': { + name: 'rpl_endofwho', + type: 'reply', + }, + '317': { + name: 'rpl_whoisidle', + type: 'reply', + }, + '318': { + name: 'rpl_endofwhois', + type: 'reply', + }, + '319': { + name: 'rpl_whoischannels', + type: 'reply', + }, + '321': { + name: 'rpl_liststart', + type: 'reply', + }, + '322': { + name: 'rpl_list', + type: 'reply', + }, + '323': { + name: 'rpl_listend', + type: 'reply', + }, + '324': { + name: 'rpl_channelmodeis', + type: 'reply', + }, + '329': { + name: 'rpl_creationtime', + type: 'reply', + }, + '331': { + name: 'rpl_notopic', + type: 'reply', + }, + '332': { + name: 'rpl_topic', + type: 'reply', + }, + '333': { + name: 'rpl_topicwhotime', + type: 'reply', + }, + '341': { + name: 'rpl_inviting', + type: 'reply', + }, + '342': { + name: 'rpl_summoning', + type: 'reply', + }, + '351': { + name: 'rpl_version', + type: 'reply', + }, + '352': { + name: 'rpl_whoreply', + type: 'reply', + }, + '353': { + name: 'rpl_namreply', + type: 'reply', + }, + '364': { + name: 'rpl_links', + type: 'reply', + }, + '365': { + name: 'rpl_endoflinks', + type: 'reply', + }, + '366': { + name: 'rpl_endofnames', + type: 'reply', + }, + '367': { + name: 'rpl_banlist', + type: 'reply', + }, + '368': { + name: 'rpl_endofbanlist', + type: 'reply', + }, + '369': { + name: 'rpl_endofwhowas', + type: 'reply', + }, + '371': { + name: 'rpl_info', + type: 'reply', + }, + '372': { + name: 'rpl_motd', + type: 'reply', + }, + '374': { + name: 'rpl_endofinfo', + type: 'reply', + }, + '375': { + name: 'rpl_motdstart', + type: 'reply', + }, + '376': { + name: 'rpl_endofmotd', + type: 'reply', + }, + '381': { + name: 'rpl_youreoper', + type: 'reply', + }, + '382': { + name: 'rpl_rehashing', + type: 'reply', + }, + '391': { + name: 'rpl_time', + type: 'reply', + }, + '392': { + name: 'rpl_usersstart', + type: 'reply', + }, + '393': { + name: 'rpl_users', + type: 'reply', + }, + '394': { + name: 'rpl_endofusers', + type: 'reply', + }, + '395': { + name: 'rpl_nousers', + type: 'reply', + }, + '401': { + name: 'err_nosuchnick', + type: 'error', + }, + '402': { + name: 'err_nosuchserver', + type: 'error', + }, + '403': { + name: 'err_nosuchchannel', + type: 'error', + }, + '404': { + name: 'err_cannotsendtochan', + type: 'error', + }, + '405': { + name: 'err_toomanychannels', + type: 'error', + }, + '406': { + name: 'err_wasnosuchnick', + type: 'error', + }, + '407': { + name: 'err_toomanytargets', + type: 'error', + }, + '409': { + name: 'err_noorigin', + type: 'error', + }, + '411': { + name: 'err_norecipient', + type: 'error', + }, + '412': { + name: 'err_notexttosend', + type: 'error', + }, + '413': { + name: 'err_notoplevel', + type: 'error', + }, + '414': { + name: 'err_wildtoplevel', + type: 'error', + }, + '421': { + name: 'err_unknowncommand', + type: 'error', + }, + '422': { + name: 'err_nomotd', + type: 'error', + }, + '423': { + name: 'err_noadmininfo', + type: 'error', + }, + '424': { + name: 'err_fileerror', + type: 'error', + }, + '431': { + name: 'err_nonicknamegiven', + type: 'error', + }, + '432': { + name: 'err_erroneusnickname', + type: 'error', + }, + '433': { + name: 'err_nicknameinuse', + type: 'error', + }, + '436': { + name: 'err_nickcollision', + type: 'error', + }, + '441': { + name: 'err_usernotinchannel', + type: 'error', + }, + '442': { + name: 'err_notonchannel', + type: 'error', + }, + '443': { + name: 'err_useronchannel', + type: 'error', + }, + '444': { + name: 'err_nologin', + type: 'error', + }, + '445': { + name: 'err_summondisabled', + type: 'error', + }, + '446': { + name: 'err_usersdisabled', + type: 'error', + }, + '451': { + name: 'err_notregistered', + type: 'error', + }, + '461': { + name: 'err_needmoreparams', + type: 'error', + }, + '462': { + name: 'err_alreadyregistred', + type: 'error', + }, + '463': { + name: 'err_nopermforhost', + type: 'error', + }, + '464': { + name: 'err_passwdmismatch', + type: 'error', + }, + '465': { + name: 'err_yourebannedcreep', + type: 'error', + }, + '467': { + name: 'err_keyset', + type: 'error', + }, + '471': { + name: 'err_channelisfull', + type: 'error', + }, + '472': { + name: 'err_unknownmode', + type: 'error', + }, + '473': { + name: 'err_inviteonlychan', + type: 'error', + }, + '474': { + name: 'err_bannedfromchan', + type: 'error', + }, + '475': { + name: 'err_badchannelkey', + type: 'error', + }, + '481': { + name: 'err_noprivileges', + type: 'error', + }, + '482': { + name: 'err_chanoprivsneeded', + type: 'error', + }, + '483': { + name: 'err_cantkillserver', + type: 'error', + }, + '491': { + name: 'err_nooperhost', + type: 'error', + }, + '501': { + name: 'err_umodeunknownflag', + type: 'error', + }, + '502': { + name: 'err_usersdontmatch', + type: 'error', + }, +}; diff --git a/apps/meteor/server/bridges/irc/servers/RFC2813/index.js b/apps/meteor/server/bridges/irc/servers/RFC2813/index.js new file mode 100644 index 0000000..752c0bc --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/RFC2813/index.js @@ -0,0 +1,195 @@ +import { EventEmitter } from 'node:events'; +import net from 'node:net'; +import util from 'node:util'; + +import { Logger } from '@rocket.chat/logger'; + +import localCommandHandlers from './localCommandHandlers'; +import parseMessage from './parseMessage'; +import peerCommandHandlers from './peerCommandHandlers'; + +const logger = new Logger('IRC Server'); + +class RFC2813 { + constructor(config) { + this.config = config; + + // Hold registered state + this.registerSteps = []; + this.isRegistered = false; + + // Hold peer server information + this.serverPrefix = null; + + // Hold the buffer while receiving + this.receiveBuffer = Buffer.from(''); + } + + /** + * Setup socket + */ + setupSocket() { + // Setup socket + this.socket = new net.Socket(); + this.socket.setNoDelay(); + this.socket.setEncoding('utf-8'); + this.socket.setKeepAlive(true); + this.socket.setTimeout(90000); + + this.socket.on('data', this.onReceiveFromPeer.bind(this)); + + this.socket.on('connect', this.onConnect.bind(this)); + this.socket.on('error', (err) => logger.error({ msg: 'Socket error', err })); + this.socket.on('timeout', () => this.log({ msg: 'Timeout' })); + this.socket.on('close', () => this.log({ msg: 'Connection Closed' })); + // Setup local + this.on('onReceiveFromLocal', this.onReceiveFromLocal.bind(this)); + } + + /** + * Log helper + */ + log(message) { + // TODO logger: debug? + if (typeof message === 'string') { + logger.info({ msg: message }); + return; + } + + logger.info(message); + } + + /** + * Connect + */ + register() { + this.log({ + msg: 'Connecting to IRC server', + host: this.config.server.host, + port: this.config.server.port, + }); + + if (!this.socket) { + this.setupSocket(); + } + + this.socket.connect(this.config.server.port, this.config.server.host); + } + + /** + * Disconnect + */ + disconnect() { + this.log('Disconnecting from server.'); + + if (this.socket) { + this.socket.destroy(); + this.socket = undefined; + } + this.isRegistered = false; + this.registerSteps = []; + } + + /** + * Setup the server connection + */ + onConnect() { + this.log('Connected! Registering as server...'); + + this.write({ + command: 'PASS', + parameters: [this.config.passwords.local, '0210', 'ngircd'], + }); + + this.write({ + command: 'SERVER', + parameters: [this.config.server.name], + trailer: this.config.server.description, + }); + } + + /** + * Sends a command message through the socket + */ + write(command) { + let buffer = command.prefix ? `:${command.prefix} ` : ''; + buffer += command.command; + + if (command.parameters && command.parameters.length > 0) { + buffer += ` ${command.parameters.join(' ')}`; + } + + if (command.trailer) { + buffer += ` :${command.trailer}`; + } + + this.log({ msg: 'Sending Command', buffer }); + + return this.socket.write(`${buffer}\r\n`); + } + + /** + * + * + * Peer message handling + * + * + */ + onReceiveFromPeer(chunk) { + if (typeof chunk === 'string') { + this.receiveBuffer += chunk; + } else { + this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]); + } + + const lines = this.receiveBuffer.toString().split(/\r\n|\r|\n|\u0007/); // eslint-disable-line no-control-regex + + // If the buffer does not end with \r\n, more chunks are coming + if (lines.pop()) { + return; + } + + // Reset the buffer + this.receiveBuffer = Buffer.from(''); + + lines.forEach((line) => { + if (line.length && !line.startsWith('a')) { + const parsedMessage = parseMessage(line); + + if (peerCommandHandlers[parsedMessage.command]) { + this.log({ msg: 'Handling peer message', line }); + + const command = peerCommandHandlers[parsedMessage.command].call(this, parsedMessage); + + if (command) { + this.log({ msg: 'Emitting peer command to local', command }); + this.emit('peerCommand', command); + } + } else { + this.log({ msg: 'Unhandled peer message', parsedMessage }); + } + } + }); + } + + /** + * + * + * Local message handling + * + * + */ + onReceiveFromLocal(command, parameters) { + if (localCommandHandlers[command]) { + this.log({ msg: 'Handling local command', command }); + + localCommandHandlers[command].call(this, parameters, this); + } else { + this.log({ msg: 'Unhandled local command', command }); + } + } +} + +util.inherits(RFC2813, EventEmitter); + +export default RFC2813; diff --git a/apps/meteor/server/bridges/irc/servers/RFC2813/localCommandHandlers.js b/apps/meteor/server/bridges/irc/servers/RFC2813/localCommandHandlers.js new file mode 100644 index 0000000..176da11 --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/RFC2813/localCommandHandlers.js @@ -0,0 +1,111 @@ +function registerUser(parameters) { + const { + name, + profile: { + irc: { nick, username }, + }, + } = parameters; + + this.write({ + prefix: this.config.server.name, + command: 'NICK', + parameters: [nick, 1, username, 'irc.rocket.chat', 1, '+i'], + trailer: name, + }); +} + +function joinChannel(parameters) { + const { + room: { name: roomName }, + user: { + profile: { + irc: { nick }, + }, + }, + } = parameters; + + this.write({ + prefix: this.config.server.name, + command: 'NJOIN', + parameters: [`#${roomName}`], + trailer: nick, + }); +} + +function joinedChannel(parameters, handler) { + const roomName = parameters.room?.name; + const nick = parameters.user?.profile?.irc?.nick; + + if (!roomName) { + handler.log('Skipping room with no name.'); + return; + } + + if (!nick) { + handler.log('Skipping user with no irc nick.'); + return; + } + + this.write({ + prefix: nick, + command: 'JOIN', + parameters: [`#${roomName}`], + }); +} + +function leftChannel(parameters) { + const { + room: { name: roomName }, + user: { + profile: { + irc: { nick }, + }, + }, + } = parameters; + + this.write({ + prefix: nick, + command: 'PART', + parameters: [`#${roomName}`], + }); +} + +function sentMessage(parameters) { + const { + user: { + profile: { + irc: { nick }, + }, + }, + to, + message, + } = parameters; + + // eslint-disable-next-line no-control-regex + const lines = message.toString().split(/\r\n|\r|\n|\u0007/); + for (const line of lines) { + this.write({ + prefix: nick, + command: 'PRIVMSG', + parameters: [to], + trailer: line, + }); + } +} + +function disconnected(parameters) { + const { + user: { + profile: { + irc: { nick }, + }, + }, + } = parameters; + + this.write({ + prefix: nick, + command: 'QUIT', + }); +} + +export default { registerUser, joinChannel, joinedChannel, leftChannel, sentMessage, disconnected }; diff --git a/apps/meteor/server/bridges/irc/servers/RFC2813/parseMessage.js b/apps/meteor/server/bridges/irc/servers/RFC2813/parseMessage.js new file mode 100644 index 0000000..15e7a61 --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/RFC2813/parseMessage.js @@ -0,0 +1,69 @@ +/** + * This file is part of https://github.com/martynsmith/node-irc + * by https://github.com/martynsmith + */ + +const replyFor = require('./codes'); + +/** + * parseMessage(line, stripColors) + * + * takes a raw "line" from the IRC server and turns it into an object with + * useful keys + * @param {String} line Raw message from IRC server. + * @return {Object} A parsed message object. + */ +module.exports = function parseMessage(line) { + const message = {}; + let match; + + // Parse prefix + match = line.match(/^:([^ ]+) +/); + if (match) { + message.prefix = match[1]; + line = line.replace(/^:[^ ]+ +/, ''); + match = message.prefix.match(/^([_a-zA-Z0-9\~\[\]\\`^{}|-]*)(!([^@]+)@(.*))?$/); + if (match) { + message.nick = match[1]; + message.user = match[3]; + message.host = match[4]; + } else { + message.server = message.prefix; + } + } + + // Parse command + match = line.match(/^([^ ]+) */); + message.command = match[1]; + message.rawCommand = match[1]; + message.commandType = 'normal'; + line = line.replace(/^[^ ]+ +/, ''); + + if (replyFor[message.rawCommand]) { + message.command = replyFor[message.rawCommand].name; + message.commandType = replyFor[message.rawCommand].type; + } + + message.args = []; + let middle; + let trailing; + + // Parse parameters + if (line.search(/^:|\s+:/) !== -1) { + match = line.match(/(.*?)(?:^:|\s+:)(.*)/); + middle = match[1].trimRight(); + trailing = match[2]; + } else { + middle = line; + } + + if (middle.length) { + message.args = middle.split(/ +/); + } + + if (typeof trailing !== 'undefined' && trailing.length) { + message.args.push(trailing); + } + + return message; +}; diff --git a/apps/meteor/server/bridges/irc/servers/RFC2813/peerCommandHandlers.js b/apps/meteor/server/bridges/irc/servers/RFC2813/peerCommandHandlers.js new file mode 100644 index 0000000..13b5b20 --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/RFC2813/peerCommandHandlers.js @@ -0,0 +1,113 @@ +function PASS() { + this.log('Received PASS command, continue registering...'); + + this.registerSteps.push('PASS'); +} + +function SERVER(parsedMessage) { + this.log('Received SERVER command, waiting for first PING...'); + + this.serverPrefix = parsedMessage.prefix; + + this.registerSteps.push('SERVER'); +} + +function PING() { + if (!this.isRegistered && this.registerSteps.length === 2) { + this.log('Received first PING command, server is registered!'); + + this.isRegistered = true; + + this.emit('registered'); + } + + this.write({ + prefix: this.config.server.name, + command: 'PONG', + parameters: [this.config.server.name], + }); +} + +function NICK(parsedMessage) { + let command; + + // Check if the message comes from the server, + // which means it is a new user + if (parsedMessage.prefix === this.serverPrefix) { + command = { + identifier: 'userRegistered', + args: { + nick: parsedMessage.args[0], + username: parsedMessage.args[2], + host: parsedMessage.args[3], + name: parsedMessage.args[6], + }, + }; + } else { + // Otherwise, it is a nick change + command = { + identifier: 'nickChanged', + args: { + nick: parsedMessage.nick, + newNick: parsedMessage.args[0], + }, + }; + } + + return command; +} + +function JOIN(parsedMessage) { + const command = { + identifier: 'joinedChannel', + args: { + roomName: parsedMessage.args[0].substring(1), + nick: parsedMessage.prefix, + }, + }; + + return command; +} + +function PART(parsedMessage) { + const command = { + identifier: 'leftChannel', + args: { + roomName: parsedMessage.args[0].substring(1), + nick: parsedMessage.prefix, + }, + }; + + return command; +} + +function PRIVMSG(parsedMessage) { + const command = { + identifier: 'sentMessage', + args: { + nick: parsedMessage.prefix, + message: parsedMessage.args[1], + }, + }; + + if (parsedMessage.args[0][0] === '#') { + command.args.roomName = parsedMessage.args[0].substring(1); + } else { + command.args.recipientNick = parsedMessage.args[0]; + } + + return command; +} + +function QUIT(parsedMessage) { + const command = { + identifier: 'disconnected', + args: { + nick: parsedMessage.prefix, + }, + }; + + return command; +} + +export default { PASS, SERVER, PING, NICK, JOIN, PART, PRIVMSG, QUIT }; diff --git a/apps/meteor/server/bridges/irc/servers/index.ts b/apps/meteor/server/bridges/irc/servers/index.ts new file mode 100644 index 0000000..6ab8fd6 --- /dev/null +++ b/apps/meteor/server/bridges/irc/servers/index.ts @@ -0,0 +1,3 @@ +import RFC2813 from './RFC2813'; + +export { RFC2813 }; diff --git a/apps/meteor/server/bridges/nextcloud/addWebdavServer.ts b/apps/meteor/server/bridges/nextcloud/addWebdavServer.ts new file mode 100644 index 0000000..d41bfb1 --- /dev/null +++ b/apps/meteor/server/bridges/nextcloud/addWebdavServer.ts @@ -0,0 +1,41 @@ +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../lib/callbacks'; +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; +import { addWebdavAccountByToken } from '../webdav/methods/addWebdavAccount'; + +Meteor.startup(() => { + settings.watch('Webdav_Integration_Enabled', (value) => { + if (value) { + return callbacks.add( + 'afterValidateLogin', + async (login) => { + const { user } = login; + const { services } = user; + if (!services?.nextcloud) { + return; + } + const token = { + token_type: 'Bearer', + access_token: services.nextcloud.accessToken, + refresh_token: services.nextcloud.refreshToken, + }; + const data = { + name: 'Nextcloud', + serverURL: `${services.nextcloud.serverURL}/remote.php/webdav/`, + token, + }; + try { + await addWebdavAccountByToken(user._id, data); + } catch (err) { + SystemLogger.error({ err }); + } + }, + callbacks.priority.MEDIUM, + 'add-webdav-server', + ); + } + callbacks.remove('afterValidateLogin', 'add-webdav-server'); + }); +}); diff --git a/apps/meteor/server/bridges/nextcloud/index.ts b/apps/meteor/server/bridges/nextcloud/index.ts new file mode 100644 index 0000000..732ad0a --- /dev/null +++ b/apps/meteor/server/bridges/nextcloud/index.ts @@ -0,0 +1,2 @@ +import './lib'; +import './addWebdavServer'; diff --git a/apps/meteor/server/bridges/nextcloud/lib.ts b/apps/meteor/server/bridges/nextcloud/lib.ts new file mode 100644 index 0000000..fab4ec7 --- /dev/null +++ b/apps/meteor/server/bridges/nextcloud/lib.ts @@ -0,0 +1,60 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { CustomOAuth } from '../../lib/auth-providers/custom-oauth/custom_oauth_server'; +import { addPassportCustomOAuth } from '../../lib/oauth/addPassportCustomOAuth'; +import { settings } from '../../settings'; + +const NEXTCLOUD_PATHS = { + serverURL: '', + tokenPath: '/index.php/apps/oauth2/api/v1/token', + tokenSentVia: 'header' as OAuthConfiguration['tokenSentVia'], + authorizePath: '/index.php/apps/oauth2/authorize', + identityPath: '/ocs/v2.php/cloud/user?format=json', + scope: 'openid', + addAutopublishFields: { + forLoggedInUser: ['services.nextcloud'], + forOtherUsers: ['services.nextcloud.name'], + }, +}; + +const Nextcloud = new CustomOAuth('nextcloud', NEXTCLOUD_PATHS); + +function configureNextcloudOAuth(): void { + passport.unuse('nextcloud'); + const enabled = settings.get('Accounts_OAuth_Nextcloud'); + if (!enabled) { + return; + } + + const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL')?.trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Nextcloud_id'); + const clientSecret = settings.get('Accounts_OAuth_Nextcloud_secret'); + + if (!serverURL || !clientId || !clientSecret) { + return; + } + + const config = { ...NEXTCLOUD_PATHS, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('nextcloud', config); + return; + } + + Nextcloud.configure(config); +} + +Meteor.startup(() => { + settings.watchMultiple( + [ + 'Accounts_OAuth_Nextcloud', + 'Accounts_OAuth_Nextcloud_URL', + 'Accounts_OAuth_Nextcloud_id', + 'Accounts_OAuth_Nextcloud_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + configureNextcloudOAuth, + ); +}); diff --git a/apps/meteor/server/bridges/slack/README.md b/apps/meteor/server/bridges/slack/README.md new file mode 100644 index 0000000..babd07e --- /dev/null +++ b/apps/meteor/server/bridges/slack/README.md @@ -0,0 +1,40 @@ +## Rocket.Chat Slack Bridge + +This package creates a bi-directional bridge between a single Slack installation and your Rocket.Chat installation. + +* Configure Rocket.Chat with your Slack API token +* Invite 'rocketbot' to your Slack channel + +### Settings + +The following can be configured in your Rocket.Chat Administration SlackBridge panel. + +#### Enabled + +#### API Token + +#### Alias format + +#### Exclude bots + +#### SlackBridge Out Enabled + +#### SlackBridge Out All + +#### SlackBridge Out Channels + +### Features + +#### Group Chat Messages +* Send and receive basic messages +* Delete messages (Can't delete slack message from rocket) +* Edit messages (Slack doesn't allow editing of BOT messages, so can't edit a Rocket msg in Slack) +* React to messages (as BOT in Slack) + +#### Files + +#### Imports + +### TODO +* Support multiple slack installations +* Private IM's diff --git a/apps/meteor/server/bridges/slack/RocketAdapter.ts b/apps/meteor/server/bridges/slack/RocketAdapter.ts new file mode 100644 index 0000000..724213e --- /dev/null +++ b/apps/meteor/server/bridges/slack/RocketAdapter.ts @@ -0,0 +1,551 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import util from 'node:util'; + +import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { rocketLogger } from './logger'; +import { sleep } from '../../../lib/utils/sleep'; +import { callbacks } from '../../lib/callbacks'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { createRoom } from '../../lib/rooms/createRoom'; +import { setUserAvatar } from '../../lib/users/setUserAvatar'; +import { settings } from '../../settings'; + +export default class RocketAdapter { + constructor(slackBridge) { + rocketLogger.debug('constructor'); + this.slackBridge = slackBridge; + this.util = util; + this.userTags = {}; + this.slackAdapters = []; + } + + connect() { + this.registerForEvents(); + } + + disconnect() { + this.unregisterForEvents(); + } + + addSlack(slack) { + if (this.slackAdapters.indexOf(slack) < 0) { + this.slackAdapters.push(slack); + } + } + + clearSlackAdapters() { + this.slackAdapters = []; + } + + registerForEvents() { + rocketLogger.debug('Register for events'); + callbacks.add('afterSaveMessage', this.onMessage.bind(this), callbacks.priority.LOW, 'SlackBridge_Out'); + callbacks.add('afterDeleteMessage', this.onMessageDelete.bind(this), callbacks.priority.LOW, 'SlackBridge_Delete'); + callbacks.add('afterSetReaction', this.onSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_SetReaction'); + callbacks.add('afterUnsetReaction', this.onUnSetReaction.bind(this), callbacks.priority.LOW, 'SlackBridge_UnSetReaction'); + } + + unregisterForEvents() { + rocketLogger.debug('Unregister for events'); + callbacks.remove('afterSaveMessage', 'SlackBridge_Out'); + callbacks.remove('afterDeleteMessage', 'SlackBridge_Delete'); + callbacks.remove('afterSetReaction', 'SlackBridge_SetReaction'); + callbacks.remove('afterUnsetReaction', 'SlackBridge_UnSetReaction'); + } + + async onMessageDelete(rocketMessageDeleted) { + for await (const slack of this.slackAdapters) { + try { + if (!slack.getSlackChannel(rocketMessageDeleted.rid)) { + // This is on a channel that the rocket bot is not subscribed on this slack server + continue; + } + + rocketLogger.debug({ msg: 'onRocketMessageDelete', rocketMessageDeleted }); + await slack.postDeleteMessage(rocketMessageDeleted); + } catch (err) { + rocketLogger.error({ msg: 'Unhandled error onMessageDelete', err }); + } + } + } + + async onSetReaction(rocketMsg, { reaction }) { + try { + if (!this.slackBridge.isReactionsEnabled) { + return; + } + + rocketLogger.debug('onRocketSetReaction'); + + if (rocketMsg._id && reaction) { + if (this.slackBridge.reactionsMap.delete(`set${rocketMsg._id}${reaction}`)) { + // This was a Slack reaction, we don't need to tell Slack about it + return; + } + if (rocketMsg) { + for await (const slack of this.slackAdapters) { + const slackChannel = slack.getSlackChannel(rocketMsg.rid); + if (slackChannel != null) { + const slackTS = slack.getTimeStamp(rocketMsg); + await slack.postReactionAdded(reaction.replace(/:/g, ''), slackChannel.id, slackTS); + } + } + } + } + } catch (err) { + rocketLogger.error({ msg: 'Unhandled error onSetReaction', err }); + } + } + + async onUnSetReaction(rocketMsg, { reaction }) { + try { + if (!this.slackBridge.isReactionsEnabled) { + return; + } + + rocketLogger.debug('onRocketUnSetReaction'); + + if (rocketMsg._id && reaction) { + if (this.slackBridge.reactionsMap.delete(`unset${rocketMsg._id}${reaction}`)) { + // This was a Slack unset reaction, we don't need to tell Slack about it + return; + } + + if (rocketMsg) { + for await (const slack of this.slackAdapters) { + const slackChannel = slack.getSlackChannel(rocketMsg.rid); + if (slackChannel != null) { + const slackTS = slack.getTimeStamp(rocketMsg); + await slack.postReactionRemove(reaction.replace(/:/g, ''), slackChannel.id, slackTS); + } + } + } + } + } catch (err) { + rocketLogger.error({ msg: 'Unhandled error onUnSetReaction', err }); + } + } + + async onMessage(rocketMessage) { + for await (const slack of this.slackAdapters) { + try { + if (!slack.getSlackChannel(rocketMessage.rid)) { + // This is on a channel that the rocket bot is not subscribed + continue; + } + rocketLogger.debug({ msg: 'onRocketMessage', rocketMessage }); + + if (rocketMessage.editedAt) { + // This is an Edit Event + await this.processMessageChanged(rocketMessage, slack); + continue; + } + // Ignore messages originating from Slack + if (rocketMessage._id.indexOf('slack-') === 0) { + continue; + } + + if (rocketMessage.file) { + await this.processFileShare(rocketMessage, slack); + continue; + } + + // A new message from Rocket.Chat + await this.processSendMessage(rocketMessage, slack); + } catch (err) { + rocketLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + + return rocketMessage; + } + + async processSendMessage(rocketMessage, slack) { + // Since we got this message, SlackBridge_Out_Enabled is true + if (settings.get('SlackBridge_Out_All') === true) { + await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), rocketMessage); + } else { + // They want to limit to certain groups + const outSlackChannels = _.pluck(settings.get('SlackBridge_Out_Channels'), '_id') || []; + // rocketLogger.debug('Out SlackChannels: ', outSlackChannels); + if (outSlackChannels.indexOf(rocketMessage.rid) !== -1) { + await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), rocketMessage); + } + } + } + + getMessageAttachment(rocketMessage) { + if (!rocketMessage.file) { + return; + } + + if (!rocketMessage.attachments || !rocketMessage.attachments.length) { + return; + } + + const fileId = rocketMessage.file._id; + return rocketMessage.attachments.find((attachment) => attachment.title_link && attachment.title_link.indexOf(`/${fileId}/`) >= 0); + } + + async processFileShare(rocketMessage, slack) { + if (!settings.get('SlackBridge_FileUpload_Enabled')) { + return; + } + + if (rocketMessage.file.name) { + let fileName = rocketMessage.file.name; + let text = rocketMessage.msg; + + const attachment = this.getMessageAttachment(rocketMessage); + if (attachment) { + fileName = Meteor.absoluteUrl(attachment.title_link); + if (!text) { + text = attachment.description; + } + } + + await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), { ...rocketMessage, msg: `${text} ${fileName}` }); + } + } + + async processMessageChanged(rocketMessage, slack) { + if (rocketMessage) { + if (rocketMessage.updatedBySlack) { + // We have already processed this + delete rocketMessage.updatedBySlack; + return; + } + + // This was a change from Rocket.Chat + const slackChannel = slack.getSlackChannel(rocketMessage.rid); + await slack.postMessageUpdate(slackChannel, rocketMessage); + } + } + + async getChannel(slackMessage) { + return slackMessage.channel ? (await this.findChannel(slackMessage.channel)) || this.addChannel(slackMessage.channel) : null; + } + + async getUser(slackUser) { + return slackUser ? (await this.findUser(slackUser)) || this.addUser(slackUser) : null; + } + + createRocketID(slackChannel, ts) { + return `slack-${slackChannel}-${ts.replace(/\./g, '-')}`; + } + + async findChannel(slackChannelId) { + return Rooms.findOneByImportId(slackChannelId); + } + + async getRocketUsers(members, slackChannel) { + const rocketUsers = []; + for await (const member of members) { + if (member !== slackChannel.creator) { + const rocketUser = (await this.findUser(member)) || (await this.addUser(member)); + if (rocketUser && rocketUser.username) { + rocketUsers.push(rocketUser.username); + } + } + } + return rocketUsers; + } + + async getRocketUserCreator(slackChannel) { + return slackChannel.creator ? (await this.findUser(slackChannel.creator)) || this.addUser(slackChannel.creator) : null; + } + + async addChannel(slackChannelID, hasRetried = false) { + rocketLogger.debug({ msg: 'Adding Rocket.Chat channel from Slack', slackChannelID }); + let addedRoom; + + for await (const slack of this.slackAdapters) { + if (addedRoom) { + continue; + } + + const slackChannel = await slack.slackAPI.getRoomInfo(slackChannelID); + if (slackChannel) { + const members = await slack.slackAPI.getMembers(slackChannelID); + if (!members) { + rocketLogger.error('Could not fetch room members'); + continue; + } + + const rocketRoom = await Rooms.findOneByName(slackChannel.name); + + if (rocketRoom || slackChannel.is_general) { + slackChannel.rocketId = slackChannel.is_general ? 'GENERAL' : rocketRoom._id; + await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); + } else { + const rocketUsers = await this.getRocketUsers(members, slackChannel); + const rocketUserCreator = await this.getRocketUserCreator(slackChannel); + + if (!rocketUserCreator) { + rocketLogger.error({ msg: 'Could not fetch room creator information', creator: slackChannel.creator }); + continue; + } + + try { + const isPrivate = slackChannel.is_private; + const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers); + slackChannel.rocketId = rocketChannel.rid; + } catch (e) { + if (!hasRetried) { + rocketLogger.debug({ msg: 'Error adding channel from Slack. Will retry in 1s.', err: e }); + // If first time trying to create channel fails, could be because of multiple messages received at the same time. Try again once after 1s. + await sleep(1000); + return (await this.findChannel(slackChannelID)) || this.addChannel(slackChannelID, true); + } + rocketLogger.error({ msg: 'Error adding channel from Slack', err: e }); + } + + const roomUpdate = { + ts: new Date(slackChannel.created * 1000), + }; + + let lastSetTopic = 0; + if (slackChannel.topic && slackChannel.topic.value) { + roomUpdate.topic = slackChannel.topic.value; + lastSetTopic = slackChannel.topic.last_set; + } + + if (slackChannel.purpose && slackChannel.purpose.value && slackChannel.purpose.last_set > lastSetTopic) { + roomUpdate.topic = slackChannel.purpose.value; + } + await Rooms.addImportIds(slackChannel.rocketId, slackChannel.id); + slack.addSlackChannel(slackChannel.rocketId, slackChannelID); + } + + addedRoom = await Rooms.findOneById(slackChannel.rocketId); + } + } + + if (!addedRoom) { + rocketLogger.debug('Channel not added'); + } + return addedRoom; + } + + async findUser(slackUserID) { + const rocketUser = await Users.findOneByImportId(slackUserID); + if (rocketUser && !this.userTags[slackUserID]) { + this.userTags[slackUserID] = { + slack: `<@${slackUserID}>`, + rocket: `@${rocketUser.username}`, + }; + } + return rocketUser; + } + + async addUser(slackUserID) { + rocketLogger.debug({ msg: 'Adding Rocket.Chat user from Slack', slackUserID }); + let addedUser; + for await (const slack of this.slackAdapters) { + if (addedUser) { + return; + } + + const user = await slack.slackAPI.getUser(slackUserID); + if (user) { + const rocketUserData = user; + const isBot = rocketUserData.is_bot === true; + const email = (rocketUserData.profile && rocketUserData.profile.email) || ''; + let existingRocketUser; + if (!isBot) { + existingRocketUser = + (await Users.findOneByEmailAddress(email)) || (await Users.findOneByUsernameIgnoringCase(rocketUserData.name)); + } else { + existingRocketUser = await Users.findOneByUsernameIgnoringCase(rocketUserData.name); + } + + if (existingRocketUser) { + rocketUserData.rocketId = existingRocketUser._id; + rocketUserData.name = existingRocketUser.username; + } else { + const newUser = { + password: Random.id(), + username: rocketUserData.name, + }; + + if (!isBot && email) { + newUser.email = email; + } + + if (isBot) { + newUser.joinDefaultChannels = false; + } + + rocketUserData.rocketId = await Accounts.createUserAsync(newUser); + const userUpdate = { + utcOffset: rocketUserData.tz_offset / 3600, // Slack's is -18000 which translates to Rocket.Chat's after dividing by 3600, + roles: isBot ? ['bot'] : ['user'], + }; + + if (rocketUserData.profile && rocketUserData.profile.real_name) { + userUpdate.name = rocketUserData.profile.real_name; + } + + if (rocketUserData.deleted) { + userUpdate.active = false; + userUpdate['services.resume.loginTokens'] = []; + } + + await Users.updateOne({ _id: rocketUserData.rocketId }, { $set: userUpdate }); + + const user = await Users.findOneById(rocketUserData.rocketId); + + let url = null; + if (rocketUserData.profile) { + if (rocketUserData.profile.image_original) { + url = rocketUserData.profile.image_original; + } else if (rocketUserData.profile.image_512) { + url = rocketUserData.profile.image_512; + } + } + if (url) { + try { + await setUserAvatar(user, url, null, 'url'); + } catch (err) { + rocketLogger.debug({ msg: 'Error setting user avatar from Slack', err }); + } + } + } + + const importIds = [rocketUserData.id]; + if (isBot && rocketUserData.profile && rocketUserData.profile.bot_id) { + importIds.push(rocketUserData.profile.bot_id); + } + await Users.addImportIds(rocketUserData.rocketId, importIds); + if (!this.userTags[slackUserID]) { + this.userTags[slackUserID] = { + slack: `<@${slackUserID}>`, + rocket: `@${rocketUserData.name}`, + }; + } + addedUser = await Users.findOneById(rocketUserData.rocketId); + } + } + + if (!addedUser) { + rocketLogger.debug('User not added'); + } + + return addedUser; + } + + addAliasToMsg(rocketUserName, rocketMsgObj) { + const aliasFormat = settings.get('SlackBridge_AliasFormat'); + if (aliasFormat) { + const alias = this.util.format(aliasFormat, rocketUserName); + + if (alias !== rocketUserName) { + rocketMsgObj.alias = alias; + } + } + + return rocketMsgObj; + } + + async createAndSaveMessage(rocketChannel, rocketUser, slackMessage, rocketMsgDataDefaults, isImporting, slack) { + if (slackMessage.type === 'message') { + let rocketMsgObj = {}; + if (!_.isEmpty(slackMessage.subtype)) { + rocketMsgObj = await slack.processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting); + if (!rocketMsgObj) { + return; + } + } else { + rocketMsgObj = { + msg: await this.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), + rid: rocketChannel._id, + u: { + _id: rocketUser._id, + username: rocketUser.username, + }, + }; + + this.addAliasToMsg(rocketUser.username, rocketMsgObj); + } + _.extend(rocketMsgObj, rocketMsgDataDefaults); + if (slackMessage.edited) { + rocketMsgObj.editedAt = new Date(parseInt(slackMessage.edited.ts.split('.')[0]) * 1000); + } + rocketMsgObj.slackTs = slackMessage.ts; + if (slackMessage.thread_ts) { + const tmessage = await Messages.findOneBySlackTs(slackMessage.thread_ts); + if (tmessage) { + rocketMsgObj.tmid = tmessage._id; + } + } + + if (slackMessage.subtype === 'bot_message') { + rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); + } + + if (slackMessage.pinned_to && slackMessage.pinned_to.indexOf(slackMessage.channel) !== -1) { + rocketMsgObj.pinned = true; + rocketMsgObj.pinnedAt = Date.now; + rocketMsgObj.pinnedBy = _.pick(rocketUser, '_id', 'username'); + } + if (slackMessage.subtype === 'bot_message') { + setTimeout(async () => { + if (slackMessage.bot_id && slackMessage.ts) { + // Make sure that a message with the same bot_id and timestamp doesn't already exists + const msg = await Messages.findOneBySlackBotIdAndSlackTs(slackMessage.bot_id, slackMessage.ts); + if (!msg) { + void sendMessage(rocketUser, rocketMsgObj, rocketChannel, { upsert: true }); + } + } + }, 500); + } else { + rocketLogger.debug('Send message to Rocket.Chat'); + await sendMessage(rocketUser, rocketMsgObj, rocketChannel, { upsert: true }); + } + } + } + + async convertSlackMsgTxtToRocketTxtFormat(slackMsgTxt) { + const regex = /(?:<@)([a-zA-Z0-9]+)(?:\|.+)?(?:>)/g; + if (!_.isEmpty(slackMsgTxt)) { + slackMsgTxt = slackMsgTxt.replace(//g, '@all'); + slackMsgTxt = slackMsgTxt.replace(//g, '@all'); + slackMsgTxt = slackMsgTxt.replace(//g, '@here'); + slackMsgTxt = slackMsgTxt.replace(/>/g, '>'); + slackMsgTxt = slackMsgTxt.replace(/</g, '<'); + slackMsgTxt = slackMsgTxt.replace(/&/g, '&'); + slackMsgTxt = slackMsgTxt.replace(/:simple_smile:/g, ':smile:'); + slackMsgTxt = slackMsgTxt.replace(/:memo:/g, ':pencil:'); + slackMsgTxt = slackMsgTxt.replace(/:piggy:/g, ':pig:'); + slackMsgTxt = slackMsgTxt.replace(/:uk:/g, ':gb:'); + slackMsgTxt = slackMsgTxt.replace(/<(http[s]?:[^>]*)>/g, '$1'); + + const promises = []; + + slackMsgTxt.replace(regex, async (match, userId) => { + if (!this.userTags[userId]) { + (await this.findUser(userId)) || (await this.addUser(userId)); // This adds userTags for the userId + } + const userTags = this.userTags[userId]; + if (userTags) { + promises.push(slackMsgTxt.replace(userTags.slack, userTags.rocket)); + } + }); + + const result = await Promise.all(promises); + slackMsgTxt = slackMsgTxt.replace(regex, () => result.shift()); + } else { + slackMsgTxt = ''; + } + return slackMsgTxt; + } +} diff --git a/apps/meteor/server/bridges/slack/SlackAPI.ts b/apps/meteor/server/bridges/slack/SlackAPI.ts new file mode 100644 index 0000000..1862e31 --- /dev/null +++ b/apps/meteor/server/bridges/slack/SlackAPI.ts @@ -0,0 +1,253 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +export class SlackAPI { + constructor(apiOrBotToken) { + this.token = apiOrBotToken; + } + + async getChannels(cursor = null) { + let channels = []; + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/conversations.list', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + types: 'public_channel', + exclude_archived: true, + limit: 1000, + ...(cursor && { cursor }), + }, + }); + const response = await request.json(); + + if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { + channels = channels.concat(response.channels); + if (response.response_metadata?.next_cursor) { + const nextChannels = await this.getChannels(response.response_metadata.next_cursor); + channels = channels.concat(nextChannels); + } + } + + return channels; + } + + async getGroups(cursor = null) { + let groups = []; + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/conversations.list', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + types: 'private_channel', + exclude_archived: true, + limit: 1000, + ...(cursor && { cursor }), + }, + }); + const response = await request.json(); + + if (response && response && Array.isArray(response.channels) && response.channels.length > 0) { + groups = groups.concat(response.channels); + if (response.response_metadata?.next_cursor) { + const nextGroups = await this.getGroups(response.response_metadata.next_cursor); + groups = groups.concat(nextGroups); + } + } + + return groups; + } + + async getRoomInfo(roomId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://slack.com/api/conversations.info`, { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + channel: roomId, + include_num_members: true, + }, + }); + const response = await request.json(); + return response && response && request.status === 200 && request.ok && response.channel; + } + + async getMembers(channelId) { + const { num_members } = this.getRoomInfo(channelId); + const MAX_MEMBERS_PER_CALL = 100; + let members = []; + let currentCursor = ''; + for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) { + const request = await fetch('https://slack.com/api/conversations.members', { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + channel: channelId, + limit: MAX_MEMBERS_PER_CALL, + ...(currentCursor && { cursor: currentCursor }), + }, + }); + const response = await request.json(); + if (response && response && request.status === 200 && request.ok && Array.isArray(response.members)) { + members = members.concat(response.members); + const hasMoreItems = response.response_metadata?.next_cursor; + if (hasMoreItems) { + currentCursor = response.response_metadata.next_cursor; + } + } + } + return members; + } + + async react(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/reactions.add', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + method: 'POST', + params: data, + }); + const response = await request.json(); + return response && request.status === 200 && response && request.ok; + } + + async removeReaction(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/reactions.remove', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + method: 'POST', + params: data, + }); + const response = await request.json(); + return response && request.status === 200 && response && request.ok; + } + + async removeMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/chat.delete', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + method: 'POST', + params: data, + }); + const response = await request.json(); + return response && request.status === 200 && response && request.ok; + } + + async sendMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/chat.postMessage', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + method: 'POST', + params: data, + }); + return request.json(); + } + + async updateMessage(data) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/chat.update', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + method: 'POST', + params: data, + }); + const response = await request.json(); + return response && request.status === 200 && response && request.ok; + } + + async getHistory(options) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://slack.com/api/conversations.history`, { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: options, + }); + const response = await request.json(); + return response; + } + + async getPins(channelId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/pins.list', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + channel: channelId, + }, + }); + const response = await request.json(); + return response && response && request.status === 200 && request.ok && response.items; + } + + async getUser(userId) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/users.info', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${this.token}`, + }, + params: { + user: userId, + }, + }); + const response = await request.json(); + return response && response && request.status === 200 && request.ok && response.user; + } + + static async verifyToken(token) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/auth.test', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${token}`, + }, + method: 'POST', + }); + const response = await request.json(); + return response && response && request.status === 200 && request.ok && response.ok; + } + + static async verifyAppCredentials({ botToken, appToken }) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch('https://slack.com/api/apps.connections.open', { + ignoreSsrfValidation: true, + headers: { + Authorization: `Bearer ${appToken}`, + }, + method: 'POST', + }); + const response = await request.json(); + const isAppTokenOk = response && response && request.status === 200 && request.ok && response.ok; + const isBotTokenOk = await this.verifyToken(botToken); + return isAppTokenOk && isBotTokenOk; + } +} diff --git a/apps/meteor/server/bridges/slack/SlackAdapter.ts b/apps/meteor/server/bridges/slack/SlackAdapter.ts new file mode 100644 index 0000000..d84c906 --- /dev/null +++ b/apps/meteor/server/bridges/slack/SlackAdapter.ts @@ -0,0 +1,1453 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import http from 'node:http'; +import https from 'node:https'; +import url from 'node:url'; + +import { Message } from '@rocket.chat/core-services'; +import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { App as SlackApp } from '@slack/bolt'; +import { RTMClient } from '@slack/rtm-api'; +import { Meteor } from 'meteor/meteor'; + +import { SlackAPI } from './SlackAPI'; +import { slackLogger } from './logger'; +import { FileUpload } from '../../lib/media/file-upload'; +import { deleteMessage } from '../../lib/messages/deleteMessage'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { updateMessage } from '../../lib/messages/updateMessage'; +import { executeSetReaction } from '../../lib/messaging/reactions/setReaction'; +import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; +import { archiveRoom } from '../../lib/rooms/archiveRoom'; +import { removeUserFromRoom } from '../../lib/rooms/removeUserFromRoom'; +import { saveRoomName, saveRoomTopic } from '../../lib/rooms/settings'; +import { unarchiveRoom } from '../../lib/rooms/unarchiveRoom'; +import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; +import { settings } from '../../settings'; + +export default class SlackAdapter { + constructor(slackBridge) { + slackLogger.debug({ msg: 'constructor' }); + this.slackBridge = slackBridge; + this.rtm = {}; // slack-client Real Time Messaging API + this.apiToken = {}; // Slack API Token passed in via Connect + this.slackApp = {}; + this.appCredential = {}; + // On Slack, a rocket integration bot will be added to slack channels, this is the list of those channels, key is Rocket Ch ID + this.slackChannelRocketBotMembershipMap = new Map(); // Key=RocketChannelID, Value=SlackChannel + this.rocket = {}; + this.messagesBeingSent = []; + this.slackBotId = false; + + this.slackAPI = {}; + } + + async connect({ apiToken, appCredential }) { + try { + const connectResult = await (appCredential ? this.connectApp(appCredential) : this.connectLegacy(apiToken)); + + if (connectResult) { + slackLogger.info({ msg: 'Connected to Slack' }); + slackLogger.debug({ msg: 'Slack connection result', connectResult }); + Meteor.startup(async () => { + try { + await this.populateMembershipChannelMap(); // If run outside of Meteor.startup, HTTP is not defined + } catch (err) { + slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); + if (err.data.error === 'invalid_auth') { + slackLogger.error('The provided token is invalid'); + } + this.slackBridge.disconnect(); + } + }); + } + } catch (err) { + slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); + this.slackBridge.disconnect(); + } + } + + /** + * Connect to the remote Slack server using the passed in app credential and register for Slack events. + * @typedef {Object} AppCredential + * @property {string} botToken + * @property {string} appToken + * @property {string} signingSecret + * @param {AppCredential} appCredential + */ + async connectApp(appCredential) { + this.appCredential = appCredential; + + // Invalid app credentials causes unhandled errors + if (!(await SlackAPI.verifyAppCredentials(appCredential))) { + throw new Error('Invalid app credentials (botToken or appToken) for the slack app'); + } + this.slackAPI = new SlackAPI(this.appCredential.botToken); + + this.slackApp = new SlackApp({ + appToken: this.appCredential.appToken, + signingSecret: this.appCredential.signingSecret, + token: this.appCredential.botToken, + socketMode: true, + }); + + this.registerForEvents(); + + const connectionResult = await this.slackApp.start(); + + return connectionResult; + } + + /** + * Connect to the remote Slack server using the passed in token API and register for Slack events. + * @param apiToken + * @deprecated + */ + async connectLegacy(apiToken) { + this.apiToken = apiToken; + + // Invalid apiToken causes unhandled errors + if (!(await SlackAPI.verifyToken(apiToken))) { + throw new Error('Invalid ApiToken for the slack legacy bot integration'); + } + + if (RTMClient != null) { + RTMClient.disconnect; + } + this.slackAPI = new SlackAPI(this.apiToken); + this.rtm = new RTMClient(this.apiToken); + + this.registerForEventsLegacy(); + + const connectionResult = await this.rtm.start(); + + return connectionResult; + } + + /** + * Unregister for slack events and disconnect from Slack + */ + async disconnect() { + if (this.rtm.connected && this.rtm.disconnect) { + await this.rtm.disconnect(); + } else if (this.slackApp.stop) { + await this.slackApp.stop(); + } + } + + setRocket(rocket) { + this.rocket = rocket; + } + + registerForEvents() { + /** + * message: { + * "client_msg_id": "caab144d-41e7-47cc-87fa-af5d50c02784", + * "type": "message", + * "text": "heyyyyy", + * "user": "U060WD4QW81", + * "ts": "1697054782.214569", + * "blocks": [], + * "team": "T060383CUDV", + * "channel": "C060HSLQPCN", + * "event_ts": "1697054782.214569", + * "channel_type": "channel" + * } + */ + this.slackApp.message(async ({ message }) => { + slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', message }); + if (message) { + try { + await this.onMessage(message); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + }); + + /** + * Event fired when a message is reacted in a channel or group app is added in + * event: { + * "type": "reaction_added", + * "user": "U060WD4QW81", + * "reaction": "telephone_receiver", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697037020.309679" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697037219.001600" + * } + */ + this.slackApp.event('reaction_added', async ({ event }) => { + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', event }); + try { + await this.onReactionAdded(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); + } + }); + + /** + * Event fired when a reaction is removed from a message in a channel or group app is added in. + * event: { + * "type": "reaction_removed", + * "user": "U060WD4QW81", + * "reaction": "raised_hands", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697028997.057629" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697029220.000600" + * } + */ + this.slackApp.event('reaction_removed', async ({ event }) => { + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', event }); + try { + await this.onReactionRemoved(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); + } + }); + + /** + * Event fired when a members joins a channel + * event: { + * "type": "member_joined_channel", + * "user": "U06039U8WK1", + * "channel": "C060HT033E2", + * "channel_type": "C", + * "team": "T060383CUDV", + * "inviter": "U060WD4QW81", + * "event_ts": "1697042377.000800" + * } + */ + this.slackApp.event('member_joined_channel', async ({ event, context }) => { + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); + try { + await this.processMemberJoinChannel(event, context); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.event('channel_left', async ({ event }) => { + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', event }); + try { + this.onChannelLeft(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.error((error) => { + slackLogger.error({ msg: 'Error on SlackApp', error }); + }); + } + + /** + * @deprecated + */ + registerForEventsLegacy() { + slackLogger.debug({ msg: 'Register for events' }); + this.rtm.on('authenticated', () => { + slackLogger.info('Connected to Slack'); + }); + + this.rtm.on('unable_to_rtm_start', () => { + this.slackBridge.disconnect(); + }); + + this.rtm.on('disconnected', () => { + slackLogger.info('Disconnected from Slack'); + this.slackBridge.disconnect(); + }); + + /** + * Event fired when someone messages a channel the bot is in + * { + * type: 'message', + * channel: [channel_id], + * user: [user_id], + * text: [message], + * ts: [ts.milli], + * team: [team_id], + * subtype: [message_subtype], + * inviter: [message_subtype = 'group_join|channel_join' -> user_id] + * } + **/ + this.rtm.on('message', async (slackMessage) => { + slackLogger.debug({ msg: 'OnSlackEvent-MESSAGE', slackMessage }); + if (slackMessage) { + try { + await this.onMessage(slackMessage); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + }); + + this.rtm.on('reaction_added', async (reactionMsg) => { + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_ADDED', reactionMsg }); + if (reactionMsg) { + try { + await this.onReactionAdded(reactionMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); + } + } + }); + + this.rtm.on('reaction_removed', async (reactionMsg) => { + slackLogger.debug({ msg: 'OnSlackEvent-REACTION_REMOVED', reactionMsg }); + if (reactionMsg) { + try { + await this.onReactionRemoved(reactionMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); + } + } + }); + + /** + * Event fired when someone creates a public channel + * { + * type: 'channel_created', + * channel: { + * id: [channel_id], + * is_channel: true, + * name: [channel_name], + * created: [ts], + * creator: [user_id], + * is_shared: false, + * is_org_shared: false + * }, + * event_ts: [ts.milli] + * } + **/ + this.rtm.on('channel_created', () => {}); + + /** + * Event fired when the bot joins a public channel + * { + * type: 'channel_joined', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_channel: true, + * created: [ts], + * creator: [user_id], + * is_archived: false, + * is_general: false, + * is_member: true, + * last_read: [ts.milli], + * latest: [message_obj], + * unread_count: 0, + * unread_count_display: 0, + * members: [ user_ids ], + * topic: { + * value: [channel_topic], + * creator: [user_id], + * last_set: 0 + * }, + * purpose: { + * value: [channel_purpose], + * creator: [user_id], + * last_set: 0 + * } + * } + * } + **/ + this.rtm.on('channel_joined', () => {}); + + /** + * Event fired when the bot leaves (or is removed from) a public channel + * { + * type: 'channel_left', + * channel: [channel_id] + * } + **/ + this.rtm.on('channel_left', (channelLeftMsg) => { + slackLogger.debug({ msg: 'OnSlackEvent-CHANNEL_LEFT', channelLeftMsg }); + if (channelLeftMsg) { + try { + this.onChannelLeft(channelLeftMsg); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + } + }); + + /** + * Event fired when an archived channel is deleted by an admin + * { + * type: 'channel_deleted', + * channel: [channel_id], + * event_ts: [ts.milli] + * } + **/ + this.rtm.on('channel_deleted', () => {}); + + /** + * Event fired when the channel has its name changed + * { + * type: 'channel_rename', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_channel: true, + * created: [ts] + * }, + * event_ts: [ts.milli] + * } + **/ + this.rtm.on('channel_rename', () => {}); + + /** + * Event fired when the bot joins a private channel + * { + * type: 'group_joined', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_group: true, + * created: [ts], + * creator: [user_id], + * is_archived: false, + * is_mpim: false, + * is_open: true, + * last_read: [ts.milli], + * latest: [message_obj], + * unread_count: 0, + * unread_count_display: 0, + * members: [ user_ids ], + * topic: { + * value: [channel_topic], + * creator: [user_id], + * last_set: 0 + * }, + * purpose: { + * value: [channel_purpose], + * creator: [user_id], + * last_set: 0 + * } + * } + * } + **/ + this.rtm.on('group_joined', () => {}); + + /** + * Event fired when the bot leaves (or is removed from) a private channel + * { + * type: 'group_left', + * channel: [channel_id] + * } + **/ + this.rtm.on('group_left', () => {}); + + /** + * Event fired when the private channel has its name changed + * { + * type: 'group_rename', + * channel: { + * id: [channel_id], + * name: [channel_name], + * is_group: true, + * created: [ts] + * }, + * event_ts: [ts.milli] + * } + **/ + this.rtm.on('group_rename', () => {}); + + /** + * Event fired when a new user joins the team + * { + * type: 'team_join', + * user: + * { + * id: [user_id], + * team_id: [team_id], + * name: [user_name], + * deleted: false, + * status: null, + * color: [color_code], + * real_name: '', + * tz: [timezone], + * tz_label: [timezone_label], + * tz_offset: [timezone_offset], + * profile: + * { + * avatar_hash: '', + * real_name: '', + * real_name_normalized: '', + * email: '', + * image_24: '', + * image_32: '', + * image_48: '', + * image_72: '', + * image_192: '', + * image_512: '', + * fields: null + * }, + * is_admin: false, + * is_owner: false, + * is_primary_owner: false, + * is_restricted: false, + * is_ultra_restricted: false, + * is_bot: false, + * presence: [user_presence] + * }, + * cache_ts: [ts] + * } + **/ + this.rtm.on('team_join', () => {}); + } + + /* + https://api.slack.com/events/reaction_removed + */ + async onReactionRemoved(slackReactionMsg) { + if (slackReactionMsg) { + if (!this.slackBridge.isReactionsEnabled) { + return; + } + const rocketUser = await this.rocket.getUser(slackReactionMsg.user); + // Lets find our Rocket originated message + let rocketMsg = await Messages.findOneBySlackTs(slackReactionMsg.item.ts); + + if (!rocketMsg) { + // Must have originated from Slack + const rocketID = this.rocket.createRocketID(slackReactionMsg.item.channel, slackReactionMsg.item.ts); + rocketMsg = await Messages.findOneById(rocketID); + } + + if (rocketMsg && rocketUser) { + const rocketReaction = `:${slackReactionMsg.reaction}:`; + const theReaction = (rocketMsg.reactions || {})[rocketReaction]; + + // If the Rocket user has already been removed, then this is an echo back from slack + if (rocketMsg.reactions && theReaction) { + if (rocketUser.roles.includes('bot')) { + return; + } + if (theReaction.usernames.indexOf(rocketUser.username) === -1) { + return; // Reaction already removed + } + } else { + // Reaction already removed + return; + } + + // Stash this away to key off it later so we don't send it back to Slack + this.slackBridge.reactionsMap.set(`unset${rocketMsg._id}${rocketReaction}`, rocketUser); + slackLogger.debug('Removing reaction from Slack'); + await executeSetReaction(rocketUser._id, rocketReaction, rocketMsg._id); + } + } + } + + /* + https://api.slack.com/events/reaction_added + */ + async onReactionAdded(slackReactionMsg) { + if (slackReactionMsg) { + if (!this.slackBridge.isReactionsEnabled) { + return; + } + const rocketUser = await this.rocket.getUser(slackReactionMsg.user); + + if (rocketUser.roles.includes('bot')) { + return; + } + + // Lets find our Rocket originated message + let rocketMsg = await Messages.findOneBySlackTs(slackReactionMsg.item.ts); + + if (!rocketMsg) { + // Must have originated from Slack + const rocketID = this.rocket.createRocketID(slackReactionMsg.item.channel, slackReactionMsg.item.ts); + rocketMsg = await Messages.findOneById(rocketID); + } + + if (rocketMsg && rocketUser) { + const rocketReaction = `:${slackReactionMsg.reaction}:`; + + // If the Rocket user has already reacted, then this is Slack echoing back to us + if (rocketMsg.reactions) { + const theReaction = rocketMsg.reactions[rocketReaction]; + if (theReaction) { + if (theReaction.usernames.indexOf(rocketUser.username) !== -1) { + return; // Already reacted + } + } + } + + // Stash this away to key off it later so we don't send it back to Slack + this.slackBridge.reactionsMap.set(`set${rocketMsg._id}${rocketReaction}`, rocketUser); + slackLogger.debug('Adding reaction from Slack'); + await executeSetReaction(rocketUser._id, rocketReaction, rocketMsg._id); + } + } + } + + onChannelLeft(channelLeftMsg) { + this.removeSlackChannel(channelLeftMsg.channel); + } + + /** + * We have received a message from slack and we need to save/delete/update it into rocket + * https://api.slack.com/events/message + */ + async onMessage(slackMessage, isImporting) { + const isAFileShare = slackMessage?.files && Array.isArray(slackMessage.files) && slackMessage.files.length; + if (isAFileShare) { + await this.processFileShare(slackMessage); + return; + } + if (slackMessage.subtype) { + switch (slackMessage.subtype) { + case 'message_deleted': + await this.processMessageDeleted(slackMessage); + break; + case 'message_changed': + await this.processMessageChanged(slackMessage); + break; + case 'channel_join': + await this.processChannelJoin(slackMessage); + break; + default: + // Keeping backwards compatability for now, refactor later + await this.processNewMessage(slackMessage, isImporting); + } + } else { + // Simple message + await this.processNewMessage(slackMessage, isImporting); + } + } + + async postFindChannel(rocketChannelName) { + slackLogger.debug({ msg: 'Searching for Slack channel or group', rocketChannelName }); + const channels = await this.slackAPI.getChannels(); + if (channels && channels.length > 0) { + for (const channel of channels) { + if (channel.name === rocketChannelName && channel.is_member === true) { + return channel; + } + } + } + const groups = await this.slackAPI.getGroups(); + if (groups && groups.length > 0) { + for (const group of groups) { + if (group.name === rocketChannelName) { + return group; + } + } + } + } + + /** + * Retrieves the Slack TS from a Rocket msg that originated from Slack + * @param rocketMsg + * @returns Slack TS or undefined if not a message that originated from slack + * @private + */ + getTimeStamp(rocketMsg) { + // slack-G3KJGGE15-1483081061-000169 + let slackTS; + let index = rocketMsg._id.indexOf('slack-'); + if (index === 0) { + // This is a msg that originated from Slack + slackTS = rocketMsg._id.substr(6, rocketMsg._id.length); + index = slackTS.indexOf('-'); + slackTS = slackTS.substr(index + 1, slackTS.length); + slackTS = slackTS.replace('-', '.'); + } else { + // This probably originated as a Rocket msg, but has been sent to Slack + slackTS = rocketMsg.slackTs; + } + + return slackTS; + } + + /** + * Adds a slack channel to our collection that the rocketbot is a member of on slack + * @param rocketChID + * @param slackChID + */ + addSlackChannel(rocketChID, slackChID) { + const ch = this.getSlackChannel(rocketChID); + if (ch == null) { + slackLogger.debug({ msg: 'Added channel', rocketChID, slackChID }); + this.slackChannelRocketBotMembershipMap.set(rocketChID, { + id: slackChID, + family: slackChID.charAt(0) === 'C' ? 'channels' : 'groups', + }); + } + } + + removeSlackChannel(slackChID) { + const keys = this.slackChannelRocketBotMembershipMap.keys(); + let slackChannel; + let key; + while ((key = keys.next().value) != null) { + slackChannel = this.slackChannelRocketBotMembershipMap.get(key); + if (slackChannel.id === slackChID) { + // Found it, need to delete it + this.slackChannelRocketBotMembershipMap.delete(key); + break; + } + } + } + + getSlackChannel(rocketChID) { + return this.slackChannelRocketBotMembershipMap.get(rocketChID); + } + + async populateMembershipChannelMapByChannels() { + const channels = await this.slackAPI.getChannels(); + if (!channels || channels.length <= 0) { + return; + } + + for await (const slackChannel of channels) { + const rocketchat_room = + (await Rooms.findOneByName(slackChannel.name, { projection: { _id: 1 } })) || + (await Rooms.findOneByImportId(slackChannel.id, { projection: { _id: 1 } })); + if (rocketchat_room && slackChannel.is_member) { + this.addSlackChannel(rocketchat_room._id, slackChannel.id); + } + } + } + + async populateMembershipChannelMapByGroups() { + const groups = await this.slackAPI.getGroups(); + if (!groups || groups.length <= 0) { + return; + } + + for await (const slackGroup of groups) { + const rocketchat_room = + (await Rooms.findOneByName(slackGroup.name, { projection: { _id: 1 } })) || + (await Rooms.findOneByImportId(slackGroup.id, { projection: { _id: 1 } })); + if (rocketchat_room && slackGroup.is_member) { + this.addSlackChannel(rocketchat_room._id, slackGroup.id); + } + } + } + + async populateMembershipChannelMap() { + slackLogger.debug('Populating channel map'); + await this.populateMembershipChannelMapByChannels(); + await this.populateMembershipChannelMapByGroups(); + } + + /* + https://api.slack.com/methods/reactions.add + */ + async postReactionAdded(reaction, slackChannel, slackTS) { + if (reaction && slackChannel && slackTS) { + const data = { + name: reaction, + channel: slackChannel, + timestamp: slackTS, + }; + + slackLogger.debug('Posting Add Reaction to Slack'); + const postResult = await this.slackAPI.react(data); + if (postResult) { + slackLogger.debug('Reaction added to Slack'); + } + } + } + + /* + https://api.slack.com/methods/reactions.remove + */ + async postReactionRemove(reaction, slackChannel, slackTS) { + if (reaction && slackChannel && slackTS) { + const data = { + name: reaction, + channel: slackChannel, + timestamp: slackTS, + }; + + slackLogger.debug('Posting Remove Reaction to Slack'); + const postResult = await this.slackAPI.removeReaction(data); + if (postResult) { + slackLogger.debug('Reaction removed from Slack'); + } + } + } + + async postDeleteMessage(rocketMessage) { + if (rocketMessage) { + const slackChannel = this.getSlackChannel(rocketMessage.rid); + + if (slackChannel != null) { + const data = { + ts: this.getTimeStamp(rocketMessage), + channel: this.getSlackChannel(rocketMessage.rid).id, + as_user: true, + }; + + slackLogger.debug('Post Delete Message to Slack', data); + const postResult = await this.slackAPI.removeMessage(data); + if (postResult) { + slackLogger.debug('Message deleted on Slack'); + } + } + } + } + + storeMessageBeingSent(data) { + this.messagesBeingSent.push(data); + } + + removeMessageBeingSent(data) { + const idx = this.messagesBeingSent.indexOf(data); + if (idx >= 0) { + this.messagesBeingSent.splice(idx, 1); + } + } + + isMessageBeingSent(username, channel) { + if (!this.messagesBeingSent.length) { + return false; + } + + return this.messagesBeingSent.some((messageData) => { + if (messageData.username !== username) { + return false; + } + + if (messageData.channel !== channel) { + return false; + } + + return true; + }); + } + + createSlackMessageId(ts, channelId) { + return `slack${channelId ? `-${channelId}` : ''}-${ts.replace(/\./g, '-')}`; + } + + async postMessage(slackChannel, rocketMessage) { + if (slackChannel?.id) { + let iconUrl = getUserAvatarURL(rocketMessage.u?.username); + if (iconUrl) { + iconUrl = Meteor.absoluteUrl().replace(/\/$/, '') + iconUrl; + } + const data = { + text: rocketMessage.msg, + channel: slackChannel.id, + username: rocketMessage.u?.username, + icon_url: iconUrl, + link_names: 1, + }; + + if (rocketMessage.tmid) { + const tmessage = await Messages.findOneById(rocketMessage.tmid); + if (tmessage?.slackTs) { + data.thread_ts = tmessage.slackTs; + } + } + slackLogger.debug({ msg: 'Post Message To Slack', data }); + + // If we don't have the bot id yet and we have multiple slack bridges, we need to keep track of the messages that are being sent + if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { + this.storeMessageBeingSent(data); + } + + const postResult = await this.slackAPI.sendMessage(data); + + if (!this.slackBotId && this.rocket.slackAdapters && this.rocket.slackAdapters.length >= 2) { + this.removeMessageBeingSent(data); + } + + if (postResult?.message?.bot_id && postResult.message.ts) { + this.slackBotId = postResult.message.bot_id; + await Messages.setSlackBotIdAndSlackTs(rocketMessage._id, postResult.message.bot_id, postResult.message.ts); + slackLogger.debug({ + msg: 'Message posted to Slack', + rocketMessageId: rocketMessage._id, + slackMessageId: postResult.message.ts, + slackBotId: postResult.message.bot_id, + }); + } + } + } + + /* + https://api.slack.com/methods/chat.update + */ + async postMessageUpdate(slackChannel, rocketMessage) { + if (slackChannel?.id) { + const data = { + ts: this.getTimeStamp(rocketMessage), + channel: slackChannel.id, + text: rocketMessage.msg, + as_user: true, + }; + slackLogger.debug({ msg: 'Post UpdateMessage To Slack', data }); + const postResult = await this.slackAPI.updateMessage(data); + if (postResult) { + slackLogger.debug('Message updated on Slack'); + } + } + } + + async processMemberJoinChannel(event, context) { + slackLogger.debug({ msg: 'Member join channel', channel: event.channel }); + const rocketCh = await this.rocket.getChannel({ channel: event.channel }); + if (rocketCh != null) { + this.addSlackChannel(rocketCh._id, event.channel); + if (context?.botUserId !== event?.user) { + const rocketChatUser = await this.rocket.getUser(event.user); + await addUserToRoom(rocketCh._id, rocketChatUser); + } + } + } + + async processChannelJoin(slackMessage) { + slackLogger.debug({ msg: 'Channel join', channelId: slackMessage.channel.id }); + const rocketCh = await this.rocket.addChannel(slackMessage.channel); + if (rocketCh != null) { + this.addSlackChannel(rocketCh._id, slackMessage.channel); + } + } + + async processFileShare(slackMessage) { + if (!settings.get('SlackBridge_FileUpload_Enabled')) { + return; + } + const file = slackMessage.files[0]; + + if (file?.url_private_download !== undefined) { + const rocketChannel = await this.rocket.getChannel(slackMessage); + const rocketUser = await this.rocket.getUser(slackMessage.user); + + // Hack to notify that a file was attempted to be uploaded + delete slackMessage.subtype; + + // If the text includes the file link, simply use the same text for the rocket message. + // If the link was not included, then use it instead of the message. + + if (slackMessage.text.indexOf(file.permalink) < 0) { + slackMessage.text = file.permalink; + } + + const ts = new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000); + const msgDataDefaults = { + _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.ts), + ts, + updatedBySlack: true, + }; + + await this.rocket.createAndSaveMessage(rocketChannel, rocketUser, slackMessage, msgDataDefaults, false); + } + } + + /* + https://api.slack.com/events/message/message_deleted + */ + async processMessageDeleted(slackMessage) { + if (slackMessage.previous_message) { + const rocketChannel = await this.rocket.getChannel(slackMessage); + const rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); + + if (rocketChannel && rocketUser) { + // Find the Rocket message to delete + let rocketMsgObj = await Messages.findOneBySlackBotIdAndSlackTs( + slackMessage.previous_message.bot_id, + slackMessage.previous_message.ts, + ); + + if (!rocketMsgObj) { + // Must have been a Slack originated msg + const _id = this.rocket.createRocketID(slackMessage.channel, slackMessage.previous_message.ts); + rocketMsgObj = await Messages.findOneById(_id); + } + + if (rocketMsgObj) { + await deleteMessage(rocketMsgObj, rocketUser); + slackLogger.debug('Rocket message deleted by Slack'); + } + } + } + } + + /* + https://api.slack.com/events/message/message_changed + */ + async processMessageChanged(slackMessage) { + if (slackMessage.previous_message) { + const currentMsg = await Messages.findOneById(this.rocket.createRocketID(slackMessage.channel, slackMessage.message.ts)); + + // Only process this change, if its an actual update (not just Slack repeating back our Rocket original change) + if (currentMsg && slackMessage.message.text !== currentMsg.msg) { + const rocketChannel = await this.rocket.getChannel(slackMessage); + const rocketUser = slackMessage.previous_message.user + ? (await this.rocket.findUser(slackMessage.previous_message.user)) || + (await this.rocket.addUser(slackMessage.previous_message.user)) + : null; + + const rocketMsgObj = { + // @TODO _id + _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.previous_message.ts), + rid: rocketChannel._id, + msg: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.message.text), + updatedBySlack: true, // We don't want to notify slack about this change since Slack initiated it + }; + + await updateMessage(rocketMsgObj, rocketUser); + slackLogger.debug('Rocket message updated by Slack'); + } + } + } + + /* + This method will get refactored and broken down into single responsibilities + */ + async processNewMessage(slackMessage, isImporting) { + const rocketChannel = await this.rocket.getChannel(slackMessage); + let rocketUser = null; + if (slackMessage.subtype === 'bot_message') { + rocketUser = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); + } else { + rocketUser = slackMessage.user + ? (await this.rocket.findUser(slackMessage.user)) || (await this.rocket.addUser(slackMessage.user)) + : null; + } + if (rocketChannel && rocketUser) { + const msgDataDefaults = { + _id: this.rocket.createRocketID(slackMessage.channel, slackMessage.ts), + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + }; + if (isImporting) { + msgDataDefaults.imported = 'slackbridge'; + } + try { + await this.rocket.createAndSaveMessage(rocketChannel, rocketUser, slackMessage, msgDataDefaults, isImporting, this); + } catch (e) { + // http://www.mongodb.org/about/contributors/error-codes/ + // 11000 == duplicate key error + if (e.name === 'MongoError' && e.code === 11000) { + return; + } + + throw e; + } + } + } + + async processBotMessage(rocketChannel, slackMessage) { + const excludeBotNames = settings.get('SlackBridge_ExcludeBotnames'); + if (slackMessage.username !== undefined && excludeBotNames && slackMessage.username.match(excludeBotNames)) { + return; + } + + if (this.slackBotId) { + if (slackMessage.bot_id === this.slackBotId) { + return; + } + } else { + const slackChannel = this.getSlackChannel(rocketChannel._id); + if (this.isMessageBeingSent(slackMessage.username || slackMessage.bot_id, slackChannel.id)) { + return; + } + } + + const rocketMsgObj = { + msg: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text), + rid: rocketChannel._id, + bot: true, + attachments: slackMessage.attachments, + username: slackMessage.username || slackMessage.bot_id, + }; + this.rocket.addAliasToMsg(slackMessage.username || slackMessage.bot_id, rocketMsgObj); + if (slackMessage.icons) { + rocketMsgObj.emoji = slackMessage.icons.emoji; + } + return rocketMsgObj; + } + + async processMeMessage(rocketUser, slackMessage) { + return this.rocket.addAliasToMsg(rocketUser.username, { + msg: `_${await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.text)}_`, + }); + } + + async processChannelJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (isImporting) { + await Message.saveSystemMessage('uj', rocketChannel._id, rocketUser.username, rocketUser, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await addUserToRoom(rocketChannel._id, rocketUser); + } + } + + async processGroupJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (slackMessage.inviter) { + const inviter = slackMessage.inviter + ? (await this.rocket.findUser(slackMessage.inviter)) || (await this.rocket.addUser(slackMessage.inviter)) + : null; + if (isImporting) { + await Message.saveSystemMessage('au', rocketChannel._id, rocketUser.username, inviter, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await addUserToRoom(rocketChannel._id, rocketUser, inviter); + } + } + } + + async processLeaveMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (isImporting) { + await Message.saveSystemMessage('ul', rocketChannel._id, rocketUser.username, rocketUser, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await removeUserFromRoom(rocketChannel._id, rocketUser); + } + } + + async processTopicMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (isImporting) { + await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.topic, rocketUser, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await saveRoomTopic(rocketChannel._id, slackMessage.topic, rocketUser, false); + } + } + + async processPurposeMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (isImporting) { + await Message.saveSystemMessage('room_changed_topic', rocketChannel._id, slackMessage.purpose, rocketUser, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await saveRoomTopic(rocketChannel._id, slackMessage.purpose, rocketUser, false); + } + } + + async processNameMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (isImporting) { + await Message.saveSystemMessage('r', rocketChannel._id, slackMessage.name, rocketUser, { + ts: new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + imported: 'slackbridge', + }); + } else { + await saveRoomName(rocketChannel._id, slackMessage.name, rocketUser, false); + } + } + + async processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (slackMessage.file?.url_private_download !== undefined) { + const details = { + message_id: this.createSlackMessageId(slackMessage.ts), + name: slackMessage.file.name, + size: slackMessage.file.size, + type: slackMessage.file.mimetype, + rid: rocketChannel._id, + }; + return this.uploadFileFromSlack( + details, + slackMessage.file.url_private_download, + rocketUser, + rocketChannel, + new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), + isImporting, + ); + } + } + + async processPinnedItemMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + if (slackMessage.attachments?.[0]?.text) { + // TODO: refactor this logic to use the service to send this system message instead of using sendMessage + const rocketMsgObj = { + rid: rocketChannel._id, + t: 'message_pinned', + msg: '', + u: { + _id: rocketUser._id, + username: rocketUser.username, + }, + attachments: [ + { + text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(slackMessage.attachments[0].text), + author_name: slackMessage.attachments[0].author_subname, + author_icon: getUserAvatarURL(slackMessage.attachments[0].author_subname), + ts: new Date(parseInt(slackMessage.attachments[0].ts.split('.')[0]) * 1000), + }, + ], + }; + + if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) { + const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id); + await Messages.setPinnedByIdAndUserId(messageId, rocketMsgObj.u, true, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000)); + } + + return rocketMsgObj; + } + slackLogger.error('Pinned item with no attachment'); + } + + async processSubtypedMessage(rocketChannel, rocketUser, slackMessage, isImporting) { + switch (slackMessage.subtype) { + case 'bot_message': + return this.processBotMessage(rocketChannel, slackMessage); + case 'me_message': + return this.processMeMessage(rocketUser, slackMessage); + case 'channel_join': + return this.processChannelJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'group_join': + return this.processGroupJoinMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'channel_leave': + case 'group_leave': + return this.processLeaveMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'channel_topic': + case 'group_topic': + return this.processTopicMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'channel_purpose': + case 'group_purpose': + return this.processPurposeMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'channel_name': + case 'group_name': + return this.processNameMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'channel_archive': + case 'group_archive': + if (!isImporting) { + await archiveRoom(rocketChannel, rocketUser); + } + return; + case 'channel_unarchive': + case 'group_unarchive': + if (!isImporting) { + await unarchiveRoom(rocketChannel); + } + return; + case 'file_share': + return this.processShareMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'file_comment': + slackLogger.error('File comment not implemented'); + return; + case 'file_mention': + slackLogger.error('File mentioned not implemented'); + return; + case 'pinned_item': + return this.processPinnedItemMessage(rocketChannel, rocketUser, slackMessage, isImporting); + case 'unpinned_item': + slackLogger.error('Unpinned item not implemented'); + } + } + + /** + Uploads the file to the storage. + @param [Object] details an object with details about the upload. name, size, type, and rid + @param [String] fileUrl url of the file to download/import + @param [Object] user the Rocket.Chat user + @param [Object] room the Rocket.Chat room + @param [Date] timeStamp the timestamp the file was uploaded + **/ + // details, slackMessage.file.url_private_download, rocketUser, rocketChannel, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000), isImporting); + async uploadFileFromSlack(details, slackFileURL, rocketUser, rocketChannel, timeStamp, isImporting) { + const requestModule = /https/i.test(slackFileURL) ? https : http; + const parsedUrl = url.parse(slackFileURL, true); + parsedUrl.headers = { Authorization: `Bearer ${this.apiToken}` }; + await requestModule.get(parsedUrl, async (stream) => { + const fileStore = FileUpload.getStore('Uploads'); + + const file = await fileStore.insert(details, stream); + + const url = file.url.replace(Meteor.absoluteUrl(), '/'); + const attachment = { + title: file.name, + title_link: url, + }; + + if (/^image\/.+/.test(file.type)) { + attachment.image_url = url; + attachment.image_type = file.type; + attachment.image_size = file.size; + attachment.image_dimensions = file.identify?.size; + } + if (/^audio\/.+/.test(file.type)) { + attachment.audio_url = url; + attachment.audio_type = file.type; + attachment.audio_size = file.size; + } + if (/^video\/.+/.test(file.type)) { + attachment.video_url = url; + attachment.video_type = file.type; + attachment.video_size = file.size; + } + + const msg = { + rid: details.rid, + ts: timeStamp, + msg: '', + file: { + _id: file._id, + }, + groupable: false, + attachments: [attachment], + }; + + if (isImporting) { + msg.imported = 'slackbridge'; + } + + if (details.message_id && typeof details.message_id === 'string') { + msg._id = details.message_id; + } + + void sendMessage(rocketUser, msg, rocketChannel, { upsert: true }); + }); + } + + async importFromHistory(options) { + slackLogger.debug('Importing messages history'); + const data = await this.slackAPI.getHistory(options); + if (Array.isArray(data.messages) && data.messages.length) { + let latest = 0; + for await (const message of data.messages.reverse()) { + slackLogger.debug({ msg: 'MESSAGE', message }); + if (!latest || message.ts > latest) { + latest = message.ts; + } + message.channel = options.channel; + await this.onMessage(message, true); + } + return { has_more: data.has_more, ts: latest }; + } + } + + async copyChannelInfo(rid, channelMap) { + slackLogger.debug({ msg: 'Copying users from Slack channel to Rocket.Chat', channelId: channelMap.id, rid }); + const channel = await this.slackAPI.getRoomInfo(channelMap.id); + if (channel) { + const members = await this.slackAPI.getMembers(channelMap.id); + if (members && Array.isArray(members) && members.length) { + for (const member of members) { + const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); + if (user) { + slackLogger.debug({ msg: 'Adding user to room', username: user.username, rid }); + await addUserToRoom(rid, user, null, { skipSystemMessage: true }); + } + } + } + + let topic = ''; + let topic_last_set = 0; + let topic_creator = null; + if (channel?.topic?.value) { + topic = channel.topic.value; + topic_last_set = channel.topic.last_set; + topic_creator = channel.topic.creator; + } + + if (channel?.purpose?.value) { + if (topic_last_set) { + if (topic_last_set < channel.purpose.last_set) { + topic = channel.purpose.topic; + topic_creator = channel.purpose.creator; + } + } else { + topic = channel.purpose.topic; + topic_creator = channel.purpose.creator; + } + } + + if (topic) { + const creator = (await this.rocket.findUser(topic_creator)) || (await this.rocket.addUser(topic_creator)); + slackLogger.debug({ msg: 'Setting room topic', rid, topic, username: creator.username }); + await saveRoomTopic(rid, topic, creator, false); + } + } + } + + async copyPins(rid, channelMap) { + const items = await this.slackAPI.getPins(channelMap.id); + if (items && Array.isArray(items) && items.length) { + for (const pin of items) { + if (pin.message) { + const user = await this.rocket.findUser(pin.message.user); + // TODO: send this system message to the room as well (using the service) + const msgObj = { + rid, + t: 'message_pinned', + msg: '', + u: { + _id: user._id, + username: user.username, + }, + attachments: [ + { + text: await this.rocket.convertSlackMsgTxtToRocketTxtFormat(pin.message.text), + author_name: user.username, + author_icon: getUserAvatarURL(user.username), + ts: new Date(parseInt(pin.message.ts.split('.')[0]) * 1000), + }, + ], + }; + + const messageId = this.createSlackMessageId(pin.message.ts, pin.channel); + await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(pin.message.ts.split('.')[0]) * 1000)); + } + } + } + } + + async importMessages(rid, callback) { + slackLogger.info({ msg: 'importMessages', rid }); + const rocketchat_room = await Rooms.findOneById(rid); + if (rocketchat_room) { + if (this.getSlackChannel(rid)) { + await this.copyChannelInfo(rid, this.getSlackChannel(rid)); + + slackLogger.debug({ msg: 'Importing messages from Slack to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); + + let results = await this.importFromHistory({ + channel: this.getSlackChannel(rid).id, + oldest: 1, + }); + while (results?.has_more) { + results = await this.importFromHistory({ + channel: this.getSlackChannel(rid).id, + oldest: results.ts, + }); + } + + slackLogger.debug({ msg: 'Pinning Slack channel messages to Rocket.Chat', slackChannel: this.getSlackChannel(rid), rid }); + await this.copyPins(rid, this.getSlackChannel(rid)); + + return callback(); + } + const slack_room = await this.postFindChannel(rocketchat_room.name); + if (slack_room) { + this.addSlackChannel(rid, slack_room.id); + return this.importMessages(rid, callback); + } + slackLogger.error({ msg: 'Could not find Slack room with specified name', roomName: rocketchat_room.name }); + return callback(new Meteor.Error('error-slack-room-not-found', 'Could not find Slack room with specified name')); + } + slackLogger.error({ msg: 'Could not find Rocket.Chat room with specified id', rid }); + return callback(new Meteor.Error('error-invalid-room', 'Invalid room')); + } +} diff --git a/apps/meteor/server/bridges/slack/index.ts b/apps/meteor/server/bridges/slack/index.ts new file mode 100644 index 0000000..40b7b56 --- /dev/null +++ b/apps/meteor/server/bridges/slack/index.ts @@ -0,0 +1,3 @@ +import './slackbridge'; +import './slackbridge_import.server'; +import './removeChannelLinks'; diff --git a/apps/meteor/server/bridges/slack/logger.ts b/apps/meteor/server/bridges/slack/logger.ts new file mode 100644 index 0000000..6c35860 --- /dev/null +++ b/apps/meteor/server/bridges/slack/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from '@rocket.chat/logger'; + +const logger = new Logger('SlackBridge'); + +export const connLogger = logger.section('Connection'); +export const classLogger = logger.section('Class'); +export const slackLogger = logger.section('Slack'); +export const rocketLogger = logger.section('Rocket'); diff --git a/apps/meteor/server/bridges/slack/removeChannelLinks.ts b/apps/meteor/server/bridges/slack/removeChannelLinks.ts new file mode 100644 index 0000000..1351afc --- /dev/null +++ b/apps/meteor/server/bridges/slack/removeChannelLinks.ts @@ -0,0 +1,41 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeSlackBridgeChannelLinks(): { message: string; params: unknown[] }; + } +} + +Meteor.methods({ + async removeSlackBridgeChannelLinks() { + const user = await Meteor.userAsync(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeSlackBridgeChannelLinks', + }); + } + + if (!(await hasPermissionAsync(user, 'remove-slackbridge-links'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'removeSlackBridgeChannelLinks', + }); + } + + if (settings.get('SlackBridge_Enabled') !== true) { + throw new Meteor.Error('SlackBridge_disabled'); + } + + await Rooms.unsetAllImportIds(); + + return { + message: 'Slackbridge_channel_links_removed_successfully', + params: [], + }; + }, +}); diff --git a/apps/meteor/server/bridges/slack/slackbridge.ts b/apps/meteor/server/bridges/slack/slackbridge.ts new file mode 100644 index 0000000..69bd9fa --- /dev/null +++ b/apps/meteor/server/bridges/slack/slackbridge.ts @@ -0,0 +1,199 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted + +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { debounce } from 'lodash'; + +import RocketAdapter from './RocketAdapter'; +import SlackAdapter from './SlackAdapter'; +import { classLogger, connLogger } from './logger'; +import { settings } from '../../settings'; + +/** + * SlackBridge interfaces between this Rocket installation and a remote Slack installation. + */ +class SlackBridgeClass { + constructor() { + this.isEnabled = false; + this.isLegacyRTM = true; + this.slackAdapters = []; + this.rocket = new RocketAdapter(this); + this.reactionsMap = new Map(); // Sync object between rocket and slack + + this.connected = false; + this.rocket.clearSlackAdapters(); + + // Settings that we cache versus looking up at runtime + this.apiTokens = false; + this.botTokens = false; + this.appTokens = false; + this.signingSecrets = false; + this.aliasFormat = ''; + this.excludeBotnames = ''; + this.isReactionsEnabled = true; + + this.processSettings(); + } + + connect() { + if (this.connected === false) { + this.slackAdapters = []; + this.rocket.clearSlackAdapters(); + + if (this.isLegacyRTM) { + const tokenList = this.apiTokens.split('\n'); + + tokenList.forEach((apiToken) => { + const slack = new SlackAdapter(this); + slack.setRocket(this.rocket); + this.rocket.addSlack(slack); + this.slackAdapters.push(slack); + + slack.connect({ apiToken }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err })); + }); + } else { + const botTokenList = this.botTokens.split('\n'); // Bot token list + const appTokenList = this.appTokens.split('\n'); // App token list + const signingSecretList = this.signingSecrets.split('\n'); // Signing secret list + + // Check if the number of tokens are the same + if (botTokenList.length !== appTokenList.length || botTokenList.length !== signingSecretList.length) { + connLogger.error('error connecting to slack: number of tokens are not the same'); + return; + } + + const appCredentials = botTokenList.map((botToken, i) => ({ + botToken, + appToken: appTokenList[i], + signingSecret: signingSecretList[i], + })); + + appCredentials.forEach((appCredential) => { + const slack = new SlackAdapter(this); + slack.setRocket(this.rocket); + this.rocket.addSlack(slack); + this.slackAdapters.push(slack); + + slack.connect({ appCredential }).catch((err) => connLogger.error({ msg: 'error connecting to slack', err })); + }); + } + + if (settings.get('SlackBridge_Out_Enabled')) { + this.rocket.connect(); + } + + this.connected = true; + connLogger.info('Enabled'); + } + } + + async reconnect() { + await this.disconnect(); + // connect if either apiTokens or appCredentials are set + if (this.isLegacyRTM && this.apiTokens) { + this.connect(); + } else if (!this.isLegacyRTM && this.botTokens && this.appTokens && this.signingSecrets) { + this.connect(); + } + } + + debouncedReconnectIfEnabled = debounce(() => { + if (this.isEnabled) { + this.reconnect(); + } + }, 500); + + async disconnect() { + try { + if (this.connected === true) { + await this.rocket.disconnect(); + await Promise.all(this.slackAdapters.map((slack) => slack.disconnect())); + this.slackAdapters = []; + this.connected = false; + connLogger.info('Slack Bridge Disconnected'); + } + } catch (error) { + connLogger.error({ msg: 'An error occurred during disconnection', err: error }); + } + } + + processSettings() { + // Check if legacy realtime api is enabled + settings.watch('SlackBridge_UseLegacy', (value) => { + if (value !== this.isLegacyRTM) { + this.isLegacyRTM = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug({ msg: 'Setting: SlackBridge_UseLegacy', value }); + }); + + // Slack installtion Bot token + settings.watch('SlackBridge_BotToken', (value) => { + if (value !== this.botTokens) { + this.botTokens = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug({ msg: 'Setting: SlackBridge_BotToken', value }); + }); + // Slack installtion App token + settings.watch('SlackBridge_AppToken', (value) => { + if (value !== this.appTokens) { + this.appTokens = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug({ msg: 'Setting: SlackBridge_AppToken', value }); + }); + // Slack installtion Signing token + settings.watch('SlackBridge_SigningSecret', (value) => { + if (value !== this.signingSecrets) { + this.signingSecrets = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug({ msg: 'Setting: SlackBridge_SigningSecret', value }); + }); + + // Slack installation API token + settings.watch('SlackBridge_APIToken', (value) => { + if (value !== this.apiTokens) { + this.apiTokens = value; + this.debouncedReconnectIfEnabled(); + } + + classLogger.debug({ msg: 'Setting: SlackBridge_APIToken', value }); + }); + + // Import messages from Slack with an alias; %s is replaced by the username of the user. If empty, no alias will be used. + settings.watch('SlackBridge_AliasFormat', (value) => { + this.aliasFormat = value; + classLogger.debug({ msg: 'Setting: SlackBridge_AliasFormat', value }); + }); + + // Do not propagate messages from bots whose name matches the regular expression above. If left empty, all messages from bots will be propagated. + settings.watch('SlackBridge_ExcludeBotnames', (value) => { + this.excludeBotnames = value; + classLogger.debug({ msg: 'Setting: SlackBridge_ExcludeBotnames', value }); + }); + + // Reactions + settings.watch('SlackBridge_Reactions_Enabled', (value) => { + this.isReactionsEnabled = value; + classLogger.debug({ msg: 'Setting: SlackBridge_Reactions_Enabled', value }); + }); + + // Is this entire SlackBridge enabled + settings.watch('SlackBridge_Enabled', (value) => { + if (this.isEnabled !== value) { + this.isEnabled = value; + if (this.isEnabled) { + this.debouncedReconnectIfEnabled(); + } else { + this.disconnect(); + } + } + classLogger.debug({ msg: 'Setting: SlackBridge_Enabled', value }); + }); + } +} + +export const SlackBridge = new SlackBridgeClass(); diff --git a/apps/meteor/server/bridges/slack/slackbridge_import.server.ts b/apps/meteor/server/bridges/slack/slackbridge_import.server.ts new file mode 100644 index 0000000..43f3276 --- /dev/null +++ b/apps/meteor/server/bridges/slack/slackbridge_import.server.ts @@ -0,0 +1,84 @@ +// This is a JS File that was renamed to TS so it won't lose its git history when converted to TS +// TODO: Remove the following lint/ts instructions when the file gets properly converted +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { Rooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Match } from 'meteor/check'; + +import { SlackBridge } from './slackbridge'; +import { i18n } from '../../lib/i18n'; +import { msgStream } from '../../lib/messaging/msgStream'; +import { slashCommands } from '../../lib/utils/slashCommand'; + +async function SlackBridgeImport({ command, params, message, userId }) { + if (command !== 'slackbridge-import' || !Match.test(params, String)) { + return; + } + + const room = await Rooms.findOneById(message.rid); + const channel = room.name; + const user = await Users.findOneById(userId); + + msgStream.emit(message.rid, { + _id: Random.id(), + rid: message.rid, + u: { username: 'rocket.cat' }, + ts: new Date(), + msg: i18n.t('SlackBridge_start', { + username: user.username, + channelName: channel, + interpolation: { escapeValue: false }, + lng: user.language, + }), + }); + + try { + for await (const slack of SlackBridge.slackAdapters) { + await slack.importMessages(message.rid, (error) => { + if (error) { + msgStream.emit(message.rid, { + _id: Random.id(), + rid: message.rid, + u: { username: 'rocket.cat' }, + ts: new Date(), + msg: i18n.t('SlackBridge_error', { + channelName: channel, + errorMessage: error.message, + interpolation: { escapeValue: false }, + lng: user.language, + }), + }); + } else { + msgStream.emit(message.rid, { + _id: Random.id(), + rid: message.rid, + u: { username: 'rocket.cat' }, + ts: new Date(), + msg: i18n.t('SlackBridge_finish', { + channelName: channel, + interpolation: { escapeValue: false }, + lng: user.language, + }), + }); + } + }); + } + } catch (error) { + msgStream.emit(message.rid, { + _id: Random.id(), + rid: message.rid, + u: { username: 'rocket.cat' }, + ts: new Date(), + msg: i18n.t('SlackBridge_error', { + channelName: channel, + errorMessage: error.message, + interpolation: { escapeValue: false }, + lng: user.language, + }), + }); + throw error; + } +} + +slashCommands.add({ command: 'slackbridge-import', callback: SlackBridgeImport }); diff --git a/apps/meteor/server/bridges/smarsh/functions/generateEml.ts b/apps/meteor/server/bridges/smarsh/functions/generateEml.ts new file mode 100644 index 0000000..a2ddbd6 --- /dev/null +++ b/apps/meteor/server/bridges/smarsh/functions/generateEml.ts @@ -0,0 +1,141 @@ +import { MessageTypes } from '@rocket.chat/message-types'; +import { Messages, SmarshHistory, Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import moment from 'moment-timezone'; + +import { sendEmail } from './sendEmail'; +import { i18n } from '../../../lib/i18n'; +import { settings } from '../../../settings'; + +const start = + ''; +const end = '
'; +const opentr = ''; +const closetr = ''; +const open20td = ''; +const open60td = ''; +const closetd = ''; + +function _getLink(attachment: { title_link: string }): string { + const url = attachment.title_link.replace(/ /g, '%20'); + + if (url.match(/^(https?:)?\/\//i)) { + return url; + } + return Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX + url; +} + +export const generateEml = async (): Promise => { + setImmediate(async () => { + const smarshMissingEmail = settings.get('Smarsh_MissingEmail_Email'); + const timeZone = settings.get('Smarsh_Timezone'); + + // TODO: revisit with more time => This appears to be a super expensive operation, going through all rooms + for await (const room of Rooms.find()) { + const smarshHistory = await SmarshHistory.findOne({ _id: room._id }); + const query: Record = { rid: room._id }; + + if (smarshHistory) { + query.ts = { $gt: smarshHistory.lastRan }; + } + + const date = new Date(); + const rows: string[] = []; + const data: { + users: string[]; + msgs: number; + files: string[]; + time: number; + room: string; + } = { + users: [], + msgs: 0, + files: [], + time: smarshHistory ? moment(date).diff(moment(smarshHistory.lastRan), 'minutes') : moment(date).diff(moment(room.ts), 'minutes'), + room: room.name ? `#${room.name}` : `Direct Message Between: ${room?.usernames?.join(' & ')}`, + }; + + const cursor = Messages.find(query); + + for await (const message of cursor) { + rows.push(opentr); + + // The timestamp + rows.push(open20td); + rows.push(moment(message.ts).tz(timeZone).format('YYYY-MM-DD HH-mm-ss z')); + rows.push(closetd); + + // The sender + rows.push(open20td); + const sender = await Users.findOne({ _id: message.u._id }); + if (!sender) { + return; + } + if (data.users.indexOf(sender?._id) === -1) { + data.users.push(sender._id); + } + + // Get the user's email, can be nothing if it is an unconfigured bot account (like rocket.cat) + if (sender.emails?.[0]?.address) { + rows.push(`${sender.name} <${sender.emails[0].address}>`); + } else { + rows.push(`${sender.name} <${smarshMissingEmail}>`); + } + rows.push(closetd); + + // The message + rows.push(open60td); + data.msgs++; + if (message.t) { + const messageType = MessageTypes.getType(message); + if (messageType) { + rows.push(messageType.text(i18n.getFixedT('en'), message)); + } else { + rows.push(`${message.msg} (${message.t})`); + } + } else if (message.file) { + data.files.push(message.file._id); + rows.push(`${message?.attachments?.[0].title} (${_getLink({ title_link: message?.attachments?.[0].title_link || '' })})})`); + } else if (message.attachments) { + const attaches: string[] = []; + message.attachments.forEach((a) => { + if ('image_url' in a && a.image_url !== undefined) { + attaches.push(a.image_url); + } + // TODO: Verify other type of attachments which need to be handled that aren't file uploads and image urls + // } else { + // console.log(a); + // } + }); + + rows.push(`${message.msg} (${attaches.join(', ')})`); + } else { + rows.push(message.msg); + } + rows.push(closetd); + + rows.push(closetr); + } + + if (rows.length !== 0) { + const result = start + rows.join('') + end; + + await SmarshHistory.updateOne( + { _id: room._id }, + { + _id: room._id, + lastRan: date, + lastResult: result, + }, + { upsert: true }, + ); + + await sendEmail({ + body: result, + subject: `Rocket.Chat, ${data.users.length} Users, ${data.msgs} Messages, ${data.files.length} Files, ${data.time} Minutes, in ${data.room}`, + files: data.files, + }); + } + } + }); +}; diff --git a/apps/meteor/server/bridges/smarsh/functions/sendEmail.ts b/apps/meteor/server/bridges/smarsh/functions/sendEmail.ts new file mode 100644 index 0000000..d21acae --- /dev/null +++ b/apps/meteor/server/bridges/smarsh/functions/sendEmail.ts @@ -0,0 +1,33 @@ +// Expects the following details: +// { +// body: '', +// subject: 'Rocket.Chat, 17 Users, 24 Messages, 1 File, 799504 Minutes, in #random', +// files: ['i3nc9l3mn'] +// } +import { Uploads } from '@rocket.chat/models'; + +import * as Mailer from '../../../lib/notifications/email/api'; +import { settings } from '../../../settings'; +import { UploadFS } from '../../../ufs'; + +export const sendEmail = async (data: { files: string[]; subject: string; body: string }) => { + const attachments = []; + + for await (const fileId of data.files) { + const file = await Uploads.findOneById(fileId); + if (file?.store === 'rocketchat_uploads' || file?.store === 'fileSystem') { + const rs = await UploadFS.getStore(file.store).getReadStream(fileId, file); + attachments.push({ + filename: file.name, + streamSource: rs, + }); + } + } + + await Mailer.sendNoWrap({ + to: settings.get('Smarsh_Email'), + from: settings.get('From_Email'), + subject: data.subject, + html: data.body, + }); +}; diff --git a/apps/meteor/server/bridges/smarsh/index.ts b/apps/meteor/server/bridges/smarsh/index.ts new file mode 100644 index 0000000..79eab14 --- /dev/null +++ b/apps/meteor/server/bridges/smarsh/index.ts @@ -0,0 +1,3 @@ +import './functions/sendEmail'; +import './functions/generateEml'; +import './startup'; diff --git a/apps/meteor/server/bridges/smarsh/startup.ts b/apps/meteor/server/bridges/smarsh/startup.ts new file mode 100644 index 0000000..778d9ea --- /dev/null +++ b/apps/meteor/server/bridges/smarsh/startup.ts @@ -0,0 +1,21 @@ +import { cronJobs } from '@rocket.chat/cron'; + +import { generateEml } from './functions/generateEml'; +import { settings } from '../../settings'; +import { smarshIntervalValuesToCronMap } from '../../settings/smarsh'; + +const smarshJobName = 'Smarsh EML Connector'; + +settings.watchMultiple( + ['Smarsh_Enabled', 'Smarsh_Email', 'From_Email', 'Smarsh_Interval'], + async function __addSmarshSyncedCronJobDebounced() { + if (await cronJobs.has(smarshJobName)) { + await cronJobs.remove(smarshJobName); + } + + if (settings.get('Smarsh_Enabled') && settings.get('Smarsh_Email') !== '' && settings.get('From_Email') !== '') { + const cronInterval = smarshIntervalValuesToCronMap[settings.get('Smarsh_Interval')]; + await cronJobs.add(smarshJobName, cronInterval, async () => generateEml()); + } + }, +); diff --git a/apps/meteor/server/bridges/webdav/README.md b/apps/meteor/server/bridges/webdav/README.md new file mode 100644 index 0000000..9200308 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/README.md @@ -0,0 +1,3 @@ +# RocketChat WebDAV + +Package for RocketChat users to interact with WebDAV servers (Tested with ownCloud and Nextcloud). diff --git a/apps/meteor/server/bridges/webdav/index.ts b/apps/meteor/server/bridges/webdav/index.ts new file mode 100644 index 0000000..36bdfca --- /dev/null +++ b/apps/meteor/server/bridges/webdav/index.ts @@ -0,0 +1,5 @@ +import './methods/addWebdavAccount'; +import './methods/getWebdavFileList'; +import './methods/getWebdavFilePreview'; +import './methods/getFileFromWebdav'; +import './methods/uploadFileToWebdav'; diff --git a/apps/meteor/server/bridges/webdav/lib/getWebdavCredentials.ts b/apps/meteor/server/bridges/webdav/lib/getWebdavCredentials.ts new file mode 100644 index 0000000..5c12c5b --- /dev/null +++ b/apps/meteor/server/bridges/webdav/lib/getWebdavCredentials.ts @@ -0,0 +1,11 @@ +import type { WebDAVClientOptions } from 'webdav'; + +export function getWebdavCredentials(account: WebDAVClientOptions): WebDAVClientOptions { + const cred = account.token + ? { token: account.token } + : { + username: account.username, + password: account.password, + }; + return cred; +} diff --git a/apps/meteor/server/bridges/webdav/lib/uploadFileToWebdav.ts b/apps/meteor/server/bridges/webdav/lib/uploadFileToWebdav.ts new file mode 100644 index 0000000..e7dc0df --- /dev/null +++ b/apps/meteor/server/bridges/webdav/lib/uploadFileToWebdav.ts @@ -0,0 +1,21 @@ +import type { IWebdavAccount } from '@rocket.chat/core-typings'; +import { WebdavAccounts } from '@rocket.chat/models'; + +import { getWebdavCredentials } from './getWebdavCredentials'; +import { WebdavClientAdapter } from './webdavClientAdapter'; + +export const uploadFileToWebdav = async (accountId: IWebdavAccount['_id'], fileData: string | Buffer, name: string): Promise => { + const account = await WebdavAccounts.findOneById(accountId); + if (!account) { + throw new Error('error-invalid-account'); + } + + const uploadFolder = 'Rocket.Chat Uploads/'; + const buffer = Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData); + + const cred = getWebdavCredentials(account); + const client = new WebdavClientAdapter(account.serverURL, cred); + // eslint-disable-next-line @typescript-eslint/no-empty-function + await client.createDirectory(uploadFolder).catch(() => {}); + await client.putFileContents(`${uploadFolder}/${name}`, buffer, { overwrite: false }); +}; diff --git a/apps/meteor/server/bridges/webdav/lib/webdavClientAdapter.ts b/apps/meteor/server/bridges/webdav/lib/webdavClientAdapter.ts new file mode 100644 index 0000000..c3d7df2 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/lib/webdavClientAdapter.ts @@ -0,0 +1,91 @@ +import stream from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; + +import type { WebDAVClient, FileStat, ResponseDataDetailed, WebDAVClientOptions } from 'webdav'; +import { createClient } from 'webdav'; + +export class WebdavClientAdapter { + _client: WebDAVClient; + + constructor(serverConfig: string, cred: WebDAVClientOptions) { + if (cred.token) { + this._client = createClient(serverConfig, { token: cred.token }); + } else { + this._client = createClient(serverConfig, { + username: cred.username, + password: cred.password, + }); + } + } + + async stat(path: string): Promise> { + try { + return await this._client.stat(path); + } catch (error: any) { + throw new Error(error.response?.statusText ? error.response.statusText : 'Error checking if directory exists on webdav'); + } + } + + async createDirectory(path: string): Promise { + try { + return await this._client.createDirectory(path); + } catch (error: any) { + throw new Error(error.response?.statusText ? error.response.statusText : 'Error creating directory on webdav'); + } + } + + async deleteFile(path: string): Promise { + try { + return await this._client.deleteFile(path); + } catch (error: any) { + throw new Error(error.response?.statusText ? error.response.statusText : 'Error deleting file on webdav'); + } + } + + async getFileContents(filename: string): Promise { + try { + return (await this._client.getFileContents(filename)) as Buffer; + } catch (error: any) { + throw new Error(error.response?.statusText ? error.response.statusText : 'Error getting file contents webdav'); + } + } + + async getDirectoryContents(path: string): Promise> { + try { + return await this._client.getDirectoryContents(path); + } catch (error: any) { + throw new Error(error.response?.statusText ? error.response.statusText : 'Error getting directory contents webdav'); + } + } + + async putFileContents(path: string, data: Buffer, options: Record = {}): Promise { + try { + return await this._client.putFileContents(path, data, options); + } catch (error: any) { + throw new Error(error.response?.statusText ?? 'Error updating file contents.'); + } + } + + createReadStream(path: string, options?: Record): Readable { + return this._client.createReadStream(path, options); + } + + createWriteStream(path: string, fileSize: number): Writable { + const ws = new stream.PassThrough(); + + this._client + .customRequest(path, { + method: 'PUT', + headers: { + ...(fileSize ? { 'Content-Length': String(fileSize) } : {}), + }, + data: ws, + maxRedirects: 0, + }) + .catch((err) => { + ws.emit('error', err); + }); + + return ws; + } +} diff --git a/apps/meteor/server/bridges/webdav/methods/addWebdavAccount.ts b/apps/meteor/server/bridges/webdav/methods/addWebdavAccount.ts new file mode 100644 index 0000000..ed6bec6 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/methods/addWebdavAccount.ts @@ -0,0 +1,153 @@ +import { api } from '@rocket.chat/core-services'; +import type { IWebdavAccountPayload } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { WebdavAccounts } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addWebdavAccount(formData: IWebdavAccountPayload): boolean; + addWebdavAccountByToken(data: IWebdavAccountPayload): boolean; + } +} + +export const addWebdavAccountByToken = async (userId: string, data: IWebdavAccountPayload): Promise => { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' }); + } + + if (!settings.get('Webdav_Integration_Enabled')) { + throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'addWebdavAccount', + }); + } + + check( + data, + Match.ObjectIncluding({ + serverURL: String, + token: Match.ObjectIncluding({ + access_token: String, + token_type: String, + refresh_token: Match.Optional(String), + }), + name: Match.Maybe(String), + }), + ); + + try { + const client = new WebdavClientAdapter(data.serverURL, { token: data.token }); + + const accountData = { + userId, + serverURL: data.serverURL, + token: data.token, + name: data.name ?? '', + }; + + await client.stat('/'); + await WebdavAccounts.updateOne( + { + userId, + serverURL: data.serverURL, + name: data.name ?? '', + }, + { + $set: accountData, + }, + { + upsert: true, + }, + ); + void api.broadcast('notify.webdav', userId, { + type: 'changed', + account: accountData, + }); + } catch (error) { + throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', { + method: 'addWebdavAccount', + }); + } + + return true; +}; + +Meteor.methods({ + async addWebdavAccount(formData) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' }); + } + + if (!settings.get('Webdav_Integration_Enabled')) { + throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'addWebdavAccount', + }); + } + + check( + formData, + Match.ObjectIncluding({ + serverURL: String, + username: String, + password: String, + name: Match.Maybe(String), + }), + ); + + const duplicateAccount = await WebdavAccounts.findOneByUserIdServerUrlAndUsername( + { userId, serverURL: formData.serverURL, username: formData.username }, + {}, + ); + + if (duplicateAccount !== null) { + throw new Meteor.Error('duplicated-account', 'Account not found', { + method: 'addWebdavAccount', + }); + } + + try { + const client = new WebdavClientAdapter(formData.serverURL, { + username: formData.username, + password: formData.password, + }); + + const accountData = { + userId, + serverURL: formData.serverURL, + username: formData.username, + password: formData.password, + name: formData.name ?? '', + }; + + await client.stat('/'); + await WebdavAccounts.insertOne(accountData); + + void api.broadcast('notify.webdav', userId, { + type: 'changed', + account: accountData, + }); + } catch (error) { + throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', { + method: 'addWebdavAccount', + }); + } + return true; + }, + + async addWebdavAccountByToken(data) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'addWebdavAccount' }); + } + + return addWebdavAccountByToken(userId, data); + }, +}); diff --git a/apps/meteor/server/bridges/webdav/methods/getFileFromWebdav.ts b/apps/meteor/server/bridges/webdav/methods/getFileFromWebdav.ts new file mode 100644 index 0000000..b1bcec7 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/methods/getFileFromWebdav.ts @@ -0,0 +1,49 @@ +import type { IWebdavAccount, IWebdavNode } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { WebdavAccounts } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { getWebdavCredentials } from '../lib/getWebdavCredentials'; +import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array }>; + } +} + +Meteor.methods({ + async getFileFromWebdav(accountId, file) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'getFileFromWebdav' }); + } + if (!settings.get('Webdav_Integration_Enabled')) { + throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'getFileFromWebdav', + }); + } + + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {}); + if (!account) { + throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { + method: 'getFileFromWebdav', + }); + } + + try { + const cred = getWebdavCredentials(account); + const client = new WebdavClientAdapter(account.serverURL, cred); + const fileContent = await client.getFileContents(file.filename); + const data = new Uint8Array(fileContent); + return { success: true, data }; + } catch (error) { + throw new Meteor.Error('unable-to-get-file', 'Unable to get file', { + method: 'getFileFromWebdav', + }); + } + }, +}); diff --git a/apps/meteor/server/bridges/webdav/methods/getWebdavFileList.ts b/apps/meteor/server/bridges/webdav/methods/getWebdavFileList.ts new file mode 100644 index 0000000..687b0da --- /dev/null +++ b/apps/meteor/server/bridges/webdav/methods/getWebdavFileList.ts @@ -0,0 +1,49 @@ +import type { IWebdavAccount, IWebdavNode } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { WebdavAccounts } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { getWebdavCredentials } from '../lib/getWebdavCredentials'; +import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getWebdavFileList(accountId: IWebdavAccount['_id'], path: string): { success: boolean; data: IWebdavNode[] }; + } +} + +Meteor.methods({ + async getWebdavFileList(accountId, path) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { method: 'getWebdavFileList' }); + } + + if (!settings.get('Webdav_Integration_Enabled')) { + throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'getWebdavFileList', + }); + } + + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {}); + if (!account) { + throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { + method: 'getWebdavFileList', + }); + } + + try { + const cred = getWebdavCredentials(account); + const client = new WebdavClientAdapter(account.serverURL, cred); + const data = (await client.getDirectoryContents(path)) as IWebdavNode[]; + return { success: true, data }; + } catch (error) { + throw new Meteor.Error('could-not-access-webdav', 'Could not access webdav', { + method: 'getWebdavFileList', + }); + } + }, +}); diff --git a/apps/meteor/server/bridges/webdav/methods/getWebdavFilePreview.ts b/apps/meteor/server/bridges/webdav/methods/getWebdavFilePreview.ts new file mode 100644 index 0000000..181cf51 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/methods/getWebdavFilePreview.ts @@ -0,0 +1,53 @@ +import type { IWebdavAccount } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { WebdavAccounts } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import { createClient } from 'webdav'; + +import { settings } from '../../../settings'; +import { getWebdavCredentials } from '../lib/getWebdavCredentials'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getWebdavFilePreview(accountId: IWebdavAccount['_id'], path: string): { success: true; data: ArrayBuffer } | undefined; + } +} + +Meteor.methods({ + async getWebdavFilePreview(accountId, path) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid User', { + method: 'getWebdavFilePreview', + }); + } + + if (!settings.get('Webdav_Integration_Enabled') || !settings.get('Accounts_OAuth_Nextcloud_URL')) { + throw new Meteor.Error('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'getWebdavFilePreview', + }); + } + + const account = await WebdavAccounts.findOneByIdAndUserId(accountId, userId, {}); + if (!account) { + throw new Meteor.Error('error-invalid-account', 'Invalid WebDAV Account', { + method: 'getWebdavFilePreview', + }); + } + + try { + const cred = getWebdavCredentials(account); + const client = createClient(account.serverURL, cred); + const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL'); + const res = await client.customRequest(`${serverURL}/index.php/core/preview.png?file=${path}&x=64&y=64`, { + method: 'GET', + responseType: 'arraybuffer', + }); + return { success: true, data: res.data as ArrayBuffer }; + } catch (error) { + // ignore error + } + }, +}); diff --git a/apps/meteor/server/bridges/webdav/methods/uploadFileToWebdav.ts b/apps/meteor/server/bridges/webdav/methods/uploadFileToWebdav.ts new file mode 100644 index 0000000..7419a75 --- /dev/null +++ b/apps/meteor/server/bridges/webdav/methods/uploadFileToWebdav.ts @@ -0,0 +1,65 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IWebdavAccount } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Logger } from '@rocket.chat/logger'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { uploadFileToWebdav } from '../lib/uploadFileToWebdav'; + +const logger = new Logger('WebDAV_Upload'); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + uploadFileToWebdav( + accountId: IWebdavAccount['_id'], + fileData: string | Buffer | ArrayBuffer, + name: string, + ): { success: boolean; message?: TranslationKey }; + } +} + +Meteor.methods({ + async uploadFileToWebdav(accountId, fileData, name) { + if (!Meteor.userId()) { + throw new MeteorError('error-invalid-user', 'Invalid User', { + method: 'uploadFileToWebdav', + }); + } + + if (!settings.get('Webdav_Integration_Enabled')) { + throw new MeteorError('error-not-allowed', 'WebDAV Integration Not Allowed', { + method: 'uploadFileToWebdav', + }); + } + + try { + await uploadFileToWebdav(accountId, fileData instanceof ArrayBuffer ? Buffer.from(fileData) : fileData, name); + return { success: true }; + } catch (err: any) { + if (typeof err === 'object' && err instanceof Error && err.name === 'error-invalid-account') { + throw new MeteorError(err.name, 'Invalid WebDAV Account', { + method: 'uploadFileToWebdav', + }); + } + + logger.error({ err }); + + if (err.response) { + const { status } = err.response; + if (status === 404) { + return { success: false, message: 'webdav-server-not-found' }; + } + if (status === 401) { + return { success: false, message: 'error-invalid-account' }; + } + if (status === 412) { + return { success: false, message: 'Duplicate_file_name_found' }; + } + } + return { success: false, message: 'FileUpload_Error' }; + } + }, +}); diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js new file mode 100644 index 0000000..2c8fff1 --- /dev/null +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -0,0 +1,44 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import _ from 'underscore'; + +export async function configureAccounts() { + const orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService; + Accounts.updateOrCreateUserFromExternalService = async function (serviceName, serviceData = {}, ...args /* , options*/) { + const services = ['facebook', 'github', 'gitlab', 'google', 'meteor-developer', 'linkedin', 'twitter', 'apple']; + + if (services.includes(serviceName) === false && serviceData._OAuthCustom !== true) { + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); + } + + if (serviceName === 'meteor-developer') { + if (Array.isArray(serviceData.emails)) { + const primaryEmail = serviceData.emails.sort((a) => a.primary !== true).filter((item) => item.verified === true)[0]; + serviceData.email = primaryEmail && primaryEmail.address; + } + } + + if (serviceData.email) { + const user = await Users.findOneByEmailAddress(serviceData.email); + if (user != null && user.services?.[serviceName]?.id !== serviceData.id) { + const findQuery = { + address: serviceData.email, + verified: true, + }; + + if (user.services?.password && !_.findWhere(user.emails, findQuery)) { + await Users.resetPasswordAndSetRequirePasswordChange( + user._id, + true, + 'This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password', + ); + } + + await Users.setServiceId(user._id, serviceName, serviceData.id); + await Users.setEmailVerified(user._id, serviceData.email); + } + } + + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); + }; +} diff --git a/apps/meteor/server/configuration/cas.ts b/apps/meteor/server/configuration/cas.ts new file mode 100644 index 0000000..0fe75bf --- /dev/null +++ b/apps/meteor/server/configuration/cas.ts @@ -0,0 +1,37 @@ +import debounce from 'lodash.debounce'; +import { Accounts } from 'meteor/accounts-base'; +import { RoutePolicy } from 'meteor/routepolicy'; +import { WebApp } from 'meteor/webapp'; + +import { loginHandlerCAS } from '../lib/cas/loginHandler'; +import { middlewareCAS } from '../lib/cas/middleware'; +import { updateCasServices } from '../lib/cas/updateCasService'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureCAS(settings: ICachedSettings) { + const _updateCasServices = debounce(updateCasServices, 2000); + + settings.watchByRegex(/^CAS_.+/, async () => { + await _updateCasServices(); + }); + + RoutePolicy.declare('/_cas/', 'network'); + + // Listen to incoming OAuth http requests + WebApp.connectHandlers.use((req, res, next) => { + middlewareCAS(req, res, next); + }); + + /* + * Register a server-side login handler. + * It is called after Accounts.callLoginMethod() is called from client. + * + */ + Accounts.registerLoginHandler('cas', (options) => { + const promise = loginHandlerCAS(options); + + // Pretend the promise has been awaited so the types will match - + // #TODO: Fix registerLoginHandler's type definitions (it accepts promises) + return promise as unknown as Awaited; + }); +} diff --git a/apps/meteor/server/configuration/configureAssets.ts b/apps/meteor/server/configuration/configureAssets.ts new file mode 100644 index 0000000..f6c86c6 --- /dev/null +++ b/apps/meteor/server/configuration/configureAssets.ts @@ -0,0 +1,6 @@ +import { RocketChatAssets } from '../lib/media/assets'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureAssets(settings: ICachedSettings): Promise { + settings.watchByRegex(/^Assets_/, (key, value) => RocketChatAssets.processAsset(key, value)); +} diff --git a/apps/meteor/server/configuration/configureBoilerplate.ts b/apps/meteor/server/configuration/configureBoilerplate.ts new file mode 100644 index 0000000..74830e4 --- /dev/null +++ b/apps/meteor/server/configuration/configureBoilerplate.ts @@ -0,0 +1,61 @@ +import { createHash } from 'node:crypto'; + +import { Meteor } from 'meteor/meteor'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; + +import type { ICachedSettings } from '../settings/CachedSettings'; + +const webAppHashes: Record = {}; + +export function getWebAppHash(arch: string): string | undefined { + if (!webAppHashes[arch]) { + const program = WebApp.clientPrograms[arch] as (typeof WebApp.clientPrograms)[string] & { + meteorRuntimeConfig: string; + }; + webAppHashes[arch] = createHash('sha1') + .update(JSON.stringify(encodeURIComponent(program.meteorRuntimeConfig))) + .digest('hex'); + } + + return webAppHashes[arch]; +} + +const { generateBoilerplate } = WebAppInternals; + +WebAppInternals.generateBoilerplate = function (...args: Parameters) { + for (const arch of Object.keys(WebApp.clientPrograms)) { + delete webAppHashes[arch]; + } + return generateBoilerplate.apply(this, args); +}; + +export function configureBoilerplate(settings: ICachedSettings): void { + settings.watch( + 'Site_Url', + // Needed as WebAppInternals.generateBoilerplate needs to be called in a fiber + Meteor.bindEnvironment(async (value) => { + if (value == null || value.trim() === '') { + return; + } + let host = value.replace(/\/$/, ''); + // let prefix = ''; + const match = value.match(/([^\/]+\/{2}[^\/]+)(\/.+)/); + if (match != null) { + host = match[1]; + // prefix = match[2].replace(/\/$/, ''); + } + (global as any).__meteor_runtime_config__.ROOT_URL = value; + + if (Meteor.absoluteUrl.defaultOptions?.rootUrl) { + Meteor.absoluteUrl.defaultOptions.rootUrl = value; + } + + // hostname = host.replace(/^https?:\/\//, ''); + process.env.MOBILE_ROOT_URL = host; + process.env.MOBILE_DDP_URL = host; + if (typeof WebAppInternals !== 'undefined' && WebAppInternals.generateBoilerplate) { + await WebAppInternals.generateBoilerplate(); + } + }), + ); +} diff --git a/apps/meteor/server/configuration/configureCDN.ts b/apps/meteor/server/configuration/configureCDN.ts new file mode 100644 index 0000000..0669083 --- /dev/null +++ b/apps/meteor/server/configuration/configureCDN.ts @@ -0,0 +1,32 @@ +import { WebAppInternals } from 'meteor/webapp'; + +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureCDN(settings: ICachedSettings): Promise { + settings.change('CDN_PREFIX', (value) => { + const useForAll = settings.get('CDN_PREFIX_ALL'); + if (value && useForAll) { + WebAppInternals.setBundledJsCssPrefix(value); + } + }); + + settings.change('CDN_JSCSS_PREFIX', (value) => { + const useForAll = settings.get('CDN_PREFIX_ALL'); + if (value && typeof value === 'string' && value.trim() && !useForAll) { + WebAppInternals.setBundledJsCssPrefix(value); + } + }); + + const cdnValue = settings.get('CDN_PREFIX'); + const useForAll = settings.get('CDN_PREFIX_ALL'); + const cdnJsCss = settings.get('CDN_JSCSS_PREFIX'); + if (cdnValue && typeof cdnValue === 'string' && cdnValue.trim()) { + if (useForAll) { + WebAppInternals.setBundledJsCssPrefix(cdnValue); + return; + } + if (cdnJsCss && typeof cdnJsCss === 'string' && cdnJsCss.trim()) { + WebAppInternals.setBundledJsCssPrefix(cdnJsCss); + } + } +} diff --git a/apps/meteor/server/configuration/configureCORS.ts b/apps/meteor/server/configuration/configureCORS.ts new file mode 100644 index 0000000..f6a11fd --- /dev/null +++ b/apps/meteor/server/configuration/configureCORS.ts @@ -0,0 +1,11 @@ +import { setCachingVersion, setInlineScriptsAllowed } from '../lib/cors/cors'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureCORS(settings: ICachedSettings): Promise { + settings.watch('Enable_CSP', async (enabled) => { + await setInlineScriptsAllowed(!enabled); + }); + settings.watch('Troubleshoot_Force_Caching_Version', (value) => { + setCachingVersion(value); + }); +} diff --git a/apps/meteor/server/configuration/configureDirectReply.ts b/apps/meteor/server/configuration/configureDirectReply.ts new file mode 100644 index 0000000..5a469f6 --- /dev/null +++ b/apps/meteor/server/configuration/configureDirectReply.ts @@ -0,0 +1,45 @@ +import _ from 'underscore'; + +import { logger } from '../features/EmailInbox/logger'; +import { DirectReplyIMAPInterceptor, POP3Helper } from '../lib/notifications/interceptDirectReplyEmails'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureDirectReply(settings: ICachedSettings): Promise { + let client: DirectReplyIMAPInterceptor | POP3Helper | undefined; + const startEmailInterceptor = _.debounce(async () => { + logger.info('Email Interceptor...'); + const protocol = settings.get('Direct_Reply_Protocol'); + + const isEnabled = + settings.get('Direct_Reply_Enable') && + protocol && + settings.get('Direct_Reply_Host') && + settings.get('Direct_Reply_Port') && + settings.get('Direct_Reply_Username') && + settings.get('Direct_Reply_Password'); + + if (client) { + await client.stop(); + } + + if (!isEnabled) { + logger.info('Email Interceptor Stopped...'); + return; + } + logger.info('Starting Email Interceptor...'); + + if (protocol === 'IMAP') { + client = new DirectReplyIMAPInterceptor(); + await client.start(); + } + + if (protocol === 'POP') { + client = new POP3Helper(settings.get('Direct_Reply_Frequency')); + client.start(); + } + }, 1000); + + settings.watchByRegex(/^Direct_Reply_.+/, startEmailInterceptor); + + void startEmailInterceptor(); +} diff --git a/apps/meteor/server/configuration/configureIRC.ts b/apps/meteor/server/configuration/configureIRC.ts new file mode 100644 index 0000000..dc12fb6 --- /dev/null +++ b/apps/meteor/server/configuration/configureIRC.ts @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; + +import Bridge from '../bridges/irc/irc-bridge'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureIRC(settings: ICachedSettings): Promise { + if (!settings.get('IRC_Enabled')) { + return; + } + + // Normalize the config values + const config = { + server: { + protocol: settings.get('IRC_Protocol'), + host: settings.get('IRC_Host'), + port: settings.get('IRC_Port'), + name: settings.get('IRC_Name'), + description: settings.get('IRC_Description'), + }, + passwords: { + local: settings.get('IRC_Local_Password'), + peer: settings.get('IRC_Peer_Password'), + }, + }; + + Meteor.ircBridge = new Bridge(config); + await Meteor.ircBridge.init(); +} diff --git a/apps/meteor/server/configuration/configureLogLevel.ts b/apps/meteor/server/configuration/configureLogLevel.ts new file mode 100644 index 0000000..6ff4685 --- /dev/null +++ b/apps/meteor/server/configuration/configureLogLevel.ts @@ -0,0 +1,17 @@ +import { logLevel, type LogLevelSetting } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; + +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureLogLevel(settings: ICachedSettings) { + const LogLevel = await Settings.getValueById('Log_Level'); + if (LogLevel) { + logLevel.emit('changed', LogLevel as LogLevelSetting); + } + + settings.watch('Log_Level', (value) => { + if (value != null) { + logLevel.emit('changed', String(value) as LogLevelSetting); + } + }); +} diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts new file mode 100644 index 0000000..1dd5905 --- /dev/null +++ b/apps/meteor/server/configuration/configurePassport.ts @@ -0,0 +1,104 @@ +import { Users } from '@rocket.chat/models'; +import bodyParser from 'body-parser'; +import MongoStore from 'connect-mongo'; +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import session from 'express-session'; +import { MongoInternals } from 'meteor/mongo'; +import { WebApp } from 'meteor/webapp'; +import passport from 'passport'; + +import { configureOAuthServices } from '../lib/oauth/configureOAuthServices'; +import { createOAuthServiceConfig } from '../lib/oauth/createOAuthServiceConfig'; +import { getOAuthServices } from '../lib/oauth/getOAuthServices'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +const oAuthPaths = ['/oauth', '/_oauth']; + +const { Router: router } = express; + +export const oAuthRouter = router(); + +const oAuthApp = express(); +oAuthApp.set('trust proxy', 1); + +export const configurePassport = (settings: ICachedSettings) => { + const { client } = MongoInternals.defaultRemoteCollectionDriver().mongo; + + oAuthApp.use( + oAuthPaths, + session({ + name: 'oauth', + secret: settings.get('Accounts_OAuth_Session_Secret'), + resave: false, + saveUninitialized: false, + proxy: true, + store: MongoStore.create({ + client, + collectionName: 'rocketchat_oauth_sessions', + ttl: 5 * 60, + autoRemove: 'native', + }), + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 5 * 60 * 1000, // 5 minutes + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + }, + }), + ); + + oAuthApp.use(oAuthPaths, passport.initialize()); + oAuthApp.use(oAuthPaths, passport.session()); + oAuthApp.use(oAuthPaths, bodyParser.urlencoded({ extended: true })); + + const createOAuthRateLimiter = () => + rateLimit({ + windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), + skip: () => + process.env.TEST_MODE === 'true' || + process.env.TEST_MODE === 'api' || + settings.get('API_Enable_Rate_Limiter') !== true || + (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), + handler: (_req, res) => { + res.status(429).json({ + success: false, + error: 'Too many requests. Please try again later.', + }); + }, + }); + + let oauthRateLimiter = createOAuthRateLimiter(); + + settings.watchMultiple(['API_Enable_Rate_Limiter_Limit_Time_Default', 'API_Enable_Rate_Limiter_Limit_Calls_Default'], () => { + oauthRateLimiter = createOAuthRateLimiter(); + }); + + oAuthRouter.use(oAuthPaths, (req, res, next) => oauthRateLimiter(req, res, next)); + + // Register OAuth Routes + oAuthApp.use(oAuthRouter); + + passport.serializeUser((user: any, done) => { + done(null, user._id); + }); + + passport.deserializeUser(async (id, done) => { + const user = await Users.findOneById(id as string, { projection: { __rooms: 0 } }); + // we don’t actually use this user later + done(null, user); + }); + + settings.watchByRegex(/^(Accounts_OAuth_[a-z0-9_]+|API_GitHub_Enterprise_URL)$/i, () => { + if (!settings.get('Accounts_OAuth_Use_Modern_Flow')) { + return; + } + + const services = getOAuthServices(settings); + const oauthServiceConfigs = createOAuthServiceConfig(settings, services); + configureOAuthServices(oauthServiceConfigs, settings); + }); + + WebApp.rawConnectHandlers.use(oAuthApp); +}; diff --git a/apps/meteor/server/configuration/configureSMTP.ts b/apps/meteor/server/configuration/configureSMTP.ts new file mode 100644 index 0000000..dc34085 --- /dev/null +++ b/apps/meteor/server/configuration/configureSMTP.ts @@ -0,0 +1,34 @@ +import { SystemLogger } from '../lib/logger/system'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export function configureSMTP(settings: ICachedSettings): void { + settings.watchMultiple( + ['SMTP_Host', 'SMTP_Port', 'SMTP_Username', 'SMTP_Password', 'SMTP_Protocol', 'SMTP_Pool', 'SMTP_IgnoreTLS'], + () => { + SystemLogger.info('Updating process.env.MAIL_URL'); + + if (!settings.get('SMTP_Host')) { + delete process.env.MAIL_URL; + return; + } + + process.env.MAIL_URL = `${settings.get('SMTP_Protocol')}://`; + + if (settings.get('SMTP_Username') && settings.get('SMTP_Password')) { + process.env.MAIL_URL += `${encodeURIComponent(settings.get('SMTP_Username'))}:${encodeURIComponent(settings.get('SMTP_Password'))}@`; + } + + process.env.MAIL_URL += encodeURIComponent(settings.get('SMTP_Host')); + + if (settings.get('SMTP_Port')) { + process.env.MAIL_URL += `:${parseInt(settings.get('SMTP_Port'))}`; + } + + process.env.MAIL_URL += `?pool=${settings.get('SMTP_Pool')}`; + + if (settings.get('SMTP_Protocol') === 'smtp' && settings.get('SMTP_IgnoreTLS')) { + process.env.MAIL_URL += '&secure=false&ignoreTLS=true'; + } + }, + ); +} diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts new file mode 100644 index 0000000..dfecb7b --- /dev/null +++ b/apps/meteor/server/configuration/index.ts @@ -0,0 +1,34 @@ +import { configureAccounts } from './accounts_meld'; +import { configureCAS } from './cas'; +import { configureAssets } from './configureAssets'; +import { configureBoilerplate } from './configureBoilerplate'; +import { configureCDN } from './configureCDN'; +import { configureCORS } from './configureCORS'; +import { configureDirectReply } from './configureDirectReply'; +import { configureIRC } from './configureIRC'; +import { configureLogLevel } from './configureLogLevel'; +import { configurePassport } from './configurePassport'; +import { configureSMTP } from './configureSMTP'; +import { configureLDAP } from './ldap'; +import { configureOAuth } from './oauth'; +import { configurePushNotifications } from './pushNotification'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureServer(settings: ICachedSettings) { + await Promise.all([ + configureLogLevel(settings), + configureAccounts(), + configureCAS(settings), + configureLDAP(settings), + configureOAuth(settings), + configureAssets(settings), + configureCORS(settings), + configureCDN(settings), + configurePushNotifications(settings), + configureBoilerplate(settings), + configureDirectReply(settings), + configureSMTP(settings), + configureIRC(settings), + configurePassport(settings), + ]); +} diff --git a/apps/meteor/server/configuration/ldap.ts b/apps/meteor/server/configuration/ldap.ts new file mode 100644 index 0000000..d6f47e2 --- /dev/null +++ b/apps/meteor/server/configuration/ldap.ts @@ -0,0 +1,55 @@ +import { LDAP } from '@rocket.chat/core-services'; +import { Accounts } from 'meteor/accounts-base'; + +import { callbacks } from '../lib/callbacks'; +import { warnUnlicensedAuthService } from '../lib/premiumAuthDeprecation'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureLDAP(settings: ICachedSettings): Promise { + // Register ldap login handler + Accounts.registerLoginHandler('ldap', async (loginRequest: Record) => { + if (!loginRequest.ldap || !loginRequest.ldapOptions) { + return undefined; + } + + warnUnlicensedAuthService('LDAP', 'ldap-enterprise'); + + return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass); + }); + + // Prevent password logins by LDAP users when LDAP is enabled + let ldapEnabled: boolean; + settings.watch('LDAP_Enable', (value) => { + if (ldapEnabled === value) { + return; + } + ldapEnabled = value as boolean; + + if (!value) { + return callbacks.remove('beforeValidateLogin', 'validateLdapLoginFallback'); + } + + callbacks.add( + 'beforeValidateLogin', + (login: Record) => { + if (!login.allowed) { + return login; + } + + // The fallback setting should only block password logins, so users that have other login services can continue using them + if (login.type !== 'password') { + return login; + } + + // LDAP users can still login locally when login fallback is enabled + if (login.user.services?.ldap?.id) { + login.allowed = settings.get('LDAP_Login_Fallback') ?? false; + } + + return login; + }, + callbacks.priority.MEDIUM, + 'validateLdapLoginFallback', + ); + }); +} diff --git a/apps/meteor/server/configuration/oauth.ts b/apps/meteor/server/configuration/oauth.ts new file mode 100644 index 0000000..5b23fd8 --- /dev/null +++ b/apps/meteor/server/configuration/oauth.ts @@ -0,0 +1,21 @@ +import debounce from 'lodash.debounce'; + +import { initCustomOAuthServices } from '../lib/oauth/initCustomOAuthServices'; +import { removeOAuthService } from '../lib/oauth/removeOAuthService'; +import { updateOAuthServices } from '../lib/oauth/updateOAuthServices'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configureOAuth(settings: ICachedSettings): Promise { + const _updateOAuthServices = debounce(updateOAuthServices, 2000); + settings.watchByRegex(/^Accounts_OAuth_.+/, () => { + return _updateOAuthServices(); + }); + + settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { + if (!value) { + return removeOAuthService(key); + } + }); + + await initCustomOAuthServices(); +} diff --git a/apps/meteor/server/configuration/pushNotification.ts b/apps/meteor/server/configuration/pushNotification.ts new file mode 100644 index 0000000..783a9e1 --- /dev/null +++ b/apps/meteor/server/configuration/pushNotification.ts @@ -0,0 +1,73 @@ +import { getWorkspaceAccessToken } from '../lib/cloud'; +import { Push } from '../lib/notifications/push'; +import type { ICachedSettings } from '../settings/CachedSettings'; + +export async function configurePushNotifications(settings: ICachedSettings): Promise { + settings.watch('Push_enable', async (enabled) => { + if (!enabled) { + await Push.unconfigure(); + return; + } + const gateways = + settings.get('Push_enable_gateway') && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms') + ? settings.get('Push_gateway').split('\n') + : undefined; + + let apn: + | { + passphrase: string; + key: string; + cert: string; + gateway?: string; + } + | undefined; + let gcm: + | { + apiKey: string; + projectNumber: string; + } + | undefined; + + // TODO: this part of the code should be refactored as the deprecated GCM methods are no longer being used and FCM is preferred. + if (!gateways) { + gcm = { + apiKey: 'TO_BE_REFACTORED', + projectNumber: 'TO_BE_REFACTORED', + }; + + apn = { + passphrase: settings.get('Push_apn_passphrase'), + key: settings.get('Push_apn_key'), + cert: settings.get('Push_apn_cert'), + }; + + if (settings.get('Push_production') !== true) { + apn = { + passphrase: settings.get('Push_apn_dev_passphrase'), + key: settings.get('Push_apn_dev_key'), + cert: settings.get('Push_apn_dev_cert'), + gateway: 'gateway.sandbox.push.apple.com', + }; + } + + if (!apn.key || apn.key.trim() === '' || !apn.cert || apn.cert.trim() === '') { + apn = undefined; + } + + if (!gcm.apiKey || gcm.apiKey.trim() === '' || !gcm.projectNumber || gcm.projectNumber.trim() === '') { + gcm = undefined; + } + } + + Push.configure({ + apn, + gcm, + production: settings.get('Push_production'), + gateways, + uniqueId: settings.get('uniqueID'), + async getAuthorization() { + return `Bearer ${await getWorkspaceAccessToken()}`; + }, + }); + }); +} diff --git a/apps/meteor/server/cron/nps.ts b/apps/meteor/server/cron/nps.ts new file mode 100644 index 0000000..8d4e2ee --- /dev/null +++ b/apps/meteor/server/cron/nps.ts @@ -0,0 +1,18 @@ +import { NPS } from '@rocket.chat/core-services'; +import { cronJobs } from '@rocket.chat/cron'; + +import { settings } from '../settings'; + +async function runNPS(): Promise { + // if NPS is disabled close any pending scheduled survey + const enabled = settings.get('NPS_survey_enabled'); + if (!enabled) { + await NPS.closeOpenSurveys(); + return; + } + await NPS.sendResults(); +} + +export async function npsCron(): Promise { + return cronJobs.add('NPS', '21 15 * * *', async () => runNPS()); +} diff --git a/apps/meteor/server/cron/oembed.ts b/apps/meteor/server/cron/oembed.ts new file mode 100644 index 0000000..2319971 --- /dev/null +++ b/apps/meteor/server/cron/oembed.ts @@ -0,0 +1,7 @@ +import { cronJobs } from '@rocket.chat/cron'; + +import { executeClearOEmbedCache } from '../meteor-methods/platform/OEmbedCacheCleanup'; + +export async function oembedCron(): Promise { + return cronJobs.add('Cleanup OEmbed cache', '24 2 * * *', async () => executeClearOEmbedCache()); +} diff --git a/apps/meteor/server/cron/start.ts b/apps/meteor/server/cron/start.ts new file mode 100644 index 0000000..783cb16 --- /dev/null +++ b/apps/meteor/server/cron/start.ts @@ -0,0 +1,6 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { MongoInternals } from 'meteor/mongo'; + +export const startCron = async () => { + return cronJobs.start((MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db()); +}; diff --git a/apps/meteor/server/cron/temporaryUploadsCleanup.ts b/apps/meteor/server/cron/temporaryUploadsCleanup.ts new file mode 100644 index 0000000..c4577f0 --- /dev/null +++ b/apps/meteor/server/cron/temporaryUploadsCleanup.ts @@ -0,0 +1,16 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { Uploads } from '@rocket.chat/models'; + +import { FileUpload } from '../lib/media/file-upload'; + +async function temporaryUploadCleanup(): Promise { + const files = await Uploads.findExpiredTemporaryFiles({ projection: { _id: 1 } }).toArray(); + + for await (const file of files) { + await FileUpload.getStore('Uploads').deleteById(file._id); + } +} + +export async function temporaryUploadCleanupCron(): Promise { + return cronJobs.add('temporaryUploadCleanup', '31 * * * *', async () => temporaryUploadCleanup()); +} diff --git a/apps/meteor/server/cron/usageReport.spec.ts b/apps/meteor/server/cron/usageReport.spec.ts new file mode 100644 index 0000000..21aba74 --- /dev/null +++ b/apps/meteor/server/cron/usageReport.spec.ts @@ -0,0 +1,53 @@ +import { AirGappedRestriction } from '@rocket.chat/license'; +import { Statistics } from '@rocket.chat/models'; + +import { sendUsageReportAndComputeRestriction } from './usageReport'; + +jest.mock('@rocket.chat/license', () => ({ + AirGappedRestriction: { + computeRestriction: jest.fn(), + }, +})); + +jest.mock('@rocket.chat/models', () => ({ + Statistics: { + findLastStatsToken: jest.fn(), + }, +})); + +jest.mock('../lib/statistics/functions/sendUsageReport', () => ({ + sendUsageReport: () => undefined, +})); + +describe('sendUsageReportAndComputeRestriction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should pass statsToken to computeRestriction when provided', async () => { + const mockStatsToken = 'test-token'; + await sendUsageReportAndComputeRestriction(mockStatsToken); + + expect(AirGappedRestriction.computeRestriction).toHaveBeenCalledWith(mockStatsToken); + expect(Statistics.findLastStatsToken).not.toHaveBeenCalled(); + }); + + it('should use findLastStatsToken result when statsToken is omitted', async () => { + const mockLastToken = 'last-token'; + (Statistics.findLastStatsToken as jest.Mock).mockResolvedValue(mockLastToken); + + await sendUsageReportAndComputeRestriction(); + + expect(Statistics.findLastStatsToken).toHaveBeenCalled(); + expect(AirGappedRestriction.computeRestriction).toHaveBeenCalledWith(mockLastToken); + }); + + it('should pass undefined to computeRestriction when both statsToken is omitted and findLastStatsToken returns undefined', async () => { + (Statistics.findLastStatsToken as jest.Mock).mockResolvedValue(undefined); + + await sendUsageReportAndComputeRestriction(); + + expect(Statistics.findLastStatsToken).toHaveBeenCalled(); + expect(AirGappedRestriction.computeRestriction).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/apps/meteor/server/cron/usageReport.ts b/apps/meteor/server/cron/usageReport.ts new file mode 100644 index 0000000..22ad61f --- /dev/null +++ b/apps/meteor/server/cron/usageReport.ts @@ -0,0 +1,37 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { AirGappedRestriction } from '@rocket.chat/license'; +import type { Logger } from '@rocket.chat/logger'; +import { Statistics } from '@rocket.chat/models'; + +import { sendUsageReport } from '../lib/statistics/functions/sendUsageReport'; + +export const sendUsageReportAndComputeRestriction = async (statsToken?: string) => { + // If the report failed to be sent we need to get the last existing token + // to ensure that the restriction respects the warning period. + // If no token is passed, the workspace will be instantly restricted. + const token = statsToken || (await Statistics.findLastStatsToken()); + void AirGappedRestriction.computeRestriction(token); +}; + +export const shouldReportStatistics = () => process.env.RC_DISABLE_STATISTICS_REPORTING?.toLowerCase() !== 'true'; + +export async function usageReportCron(logger: Logger): Promise { + // The actual send suppression happens inside `sendUsageReport`, but since this + // is the entry point, we log a warning here when reporting is disabled. + if (!shouldReportStatistics()) { + logger.warn( + 'Statistics reporting disabled via environment variable (RC_DISABLE_STATISTICS_REPORTING). This may impact product improvements.', + ); + } + + const statsToken = await sendUsageReport(logger); + await sendUsageReportAndComputeRestriction(statsToken); + + const name = 'Generate and save statistics'; + const now = new Date(); + + return cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => { + const statsToken = await sendUsageReport(logger); + await sendUsageReportAndComputeRestriction(statsToken); + }); +} diff --git a/apps/meteor/server/cron/userDataDownloads.ts b/apps/meteor/server/cron/userDataDownloads.ts new file mode 100644 index 0000000..2ec411b --- /dev/null +++ b/apps/meteor/server/cron/userDataDownloads.ts @@ -0,0 +1,40 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; + +import * as dataExport from '../lib/dataExport'; +import { settings } from '../settings'; + +export const userDataDownloadsCron = (): void => { + const jobName = 'UserDataDownload'; + + const plug = async ({ + disabled, + processingFrequency, + }: { + disabled: boolean; + processingFrequency: number; + }): Promise<(() => Promise) | undefined> => { + if (disabled) { + return; + } + + await cronJobs.add(jobName, `*/${processingFrequency} * * * *`, async () => dataExport.processDataDownloads()); + + return async () => { + await cronJobs.remove(jobName); + }; + }; + + let unplug: (() => void) | undefined; + + settings.watchMultiple( + ['Troubleshoot_Disable_Data_Exporter_Processor', 'UserData_ProcessingFrequency'], + async ([disabled, processingFrequency]: SettingValue[]): Promise => { + await unplug?.(); + unplug = await plug({ + disabled: disabled === true, + processingFrequency: typeof processingFrequency === 'number' && processingFrequency > 0 ? processingFrequency : 2, + }); + }, + ); +}; diff --git a/apps/meteor/server/cron/videoConferences.ts b/apps/meteor/server/cron/videoConferences.ts new file mode 100644 index 0000000..5af8263 --- /dev/null +++ b/apps/meteor/server/cron/videoConferences.ts @@ -0,0 +1,26 @@ +import { VideoConf } from '@rocket.chat/core-services'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; + +// 24 hours +const VIDEO_CONFERENCE_TTL = 24 * 60 * 60 * 1000; + +async function runVideoConferences(): Promise { + const minimum = new Date(new Date().valueOf() - VIDEO_CONFERENCE_TTL); + + const calls = await ( + await VideoConferenceModel.findAllLongRunning(minimum) + ) + .map(({ _id: callId }: Pick) => callId) + .toArray(); + + await Promise.all(calls.map((callId) => VideoConf.setStatus(callId, VideoConferenceStatus.EXPIRED))); +} + +export async function videoConferencesCron(): Promise { + void runVideoConferences(); + + return cronJobs.add('VideoConferences', '0 */3 * * *', async () => runVideoConferences()); +} diff --git a/apps/meteor/server/database/readSecondaryPreferred.ts b/apps/meteor/server/database/readSecondaryPreferred.ts new file mode 100644 index 0000000..4bf7e5a --- /dev/null +++ b/apps/meteor/server/database/readSecondaryPreferred.ts @@ -0,0 +1,18 @@ +import type { Db, ReadPreferenceLike } from 'mongodb'; +import { ReadPreference } from 'mongodb'; + +export function readSecondaryPreferred(db?: Db, tags: any[] = []): ReadPreferenceLike { + const { readPreference } = db?.options || {}; + + if (tags.length) { + return new ReadPreference(ReadPreference.SECONDARY_PREFERRED, tags); + } + if (readPreference && readPreference instanceof ReadPreference && readPreference.tags?.length) { + return new ReadPreference(ReadPreference.SECONDARY_PREFERRED, readPreference.tags); + } + + // For some reason the new ReadPreference definition requires the tags parameter even not been + // required by the code implementation and, for some reason, when passing an empty array it + // doesn't ignore the tags and stop using the secondaries. + return ReadPreference.SECONDARY_PREFERRED; +} diff --git a/apps/meteor/server/database/trash.ts b/apps/meteor/server/database/trash.ts new file mode 100644 index 0000000..be4caac --- /dev/null +++ b/apps/meteor/server/database/trash.ts @@ -0,0 +1,6 @@ +import { TrashRaw } from '@rocket.chat/models'; + +import { db } from './utils'; + +const Trash = new TrashRaw(db); +export const trashCollection = Trash.col; diff --git a/apps/meteor/server/database/utils.ts b/apps/meteor/server/database/utils.ts new file mode 100644 index 0000000..e4b9508 --- /dev/null +++ b/apps/meteor/server/database/utils.ts @@ -0,0 +1,90 @@ +import type { OffCallbackHandler } from '@rocket.chat/emitter'; +import { Emitter } from '@rocket.chat/emitter'; +import { MongoInternals } from 'meteor/mongo'; +import type { ClientSession, MongoError } from 'mongodb'; + +import { SystemLogger } from '../lib/logger/system'; + +export const { db, client } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +/** + * In MongoDB, errors like UnknownTransactionCommitResult and TransientTransactionError occur primarily in the context of distributed transactions + * and are often due to temporary network issues, server failures, or timeouts. Here’s what each error means and some common causes: + * + * 1. UnknownTransactionCommitResult: The client does not know if the transaction was committed successfully or not. + * This error can occur when there’s a network disruption between the client and the MongoDB server during the transaction commit, + * or when the primary node (which handles transaction commits) is unavailable, possibly due to a primary election or failover in a replica set. + * + * 2. TransientTransactionError: A temporary issue disrupts a transaction before it completes. + * + * Since these errors are transient, they may resolve on their own when retried after a short interval. + */ +export const shouldRetryTransaction = (e: unknown): boolean => + (e as MongoError)?.errorLabels?.includes('UnknownTransactionCommitResult') || + (e as MongoError)?.errorLabels?.includes('TransientTransactionError'); + +const isExtendedSession = (session: any): session is ExtendedSession => { + return 'onceSuccesfulCommit' in session; +}; +export const onceTransactionCommitedSuccessfully = async (cb: () => Promise | void, session?: T) => { + if (!session) { + await cb(); + return; + } + if (session?.inTransaction() && isExtendedSession(session)) { + const withError = async () => { + try { + await cb(); + } catch (error) { + SystemLogger.error({ err: error }); + } + }; + + session.onceSuccesfulCommit(() => { + void withError(); + }); + } +}; + +type ExtendedSession = ClientSession & { + onceSuccesfulCommit: (cb: (session: ClientSession) => void) => OffCallbackHandler; +}; + +const getExtendedSession = (session: ClientSession, onceSuccesfulCommit: ExtendedSession['onceSuccesfulCommit']): ExtendedSession => { + return Object.assign(session, { onceSuccesfulCommit }); +}; + +class UnsuccessfulTransactionError extends Error { + override name = UnsuccessfulTransactionError.name; + + constructor(message?: string) { + super(message || 'Something went wrong while trying to commit changes. Please try again.'); + } +} + +export const wrapInSessionTransaction = + , U>(curriedCallback: (session: ClientSession) => (...args: T) => U) => + async (...args: T): Promise> => { + const ee = new Emitter<{ success: ClientSession }>(); + + const extendedSession = getExtendedSession(client.startSession(), (cb) => ee.once('success', cb)); + + const dispatch = (session: ClientSession) => ee.emit('success', session); + try { + extendedSession.startTransaction(); + extendedSession.once('ended', dispatch); + + const result = await curriedCallback(extendedSession).apply(this, args); + await extendedSession.commitTransaction(); + return result; + } catch (error) { + await extendedSession.abortTransaction(); + extendedSession.removeListener('ended', dispatch); + if (shouldRetryTransaction(error)) { + throw new UnsuccessfulTransactionError(''); + } + throw error; + } finally { + await extendedSession.endSession(); + } + }; diff --git a/apps/meteor/server/deasync/deasync.ts b/apps/meteor/server/deasync/deasync.ts new file mode 100644 index 0000000..b794d32 --- /dev/null +++ b/apps/meteor/server/deasync/deasync.ts @@ -0,0 +1,3 @@ +import { awaitSync as deasyncPromise, deasync } from '@kaciras/deasync'; + +export { deasyncPromise, deasync }; diff --git a/apps/meteor/server/email/IMAPInterceptor.ts b/apps/meteor/server/email/IMAPInterceptor.ts new file mode 100644 index 0000000..1133ad3 --- /dev/null +++ b/apps/meteor/server/email/IMAPInterceptor.ts @@ -0,0 +1,243 @@ +import { EventEmitter } from 'node:events'; +import { Readable } from 'node:stream'; + +import { EmailInbox } from '@rocket.chat/models'; +import type { ImapMessage, ImapMessageBodyInfo } from 'imap'; +import IMAP from 'imap'; +import type { ParsedMail } from 'mailparser'; +import { simpleParser } from 'mailparser'; + +import { logger } from '../features/EmailInbox/logger'; +import { notifyOnEmailInboxChanged } from '../lib/notifyListener'; + +type IMAPOptions = { + deleteAfterRead: boolean; + filter: any[]; + rejectBeforeTS?: Date; + markSeen: boolean; + maxRetries: number; +}; + +export declare interface IMAPInterceptor { + on(event: 'email', listener: (email: ParsedMail) => void): this; +} + +export class IMAPInterceptor extends EventEmitter { + private imap: IMAP; + + private config: IMAP.Config; + + private backoffDurationMS = 3000; + + private backoff: NodeJS.Timeout; + + private retries = 0; + + private inboxId: string; + + constructor( + imapConfig: IMAP.Config, + private options: IMAPOptions = { + deleteAfterRead: false, + filter: ['UNSEEN'], + markSeen: true, + maxRetries: 10, + }, + id: string, + ) { + super(); + + this.config = imapConfig; + + this.imap = new IMAP({ + connTimeout: 10000, + keepalive: true, + ...(imapConfig.tls && { tlsOptions: { servername: imapConfig.host } }), + ...imapConfig, + }); + this.retries = 0; + this.inboxId = id; + this.imap.on('error', async (err: Error) => { + logger.error({ msg: 'IMAP error', err }); + }); + void this.start(); + } + + openInbox(): Promise { + return new Promise((resolve, reject) => { + const cb = (err: Error, mailbox: IMAP.Box) => { + if (err) { + reject(err); + } else { + resolve(mailbox); + } + }; + this.imap.openBox('INBOX', false, cb); + }); + } + + async start(): Promise { + // On successfully connected. + this.imap.on('ready', async () => { + if (this.isActive()) { + logger.info({ msg: 'IMAP connected', user: this.config.user }); + clearTimeout(this.backoff); + this.retries = 0; + this.backoffDurationMS = 3000; + await this.openInbox(); + this.imap.on('mail', () => this.getEmails().catch((err: Error) => logger.debug({ msg: 'Error on getEmails', err }))); + } else { + logger.error("Can't connect to IMAP server"); + } + }); + + this.imap.on('error', async () => { + this.retries++; + await this.reconnect(); + }); + + this.imap.on('close', async () => { + await this.reconnect(); + }); + this.retries += 1; + return this.imap.connect(); + } + + isActive(): boolean { + return !!(this.imap?.state && this.imap.state !== 'disconnected'); + } + + stop(callback = new Function()): void { + if (this.backoff) { + clearTimeout(this.backoff); + this.backoffDurationMS = 3000; + } + this.stopWithNoStopBackoff(callback); + } + + private stopWithNoStopBackoff(callback = new Function()): void { + logger.debug('IMAP stop called'); + this.imap.removeAllListeners(); + this.imap.once('end', () => { + logger.debug('IMAP stopped'); + callback?.(); + }); + this.imap.end(); + this.imap.on('error', async (err: Error) => { + logger.error({ msg: 'IMAP error', err }); + }); + } + + async reconnect(): Promise { + if (!this.isActive() && !this.canRetry()) { + logger.info({ msg: 'Max retries reached', user: this.config.user }); + this.stop(); + return this.selfDisable(); + } + + if (this.backoff) { + clearTimeout(this.backoff); + this.backoffDurationMS = 3000; + } + + this.backoff = setTimeout( + () => { + this.stopWithNoStopBackoff(); + void this.start(); + }, + (this.backoffDurationMS += this.backoffDurationMS), + ); + } + + imapSearch(): Promise { + return new Promise((resolve, reject) => { + const cb = (err: Error, results: number[]) => { + if (err) { + reject(err); + } else { + resolve(results); + } + }; + this.imap.search(this.options.filter, cb); + }); + } + + parseEmails(stream: NodeJS.ReadableStream, _info: ImapMessageBodyInfo): Promise { + return new Promise((resolve, reject) => { + const cb = (err: Error, mail: ParsedMail) => { + if (err) { + reject(err); + } else { + resolve(mail); + } + }; + simpleParser(new Readable().wrap(stream), cb); + }); + } + + imapFetch(emailIds: number[]): Promise { + return new Promise((resolve, reject) => { + const out: number[] = []; + const messagecb = (msg: ImapMessage, seqno: number) => { + out.push(seqno); + const bodycb = (stream: NodeJS.ReadableStream, _info: ImapMessageBodyInfo): void => { + simpleParser(new Readable().wrap(stream), (_err, email) => { + if (this.options.rejectBeforeTS && email.date && email.date < this.options.rejectBeforeTS) { + logger.error({ msg: 'Rejecting email on inbox', user: this.config.user, subject: email.subject }); + return; + } + this.emit('email', email); + if (this.options.deleteAfterRead) { + this.imap.seq.addFlags(email, 'Deleted', (err) => { + if (err) { + logger.warn({ msg: 'Mark deleted error', err }); + } + }); + } + }); + }; + msg.once('body', bodycb); + }; + const errorcb = (err: Error): void => { + logger.warn({ msg: 'Fetch error', err }); + reject(err); + }; + const endcb = (): void => { + resolve(out); + }; + const fetch = this.imap.fetch(emailIds, { + bodies: ['HEADER', 'TEXT', ''], + struct: true, + markSeen: this.options.markSeen, + }); + + fetch.on('message', messagecb); + fetch.on('error', errorcb); + fetch.on('end', endcb); + }); + } + + // Fetch all UNSEEN messages and pass them for further processing + async getEmails(): Promise { + const emailIds = await this.imapSearch(); + await this.imapFetch(emailIds); + } + + canRetry(): boolean { + return this.retries < this.options.maxRetries || this.options.maxRetries === -1; + } + + async selfDisable(): Promise { + logger.info({ msg: 'Disabling inbox', inboxId: this.inboxId }); + + // Again, if there's 2 inboxes with the same email, this will prevent looping over the already disabled one + // Active filter is just in case :) + const value = await EmailInbox.setDisabledById(this.inboxId); + + if (value) { + void notifyOnEmailInboxChanged(value, 'updated'); + } + + logger.info({ msg: 'IMAP inbox automatically disabled', inboxId: this.inboxId }); + } +} diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox.ts b/apps/meteor/server/features/EmailInbox/EmailInbox.ts new file mode 100644 index 0000000..e17fc78 --- /dev/null +++ b/apps/meteor/server/features/EmailInbox/EmailInbox.ts @@ -0,0 +1,98 @@ +import type { IEmailInbox } from '@rocket.chat/core-typings'; +import { EmailInbox, EmailMessageHistory } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; + +import { onEmailReceived } from './EmailInbox_Incoming'; +import { logger } from './logger'; +import { IMAPInterceptor } from '../../email/IMAPInterceptor'; +import { settings } from '../../settings'; + +export type Inbox = { + imap: IMAPInterceptor; + smtp: Mail; + config: IEmailInbox; +}; + +export const inboxes = new Map(); + +export async function configureEmailInboxes(): Promise { + const emailInboxesCursor = EmailInbox.findActive(); + + logger.info('Clearing old email inbox registrations'); + for (const { imap } of inboxes.values()) { + imap.stop(); + } + + inboxes.clear(); + + for await (const emailInboxRecord of emailInboxesCursor) { + try { + logger.info({ msg: 'Setting up email interceptor', email: emailInboxRecord.email }); + + const imap = new IMAPInterceptor( + { + password: emailInboxRecord.imap.password, + user: emailInboxRecord.imap.username, + host: emailInboxRecord.imap.server, + port: emailInboxRecord.imap.port, + ...(emailInboxRecord.imap.secure + ? { + tls: emailInboxRecord.imap.secure, + tlsOptions: { + rejectUnauthorized: false, + }, + } + : {}), + }, + { + deleteAfterRead: false, + filter: [['UNSEEN'], ['SINCE', emailInboxRecord._createdAt]], + rejectBeforeTS: emailInboxRecord._createdAt, + markSeen: true, + maxRetries: emailInboxRecord.imap.maxRetries, + }, + emailInboxRecord._id, + ); + + imap.on('email', async (email) => { + if (!email.messageId) { + return; + } + + try { + await EmailMessageHistory.create({ _id: email.messageId, email: emailInboxRecord.email }); + void onEmailReceived(email, emailInboxRecord.email, emailInboxRecord.department); + } catch (err: any) { + // In case the email message history has been received by other instance.. + logger.error({ err }); + } + }); + + await imap.start(); + + const smtp = nodemailer.createTransport({ + host: emailInboxRecord.smtp.server, + port: emailInboxRecord.smtp.port, + secure: emailInboxRecord.smtp.secure, + auth: { + user: emailInboxRecord.smtp.username, + pass: emailInboxRecord.smtp.password, + }, + }); + + inboxes.set(emailInboxRecord.email, { imap, smtp, config: emailInboxRecord }); + } catch (err) { + logger.error({ msg: 'Error setting up email interceptor', email: emailInboxRecord.email, err }); + } + } + + logger.info({ msg: 'Configured a total of inboxes', count: inboxes.size }); +} + +Meteor.startup(() => { + settings.watchOnce('Livechat_Routing_Method', (_) => { + void configureEmailInboxes(); + }); +}); diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts new file mode 100644 index 0000000..231a6db --- /dev/null +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -0,0 +1,254 @@ +import type { + ILivechatVisitor, + IOmnichannelRoom, + VideoAttachmentProps, + ImageAttachmentProps, + AudioAttachmentProps, +} from '@rocket.chat/core-typings'; +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { Random } from '@rocket.chat/random'; +import type { ParsedMail, Attachment } from 'mailparser'; +import { stripHtml } from 'string-strip-html'; + +import { logger } from './logger'; +import { i18n } from '../../lib/i18n'; +import { FileUpload } from '../../lib/media/file-upload'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { QueueManager } from '../../lib/omnichannel/QueueManager'; +import { setDepartmentForGuest } from '../../lib/omnichannel/departmentsLib'; +import { sendMessage } from '../../lib/omnichannel/messages'; +import { settings } from '../../settings'; + +type FileAttachment = VideoAttachmentProps & ImageAttachmentProps & AudioAttachmentProps; + +const language = settings.get('Language') || 'en'; +const t = i18n.getFixedT(language); + +async function getGuestByEmail(email: string, name: string, department = ''): Promise { + const guest = await LivechatVisitors.findOneGuestByEmailAddress(email); + + if (guest) { + if (guest.department !== department) { + if (!department) { + await LivechatVisitors.removeDepartmentById(guest._id); + delete guest.department; + return guest; + } + return setDepartmentForGuest({ visitorId: guest._id, department }); + } + return guest; + } + + const livechatVisitor = await registerGuest( + { + token: Random.id(), + name: name || email, + email, + department, + }, + { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }, + ); + + if (!livechatVisitor) { + throw new Error('Error getting guest'); + } + + return livechatVisitor; +} + +async function uploadAttachment(attachmentParam: Attachment, rid: string, visitorToken: string): Promise> { + const details = { + name: attachmentParam.filename, + size: attachmentParam.size, + type: attachmentParam.contentType, + rid, + visitorToken, + }; + + const fileStore = FileUpload.getStore('Uploads'); + + const file = await fileStore.insert(details, attachmentParam.content); + + const url = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); + + const attachment: Partial = { + title: file.name || '', + title_link: url, + }; + + if (file.type && /^image\/.+/.test(file.type)) { + attachment.image_url = url; + attachment.image_type = file.type; + attachment.image_size = file.size; + attachment.image_dimensions = file.identify?.size != null ? file.identify.size : undefined; + } + + if (file.type && /^audio\/.+/.test(file.type)) { + attachment.audio_url = url; + attachment.audio_type = file.type; + attachment.audio_size = file.size; + } + + if (file.type && /^video\/.+/.test(file.type)) { + attachment.video_url = url; + attachment.video_type = file.type; + attachment.video_size = file.size; + } + + return attachment; +} + +export async function onEmailReceived(email: ParsedMail, inbox: string, department = ''): Promise { + logger.info({ msg: 'New email conversation received on inbox. Will be assigned to department', inbox, department }); + if (!email.from?.value?.[0]?.address) { + return; + } + + const references = typeof email.references === 'string' ? [email.references] : email.references; + const initialRef = [email.messageId, email.inReplyTo].filter(Boolean) as string[]; + const thread = (references?.length ? references : []).flatMap((t: string) => t.split(',')).concat(initialRef); + const guest = await getGuestByEmail(email.from.value[0].address, email.from.value[0].name, department); + + if (!guest) { + logger.error({ msg: 'No visitor found', address: email.from.value[0].address }); + return; + } + + let room: IOmnichannelRoom | null = await LivechatRooms.findOneByVisitorTokenAndEmailThreadAndDepartment( + guest.token, + thread, + department, + {}, + ); + + logger.debug({ + msg: 'Room found for guest', + room, + guest, + }); + + if (room?.closedAt) { + room = await QueueManager.unarchiveRoom(room); + } + + // TODO: html => md with turndown + const msg = email.html + ? stripHtml(email.html, { + dumpLinkHrefsNearby: { + enabled: true, + putOnNewLine: false, + wrapHeads: '(', + wrapTails: ')', + }, + skipHtmlDecoding: false, + }).result + : email.text || ''; + + const rid = room?._id ?? Random.id(); + const msgId = Random.id(); + + sendMessage({ + guest, + message: { + _id: msgId, + groupable: false, + msg, + token: guest.token, + attachments: [ + { + actions: [ + { + type: 'button', + text: t('Reply_via_Email'), + msg: 'msg', + msgId, + msg_in_chat_window: true, + msg_processing_type: 'respondWithQuotedMessage', + }, + ], + }, + ], + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `**${t('From')}:** ${email.from.text}\n**${t('Subject')}:** ${email.subject}`, + }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: msg, + }, + }, + ], + rid, + email: { + thread, + messageId: email.messageId, + }, + }, + roomInfo: { + email: { + inbox, + thread, + replyTo: email.from.value[0].address, + subject: email.subject, + }, + source: { + type: OmnichannelSourceType.EMAIL, + id: inbox, + alias: 'email-inbox', + }, + }, + agent: undefined, + }) + .then(async () => { + if (!email.attachments.length) { + return; + } + + const attachments = []; + for await (const attachment of email.attachments) { + if (attachment.type !== 'attachment') { + continue; + } + + try { + attachments.push(await uploadAttachment(attachment, rid, guest.token)); + } catch (err) { + logger.error({ msg: 'Error uploading attachment from email', err }); + } + } + + await Messages.updateOne( + { _id: msgId }, + { + $addToSet: { + attachments: { + $each: attachments, + }, + }, + }, + ); + room && (await LivechatRooms.updateEmailThreadByRoomId(room._id, thread)); + void notifyOnMessageChange({ + id: msgId, + }); + }) + .catch((err) => { + logger.error({ + msg: 'Error receiving email', + err, + }); + }); +} diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts new file mode 100644 index 0000000..31cfa0b --- /dev/null +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -0,0 +1,346 @@ +import { isIMessageInbox } from '@rocket.chat/core-typings'; +import type { IEmailInbox, IUser, IOmnichannelRoom, SlashCommandCallbackParams, IUpload } from '@rocket.chat/core-typings'; +import { Messages, Uploads, LivechatRooms, Rooms, Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import { Match } from 'meteor/check'; +import type Mail from 'nodemailer/lib/mailer'; + +import { inboxes } from './EmailInbox'; +import type { Inbox } from './EmailInbox'; +import { logger } from './logger'; +import { callbacks } from '../../lib/callbacks'; +import { i18n } from '../../lib/i18n'; +import { FileUpload } from '../../lib/media/file-upload'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { settings } from '../../settings'; + +const livechatQuoteRegExp = /^\[\s\]\(https?:\/\/.+\/live\/.+\?msg=(?.+?)\)\s(?.+)/s; + +const getRocketCatUser = async (): Promise => Users.findOneById('rocket.cat'); + +const language = settings.get('Language') || 'en'; +const t = i18n.getFixedT(language); + +async function buildMailAttachment(file: IUpload): Promise { + const buffer = await FileUpload.getBuffer(file); + if (!buffer) { + return; + } + return { + content: buffer, + contentType: file.type, + filename: file.name, + }; +} + +// TODO: change these messages with room notifications +const sendErrorReplyMessage = async (error: string, options: any) => { + if (!options?.rid || !options?.msgId) { + return; + } + + const message = { + groupable: false, + msg: `@${options.sender} something went wrong when replying email, sorry. **Error:**: ${error}`, + _id: String(Date.now()), + rid: options.rid, + ts: new Date(), + }; + + const user = await getRocketCatUser(); + if (!user) { + return; + } + + return sendMessage(user, message, { _id: options.rid }); +}; + +const sendSuccessReplyMessage = async (options: { room: IOmnichannelRoom; msgId: string; sender: string }) => { + if (!options?.room?._id || !options?.msgId) { + return; + } + const message = { + groupable: false, + msg: `@${options.sender} Attachment was sent successfully`, + _id: String(Date.now()), + rid: options.room._id, + ts: new Date(), + }; + + const user = await getRocketCatUser(); + if (!user) { + return; + } + + return sendMessage(user, message, options.room); +}; + +async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promise<{ messageId: string }> { + return inbox.smtp + .sendMail({ + from: inbox.config.senderInfo + ? { + name: inbox.config.senderInfo, + address: inbox.config.email, + } + : inbox.config.email, + ...mail, + }) + .then((info) => { + logger.info({ msg: 'Message sent', info }); + return info; + }) + .catch(async (err) => { + logger.error({ msg: 'Error sending Email reply', err }); + + if (!options?.msgId) { + return; + } + + await sendErrorReplyMessage(err.message, options); + }); +} + +slashCommands.add({ + command: 'sendEmailAttachment', + callback: async ({ command, params }: SlashCommandCallbackParams<'sendEmailAttachment'>) => { + if (command !== 'sendEmailAttachment' || !Match.test(params, String)) { + return; + } + + const message = await Messages.findOneById(params.trim()); + if (!message) { + return; + } + + const fileRefs = (message.files || [message.file]).filter(isTruthy); + if (!fileRefs.length) { + return; + } + + const room = await Rooms.findOneById(message.rid); + if (!room?.email) { + return; + } + + const inbox = inboxes.get(room.email.inbox); + + if (!inbox) { + return sendErrorReplyMessage(`Email inbox ${room.email.inbox} not found or disabled.`, { + msgId: message._id, + sender: message.u.username, + rid: room._id, + }); + } + + const files = await Uploads.find({ _id: { $in: fileRefs.map((f) => f._id) } }).toArray(); + const emailAttachments = await Promise.all(files.map(buildMailAttachment)); + const validAttachments = emailAttachments.filter((a): a is Mail.Attachment => Boolean(a)); + if (validAttachments.length === 0) { + return; + } + + const emailText = + message?.attachments + ?.map((a) => a.description) + .filter(Boolean) + .join('\n\n') || ''; + + void sendEmail( + inbox, + { + to: room.email?.replyTo, + subject: room.email?.subject, + text: emailText, + attachments: validAttachments, + inReplyTo: Array.isArray(room.email?.thread) ? room.email?.thread[0] : room.email?.thread, + references: ([] as string[]).concat(room.email?.thread || []), + }, + { + msgId: message._id, + sender: message.u.username, + rid: message.rid, + }, + ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId)); + + await Messages.updateOne( + { _id: message._id }, + { + $set: { + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `**${t('To')}:** ${room.email.replyTo}\n**${t('Subject')}:** ${room.email.subject}`, + }, + ], + }, + ], + }, + $pull: { + attachments: { 'actions.0.type': 'button' }, + }, + }, + ); + void notifyOnMessageChange({ + id: message._id, + }); + + return sendSuccessReplyMessage({ + msgId: message._id, + sender: message.u.username, + room, + }); + }, + options: { + description: 'Send attachment as email', + params: 'msg_id', + }, + providesPreview: false, +}); + +callbacks.add( + 'afterSaveMessage', + async (message, { room: omnichannelRoom }) => { + const room = omnichannelRoom as IOmnichannelRoom; + + if (!room?.email?.inbox) { + return message; + } + + const user = await getRocketCatUser(); + if (!user) { + return message; + } + + if (message.files?.length && message.u.username !== 'rocket.cat') { + await sendMessage( + user, + { + msg: '', + attachments: [ + { + actions: [ + { + type: 'button', + text: t('Send_via_Email_as_attachment'), + msg: `/sendEmailAttachment ${message._id}`, + msg_in_chat_window: true, + msg_processing_type: 'sendMessage', + }, + ], + }, + ], + }, + room, + { upsert: true }, + ); + return message; + } + + const { msg } = message; + + // Try to identify a quote in a livechat room + const match = msg.match(livechatQuoteRegExp); + if (!match?.groups) { + return message; + } + + const inbox = inboxes.get(room.email.inbox); + + if (!inbox) { + await sendErrorReplyMessage(`Email inbox ${room.email.inbox} not found or disabled.`, { + msgId: message._id, + sender: message.u.username, + rid: room._id, + }); + + return message; + } + + if (!inbox) { + return message; + } + + const replyToMessage = await Messages.findOneById(match.groups.id); + if (!replyToMessage || !isIMessageInbox(replyToMessage) || !replyToMessage.email?.messageId) { + return message; + } + + void sendEmail( + inbox, + { + text: match.groups.text, + inReplyTo: replyToMessage.email.messageId, + references: [...(replyToMessage.email.references ?? []), replyToMessage.email.messageId], + to: room.email.replyTo, + subject: room.email.subject, + }, + { + msgId: message._id, + sender: message.u.username, + rid: room._id, + }, + ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId)); + + message.msg = match.groups.text; + + message.groupable = false; + + message.blocks = [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `**${t('To')}:** ${room.email.replyTo}\n**${t('Subject')}:** ${room.email.subject}`, + }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: message.msg, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `> ---\n${replyToMessage.msg.replace(/^/gm, '> ')}`, + }, + }, + ]; + + delete message.urls; + + return message; + }, + callbacks.priority.LOW, + 'ReplyEmail', +); + +export async function sendTestEmailToInbox(emailInboxRecord: IEmailInbox, user: IUser): Promise { + const inbox = inboxes.get(emailInboxRecord.email); + + if (!inbox) { + throw new Error('inbox-not-found'); + } + + const address = user.emails?.find((email) => email.verified)?.address; + + if (!address) { + throw new Error('user-without-verified-email'); + } + + void sendEmail(inbox, { + to: address, + subject: 'Test of inbox configuration', + text: 'Test of inbox configuration successful', + }); +} diff --git a/apps/meteor/server/features/EmailInbox/index.ts b/apps/meteor/server/features/EmailInbox/index.ts new file mode 100644 index 0000000..dfe6fde --- /dev/null +++ b/apps/meteor/server/features/EmailInbox/index.ts @@ -0,0 +1,2 @@ +import './EmailInbox_Incoming'; +import './EmailInbox_Outgoing'; diff --git a/apps/meteor/server/features/EmailInbox/logger.ts b/apps/meteor/server/features/EmailInbox/logger.ts new file mode 100644 index 0000000..38352ab --- /dev/null +++ b/apps/meteor/server/features/EmailInbox/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('EmailInbox'); diff --git a/apps/meteor/server/hooks/afterUserActions.ts b/apps/meteor/server/hooks/afterUserActions.ts new file mode 100644 index 0000000..7e62970 --- /dev/null +++ b/apps/meteor/server/hooks/afterUserActions.ts @@ -0,0 +1,24 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; + +import { callbacks } from '../lib/callbacks'; +import { notifyOnSubscriptionChangedByUserId } from '../lib/notifyListener'; +import { unarchiveUserSubscriptions } from '../lib/users/unarchiveUserSubscriptions'; + +const handleDeactivateUser = async (user: IUser): Promise => { + const { modifiedCount } = await Subscriptions.setArchivedByUserId(user._id, true); + if (modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } +}; + +const handleActivateUser = async (user: IUser): Promise => { + const unarchived = await unarchiveUserSubscriptions(user._id); + if (unarchived) { + void notifyOnSubscriptionChangedByUserId(user._id); + } +}; + +callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'subscription-archive-on-deactivate'); + +callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'subscription-unarchive-on-activate'); diff --git a/apps/meteor/server/hooks/auth/login.ts b/apps/meteor/server/hooks/auth/login.ts new file mode 100644 index 0000000..6b9c0e4 --- /dev/null +++ b/apps/meteor/server/hooks/auth/login.ts @@ -0,0 +1,30 @@ +import { Accounts } from 'meteor/accounts-base'; + +import type { ILoginAttempt } from '../../lib/auth/ILoginAttempt'; +import { logFailedLoginAttempts } from '../../lib/auth/logLoginAttempts'; +import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../../lib/auth/restrictLoginAttempts'; +import { callbacks } from '../../lib/callbacks'; +import { settings } from '../../settings'; + +const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user']; + +Accounts.onLoginFailure(async (login: ILoginAttempt) => { + // do not save specific failed login attempts + if ( + settings.get('Block_Multiple_Failed_Logins_Enabled') && + login.error?.error && + !ignoredErrorTypes.includes(String(login.error.error)) + ) { + await saveFailedLoginAttempts(login); + } + + logFailedLoginAttempts(login); +}); + +callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { + if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { + return; + } + + return saveSuccessfulLogin(login); +}); diff --git a/apps/meteor/server/hooks/index.ts b/apps/meteor/server/hooks/index.ts new file mode 100644 index 0000000..1a9f550 --- /dev/null +++ b/apps/meteor/server/hooks/index.ts @@ -0,0 +1,2 @@ +import './sauMonitorHooks'; +import './userLogoutCleanUp'; diff --git a/apps/meteor/server/hooks/messages/afterSaveMessage.ts b/apps/meteor/server/hooks/messages/afterSaveMessage.ts new file mode 100644 index 0000000..35cd826 --- /dev/null +++ b/apps/meteor/server/hooks/messages/afterSaveMessage.ts @@ -0,0 +1,61 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Rooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import type { SendMessageOptions } from '../../lib/messages/sendMessage'; + +export async function afterSaveMessage( + message: IMessage, + room: IRoom, + user: IUser, + { + roomUpdater, + options, + }: { + roomUpdater?: Updater; + options?: SendMessageOptions; + } = {}, +): Promise { + const updater = roomUpdater ?? Rooms.getUpdater(); + + const data: IMessage = (await callbacks.run('afterSaveMessage', message, { + room, + user, + roomUpdater: updater, + options, + })) as unknown as IMessage; + + if (!roomUpdater && updater.hasChanges()) { + await Rooms.updateFromUpdater({ _id: room._id }, updater); + } + + void Message.afterSave({ message: data }); + + // TODO: Fix type - callback configuration needs to be updated + return data; +} + +export function afterSaveMessageAsync( + message: IMessage, + room: IRoom, + user: IUser, + { + roomUpdater: updater, + options, + }: { + roomUpdater?: Updater; + options?: SendMessageOptions; + } = {}, +): void { + const roomUpdater = updater ?? Rooms.getUpdater(); + + callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater, options }); + + if (roomUpdater.hasChanges()) { + void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); + } + + void Message.afterSave({ message }); +} diff --git a/apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts b/apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts new file mode 100644 index 0000000..74a0923 --- /dev/null +++ b/apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts @@ -0,0 +1,140 @@ +import { api } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { isDirectMessageRoom, isEditedMessage, isOmnichannelRoom, isRoomFederated, getUserDisplayName } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import type { ActionsBlock } from '@rocket.chat/ui-kit'; +import moment from 'moment'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { i18n } from '../../lib/i18n'; +import { settings } from '../../settings'; + +const APP_ID = 'mention-core'; +const getBlocks = (mentions: IMessage['mentions'], messageId: string, lng: string | undefined) => { + const stringifiedMentions = JSON.stringify(mentions); + return { + addUsersBlock: { + type: 'button', + appId: APP_ID, + blockId: messageId, + value: stringifiedMentions, + actionId: 'add-users', + text: { + type: 'plain_text', + text: i18n.t('Add_them', { lng }), + }, + }, + dismissBlock: { + type: 'button', + appId: APP_ID, + blockId: messageId, + value: stringifiedMentions, + actionId: 'dismiss', + text: { + type: 'plain_text', + text: i18n.t('Do_nothing', { lng }), + }, + }, + dmBlock: { + type: 'button', + appId: APP_ID, + value: stringifiedMentions, + blockId: messageId, + actionId: 'share-message', + text: { + type: 'plain_text', + text: i18n.t('Let_them_know', { lng }), + }, + }, + } as const; +}; + +callbacks.add( + 'afterSaveMessage', + async (message, { room }) => { + // TODO: check if I need to test this 60 second rule. + // If the message was edited, or is older than 60 seconds (imported) + // the notifications will be skipped, so we can also skip this validation + if (isEditedMessage(message) || (message.ts && Math.abs(moment(message.ts).diff(moment())) > 60000) || !message.mentions) { + return message; + } + + const mentions = message.mentions.filter(({ _id, type }) => _id !== 'all' && _id !== 'here' && type !== 'team'); + if (!mentions.length) { + return message; + } + + if (isDirectMessageRoom(room) || isRoomFederated(room) || isOmnichannelRoom(room)) { + return message; + } + + const subs = await Subscriptions.findByRoomIdAndUserIds( + message.rid, + mentions.map(({ _id }) => _id), + { projection: { u: 1 } }, + ).toArray(); + + // get all users that are mentioned but not in the channel + const mentionsUsersNotInChannel = mentions.filter(({ _id }) => !subs.some((sub) => sub.u._id === _id)); + + if (!mentionsUsersNotInChannel.length) { + return message; + } + + const canAddUsersToThisRoom = await hasPermissionAsync(message.u._id, 'add-user-to-joined-room', message.rid); + const canAddToAnyRoom = await (room.t === 'c' + ? hasPermissionAsync(message.u._id, 'add-user-to-any-c-room') + : hasPermissionAsync(message.u._id, 'add-user-to-any-p-room')); + const canDMUsers = await hasPermissionAsync(message.u._id, 'create-d'); // TODO: Perhaps check if user has DM with mentioned user (might be too expensive) + const canAddUsers = canAddUsersToThisRoom || canAddToAnyRoom; + + const { language } = (await Users.findOneById(message.u._id)) || {}; + + const actionBlocks = getBlocks(mentionsUsersNotInChannel, message._id, language); + const elements: ActionsBlock['elements'] = [ + canAddUsers && actionBlocks.addUsersBlock, + (canAddUsers || canDMUsers) && actionBlocks.dismissBlock, + canDMUsers && actionBlocks.dmBlock, + ].filter(isTruthy); + + const messageLabel = canAddUsers + ? 'You_mentioned___mentions__but_theyre_not_in_this_room' + : 'You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them'; + + const useRealName = settings.get('UI_Use_Real_Name'); + + const usernamesOrNames = mentionsUsersNotInChannel.map(({ username, name }) => `*${getUserDisplayName(name, username, useRealName)}*`); + + const mentionsText = usernamesOrNames.join(', '); + + // TODO: Mentions style + void api.broadcast('notify.ephemeralMessage', message.u._id, message.rid, { + msg: '', + mentions: mentionsUsersNotInChannel, + tmid: message.tmid, + blocks: [ + { + appId: APP_ID, + type: 'section', + text: { + type: 'mrkdwn', + text: i18n.t(messageLabel, { mentions: mentionsText, lng: language }), + }, + } as const, + Boolean(elements.length) && + ({ + type: 'actions', + appId: APP_ID, + elements, + } as const), + ].filter(isTruthy), + private: true, + }); + + return message; + }, + callbacks.priority.LOW, + 'mention-user-not-in-channel', +); diff --git a/apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts b/apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts new file mode 100644 index 0000000..5191e51 --- /dev/null +++ b/apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts @@ -0,0 +1,231 @@ +import type { IMessage, IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import moment from 'moment'; + +import { callbacks } from '../../lib/callbacks'; +import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight'; +import { + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedByRoomIdAndUserId, + notifyOnSubscriptionChangedByRoomIdAndUserIds, +} from '../../lib/notifyListener'; +import { settings } from '../../settings'; + +export async function getMentions(message: IMessage): Promise<{ toAll: boolean; toHere: boolean; mentionIds: string[] }> { + const { + mentions, + u: { _id: senderId }, + } = message; + + if (!mentions) { + return { + toAll: false, + toHere: false, + mentionIds: [], + }; + } + + const toAll = mentions.some(({ _id }) => _id === 'all'); + const toHere = mentions.some(({ _id }) => _id === 'here'); + + const teamsMentions = mentions.filter((mention) => mention.type === 'team'); + const filteredMentions = mentions + .filter((mention) => !mention.type || mention.type === 'user') + .filter(({ _id }) => _id !== senderId && !['all', 'here'].includes(_id)) + .map(({ _id }) => _id); + + const mentionIds = await callbacks.run('beforeGetMentions', filteredMentions, teamsMentions); + + return { + toAll, + toHere, + mentionIds, + }; +} + +type UnreadCountType = 'all_messages' | 'user_mentions_only' | 'group_mentions_only' | 'user_and_group_mentions_only'; + +const getGroupMentions = (roomType: RoomType, unreadCount: Exclude): number => { + const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); + return roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0; +}; + +const getUserMentions = (roomType: RoomType, unreadCount: Exclude): number => { + const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); + return roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0; +}; + +export const getUserIdsFromHighlights = async (rid: IRoom['_id'], message: IMessage): Promise => { + const highlightOptions = { projection: { 'userHighlights': 1, 'u._id': 1 } }; + const subs = await Subscriptions.findByRoomWithUserHighlights(rid, highlightOptions).toArray(); + + return subs + .filter( + ({ userHighlights, u: { _id: uid } }) => userHighlights && messageContainsHighlight(message, userHighlights) && uid !== message.u._id, + ) + .map(({ u: { _id: uid } }) => uid); +}; + +const getUnreadSettingCount = (roomType: RoomType): UnreadCountType => { + let unreadSetting = 'Unread_Count'; + switch (roomType) { + case 'd': { + unreadSetting = 'Unread_Count_DM'; + break; + } + case 'l': { + unreadSetting = 'Unread_Count_Omni'; + break; + } + } + + return settings.get(unreadSetting); +}; + +async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise { + if (!room || message.tmid) { + return; + } + + const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]); + + const { toAll, toHere, mentionIds } = mentions; + const userIds = [...new Set([...mentionIds, ...highlightIds])]; + const unreadCount = getUnreadSettingCount(room.t); + const unreadAllMessages = unreadCount === 'all_messages'; + + const userMentionInc = getUserMentions(room.t, unreadCount as Exclude); + const groupMentionInc = getGroupMentions(room.t, unreadCount as Exclude); + + // find all subscriptions that will need to be notified after the update. + // we need to use toArray() here and keep results in memory because we'll update the them later + const subs = await Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({ + roomId: room._id, + uidsExclude: [message.u._id], + uidsInclude: userIds, + onlyRead: !toAll && !toHere && !unreadAllMessages, + }).toArray(); + + // Give priority to user mentions over group mentions + if (userIds.length) { + await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc); + } else if (toAll || toHere) { + await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc); + } + + if (!toAll && !toHere && unreadAllMessages) { + await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); + } + + // update subscriptions of other members of the room + await Promise.all([ + Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id), + Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id), + ]); + + subs.forEach((sub) => { + const hasUserMention = userIds.includes(sub.u._id); + const shouldIncUnread = hasUserMention || toAll || toHere || unreadAllMessages; + void notifyOnSubscriptionChanged( + { + ...sub, + alert: true, + open: true, + ...(shouldIncUnread && { unread: sub.unread + 1 }), + ...(hasUserMention && { userMentions: sub.userMentions + 1 }), + ...((toAll || toHere) && { groupMentions: sub.groupMentions + 1 }), + }, + 'updated', + ); + }); + + // update subscription of the message sender + const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id); + if (setAsReadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(message.rid, message.u._id); + } +} + +export async function updateThreadUsersSubscriptions(message: IMessage, replies: IUser['_id'][]): Promise { + // Don't increase unread counter on thread messages + const repliesPlusSender = [...new Set([message.u._id, ...replies])]; + + const responses = await Promise.all([ + Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies), + Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender), + Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()), + ]); + + responses.some((response) => response?.modifiedCount) && + void notifyOnSubscriptionChangedByRoomIdAndUserIds(message.rid, repliesPlusSender); +} + +export async function notifyUsersOnMessage(message: IMessage, room: IRoom, roomUpdater: Updater): Promise { + // Skips this callback if the message was edited and increments it if the edit was way in the past (aka imported) + if (isEditedMessage(message)) { + if (Math.abs(moment(message.editedAt).diff(Date.now())) > 60000) { + // TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); + return message; + } + + // Only updates last message if it was edited (skip rest of callback) + if ( + settings.get('Store_Last_Message') && + (!message.tmid || message.tshow) && + (!room.lastMessage || room.lastMessage._id === message._id) + ) { + Rooms.getLastMessageUpdateQuery(message, roomUpdater); + } + + return message; + } + + if (message.ts && Math.abs(moment(message.ts).diff(Date.now())) > 60000) { + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); + return message; + } + + // If message sent ONLY on a thread, skips the rest as it is done on a callback specific to threads + if (message.tmid && !message.tshow) { + Rooms.getIncMsgCountUpdateQuery(1, roomUpdater); + return message; + } + + // Update all the room activity tracker fields + Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater); + await updateUsersSubscriptions(message, room); + + return message; +} + +export async function notifyUsersOnSystemMessage(message: IMessage, room: IRoom): Promise { + const roomUpdater = Rooms.getUpdater(); + Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater); + + if (roomUpdater.hasChanges()) { + await Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); + } + + // TODO: Rewrite to use just needed calls from the function + await updateUsersSubscriptions(message, room); + + return message; +} + +callbacks.add( + 'afterSaveMessage', + async (message, { room, roomUpdater }) => { + if (!roomUpdater) { + return message; + } + + await notifyUsersOnMessage(message, room, roomUpdater); + + return message; + }, + callbacks.priority.MEDIUM, + 'notifyUsersOnMessage', +); diff --git a/apps/meteor/server/hooks/messages/processThreads.ts b/apps/meteor/server/hooks/messages/processThreads.ts new file mode 100644 index 0000000..7a022df --- /dev/null +++ b/apps/meteor/server/hooks/messages/processThreads.ts @@ -0,0 +1,95 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { updateThreadUsersSubscriptions, getMentions } from './notifyUsersOnMessage'; +import { sendMessageNotifications } from './sendNotificationsOnMessage'; +import { callbacks } from '../../lib/callbacks'; +import type { SendMessageOptions } from '../../lib/messages/sendMessage'; +import { reply } from '../../lib/messaging/threads/functions'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { settings } from '../../settings'; + +async function notifyUsersOnReply(message: IMessage, replies: IUser['_id'][]) { + // skips this callback if the message was edited + if (isEditedMessage(message)) { + return message; + } + + await updateThreadUsersSubscriptions(message, replies); + + return message; +} + +async function metaData(message: IMessage, parentMessage: IMessage, followers: string[]) { + await reply({ tmid: message.tmid }, message, parentMessage, followers); + + return message; +} + +const notification = async (message: IMessage, room: IRoom, replies: string[]) => { + // skips this callback if the message was edited + if (isEditedMessage(message)) { + return message; + } + + // will send a notification to everyone who replied/followed the thread except the owner of the message + await sendMessageNotifications(message, room, replies); + + return message; +}; + +export async function processThreads(message: IMessage, room: IRoom, options?: SendMessageOptions) { + if (!message.tmid) { + return message; + } + + const parentMessage = await Messages.findOneById(message.tmid); + if (!parentMessage) { + return message; + } + + const { mentionIds } = await getMentions(message); + + const replies = [ + ...new Set([ + ...((!parentMessage.tcount ? [parentMessage.u._id] : parentMessage.replies) || []), + ...(!parentMessage.tcount && room.t === 'd' && room.uids ? room.uids : []), + ...mentionIds, + ]), + ].filter((userId) => userId !== message.u._id); + + await notifyUsersOnReply(message, replies); + await metaData(message, parentMessage, replies); + + if (!isEditedMessage(message)) { + callbacks.runAsync('afterReadMessages', room, { uid: message.u._id, tmid: message.tmid }); + } + + if (!options?.skipNotifications) { + await notification(message, room, replies); + } + void notifyOnMessageChange({ + id: message.tmid, + }); + + return message; +} + +Meteor.startup(() => { + settings.watch('Threads_enabled', (value) => { + if (!value) { + callbacks.remove('afterSaveMessage', 'threads-after-save-message'); + return; + } + callbacks.add( + 'afterSaveMessage', + async (message, { room, options }) => { + return processThreads(message, room, options); + }, + callbacks.priority.LOW, + 'threads-after-save-message', + ); + }); +}); diff --git a/apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts b/apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts new file mode 100644 index 0000000..6294260 --- /dev/null +++ b/apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts @@ -0,0 +1,134 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Messages, Rooms, VideoConference } from '@rocket.chat/models'; +import type { Updater } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage'; +import { deleteRoom } from '../../lib/rooms/deleteRoom'; + +/** + * The messages count and the last message timestamp of the room are only written to the database + * once every `afterSaveMessage` callback ran, so the changes staged for the message being saved + * have to be applied on top of the stored room, otherwise the discussion metadata would always + * be left one message behind. + */ +const withPendingRoomChanges = ( + room: Pick, + roomUpdater?: Updater, +): Pick => { + const { $inc, $set } = roomUpdater?.getRawUpdateFilter() ?? {}; + const pendingMsgs = typeof $inc?.msgs === 'number' ? $inc.msgs : 0; + const pendingLm = $set?.lm instanceof Date ? $set.lm : undefined; + + return { + ...room, + msgs: room.msgs + pendingMsgs, + lm: pendingLm ?? room.lm, + }; +}; + +/** + * We need to propagate the writing of new message in a discussion to the linking + * system message + */ +callbacks.add( + 'afterSaveMessage', + async (message, { room: { _id, prid }, roomUpdater }) => { + if (!prid) { + return message; + } + + const room = await Rooms.findOneById(_id, { + projection: { + msgs: 1, + lm: 1, + sysMes: 1, + }, + }); + + if (!room) { + return message; + } + + await updateAndNotifyParentRoomWithParentMessage(withPendingRoomChanges(room, roomUpdater)); + + return message; + }, + callbacks.priority.LOW, + 'PropagateDiscussionMetadata', +); + +callbacks.add( + 'afterDeleteMessage', + async (message, { room: { _id, prid } }) => { + if (prid) { + const room = await Rooms.findOneById(_id, { + projection: { + msgs: 1, + lm: 1, + sysMes: 1, + }, + }); + + if (room) { + await updateAndNotifyParentRoomWithParentMessage(room); + } + } + if (message.drid) { + await deleteRoom(message.drid); + } + return message; + }, + callbacks.priority.LOW, + 'PropagateDiscussionMetadata', +); + +callbacks.add( + 'afterDeleteRoom', + async (rid) => { + for await (const { _id } of Rooms.find({ prid: rid }, { projection: { _id: 1 } })) { + await deleteRoom(_id); + } + + return rid; + }, + callbacks.priority.LOW, + 'DeleteDiscussionChain', +); + +// TODO discussions define new fields +callbacks.add( + 'afterRoomNameChange', + async (roomConfig) => { + const { + room: { _id: rid }, + name, + oldName, + } = roomConfig; + await Rooms.updateMany({ prid: rid, ...(oldName && { topic: oldName }) }, { $set: { topic: name } }); + return roomConfig; + }, + callbacks.priority.LOW, + 'updateTopicDiscussion', +); + +callbacks.add( + 'afterDeleteRoom', + async (drid) => { + await Messages.updateMany( + { drid }, + { + $unset: { + dcount: 1, + dlm: 1, + drid: 1, + }, + }, + ); + + await VideoConference.unsetDiscussionRid(drid); + return drid; + }, + callbacks.priority.LOW, + 'CleanDiscussionMessage', +); diff --git a/apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts b/apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts new file mode 100644 index 0000000..32d3c11 --- /dev/null +++ b/apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts @@ -0,0 +1,432 @@ +import { + type IMessage, + type ISubscription, + type IUser, + type IRoom, + type NotificationItem, + isEditedMessage, + type AtLeast, +} from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import moment from 'moment'; +import type { RootFilterOperators } from 'mongodb'; + +import { getMentions } from './notifyUsersOnMessage'; +import { shortnameToUnicode } from '../../../app/emoji-native/lib/shortnameToUnicode'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../../lib/notifications/message'; +import { notifyDesktopUser, shouldNotifyDesktop } from '../../lib/notifications/message/desktop'; +import { getEmailData, shouldNotifyEmail } from '../../lib/notifications/message/email'; +import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight'; +import { getPushData, shouldNotifyMobile } from '../../lib/notifications/message/mobile'; +import { Notification } from '../../lib/notifications/queue/NotificationQueue'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { settings } from '../../settings'; + +type SubscriptionAggregation = { + receiver: [Pick | null]; +} & Pick< + ISubscription, + | 'desktopNotifications' + | 'emailNotifications' + | 'mobilePushNotifications' + | 'muteGroupMentions' + | 'name' + | 'rid' + | 'userHighlights' + | 'u' + | 'audioNotificationValue' +>; + +type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; + +export const sendNotification = async ({ + subscription, + sender, + hasReplyToThread, + hasMentionToAll, + hasMentionToHere, + message, + notificationMessage, + room, + mentionIds, + disableAllMessageNotifications, +}: { + subscription: SubscriptionAggregation; + sender: Pick; + + hasReplyToThread: boolean; + hasMentionToAll: boolean; + hasMentionToHere: boolean; + message: AtLeast; + notificationMessage: string; + room: IRoom; + mentionIds: string[]; + disableAllMessageNotifications: boolean; +}) => { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { + return; + } + + // don't notify the sender + if (subscription.u._id === sender._id) { + return; + } + + const hasMentionToUser = mentionIds.includes(subscription.u._id); + + // mute group notifications (@here and @all) if not directly mentioned as well + if (!hasMentionToUser && !hasReplyToThread && subscription.muteGroupMentions && (hasMentionToAll || hasMentionToHere)) { + return; + } + + if (!subscription.receiver) { + subscription.receiver = [ + await Users.findOneById(subscription.u._id, { + projection: { + 'active': 1, + 'emails': 1, + 'language': 1, + 'status': 1, + 'statusConnection': 1, + 'username': 1, + 'settings.preferences.enableMobileRinging': 1, + }, + }), + ]; + } + + const [receiver] = subscription.receiver; + + if (!receiver) { + throw new Error('receiver not found'); + } + + const roomType = room.t; + // If the user doesn't have permission to view direct messages, don't send notification of direct messages. + if (roomType === 'd' && !(await hasPermissionAsync(subscription.u._id, 'view-d-room'))) { + return; + } + + const isThread = !!message.tmid && !message.tshow; + const isVideoConf = message.t === 'videoconf'; + + notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver); + + const isHighlighted = messageContainsHighlight(message, subscription.userHighlights); + + const { desktopNotifications, mobilePushNotifications, emailNotifications } = subscription; + + // busy users don't receive desktop notification + if ( + shouldNotifyDesktop({ + disableAllMessageNotifications, + status: receiver.status ?? 'offline', + statusConnection: receiver.statusConnection ?? 'offline', + desktopNotifications, + hasMentionToAll, + hasMentionToHere, + isHighlighted, + hasMentionToUser, + hasReplyToThread, + roomType, + isThread, + }) + ) { + await notifyDesktopUser({ + notificationMessage, + userId: subscription.u._id, + user: sender, + message, + room, + audioNotificationValue: subscription.audioNotificationValue, + }); + } + + const queueItems: NotificationItem[] = []; + + if ( + shouldNotifyMobile({ + disableAllMessageNotifications, + mobilePushNotifications, + hasMentionToAll, + isHighlighted, + hasMentionToUser, + hasReplyToThread, + roomType, + isThread, + isVideoConf, + userPreferences: receiver.settings?.preferences, + roomUids: room.uids, + }) + ) { + queueItems.push({ + type: 'push', + data: await getPushData({ + notificationMessage, + room, + message, + userId: subscription.u._id, + senderUsername: sender.username, + senderName: sender.name, + receiver, + }), + }); + } + + if ( + receiver.emails && + shouldNotifyEmail({ + disableAllMessageNotifications, + statusConnection: receiver.statusConnection, + emailNotifications, + isHighlighted, + hasMentionToUser, + hasMentionToAll, + hasReplyToThread, + roomType, + isThread, + }) + ) { + const messageWithUnicode = message.msg ? shortnameToUnicode(message.msg) : message.msg; + const firstAttachment = message.attachments?.[0]; + + const convertedFirstAttachment = firstAttachment + ? { + ...firstAttachment, + description: typeof firstAttachment.description === 'string' ? shortnameToUnicode(firstAttachment.description) : undefined, + text: typeof firstAttachment.text === 'string' ? shortnameToUnicode(firstAttachment.text) : undefined, + } + : undefined; + + const attachments = convertedFirstAttachment ? [convertedFirstAttachment, ...(message.attachments?.slice(1) ?? [])] : []; + for (const email of receiver.emails) { + if (email.verified) { + queueItems.push({ + type: 'email', + data: await getEmailData({ + message: { + ...message, + msg: messageWithUnicode, + ...(attachments.length > 0 ? { attachments } : {}), + }, + receiver, + sender, + subscription, + room, + emailAddress: email.address, + hasMentionToUser, + }), + }); + + break; + } + } + } + + if (queueItems.length) { + void Notification.scheduleItem({ + user: receiver, + uid: subscription.u._id, + rid: room._id, + mid: message._id, + items: queueItems, + }); + } +}; + +const project = { + $project: { + 'desktopNotifications': 1, + 'emailNotifications': 1, + 'mobilePushNotifications': 1, + 'muteGroupMentions': 1, + 'name': 1, + 'rid': 1, + 'userHighlights': 1, + 'u._id': 1, + 'receiver.active': 1, + 'receiver.emails': 1, + 'receiver.language': 1, + 'receiver.status': 1, + 'receiver.statusConnection': 1, + 'receiver.username': 1, + 'receiver.settings.preferences.enableMobileRinging': 1, + 'audioNotificationValue': 1, + }, +} as const; + +const filter = { + $match: { + 'receiver.active': true, + }, +} as const; + +const lookup = { + $lookup: { + from: 'users', + localField: 'u._id', + foreignField: '_id', + as: 'receiver', + }, +} as const; + +export async function sendMessageNotifications(message: IMessage, room: IRoom, usersInThread: string[] = []) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { + return; + } + + const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message); + if (!sender) { + return message; + } + + const { toAll: hasMentionToAll, toHere: hasMentionToHere, mentionIds } = await getMentions(message); + + const mentionIdsWithoutGroups = [...mentionIds]; + + // getMentions removes `all` and `here` from mentionIds so we need to add them back for compatibility + if (hasMentionToAll) { + mentionIds.push('all'); + } + if (hasMentionToHere) { + mentionIds.push('here'); + } + + // add users in thread to mentions array because they follow the same rules + mentionIds.push(...usersInThread); + + let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg); + if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) { + notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions ?? []); + } + + // Don't fetch all users if room exceeds max members + const maxMembersForNotification = settings.get('Notifications_Max_Room_Members'); + const roomMembersCount = await Users.countRoomMembers(room._id); + const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0; + + const query: WithRequiredProperty, '$or'> = { + rid: room._id, + ignored: { $ne: sender._id }, + disableNotifications: { $ne: true }, + $or: [{ 'userHighlights.0': { $exists: 1 } }, ...(usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [])], + } as const; + + (['desktop', 'mobile', 'email'] as const).forEach((kind) => { + const notificationField = kind === 'mobile' ? 'mobilePush' : `${kind}Notifications`; + + query.$or.push({ + [notificationField]: 'all', + ...(disableAllMessageNotifications ? { [`${kind}PrefOrigin`]: { $ne: 'user' } } : {}), + }); + + if (disableAllMessageNotifications) { + return; + } + + if (room.t === 'd') { + query.$or.push({ + [notificationField]: 'mentions', + }); + } else if (mentionIdsWithoutGroups.length > 0) { + query.$or.push({ + [notificationField]: 'mentions', + 'u._id': { $in: mentionIdsWithoutGroups }, + }); + } + + const serverField = kind === 'email' ? 'emailNotificationMode' : `${kind}Notifications`; + const serverPreference = settings.get(`Accounts_Default_User_Preferences_${serverField}`); + + if (serverPreference === 'all' || hasMentionToAll || hasMentionToHere || room.t === 'd') { + query.$or.push({ + [notificationField]: { $exists: false }, + }); + } else if (serverPreference === 'mentions' && mentionIdsWithoutGroups.length > 0) { + query.$or.push({ + [notificationField]: { $exists: false }, + 'u._id': { $in: mentionIdsWithoutGroups }, + }); + } + }); + + // the find below is crucial. All subscription records returned will receive at least one kind of notification. + // the query is defined by the server's default values and Notifications_Max_Room_Members setting. + + const subscriptions = await Subscriptions.col.aggregate([{ $match: query }, lookup, filter, project]).toArray(); + + subscriptions.forEach( + (subscription) => + void sendNotification({ + subscription, + sender, + hasMentionToAll, + hasMentionToHere, + message, + notificationMessage, + room, + mentionIds, + disableAllMessageNotifications, + hasReplyToThread: usersInThread?.includes(subscription.u._id), + }), + ); + + return { + sender, + hasMentionToAll, + hasMentionToHere, + notificationMessage, + mentionIds, + mentionIdsWithoutGroups, + }; +} + +export async function sendAllNotifications(message: IMessage, room: IRoom) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { + return message; + } + + // threads + if (message.tmid) { + return message; + } + // skips this callback if the message was edited + if (isEditedMessage(message)) { + return message; + } + + if (message.ts && Math.abs(moment(message.ts).diff(new Date())) > 60000) { + return message; + } + + if (room?.t == null) { + return message; + } + + await sendMessageNotifications(message, room); + + return message; +} + +settings.watch('Troubleshoot_Disable_Notifications', (value) => { + if (value) { + return callbacks.remove('afterSaveMessage', 'sendNotificationsOnMessage'); + } + + callbacks.add( + 'afterSaveMessage', + (message, { room, options }) => { + if (options?.skipNotifications) { + return message; + } + + return sendAllNotifications(message, room); + }, + callbacks.priority.LOW, + 'sendNotificationsOnMessage', + ); +}); diff --git a/apps/meteor/server/hooks/omnichannel/afterAgentRemoved.ts b/apps/meteor/server/hooks/omnichannel/afterAgentRemoved.ts new file mode 100644 index 0000000..81dd9b5 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/afterAgentRemoved.ts @@ -0,0 +1,41 @@ +import { LivechatDepartment, Users, LivechatDepartmentAgents, LivechatVisitors } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { notifyOnLivechatDepartmentAgentChanged, notifyOnUserChange } from '../../lib/notifyListener'; + +callbacks.add('livechat.afterAgentRemoved', async ({ agent }) => { + const departments = await LivechatDepartmentAgents.findByAgentId(agent._id).toArray(); + + const [{ modifiedCount }, { deletedCount }] = await Promise.all([ + Users.removeAgent(agent._id), + LivechatDepartmentAgents.removeByAgentId(agent._id), + agent.username && LivechatVisitors.removeContactManagerByUsername(agent.username), + departments.length && LivechatDepartment.decreaseNumberOfAgentsByIds(departments.map(({ departmentId }) => departmentId)), + ]); + + if (modifiedCount > 0) { + void notifyOnUserChange({ + id: agent._id, + clientAction: 'updated', + diff: { + operator: false, + livechat: null, + statusLivechat: null, + openBusinessHours: null, + }, + }); + } + + if (deletedCount > 0) { + departments.forEach((depAgent) => { + void notifyOnLivechatDepartmentAgentChanged( + { + _id: depAgent._id, + agentId: agent._id, + departmentId: depAgent.departmentId, + }, + 'removed', + ); + }); + } +}); diff --git a/apps/meteor/server/hooks/omnichannel/afterSaveOmnichannelMessage.ts b/apps/meteor/server/hooks/omnichannel/afterSaveOmnichannelMessage.ts new file mode 100644 index 0000000..633ede3 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/afterSaveOmnichannelMessage.ts @@ -0,0 +1,24 @@ +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; + +callbacks.add( + 'afterSaveMessage', + async (message, { room }) => { + if (!isOmnichannelRoom(room)) { + return message; + } + + const updater = LivechatRooms.getUpdater(); + const result = await callbacks.run('afterOmnichannelSaveMessage', message, { room, roomUpdater: updater }); + + if (updater.hasChanges()) { + await LivechatRooms.updateFromUpdater({ _id: room._id }, updater); + } + + return result; + }, + callbacks.priority.MEDIUM, + 'after-omnichannel-save-message', +); diff --git a/apps/meteor/server/hooks/omnichannel/afterUserActions.ts b/apps/meteor/server/hooks/omnichannel/afterUserActions.ts new file mode 100644 index 0000000..32f49dd --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/afterUserActions.ts @@ -0,0 +1,52 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { afterAgentUserActivated, afterAgentAdded, afterRemoveAgent } from '../../lib/omnichannel/hooks'; + +type IAfterSaveUserProps = { + user: IUser; + oldUser: IUser | null; +}; + +const wasAgent = (user: Pick | null) => user?.roles?.includes('livechat-agent'); +const isAgent = (user: Pick | null) => user?.roles?.includes('livechat-agent'); + +const handleAgentUpdated = async (userData: IAfterSaveUserProps) => { + const { user: newUser, oldUser } = userData; + + if (wasAgent(oldUser) && !isAgent(newUser)) { + await afterRemoveAgent(newUser); + } + + if (!wasAgent(oldUser) && isAgent(newUser)) { + await afterAgentAdded(newUser); + } +}; + +const handleAgentCreated = async (user: IUser) => { + // created === no prev roles :) + if (isAgent(user)) { + await afterAgentAdded(user); + } +}; + +const handleDeactivateUser = async (user: IUser) => { + if (wasAgent(user)) { + await Users.makeAgentUnavailable(user._id); + } +}; + +const handleActivateUser = async (user: IUser) => { + if (isAgent(user) && user.username) { + await afterAgentUserActivated(user); + } +}; + +callbacks.add('afterCreateUser', handleAgentCreated, callbacks.priority.LOW, 'livechat-after-create-user-update-agent'); + +callbacks.add('afterSaveUser', handleAgentUpdated, callbacks.priority.LOW, 'livechat-after-save-user-update-agent'); + +callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'livechat-after-deactivate-user-remove-agent'); + +callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'livechat-after-activate-user-add-agent'); diff --git a/apps/meteor/server/hooks/omnichannel/leadCapture.ts b/apps/meteor/server/hooks/omnichannel/leadCapture.ts new file mode 100644 index 0000000..34da136 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/leadCapture.ts @@ -0,0 +1,69 @@ +import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; + +import { callbacks } from '../../lib/callbacks'; +import { settings } from '../../settings'; + +function validateMessage(message: IMessage, room: IOmnichannelRoom) { + // skips this callback if the message was edited + if (isEditedMessage(message)) { + return false; + } + + // message valid only if it is a livechat room + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) { + return false; + } + + // if the message hasn't a token, it was NOT sent from the visitor, so ignore it + if (!message.token) { + return false; + } + + // if the message has a type means it is a special message (like the closing comment), so skips + if (message.t) { + return false; + } + + return true; +} + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room }) => { + if (!validateMessage(message, room)) { + return message; + } + + const phoneRegexSetting = settings.get('Livechat_lead_phone_regex'); + const emailRegexSetting = settings.get('Livechat_lead_email_regex'); + + const safeMatch = (pattern: string, flags: string, text: string): string[] => { + if (!pattern) { + return []; + } + try { + const re = new RegExp(pattern, flags); + return text.match(re)?.filter(isTruthy) ?? []; + } catch { + return []; + } + }; + + const uniq = (arr: string[]) => [...new Set(arr.filter(isTruthy))]; + + const matchedPhones = uniq(safeMatch(phoneRegexSetting, 'g', message.msg)); + const matchedEmails = uniq(safeMatch(emailRegexSetting, 'gi', message.msg)); + + if (matchedEmails.length || matchedPhones.length) { + await LivechatVisitors.saveGuestEmailPhoneById(room.v._id, matchedEmails, matchedPhones); + await callbacks.run('livechat.leadCapture', room); + } + + return message; + }, + callbacks.priority.LOW, + 'leadCapture', +); diff --git a/apps/meteor/server/hooks/omnichannel/markRoomNotResponded.ts b/apps/meteor/server/hooks/omnichannel/markRoomNotResponded.ts new file mode 100644 index 0000000..84419ac --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/markRoomNotResponded.ts @@ -0,0 +1,30 @@ +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; + +callbacks.add( + 'afterOmnichannelSaveMessage', + (message, { room, roomUpdater }) => { + // skips this callback if the message was edited + if (!message || isEditedMessage(message)) { + return message; + } + + // if the message has not a token, it was sent by the agent, so ignore it + if (!message.token) { + return message; + } + + // check if room is yet awaiting for response + if (room.waitingResponse) { + return message; + } + + LivechatRooms.getNotResponseByRoomIdUpdateQuery(roomUpdater); + + return message; + }, + callbacks.priority.LOW, + 'markRoomNotResponded', +); diff --git a/apps/meteor/server/hooks/omnichannel/markRoomResponded.ts b/apps/meteor/server/hooks/omnichannel/markRoomResponded.ts new file mode 100644 index 0000000..bccc84d --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/markRoomResponded.ts @@ -0,0 +1,79 @@ +import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { LivechatRooms, LivechatContacts, LivechatInquiry } from '@rocket.chat/models'; +import moment from 'moment'; + +import { callbacks } from '../../lib/callbacks'; +import { notifyOnLivechatInquiryChanged } from '../../lib/notifyListener'; +import { isMessageFromBot } from '../../lib/omnichannel/isMessageFromBot'; +import { settings } from '../../settings'; + +export async function markRoomResponded( + message: IMessage, + room: IOmnichannelRoom, + roomUpdater: Updater, +): Promise { + if ( + isSystemMessage(message) || + isEditedMessage(message) || + isMessageFromVisitor(message) || + (settings.get('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message))) + ) { + return; + } + + const monthYear = moment().format('YYYY-MM'); + const isContactActive = await LivechatContacts.isContactActiveOnPeriod({ visitorId: room.v._id, source: room.source }, monthYear); + + // Case: agent answers & visitor is not active, we mark visitor as active + if (!isContactActive) { + await LivechatContacts.markContactActiveForPeriod({ visitorId: room.v._id, source: room.source }, monthYear); + } + + if (!room.v?.activity?.includes(monthYear)) { + LivechatRooms.getVisitorActiveForPeriodUpdateQuery(monthYear, roomUpdater, room.v?.activity); + const livechatInquiry = await LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear); + + if (livechatInquiry) { + void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v }); + } + } + + if (!room.waitingResponse) { + // case where agent sends second message or any subsequent message in a room before visitor responds to the first message + // in this case, we just need to update the lastMessageTs of the responseBy object + if (room.responseBy) { + LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater); + } + + return room.responseBy; + } + + // Since we're updating the whole object anyways, we re-use the same values from object (or from message if not present) + // And then we update the lastMessageTs, which is the only thing that should be updating here + const { responseBy: { _id, username, firstResponseTs } = {} } = room; + const responseBy: IOmnichannelRoom['responseBy'] = { + _id: _id || message.u._id, + username: username || message.u.username, + firstResponseTs: firstResponseTs || new Date(message.ts), + lastMessageTs: new Date(message.ts), + }; + + LivechatRooms.getResponseByRoomIdUpdateQuery(responseBy, roomUpdater); + + return responseBy; +} + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room, roomUpdater }) => { + if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) { + return; + } + + await markRoomResponded(message, room, roomUpdater); + }, + callbacks.priority.HIGH, + 'markRoomResponded', +); diff --git a/apps/meteor/server/hooks/omnichannel/offlineMessage.ts b/apps/meteor/server/hooks/omnichannel/offlineMessage.ts new file mode 100644 index 0000000..3642661 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/offlineMessage.ts @@ -0,0 +1,26 @@ +import { callbacks } from '../../lib/callbacks'; +import { sendRequest } from '../../lib/omnichannel/webhooks'; +import { settings } from '../../settings'; + +callbacks.add( + 'livechat.offlineMessage', + async (data) => { + if (!settings.get('Livechat_webhook_on_offline_msg')) { + return data; + } + + const postData = { + type: 'LivechatOfflineMessage', + sentAt: new Date(), + visitor: { + name: data.name, + email: data.email, + }, + message: data.message, + }; + + await sendRequest(postData); + }, + callbacks.priority.MEDIUM, + 'livechat-send-email-offline-message', +); diff --git a/apps/meteor/server/hooks/omnichannel/offlineMessageToChannel.ts b/apps/meteor/server/hooks/omnichannel/offlineMessageToChannel.ts new file mode 100644 index 0000000..50bf010 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/offlineMessageToChannel.ts @@ -0,0 +1,70 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatDepartment, Users, Rooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { i18n } from '../../lib/i18n'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { settings } from '../../settings'; + +callbacks.add( + 'livechat.offlineMessage', + async (data) => { + if (!settings.get('Livechat_OfflineMessageToChannel_enabled')) { + return data; + } + + let channelName = settings.get('Livechat_OfflineMessageToChannel_channel_name'); + let departmentName; + const { name, email, department, message: text, host } = data; + if (department && department !== '') { + const dept = await LivechatDepartment.findOneById>( + department, + { + projection: { name: 1, offlineMessageChannelName: 1 }, + }, + ); + departmentName = dept?.name; + if (dept?.offlineMessageChannelName) { + channelName = dept.offlineMessageChannelName; + } + } + + if (!channelName || channelName === '') { + return data; + } + + const room = await Rooms.findOneByName(channelName, { projection: { t: 1, archived: 1 } }); + if (!room || room.archived || (isOmnichannelRoom(room) && room.closedAt)) { + return data; + } + + const user = await Users.findOneById('rocket.cat', { projection: { username: 1 } }); + if (!user) { + return data; + } + + const lng = settings.get('Language') || 'en'; + + let msg = `${i18n.t('New_Livechat_offline_message_has_been_sent', { lng })}: \n`; + if (host && host !== '') { + msg = msg.concat(`${i18n.t('Sent_from', { lng })}: ${host} \n`); + } + msg = msg.concat(`${i18n.t('Visitor_Name', { lng })}: ${name} \n`); + msg = msg.concat(`${i18n.t('Visitor_Email', { lng })}: ${email} \n`); + if (departmentName) { + msg = msg.concat(`${i18n.t('Department', { lng })}: ${departmentName} \n`); + } + msg = msg.concat(`${i18n.t('Message', { lng })}: ${text} \n`); + + const message = { + rid: room._id, + msg, + groupable: false, + }; + + await sendMessage(user, message, room, { upsert: true }); + }, + callbacks.priority.MEDIUM, + 'livechat-send-email-offline-message-to-channel', +); diff --git a/apps/meteor/server/hooks/omnichannel/processRoomAbandonment.ts b/apps/meteor/server/hooks/omnichannel/processRoomAbandonment.ts new file mode 100644 index 0000000..4c21b90 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/processRoomAbandonment.ts @@ -0,0 +1,137 @@ +import type { IOmnichannelRoom, IMessage, IBusinessHourWorkHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatBusinessHours, LivechatDepartment, Messages, LivechatRooms } from '@rocket.chat/models'; +import moment from 'moment'; + +import { callbacks } from '../../lib/callbacks'; +import { businessHourManager } from '../../lib/omnichannel/business-hour'; +import type { CloseRoomParams } from '../../lib/omnichannel/localTypes'; +import { settings } from '../../settings'; + +export const getSecondsWhenOfficeHoursIsDisabled = (room: IOmnichannelRoom, agentLastMessage: IMessage) => + moment(new Date(room.closedAt || new Date())).diff(moment(new Date(agentLastMessage.ts)), 'seconds'); + +export const parseDays = ( + acc: Record, + day: IBusinessHourWorkHour, +) => { + acc[day.day] = { + start: { day: day.start.utc.dayOfWeek, time: day.start.utc.time }, + finish: { day: day.finish.utc.dayOfWeek, time: day.finish.utc.time }, + open: day.open, + }; + return acc; +}; + +export const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLastMessage: IMessage) => { + if (!settings.get('Livechat_enable_business_hours')) { + return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + } + let officeDays; + const department = room.departmentId + ? await LivechatDepartment.findOneById>(room.departmentId, { + projection: { businessHourId: 1 }, + }) + : null; + if (department?.businessHourId) { + const businessHour = await LivechatBusinessHours.findOneById(department.businessHourId); + if (!businessHour) { + return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + } + + officeDays = (await businessHourManager.getBusinessHour(businessHour._id, businessHour.type))?.workHours.reduce(parseDays, {}); + } else { + officeDays = (await businessHourManager.getBusinessHour())?.workHours.reduce(parseDays, {}); + } + + // Empty object we assume invalid config + if (!officeDays || !Object.keys(officeDays).length) { + return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + } + + let totalSeconds = 0; + const endOfConversation = moment.utc(new Date(room.closedAt || new Date())); + const startOfInactivity = moment.utc(new Date(agentLastMessage.ts)); + const daysOfInactivity = endOfConversation.clone().startOf('day').diff(startOfInactivity.clone().startOf('day'), 'days'); + const inactivityDay = moment.utc(new Date(agentLastMessage.ts)); + + for (let index = 0; index <= daysOfInactivity; index++) { + const today = inactivityDay.clone().format('dddd'); + const officeDay = officeDays[today]; + + if (!officeDay) { + inactivityDay.add(1, 'days'); + continue; + } + + if (!officeDay.open) { + inactivityDay.add(1, 'days'); + continue; + } + + if (!officeDay?.start?.time || !officeDay?.finish?.time) { + inactivityDay.add(1, 'days'); + continue; + } + + const [officeStartHour, officeStartMinute] = officeDay.start.time.split(':'); + const [officeCloseHour, officeCloseMinute] = officeDay.finish.time.split(':'); + // We should only take in consideration the time where the office is open and the conversation was inactive + const todayStartOfficeHours = inactivityDay + .clone() + .set({ hour: parseInt(officeStartHour, 10), minute: parseInt(officeStartMinute, 10) }); + const todayEndOfficeHours = inactivityDay.clone().set({ hour: parseInt(officeCloseHour, 10), minute: parseInt(officeCloseMinute, 10) }); + + // 1: Room was inactive the whole day, we add the whole time BH is inactive + if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) { + totalSeconds += todayEndOfficeHours.diff(todayStartOfficeHours, 'seconds'); + } + + // 2: Room was inactive before start but was closed before end of BH, we add the inactive time + if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) { + totalSeconds += endOfConversation.diff(todayStartOfficeHours, 'seconds'); + } + + // 3: Room was inactive after start and ended after end of BH, we add the inactive time + if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) { + totalSeconds += todayEndOfficeHours.diff(startOfInactivity, 'seconds'); + } + + // 4: Room was inactive after start and before end of BH, we add the inactive time + if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) { + totalSeconds += endOfConversation.diff(startOfInactivity, 'seconds'); + } + + inactivityDay.add(1, 'days'); + } + return totalSeconds; +}; + +export const onCloseRoom = async (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => { + const { room } = params; + + if (!isOmnichannelRoom(room)) { + return params; + } + + const closedByAgent = room.closer !== 'visitor'; + const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token; + if (!closedByAgent || !wasTheLastMessageSentByAgent) { + return params; + } + + if (!room.v?.lastMessageTs) { + return params; + } + + const agentLastMessage = await Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs); + if (!agentLastMessage) { + return params; + } + const secondsSinceLastAgentResponse = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + await LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse); + + return params; +}; + +callbacks.add('livechat.closeRoom', onCloseRoom, callbacks.priority.HIGH, 'process-room-abandonment'); diff --git a/apps/meteor/server/hooks/omnichannel/saveAnalyticsData.ts b/apps/meteor/server/hooks/omnichannel/saveAnalyticsData.ts new file mode 100644 index 0000000..8cbff98 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/saveAnalyticsData.ts @@ -0,0 +1,90 @@ +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { isMessageFromBot } from '../../lib/omnichannel/isMessageFromBot'; +import { normalizeMessageFileUpload } from '../../lib/utils/functions/normalizeMessageFileUpload'; +import { settings } from '../../settings'; + +const getMetricValue = (metric: T | undefined, defaultValue: T): T => metric ?? defaultValue; +const calculateTimeDifference = (startTime: T, now: Date): number => + (now.getTime() - new Date(startTime).getTime()) / 1000; +const calculateAvgResponseTime = (totalResponseTime: number, newResponseTime: number, responseCount: number) => + (totalResponseTime + newResponseTime) / (responseCount + 1); + +const getFirstResponseAnalytics = ( + visitorLastQuery: Date, + agentJoinTime: Date, + totalResponseTime: number, + responseCount: number, + now: Date, +) => { + const responseTime = calculateTimeDifference(visitorLastQuery, now); + const reactionTime = calculateTimeDifference(agentJoinTime, now); + const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount); + + return { + firstResponseDate: now, + firstResponseTime: responseTime, + responseTime, + avgResponseTime, + firstReactionDate: now, + firstReactionTime: reactionTime, + reactionTime, + }; +}; + +const getSubsequentResponseAnalytics = (visitorLastQuery: Date, totalResponseTime: number, responseCount: number, now: Date) => { + const responseTime = calculateTimeDifference(visitorLastQuery, now); + const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount); + + return { + responseTime, + avgResponseTime, + reactionTime: responseTime, + }; +}; + +const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record | undefined => { + const visitorLastQuery = getMetricValue(room.metrics?.v?.lq, room.ts); + const agentLastReply = getMetricValue(room.metrics?.servedBy?.lr, room.ts); + const agentJoinTime = getMetricValue(room.servedBy?.ts, room.ts); + const totalResponseTime = getMetricValue(room.metrics?.response?.tt, 0); + const responseCount = getMetricValue(room.metrics?.response?.total, 0); + + if (agentLastReply === room.ts) { + return getFirstResponseAnalytics(visitorLastQuery, agentJoinTime, totalResponseTime, responseCount, now); + } + if (visitorLastQuery > agentLastReply) { + return getSubsequentResponseAnalytics(visitorLastQuery, totalResponseTime, responseCount, now); + } +}; + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room, roomUpdater }) => { + if (!message || isEditedMessage(message) || isSystemMessage(message)) { + return message; + } + + if (message.file) { + message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; + } + + if (isMessageFromVisitor(message)) { + LivechatRooms.getAnalyticsUpdateQueryBySentByVisitor(room, message, roomUpdater); + } else { + if (settings.get('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message))) { + return message; + } + + const analyticsData = getAnalyticsData(room, new Date()); + LivechatRooms.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, roomUpdater); + } + + return message; + }, + callbacks.priority.LOW, + 'saveAnalyticsData', +); diff --git a/apps/meteor/server/hooks/omnichannel/saveLastMessageToInquiry.ts b/apps/meteor/server/hooks/omnichannel/saveLastMessageToInquiry.ts new file mode 100644 index 0000000..be98692 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/saveLastMessageToInquiry.ts @@ -0,0 +1,34 @@ +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { LivechatInquiry } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import { notifyOnLivechatInquiryChanged } from '../../lib/notifyListener'; +import { RoutingManager } from '../../lib/omnichannel/RoutingManager'; +import { settings } from '../../settings'; + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room }) => { + if (isEditedMessage(message) || message.t) { + return message; + } + + if (!RoutingManager.getConfig()?.showQueue) { + // since last message is only getting used on UI as preview message when queue is enabled + return message; + } + + if (!settings.get('Store_Last_Message')) { + return message; + } + + const livechatInquiry = await LivechatInquiry.setLastMessageByRoomId(room._id, message); + if (livechatInquiry) { + void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { lastMessage: message }); + } + + return message; + }, + callbacks.priority.LOW, + 'save-last-message-to-inquiry', +); diff --git a/apps/meteor/server/hooks/omnichannel/saveLastVisitorMessageTs.ts b/apps/meteor/server/hooks/omnichannel/saveLastVisitorMessageTs.ts new file mode 100644 index 0000000..2917b04 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/saveLastVisitorMessageTs.ts @@ -0,0 +1,19 @@ +import { isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { roomUpdater }) => { + if (message.t || !isMessageFromVisitor(message)) { + return message; + } + + await LivechatRooms.getVisitorLastMessageTsUpdateQueryByRoomId(message.ts, roomUpdater); + + return message; + }, + callbacks.priority.HIGH, + 'save-last-visitor-message-timestamp', +); diff --git a/apps/meteor/server/hooks/omnichannel/sendEmailTranscriptOnClose.ts b/apps/meteor/server/hooks/omnichannel/sendEmailTranscriptOnClose.ts new file mode 100644 index 0000000..0dfe9c7 --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/sendEmailTranscriptOnClose.ts @@ -0,0 +1,63 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { callbacks } from '../../lib/callbacks'; +import type { CloseRoomParams } from '../../lib/omnichannel/localTypes'; +import { sendTranscript } from '../../lib/omnichannel/sendTranscript'; + +type LivechatCloseCallbackParams = { + room: IOmnichannelRoom; + options: CloseRoomParams['options']; +}; + +const sendEmailTranscriptOnClose = async (params: LivechatCloseCallbackParams): Promise => { + const { room, options } = params; + + if (!isOmnichannelRoom(room)) { + return params; + } + + const { _id: rid, v: { token } = {} } = room; + if (!token) { + return params; + } + + const transcriptData = resolveTranscriptData(room, options); + if (!transcriptData) { + return params; + } + + const { email, subject, requestedBy: user } = transcriptData; + + await Promise.all([sendTranscript({ token, rid, email, subject, user }), LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid)]); + + delete room.transcriptRequest; + + return { + room, + options, + }; +}; + +const resolveTranscriptData = ( + room: IOmnichannelRoom, + options: LivechatCloseCallbackParams['options'] = {}, +): IOmnichannelRoom['transcriptRequest'] | undefined => { + const { transcriptRequest: roomTranscriptRequest } = room; + + const { emailTranscript: optionsTranscriptRequest } = options; + + // Note: options.emailTranscript will override the room.transcriptRequest check + // If options.emailTranscript is not set, then the room.transcriptRequest will be checked + if (optionsTranscriptRequest === undefined) { + return roomTranscriptRequest; + } + + if (!optionsTranscriptRequest.sendToVisitor) { + return undefined; + } + return optionsTranscriptRequest.requestData; +}; + +callbacks.add('livechat.closeRoom', sendEmailTranscriptOnClose, callbacks.priority.HIGH, 'livechat-send-email-transcript-on-close-room'); diff --git a/apps/meteor/server/hooks/omnichannel/sendToCRM.ts b/apps/meteor/server/hooks/omnichannel/sendToCRM.ts new file mode 100644 index 0000000..b579fbb --- /dev/null +++ b/apps/meteor/server/hooks/omnichannel/sendToCRM.ts @@ -0,0 +1,263 @@ +import type { IOmnichannelRoom, IOmnichannelSystemMessage, IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Messages } from '@rocket.chat/models'; +import type { Response } from '@rocket.chat/server-fetch'; + +import { callbacks } from '../../lib/callbacks'; +import { getLivechatRoomGuestInfo } from '../../lib/omnichannel/guests'; +import { sendRequest } from '../../lib/omnichannel/webhooks'; +import { normalizeMessageFileUpload } from '../../lib/utils/functions/normalizeMessageFileUpload'; +import { settings } from '../../settings'; + +type AdditionalFields = + | Record + | { + departmentId: IOmnichannelRoom['departmentId']; + } + | { + departmentId: IOmnichannelRoom['departmentId']; + servedBy: IOmnichannelRoom['servedBy']; + } + | { + departmentId: IOmnichannelRoom['departmentId']; + servedBy: IOmnichannelRoom['servedBy']; + oldDepartmentId: IOmnichannelRoom['departmentId']; + oldServedBy: { _id: string; ts: Date; username?: string }; + } + | { + departmentId: IOmnichannelRoom['departmentId']; + servedBy: IOmnichannelRoom['servedBy']; + closedAt: IOmnichannelRoom['closedAt']; + closedBy: IOmnichannelRoom['closedBy']; + closer: IOmnichannelRoom['closer']; + }; + +type OmnichannelRoomWithExtraFields = IOmnichannelRoom & { + oldServedBy?: { _id: string; ts: Date; username?: string }; + oldDepartmentId?: IOmnichannelRoom['departmentId']; +}; + +type CRMActions = + | 'LivechatSessionStart' + | 'LivechatSessionQueued' + | 'LivechatSession' + | 'LivechatSessionTaken' + | 'LivechatSessionForwarded' + | 'LivechatEdit' + | 'Message' + | 'LeadCapture'; + +const msgNavType = 'livechat_navigation_history'; +const msgClosingType = 'livechat-close'; + +export const isOmnichannelNavigationMessage = (message: IMessage): message is IOmnichannelSystemMessage => { + return message.t === msgNavType; +}; + +export const isOmnichannelClosingMessage = (message: IMessage): message is IOmnichannelSystemMessage => { + return message.t === msgClosingType; +}; + +export const sendMessageType = (msgType: string): boolean => { + switch (msgType) { + case msgClosingType: + return true; + case msgNavType: + return ( + settings.get('Livechat_Visitor_navigation_as_a_message') && + settings.get('Send_visitor_navigation_history_livechat_webhook_request') + ); + default: + return false; + } +}; + +export const getAdditionalFieldsByType = (type: CRMActions, room: OmnichannelRoomWithExtraFields): AdditionalFields => { + const { departmentId, servedBy, closedAt, closedBy, closer, oldServedBy, oldDepartmentId } = room; + switch (type) { + case 'LivechatSessionStart': + case 'LivechatSessionQueued': + return { departmentId }; + case 'LivechatSession': + return { departmentId, servedBy, closedAt, closedBy, closer }; + case 'LivechatSessionTaken': + return { departmentId, servedBy }; + case 'LivechatSessionForwarded': + return { departmentId, servedBy, oldDepartmentId, oldServedBy }; + default: + return {}; + } +}; + +export async function sendToCRM( + type: CRMActions, + room: OmnichannelRoomWithExtraFields, + includeMessages: boolean | IOmnichannelSystemMessage[] = true, +): Promise { + if (!settings.get('Livechat_webhookUrl')) { + return room; + } + + const postData: Awaited> & { + type: string; + messages: IOmnichannelSystemMessage[]; + } = { + ...(await getLivechatRoomGuestInfo(room)), + type, + messages: [], + }; + + let messages: IOmnichannelSystemMessage[] | null = null; + if (typeof includeMessages === 'boolean' && includeMessages) { + messages = await Messages.findVisibleByRoomId(room._id, { sort: { ts: 1 } }).toArray(); + } else if (includeMessages instanceof Array) { + messages = includeMessages; + } + + if (messages) { + for (const message of messages) { + if (message.t && !sendMessageType(message.t)) { + continue; + } + const msg = { + _id: message._id, + username: message.u.username, + msg: message.msg || JSON.stringify(message.blocks), + ...(message.blocks && message.blocks.length > 0 ? { blocks: message.blocks } : {}), + ts: message.ts, + rid: message.rid, + ...(isEditedMessage(message) && { editedAt: message.editedAt }), + ...(message.u.username !== postData.visitor.username && { agentId: message.u._id }), + ...(isOmnichannelNavigationMessage(message) && { navigation: message.navigation }), + ...(isOmnichannelClosingMessage(message) && { closingMessage: true }), + ...(message.file && { file: message.file, attachments: message.attachments }), + }; + + const { u } = message; + postData.messages.push({ ...(await normalizeMessageFileUpload({ u, ...msg })), ...{ _updatedAt: message._updatedAt } }); + } + } + + const additionalData = getAdditionalFieldsByType(type, room); + const responseData = Object.assign(postData, additionalData); + + // do not wait for the request to be answered + // this will avoid blocking the process of saving the message + void sendRequest(responseData, 5, async (response: Response) => { + if (!response) { + return; + } + const responseData = await response.text(); + await LivechatRooms.saveCRMDataByRoomId(room._id, responseData); + }); + + return room; +} + +callbacks.add( + 'livechat.closeRoom', + async (params) => { + const { room } = params; + if (!settings.get('Livechat_webhook_on_close')) { + return params; + } + + await sendToCRM('LivechatSession', room); + + return params; + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-close-room', +); + +callbacks.add( + 'livechat.afterForwardChatToAgent', + async (params) => { + const { rid, oldServedBy } = params; + if (!settings.get('Livechat_webhook_on_forward')) { + return params; + } + + const originalRoom = await LivechatRooms.findOneById(rid); + if (!originalRoom) { + return params; + } + + const room = Object.assign(originalRoom, { oldServedBy }); + await sendToCRM('LivechatSessionForwarded', room); + return params; + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-room-forwarded-to-agent', +); + +callbacks.add( + 'livechat.afterForwardChatToDepartment', + async (params) => { + const { rid, oldDepartmentId } = params; + if (!settings.get('Livechat_webhook_on_forward')) { + return params; + } + + const originalRoom = await LivechatRooms.findOneById(rid); + if (!originalRoom) { + return params; + } + + const room = Object.assign(originalRoom, { oldDepartmentId }); + await sendToCRM('LivechatSessionForwarded', room); + return params; + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-room-forwarded-to-department', +); + +callbacks.add( + 'livechat.saveInfo', + async (room) => { + // Do not send to CRM if the chat is still open + if (!isOmnichannelRoom(room) || room.open) { + return room; + } + + return sendToCRM('LivechatEdit', room); + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-save-info', +); + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room }) => { + // if the message has a token, it was sent from the visitor + // if not, it was sent from the agent + if (message.token && !settings.get('Livechat_webhook_on_visitor_message')) { + return message; + } + if (!message.token && !settings.get('Livechat_webhook_on_agent_message')) { + return message; + } + // if the message has a type means it is a special message (like the closing comment), so skips + // unless the settings that handle with visitor navigation history are enabled + if (message.t && !sendMessageType(message.t)) { + return message; + } + + await sendToCRM('Message', room, [message]); + return message; + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-message', +); + +callbacks.add( + 'livechat.leadCapture', + (room) => { + if (!settings.get('Livechat_webhook_on_capture')) { + return room; + } + return sendToCRM('LeadCapture', room, false); + }, + callbacks.priority.MEDIUM, + 'livechat-send-crm-lead-capture', +); diff --git a/apps/meteor/server/hooks/rooms/beforeAddUserToRoom.ts b/apps/meteor/server/hooks/rooms/beforeAddUserToRoom.ts new file mode 100644 index 0000000..984dc34 --- /dev/null +++ b/apps/meteor/server/hooks/rooms/beforeAddUserToRoom.ts @@ -0,0 +1,6 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const beforeAddUserToRoom = makeFunction(async (_users: IUser['username'][], _room: IRoom, _actor?: IUser) => { + // no op on CE +}); diff --git a/apps/meteor/server/hooks/sauMonitorHooks.ts b/apps/meteor/server/hooks/sauMonitorHooks.ts new file mode 100644 index 0000000..adf252a --- /dev/null +++ b/apps/meteor/server/hooks/sauMonitorHooks.ts @@ -0,0 +1,59 @@ +import { hashLoginToken } from '@rocket.chat/account-utils'; +import { InstanceStatus } from '@rocket.chat/instance-status'; +import { getHeader } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import type { ILoginAttempt } from '../lib/auth/ILoginAttempt'; +import { deviceManagementEvents } from '../services/device-management/events'; +import { sauEvents } from '../services/sauMonitor/events'; + +Accounts.onLogin((info: ILoginAttempt) => { + const { + methodArguments, + connection: { httpHeaders }, + } = info; + + if (!info.user) { + return; + } + + const { resume } = methodArguments.find((arg) => 'resume' in arg) ?? {}; + const loginToken = resume ? hashLoginToken(resume) : ''; + const instanceId = InstanceStatus.id(); + const clientAddress = info.connection.clientAddress || getHeader(httpHeaders, 'x-real-ip'); + const userAgent = getHeader(httpHeaders, 'user-agent'); + const host = getHeader(httpHeaders, 'host'); + + sauEvents.emit('sau.accounts.login', { + userId: info.user._id, + instanceId, + userAgent, + loginToken, + connectionId: info.connection.id, + clientAddress, + host, + }); + + if (!loginToken) { + deviceManagementEvents.emit('device-login', { userId: info.user._id, userAgent, clientAddress }); + } +}); + +Accounts.onLogout((info) => { + if (!info.user) { + return; + } + + sauEvents.emit('sau.accounts.logout', { userId: info.user._id, sessionId: info.connection.id }); +}); + +Meteor.onConnection((connection) => { + connection.onClose(async () => { + sauEvents.emit('sau.socket.disconnected', { connectionId: connection.id, instanceId: InstanceStatus.id() }); + }); +}); + +Meteor.onConnection((connection) => { + sauEvents.emit('sau.socket.connected', { instanceId: InstanceStatus.id(), connectionId: connection.id }); +}); diff --git a/apps/meteor/server/hooks/userLogoutCleanUp.ts b/apps/meteor/server/hooks/userLogoutCleanUp.ts new file mode 100644 index 0000000..c725cdb --- /dev/null +++ b/apps/meteor/server/hooks/userLogoutCleanUp.ts @@ -0,0 +1,21 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; + +import { afterLogoutCleanUpCallback } from '../lib/callbacks/afterLogoutCleanUpCallback'; + +export const runUserLogoutCleanUp = async (user: IUser): Promise => { + setImmediate(() => { + void afterLogoutCleanUpCallback.run(user); + }); + + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user); +}; + +Accounts.onLogout(async ({ user }: { user?: IUser }) => { + if (!user) { + return; + } + + await runUserLogoutCleanUp(user); +}); diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts new file mode 100644 index 0000000..94796dd --- /dev/null +++ b/apps/meteor/server/importPackages.ts @@ -0,0 +1,112 @@ +import './lib/cors'; +import './lib/2fa/MethodInvocationOverride'; +import './lib/2fa/loginHandler'; +import './api'; +import './lib/auth-providers/apple/appleOauthRegisterService'; +import './lib/auth-providers/apple/loginHandler'; +import './lib/auth-providers/apple/applePassportOAuth'; +import './lib/media/assets'; +import './lib/authorization'; +import './lib/autotranslate'; +import './lib/bot-helpers'; +import './lib/rooms/settings'; +import './lib/cloud'; +import './lib/auth-providers/crowd/crowd'; +import './lib/auth-providers/custom-oauth/custom_oauth_server'; +import './lib/media/custom-sounds/startup/custom-sounds'; +import './lib/auth-providers/dolphin'; +import './lib/auth-providers/drupal'; +import './lib/messaging/emoji'; +import './lib/media/emoji-custom/startup/emoji-custom'; +import './lib/media/emoji-native/lib'; +import './lib/media/emoji-native/callbacks'; +import './lib/error-handler'; +import './lib/media/file'; +import './lib/media/file-upload'; +import './lib/auth-providers/github-enterprise'; +import './lib/auth-providers/gitlab'; +import './lib/auth-providers/google'; +import './lib/auth-providers/iframe'; +import './lib/import'; +import './lib/import/csv'; +import './lib/import/omnichannel-contacts'; +import './lib/import/pending-files'; +import './lib/import/pending-avatars'; +import './lib/import/slack'; +import './lib/import/slack-users'; +import './lib/integrations/logger'; +import './lib/integrations/lib/validateOutgoingIntegration'; +import './api/webhooks'; +import './lib/integrations/lib/triggerHandler'; +import './lib/integrations/triggers'; +import './lib/integrations/startup'; +import './bridges/irc'; +import '../app/lib/lib/MessageTypes'; +import './lib/bugsnag'; +import './lib/debug'; +import './lib/auth/loginErrorMessageOverride'; +import './lib/auth-providers/oauth/oauth'; +import './lib/auth-providers/oauth/facebook'; +import './lib/auth-providers/oauth/google'; +import './lib/auth-providers/oauth/proxy'; +import './lib/auth-providers/oauth/twitter'; +import './hooks/messages/mentionUserNotInChannel'; +import './hooks/afterUserActions'; +import './hooks/messages/notifyUsersOnMessage'; +import './lib/auth-providers/meteor-developer'; +import './lib/auth-providers/linkedin'; +import './lib/auth/token-login'; +import './lib/notifications/email/api'; +import './lib/messaging/markdown'; +import './lib/messaging/mentions/getMentionedTeamMembers'; +import './lib/messaging/unread/unreadMessages'; +import './lib/messaging/pins/pinMessage'; +import './lib/messaging/stars/starMessage'; +import './bridges/nextcloud'; +import './lib/auth/oauth2-server'; +import './lib/notifications/push-config'; +import './lib/rooms/retention'; +import './bridges/slack'; +import './slashcommands/archiveroom'; +import './slashcommands/asciiarts'; +import './slashcommands/ban'; +import './slashcommands/create'; +import './slashcommands/help'; +import './slashcommands/hide'; +import './slashcommands/invite'; +import './slashcommands/inviteall'; +import './slashcommands/join'; +import './slashcommands/kick'; +import './slashcommands/leave'; +import './slashcommands/me'; +import './slashcommands/msg'; +import './slashcommands/mute'; +import './slashcommands/status'; +import './slashcommands/topic'; +import './slashcommands/unarchiveroom'; +import './bridges/smarsh'; +import './settings/theme'; +import './hooks/messages/processThreads'; +import './lib/ui-master'; +import './bridges/webdav'; +import './lib/auth-providers/wordpress'; +import './lib/saml/startup'; +import './lib/saml/loginHandler'; +import './lib/saml/listener'; +import './lib/e2e'; +import './lib/cloud/version-check'; +import './lib/search/model/SearchProvider'; +import './lib/search/events'; +import './lib/search/search.internalService'; +import './lib/search/register'; +import './lib/search/startup'; +import './lib/search/service'; +import './lib/messaging/discussions/permissions'; +import './hooks/messages/propagateDiscussionMetadata'; +import './lib/metrics'; +import './lib/notifications/core'; +import './lib/messaging/reactions/setReaction'; +import './lib/omnichannel'; +import './hooks/auth/login'; +import './lib/auth/startup'; +import './lib/auth-providers/github'; diff --git a/apps/meteor/server/lib/2fa/MethodInvocationOverride.js b/apps/meteor/server/lib/2fa/MethodInvocationOverride.js new file mode 100644 index 0000000..e176402 --- /dev/null +++ b/apps/meteor/server/lib/2fa/MethodInvocationOverride.js @@ -0,0 +1,17 @@ +import { DDP } from 'meteor/ddp'; +import { DDPCommon } from 'meteor/ddp-common'; + +class MethodInvocation extends DDPCommon.MethodInvocation { + constructor(options) { + const result = super(options); + const currentInvocation = DDP._CurrentInvocation.get(); + + if (currentInvocation) { + this.twoFactorChecked = currentInvocation.twoFactorChecked; + } + + return result; + } +} + +DDPCommon.MethodInvocation = MethodInvocation; diff --git a/apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts b/apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts new file mode 100644 index 0000000..fd56eef --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/EmailCheck.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsMock = sinon.stub(); + +const { EmailCheck } = proxyquire.noCallThru().load('./EmailCheck', { + '@rocket.chat/models': { + Users: {}, + }, + 'meteor/accounts-base': { + Accounts: { + _bcryptRounds: () => '123', + }, + }, + '../../i18n': { + i18n: { + t: (key: string) => key, + }, + }, + '../../notifications/email/api': { + send: () => undefined, + }, + '../../../settings': { + settings: { + get: settingsMock, + }, + }, +}); + +const normalUserMock = { services: { email2fa: { enabled: true } }, emails: [{ email: 'abc@gmail.com', verified: true }] }; +const normalUserWithUnverifiedEmailMock = { + services: { email2fa: { enabled: true } }, + emails: [{ email: 'abc@gmail.com', verified: false }], +}; +const OAuthUserMock = { services: { google: {} }, emails: [{ email: 'abc@gmail.com', verified: true }] }; + +describe('EmailCheck', () => { + let emailCheck: typeof EmailCheck; + beforeEach(() => { + settingsMock.reset(); + + emailCheck = new EmailCheck(); + }); + + it('should return EmailCheck is enabled for a normal user', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserMock); + + expect(isEmail2FAEnabled).to.be.equal(true); + }); + + it('should return EmailCheck is not enabled for a normal user with unverified email', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserWithUnverifiedEmailMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); + + it('should return EmailCheck is not enabled for a OAuth user with setting being false', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(OAuthUserMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); +}); diff --git a/apps/meteor/server/lib/2fa/code/EmailCheck.ts b/apps/meteor/server/lib/2fa/code/EmailCheck.ts new file mode 100644 index 0000000..4572a09 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/EmailCheck.ts @@ -0,0 +1,150 @@ +import { isOAuthUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import bcrypt from 'bcrypt'; +import { Accounts } from 'meteor/accounts-base'; + +import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck'; +import { settings } from '../../../settings'; +import { i18n } from '../../i18n'; +import * as Mailer from '../../notifications/email/api'; + +export class EmailCheck implements ICodeCheck { + public readonly name: string = 'email'; + + private getUserVerifiedEmails(user: TwoFactorUser): string[] { + if (!Array.isArray(user.emails)) { + return []; + } + return user.emails.filter(({ verified }) => verified).map((e) => e.address); + } + + public isEnabled(user: TwoFactorUser): boolean { + if (!settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled')) { + return false; + } + + if (!settings.get('Accounts_twoFactorAuthentication_email_available_for_OAuth_users') && isOAuthUser(user)) { + return false; + } + + if (!user.services?.email2fa?.enabled) { + return false; + } + + return this.getUserVerifiedEmails(user).length > 0; + } + + private async send2FAEmail(address: string, random: string, user: TwoFactorUser): Promise { + const language = user.language || settings.get('Language') || 'en'; + + const t = i18n.getFixedT(language); + + await Mailer.send({ + to: address, + from: settings.get('From_Email'), + subject: 'Authentication code', + replyTo: undefined, + data: { + code: random.replace(/^(\d{3})/, '$1-'), + }, + headers: undefined, + text: ` +${t('Here_is_your_authentication_code')} + +__code__ + +${t('Do_not_provide_this_code_to_anyone')} +${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} +`, + html: ` +

${t('Here_is_your_authentication_code')}

+

+ __code__ +

+

${t('Do_not_provide_this_code_to_anyone')}

+

${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')}

+ `, + }); + } + + public async verify(user: TwoFactorUser, codeFromEmail: string): Promise { + if (!this.isEnabled(user)) { + return false; + } + + if (!user.services?.emailCode) { + return false; + } + + // Remove non digits + codeFromEmail = codeFromEmail.replace(/([^\d])/g, ''); + + const { code, expire } = user.services.emailCode; + + if (expire < new Date()) { + return false; + } + + if (await bcrypt.compare(codeFromEmail, code)) { + await Users.removeEmailCodeOfUserId(user._id); + return true; + } + + await Users.incrementInvalidEmailCodeAttempt(user._id); + + return false; + } + + public async sendEmailCode(user: TwoFactorUser): Promise { + const emails = this.getUserVerifiedEmails(user); + const random = Random._randomString(6, '0123456789'); + const encryptedRandom = await bcrypt.hash(random, Accounts._bcryptRounds()); + const expire = new Date(); + const expirationInSeconds = parseInt(settings.get('Accounts_TwoFactorAuthentication_By_Email_Code_Expiration'), 10); + + expire.setSeconds(expire.getSeconds() + expirationInSeconds); + + await Users.addEmailCodeByUserId(user._id, encryptedRandom, expire); + + for (const address of emails) { + await this.send2FAEmail(address, random, user); + } + } + + public async processInvalidCode(user: TwoFactorUser): Promise { + await Users.removeExpiredEmailCodeOfUserId(user._id); + + // Generate new code if the there isn't any code with more than 5 minutes to expire + const expireWithDelta = new Date(); + expireWithDelta.setMinutes(expireWithDelta.getMinutes() - 5); + + const emails = this.getUserVerifiedEmails(user); + + const emailOrUsername = user.username || emails[0]; + + const hasValidCode = + user.services?.emailCode?.expire && + user.services?.emailCode?.expire > expireWithDelta && + !(await this.maxFaildedAttemtpsReached(user)); + if (hasValidCode) { + return { + emailOrUsername, + codeGenerated: false, + codeExpires: user.services?.emailCode?.expire, + }; + } + + await this.sendEmailCode(user); + + return { + codeGenerated: true, + emailOrUsername, + }; + } + + public async maxFaildedAttemtpsReached(user: TwoFactorUser) { + const maxAttempts = settings.get('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts'); + return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts); + } +} diff --git a/apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts b/apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts new file mode 100644 index 0000000..83144ef --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/EmailCheckForOAuth.ts @@ -0,0 +1,38 @@ +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { EmailCheck } from './EmailCheck'; +import type { TwoFactorUser } from './ICodeCheck'; + +export class EmailCheckForOAuth extends EmailCheck { + public override readonly name = 'email-oauth'; + + public readonly method = 'email'; + + public async sendTwoFactorChallenge(user: TwoFactorUser): Promise { + const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email'); + await this.sendEmailCode(user); + return challengeId; + } + + public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/server/lib/2fa/code/ICodeCheck.ts b/apps/meteor/server/lib/2fa/code/ICodeCheck.ts new file mode 100644 index 0000000..9690816 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/ICodeCheck.ts @@ -0,0 +1,22 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +/** The user fields the 2FA checks read; `getUserForCheck` projects exactly these. */ +export type TwoFactorUser = Pick; + +export interface IProcessInvalidCodeResult { + codeGenerated: boolean; + codeExpires?: Date; + emailOrUsername?: string; +} + +export interface ICodeCheck { + readonly name: string; + + isEnabled(user: TwoFactorUser, force?: boolean): boolean; + + verify(user: TwoFactorUser, code: string, force?: boolean): Promise; + + processInvalidCode(user: TwoFactorUser): Promise; + + maxFaildedAttemtpsReached(user: TwoFactorUser): Promise; +} diff --git a/apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts b/apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts new file mode 100644 index 0000000..7bc6948 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/PasswordCheckFallback.ts @@ -0,0 +1,48 @@ +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; + +import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck'; +import { settings } from '../../../settings'; + +export class PasswordCheckFallback implements ICodeCheck { + public readonly name = 'password'; + + public isEnabled(user: TwoFactorUser, force: boolean): boolean { + if (force) { + return true; + } + // TODO: Remove this setting for version 4.0 forcing the + // password fallback for who has password set. + if (settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + return user.services?.password?.bcrypt != null; + } + return false; + } + + public async verify(user: TwoFactorUser, code: string, force: boolean): Promise { + if (!this.isEnabled(user, force)) { + return false; + } + + const passCheck = await Accounts._checkPasswordAsync(user as Meteor.User, { + digest: code.toLowerCase(), + algorithm: 'sha-256', + }); + + if (passCheck.error) { + return false; + } + + return true; + } + + public async processInvalidCode(): Promise { + return { + codeGenerated: false, + }; + } + + public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise { + return false; + } +} diff --git a/apps/meteor/server/lib/2fa/code/TOTPCheck.ts b/apps/meteor/server/lib/2fa/code/TOTPCheck.ts new file mode 100644 index 0000000..cf9dd18 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/TOTPCheck.ts @@ -0,0 +1,43 @@ +import type { ICodeCheck, IProcessInvalidCodeResult, TwoFactorUser } from './ICodeCheck'; +import { settings } from '../../../settings'; +import { TOTP } from '../lib/totp'; + +export class TOTPCheck implements ICodeCheck { + public readonly name: string = 'totp'; + + public isEnabled(user: TwoFactorUser): boolean { + if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) { + return false; + } + + return user.services?.totp?.enabled === true; + } + + public async verify(user: TwoFactorUser, code: string): Promise { + if (!this.isEnabled(user)) { + return false; + } + + if (!user.services?.totp?.secret) { + return false; + } + + return TOTP.verify({ + secret: user.services?.totp?.secret, + token: code, + userId: user._id, + backupTokens: user.services?.totp?.hashedBackup, + }); + } + + public async processInvalidCode(): Promise { + // Nothing to do + return { + codeGenerated: false, + }; + } + + public async maxFaildedAttemtpsReached(_user: TwoFactorUser): Promise { + return false; + } +} diff --git a/apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts b/apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts new file mode 100644 index 0000000..7620c77 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/TOTPCheckForOAuth.ts @@ -0,0 +1,36 @@ +import { TwoFactorChallenges } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import type { TwoFactorUser } from './ICodeCheck'; +import { TOTPCheck } from './TOTPCheck'; + +export class TOTPCheckForOAuth extends TOTPCheck { + public override readonly name = 'totp-oauth'; + + public readonly method = 'totp'; + + public async sendTwoFactorChallenge(user: TwoFactorUser): Promise { + return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp'); + } + + public async verifyEmailTwoFactorChallenge(user: TwoFactorUser, challengeId: string, code: string): Promise { + const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); + if (!challenge) { + return false; + } + + if (challenge.expireAt && challenge.expireAt < new Date()) { + throw new Meteor.Error('error-challenge-expired', 'challenge expired'); + } + + const isCodeValid = await this.verify(user, code); + + if (!isCodeValid) { + return false; + } + + await TwoFactorChallenges.removeByPendingChallengeId(challengeId); + + return true; + } +} diff --git a/apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts b/apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts new file mode 100644 index 0000000..864ed27 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/checkCodeForUser.spec.ts @@ -0,0 +1,193 @@ +import { expect } from 'chai'; +import { before, after, beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsGet = sinon.stub(); +const totpEnabled = sinon.stub().returns(false); + +class MeteorError extends Error { + error: string; + + details?: unknown; + + constructor(error: string, reason?: string, details?: unknown) { + super(reason ?? error); + this.error = error; + this.details = details; + } +} + +// Disabled second-factor methods so the password fallback is the only candidate. +class TOTPCheck { + name = 'totp'; + + isEnabled(): boolean { + return totpEnabled(); + } + + async processInvalidCode(): Promise<{ codeGenerated: boolean }> { + return { codeGenerated: false }; + } +} + +class EmailCheck { + name = 'email'; + + isEnabled(): boolean { + return false; + } +} + +class PasswordCheckFallback { + name = 'password'; + + isEnabled(): boolean { + return true; + } + + async verify(): Promise { + return false; + } + + async processInvalidCode(): Promise<{ codeGenerated: boolean }> { + return { codeGenerated: false }; + } + + async maxFaildedAttemtpsReached(): Promise { + return false; + } +} + +const { checkCodeForUser, getFingerprintFromConnection } = proxyquire.noCallThru().load('./index', { + '@rocket.chat/models': { + Users: { + findOneById: async () => null, + setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined, + }, + }, + 'meteor/accounts-base': { + Accounts: { + _getLoginToken: () => 'token-hash', + }, + }, + 'meteor/meteor': { + Meteor: { Error: MeteorError }, + }, + './TOTPCheck': { TOTPCheck }, + './EmailCheck': { EmailCheck }, + './PasswordCheckFallback': { PasswordCheckFallback }, + '../../shared/getModifiedHttpHeaders': { + normalizeHeaders: (headers: unknown) => headers, + }, + '../../../settings': { + settings: { get: settingsGet }, + }, +}); + +const REMEMBER_FOR_SECONDS = 1800; + +const buildUser = (createdAt: Date) => ({ + _id: 'user-id', + createdAt, + services: { + password: { bcrypt: 'hashed' }, + resume: { loginTokens: [{ hashedToken: 'token-hash' }] }, + }, +}); + +const connection = { + id: 'connection-id', + httpHeaders: { 'user-agent': 'agent' }, + clientAddress: '127.0.0.1', +}; + +describe('checkCodeForUser - post-registration grace window', () => { + let originalTestMode: string | undefined; + + before(() => { + originalTestMode = process.env.TEST_MODE; + delete process.env.TEST_MODE; + + settingsGet.callsFake((key: string) => { + switch (key) { + case 'Accounts_TwoFactorAuthentication_Enabled': + return true; + case 'Accounts_TwoFactorAuthentication_RememberFor': + return REMEMBER_FOR_SECONDS; + case 'Accounts_TwoFactorAuthentication_Enforce_Password_Fallback': + return true; + default: + return false; + } + }); + }); + + after(() => { + if (originalTestMode === undefined) { + delete process.env.TEST_MODE; + } else { + process.env.TEST_MODE = originalTestMode; + } + }); + + beforeEach(() => { + totpEnabled.reset(); + totpEnabled.returns(false); + }); + + it('should not prompt a freshly registered user even when disableRememberMe is set (Setup Wizard regression)', async () => { + const user = buildUser(new Date()); + + const result = await checkCodeForUser({ + user, + options: { disableRememberMe: true }, + connection, + }); + + expect(result).to.be.equal(true); + }); + + it('should still prompt a freshly registered user who already has a 2FA method configured', async () => { + totpEnabled.returns(true); + const user = buildUser(new Date()); + + await expect( + checkCodeForUser({ + user, + options: { disableRememberMe: true }, + connection, + }), + ).to.be.rejectedWith('TOTP Required'); + }); + + it('should still prompt when the post-registration grace window has expired and disableRememberMe is set', async () => { + const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000)); + + await expect( + checkCodeForUser({ + user, + options: { disableRememberMe: true }, + connection, + }), + ).to.be.rejectedWith('TOTP Required'); + }); + + it('should ignore a remembered authorization when disableRememberMe is set and grace window expired', async () => { + const user = buildUser(new Date(Date.now() - (REMEMBER_FOR_SECONDS + 60) * 1000)); + // A previously remembered 2FA authorization that is still valid in time. + user.services.resume.loginTokens[0] = { + hashedToken: 'token-hash', + twoFactorAuthorizedUntil: new Date(Date.now() + 60 * 1000), + twoFactorAuthorizedHash: getFingerprintFromConnection(connection), + } as (typeof user.services.resume.loginTokens)[number]; + + await expect( + checkCodeForUser({ + user, + options: { disableRememberMe: true }, + connection, + }), + ).to.be.rejectedWith('TOTP Required'); + }); +}); diff --git a/apps/meteor/server/lib/2fa/code/index.spec.ts b/apps/meteor/server/lib/2fa/code/index.spec.ts new file mode 100644 index 0000000..80baf62 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/index.spec.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsMock = sinon.stub(); +const getLoginTokenStub = sinon.stub(); + +class TOTPCheckMock { + name = 'totp'; + + isEnabled() { + return true; + } + + async processInvalidCode() { + return {}; + } + + async verify() { + return false; + } + + async maxFaildedAttemtpsReached() { + return false; + } +} + +class DisabledCheckMock { + name = 'email'; + + isEnabled() { + return false; + } +} + +class MeteorErrorMock extends Error { + error: string; + + details: unknown; + + constructor(error: string, reason?: string, details?: unknown) { + super(reason); + this.error = error; + this.details = details; + } +} + +const { checkCodeForUser } = proxyquire.noCallThru().load('./index', { + './TOTPCheck': { TOTPCheck: TOTPCheckMock }, + './EmailCheck': { EmailCheck: DisabledCheckMock }, + './PasswordCheckFallback': { PasswordCheckFallback: class extends DisabledCheckMock {} }, + '../../shared/getModifiedHttpHeaders': { normalizeHeaders: (headers: unknown) => headers }, + '../../../settings': { settings: { get: settingsMock } }, + '@rocket.chat/models': { + Users: { + findOneById: async () => null, + setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined, + }, + }, + 'meteor/accounts-base': { Accounts: { _getLoginToken: getLoginTokenStub } }, + 'meteor/meteor': { Meteor: { Error: MeteorErrorMock } }, +}); + +const HASHED_TOKEN = 'hashed-login-token'; + +const userWithBypassToken = { + _id: 'user-id', + services: { resume: { loginTokens: [{ hashedToken: HASHED_TOKEN, bypassTwoFactor: true }] } }, +} as any; + +const connection = { + id: 'connection-id', + clientAddress: '127.0.0.1', + httpHeaders: {}, +} as any; + +describe('checkCodeForUser - bypassTwoFactor token resolution (SUP-1064)', () => { + let testMode: string | undefined; + + beforeEach(() => { + // TEST_MODE short-circuits the whole check; remove it so the token resolution path runs. + testMode = process.env.TEST_MODE; + delete process.env.TEST_MODE; + + settingsMock.reset(); + settingsMock.withArgs('Accounts_TwoFactorAuthentication_Enabled').returns(true); + getLoginTokenStub.reset(); + }); + + afterEach(() => { + if (testMode !== undefined) { + process.env.TEST_MODE = testMode; + } + }); + + it('should honor a bypassTwoFactor token resolved from the REST connection (connection.token)', async () => { + // REST: the token is not registered in account data, only carried on the connection. + getLoginTokenStub.returns(undefined); + + const authorized = await checkCodeForUser({ + user: userWithBypassToken, + connection: { ...connection, token: HASHED_TOKEN }, + options: {}, + }); + + expect(authorized).to.be.equal(true); + }); + + it('should honor a bypassTwoFactor token resolved from account data (DDP, _getLoginToken)', async () => { + // DDP: the token is registered in Accounts._accountData and read via _getLoginToken. + getLoginTokenStub.returns(HASHED_TOKEN); + + const authorized = await checkCodeForUser({ + user: userWithBypassToken, + connection: { ...connection, token: undefined }, + options: {}, + }); + + expect(authorized).to.be.equal(true); + }); + + it('should still require a second factor when the token cannot be resolved from either source', async () => { + // Regression guard: this is the buggy state (#38017) the fix addresses. + getLoginTokenStub.returns(undefined); + + await expect( + checkCodeForUser({ + user: userWithBypassToken, + connection: { ...connection, token: undefined }, + options: {}, + }), + ).to.be.rejectedWith('TOTP Required'); + }); +}); diff --git a/apps/meteor/server/lib/2fa/code/index.ts b/apps/meteor/server/lib/2fa/code/index.ts new file mode 100644 index 0000000..619bd10 --- /dev/null +++ b/apps/meteor/server/lib/2fa/code/index.ts @@ -0,0 +1,268 @@ +import crypto from 'node:crypto'; + +import type { IUser, IMethodConnection } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { EmailCheck } from './EmailCheck'; +import type { ICodeCheck, TwoFactorUser } from './ICodeCheck'; +import { PasswordCheckFallback } from './PasswordCheckFallback'; +import { TOTPCheck } from './TOTPCheck'; +import { settings } from '../../../settings'; +import { normalizeHeaders } from '../../shared/getModifiedHttpHeaders'; + +export interface ITwoFactorOptions { + disablePasswordFallback?: boolean; + disableRememberMe?: boolean; + requireSecondFactor?: boolean; // whether any two factor should be required +} + +const totpCheck = new TOTPCheck(); +export const emailCheck = new EmailCheck(); +const passwordCheckFallback = new PasswordCheckFallback(); + +const checkMethods = new Map(); + +checkMethods.set(totpCheck.name, totpCheck); +checkMethods.set(emailCheck.name, emailCheck); + +function getMethodByNameOrFirstActiveForUser(user: TwoFactorUser, name?: string): ICodeCheck | undefined { + if (name && checkMethods.has(name)) { + return checkMethods.get(name); + } + + return Array.from(checkMethods.values()).find((method) => method.isEnabled(user)); +} + +function getAvailableMethodNames(user: TwoFactorUser): string[] { + return ( + Array.from(checkMethods) + .filter(([, method]) => method.isEnabled(user)) + .map(([name]) => name) || [] + ); +} + +export async function getUserForCheck(userId: string): Promise { + return Users.findOneById(userId, { + projection: { + username: 1, + emails: 1, + language: 1, + createdAt: 1, + services: 1, + }, + }); +} + +export function getFingerprintFromConnection(connection: IMethodConnection): string { + const data = JSON.stringify({ + userAgent: connection.httpHeaders['user-agent'], + clientAddress: connection.clientAddress, + }); + + return crypto.createHash('sha256').update(data).digest('hex'); +} + +export function getRememberDate(from: Date = new Date()): Date | undefined { + const rememberFor = settings.get('Accounts_TwoFactorAuthentication_RememberFor'); + + if (rememberFor <= 0) { + return; + } + + const expires = new Date(from); + expires.setSeconds(expires.getSeconds() + rememberFor); + + return expires; +} + +function isAuthorizedForToken(connection: IMethodConnection, user: TwoFactorUser, options: ITwoFactorOptions): boolean { + // Resolve the current login token from both transports: + // - DDP: the login flow registers it in `Accounts._accountData`, read via `_getLoginToken`. + // - REST: it is not registered in account data, so it is carried on `connection.token`. + // Both are needed; REST is the fallback that fixes `bypassTwoFactor` PATs (SUP-1064). + const currentToken = Accounts._getLoginToken(connection.id) || connection.token; + const tokenObject = user.services?.resume?.loginTokens?.find((i) => i.hashedToken === currentToken); + + if (!tokenObject) { + return false; + } + + // if any two factor is required, early abort + if (options.requireSecondFactor) { + return false; + } + + if ('bypassTwoFactor' in tokenObject && tokenObject.bypassTwoFactor === true) { + return true; + } + + // Skip 2FA for a freshly registered user who has not set up any 2FA method yet, + // until the grace period that starts at registration expires. + // (e.g. the Setup Wizard saving settings between steps before any 2FA is configured) + const rememberPeriodEnd = user.createdAt && getRememberDate(user.createdAt); + const isWithinRememberPeriod = rememberPeriodEnd && rememberPeriodEnd >= new Date(); + const hasNoTwoFactorMethod = getAvailableMethodNames(user).length === 0; + if (isWithinRememberPeriod && hasNoTwoFactorMethod) { + return true; + } + + if (options.disableRememberMe === true) { + return false; + } + + if (!tokenObject.twoFactorAuthorizedUntil || !tokenObject.twoFactorAuthorizedHash) { + return false; + } + + if (tokenObject.twoFactorAuthorizedUntil < new Date()) { + return false; + } + + if (tokenObject.twoFactorAuthorizedHash !== getFingerprintFromConnection(connection)) { + return false; + } + + return true; +} + +export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise { + const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } }); + if (!user) { + throw new Meteor.Error('error-user-not-found', 'user not found'); + } + + const expires = getRememberDate(); + if (!expires) { + return; + } + + await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires); +} + +async function rememberAuthorization(connection: IMethodConnection, user: TwoFactorUser): Promise { + // Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData` + // via `_getLoginToken`, REST falls back to the token carried on `connection.token`. + const currentToken = Accounts._getLoginToken(connection.id) || connection.token; + + const expires = getRememberDate(); + if (!expires) { + return; + } + + if (!currentToken) { + return; + } + + await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken( + user._id, + currentToken, + getFingerprintFromConnection(connection), + expires, + ); +} + +interface ICheckCodeForUser { + user: TwoFactorUser | string; + code?: string; + method?: string; + options?: ITwoFactorOptions; + connection?: IMethodConnection; +} + +export const getSecondFactorMethod = ( + user: TwoFactorUser, + method: string | undefined, + options: ITwoFactorOptions, +): ICodeCheck | undefined => { + // try first getting one of the available methods or the one that was already provided + const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); + if (selectedMethod) { + return selectedMethod; + } + + // if none found but a second factor is required, chose the password check + if (options.requireSecondFactor) { + return passwordCheckFallback; + } + + // check if password fallback is enabled + if (!options.disablePasswordFallback && passwordCheckFallback.isEnabled(user, !!options.requireSecondFactor)) { + return passwordCheckFallback; + } +}; + +export async function checkCodeForUser({ user, code, method, options = {}, connection }: ICheckCodeForUser): Promise { + if (process.env.TEST_MODE && !options.requireSecondFactor) { + return true; + } + + if (!settings.get('Accounts_TwoFactorAuthentication_Enabled')) { + return true; + } + + let existingUser: TwoFactorUser | null; + if (typeof user === 'string') { + existingUser = await getUserForCheck(user); + } else { + existingUser = user; + } + + if (!existingUser) { + throw new Meteor.Error('totp-user-not-found', 'TOTP User not found'); + } + + const headers = normalizeHeaders(connection?.httpHeaders); + + if (!code && !method && headers?.['x-2fa-code'] && headers['x-2fa-method']) { + code = headers['x-2fa-code']; + method = headers['x-2fa-method']; + } + + if (connection && isAuthorizedForToken(connection, existingUser, options)) { + return true; + } + + // select a second factor method or return if none is found/available + const selectedMethod = getSecondFactorMethod(existingUser, method, options); + if (!selectedMethod) { + return true; + } + + const data = await selectedMethod.processInvalidCode(existingUser); + + const availableMethods = getAvailableMethodNames(existingUser); + + if (!code) { + throw new Meteor.Error('totp-required', 'TOTP Required', { + method: selectedMethod.name, + ...data, + availableMethods, + }); + } + + const valid = await selectedMethod.verify(existingUser, code, options.requireSecondFactor); + if (!valid) { + const tooManyFailedAttempts = await selectedMethod.maxFaildedAttemtpsReached(existingUser); + if (tooManyFailedAttempts) { + throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached', { + method: selectedMethod.name, + ...data, + availableMethods, + }); + } + + throw new Meteor.Error('totp-invalid', 'TOTP Invalid', { + method: selectedMethod.name, + ...data, + availableMethods, + }); + } + + if (options.disableRememberMe !== true && connection) { + await rememberAuthorization(connection, existingUser); + } + + return true; +} diff --git a/apps/meteor/server/lib/2fa/functions/resetTOTP.ts b/apps/meteor/server/lib/2fa/functions/resetTOTP.ts new file mode 100644 index 0000000..b9a21e1 --- /dev/null +++ b/apps/meteor/server/lib/2fa/functions/resetTOTP.ts @@ -0,0 +1,84 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { i18n } from '../../i18n'; +import { isUserIdFederated } from '../../isUserIdFederated'; +import * as Mailer from '../../notifications/email/api'; +import { notifyOnUserChange } from '../../notifyListener'; + +const sendResetNotification = async function (uid: string): Promise { + const user = await Users.findOneById>(uid, { + projection: { language: 1, emails: 1 }, + }); + if (!user) { + throw new Meteor.Error('invalid-user'); + } + + const language = user.language || settings.get('Language') || 'en'; + const addresses = user.emails?.filter(({ verified }) => Boolean(verified)).map((e) => e.address); + if (!addresses?.length) { + return; + } + + const t = i18n.getFixedT(language); + const text = ` + ${t('Your_TOTP_has_been_reset')} + + ${t('TOTP_Reset_Other_Key_Warning')} + `; + const html = ` +

${t('Your_TOTP_has_been_reset')}

+

${t('TOTP_Reset_Other_Key_Warning')}

+ `; + + const from = settings.get('From_Email'); + const subject = t('TOTP_reset_email'); + + for (const address of addresses) { + try { + await Mailer.send({ + to: address, + from, + subject, + text, + html, + } as any); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, { + function: 'resetUserTOTP', + message, + }); + } + } +}; + +export async function resetTOTP(userId: string, notifyUser = false): Promise { + if (notifyUser) { + await sendResetNotification(userId); + } + + const isUserFederated = await isUserIdFederated(userId); + if (isUserFederated) { + throw new Meteor.Error('error-not-allowed', 'Federated Users cant have TOTP', { function: 'resetTOTP' }); + } + + const result = await Users.resetTOTPById(userId); + + if (result?.modifiedCount === 1) { + await Users.unsetLoginTokens(userId); + + void notifyOnUserChange({ + clientAction: 'updated', + id: userId, + diff: { + 'services.resume.loginTokens': [], + }, + }); + return true; + } + + return false; +} diff --git a/apps/meteor/server/lib/2fa/functions/totp.ts b/apps/meteor/server/lib/2fa/functions/totp.ts new file mode 100644 index 0000000..d0d1a4d --- /dev/null +++ b/apps/meteor/server/lib/2fa/functions/totp.ts @@ -0,0 +1,154 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../notifyListener'; +import { TOTP } from '../lib/totp'; + +const requireUser = async (userId: string | null) => { + if (!userId) { + throw new Meteor.Error('not-authorized'); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + return user; +}; + +export const enableTotp = async (userId: string | null): Promise<{ secret: string; url: string }> => { + const user = await requireUser(userId); + + if (!user.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + if (user.services?.totp?.enabled) { + throw new Meteor.Error('error-2fa-already-enabled'); + } + + const secret = TOTP.generateSecret(); + + await Users.disable2FAAndSetTempSecretByUserId(user._id, secret.base32); + + return { + secret: secret.base32, + url: TOTP.generateOtpauthURL(secret, user.username), + }; +}; + +export const disableTotp = async (userId: string | null, code: string): Promise => { + const user = await requireUser(userId); + + if (!user.services?.totp?.enabled) { + return false; + } + + const verified = await TOTP.verify({ + secret: user.services.totp.secret, + token: code, + userId: user._id, + backupTokens: user.services.totp.hashedBackup, + }); + + if (!verified) { + return false; + } + + const { modifiedCount } = await Users.disable2FAByUserId(user._id); + + if (!modifiedCount) { + return false; + } + + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': false } }); + + return true; +}; + +export const validateTotpTempToken = async (userId: string | null, userToken: string, authToken?: string): Promise<{ codes: string[] }> => { + const user = await requireUser(userId); + + if (!user.services?.totp?.tempSecret) { + throw new Meteor.Error('invalid-totp'); + } + + const verified = await TOTP.verify({ + secret: user.services.totp.tempSecret, + token: userToken, + }); + if (!verified) { + throw new Meteor.Error('invalid-totp'); + } + + const { codes, hashedCodes } = TOTP.generateCodes(); + + await Users.enable2FAAndSetSecretAndCodesByUserId(user._id, user.services.totp.tempSecret, hashedCodes); + + if (authToken) { + const hashedToken = Accounts._hashLoginToken(authToken); + + const { modifiedCount } = await Users.removeNonPATLoginTokensExcept(user._id, hashedToken); + + if (modifiedCount > 0) { + void notifyOnUserChangeAsync(async () => { + const refreshed = await Users.findOneById(user._id, { + projection: { 'services.resume.loginTokens': 1, 'services.totp': 1 }, + }); + return { + clientAction: 'updated', + id: user._id, + diff: { + 'services.resume.loginTokens': refreshed?.services?.resume?.loginTokens, + ...(refreshed?.services?.totp && { 'services.totp.enabled': refreshed.services.totp.enabled }), + }, + }; + }); + } else { + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } }); + } + } else { + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { 'services.totp.enabled': true } }); + } + + return { codes }; +}; + +export const regenerateTotpCodes = async (userId: string | null, userToken: string): Promise<{ codes: string[] } | undefined> => { + const user = await requireUser(userId); + + if (!user.services?.totp?.enabled) { + throw new Meteor.Error('invalid-totp'); + } + + const verified = await TOTP.verify({ + secret: user.services.totp.secret, + token: userToken, + userId: user._id, + backupTokens: user.services.totp.hashedBackup, + }); + + if (!verified) { + return undefined; + } + + const { codes, hashedCodes } = TOTP.generateCodes(); + + await Users.update2FABackupCodesByUserId(user._id, hashedCodes); + + return { codes }; +}; + +export const codesRemainingTotp = async (userId: string | null): Promise<{ remaining: number }> => { + const user = await requireUser(userId); + + if (!user.services?.totp?.enabled) { + throw new Meteor.Error('invalid-totp'); + } + + return { + remaining: user.services.totp.hashedBackup?.length ?? 0, + }; +}; diff --git a/apps/meteor/server/lib/2fa/lib/totp.ts b/apps/meteor/server/lib/2fa/lib/totp.ts new file mode 100644 index 0000000..8ea7cb2 --- /dev/null +++ b/apps/meteor/server/lib/2fa/lib/totp.ts @@ -0,0 +1,78 @@ +import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { SHA256 } from '@rocket.chat/sha256'; +import speakeasy from 'speakeasy'; + +import { settings } from '../../../settings'; + +export const TOTP = { + generateSecret(): speakeasy.GeneratedSecret { + return speakeasy.generateSecret(); + }, + + generateOtpauthURL(secret: speakeasy.GeneratedSecret, username: string): string { + return speakeasy.otpauthURL({ + secret: secret.ascii, + label: `Rocket.Chat:${username}`, + }); + }, + + async verify({ + secret, + token, + backupTokens, + userId, + }: { + secret: string; + token: string; + backupTokens?: string[]; + userId?: string; + }): Promise { + // validates a backup code + if (token.length === 8 && backupTokens) { + const hashedCode = SHA256(token); + const usedCode = backupTokens.indexOf(hashedCode); + + if (usedCode !== -1 && userId) { + backupTokens.splice(usedCode, 1); + + // mark the code as used (remove it from the list) + await Users.update2FABackupCodesByUserId(userId, backupTokens); + return true; + } + + return false; + } + + const maxDelta = settings.get('Accounts_TwoFactorAuthentication_MaxDelta'); + if (maxDelta) { + const verifiedDelta = speakeasy.totp.verifyDelta({ + secret, + encoding: 'base32', + token, + window: maxDelta, + }); + + return verifiedDelta !== undefined; + } + + return speakeasy.totp.verify({ + secret, + encoding: 'base32', + token, + }); + }, + + generateCodes(): { codes: string[]; hashedCodes: string[] } { + // generate 12 backup codes + const codes = []; + const hashedCodes = []; + for (let i = 0; i < 12; i++) { + const code = Random.id(8); + codes.push(code); + hashedCodes.push(SHA256(code)); + } + + return { codes, hashedCodes }; + }, +}; diff --git a/apps/meteor/server/lib/2fa/loginHandler.ts b/apps/meteor/server/lib/2fa/loginHandler.ts new file mode 100644 index 0000000..4b1742e --- /dev/null +++ b/apps/meteor/server/lib/2fa/loginHandler.ts @@ -0,0 +1,110 @@ +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { checkCodeForUser } from './code'; +import { callbacks } from '../callbacks'; + +const isMeteorError = (error: any): error is Meteor.Error => { + return error?.meteorError !== undefined; +}; + +const isCredentialWithError = (credential: any): credential is { error: Error } => { + return credential?.error !== undefined; +}; + +Accounts.registerLoginHandler('totp', function (options) { + if (!options.totp?.code) { + return; + } + + // @ts-expect-error - not sure how to type this yet + return Accounts._runLoginHandlers(this, options.totp.login); +}); + +callbacks.add( + 'onValidateLogin', + async (login) => { + if ( + !login.user || + login.type === 'resume' || + login.type === 'proxy' || + login.type === 'cas' || + (login.type === 'password' && login.methodName === 'resetPassword') || + login.methodName === 'verifyEmail' + ) { + return login; + } + + const [loginArgs] = login.methodArguments; + const { totp } = loginArgs; + + await checkCodeForUser({ + user: login.user, + code: totp?.code, + options: { disablePasswordFallback: true }, + }); + + return login; + }, + callbacks.priority.MEDIUM, + '2fa', +); + +const copyTo = (from: T, to: T): T => { + Object.getOwnPropertyNames(to).forEach((key) => { + const idx: keyof T = key as keyof T; + to[idx] = from[idx]; + }); + + return to; +}; + +const recreateError = (errorDoc: Error | Meteor.Error): Error | Meteor.Error => { + if (isMeteorError(errorDoc)) { + const error = new Meteor.Error(''); + return copyTo(errorDoc, error); + } + + const error = new Error(); + return copyTo(errorDoc, error); +}; + +OAuth._retrievePendingCredential = async function (key, ...args): Promise { + const credentialSecret = args.length > 0 && args[0] !== undefined ? args[0] : undefined; + check(key, String); + + const pendingCredential = await OAuth._pendingCredentials.findOneAsync({ + key, + credentialSecret, + }); + + if (!pendingCredential) { + return; + } + + if (isCredentialWithError(pendingCredential.credential)) { + await OAuth._pendingCredentials.removeAsync({ + _id: pendingCredential._id, + }); + return recreateError(pendingCredential.credential.error); + } + + // Work-around to make the credentials reusable for 2FA + const future = new Date(); + future.setMinutes(future.getMinutes() + 2); + + await OAuth._pendingCredentials.updateAsync( + { + _id: pendingCredential._id, + }, + { + $set: { + createdAt: future, + }, + }, + ); + + return OAuth.openSecret(pendingCredential.credential); +}; diff --git a/apps/meteor/server/lib/2fa/twoFactorRequired.ts b/apps/meteor/server/lib/2fa/twoFactorRequired.ts new file mode 100644 index 0000000..007de8a --- /dev/null +++ b/apps/meteor/server/lib/2fa/twoFactorRequired.ts @@ -0,0 +1,51 @@ +import { Meteor } from 'meteor/meteor'; + +import type { ITwoFactorOptions } from './code'; +import { checkCodeForUser } from './code'; + +export type AuthenticatedContext = { + userId: string; + token: string; + connection: { + id: string; + clientAddress: string; + httpHeaders: Record; + }; + twoFactorChecked?: boolean; +}; + +export const twoFactorRequired = Promise>( + fn: ThisParameterType extends AuthenticatedContext + ? TFunction + : (this: AuthenticatedContext, ...args: Parameters) => ReturnType, + options?: ITwoFactorOptions, +) => + async function (this, ...args) { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'twoFactorRequired' }); + } + + // get two factor options from last item of args and remove it + const twoFactor = args.pop(); + if (twoFactor) { + if (twoFactor.twoFactorCode && twoFactor.twoFactorMethod) { + await checkCodeForUser({ + user: this.userId, + connection: this.connection || undefined, + code: twoFactor.twoFactorCode, + method: twoFactor.twoFactorMethod, + options, + }); + this.twoFactorChecked = true; + } else { + // if it was not two factor options, put it back + args.push(twoFactor); + } + } + + if (!this.twoFactorChecked) { + await checkCodeForUser({ user: this.userId, connection: this.connection || undefined, options }); + } + + return fn.apply(this, args); + } as (this: ThisParameterType, ...args: Parameters) => ReturnType; diff --git a/apps/meteor/server/lib/OutboundMessageProvider.ts b/apps/meteor/server/lib/OutboundMessageProvider.ts new file mode 100644 index 0000000..7c8b3c1 --- /dev/null +++ b/apps/meteor/server/lib/OutboundMessageProvider.ts @@ -0,0 +1,69 @@ +import type { + IOutboundEmailMessageProvider, + IOutboundMessageProviders, + IOutboundPhoneMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; +import type { ValidOutboundProvider, IOutboundProvider, IOutboundMessageProvider } from '@rocket.chat/core-typings'; + +export class OutboundMessageProvider implements IOutboundMessageProvider { + private readonly outboundMessageProviders: Map; + + constructor() { + this.outboundMessageProviders = new Map([ + ['phone', []], + ['email', []], + ]); + } + + public findOneByProviderId(providerId: string) { + for (const providers of this.outboundMessageProviders.values()) { + for (const provider of providers) { + if (provider.appId === providerId) { + return provider; + } + } + } + return undefined; + } + + public registerPhoneProvider(provider: IOutboundPhoneMessageProvider): void { + this.outboundMessageProviders.set('phone', [...(this.outboundMessageProviders.get('phone') || []), provider]); + } + + public registerEmailProvider(provider: IOutboundEmailMessageProvider): void { + this.outboundMessageProviders.set('email', [...(this.outboundMessageProviders.get('email') || []), provider]); + } + + public getOutboundMessageProviders(type?: ValidOutboundProvider): IOutboundProvider[] { + if (type) { + return Array.from(this.outboundMessageProviders.get(type)?.values() || []).map((provider) => ({ + providerId: provider.appId, + providerName: provider.name, + providerType: provider.type, + ...(provider.documentationUrl && { documentationUrl: provider.documentationUrl }), + ...(provider.supportsTemplates && { supportsTemplates: provider.supportsTemplates }), + })); + } + + return Array.from(this.outboundMessageProviders.values()) + .flatMap((providers) => providers) + .map((provider) => ({ + providerId: provider.appId, + providerName: provider.name, + supportsTemplates: provider.supportsTemplates, + providerType: provider.type, + })); + } + + public unregisterProvider(appId: string, providerType: ValidOutboundProvider): void { + const providers = this.outboundMessageProviders.get(providerType); + if (!providers) { + return; + } + + this.outboundMessageProviders.set( + providerType, + providers.filter((provider: IOutboundMessageProviders) => provider.appId !== appId), + ); + } +} diff --git a/apps/meteor/server/lib/RateLimiter.js b/apps/meteor/server/lib/RateLimiter.js new file mode 100644 index 0000000..ac2b35b --- /dev/null +++ b/apps/meteor/server/lib/RateLimiter.js @@ -0,0 +1,17 @@ +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +export const RateLimiterClass = new (class { + limitMethod(methodName, numRequests, timeInterval, matchers) { + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { + return; + } + const match = { + type: 'method', + name: methodName, + }; + Object.entries(matchers).forEach(([key, matcher]) => { + match[key] = (...args) => matcher(...args); + }); + return DDPRateLimiter.addRule(match, numRequests, timeInterval); + } +})(); diff --git a/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts b/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts new file mode 100644 index 0000000..4d293b6 --- /dev/null +++ b/apps/meteor/server/lib/auditServerEvents/userChanged.spec.ts @@ -0,0 +1,297 @@ +import { faker } from '@faker-js/faker'; +import type { IAuditServerUserActor, IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { UpdaterImpl } from '@rocket.chat/models'; + +import { UserChangedAuditStore } from './userChanged'; +import { createFakeUser } from '../../../tests/mocks/data'; + +const makeFakeActor = (): Omit => { + return { + ip: faker.internet.ip(), + useragent: faker.internet.userAgent(), + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + }; +}; + +const createUserAndUpdater = (overrides?: Partial): [IUser, Updater, Omit] => { + const originalUser = createFakeUser(overrides); + const updater = new UpdaterImpl(); + + const actor = makeFakeActor(); + + return [originalUser, updater, actor]; +}; + +const createEmailsField = (address?: string, verified = true) => { + return { + emails: [ + { + address: address || faker.internet.email(), + verified, + }, + ], + }; +}; + +jest.mock('@rocket.chat/models', () => { + return { + UpdaterImpl: jest.requireActual('@rocket.chat/models').UpdaterImpl, + ServerEvents: { + createAuditServerEvent: (...args: any) => args, + }, + }; +}); + +const createObfuscatedFields = (_2faEnabled = true): Pick => { + return { + services: { + password: { + bcrypt: faker.string.uuid(), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore this field is not in IUser, but is present in DB + enroll: { + token: faker.string.uuid(), + email: faker.internet.email(), + when: faker.date.past(), + reason: 'enroll', + }, + }, + email2fa: { + enabled: _2faEnabled, + changedAt: faker.date.past(), + }, + email: { + verificationTokens: [ + { + token: faker.string.uuid(), + address: faker.internet.email(), + when: faker.date.past(), + }, + ], + }, + resume: { + loginTokens: [ + { + when: faker.date.past(), + hashedToken: faker.string.uuid(), + twoFactorAuthorizedHash: faker.string.uuid(), + twoFactorAuthorizedUntil: faker.date.past(), + }, + ], + }, + }, + e2e: { + private_key: faker.string.uuid(), + public_key: faker.string.uuid(), + }, + inviteToken: faker.string.uuid(), + oauth: { authorizedClients: [faker.string.uuid(), faker.string.uuid()] }, + }; +}; + +const getObfuscatedFields = (email2faState: { enabled: boolean; changedAt: Date }) => ({ + e2e: '****', + oauth: '****', + inviteToken: '****', + services: getObfuscatedServices(email2faState), +}); + +const getObfuscatedServices = (email2faState: { enabled: boolean; changedAt: Date }) => { + return { + password: '****', + email2fa: email2faState, + email: '****', + resume: '****', + }; +}; + +describe('userChanged audit module', () => { + it('should build event with only name and username fields', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() }); + + const store = new UserChangedAuditStore(actor); + + const [newUsername, newName] = [faker.internet.userName(), faker.person.fullName()]; + + updater.set('username', newUsername); + updater.set('name', newName); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { username: user.username, name: user.name }, + operation: { $set: { username: newUsername, name: newName } }, + }, + { ...actor, type: 'user' }, + ]); + }); + + it('should build event with only emails field', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField() }); + + const store = new UserChangedAuditStore(actor); + + const newEmailsField = createEmailsField(); + + updater.set('emails', newEmailsField.emails); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + operation: { $set: { ...newEmailsField } }, + user_data: { emails: user.emails }, + }, + { ...actor, type: 'user' }, + ]); + }); + + it('should build event with every changed field', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), active: false }); + + const changes = { + ...createFakeUser(), + ...createEmailsField(), + type: 'bot', + active: true, + }; + + Object.entries(changes).forEach(([key, value]: any) => { + updater.set(key, value); + }); + + updater.addToSet('roles', 'user'); + updater.addToSet('roles', 'bot'); + + const store = new UserChangedAuditStore(actor); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: user, + operation: { + $set: { + ...changes, + }, + $addToSet: { + roles: { $each: ['user', 'bot'] }, + }, + }, + }, + { + ...actor, + type: 'user', + }, + ]); + }); + it('should obfuscate sensitive fields', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + const changes = { + ...createFakeUser(), + ...createEmailsField(), + ...createObfuscatedFields(true), + type: 'bot', + active: true, + }; + + Object.entries(changes).forEach(([key, value]: any) => { + updater.set(key, value); + }); + + updater.addToSet('roles', 'user'); + updater.addToSet('roles', 'bot'); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { ...user, ...getObfuscatedFields(user.services?.email2fa as any) }, + operation: { + $set: { + ...changes, + ...getObfuscatedFields(changes.services?.email2fa as any), + }, + $addToSet: { + roles: { $each: ['user', 'bot'] }, + }, + }, + }, + { + ...actor, + type: 'user', + }, + ]); + }); + it('should obfuscate nested services', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + updater.set('services.password.bcrypt', faker.string.uuid()); + updater.set('services.resume.loginTokens', faker.string.uuid()); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { services: { password: '****', resume: '****' } }, + operation: { $set: { 'services.password.bcrypt': '****', 'services.resume.loginTokens': '****' } }, + }, + { ...actor, type: 'user' }, + ]); + }); + it('should obfuscate all services when they are set at once', async () => { + const [user, updater, actor] = createUserAndUpdater({ ...createEmailsField(), ...createObfuscatedFields(false), active: false }); + + const store = new UserChangedAuditStore(actor); + + updater.set('services', { password: { bcrypt: faker.string.uuid() } }); + + store.setOriginalUser(user as IUser); + store.setUpdateFilter(updater.getUpdateFilter()); + + const event = await store.commitAuditEvent(); + + expect(event).toEqual([ + 'user.changed', + { + user: { _id: user._id, username: user.username }, + user_data: { services: getObfuscatedServices(user.services?.email2fa as any) }, + operation: { $set: { services: { password: '****' } } }, + }, + { ...actor, type: 'user' }, + ]); + }); +}); diff --git a/apps/meteor/server/lib/auditServerEvents/userChanged.ts b/apps/meteor/server/lib/auditServerEvents/userChanged.ts new file mode 100644 index 0000000..ea71794 --- /dev/null +++ b/apps/meteor/server/lib/auditServerEvents/userChanged.ts @@ -0,0 +1,168 @@ +import type { IAuditServerUserActor, IServerEvents, ExtractDataToParams, IUser } from '@rocket.chat/core-typings'; +import { ServerEvents } from '@rocket.chat/models'; +import type { UpdateFilter } from 'mongodb'; + +const userKeysToObfuscate = ['authorizedClients', 'e2e', 'inviteToken', 'oauth']; +const nestableKeysToObfuscate = ['services', 'password', 'bcrypt']; // ex: services.password.bcrypt + +const obfuscateServices = (services: Record): Record => { + return Object.fromEntries( + Object.keys(services).map((key) => { + // Email 2FA is okay, only tells if it's enabled + if (key === 'email2fa') { + return [key, services[key]]; + } + return [key, '****']; + }), + ); +}; +export class UserChangedAuditStore { + private originalUser: Partial | undefined; + + private updateFilter: UpdateFilter | undefined; + + private actor: IAuditServerUserActor; + + constructor(actor: Omit, type: IAuditServerUserActor['type'] = 'user') { + this.actor = { ...actor, type }; + } + + public setOriginalUser(user: Partial) { + this.originalUser = user; + } + + public setUpdateFilter(updateFilter: UpdateFilter) { + this.updateFilter = Object.fromEntries( + Object.entries(updateFilter).map(([key, value]) => { + const obfuscatedValue = Object.entries(value).reduce((acc, [k, v]) => { + if (userKeysToObfuscate.includes(k)) { + return { + ...acc, + [k]: '****', + }; + } + + // In case all services are set at once, we need to obfuscate them + if (k === 'services') { + return { + ...acc, + [k]: obfuscateServices(v as Record), + }; + } + + if (nestableKeysToObfuscate.some((key) => k.includes(key))) { + return { + ...acc, + [k]: '****', + }; + } + + return { ...acc, [k]: v }; + }, {}); + + return [key, obfuscatedValue]; + }), + ); + } + + private filterUserChangedProperties(originalUser: Partial, updateFilter: UpdateFilter): Partial { + if (Object.keys(updateFilter).length === 0) { + return {}; + } + + // extract keys from updateFilter (keys are nested in $set, $unset, $inc, etc) + const updateFilterKeys: string[] = Object.values(updateFilter).reduce((acc, current) => { + const keys = Object.keys(current); + if (keys.length === 0) { + return acc; + } + return [...acc, ...keys]; + }, []); + + return Object.entries(originalUser).reduce((acc, [key, value]) => { + if (!updateFilterKeys.some((k) => k.includes(key))) { + return acc; + } + + if (userKeysToObfuscate.includes(key)) { + return { + ...acc, + [key]: '****', + }; + } + + if (key === 'services') { + // In case all services are set at once we should + // obfuscate all user services, because they'll all change + if (updateFilterKeys.some((k) => k === 'services')) { + return { + ...acc, + [key]: obfuscateServices(value as Record), + }; + } + + const changedNestedServices = updateFilterKeys + .filter((k) => k.includes(key) && k.includes('.')) + .map((serviceKey) => { + // service key can be nested with dot notation + // ex: services.password.bcrypt + const serviceKeyParts = serviceKey.split('.'); + return [serviceKeyParts[1], value[serviceKey as keyof typeof value]]; + }) + .filter(Boolean); + + if (!changedNestedServices.length) { + return acc; + } + + return { + ...acc, + [key]: obfuscateServices(Object.fromEntries(changedNestedServices) as Record), + }; + } + + return { + ...acc, + [key]: value, + }; + }, {}); + } + + private getEventData( + originalUser: Partial, + updateFilter: UpdateFilter, + ): ExtractDataToParams { + const userData = this.filterUserChangedProperties(originalUser, updateFilter); + + return { + user: { _id: originalUser._id || '', username: originalUser.username }, + user_data: userData, + operation: updateFilter, + }; + } + + private buildEvent(): ['user.changed', ExtractDataToParams, IAuditServerUserActor] { + if (!this.updateFilter) { + throw new Error('UserChangedAuditStore - Updater is undefined'); + } + + if (!this.originalUser) { + throw new Error('UserChangedAuditStore - OriginalUser is undefined'); + } + + const eventData = this.getEventData(this.originalUser, this.updateFilter); + + if (Object.keys(eventData.user_data).length === 0 || Object.keys(eventData.operation).length === 0) { + // UpdaterImpl throws an error when trying to build the filter if no changes are detected + // so we should never get here + throw new Error('UserChangedAuditStore - No changes detected'); + } + + return ['user.changed', eventData, this.actor]; + } + + public async commitAuditEvent() { + const event = this.buildEvent(); + return ServerEvents.createAuditServerEvent(...event); + } +} diff --git a/apps/meteor/server/lib/auth-providers/apple/AppleCustomOAuth.ts b/apps/meteor/server/lib/auth-providers/apple/AppleCustomOAuth.ts new file mode 100644 index 0000000..c2730d8 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/AppleCustomOAuth.ts @@ -0,0 +1,38 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { Accounts } from 'meteor/accounts-base'; + +import { handleIdentityToken } from './handleIdentityToken'; +import { settings } from '../../../settings'; +import { CustomOAuth } from '../custom-oauth/custom_oauth_server'; + +export class AppleCustomOAuth extends CustomOAuth { + override async getIdentity(_accessToken: string, query: Record): Promise { + const { id_token: identityToken, user: userStr = '' } = query; + + let usrObj = {} as any; + try { + usrObj = JSON.parse(userStr); + } catch (e) { + // ignore + } + + try { + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; + + const serviceData = await handleIdentityToken(identityToken, clientId); + + if (usrObj?.name) { + serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${ + usrObj.name.lastName ? ` ${usrObj.name.lastName}` : '' + }`; + } + + return serviceData; + } catch (error: any) { + return { + type: 'apple', + error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message), + }; + } + } +} diff --git a/apps/meteor/server/lib/auth-providers/apple/appleOauthRegisterService.ts b/apps/meteor/server/lib/auth-providers/apple/appleOauthRegisterService.ts new file mode 100644 index 0000000..fbe27d1 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/appleOauthRegisterService.ts @@ -0,0 +1,125 @@ +import { createPrivateKey, sign } from 'node:crypto'; + +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { AppleCustomOAuth } from './AppleCustomOAuth'; +import { config } from '../../../../app/apple/lib/config'; +import { settings } from '../../../settings'; + +new AppleCustomOAuth('apple', config); + +const toBase64Url = (obj: Record) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +function generateAppleClientSecret(header: Record, payload: Record, privateKeyString: string): string { + const headerB64 = toBase64Url(header); + const payloadB64 = toBase64Url(payload); + const dataToSign = `${headerB64}.${payloadB64}`; + + const privateKey = createPrivateKey({ + key: privateKeyString, + format: 'pem', + type: 'pkcs8', + }); + + const signature = sign('sha256', Buffer.from(dataToSign), { + key: privateKey, + dsaEncoding: 'ieee-p1363', + }); + + const signatureB64 = signature.toString('base64url'); + + return `${dataToSign}.${signatureB64}`; +} + +settings.watchMultiple( + [ + 'Accounts_OAuth_Apple', + 'Accounts_OAuth_Apple_id', + 'Accounts_OAuth_Apple_secretKey', + 'Accounts_OAuth_Apple_iss', + 'Accounts_OAuth_Apple_kid', + ], + async ([enabled, clientId, serverSecret, iss, kid]) => { + if (!enabled) { + return ServiceConfiguration.configurations.removeAsync({ + service: 'apple', + }); + } + + const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) => + typeof value === 'string' ? value.trim() : '', + ); + + const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean); + + // Hide web button if settings are incomplete, but preserve mobile-only setup if enabled. + if (!hasAllFields) { + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + return; + } + + const HEADER = { + kid: normalizedKid, + alg: 'ES256', + }; + + const now = new Date(); + const exp = new Date(); + exp.setMonth(exp.getMonth() + 5); + + try { + const secret = generateAppleClientSecret( + HEADER, + { + iss: normalizedIss, + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(exp.getTime() / 1000), + aud: 'https://appleid.apple.com', + sub: normalizedClientId, + }, + normalizedServerSecret, + ); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: true, + secret, + enabled: settings.get('Accounts_OAuth_Apple'), + loginStyle: 'popup', + clientId: normalizedClientId, + buttonColor: '#000', + buttonLabelColor: '#FFF', + }, + }, + ); + } catch (error) { + console.error('Failed to configure Apple OAuth service', error); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + } + }, +); diff --git a/apps/meteor/server/lib/auth-providers/apple/applePassportOAuth.ts b/apps/meteor/server/lib/auth-providers/apple/applePassportOAuth.ts new file mode 100644 index 0000000..9400b09 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/applePassportOAuth.ts @@ -0,0 +1,178 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import express from 'express'; +import { Accounts } from 'meteor/accounts-base'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import { Strategy as AppleStrategy } from 'passport-apple'; +import type { Profile } from 'passport-apple'; + +import { AppleCustomOAuth } from './AppleCustomOAuth'; +import { handleIdentityToken } from './handleIdentityToken'; +import { config } from '../../../../app/apple/lib/config'; +import { oAuthRouter } from '../../../configuration/configurePassport'; +import { settings } from '../../../settings'; +import { allowPassportOAuthMiddleware } from '../../oauth/allowPassportOAuthMiddleware'; +import { passportOAuthCallback } from '../../oauth/passportOAuthCallback'; +import { removeOAuthRoutes } from '../../oauth/removeOAuthRoutes'; + +new AppleCustomOAuth('apple', config); + +type RequestWithAppleProfile = { + appleProfile?: { + name?: { + firstName?: string; + middleName?: string; + lastName?: string; + }; + }; +}; + +function isRequestWithAppleProfile(req: object): req is RequestWithAppleProfile { + if ( + 'appleProfile' in req && + req.appleProfile && + typeof req.appleProfile === 'object' && + 'name' in req.appleProfile && + req.appleProfile.name && + typeof req.appleProfile.name === 'object' + ) { + return true; + } + return false; +} + +settings.watchMultiple( + [ + 'Accounts_OAuth_Apple', + 'Accounts_OAuth_Apple_id', + 'Accounts_OAuth_Apple_secretKey', + 'Accounts_OAuth_Apple_iss', + 'Accounts_OAuth_Apple_kid', + 'Accounts_OAuth_Use_Modern_Flow', + ], + async ([enabled, clientId, serverSecret, iss, kid, useModernFlow]) => { + passport.unuse('apple'); + removeOAuthRoutes('apple'); + + if (!useModernFlow) { + return; + } + + if (!enabled) { + return ServiceConfiguration.configurations.removeAsync({ + service: 'apple', + }); + } + + // if everything is empty but Apple login is enabled, don't show the login button + if (!clientId && !serverSecret && !iss && !kid) { + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + return; + } + + if (typeof clientId !== 'string' || !clientId) { + return ServiceConfiguration.configurations.removeAsync({ + service: 'apple', + }); + } + + passport.use( + 'apple', + + new AppleStrategy( + { + clientID: clientId, + teamID: settings.get('Accounts_OAuth_Apple_iss'), + keyID: settings.get('Accounts_OAuth_Apple_kid'), + privateKeyString: settings.get('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'), + callbackURL: `${settings.get('Site_Url').replace(/\/$/, '')}/_oauth/apple`, + scope: ['name', 'email'], + passReqToCallback: true, + state: false, + }, + async (req, accessToken: string, refreshToken: string, idToken: string, _profile: Profile, done) => { + try { + const serviceData = await handleIdentityToken(idToken, clientId); + + if (isRequestWithAppleProfile(req)) { + const appleName = req.appleProfile?.name; + serviceData.name = [appleName?.firstName, appleName?.middleName, appleName?.lastName].filter(Boolean).join(' '); + } + + const user = await Accounts.updateOrCreateUserFromExternalService( + 'apple', + { + accessToken, + refreshToken, + ...serviceData, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + } catch (error: any) { + done(error); + return { + type: 'apple', + error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message), + }; + } + }, + ), + ); + + const callbackHandler = [ + allowPassportOAuthMiddleware('apple'), + express.urlencoded({ extended: true }), + passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }), + passportOAuthCallback(settings.get('Site_Url').replace(/\/$/, '')), + ]; + + oAuthRouter.get( + '/oauth/apple', + allowPassportOAuthMiddleware('apple'), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate('apple', { + scope: ['name', 'email'], + }), + ); + + oAuthRouter + .route('/_oauth/apple') + .post(...callbackHandler) + .get(...callbackHandler); + }, +); diff --git a/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.spec.ts b/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.spec.ts new file mode 100644 index 0000000..b63c3f9 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.spec.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; + +import { serverFetch } from '@rocket.chat/server-fetch'; +import { Response } from 'node-fetch'; + +import { handleIdentityToken } from './handleIdentityToken'; + +jest.mock('@rocket.chat/server-fetch', () => ({ + serverFetch: jest.fn(), +})); + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, +}); + +const jwkPublicKey = publicKey.export({ format: 'jwk' }); + +const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +describe('handleIdentityToken', () => { + const mockClientId = 'com.yourcompany.app'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should throw an error if the token has the wrong audience', async () => { + const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payload = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'wrong.client.id', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const mockToken = `${header}.${payload}.dummySignature`; + + await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired'); + }); + + it('should successfully validate a valid token', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: mockClientId, + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, mockClientId); + + expect(result.id).toBe('user123'); + expect(result.iss).toBe('https://appleid.apple.com'); + }); + + it('should accept default mobile audience when client id setting is empty', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'chat.rocket.ios', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, ''); + + expect(result.id).toBe('user123'); + expect(result.aud).toBe('chat.rocket.ios'); + }); +}); diff --git a/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.ts b/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.ts new file mode 100644 index 0000000..ab4a937 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/handleIdentityToken.ts @@ -0,0 +1,166 @@ +import { createPublicKey, verify } from 'node:crypto'; + +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +type AppleJWK = { + kty: string; + kid: string; + use: string; + alg: string; + n: string; + e: string; +}; + +type AppleJWTPayload = { + iss: string; + sub: string; + aud: string | string[]; + exp: number; + iat: number; + email?: string; + email_verified?: string | boolean; + is_private_email?: string | boolean; +}; + +const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios']; + +let cachedKeys: AppleJWK[] | null = null; +let lastFetchTime = 0; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +async function getApplePublicKeys(forceRefresh = false): Promise { + const now = Date.now(); + + if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) { + return cachedKeys; + } + + try { + const response = await fetch('https://appleid.apple.com/auth/keys', { + method: 'GET', + // SECURITY: Hardcoded URL, no SSRF protection needed + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { keys: AppleJWK[] }; + cachedKeys = data.keys; + lastFetchTime = now; + + return cachedKeys; + } catch (error) { + if (cachedKeys) { + console.warn('Failed to refresh Apple public keys, using stale cache', error); + return cachedKeys; + } + throw new Error('Could not retrieve Apple public keys', { cause: error }); + } +} + +function decodeBase64Url(str: string): string { + return Buffer.from(str, 'base64url').toString('utf8'); +} + +async function verifyAppleJWT( + headerB64: string, + payloadB64: string, + signatureB64: string, + clientId: string, +): Promise { + const header = JSON.parse(decodeBase64Url(headerB64)); + const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload; + + const nowInSeconds = Math.floor(Date.now() / 1000); + + if (payload.exp < nowInSeconds) { + console.error('Apple JWT has expired'); + return null; + } + + if (payload.iss !== 'https://appleid.apple.com') { + console.error('Invalid issuer. Expected https://appleid.apple.com'); + return null; + } + + const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + const configuredAudiences = clientId + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences])); + + const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud)); + + if (!isAudienceValid) { + console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`); + return null; + } + + let applePublicKeys = await getApplePublicKeys(); + let keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + applePublicKeys = await getApplePublicKeys(true); // Force refresh + keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + console.error('Matching Key ID (kid) not found in Apple JWKS'); + return null; + } + } + + try { + const publicKey = createPublicKey({ + key: { + kty: keyData.kty, + n: keyData.n, + e: keyData.e, + }, + format: 'jwk', + }); + + const isSignatureValid = verify( + 'RSA-SHA256', + Buffer.from(`${headerB64}.${payloadB64}`), + publicKey, + Buffer.from(signatureB64, 'base64url'), + ); + + return isSignatureValid ? payload : null; + } catch (error) { + console.error('Cryptographic signature verification failed:', error); + return null; + } +} + +export async function handleIdentityToken(identityToken: string, clientId: string): Promise> { + const parts = identityToken.split('.'); + + if (parts.length !== 3) { + throw new Error('Malformed identityToken: JWT must have 3 parts'); + } + + const [headerB64, payloadB64, signatureB64] = parts; + + const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId); + + if (!payload) { + throw new Error('identityToken is not a valid Apple JWT or has expired'); + } + + if (!payload.sub) { + throw new Error('Insufficient data: Missing subject (sub) in auth response token'); + } + + const serviceData = { + id: payload.sub, + ...payload, + }; + + return serviceData; +} diff --git a/apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts b/apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts new file mode 100644 index 0000000..0e9de43 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts @@ -0,0 +1,124 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { handleIdentityToken } from './handleIdentityToken'; +import { settings } from '../../../settings'; + +jest.mock( + 'meteor/accounts-base', + () => ({ + Accounts: { + registerLoginHandler: jest.fn(), + updateOrCreateUserFromExternalService: jest.fn(), + LoginCancelledError: { numericError: 400 }, + }, + }), + { virtual: true }, +); + +jest.mock( + 'meteor/meteor', + () => ({ + Meteor: { + Error: class extends Error { + constructor( + public error: number, + public reason: string, + ) { + super(reason); + } + }, + }, + }), + { virtual: true }, +); + +jest.mock('../../../settings', () => ({ + settings: { + get: jest.fn(), + }, +})); + +jest.mock('./handleIdentityToken', () => ({ + handleIdentityToken: jest.fn(), +})); + +describe('Apple OAuth loginHandler', () => { + let loginHandlerCallback: Parameters[1]; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('./loginHandler'); + loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app'; + return null; + }); + }); + + it('should not use the client-provided email if Apple does not provide one', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + }); + + const maliciousLoginRequest = { + identityToken: 'valid.token.without_email', + email: 'alice@email.tld', + fullName: { givenName: 'Alice', familyName: 'Sender' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' }); + + await loginHandlerCallback(maliciousLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123' }, + { profile: { name: 'Alice Sender' } }, + ); + }); + + it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + email: 'legit@email.tld', + }); + + const legitLoginRequest = { + identityToken: 'valid.token.with_email', + fullName: { givenName: 'John', familyName: 'Doe' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback(legitLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123', email: 'legit@email.tld' }, + { profile: { name: 'John Doe' } }, + ); + }); + + it('should pass empty client id to token validation when setting is not configured', async () => { + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return ''; + return null; + }); + + jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' }); + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback({ + identityToken: 'valid.token.with_mobile_default_audience', + fullName: { givenName: 'Mobile', familyName: 'User' }, + }); + + expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', ''); + }); +}); diff --git a/apps/meteor/server/lib/auth-providers/apple/loginHandler.ts b/apps/meteor/server/lib/auth-providers/apple/loginHandler.ts new file mode 100644 index 0000000..8e1aa5b --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/apple/loginHandler.ts @@ -0,0 +1,46 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { handleIdentityToken } from './handleIdentityToken'; +import { settings } from '../../../settings'; + +Accounts.registerLoginHandler('apple', async (loginRequest) => { + if (!loginRequest.identityToken) { + return; + } + + if (!settings.get('Accounts_OAuth_Apple')) { + return; + } + + const { identityToken, fullName } = loginRequest; + + try { + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; + + const serviceData = await handleIdentityToken(identityToken, clientId); + + const profile: { name?: string } = {}; + + const { givenName, familyName } = fullName || {}; + if (givenName && familyName) { + profile.name = `${givenName} ${familyName}`; + } + + const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); + + if (result?.userId === undefined) { + return { + type: 'apple', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), + }; + } + + return result; + } catch (error: any) { + return { + type: 'apple', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, error.message), + }; + } +}); diff --git a/apps/meteor/server/lib/auth-providers/crowd/crowd.ts b/apps/meteor/server/lib/auth-providers/crowd/crowd.ts new file mode 100644 index 0000000..c92ee34 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/crowd/crowd.ts @@ -0,0 +1,424 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { Users } from '@rocket.chat/models'; +import { SHA256 } from '@rocket.chat/sha256'; +import AtlassianCrowd from 'atlassian-crowd-patched'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { logger } from './logger'; +import { settings } from '../../../settings'; +import { crowdIntervalValuesToCronMap } from '../../../settings/crowd'; +import { notifyOnUserChange, notifyOnUserChangeById, notifyOnUserChangeAsync } from '../../notifyListener'; +import { deleteUser } from '../../users/deleteUser'; +import { setRealName } from '../../users/setRealName'; +import { setUserActiveStatus } from '../../users/setUserActiveStatus'; + +type CrowdUser = Pick & { crowd: Record; crowd_username: string }; + +function fallbackDefaultAccountSystem(bind: typeof Accounts, username: string | Record, password: string) { + if (typeof username === 'string') { + if (username.indexOf('@') === -1) { + username = { username }; + } else { + username = { email: username }; + } + } + + logger.info({ msg: 'Fallback to default account system', username }); + + const loginRequest = { + user: username, + password: { + digest: SHA256(password), + algorithm: 'sha-256', + }, + }; + + return Accounts._runLoginHandlers(bind, loginRequest); +} + +export class CROWD { + private crowdClient: any; + + private options: { + crowd: { + base: string; + }; + application: { + name: string; + password: string; + }; + rejectUnauthorized: boolean; + }; + + constructor() { + let url = settings.get('CROWD_URL'); + + this.options = { + crowd: { + base: !/\/$/.test(url) ? (url += '/') : url, + }, + application: { + name: settings.get('CROWD_APP_USERNAME'), + password: settings.get('CROWD_APP_PASSWORD'), + }, + rejectUnauthorized: settings.get('CROWD_Reject_Unauthorized'), + }; + + this.crowdClient = new AtlassianCrowd(this.options); + } + + async checkConnection(): Promise { + return new Promise((resolve, reject) => + this.crowdClient.ping((err: any) => { + if (err) { + reject(err); + } + resolve(); + }), + ); + } + + async fetchCrowdUser(crowdUsername: string): Promise> { + return new Promise((resolve, reject) => + this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record) => { + if (err) { + reject(err); + } + resolve({ + displayname: userResponse['display-name'], + username: userResponse.name, + email: userResponse.email, + active: userResponse.active, + crowd_username: crowdUsername, + }); + }), + ); + } + + async searchForCrowdUserByMail(email?: string): Promise | undefined> { + return new Promise((resolve) => + this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record) => { + if (err) { + resolve(undefined); + } + resolve(response); + }), + ); + } + + async authenticate(username: string, password: string): Promise | undefined> { + if (!username || !password) { + logger.error('No username or password'); + return; + } + const projection = { username: 1, crowd_username: 1, crowd: 1 }; + logger.info('Extracting crowd_username'); + let user = null; + let crowdUsername = username; + + if (username.indexOf('@') !== -1) { + const email = username; + + user = await Users.findOne({ 'emails.address': email }, { projection }); + if (user) { + crowdUsername = user.crowd_username; + } else { + logger.debug({ msg: 'Could not find a user by email', username }); + } + } + + if (user == null) { + user = await Users.findOne({ username }, { projection }); + if (user) { + crowdUsername = user.crowd_username; + } else { + logger.debug('Could not find a user by username'); + } + } + + if (user == null) { + user = await Users.findOne({ crowd_username: username }, { projection }); + if (user) { + crowdUsername = user.crowd_username; + } else { + logger.debug({ msg: 'Could not find a user with by crowd_username', username }); + } + } + + if (user && !crowdUsername) { + logger.debug('Local user found, redirecting to fallback login'); + return { + crowd: false, + }; + } + + if (!user && crowdUsername) { + logger.debug('New user. User is not synced yet.'); + } + logger.debug({ msg: 'Going to crowd', crowdUsername }); + + return new Promise((resolve, reject) => + this.crowdClient.user.authenticate(crowdUsername, password, async (err: any, res: Record) => { + if (err) { + reject(err); + } + const user = res; + try { + const crowdUser: Record = await this.fetchCrowdUser(crowdUsername); + if (user && settings.get('CROWD_Allow_Custom_Username') === true) { + crowdUser.username = user.name; + } + + if (user) { + crowdUser._id = user._id; + } + crowdUser.password = password; + + resolve(crowdUser); + } catch (err) { + reject(err); + } + }), + ); + } + + async syncDataToUser(crowdUser: Record, id: string) { + const user = { + username: this.cleanUsername(crowdUser.username), + crowd_username: crowdUser.crowd_username, + emails: [ + { + address: crowdUser.email, + verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), + }, + ], + crowd: true, + }; + + if (crowdUser.password) { + await Accounts.setPasswordAsync(id, crowdUser.password, { + logout: false, + }); + + await Users.unsetRequirePasswordChange(id); + } + + if (crowdUser.displayname) { + await setRealName(id, crowdUser.displayname); + } + + await Users.updateOne( + { _id: id }, + { + $set: user, + }, + ); + + void notifyOnUserChange({ + clientAction: 'updated', + id, + diff: { + ...user, + ...(crowdUser.displayname && { name: crowdUser.displayname }), + }, + }); + + await setUserActiveStatus(id, crowdUser.active); + } + + async sync() { + // if crowd is disabled bail out + if (settings.get('CROWD_Enable') !== true) { + return; + } + + const users = + ((await Users.findCrowdUsers().toArray()) as unknown as (IUser & { crowd: Record; crowd_username: string })[]) || []; + + logger.info('Sync started...'); + + for (const user of users) { + let crowdUsername = user.hasOwnProperty('crowd_username') ? user.crowd_username : user.username; + logger.info({ msg: 'Syncing user', crowdUsername }); + if (!crowdUsername) { + logger.warn({ msg: 'User has no crowd_username', username: user.username }); + continue; + } + + let crowdUser = null; + + try { + crowdUser = await this.fetchCrowdUser(crowdUsername); + } catch (err) { + logger.debug({ msg: 'Error while syncing user from CROWD', err }); + logger.error({ msg: 'Could not sync user with username', crowd_username: crowdUsername }); + + const email = user.emails?.[0].address; + logger.info({ msg: 'Attempting to find user by email', email }); + + const response = await this.searchForCrowdUserByMail(email); + if (!response || response.users.length === 0) { + logger.warn({ msg: 'Could not find user in CROWD with username or email', crowd_username: crowdUsername, email }); + if (settings.get('CROWD_Remove_Orphaned_Users') === true) { + logger.info({ msg: 'Removing user', crowd_username: crowdUsername }); + setImmediate(async () => { + await deleteUser(user._id); + logger.info({ msg: 'User removed', crowd_username: crowdUsername }); + }); + } + return; + } + crowdUsername = response.users[0].name; + logger.info({ msg: 'User found by email. Syncing user', crowd_username: crowdUsername }); + if (!crowdUsername) { + logger.warn({ msg: 'User has no crowd_username', username: user.username }); + continue; + } + + crowdUser = await this.fetchCrowdUser(crowdUsername); + } + + if (settings.get('CROWD_Allow_Custom_Username') === true) { + crowdUser.username = user.username; + } + + await this.syncDataToUser(crowdUser, user._id); + } + } + + cleanUsername(username: string) { + if (settings.get('CROWD_Clean_Usernames') === true) { + return username.split('@')[0]; + } + return username; + } + + async updateUserCollection(crowdUser: Record) { + const username = crowdUser.crowd_username || crowdUser.username; + const mail = crowdUser.email; + + // If id is not provided, user is linked by crowd_username or email address + const userQuery = { + ...(crowdUser._id && { _id: crowdUser._id }), + ...(!crowdUser._id && { + $or: [{ crowd_username: username }, { 'emails.address': mail }], + }), + }; + + // find our existing user if they exist + const user = await Users.findOne(userQuery); + + if (user) { + const stampedToken = Accounts._generateStampedLoginToken(); + + await Users.updateOne( + { _id: user._id }, + { + $push: { + 'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken), + }, + }, + ); + + // TODO this can be optmized so places that care about loginTokens being removed are invoked directly + // instead of having to listen to every watch.users event + void notifyOnUserChangeAsync(async () => { + const userTokens = await Users.findOneById(crowdUser._id, { projection: { 'services.resume.loginTokens': 1 } }); + if (!userTokens) { + return; + } + + return { + clientAction: 'updated', + id: crowdUser._id, + diff: { 'services.resume.loginTokens': userTokens.services?.resume?.loginTokens }, + }; + }); + + await this.syncDataToUser(crowdUser, user._id); + + return { + userId: user._id, + token: stampedToken.token, + }; + } + + // Attempt to create the new user + try { + crowdUser._id = await Accounts.createUserAsync(crowdUser); + + void notifyOnUserChangeById({ clientAction: 'inserted', id: crowdUser._id }); + + // sync the user data + await this.syncDataToUser(crowdUser, crowdUser._id); + + return { + userId: crowdUser._id, + }; + } catch (err) { + logger.error({ msg: 'Error creating new crowd user.', err }); + } + } +} + +Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, loginRequest) { + if (!loginRequest.crowd) { + return undefined; + } + + logger.info({ msg: 'Init CROWD login', username: loginRequest.username }); + + if (settings.get('CROWD_Enable') !== true) { + return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword); + } + + try { + const crowd = new CROWD(); + const user = await crowd.authenticate(loginRequest.username, loginRequest.crowdPassword); + + if (user?.crowd === false) { + logger.debug({ msg: 'User is not a valid crowd user, falling back', username: loginRequest.username }); + return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword); + } + + if (!user) { + logger.debug({ msg: 'User is not allowed to access Rocket.Chat', username: loginRequest.username }); + return new Meteor.Error('not-authorized', 'User is not authorized by crowd'); + } + + const result = await crowd.updateUserCollection(user); + + return result; + } catch (err: any) { + logger.error({ msg: 'Crowd user not authenticated due to an error', err }); + + throw new Meteor.Error('user-not-found', err.message); + } +}); + +const jobName = 'CROWD_Sync'; + +Meteor.startup(() => { + settings.watchMultiple(['CROWD_Sync_User_Data', 'CROWD_Sync_Interval'], async function addCronJobDebounced([data, interval]) { + if (data !== true) { + logger.info('Disabling CROWD Background Sync'); + if (await cronJobs.has(jobName)) { + await cronJobs.remove(jobName); + } + return; + } + const crowd = new CROWD(); + if (interval) { + if (await cronJobs.has(jobName)) { + await cronJobs.remove(jobName); + } + + logger.info('Enabling CROWD Background Sync'); + const cronInterval = crowdIntervalValuesToCronMap[String(interval)]; + + await cronJobs.add(jobName, cronInterval, () => crowd.sync()); + } + }); +}); diff --git a/apps/meteor/server/lib/auth-providers/crowd/logger.ts b/apps/meteor/server/lib/auth-providers/crowd/logger.ts new file mode 100644 index 0000000..b241746 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/crowd/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('CROWD'); diff --git a/apps/meteor/server/lib/auth-providers/custom-oauth/README.md b/apps/meteor/server/lib/auth-providers/custom-oauth/README.md new file mode 100644 index 0000000..e3669b5 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/custom-oauth/README.md @@ -0,0 +1,7 @@ +# Custom OAuth + +An implementation of the OAuth2 flow. It works with your own private OAuth2 server instance. + +This software is supplied courtesy of the [Rocket.Chat](https://rocket.chat/) open source communications platform. + +See the [project page](https://www.meteor.com/accounts) on Meteor Accounts for more details. diff --git a/apps/meteor/server/lib/auth-providers/custom-oauth/customOAuth.ts b/apps/meteor/server/lib/auth-providers/custom-oauth/customOAuth.ts new file mode 100644 index 0000000..6862037 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/custom-oauth/customOAuth.ts @@ -0,0 +1,403 @@ +import { LDAP } from '@rocket.chat/core-services'; +import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; +import { isAbsoluteURL } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import type { DoneCallback } from 'passport'; +import type { VerifyFunction, StrategyOptions } from 'passport-oauth2'; +import { Strategy } from 'passport-oauth2'; + +import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; +import { client } from '../../../database/utils'; +import { settings } from '../../../settings/cached'; +import { callbacks } from '../../callbacks'; +import { notifyOnUserChange } from '../../notifyListener'; +import { saveUserIdentity } from '../../users/saveUserIdentity'; + +const logger = new Logger('CustomOAuth'); +const BeforeUpdateOrCreateUserFromExternalService = new Map< + string, + (serviceName: string, serviceData: Record) => Promise +>(); +const CustomOAuthInstances = new Map(); + +export class CustomOAuthStrategy extends Strategy { + options: StrategyOptions; + + config: OAuthConfiguration & { clientSecret: string }; + + identityPath: string; + + emailPath: string; + + tokenSentVia: string; + + identityTokenSentVia: string; + + keyField: string; + + usernameField: string; + + emailField: string; + + nameField: string; + + avatarField: string; + + mergeUsers: boolean; + + mergeUsersDistinctServices: boolean; + + rolesClaim: string; + + accessTokenParam: string; + + serverURL: string; + + tokenPath: string; + + constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) { + if (!config.serverURL || typeof config.serverURL !== 'string') { + throw new Meteor.Error('customOAuth: serverURL is required and must be String'); + } + + const options: StrategyOptions = { + authorizationURL: config.serverURL + config.authorizePath, + tokenURL: config.serverURL + config.tokenPath, + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${settings.get('Site_Url').replace(/\/$/, '')}/_oauth/${name}`, + state: true, + pkce: config.pkce ?? true, + scope: config.scope, + }; + + super(options, verify); + + this.serverURL = config.serverURL; + this.tokenPath = config.tokenPath || '/oauth/token'; + this.identityPath = config.identityPath || '/me'; + this.tokenSentVia = config.tokenSentVia; + this.identityTokenSentVia = config.identityTokenSentVia; + this.keyField = config.keyField; + this.usernameField = config.usernameField?.trim(); + this.emailField = config.emailField?.trim(); + this.nameField = config.nameField?.trim(); + this.avatarField = config.avatarField?.trim(); + this.mergeUsers = !!config.mergeUsers; + this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices; + this.rolesClaim = config.rolesClaim || 'roles'; + this.accessTokenParam = config.accessTokenParam || 'access_token'; + + if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') { + this.identityTokenSentVia = this.tokenSentVia; + } + + if (!isAbsoluteURL(this.tokenPath)) { + this.tokenPath = this.serverURL + this.tokenPath; + } + + if (!isAbsoluteURL(this.identityPath)) { + this.identityPath = this.serverURL + this.identityPath; + } + + if (this.emailPath && !isAbsoluteURL(this.emailPath)) { + this.emailPath = this.serverURL + this.emailPath; + } + + if (this.identityTokenSentVia === 'header') { + this._oauth2.useAuthorizationHeaderforGET(true); + } + + if (config.accessTokenParam && config.accessTokenParam !== 'access_token') { + this._oauth2.setAccessTokenName(config.accessTokenParam); + } + + if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') { + Accounts.addAutopublishFields(config.addAutopublishFields); + } + + this.name = name; + this.options = options; + this.config = config; + + this.addHookToProcessUser(); + } + + getUsername(data: Record) { + try { + const value = fromTemplate(this.usernameField, data); + + if (!value) { + logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data }); + throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`); + } + + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract username', { cause: error }); + } + } + + getEmail(data: Record) { + try { + const value = fromTemplate(this.emailField, data); + + if (!value) { + logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data }); + throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`); + } + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract email', { cause: error }); + } + } + + getCustomName(data: Record) { + try { + const value = fromTemplate(this.nameField, data); + + if (!value) { + return this.getName(data); + } + + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract custom name', { cause: error }); + } + } + + getAvatarUrl(data: Record) { + try { + const value = fromTemplate(this.avatarField, data); + + if (!value) { + logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data }); + } + return value as string; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error }); + } + } + + getName(identity: Record): string { + const name = (identity.name || + identity.username || + identity.nickname || + identity.CharacterName || + identity.userName || + identity.preferred_username || + identity.user?.name) as string; + return name; + } + + normalizeIdentity(identity: Record) { + if (identity) { + for (const normalizer of Object.values(normalizers)) { + const result = normalizer(identity); + if (result) { + identity = result; + } + } + } + + if (this.usernameField) { + identity.username = this.getUsername(identity); + } + + if (this.emailField) { + identity.email = this.getEmail(identity); + } + + if (this.avatarField) { + identity.avatarUrl = this.getAvatarUrl(identity); + } + + if (this.nameField) { + identity.name = this.getCustomName(identity); + } else { + identity.name = this.getName(identity); + } + + return renameInvalidProperties(identity); + } + + override userProfile(accessToken: string, done: DoneCallback) { + if (!this.identityPath) { + return done(new Error(`identityPath is required for ${this.name} custom oauth`)); + } + + this._oauth2.get(this.identityPath, accessToken, (err, body, res) => { + if (err) { + return done(JSON.stringify(err)); + } + + if ((res && res.statusCode !== 200) || !body) { + return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`)); + } + + try { + const result = JSON.parse(typeof body === 'string' ? body : body.toString()); + const normalizedIdentity = this.normalizeIdentity(result); + //Nextcloud URL needed on addWebdavServer + normalizedIdentity.serverURL = this.serverURL; + return done(null, normalizedIdentity); + } catch (e) { + return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`)); + } + }); + } + + addHookToProcessUser() { + BeforeUpdateOrCreateUserFromExternalService.set(this.name, async (serviceName, serviceData) => { + if (serviceName !== this.name) { + return; + } + + if (serviceData.username) { + let user: IUser | null = null; + + if (this.keyField === 'username') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByUsernameIgnoringCase(serviceData.username) + : await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName); + } else if (this.keyField === 'email') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByEmailAddress(serviceData.email) + : await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName); + } + + if (!user) { + return; + } + + await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); + // User already created or merged and has identical name as before + if ( + user.services?.[serviceName as keyof NonNullable] && + user.services[serviceName as keyof NonNullable].id === serviceData.id && + user.name === serviceData.name && + (this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email)) + ) { + return; + } + + if (this.mergeUsers !== true) { + throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`); + } + + const serviceIdKey = `services.${serviceName}.id`; + const successCallbacks = [ + async () => { + const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } }); + if (updatedUser) { + const { _id, ...diff } = updatedUser; + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff }); + } + }, + ]; + + const session = client.startSession(); + try { + // Extend the session to match the ExtendedSession type expected by saveUserIdentity + Object.assign(session, { + onceSuccesfulCommit: (cb: () => Promise) => { + successCallbacks.push(cb); + }, + }); + + session.startTransaction(); + + const updater = Users.getUpdater(); + + if (this.keyField === 'username' && serviceData.email) { + updater.set('emails', [{ address: serviceData.email, verified: true }]); + } + + updater.set(serviceIdKey as keyof IUser['services'], serviceData.id); + + await saveUserIdentity({ + _id: user._id, + name: serviceData.name, + updater, + session, + updateUsernameInBackground: true, + // Username needs to be included otherwise the name won't be updated in some collections + username: user.username, + }); + await Users.updateFromUpdater({ _id: user._id }, updater, { session }); + + await session.commitTransaction(); + } catch (e) { + await session.abortTransaction(); + throw e; + } finally { + await session.endSession(); + } + + void Promise.allSettled(successCallbacks.map((cb) => cb())); + } + }); + + CustomOAuthInstances.set(this.name, this); + } +} + +Accounts.validateNewUser((user: IUser & { email: string }) => { + for (const [name, instance] of CustomOAuthInstances) { + const service = user.services?.[name as keyof NonNullable]; + if (!service?.id) { + continue; + } + + if (instance.usernameField) { + user.username = service.username; + } + + if (instance.emailField) { + user.email = service.email; + } + + if (instance.nameField) { + user.name = service.name; + } + } + + return true; +}); + +const { updateOrCreateUserFromExternalService } = Accounts; + +Accounts.updateOrCreateUserFromExternalService = async function (...args) { + for (const hook of BeforeUpdateOrCreateUserFromExternalService.values()) { + await hook.apply(this, args as unknown as [string, Record]); + } + + const [serviceName, serviceData] = args; + + const user = await updateOrCreateUserFromExternalService.apply(this, args); + + if (!user?.userId) { + return undefined; + } + + const fullUser = await Users.findOneById(user.userId as string); + + if (!fullUser) { + return undefined; + } + + if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) { + await LDAP.loginAuthenticatedUserRequest(fullUser.username); + } + + await callbacks.run('afterValidateNewOAuthUser', { + identity: serviceData, + serviceName, + user: fullUser, + }); + + return user; +}; diff --git a/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts b/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts new file mode 100644 index 0000000..670b803 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts @@ -0,0 +1,7 @@ +export class CustomOAuth { + constructor(name: string, options: Record); + + getIdentity(accessToken: string, query: Record): any; + + configure(options: Record): any; +} diff --git a/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.js b/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.js new file mode 100644 index 0000000..fe7c9c6 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.js @@ -0,0 +1,554 @@ +import { LDAP } from '@rocket.chat/core-services'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { isAbsoluteURL } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import _ from 'underscore'; + +import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; +import { client } from '../../../database/utils'; +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { notifyOnUserChange } from '../../notifyListener'; +import { saveUserIdentity } from '../../users/saveUserIdentity'; +import { registerAccessTokenService } from '../oauth/oauth'; + +const logger = new Logger('CustomOAuth', { redact: ['options.clientSecret'] }); + +const Services = {}; +const BeforeUpdateOrCreateUserFromExternalService = []; + +/** + * @deprecated in favor of new Passport OAuth implementation. + */ +export class CustomOAuth { + constructor(name, options) { + logger.debug({ msg: 'Init CustomOAuth', name, options }); + + this.name = name; + if (!Match.test(this.name, String)) { + throw new Meteor.Error('CustomOAuth: Name is required and must be String'); + } + + if (Services[this.name]) { + Services[this.name].configure(options); + return; + } + + Services[this.name] = this; + + this.configure(options); + + this.userAgent = 'Meteor'; + if (Meteor.release) { + this.userAgent += `/${Meteor.release}`; + } + + Accounts.oauth.registerService(this.name); + this.registerService(); + this.addHookToProcessUser(); + this.registerAccessTokenService(this.name, this.accessTokenParam); + } + + configure(options) { + if (!Match.test(options, Object)) { + throw new Meteor.Error('CustomOAuth: Options is required and must be Object'); + } + + if (!Match.test(options.serverURL, String)) { + throw new Meteor.Error('CustomOAuth: Options.serverURL is required and must be String'); + } + + if (!Match.test(options.tokenPath, String)) { + options.tokenPath = '/oauth/token'; + } + + if (!Match.test(options.identityPath, String)) { + options.identityPath = '/me'; + } + + if (!Match.test(options.accessTokenParam, String)) { + options.accessTokenParam = 'access_token'; + } + + this.serverURL = options.serverURL; + this.tokenPath = options.tokenPath; + this.identityPath = options.identityPath; + this.emailPath = options.emailPath; + this.tokenSentVia = options.tokenSentVia; + this.identityTokenSentVia = options.identityTokenSentVia; + this.keyField = options.keyField; + this.usernameField = (options.usernameField || '').trim(); + this.emailField = (options.emailField || '').trim(); + this.nameField = (options.nameField || '').trim(); + this.avatarField = (options.avatarField || '').trim(); + this.mergeUsers = options.mergeUsers; + this.mergeUsersDistinctServices = options.mergeUsersDistinctServices; + this.rolesClaim = options.rolesClaim || 'roles'; + this.accessTokenParam = options.accessTokenParam; + this.channelsAdmin = options.channelsAdmin || 'rocket.cat'; + + if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') { + this.identityTokenSentVia = this.tokenSentVia; + } + + if (!isAbsoluteURL(this.tokenPath)) { + this.tokenPath = this.serverURL + this.tokenPath; + } + + if (!isAbsoluteURL(this.identityPath)) { + this.identityPath = this.serverURL + this.identityPath; + } + + if (this.emailPath && !isAbsoluteURL(this.emailPath)) { + this.emailPath = this.serverURL + this.emailPath; + } + + if (Match.test(options.addAutopublishFields, Object)) { + Accounts.addAutopublishFields(options.addAutopublishFields); + } + } + + async getAccessToken(query) { + const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); + if (!config) { + throw new Accounts.ConfigError(); + } + + let response = undefined; + + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user + 'Accept': 'application/json', + }; + const params = new URLSearchParams({ + code: query.code, + redirect_uri: OAuth._redirectUri(this.name, config), + grant_type: 'authorization_code', + state: query.state, + }); + + // Only send clientID / secret once on header or payload. + if (this.tokenSentVia === 'header') { + const b64 = Buffer.from(`${config.clientId}:${OAuth.openSecret(config.secret)}`).toString('base64'); + headers.Authorization = `Basic ${b64}`; + } else { + params.append('client_secret', config.secret); + params.append('client_id', config.clientId); + } + + try { + const request = await fetch(`${this.tokenPath}`, { + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + method: 'POST', + headers, + body: params, + }); + + if (!request.ok) { + throw new Error(request.statusText); + } + + response = await request.json(); + } catch (err) { + const error = new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${err.message}`); + throw _.extend(error, { response: err.response }); + } + + if (response.error) { + // if the http response was a json object with an error attribute + throw new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${response.error}`); + } else { + return response; + } + } + + async getIdentity(accessToken) { + const params = {}; + const headers = { + 'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user + 'Accept': 'application/json', + }; + + if (this.identityTokenSentVia === 'header') { + headers.Authorization = `Bearer ${accessToken}`; + } else { + params[this.accessTokenParam] = accessToken; + } + + try { + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + const request = await fetch(`${this.identityPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true }); + + if (!request.ok) { + throw new Error(request.statusText); + } + + const response = await request.json(); + + logger.debug({ msg: 'Identity response', response }); + + return this.normalizeIdentity(response, accessToken); + } catch (err) { + const error = new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}. ${err.message}`); + throw _.extend(error, { response: err.response }); + } + } + + registerService() { + const self = this; + OAuth.registerService(this.name, 2, null, async (query) => { + const response = await self.getAccessToken(query); + const identity = await self.getIdentity(response.access_token, query); + + const serviceData = { + _OAuthCustom: true, + serverURL: self.serverURL, + accessToken: response.access_token, + idToken: response.id_token, + expiresAt: +new Date() + 1000 * parseInt(response.expires_in, 10), + }; + + // only set the token in serviceData if it's there. this ensures + // that we don't lose old ones (since we only get this on the first + // log in attempt) + if (response.refresh_token) { + serviceData.refreshToken = response.refresh_token; + } + + _.extend(serviceData, identity); + + const data = { + serviceData, + options: { + profile: { + name: identity.name, + }, + }, + }; + + return data; + }); + } + + async normalizeIdentity(identity, accessToken) { + if (identity) { + for (const normalizer of Object.values(normalizers)) { + const result = normalizer(identity); + if (result) { + identity = result; + } + } + } + + if (this.usernameField) { + identity.username = this.getUsername(identity); + } + + if (this.emailField) { + identity.email = this.getEmail(identity); + } + + if (!identity.email && this.emailPath) { + identity.email = await this.getEmailFromPath(accessToken); + } + + if (this.avatarField) { + identity.avatarUrl = this.getAvatarUrl(identity); + } + + if (this.nameField) { + identity.name = this.getCustomName(identity); + } else { + identity.name = this.getName(identity); + } + + return renameInvalidProperties(identity); + } + + async getEmailFromPath(accessToken) { + if (!this.emailPath) { + throw new Meteor.Error('CustomOAuth: emailPath is required'); + } + + const params = {}; + const headers = { + 'User-Agent': this.userAgent, + 'Accept': 'application/json', + }; + + if (this.identityTokenSentVia === 'header') { + headers.Authorization = `Bearer ${accessToken}`; + } else { + params[this.accessTokenParam] = accessToken; + } + + try { + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + const request = await fetch(`${this.emailPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true }); + + if (!request.ok) { + throw new Error(request.statusText); + } + + const response = await request.json(); + + return response.find((email) => email.primary === true)?.email; + } catch (err) { + const error = new Error(`Failed to fetch emails from ${this.name} at ${this.emailPath}. ${err.message}`); + throw _.extend(error, { response: err.response }); + } + } + + retrieveCredential(credentialToken, credentialSecret) { + return OAuth.retrieveCredential(credentialToken, credentialSecret); + } + + getUsername(data) { + try { + const value = fromTemplate(this.usernameField, data); + + if (!value) { + throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`, data); + } + return value; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract username', error.message); + } + } + + getEmail(data) { + try { + const value = fromTemplate(this.emailField, data); + + if (!value) { + throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`, data); + } + return value; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract email', error.message); + } + } + + getCustomName(data) { + try { + const value = fromTemplate(this.nameField, data); + + if (!value) { + return this.getName(data); + } + + return value; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract custom name', error.message); + } + } + + getAvatarUrl(data) { + try { + const value = fromTemplate(this.avatarField, data); + + if (!value) { + logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data }); + } + return value; + } catch (error) { + throw new Error('CustomOAuth: Failed to extract avatar url', error.message); + } + } + + getName(identity) { + const name = + identity.name || + identity.username || + identity.nickname || + identity.CharacterName || + identity.userName || + identity.preferred_username || + (identity.user && identity.user.name); + return name; + } + + addHookToProcessUser() { + BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData /* , options*/) => { + if (serviceName !== this.name) { + return; + } + + if (serviceData.username) { + let user = undefined; + + if (this.keyField === 'username') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByUsernameIgnoringCase(serviceData.username) + : await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName); + } else if (this.keyField === 'email') { + user = this.mergeUsersDistinctServices + ? await Users.findOneByEmailAddress(serviceData.email) + : await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName); + } + + if (!user) { + return; + } + + await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); + + // User already created or merged and has identical name as before + if ( + user.services && + user.services[serviceName] && + user.services[serviceName].id === serviceData.id && + user.name === serviceData.name && + (this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email)) + ) { + return; + } + + if (this.mergeUsers !== true) { + throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`); + } + + const serviceIdKey = `services.${serviceName}.id`; + const successCallbacks = [ + async () => { + const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } }); + if (updatedUser) { + const { _id, ...diff } = updatedUser; + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff }); + } + }, + ]; + + const session = client.startSession(); + try { + // Extend the session to match the ExtendedSession type expected by saveUserIdentity + Object.assign(session, { + onceSuccesfulCommit: (cb) => { + successCallbacks.push(cb); + }, + }); + + session.startTransaction(); + + const updater = Users.getUpdater(); + + if (this.keyField === 'username' && serviceData.email) { + updater.set('emails', [{ address: serviceData.email, verified: true }]); + } + + updater.set(serviceIdKey, serviceData.id); + + await saveUserIdentity({ + _id: user._id, + name: serviceData.name, + updater, + session, + updateUsernameInBackground: true, + // Username needs to be included otherwise the name won't be updated in some collections + username: user.username, + }); + await Users.updateFromUpdater({ _id: user._id }, updater, { session }); + + await session.commitTransaction(); + } catch (e) { + await session.abortTransaction(); + throw e; + } finally { + await session.endSession(); + } + + void Promise.allSettled(successCallbacks.map((cb) => cb())); + } + }); + + Accounts.validateNewUser((user) => { + if (!user.services || !user.services[this.name] || !user.services[this.name].id) { + return true; + } + + if (this.usernameField) { + user.username = user.services[this.name].username; + } + + if (this.emailField) { + user.email = user.services[this.name].email; + } + + if (this.nameField) { + user.name = user.services[this.name].name; + } + + return true; + }); + } + + registerAccessTokenService(name) { + const self = this; + const whitelisted = ['id', 'email', 'username', 'name', this.rolesClaim]; + + registerAccessTokenService(name, async (options) => { + check( + options, + Match.ObjectIncluding({ + accessToken: String, + expiresIn: Match.Integer, + }), + ); + + const identity = await self.getIdentity(options.accessToken); + + const serviceData = { + accessToken: options.accessToken, + expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10), + }; + + const fields = _.pick(identity, whitelisted); + _.extend(serviceData, fields); + + return { + serviceData, + options: { + profile: { + name: identity.name, + }, + }, + }; + }); + } +} + +const { updateOrCreateUserFromExternalService } = Accounts; + +Accounts.updateOrCreateUserFromExternalService = async function (...args /* serviceName, serviceData, options*/) { + for (const hook of BeforeUpdateOrCreateUserFromExternalService) { + await hook.apply(this, args); + } + + const [serviceName, serviceData] = args; + + const user = await updateOrCreateUserFromExternalService.apply(this, args); + if (!user.userId) { + return undefined; + } + + const fullUser = await Users.findOneById(user.userId); + if (settings.get('LDAP_Update_Data_On_OAuth_Login')) { + await LDAP.loginAuthenticatedUserRequest(fullUser.username); + } + + await callbacks.run('afterValidateNewOAuthUser', { + identity: serviceData, + serviceName, + user: fullUser, + }); + + return user; +}; diff --git a/apps/meteor/server/lib/auth-providers/custom-oauth/transform_helpers.js b/apps/meteor/server/lib/auth-providers/custom-oauth/transform_helpers.js new file mode 100644 index 0000000..7f794bf --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/custom-oauth/transform_helpers.js @@ -0,0 +1,160 @@ +import { isObject } from '../../../../lib/utils/isObject'; + +export const normalizers = { + // Set 'id' to '_id' for any sources that provide it + _id(identity) { + if (identity._id && !identity.id) { + identity.id = identity._id; + } + }, + + // Fix for Reddit + redit(identity) { + if (identity.result) { + return identity.result; + } + }, + + // Fix WordPress-like identities having 'ID' instead of 'id' + wordpress(identity) { + if (identity.ID && !identity.id) { + identity.id = identity.ID; + } + }, + + // Fix Auth0-like identities having 'user_id' instead of 'id' + user_id(identity) { + if (identity.user_id && !identity.id) { + identity.id = identity.user_id; + } + }, + + characterid(identity) { + if (identity.CharacterID && !identity.id) { + identity.id = identity.CharacterID; + } + }, + + // Fix Dataporten having 'user.userid' instead of 'id' + dataporten(identity) { + if (identity.user && identity.user.userid && !identity.id) { + if (identity.user.userid_sec && identity.user.userid_sec[0]) { + identity.id = identity.user.userid_sec[0]; + } else { + identity.id = identity.user.userid; + } + identity.email = identity.user.email; + } + }, + + // Fix for Xenforo [BD]API plugin for 'user.user_id; instead of 'id' + xenforo(identity) { + if (identity.user && identity.user.user_id && !identity.id) { + identity.id = identity.user.user_id; + identity.email = identity.user.user_email; + } + }, + + // Fix general 'phid' instead of 'id' from phabricator + phabricator(identity) { + if (identity.phid && !identity.id) { + identity.id = identity.phid; + } + }, + + // Fix Keycloak-like identities having 'sub' instead of 'id' + kaycloak(identity) { + if (identity.sub && !identity.id) { + identity.id = identity.sub; + } + }, + + // Fix OpenShift identities where id is in 'metadata' object + openshift(identity) { + if (!identity.id && identity.metadata && identity.metadata.uid) { + identity.id = identity.metadata.uid; + identity.name = identity.fullName; + } + }, + + // Fix general 'userid' instead of 'id' from provider + userid(identity) { + if (identity.userid && !identity.id) { + identity.id = identity.userid; + } + }, + + // Fix Nextcloud provider + nextcloud(identity) { + if (!identity.id && identity.ocs && identity.ocs.data && identity.ocs.data.id) { + identity.id = identity.ocs.data.id; + identity.name = identity.ocs.data.displayname; + identity.email = identity.ocs.data.email; + } + }, + + // Fix when authenticating from a meteor app with 'emails' field + meteor(identity) { + if (!identity.email && identity.emails && Array.isArray(identity.emails) && identity.emails.length >= 1) { + identity.email = identity.emails[0].address ? identity.emails[0].address : undefined; + } + }, +}; + +const IDENTITY_PROPNAME_FILTER = /(\.)/g; +export const renameInvalidProperties = (input) => { + if (Array.isArray(input)) { + return input.map(renameInvalidProperties); + } + if (!isObject(input)) { + return input; + } + + return Object.entries(input).reduce( + (result, [name, value]) => ({ + ...result, + [name.replace(IDENTITY_PROPNAME_FILTER, '_')]: renameInvalidProperties(value), + }), + {}, + ); +}; + +export const getNestedValue = (propertyPath, source) => + propertyPath.split('.').reduce((prev, curr) => (prev ? prev[curr] : undefined), source); + +// /^(.+)@/::email +const REGEXP_FROM_FORMULA = /^\/((?!\/::).*)\/::(.+)/; +export const getRegexpMatch = (formula, data) => { + const regexAndPath = REGEXP_FROM_FORMULA.exec(formula); + if (!regexAndPath) { + return getNestedValue(formula, data); + } + if (regexAndPath.length !== 3) { + throw new Error(`expected array of length 3, got ${regexAndPath.length}`); + } + + const [, regexString, path] = regexAndPath; + const nestedValue = getNestedValue(path, data); + const regex = new RegExp(regexString); + const matches = regex.exec(nestedValue); + + // regexp does not match nested value + if (!matches) { + return undefined; + } + + // we only support regular expressions with a single capture group + const [, value] = matches; + + // this could mean we return `undefined` (e.g. when capture group is empty) + return value; +}; + +const templateStringRegex = /{{((?:(?!}}).)+)}}/g; +export const fromTemplate = (template, data) => { + if (!templateStringRegex.test(template)) { + return getNestedValue(template, data); + } + + return template.replace(templateStringRegex, (fullMatch, match) => getRegexpMatch(match, data)); +}; diff --git a/apps/meteor/server/lib/auth-providers/dolphin.ts b/apps/meteor/server/lib/auth-providers/dolphin.ts new file mode 100644 index 0000000..ba89bbd --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/dolphin.ts @@ -0,0 +1,91 @@ +import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import _ from 'underscore'; + +import { callbacks } from '../callbacks'; +import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback'; +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; +import { settings } from '../../settings'; + +const config: Partial = { + serverURL: '', + authorizePath: '/m/oauth2/auth/', + tokenPath: '/m/oauth2/token/', + identityPath: '/m/oauth2/api/me/', + scope: 'basic', + addAutopublishFields: { + forLoggedInUser: ['services.dolphin'], + forOtherUsers: ['services.dolphin.name'], + }, + accessTokenParam: 'access_token', +}; + +const Dolphin = new CustomOAuth('dolphin', config); + +function DolphinOnCreateUser(options: any, user?: IUser) { + // TODO: callbacks Fix this + if (user?.services?.dolphin?.NickName) { + user.username = user.services.dolphin.NickName; + } + return options; +} + +const configureDolphinOAuth = () => { + passport.unuse('dolphin'); + + const enabled = settings.get('Accounts_OAuth_Dolphin'); + if (!enabled) { + return; + } + + const serverURL = settings.get('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Dolphin_id'); + const clientSecret = settings.get('Accounts_OAuth_Dolphin_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + const completeConfig = { ...config, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('dolphin', completeConfig); + return; + } + + Dolphin.configure(completeConfig); +}; + +Meteor.startup(async () => { + const updateConfig = _.debounce(configureDolphinOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Dolphin', + 'Accounts_OAuth_Dolphin_URL', + 'Accounts_OAuth_Dolphin_id', + 'Accounts_OAuth_Dolphin_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); + + if (settings.get('Accounts_OAuth_Dolphin_URL')) { + const data = { + buttonLabelText: settings.get('Accounts_OAuth_Dolphin_button_label_text'), + buttonColor: settings.get('Accounts_OAuth_Dolphin_button_color'), + buttonLabelColor: settings.get('Accounts_OAuth_Dolphin_button_label_color'), + clientId: settings.get('Accounts_OAuth_Dolphin_id'), + secret: settings.get('Accounts_OAuth_Dolphin_secret'), + serverURL: settings.get('Accounts_OAuth_Dolphin_URL'), + loginStyle: settings.get('Accounts_OAuth_Dolphin_login_style'), + }; + + await ServiceConfiguration.configurations.upsertAsync({ service: 'dolphin' }, { $set: data }); + } + + beforeCreateUserCallback.add(DolphinOnCreateUser, callbacks.priority.HIGH, 'dolphin'); +}); diff --git a/apps/meteor/server/lib/auth-providers/drupal.README.md b/apps/meteor/server/lib/auth-providers/drupal.README.md new file mode 100644 index 0000000..be85fc7 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/drupal.README.md @@ -0,0 +1,23 @@ +#Drupal oAuth Integration module. +This module works in conjunction with the [Rocket.Chat+ Module for Drupal](https://www.drupal.org/project/rocket_chat) +Version 7.x-1.1 or later. + +A full set of instructions for how to connect the 2 are present in the drupal module's documentation. + +Basically to connect the 2 you first setup the oAuth server connection in your drupal, with the proper permissions +("Use OAuth2 Server" => "Anonymous User" = Checked). + +In the Rocket chat you have to do the following: +- fill in the 'Client ID'. + Bear in mind that the Client ID should not be guessable,but is seen in the URL when doing the login. +- fill in the 'Client Secret'. + This should be treated as a Secret Key (like the Secret Key of a TLS certificate). it __must not__ be guesable or + derivable, and is best a Alphanumerical sequence between 16 and 48 cahracters long (longer would be better but longer + than 48 characters can be problem with long URI's) +- fill in the Drupal's BaseURL. +- on the Drupal use the "Restrict redirect URIs" Setting to limit possible exploits. and set the Redirect URI's to + whatever is in the Callback URL (like `https://Rocketchat.example.com/_oauth/drupal` and possibly also the + `https://Rocketchat.example.com/_oauth/drupal?close` URI.). +- Lastly do not forget to Enable the Drupal OAuth and `SAVE CHANGES`. + +When all is a Blue Button with a drupal like logo will apear on the login page of Rocket.Chat+ diff --git a/apps/meteor/server/lib/auth-providers/drupal.ts b/apps/meteor/server/lib/auth-providers/drupal.ts new file mode 100644 index 0000000..35a5072 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/drupal.ts @@ -0,0 +1,66 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; +import _ from 'underscore'; + +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; +import { settings } from '../../settings'; + +const config: Partial = { + serverURL: '', + identityPath: '/oauth2/UserInfo', + authorizePath: '/oauth2/authorize', + tokenPath: '/oauth2/token', + scope: 'openid email profile offline_access', + tokenSentVia: 'payload', + usernameField: 'preferred_username', + mergeUsers: true, + addAutopublishFields: { + forLoggedInUser: ['services.drupal'], + forOtherUsers: ['services.drupal.name'], + }, + accessTokenParam: 'access_token', +}; + +const Drupal = new CustomOAuth('drupal', config); + +const configureDrupalOAuth = () => { + passport.unuse('drupal'); + const enabled = settings.get('Accounts_OAuth_Drupal'); + if (!enabled) { + return; + } + + const serverURL = settings.get('API_Drupal_URL').trim().replace(/\/*$/, ''); + const clientId = settings.get('Accounts_OAuth_Drupal_id'); + const clientSecret = settings.get('Accounts_OAuth_Drupal_secret'); + + if (!clientId || !clientSecret || !serverURL) { + return; + } + + const completeConfig = { ...config, serverURL, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('drupal', completeConfig); + return; + } + + Drupal.configure(completeConfig); +}; + +Meteor.startup(() => { + const updateConfig = _.debounce(configureDrupalOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Drupal', + 'API_Drupal_URL', + 'Accounts_OAuth_Drupal_id', + 'Accounts_OAuth_Drupal_secret', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); +}); diff --git a/apps/meteor/server/lib/auth-providers/github-enterprise.ts b/apps/meteor/server/lib/auth-providers/github-enterprise.ts new file mode 100644 index 0000000..3edd8cb --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/github-enterprise.ts @@ -0,0 +1,28 @@ +import type { OauthConfig } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; + +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; +import { settings } from '../../settings'; + +// GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise +// In RocketChat -> Administration the URL needs to be http(s)://{github.enterprise.server}/ + +const config: OauthConfig = { + serverURL: '', + identityPath: '/api/v3/user', + authorizePath: '/login/oauth/authorize', + tokenPath: '/login/oauth/access_token', + addAutopublishFields: { + forLoggedInUser: ['services.github-enterprise'], + forOtherUsers: ['services.github-enterprise.username'], + }, +}; + +const GitHubEnterprise = new CustomOAuth('github_enterprise', config); + +Meteor.startup(() => { + settings.watch('API_GitHub_Enterprise_URL', (value) => { + config.serverURL = value; + GitHubEnterprise.configure(config); + }); +}); diff --git a/apps/meteor/server/lib/auth-providers/github.ts b/apps/meteor/server/lib/auth-providers/github.ts new file mode 100644 index 0000000..441c8e6 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/github.ts @@ -0,0 +1,20 @@ +import type { OauthConfig } from '@rocket.chat/core-typings'; + +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; + +const config: OauthConfig = { + serverURL: 'https://github.com', + identityPath: 'https://api.github.com/user', + tokenPath: 'https://github.com/login/oauth/access_token', + emailPath: 'https://api.github.com/user/emails', + scope: 'user:email', + mergeUsers: false, + addAutopublishFields: { + forLoggedInUser: ['services.github'], + forOtherUsers: ['services.github.username'], + }, + accessTokenParam: 'access_token', + identityTokenSentVia: 'header', +}; + +export const Github = new CustomOAuth('github', config); diff --git a/apps/meteor/server/lib/auth-providers/gitlab.ts b/apps/meteor/server/lib/auth-providers/gitlab.ts new file mode 100644 index 0000000..0445283 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/gitlab.ts @@ -0,0 +1,69 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; +import _ from 'underscore'; + +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; +import { settings } from '../../settings'; + +const config: Partial = { + serverURL: 'https://gitlab.com', + identityPath: '/api/v4/user', + authorizePath: '/oauth/authorize', + tokenPath: '/oauth/token', + scope: 'read_user', + mergeUsers: false, + addAutopublishFields: { + forLoggedInUser: ['services.gitlab'], + forOtherUsers: ['services.gitlab.username'], + }, + accessTokenParam: 'access_token', +}; + +const Gitlab = new CustomOAuth('gitlab', config); + +const configureGitlabOAuth = () => { + passport.unuse('gitlab'); + + const enabled = settings.get('Accounts_OAuth_Gitlab'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Gitlab_id'); + const clientSecret = settings.get('Accounts_OAuth_Gitlab_secret'); + const serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; + const identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; + const mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); + + if (!clientId || !clientSecret) { + return; + } + + const completeConfig = { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth('gitlab', completeConfig); + return; + } + + Gitlab.configure(completeConfig); +}; + +Meteor.startup(() => { + const updateConfig = _.debounce(configureGitlabOAuth, 300); + + settings.watchMultiple( + [ + 'Accounts_OAuth_Gitlab', + 'API_Gitlab_URL', + 'Accounts_OAuth_Gitlab_id', + 'Accounts_OAuth_Gitlab_secret', + 'Accounts_OAuth_Gitlab_identity_path', + 'Accounts_OAuth_Gitlab_merge_users', + 'Accounts_OAuth_Use_Modern_Flow', + ], + updateConfig, + ); +}); diff --git a/apps/meteor/server/lib/auth-providers/google.js b/apps/meteor/server/lib/auth-providers/google.js new file mode 100644 index 0000000..540d626 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/google.js @@ -0,0 +1,106 @@ +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +// The code on this file was copied directly from Meteor and modified to support mobile google oauth +// https://github.com/meteor/meteor/blob/ffcfa5062cf1bf8a64ea64fef681ffcd99fe7939/packages/oauth/oauth_server.js + +Meteor.startup(() => { + const appRedirectUrl = 'rocketchat://auth'; + + const renderEndOfLoginResponse = async (options) => { + const escape = (s) => { + if (!s) { + return s; + } + + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\'/g, '"') + .replace(/\'/g, ''') + .replace(/\//g, '/'); + }; + + const config = { + setCredentialToken: !!options.setCredentialToken, + credentialToken: escape(options.credentialToken), + credentialSecret: escape(options.credentialSecret), + storagePrefix: escape(OAuth._storageTokenPrefix), + redirectUrl: escape(options.redirectUrl), + isCordova: Boolean(options.isCordova), + }; + + let template; + if (options.loginStyle === 'popup') { + template = await OAuth._endOfPopupResponseTemplate(); + } else if (options.loginStyle === 'redirect') { + template = await OAuth._endOfRedirectResponseTemplate(); + } else { + throw new Error(`invalid loginStyle: ${options.loginStyle}`); + } + + const result = template + .replace(/##CONFIG##/, JSON.stringify(config)) + .replace(/##ROOT_URL_PATH_PREFIX##/, __meteor_runtime_config__.ROOT_URL_PATH_PREFIX); + + return `\n${result}`; + }; + + OAuth._endOfLoginResponse = async (res, details) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + let redirectUrl; + + if (details.loginStyle === 'redirect') { + redirectUrl = OAuth._stateFromQuery(details.query).redirectUrl; + const appHost = Meteor.absoluteUrl(); + + if (redirectUrl.startsWith(appRedirectUrl)) { + redirectUrl = `${appRedirectUrl}?host=${appHost}&type=oauth`; + + if (details.error) { + const error = encodeURIComponent(details.error.toString()); + redirectUrl = `${redirectUrl}&error=${error}`; + } + + if (details.credentials) { + const { token, secret } = details.credentials; + redirectUrl = `${redirectUrl}&credentialToken=${token}&credentialSecret=${secret}`; + } + } else if (!Meteor.settings?.packages?.oauth?.disableCheckRedirectUrlOrigin && OAuth._checkRedirectUrlOrigin(redirectUrl)) { + details.error = `redirectUrl (${redirectUrl}) is not on the same host as the app (${appHost})`; + redirectUrl = appHost; + } + } + + const isCordova = OAuth._isCordovaFromQuery(details.query); + + if (details.error) { + res.end( + await renderEndOfLoginResponse({ + loginStyle: details.loginStyle, + setCredentialToken: false, + redirectUrl, + isCordova, + }), + 'utf-8', + ); + return; + } + + // If we have a credentialSecret, report it back to the parent + // window, with the corresponding credentialToken. The parent window + // uses the credentialToken and credentialSecret to log in over DDP. + res.end( + await renderEndOfLoginResponse({ + loginStyle: details.loginStyle, + setCredentialToken: true, + credentialToken: details.credentials.token, + credentialSecret: details.credentials.secret, + redirectUrl, + isCordova, + }), + 'utf-8', + ); + }; +}); diff --git a/apps/meteor/server/lib/auth-providers/iframe.ts b/apps/meteor/server/lib/auth-providers/iframe.ts new file mode 100644 index 0000000..b3e661c --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/iframe.ts @@ -0,0 +1,37 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +Accounts.registerLoginHandler('iframe', async (result) => { + if (!result.iframe) { + return; + } + + check(result.token, String); + + const user = await Users.findOne({ + 'services.iframe.token': result.token, + }); + + if (user) { + return { + userId: user._id, + }; + } +}); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'OAuth.retrieveCredential'(credentialToken: string, credentialSecret: string): unknown; + } +} + +Meteor.methods({ + 'OAuth.retrieveCredential'(credentialToken, credentialSecret) { + return OAuth.retrieveCredential(credentialToken, credentialSecret); + }, +}); diff --git a/apps/meteor/server/lib/auth-providers/linkedin.ts b/apps/meteor/server/lib/auth-providers/linkedin.ts new file mode 100644 index 0000000..8357a74 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/linkedin.ts @@ -0,0 +1,49 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { settings } from '../../settings'; +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; + +const config: Partial = { + serverURL: 'https://www.linkedin.com', + authorizePath: '/oauth/v2/authorization', + tokenPath: '/oauth/v2/accessToken', + identityPath: 'https://api.linkedin.com/v2/userinfo', + scope: 'openid email profile', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.linkedin'], + forOtherUsers: ['services.linkedin.name'], + }, + pkce: false, + emailField: 'email', + avatarField: 'picture', +}; + +const serviceKey = 'linkedin'; + +const configureLinkedInOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Linkedin'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Linkedin_id'); + const clientSecret = settings.get('Accounts_OAuth_Linkedin_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'], + configureLinkedInOAuth, + ); +}); diff --git a/apps/meteor/server/lib/auth-providers/meteor-developer.ts b/apps/meteor/server/lib/auth-providers/meteor-developer.ts new file mode 100644 index 0000000..154c182 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/meteor-developer.ts @@ -0,0 +1,46 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import passport from 'passport'; + +import { settings } from '../../settings'; +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; + +const config: Partial = { + serverURL: 'https://www.meteor.com', + authorizePath: '/oauth2/authorize', + tokenPath: '/oauth2/token', + identityPath: '/api/v1/identity', + scope: 'email', + tokenSentVia: 'header', + addAutopublishFields: { + forLoggedInUser: ['services.meteor-developer'], + forOtherUsers: ['services.meteor-developer.username'], + }, +}; + +const serviceKey = 'meteor'; + +const configureMeteorDeveloperOAuth = (): void => { + passport.unuse(serviceKey); + + const enabled = settings.get('Accounts_OAuth_Meteor'); + if (!enabled) { + return; + } + + const clientId = settings.get('Accounts_OAuth_Meteor_id'); + const clientSecret = settings.get('Accounts_OAuth_Meteor_secret'); + + if (!clientId || !clientSecret) { + return; + } + + addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); +}; + +Meteor.startup(() => { + settings.watchMultiple( + ['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'], + configureMeteorDeveloperOAuth, + ); +}); diff --git a/apps/meteor/server/lib/auth-providers/oauth/facebook.js b/apps/meteor/server/lib/auth-providers/oauth/facebook.js new file mode 100644 index 0000000..ee982c4 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/oauth/facebook.js @@ -0,0 +1,68 @@ +import crypto from 'node:crypto'; + +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Match, check } from 'meteor/check'; +import { OAuth } from 'meteor/oauth'; +import _ from 'underscore'; + +import { registerAccessTokenService } from './oauth'; + +const whitelisted = ['id', 'email', 'name', 'first_name', 'last_name', 'link', 'gender', 'locale', 'age_range']; + +const FB_API_VERSION = 'v2.9'; +const FB_URL = 'https://graph.facebook.com'; + +const getIdentity = async function (accessToken, fields, secret) { + const hmac = crypto.createHmac('sha256', OAuth.openSecret(secret)); + hmac.update(accessToken); + + try { + const request = await fetch(`${FB_URL}/${FB_API_VERSION}/me`, { + params: { + access_token: accessToken, + appsecret_proof: hmac.digest('hex'), + fields: fields.join(','), + }, + }); + + if (!request.ok) { + throw new Error(await request.text()); + } + + return request.json(); + } catch (err) { + throw _.extend(new Error(`Failed to fetch identity from Facebook. ${err.message}`), { + response: err.message, + }); + } +}; + +registerAccessTokenService('facebook', async (options) => { + check( + options, + Match.ObjectIncluding({ + accessToken: String, + secret: String, + expiresIn: Match.Integer, + }), + ); + + const identity = await getIdentity(options.accessToken, whitelisted, options.secret); + + const serviceData = { + accessToken: options.accessToken, + expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10), + }; + + const fields = _.pick(identity, whitelisted); + _.extend(serviceData, fields); + + return { + serviceData, + options: { + profile: { + name: identity.name, + }, + }, + }; +}); diff --git a/apps/meteor/server/lib/auth-providers/oauth/google.js b/apps/meteor/server/lib/auth-providers/oauth/google.js new file mode 100644 index 0000000..34c626d --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/oauth/google.js @@ -0,0 +1,83 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Match, check } from 'meteor/check'; +import { Google } from 'meteor/google-oauth'; +import _ from 'underscore'; + +import { registerAccessTokenService } from './oauth'; + +async function getIdentity(accessToken) { + try { + const request = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', { + params: { access_token: accessToken }, + }); + + if (!request.ok) { + throw new Error(await request.text()); + } + + return request.json(); + } catch (err) { + throw _.extend(new Error(`Failed to fetch identity from Google. ${err.message}`), { + response: err.message, + }); + } +} + +async function getScopes(accessToken) { + try { + const request = await fetch('https://www.googleapis.com/oauth2/v1/tokeninfo', { + params: { access_token: accessToken }, + }); + + if (!request.ok) { + throw new Error(await request.text()); + } + + return (await request.json()).scope.split(' '); + } catch (err) { + throw _.extend(new Error(`Failed to fetch tokeninfo from Google. ${err.message}`), { + response: err.message, + }); + } +} + +registerAccessTokenService('google', async (options) => { + check( + options, + Match.ObjectIncluding({ + accessToken: String, + idToken: String, + expiresIn: Match.Integer, + scope: Match.Maybe(String), + identity: Match.Maybe(Object), + }), + ); + + const identity = await getIdentity(options.accessToken); + + const serviceData = { + accessToken: options.accessToken, + idToken: options.idToken, + expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10), + scope: options.scopes || (await getScopes(options.accessToken)), + }; + + const fields = _.pick(identity, Google.whitelistedFields); + _.extend(serviceData, fields); + + // only set the token in serviceData if it's there. this ensures + // that we don't lose old ones (since we only get this on the first + // log in attempt) + if (options.refreshToken) { + serviceData.refreshToken = options.refreshToken; + } + + return { + serviceData, + options: { + profile: { + name: identity.name, + }, + }, + }; +}); diff --git a/apps/meteor/server/lib/auth-providers/oauth/oauth.js b/apps/meteor/server/lib/auth-providers/oauth/oauth.js new file mode 100644 index 0000000..2734241 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/oauth/oauth.js @@ -0,0 +1,55 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import _ from 'underscore'; + +const AccessTokenServices = {}; + +export const registerAccessTokenService = function (serviceName, handleAccessTokenRequest) { + AccessTokenServices[serviceName] = { + serviceName, + handleAccessTokenRequest, + }; +}; + +// Listen to calls to `login` with an oauth option set. This is where +// users actually get logged in to meteor via oauth. +Accounts.registerLoginHandler(async (options) => { + if (!options.accessToken) { + return undefined; // don't handle + } + + check( + options, + Match.ObjectIncluding({ + serviceName: String, + }), + ); + + const service = AccessTokenServices[options.serviceName]; + + // Skip everything if there's no service set by the oauth middleware + if (!service) { + throw new Error(`Unexpected AccessToken service ${options.serviceName}`); + } + + // Make sure we're configured + if (!(await ServiceConfiguration.configurations.findOneAsync({ service: options.serviceName }))) { + throw new Accounts.ConfigError(); + } + + if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) { + // serviceName was not found in the registered services list. + // This could happen because the service never registered itself or + // unregisterService was called on it. + return { + type: 'oauth', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, `No registered oauth service found for: ${service.serviceName}`), + }; + } + + const oauthResult = await service.handleAccessTokenRequest(options); + + return Accounts.updateOrCreateUserFromExternalService(service.serviceName, oauthResult.serviceData, oauthResult.options); +}); diff --git a/apps/meteor/server/lib/auth-providers/oauth/proxy.js b/apps/meteor/server/lib/auth-providers/oauth/proxy.js new file mode 100644 index 0000000..e7fa300 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/oauth/proxy.js @@ -0,0 +1,12 @@ +import { OAuth } from 'meteor/oauth'; +import _ from 'underscore'; + +import { settings } from '../../../settings'; + +OAuth._redirectUri = _.wrap(OAuth._redirectUri, (func, serviceName, ...args) => { + const proxy = settings.get('Accounts_OAuth_Proxy_services').replace(/\s/g, '').split(','); + if (proxy.includes(serviceName)) { + return `${settings.get('Accounts_OAuth_Proxy_host')}/oauth_redirect`; + } + return func(serviceName, ...args); +}); diff --git a/apps/meteor/server/lib/auth-providers/oauth/twitter.js b/apps/meteor/server/lib/auth-providers/oauth/twitter.js new file mode 100644 index 0000000..bbdf140 --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/oauth/twitter.js @@ -0,0 +1,55 @@ +import { Match, check } from 'meteor/check'; +import { TwitterApi } from 'twitter-api-v2'; +import _ from 'underscore'; + +import { registerAccessTokenService } from './oauth'; + +const whitelistedFields = ['id', 'name', 'description', 'profile_image_url', 'profile_image_url_https', 'lang', 'email']; + +const getIdentity = async function (accessToken, appId, appSecret, accessTokenSecret) { + const client = new TwitterApi({ + appKey: appId, + appSecret, + accessToken, + accessSecret: accessTokenSecret, + }); + try { + return await client.v1.verifyCredentials({ include_email: true }); + } catch (err) { + throw _.extend(new Error(`Failed to fetch identity from Twitter. ${err.message}`), { + data: err.data ?? err.response, + }); + } +}; + +registerAccessTokenService('twitter', async (options) => { + check( + options, + Match.ObjectIncluding({ + accessToken: String, + appSecret: String, + appId: String, + accessTokenSecret: String, + expiresIn: Match.Integer, + }), + ); + + const identity = await getIdentity(options.accessToken, options.appId, options.appSecret, options.accessTokenSecret); + + const serviceData = { + accessToken: options.accessToken, + expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10), + }; + + const fields = _.pick(identity, whitelistedFields); + _.extend(serviceData, fields); + + return { + serviceData, + options: { + profile: { + name: identity.name, + }, + }, + }; +}); diff --git a/apps/meteor/server/lib/auth-providers/wordpress.ts b/apps/meteor/server/lib/auth-providers/wordpress.ts new file mode 100644 index 0000000..5c3169d --- /dev/null +++ b/apps/meteor/server/lib/auth-providers/wordpress.ts @@ -0,0 +1,115 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import passport from 'passport'; +import _ from 'underscore'; + +import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth'; +import { CustomOAuth } from './custom-oauth/custom_oauth_server'; +import { settings } from '../../settings'; + +const config: Partial = { + serverURL: '', + identityPath: '/oauth/me', + + addAutopublishFields: { + forLoggedInUser: ['services.wordpress'], + forOtherUsers: ['services.wordpress.user_login'], + }, + accessTokenParam: 'access_token', +}; + +const serviceKey = 'wordpress'; + +const WordPress = new CustomOAuth(serviceKey, config); + +const fillSettings = _.debounce(async (): Promise => { + config.serverURL = settings.get('API_Wordpress_URL'); + if (!config.serverURL) { + if (config.serverURL === undefined) { + return fillSettings(); + } + return; + } + + delete config.identityPath; + delete config.identityTokenSentVia; + delete config.authorizePath; + delete config.tokenPath; + delete config.scope; + + passport.unuse(serviceKey); + + const serverType = settings.get('Accounts_OAuth_Wordpress_server_type'); + switch (serverType) { + case 'custom': + if (settings.get('Accounts_OAuth_Wordpress_identity_path')) { + config.identityPath = settings.get('Accounts_OAuth_Wordpress_identity_path'); + } + + if (settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via')) { + config.identityTokenSentVia = settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via'); + } + + if (settings.get('Accounts_OAuth_Wordpress_token_path')) { + config.tokenPath = settings.get('Accounts_OAuth_Wordpress_token_path'); + } + + if (settings.get('Accounts_OAuth_Wordpress_authorize_path')) { + config.authorizePath = settings.get('Accounts_OAuth_Wordpress_authorize_path'); + } + + if (settings.get('Accounts_OAuth_Wordpress_scope')) { + config.scope = settings.get('Accounts_OAuth_Wordpress_scope'); + } + break; + case 'wordpress-com': + config.identityPath = '/rest/v1/me'; + config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia']; + config.authorizePath = '/oauth2/authorize'; + config.tokenPath = '/oauth2/token'; + config.scope = 'auth'; + break; + default: + config.identityPath = '/oauth/me'; + config.authorizePath = '/oauth/authorize'; + config.tokenPath = '/oauth/token'; + break; + } + + const clientId = settings.get('Accounts_OAuth_Wordpress_id'); + const clientSecret = settings.get('Accounts_OAuth_Wordpress_secret'); + + if (!clientId || !clientSecret) { + return; + } + + const completeConfig = { ...config, clientId, clientSecret }; + + if (settings.get('Accounts_OAuth_Use_Modern_Flow')) { + addPassportCustomOAuth(serviceKey, completeConfig); + } else { + WordPress.configure(completeConfig); + } + + const enabled = settings.get('Accounts_OAuth_Wordpress'); + if (enabled) { + await ServiceConfiguration.configurations.upsertAsync( + { + service: serviceKey, + }, + { + $set: config, + }, + ); + } else { + await ServiceConfiguration.configurations.removeAsync({ + service: serviceKey, + }); + } +}, 1000); + +Meteor.startup(() => { + settings.watchByRegex(/(API\_Wordpress\_URL)?(Accounts\_OAuth\_Wordpress\_)?/, () => fillSettings()); + settings.watch('Accounts_OAuth_Use_Modern_Flow', () => fillSettings()); +}); diff --git a/apps/meteor/server/lib/auth/ILoginAttempt.ts b/apps/meteor/server/lib/auth/ILoginAttempt.ts new file mode 100644 index 0000000..c4c89f7 --- /dev/null +++ b/apps/meteor/server/lib/auth/ILoginAttempt.ts @@ -0,0 +1,27 @@ +import type { MeteorError } from '@rocket.chat/core-services'; +import type { IUser, IMethodConnection } from '@rocket.chat/core-typings'; + +interface IMethodArgument { + user?: { username: string }; + password?: { + digest: string; + algorithm: string; + }; + resume?: string; + + cas?: boolean; + + totp?: { + code: string; + }; +} + +export interface ILoginAttempt { + type: string; + allowed: boolean; + methodName: string; + methodArguments: IMethodArgument[]; + connection: IMethodConnection; + user?: IUser; + error?: MeteorError; +} diff --git a/apps/meteor/server/lib/auth/generatePassword.ts b/apps/meteor/server/lib/auth/generatePassword.ts new file mode 100644 index 0000000..bf8d247 --- /dev/null +++ b/apps/meteor/server/lib/auth/generatePassword.ts @@ -0,0 +1,31 @@ +import generator from 'generate-password'; + +import { passwordPolicy } from './passwordPolicy'; + +export const generatePassword = (): string => { + const policies = passwordPolicy.getPasswordPolicy(); + + const maxLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-maxLength')?.[1]?.maxLength as number) || -1; + const minLength: number = (policies.policy.find(([key]) => key === 'get-password-policy-minLength')?.[1]?.minLength as number) || -1; + + const length = Math.min(Math.max(minLength, 12), maxLength > 0 ? maxLength : Number.MAX_SAFE_INTEGER); + + if (policies.enabled) { + for (let i = 0; i < 10; i++) { + const password = generator.generate({ + length, + ...(policies.policy && { numbers: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneSpecialCharacter') && { symbols: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneLowercase') && { lowercase: true }), + ...(policies.policy.some(([key]) => key === 'get-password-policy-mustContainAtLeastOneUppercase') && { uppercase: true }), + strict: true, + }); + + if (passwordPolicy.validate(password)) { + return password; + } + } + } + + return generator.generate({ length: 17 }); +}; diff --git a/apps/meteor/server/lib/auth/logLoginAttempts.ts b/apps/meteor/server/lib/auth/logLoginAttempts.ts new file mode 100644 index 0000000..00dd179 --- /dev/null +++ b/apps/meteor/server/lib/auth/logLoginAttempts.ts @@ -0,0 +1,37 @@ +import type { ILoginAttempt } from './ILoginAttempt'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export const logFailedLoginAttempts = (login: ILoginAttempt): void => { + if (!settings.get('Login_Logs_Enabled')) { + return; + } + + let user = 'unknown'; + if (login.methodArguments[0]?.user?.username && settings.get('Login_Logs_Username')) { + user = login.methodArguments[0]?.user?.username; + } + const { connection } = login; + let { clientAddress } = connection; + if (!settings.get('Login_Logs_ClientIp')) { + clientAddress = '-'; + } + let forwardedFor = connection.httpHeaders?.['x-forwarded-for']; + let realIp = connection.httpHeaders?.['x-real-ip']; + if (!settings.get('Login_Logs_ForwardedForIp')) { + forwardedFor = '-'; + realIp = '-'; + } + let userAgent = connection.httpHeaders?.['user-agent']; + if (!settings.get('Login_Logs_UserAgent')) { + userAgent = '-'; + } + SystemLogger.info({ + msg: 'Failed login detected', + user, + clientAddress, + forwardedFor, + realIp, + userAgent, + }); +}; diff --git a/apps/meteor/server/lib/auth/loginErrorMessageOverride.ts b/apps/meteor/server/lib/auth/loginErrorMessageOverride.ts new file mode 100644 index 0000000..e2a6e0d --- /dev/null +++ b/apps/meteor/server/lib/auth/loginErrorMessageOverride.ts @@ -0,0 +1,16 @@ +// Do not disclose if user exists when password is invalid +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +const { _runLoginHandlers } = Accounts; + +Accounts._options.ambiguousErrorMessages = true; +Accounts._runLoginHandlers = async function (methodInvocation, options) { + const result = await _runLoginHandlers.call(Accounts, methodInvocation, options); + + if (result.error instanceof Meteor.Error) { + result.error = new Meteor.Error(401, 'User not found'); + } + + return result; +}; diff --git a/apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts b/apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts new file mode 100644 index 0000000..2add2e8 --- /dev/null +++ b/apps/meteor/server/lib/auth/oauth2-server/addOAuthApp.ts @@ -0,0 +1,69 @@ +import type { IOAuthApps, IUser } from '@rocket.chat/core-typings'; +import { OAuthApps, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; + +import { parseUriList } from './parseUriList'; +import type { OauthAppsAddParams } from '../../../api/v1/oauthapps'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +export async function addOAuthApp(applicationParams: OauthAppsAddParams, uid: IUser['_id'] | undefined): Promise { + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' }); + } + + const user = await Users.findOneById(uid, { projection: { username: 1 } }); + + if (!user?.username) { + // TODO: username is required, but not always present + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthApp' }); + } + + if (!(await hasPermissionAsync(uid, 'manage-oauth-apps'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addOAuthApp' }); + } + + if (!applicationParams.name || typeof applicationParams.name.valueOf() !== 'string' || applicationParams.name.trim() === '') { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'addOAuthApp' }); + } + + if ( + !applicationParams.redirectUri || + typeof applicationParams.redirectUri.valueOf() !== 'string' || + applicationParams.redirectUri.trim() === '' + ) { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { + method: 'addOAuthApp', + }); + } + + if (typeof applicationParams.active !== 'boolean') { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'addOAuthApp', + }); + } + + const application = { + ...applicationParams, + redirectUri: parseUriList(applicationParams.redirectUri), + clientId: Random.id(), + clientSecret: Random.secret(), + _createdAt: new Date(), + _updatedAt: new Date(), + _createdBy: { + _id: user._id, + username: user.username, + }, + }; + + if (application.redirectUri.length === 0) { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { + method: 'addOAuthApp', + }); + } + + return { + ...application, + _id: (await OAuthApps.insertOne(application)).insertedId, + }; +} diff --git a/apps/meteor/server/lib/auth/oauth2-server/index.ts b/apps/meteor/server/lib/auth/oauth2-server/index.ts new file mode 100644 index 0000000..159416c --- /dev/null +++ b/apps/meteor/server/lib/auth/oauth2-server/index.ts @@ -0,0 +1,4 @@ +import './oauth2-server'; +import './addOAuthApp'; +import '../../../meteor-methods/auth/updateOAuthApp'; +import '../../../meteor-methods/auth/deleteOAuthApp'; diff --git a/apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts b/apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts new file mode 100644 index 0000000..a69e5c5 --- /dev/null +++ b/apps/meteor/server/lib/auth/oauth2-server/oauth2-server.ts @@ -0,0 +1,85 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { OAuthAccessTokens, Users } from '@rocket.chat/models'; +import type { Request, Response } from 'express'; +import type express from 'express'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { isPlainObject } from '../../../../lib/utils/isPlainObject'; +import { API } from '../../../api'; +import { OAuth2Server } from '../../../oauth2-server/oauth'; + +const oauth2server = new OAuth2Server({ + // If you're developing something related to oauth servers, you should change this to true + debug: false, +}); + +// https://github.com/RocketChat/rocketchat-oauth2-server/blob/e758fd7ef69348c7ceceabe241747a986c32d036/model.coffee#L27-L27 +async function getAccessToken(accessToken: string) { + return OAuthAccessTokens.findOneByAccessToken(accessToken); +} + +export async function oAuth2ServerAuth(partialRequest: { authorization?: string; accessToken?: string }): Promise { + const headerToken = partialRequest.authorization?.replace('Bearer ', ''); + const incomingToken = headerToken || partialRequest.accessToken; + + if (!incomingToken) { + return; + } + + const accessToken = await getAccessToken(incomingToken); + + // If there is no token available or the token has expired, return undefined + if (!accessToken || (accessToken.expires != null && accessToken.expires < new Date())) { + return; + } + + const user = await Users.findOneActiveById(accessToken.userId); + + if (!user) { + return; + } + + return user; +} + +oauth2server.app.disable('x-powered-by'); + +oauth2server.app.get('/oauth/userinfo', async (req: Request, res: Response) => { + if (req.headers.authorization == null) { + return res.status(401).send('No token'); + } + const accessToken = req.headers.authorization.replace('Bearer ', ''); + const token = await getAccessToken(accessToken); + if (token == null) { + return res.status(401).send('Invalid Token'); + } + const user = await Users.findOneActiveById(token.userId); + if (!user) { + return res.status(401).send('Invalid Token'); + } + return res.send({ + sub: user._id, + name: user.name, + email: user.emails?.[0].address, + email_verified: user.emails?.[0].verified, + department: '', + birthdate: '', + preffered_username: user.username, + updated_at: user._updatedAt, + picture: `${Meteor.absoluteUrl()}avatar/${user.username}`, + }); +}); + +API.v1.addAuthMethod((routeContext) => { + const authorization = routeContext.request.headers.get('authorization') ?? undefined; + const query = isPlainObject(routeContext.queryParams) ? routeContext.queryParams : {}; + const accessToken = typeof query.access_token === 'string' ? query.access_token : undefined; + if (routeContext.queryParams && 'access_token' in routeContext.queryParams) { + delete routeContext.queryParams.access_token; + } + + return oAuth2ServerAuth({ authorization, accessToken }); +}); + +(WebApp.connectHandlers as unknown as ReturnType).use(oauth2server.app); diff --git a/apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts b/apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts new file mode 100644 index 0000000..3693bd3 --- /dev/null +++ b/apps/meteor/server/lib/auth/oauth2-server/parseUriList.ts @@ -0,0 +1,17 @@ +export const parseUriList = (userUri: string) => { + if (userUri.indexOf('\n') < 0 && userUri.indexOf(',') < 0) { + return userUri; + } + + const uriList: string[] = []; + userUri.split(/[,\n]/).forEach((item) => { + const uri = item.trim(); + if (uri === '') { + return; + } + + uriList.push(uri); + }); + + return uriList.join(','); // TODO: This is a hack because the original code was returning a string or an array of strings +}; diff --git a/apps/meteor/server/lib/auth/passwordPolicy.ts b/apps/meteor/server/lib/auth/passwordPolicy.ts new file mode 100644 index 0000000..9556f74 --- /dev/null +++ b/apps/meteor/server/lib/auth/passwordPolicy.ts @@ -0,0 +1,64 @@ +import { PasswordPolicy } from '@rocket.chat/password-policies'; + +import { settings } from '../../settings'; + +const enabled = false; +const minLength = -1; +const maxLength = -1; +const forbidRepeatingCharacters = false; +const forbidRepeatingCharactersCount = 3; +const mustContainAtLeastOneLowercase = false; +const mustContainAtLeastOneUppercase = false; +const mustContainAtLeastOneNumber = false; +const mustContainAtLeastOneSpecialCharacter = false; + +export let passwordPolicy = new PasswordPolicy({ + enabled, + minLength, + maxLength, + forbidRepeatingCharacters, + forbidRepeatingCharactersCount, + mustContainAtLeastOneLowercase, + mustContainAtLeastOneUppercase, + mustContainAtLeastOneNumber, + mustContainAtLeastOneSpecialCharacter, + throwError: true, +}); + +settings.watchMultiple( + [ + 'Accounts_Password_Policy_Enabled', + 'Accounts_Password_Policy_MinLength', + 'Accounts_Password_Policy_MaxLength', + 'Accounts_Password_Policy_ForbidRepeatingCharacters', + 'Accounts_Password_Policy_ForbidRepeatingCharactersCount', + 'Accounts_Password_Policy_AtLeastOneLowercase', + 'Accounts_Password_Policy_AtLeastOneUppercase', + 'Accounts_Password_Policy_AtLeastOneNumber', + 'Accounts_Password_Policy_AtLeastOneSpecialCharacter', + ], + ([ + enabled, + minLength, + maxLength, + forbidRepeatingCharacters, + forbidRepeatingCharactersCount, + mustContainAtLeastOneLowercase, + mustContainAtLeastOneUppercase, + mustContainAtLeastOneNumber, + mustContainAtLeastOneSpecialCharacter, + ]) => { + passwordPolicy = new PasswordPolicy({ + enabled: Boolean(enabled), + minLength: Number(minLength), + maxLength: Number(maxLength), + forbidRepeatingCharacters: Boolean(forbidRepeatingCharacters), + forbidRepeatingCharactersCount: Number(forbidRepeatingCharactersCount), + mustContainAtLeastOneLowercase: Boolean(mustContainAtLeastOneLowercase), + mustContainAtLeastOneUppercase: Boolean(mustContainAtLeastOneUppercase), + mustContainAtLeastOneNumber: Boolean(mustContainAtLeastOneNumber), + mustContainAtLeastOneSpecialCharacter: Boolean(mustContainAtLeastOneSpecialCharacter), + throwError: true, + }); + }, +); diff --git a/apps/meteor/server/lib/auth/restrictLoginAttempts.ts b/apps/meteor/server/lib/auth/restrictLoginAttempts.ts new file mode 100644 index 0000000..809c67b --- /dev/null +++ b/apps/meteor/server/lib/auth/restrictLoginAttempts.ts @@ -0,0 +1,173 @@ +import type { IServerEvent } from '@rocket.chat/core-typings'; +import { ServerEventType } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Rooms, ServerEvents, Users } from '@rocket.chat/models'; + +import { addMinutesToADate } from '../../../lib/utils/addMinutesToADate'; +import { settings } from '../../settings'; +import { getClientAddress } from '../getClientAddress'; +import type { ILoginAttempt } from './ILoginAttempt'; +import { sendMessage } from '../messages/sendMessage'; + +const logger = new Logger('LoginProtection'); + +const notifyFailedLogin = async (ipOrUsername: string, blockedUntil: Date, failedAttempts: number): Promise => { + const channelToNotify = settings.get('Block_Multiple_Failed_Logins_Notify_Failed_Channel'); + + if (!channelToNotify) { + logger.error('Cannot notify failed logins: channel provided is invalid'); + return; + } + // verify channel exists + // to avoid issues when "fname" is presented in the UI, check if the name matches it as well + const room = await Rooms.findOneByNameOrFname(channelToNotify); + if (!room) { + logger.error({ msg: 'Cannot notify failed logins: channel provided does not exist', channelToNotify }); + return; + } + + const rocketCat = await Users.findOneById('rocket.cat'); + // send message + const message = { + attachments: [ + { + fields: [ + { + title: 'Failed login attempt threshold exceeded', + value: `User or IP: ${ipOrUsername}\nBlocked until: ${blockedUntil}\nFailed Attempts: ${failedAttempts}`, + short: true, + }, + ], + color: 'red', + }, + ], + }; + + await sendMessage(rocketCat, message, room); +}; + +export const isValidLoginAttemptByIp = async (ip: string): Promise => { + const whitelist = String(settings.get('Block_Multiple_Failed_Logins_Ip_Whitelist')).split(','); + + if ( + !settings.get('Block_Multiple_Failed_Logins_Enabled') || + !settings.get('Block_Multiple_Failed_Logins_By_Ip') || + whitelist.includes(ip) + ) { + return true; + } + + // misconfigured + const attemptsUntilBlock = settings.get('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip'); + if (!attemptsUntilBlock) { + return true; + } + + // if user never failed to login, then it's valid + const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByIp(ip))?.ts; + if (!lastFailedAttemptAt) { + return true; + } + + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes'); + + const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1); + const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByIp(ip))?.ts; + + // successful logins should reset the counter + const startTime = lastSuccessfulAttemptAt + ? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime())) + : lockoutTimeStart; + + const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByIpSince(ip, startTime); + + // if user didn't reach the threshold, then it's valid + if (failedAttemptsSinceLastLogin < attemptsUntilBlock) { + return true; + } + + if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) { + const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock); + + await notifyFailedLogin(ip, willBeBlockedUntil, failedAttemptsSinceLastLogin); + } + + return false; +}; + +export const isValidAttemptByUser = async (login: ILoginAttempt): Promise => { + if (!settings.get('Block_Multiple_Failed_Logins_Enabled') || !settings.get('Block_Multiple_Failed_Logins_By_User')) { + return true; + } + + const loginUsername = login.methodArguments[0].user?.username; + if (!loginUsername) { + return true; + } + + // misconfigured + const attemptsUntilBlock = settings.get('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User'); + if (!attemptsUntilBlock) { + return true; + } + + // if user never failed to login, then it's valid + const lastFailedAttemptAt = (await ServerEvents.findLastFailedAttemptByUsername(loginUsername))?.ts; + if (!lastFailedAttemptAt) { + return true; + } + + const minutesUntilUnblock = settings.get('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes'); + + const lockoutTimeStart = addMinutesToADate(new Date(), minutesUntilUnblock * -1); + const lastSuccessfulAttemptAt = (await ServerEvents.findLastSuccessfulAttemptByUsername(loginUsername))?.ts; + + // succesful logins should reset the counter + const startTime = lastSuccessfulAttemptAt + ? new Date(Math.max(lockoutTimeStart.getTime(), lastSuccessfulAttemptAt.getTime())) + : lockoutTimeStart; + + // get total failed attempts during the lockout time + const failedAttemptsSinceLastLogin = await ServerEvents.countFailedAttemptsByUsernameSince(loginUsername, startTime); + + // if user didn't reach the threshold, then it's valid + if (failedAttemptsSinceLastLogin < attemptsUntilBlock) { + return true; + } + + if (settings.get('Block_Multiple_Failed_Logins_Notify_Failed')) { + const willBeBlockedUntil = addMinutesToADate(new Date(lastFailedAttemptAt), minutesUntilUnblock); + + await notifyFailedLogin(loginUsername, willBeBlockedUntil, failedAttemptsSinceLastLogin); + } + + return false; +}; + +export const saveFailedLoginAttempts = async (login: ILoginAttempt): Promise => { + const user: IServerEvent['u'] = { + _id: login.user?._id, + username: login.user?.username || login.methodArguments[0].user?.username, + }; + + await ServerEvents.insertOne({ + ip: getClientAddress(login.connection), + t: ServerEventType.FAILED_LOGIN_ATTEMPT, + ts: new Date(), + u: user, + }); +}; + +export const saveSuccessfulLogin = async (login: ILoginAttempt): Promise => { + const user: IServerEvent['u'] = { + _id: login.user?._id, + username: login.user?.username || login.methodArguments[0].user?.username, + }; + + await ServerEvents.insertOne({ + ip: getClientAddress(login.connection), + t: ServerEventType.LOGIN, + ts: new Date(), + u: user, + }); +}; diff --git a/apps/meteor/server/lib/auth/startup.js b/apps/meteor/server/lib/auth/startup.js new file mode 100644 index 0000000..036f676 --- /dev/null +++ b/apps/meteor/server/lib/auth/startup.js @@ -0,0 +1,531 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { User } from '@rocket.chat/core-services'; +import { Roles, Settings, Users } from '@rocket.chat/models'; +import { escapeRegExp, escapeHTML, getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { isValidAttemptByUser, isValidLoginAttemptByIp } from './restrictLoginAttempts'; +import { parseCSV } from '../../../lib/utils/parseCSV'; +import { safeHtmlDots } from '../../../lib/utils/safeHtmlDots'; +import { getNewUserRoles } from '../../services/user/lib/getNewUserRoles'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback'; +import { getClientAddress } from '../getClientAddress'; +import { getMaxLoginTokens } from '../getMaxLoginTokens'; +import { i18n } from '../i18n'; +import * as Mailer from '../notifications/email/api'; +import { notifyOnSettingChangedById } from '../notifyListener'; +import { addUserRolesAsync } from '../roles/addUserRoles'; +import { joinDefaultChannels } from '../rooms/joinDefaultChannels'; +import { getAvatarSuggestionForUser } from '../users/getAvatarSuggestionForUser'; +import { setAvatarFromServiceWithValidation } from '../users/setUserAvatar'; +import { getBaseUserFields } from '../utils/functions/getBaseUserFields'; + +Accounts.config({ + forbidClientAccountCreation: true, +}); +/** + * Accounts calls `_initServerPublications` and holds the `_defaultPublishFields`, without Object.assign its not possible + * to extend the projection + * + * the idea is to send all required fields to the client during login + * we tried `defaultFieldsSelector` , but it changes all Meteor.userAsync projections which is undesirable + * + * + * we are removing the status here because meteor send 'offline' + */ +Object.assign(Accounts._defaultPublishFields.projection, (({ status, ...rest }) => rest)(getBaseUserFields(true))); + +// Override Meteor's _expireTokens to ensure the correct login expiration is used. +// If loginExpirationInDays is not set (e.g., startup was disrupted before settings watcher fired), +// read the setting directly from MongoDB before proceeding with token cleanup. +// This prevents tokens from being incorrectly deleted using Meteor's 90-day default. +const { _expireTokens } = Accounts; +Accounts._expireTokens = async function (oldestValidDate, userId) { + if (!Accounts._options.loginExpirationInDays) { + const loginExpiration = await Settings.getValueById('Accounts_LoginExpiration'); + Accounts._options.loginExpirationInDays = getLoginExpirationInDays(loginExpiration); + } + return _expireTokens.call(Accounts, oldestValidDate, userId); +}; + +Meteor.startup(() => { + settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => { + Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration')); + + Accounts.emailTemplates.siteName = settings.get('Site_Name'); + + Accounts.emailTemplates.from = `${settings.get('Site_Name')} <${settings.get('From_Email')}>`; + }); +}); + +Accounts.emailTemplates.userToActivate = { + subject() { + const subject = i18n.t('Accounts_Admin_Email_Approval_Needed_Subject_Default'); + const siteName = settings.get('Site_Name'); + + return `[${siteName}] ${subject}`; + }, + + html(options = {}) { + const email = options.reason + ? 'Accounts_Admin_Email_Approval_Needed_With_Reason_Default' + : 'Accounts_Admin_Email_Approval_Needed_Default'; + + return Mailer.replace(i18n.t(email), { + name: escapeHTML(options.name), + email: escapeHTML(options.email), + reason: escapeHTML(options.reason), + }); + }, +}; + +Accounts.emailTemplates.userActivated = { + subject({ active, username }) { + const activated = username ? 'Activated' : 'Approved'; + const action = active ? activated : 'Deactivated'; + const subject = `Accounts_Email_${action}_Subject`; + const siteName = settings.get('Site_Name'); + + return `[${siteName}] ${i18n.t(subject)}`; + }, + + html({ active, name, username }) { + const activated = username ? 'Activated' : 'Approved'; + const action = active ? activated : 'Deactivated'; + + return Mailer.replace(i18n.t(`Accounts_Email_${action}`), { + name: escapeHTML(name), + }); + }, +}; + +let verifyEmailTemplate = ''; +let enrollAccountTemplate = ''; +let resetPasswordTemplate = ''; +Meteor.startup(() => { + Mailer.getTemplateWrapped('Verification_Email', (value) => { + verifyEmailTemplate = value; + }); + Mailer.getTemplateWrapped('Accounts_Enrollment_Email', (value) => { + enrollAccountTemplate = value; + }); + Mailer.getTemplateWrapped('Forgot_Password_Email', (value) => { + resetPasswordTemplate = value; + }); +}); + +Accounts.emailTemplates.verifyEmail.html = function (userModel, url) { + const name = safeHtmlDots(userModel.name); + + return Mailer.replace(verifyEmailTemplate, { Verification_Url: url, name }); +}; + +Accounts.emailTemplates.verifyEmail.subject = function () { + const subject = settings.get('Verification_Email_Subject'); + return Mailer.replace(subject || ''); +}; + +Accounts.urls.resetPassword = function (token) { + return Meteor.absoluteUrl(`reset-password/${token}`); +}; + +Accounts.emailTemplates.resetPassword.subject = function (userModel) { + return Mailer.replace(settings.get('Forgot_Password_Email_Subject') || '', { + name: userModel.name, + }); +}; + +Accounts.emailTemplates.resetPassword.html = function (userModel, url) { + return Mailer.replacekey( + Mailer.replace(resetPasswordTemplate, { + name: userModel.name, + }), + 'Forgot_Password_Url', + url, + ); +}; + +Accounts.emailTemplates.enrollAccount.subject = function (user) { + const subject = settings.get('Accounts_Enrollment_Email_Subject'); + return Mailer.replace(subject, user); +}; + +Accounts.emailTemplates.enrollAccount.html = function (user = {} /* , url*/) { + return Mailer.replace(enrollAccountTemplate, { + name: escapeHTML(user.name), + email: user.emails && user.emails[0] && escapeHTML(user.emails[0].address), + }); +}; + +const getLinkedInName = ({ firstName, lastName }) => { + const { preferredLocale, localized: firstNameLocalized } = firstName; + const { localized: lastNameLocalized } = lastName; + + // LinkedIn new format + if (preferredLocale && firstNameLocalized && preferredLocale.language && preferredLocale.country) { + const locale = `${preferredLocale.language}_${preferredLocale.country}`; + + if (firstNameLocalized[locale] && lastNameLocalized[locale]) { + return `${firstNameLocalized[locale]} ${lastNameLocalized[locale]}`; + } + if (firstNameLocalized[locale]) { + return firstNameLocalized[locale]; + } + } + + // LinkedIn old format + if (!lastName) { + return firstName; + } + return `${firstName} ${lastName}`; +}; + +const validateEmailDomain = (user) => { + if (user.type === 'visitor') { + return true; + } + + let domainWhiteList = settings.get('Accounts_AllowedDomainsList'); + if (_.isEmpty(domainWhiteList?.trim())) { + return true; + } + + domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim()); + + if (user.emails && user.emails.length > 0) { + const email = user.emails[0].address; + const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`)); + + if (!inWhiteList) { + throw new Meteor.Error('error-invalid-domain'); + } + } + + return true; +}; + +const onCreateUserAsync = async function (options, user = {}) { + if (!options.skipBeforeCreateUserCallback) { + await beforeCreateUserCallback.run(options, user); + } + user.status = 'offline'; + + user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers'); + if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) { + user.inactiveReason = 'pending_approval'; + } + + if (!user.name) { + if (options.profile) { + if (options.profile.name) { + user.name = options.profile.name; + } else if (options.profile.firstName) { + // LinkedIn format + user.name = getLinkedInName(options.profile); + } + } + } + + if (user.services) { + const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); + + for (const service of Object.values(user.services)) { + const suggestedName = service.name || service.username; + if (!user.name && suggestedName) { + user.name = suggestedName; + } + + if (!user.emails && service.email) { + user.emails = [ + { + address: service.email, + verified, + }, + ]; + } + } + } + + if (!options.skipAdminEmail && !user.active) { + const destinations = []; + const usersInRole = await Roles.findUsersInRole('admin'); + await usersInRole.forEach((adminUser) => { + if (Array.isArray(adminUser.emails)) { + adminUser.emails.forEach((email) => { + destinations.push(`${adminUser.name}<${email.address}>`); + }); + } + }); + + const email = { + to: destinations, + from: settings.get('From_Email'), + subject: Accounts.emailTemplates.userToActivate.subject(), + html: Accounts.emailTemplates.userToActivate.html({ + ...options, + name: options.name || options.profile?.name, + email: options.email || user.emails[0].address, + }), + }; + + await Mailer.send(email); + } + + if (!options.skipOnCreateUserCallback) { + await callbacks.run('onCreateUser', options, user); + } + + if (!options.skipEmailValidation && !validateEmailDomain(user)) { + throw new Meteor.Error(403, 'User validation failed'); + } + + return removeEmpty(user); +}; + +Accounts.onCreateUser(function (...args) { + // Depends on meteor support for Async + return onCreateUserAsync.call(this, ...args); +}); + +const { insertUserDoc } = Accounts; + +Accounts.insertUserDoc = async function (options, user) { + const globalRoles = new Set(); + + if (Match.test(options.globalRoles, [String]) && options.globalRoles.length > 0) { + options.globalRoles.map((role) => globalRoles.add(role)); + } + + if (Match.test(user.globalRoles, [String]) && user.globalRoles.length > 0) { + user.globalRoles.map((role) => globalRoles.add(role)); + } + + delete user.globalRoles; + + // for some reason, the name is not being set in the user object but is being set in the options object + if (options.name && typeof options.name === 'string') { + user.name = options.name; + } + + if (user.services && !user.services.password && !options.skipAuthServiceDefaultRoles) { + const defaultAuthServiceRoles = parseCSV(settings.get('Accounts_Registration_AuthenticationServices_Default_Roles') || ''); + + if (defaultAuthServiceRoles.length > 0) { + defaultAuthServiceRoles.map((role) => globalRoles.add(role)); + } + } + + const arrayGlobalRoles = [...globalRoles]; + const roles = options.skipNewUserRolesSetting ? arrayGlobalRoles : getNewUserRoles(arrayGlobalRoles); + + if (!user.type) { + user.type = 'user'; + } + + if ( + settings.get('Accounts_TwoFactorAuthentication_Enabled') && + settings.get('Accounts_TwoFactorAuthentication_By_Email_Enabled') && + settings.get('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In') + ) { + user.services = user.services || {}; + user.services.email2fa = { + enabled: true, + changedAt: new Date(), + }; + } + + // Make sure that the user has the field 'roles' + if (!user.roles) { + user.roles = []; + } + + const _id = await insertUserDoc.call(Accounts, options, user); + + user = await Users.findOne({ + _id, + }); + + /** + * if settings shows setup wizard to be pending + * and no admin's been found, + * and existing role list doesn't include admin + * create this user admin. + * count this as the completion of setup wizard step 1. + */ + if (!options.skipAdminCheck) { + const hasAdmin = await Users.findOneByRolesAndType('admin', 'user', { projection: { _id: 1 } }); + if (!roles.includes('admin') && !hasAdmin) { + roles.push('admin'); + if (settings.get('Show_Setup_Wizard') === 'pending') { + // TODO: audit + (await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount && + void notifyOnSettingChangedById('Show_Setup_Wizard'); + } + } + } + + await addUserRolesAsync(_id, roles); + + // Make user's roles to be present on callback + user = await Users.findOneById(_id, { projection: { username: 1, type: 1, roles: 1 } }); + + if (user.username) { + if (options.joinDefaultChannels !== false) { + await joinDefaultChannels(_id, options.joinDefaultChannelsSilenced); + } + + if (!options.skipAfterCreateUserCallback && user.type !== 'visitor') { + setImmediate(() => { + return callbacks.run('afterCreateUser', user); + }); + } + if (!options.skipDefaultAvatar && settings.get('Accounts_SetDefaultAvatar') === true) { + const avatarSuggestions = await getAvatarSuggestionForUser(user); + for (const service of Object.keys(avatarSuggestions)) { + const avatarData = avatarSuggestions[service]; + if (service !== 'gravatar') { + await setAvatarFromServiceWithValidation(_id, avatarData.blob, '', service); + break; + } + } + } + } + + if (!options.skipAppsEngineEvent) { + // `post` triggered events don't need to wait for the promise to resolve + Apps.self?.triggerEvent(AppEvents.IPostUserCreated, { user, performedBy: options.performedBy }).catch((e) => { + Apps.self?.getRocketChatLogger().error({ msg: 'Error while executing post user created event', err: e }); + }); + } + + return _id; +}; + +const validateLoginAttemptAsync = async function (login) { + login = await callbacks.run('beforeValidateLogin', login); + + if (!(await isValidLoginAttemptByIp(getClientAddress(login.connection)))) { + throw new Meteor.Error('error-login-blocked-for-ip', 'Login has been temporarily blocked For IP', { + function: 'Accounts.validateLoginAttempt', + }); + } + + if (!(await isValidAttemptByUser(login))) { + throw new Meteor.Error('error-login-blocked-for-user', 'Login has been temporarily blocked For User', { + function: 'Accounts.validateLoginAttempt', + }); + } + + if (login.allowed !== true) { + return login.allowed; + } + + if (login.user.type === 'visitor') { + return true; + } + + if (login.user.type === 'app') { + throw new Meteor.Error('error-app-user-is-not-allowed-to-login', 'App user is not allowed to login', { + function: 'Accounts.validateLoginAttempt', + }); + } + + if (!!login.user.active !== true) { + throw new Meteor.Error('error-user-is-not-activated', 'User is not activated', { + function: 'Accounts.validateLoginAttempt', + }); + } + + if (!login.user.roles || !Array.isArray(login.user.roles)) { + throw new Meteor.Error('error-user-has-no-roles', 'User has no roles', { + function: 'Accounts.validateLoginAttempt', + }); + } + + if (login.user.roles.includes('admin') === false && login.type === 'password' && settings.get('Accounts_EmailVerification') === true) { + const validEmail = login.user.emails.filter((email) => email.verified === true); + if (validEmail.length === 0) { + throw new Meteor.Error('error-invalid-email', 'Invalid email __email__'); + } + } + + login = await callbacks.run('onValidateLogin', login); + + await Users.updateLastLoginById(login.user._id); + setImmediate(() => { + return callbacks.run('afterValidateLogin', login); + }); + + /** + * Trigger the event only when the + * user does login in Rocket.chat + */ + if (login.type !== 'resume') { + // App IPostUserLoggedIn event hook + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedIn, login.user); + } + + return true; +}; + +Accounts.validateLoginAttempt(function (...args) { + // Depends on meteor support for Async + return validateLoginAttemptAsync.call(this, ...args); +}); + +Accounts.validateNewUser((user) => { + if (user.type === 'visitor') { + return true; + } + + if ( + settings.get('Accounts_Registration_AuthenticationServices_Enabled') === false && + settings.get('LDAP_Enable') === false && + !(user.services && user.services.password) + ) { + throw new Meteor.Error('registration-disabled-authentication-services', 'User registration is disabled for authentication services'); + } + + return true; +}); + +Accounts.validateNewUser((user) => { + if (user.type === 'visitor') { + return true; + } + + let domainWhiteList = settings.get('Accounts_AllowedDomainsList'); + if (_.isEmpty(domainWhiteList?.trim())) { + return true; + } + + domainWhiteList = domainWhiteList.split(',').map((domain) => domain.trim()); + + if (user.emails && user.emails.length > 0) { + const email = user.emails[0].address; + const inWhiteList = domainWhiteList.some((domain) => email.match(`@${escapeRegExp(domain)}$`)); + + if (inWhiteList === false) { + throw new Meteor.Error('error-invalid-domain'); + } + } + + return true; +}); + +Accounts.onLogin(async ({ user }) => { + if (!user || !user.services || !user.services.resume || !user.services.resume.loginTokens || !user._id) { + return; + } + + if (user.services.resume.loginTokens.length < getMaxLoginTokens()) { + return; + } + + await User.ensureLoginTokensLimit(user._id); +}); diff --git a/apps/meteor/server/lib/auth/token-login.js b/apps/meteor/server/lib/auth/token-login.js new file mode 100644 index 0000000..a7969b1 --- /dev/null +++ b/apps/meteor/server/lib/auth/token-login.js @@ -0,0 +1,23 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; + +Accounts.registerLoginHandler('login-token', async (result) => { + if (!result.loginToken) { + return; + } + + check(result.loginToken, String); + + const user = await Users.findOne({ + 'services.loginToken.token': result.loginToken, + }); + + if (user) { + await Users.updateOne({ _id: user._id }, { $unset: { 'services.loginToken': 1 } }); + + return { + userId: user._id, + }; + } +}); diff --git a/apps/meteor/server/lib/authorization/README.md b/apps/meteor/server/lib/authorization/README.md new file mode 100644 index 0000000..93d86ed --- /dev/null +++ b/apps/meteor/server/lib/authorization/README.md @@ -0,0 +1,41 @@ +Supports role or permission based authorization, and defines the association between them. + +A user is associated with role(s), and a role is associated with permission(s). This package depends on alanning:roles for the role/user association, while the role/permission association is handled internally. Thus, the underlying alanning:roles has no concept of a permission or the association between a role and permission. + +Authorization checks can be done based on a role or permission. However, permission based checks are preferred because they loosely associate an action with a role. For example: + +``` +# permission based check +if hasPermission(userId, 'edit-message') ... + # action is loosely associated to role via permission. Thus action can be revoked + # at runtime by removing the permission for user's role instead of modifying the action code. + +# role based check +if hasRole(userId, 'admin') +if hasAnyRole(userId, ['admin','site-moderator','moderator']) + # action is statically associated with the role + # action code has to be modified to add/remove role authorization + +``` + +Usage: +``` +# assign user to admin role. Permissions scoped globally +RocketChat.authz.addUserRoles(userId, ['admin']) + +# assign user to moderator role. Permissions scoped to the specified room +# user can moderate (e.g. edit channel name, delete private group message) for only one room specified by the roomId +RocketChat.authz.addUserRoles(userId, ['moderator'], roomId ) + +# check if user can modify message for any room +RocketChat.authz.hasPermission(userId, 'edit-message') + +# check if user can modify message for the specified room. Also returns true if user +# has 'edit-message' at global scope. +RocketChat.authz.hasPermission(userId, 'edit-message', roomId) +``` + +Notes: +1. Roles are statically defined. UI needs to be implemented to dynamically assign permission(s) to a Role +2. 'admin', 'moderator', 'user' role identifiers should NOT be changed (unless you update the associated code) because they are referenced when creating users and creating rooms. +3. edit, delete message permissions are at either the global or room scope. i.e. role with edit-message with GLOBAL scope can edit ANY message regardless of the room type. However, role with edit-message with room scope can only edit messages for the room. The global scope is associated with the admin role while the "room-scoped" permission is assigned to the room "moderator" (room creator). If we want a middle ground that allows for edit-message for only channel/group/direct, then we need to create individual edit-c-message, edit-p-message, edit-d-message permissions. diff --git a/apps/meteor/server/lib/authorization/canAccessRoom.ts b/apps/meteor/server/lib/authorization/canAccessRoom.ts new file mode 100644 index 0000000..9642228 --- /dev/null +++ b/apps/meteor/server/lib/authorization/canAccessRoom.ts @@ -0,0 +1,12 @@ +import { Authorization } from '@rocket.chat/core-services'; + +export const canAccessRoomAsync = Authorization.canAccessRoom; +export const canAccessRoomIdAsync = Authorization.canAccessRoomId; +export const canAccessRoomIdsAsync = Authorization.canAccessRoomIds; +export const roomAccessAttributes = { + _id: 1, + t: 1, + teamId: 1, + prid: 1, + abacAttributes: 1, +}; diff --git a/apps/meteor/server/lib/authorization/canDeleteMessage.ts b/apps/meteor/server/lib/authorization/canDeleteMessage.ts new file mode 100644 index 0000000..b5c5151 --- /dev/null +++ b/apps/meteor/server/lib/authorization/canDeleteMessage.ts @@ -0,0 +1,77 @@ +import type { IUser, IRoom } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +import { canAccessRoomAsync } from './canAccessRoom'; +import { hasPermissionAsync } from './hasPermission'; +import { getValue } from '../../settings/raw'; + +const elapsedTime = (ts: Date): number => { + const dif = Date.now() - ts.getTime(); + return Math.round(dif / 1000 / 60); +}; + +export const canDeleteMessageAsync = async ( + deletingUser: Pick, + { u, rid, ts }: { u: Pick; rid: string; ts?: Date }, +): Promise => { + const room = await Rooms.findOneById>(rid, { + projection: { + _id: 1, + ro: 1, + unmuted: 1, + t: 1, + teamId: 1, + prid: 1, + }, + }); + + if (!room) { + return false; + } + + if (!(await canAccessRoomAsync(room, { _id: deletingUser._id }))) { + return false; + } + + const forceDelete = await hasPermissionAsync(deletingUser._id, 'force-delete-message', rid); + + if (forceDelete) { + return true; + } + + if (!ts) { + return false; + } + const deleteAllowed = await getValue('Message_AllowDeleting'); + + if (!deleteAllowed) { + return false; + } + + const allowedToDeleteAny = await hasPermissionAsync(deletingUser._id, 'delete-message', rid); + + const allowed = + allowedToDeleteAny || (deletingUser._id === u._id && (await hasPermissionAsync(deletingUser._id, 'delete-own-message', rid))); + if (!allowed) { + return false; + } + const bypassBlockTimeLimit = await hasPermissionAsync(deletingUser._id, 'bypass-time-limit-edit-and-delete', rid); + + if (!bypassBlockTimeLimit) { + const blockDeleteInMinutes = await getValue('Message_AllowDeleting_BlockDeleteInMinutes'); + + if (blockDeleteInMinutes) { + const timeElapsedForMessage = elapsedTime(ts); + return timeElapsedForMessage <= blockDeleteInMinutes; + } + } + + if (room.ro === true && !(await hasPermissionAsync(deletingUser._id, 'post-readonly', rid))) { + // Unless the user was manually unmuted + if (deletingUser.username && !(room.unmuted || []).includes(deletingUser.username)) { + throw new Error("You can't delete messages because the room is readonly."); + } + } + + return true; +}; diff --git a/apps/meteor/server/lib/authorization/canSendMessage.ts b/apps/meteor/server/lib/authorization/canSendMessage.ts new file mode 100644 index 0000000..fd1470e --- /dev/null +++ b/apps/meteor/server/lib/authorization/canSendMessage.ts @@ -0,0 +1,66 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; + +import { canAccessRoomAsync } from './canAccessRoom'; +import { hasPermissionAsync } from './hasPermission'; +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../rooms/roomCoordinator'; + +const subscriptionOptions = { + projection: { + blocked: 1, + blocker: 1, + }, +}; + +// TODO: remove option uid and username and type +export async function validateRoomMessagePermissionsAsync( + room: IRoom | null, + args: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, + extraData?: Record, +): Promise { + if (!room) { + throw new Error('error-invalid-room'); + } + + if (room.archived) { + throw new Error('room_is_archived'); + } + if (args.type !== 'app' && !(await canAccessRoomAsync(room, 'uid' in args ? { _id: args.uid } : args, extraData))) { + throw new Error('error-not-allowed'); + } + + if ( + await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, 'uid' in args ? args.uid : args._id) + ) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, 'uid' in args ? args.uid : args._id, subscriptionOptions); + if (subscription && (subscription.blocked || subscription.blocker)) { + throw new Error('room_is_blocked'); + } + } + + if (room.ro === true && !(await hasPermissionAsync('uid' in args ? args.uid : args._id, 'post-readonly', room._id))) { + // Unless the user was manually unmuted + if (args.username && !(room.unmuted || []).includes(args.username)) { + throw new Error("You can't send messages because the room is readonly."); + } + } + + if (args.username && room?.muted?.includes(args.username)) { + throw new Error('You_have_been_muted'); + } +} +// TODO: remove option uid and username and type +export async function canSendMessageAsync( + rid: IRoom['_id'], + user: { uid: IUser['_id']; username: IUser['username']; type: IUser['type'] } | IUser, + extraData?: Record, +): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('error-invalid-room'); + } + + await validateRoomMessagePermissionsAsync(room, user, extraData); + return room; +} diff --git a/apps/meteor/server/lib/authorization/constant/permissions.ts b/apps/meteor/server/lib/authorization/constant/permissions.ts new file mode 100644 index 0000000..c54f84c --- /dev/null +++ b/apps/meteor/server/lib/authorization/constant/permissions.ts @@ -0,0 +1,242 @@ +// Note: +// 1.if we need to create a role that can only edit channel message, but not edit group message +// then we can define edit--message instead of edit-message +// 2. admin, moderator, and user roles should not be deleted as they are referenced in the code. +export const permissions = [ + { _id: 'access-permissions', roles: ['admin'] }, + { _id: 'access-marketplace', roles: ['admin', 'user'] }, + { _id: 'access-setting-permissions', roles: ['admin'] }, + { _id: 'add-oauth-service', roles: ['admin'] }, + { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'add-user-to-any-c-room', roles: ['admin'] }, + { _id: 'add-user-to-any-p-room', roles: [] }, + { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, + { _id: 'kick-user-from-any-p-room', roles: [] }, + { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, + { _id: 'archive-room', roles: ['admin', 'owner'] }, + { _id: 'assign-admin-role', roles: ['admin'] }, + { _id: 'assign-roles', roles: ['admin'] }, + { _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'bulk-register-user', roles: ['admin'] }, + { _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] }, + { _id: 'create-c', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-d', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-p', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-personal-access-tokens', roles: ['admin', 'user'] }, + { _id: 'create-user', roles: ['admin'] }, + { _id: 'clean-channel-history', roles: ['admin'] }, + { _id: 'delete-c', roles: ['admin', 'owner'] }, + { _id: 'delete-d', roles: ['admin'] }, + { _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-own-message', roles: ['admin', 'user', 'federated-external'] }, + { _id: 'delete-p', roles: ['admin', 'owner'] }, + { _id: 'delete-user', roles: ['admin'] }, + { _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-other-user-active-status', roles: ['admin'] }, + { _id: 'edit-other-user-info', roles: ['admin'] }, + { _id: 'edit-other-user-password', roles: ['admin'] }, + { _id: 'edit-other-user-avatar', roles: ['admin'] }, + { _id: 'edit-other-user-e2ee', roles: ['admin'] }, + { _id: 'edit-other-user-totp', roles: ['admin'] }, + { _id: 'edit-privileged-setting', roles: ['admin'] }, + { _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-room-retention-policy', roles: ['admin'] }, + { _id: 'force-delete-message', roles: ['admin', 'owner'] }, + { _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] }, + { _id: 'leave-c', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-p', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, + { _id: 'logout-other-user', roles: ['admin'] }, + { _id: 'manage-assets', roles: ['admin'] }, + { _id: 'manage-email-inbox', roles: ['admin'] }, + { _id: 'manage-emoji', roles: ['admin'] }, + { _id: 'manage-user-status', roles: ['admin'] }, + { _id: 'manage-outgoing-integrations', roles: ['admin'] }, + { _id: 'manage-incoming-integrations', roles: ['admin'] }, + { _id: 'manage-own-outgoing-integrations', roles: ['admin'] }, + { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, + { _id: 'manage-oauth-apps', roles: ['admin'] }, + { _id: 'manage-selected-settings', roles: ['admin'] }, + { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, + { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, + { _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'run-import', roles: ['admin'] }, + { _id: 'run-migration', roles: ['admin'] }, + { _id: 'set-moderator', roles: ['admin', 'owner'] }, + { _id: 'set-owner', roles: ['admin', 'owner'] }, + { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, + { _id: 'set-leader', roles: ['admin', 'owner'] }, + { _id: 'start-discussion', roles: ['admin', 'user', 'federated-external', 'guest', 'app'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'federated-external', 'owner', 'app'] }, + { _id: 'unarchive-room', roles: ['admin'] }, + { _id: 'view-c-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'anonymous'] }, + { _id: 'user-generate-access-token', roles: ['admin'] }, + { _id: 'view-d-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'guest'] }, + { _id: 'view-device-management', roles: ['admin'] }, + { _id: 'view-engagement-dashboard', roles: ['admin'] }, + { _id: 'view-full-other-user-info', roles: ['admin'] }, + { _id: 'view-joined-room', roles: ['guest', 'bot', 'app', 'anonymous'] }, + { _id: 'view-join-code', roles: ['admin'] }, + { _id: 'view-logs', roles: ['admin'] }, + { _id: 'view-other-user-channels', roles: ['admin'] }, + { _id: 'view-p-room', roles: ['admin', 'user', 'federated-external', 'anonymous', 'guest'] }, + { _id: 'view-privileged-setting', roles: ['admin'] }, + { _id: 'view-room-administration', roles: ['admin'] }, + { _id: 'view-statistics', roles: ['admin'] }, + { _id: 'view-user-administration', roles: ['admin'] }, + { _id: 'preview-c-room', roles: ['admin', 'user', 'federated-external', 'anonymous'] }, + { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, + { _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, + { + _id: 'view-l-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'create-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'update-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'view-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'delete-livechat-contact', + roles: ['livechat-manager', 'admin'], + }, + { + _id: 'view-livechat-contact-history', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'view-omnichannel-contact-center', + roles: ['livechat-manager', 'livechat-agent', 'livechat-monitor', 'admin'], + }, + { _id: 'edit-omnichannel-contact', roles: ['livechat-manager', 'livechat-agent', 'admin'] }, + { _id: 'view-livechat-rooms', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'close-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'close-others-livechat-room', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'on-hold-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'on-hold-others-livechat-room', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'save-others-livechat-room-info', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'remove-closed-livechat-rooms', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-analytics', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'view-livechat-queue', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'transfer-livechat-guest', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { _id: 'manage-livechat-managers', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-livechat-agents', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'manage-livechat-departments', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-departments', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, + { + _id: 'add-livechat-department-agents', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-real-time-monitoring', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { _id: 'view-livechat-triggers', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-customfields', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-installation', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-appearance', roles: ['livechat-manager', 'admin'] }, + { _id: 'view-livechat-webhooks', roles: ['livechat-manager', 'admin'] }, + { + _id: 'view-livechat-business-hours', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-closed-same-department', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-closed-by-another-agent', + roles: ['livechat-manager', 'livechat-monitor', 'admin'], + }, + { + _id: 'view-livechat-room-customfields', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { + _id: 'edit-livechat-room-customfields', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, + { _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] }, + { _id: 'mail-messages', roles: ['admin'] }, + { _id: 'toggle-room-e2e-encryption', roles: ['owner', 'admin'] }, + { _id: 'message-impersonate', roles: ['bot', 'app'] }, + { _id: 'create-team', roles: ['admin', 'user'] }, + { _id: 'delete-team', roles: ['admin', 'owner'] }, + { _id: 'convert-team', roles: ['admin', 'owner'] }, + { _id: 'edit-team', roles: ['admin', 'owner'] }, + { _id: 'add-team-member', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-team-member', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'edit-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'view-all-team-channels', roles: ['admin', 'owner'] }, + { _id: 'view-all-teams', roles: ['admin'] }, + { _id: 'remove-closed-livechat-room', roles: ['livechat-manager', 'admin'] }, + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, + + // New Media calls permissions + { _id: 'allow-internal-voice-calls', roles: ['admin', 'user'] }, + { _id: 'allow-external-voice-calls', roles: ['admin', 'user'] }, + + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-apps', roles: ['admin'] }, + { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'set-readonly', roles: ['admin', 'owner'] }, + { _id: 'set-react-when-readonly', roles: ['admin', 'owner'] }, + { _id: 'manage-cloud', roles: ['admin'] }, + { _id: 'manage-sounds', roles: ['admin'] }, + { _id: 'access-mailer', roles: ['admin'] }, + { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, + { _id: 'mobile-upload-file', roles: ['user', 'federated-external', 'admin'] }, + { _id: 'send-mail', roles: ['admin'] }, + { _id: 'view-federation-data', roles: ['admin'] }, + { _id: 'access-federation', roles: ['admin', 'user', 'federated-external'] }, + { _id: 'add-all-to-room', roles: ['admin'] }, + { _id: 'get-server-info', roles: ['admin'] }, + { _id: 'register-on-cloud', roles: ['admin'] }, + { _id: 'test-admin-options', roles: ['admin'] }, + { _id: 'test-push-notifications', roles: ['admin', 'user'] }, + { _id: 'sync-auth-services-users', roles: ['admin'] }, + { _id: 'restart-server', roles: ['admin'] }, + { _id: 'remove-slackbridge-links', roles: ['admin'] }, + { _id: 'view-import-operations', roles: ['admin'] }, + { _id: 'clear-oembed-cache', roles: ['admin'] }, + { _id: 'videoconf-ring-users', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'view-moderation-console', roles: ['admin'] }, + { _id: 'manage-moderation-actions', roles: ['admin'] }, + { _id: 'bypass-time-limit-edit-and-delete', roles: ['bot', 'app'] }, + { _id: 'export-messages-as-pdf', roles: ['admin', 'user'] }, +]; diff --git a/apps/meteor/server/lib/authorization/getRoles.ts b/apps/meteor/server/lib/authorization/getRoles.ts new file mode 100644 index 0000000..bee995f --- /dev/null +++ b/apps/meteor/server/lib/authorization/getRoles.ts @@ -0,0 +1,7 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +export const getRoles = async (): Promise => Roles.find().toArray(); + +export const getRoleIds = async (): Promise => + (await Roles.find({}, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); diff --git a/apps/meteor/server/lib/authorization/getUsersInRole.ts b/apps/meteor/server/lib/authorization/getUsersInRole.ts new file mode 100644 index 0000000..3b3af20 --- /dev/null +++ b/apps/meteor/server/lib/authorization/getUsersInRole.ts @@ -0,0 +1,51 @@ +import type { IRole, IUser } from '@rocket.chat/core-typings'; +import type { FindPaginated } from '@rocket.chat/model-typings'; +import { Roles, Subscriptions, Users } from '@rocket.chat/models'; +import { compact } from 'lodash'; +import type { Document, FindCursor, FindOptions } from 'mongodb'; + +export function getUsersInRole(roleId: IRole['_id'], scope?: string): Promise>; + +export function getUsersInRole(roleId: IRole['_id'], scope: string | undefined, options: FindOptions): Promise>; + +export function getUsersInRole

( + roleId: IRole['_id'], + scope: string | undefined, + options: FindOptions

, +): Promise>; + +export function getUsersInRole

( + roleId: IRole['_id'], + scope: string | undefined, + options?: any | undefined, +): Promise> { + // TODO move the code from Roles.findUsersInRole to here and change all places to use this function + return Roles.findUsersInRole(roleId, scope, options); +} + +export async function getUsersInRolePaginated( + roleId: IRole['_id'], + scope: string | undefined, + options?: any | undefined, +): Promise>> { + if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) { + throw new Error('Roles.findUsersInRole method received a role scope instead of a scope value.'); + } + + const role = await Roles.findOneById>(roleId, { projection: { scope: 1 } }); + if (!role) { + throw new Error('role not found'); + } + + switch (role.scope) { + case 'Subscriptions': + const subscriptions = await Subscriptions.findByRolesAndRoomId({ roles: role._id, rid: scope }, { projection: { 'u._id': 1 } }) + .map((subscription) => subscription.u?._id) + .toArray(); + + return Users.findPaginated({ _id: { $in: compact(subscriptions) } }, options || {}); + case 'Users': + default: + return Users.findPaginatedUsersInRoles([role._id], options); + } +} diff --git a/apps/meteor/server/lib/authorization/hasPermission.ts b/apps/meteor/server/lib/authorization/hasPermission.ts new file mode 100644 index 0000000..2cf2fa5 --- /dev/null +++ b/apps/meteor/server/lib/authorization/hasPermission.ts @@ -0,0 +1,24 @@ +import { Authorization } from '@rocket.chat/core-services'; +import type { UserWithRoles } from '@rocket.chat/core-services'; +import type { IUser, IPermission, IRoom } from '@rocket.chat/core-typings'; + +// Forward only the fields the permission check needs, so a full user document +// (with services, e2e keys, etc.) isn't serialized to the authorization service. +const toSubject = (user: IUser['_id'] | UserWithRoles): IUser['_id'] | UserWithRoles => + typeof user === 'string' ? user : { _id: user._id, roles: user.roles }; + +export const hasAllPermissionAsync = async ( + user: IUser['_id'] | UserWithRoles, + permissions: IPermission['_id'][], + scope?: IRoom['_id'], +): Promise => Authorization.hasAllPermission(toSubject(user), permissions, scope); +export const hasPermissionAsync = async ( + user: IUser['_id'] | UserWithRoles, + permissionId: IPermission['_id'], + scope?: IRoom['_id'], +): Promise => Authorization.hasPermission(toSubject(user), permissionId, scope); +export const hasAtLeastOnePermissionAsync = async ( + user: IUser['_id'] | UserWithRoles, + permissions: IPermission['_id'][], + scope?: IRoom['_id'], +): Promise => Authorization.hasAtLeastOnePermission(toSubject(user), permissions, scope); diff --git a/apps/meteor/server/lib/authorization/hasRole.ts b/apps/meteor/server/lib/authorization/hasRole.ts new file mode 100644 index 0000000..00f7817 --- /dev/null +++ b/apps/meteor/server/lib/authorization/hasRole.ts @@ -0,0 +1,31 @@ +import type { IRole, IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +/** + * @deprecated use `Authorization.hasAnyRole` instead + */ +export const hasAnyRoleAsync = async ( + userId: IUser['_id'], + roleIds: IRole['_id'][], + scope?: IRoom['_id'] | undefined, +): Promise => { + if (!Array.isArray(roleIds)) { + throw new Error('error-invalid-arguments'); + } + + if (!userId || userId === '') { + return false; + } + + return Roles.isUserInRoles(userId, roleIds, scope); +}; + +export const hasRoleAsync = async (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id'] | undefined): Promise => { + if (Array.isArray(roleId)) { + throw new Error('error-invalid-arguments'); + } + + return hasAnyRoleAsync(userId, [roleId], scope); +}; + +export const subscriptionHasRole = (sub: ISubscription, role: IRole['_id']): boolean | undefined => sub.roles?.includes(role); diff --git a/apps/meteor/server/lib/authorization/index.ts b/apps/meteor/server/lib/authorization/index.ts new file mode 100644 index 0000000..85684bb --- /dev/null +++ b/apps/meteor/server/lib/authorization/index.ts @@ -0,0 +1,7 @@ +import { roomAccessAttributes, canAccessRoomAsync } from './canAccessRoom'; +import { getRoles } from './getRoles'; +import { getUsersInRole } from './getUsersInRole'; +import { subscriptionHasRole } from './hasRole'; +import './streamer/permissions'; + +export { getRoles, getUsersInRole, subscriptionHasRole, canAccessRoomAsync, roomAccessAttributes }; diff --git a/apps/meteor/server/lib/authorization/isABACManagedRoom.ts b/apps/meteor/server/lib/authorization/isABACManagedRoom.ts new file mode 100644 index 0000000..3c0365a --- /dev/null +++ b/apps/meteor/server/lib/authorization/isABACManagedRoom.ts @@ -0,0 +1,14 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { settings } from '../../settings'; + +export const isABACManagedRoom = (room: Pick): boolean => + room.t === 'p' && settings.get('ABAC_Enabled') && Array.isArray(room.abacAttributes) && room.abacAttributes.length > 0; + +export const stripABACManagedFieldsForAdmin = >(room: T): T => { + if (!isABACManagedRoom(room)) { + return room; + } + const { announcement, topic, description, ...rest } = room as T & Pick; + return rest as T; +}; diff --git a/apps/meteor/server/lib/authorization/permissionRole.ts b/apps/meteor/server/lib/authorization/permissionRole.ts new file mode 100644 index 0000000..de04600 --- /dev/null +++ b/apps/meteor/server/lib/authorization/permissionRole.ts @@ -0,0 +1,83 @@ +import { License } from '@rocket.chat/core-services'; +import { Permissions, Roles } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from './hasPermission'; +import { CONSTANTS, AuthorizationUtils } from '../../../app/authorization/lib'; +import { notifyOnPermissionChangedById } from '../notifyListener'; + +export const addPermissionToRoleMethod = async (uid: string, permissionId: string, role: string): Promise => { + if (role === 'guest' && !AuthorizationUtils.hasRestrictionsToRole(role) && (await License.hasValidLicense())) { + AuthorizationUtils.addRolePermissionWhiteList(role, await License.getGuestPermissions()); + } + + if (AuthorizationUtils.isPermissionRestrictedForRole(permissionId, role)) { + throw new Meteor.Error('error-action-not-allowed', 'Permission is restricted', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + const permission = await Permissions.findOneById(permissionId); + + if (!permission) { + throw new Meteor.Error('error-invalid-permission', 'Permission does not exist', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + if (!(await Roles.findOneById(role, { projection: { _id: 1 } }))) { + throw new Meteor.Error('error-invalid-role', 'Role does not exist', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + if ( + !(await hasPermissionAsync(uid, 'access-permissions')) || + (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + if (permission.groupPermissionId) { + await Permissions.addRole(permission.groupPermissionId, role); + void notifyOnPermissionChangedById(permission.groupPermissionId); + } + + await Permissions.addRole(permission._id, role); + + void notifyOnPermissionChangedById(permission._id); +}; + +export const removeRoleFromPermissionMethod = async (uid: string, permissionId: string, role: string): Promise => { + const permission = await Permissions.findOneById(permissionId); + + if (!permission) { + throw new Meteor.Error('error-permission-not-found', 'Permission not found', { + method: 'authorization:removeRoleFromPermission', + }); + } + + if ( + !(await hasPermissionAsync(uid, 'access-permissions')) || + (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Removing permission is not allowed', { + method: 'authorization:removeRoleFromPermission', + action: 'Removing_permission', + }); + } + + if (permission.groupPermissionId) { + await Permissions.removeRole(permission.groupPermissionId, role); + void notifyOnPermissionChangedById(permission.groupPermissionId); + } + + await Permissions.removeRole(permission._id, role); + void notifyOnPermissionChangedById(permission._id); +}; diff --git a/apps/meteor/server/lib/authorization/streamer/permissions/index.ts b/apps/meteor/server/lib/authorization/streamer/permissions/index.ts new file mode 100644 index 0000000..545b706 --- /dev/null +++ b/apps/meteor/server/lib/authorization/streamer/permissions/index.ts @@ -0,0 +1,40 @@ +import type { IPermission, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Permissions } from '@rocket.chat/models'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { WithId } from 'mongodb'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'permissions/get'( + updatedSince?: Date, + ): Promise>[] }>; + } +} + +export const permissionsGetMethod = async ( + updatedAt?: Date, +): Promise>[] }> => { + const records = await Permissions.find(updatedAt && { _updatedAt: { $gt: updatedAt } }).toArray(); + + if (updatedAt instanceof Date) { + return { + update: records, + remove: await Permissions.trashFindDeletedAfter(updatedAt, {}, { projection: { _id: 1, _deletedAt: 1 } }).toArray(), + }; + } + + return records; +}; + +Meteor.methods({ + async 'permissions/get'(updatedAt?: Date) { + check(updatedAt, Match.Maybe(Date)); + // TODO: should we return this for non logged users? + // TODO: we could cache this collection + + return permissionsGetMethod(updatedAt); + }, +}); diff --git a/apps/meteor/server/lib/authorization/upsertPermissions.ts b/apps/meteor/server/lib/authorization/upsertPermissions.ts new file mode 100644 index 0000000..1c4ca5f --- /dev/null +++ b/apps/meteor/server/lib/authorization/upsertPermissions.ts @@ -0,0 +1,123 @@ +/* eslint no-multi-spaces: 0 */ +import type { IPermission, ISetting } from '@rocket.chat/core-typings'; +import { Permissions, Settings } from '@rocket.chat/models'; + +import { permissions } from './constant/permissions'; +import { getSettingPermissionId, CONSTANTS } from '../../../app/authorization/lib'; +import { settings } from '../../settings'; +import { createOrUpdateProtectedRoleAsync } from '../roles/createOrUpdateProtectedRole'; + +export const upsertPermissions = async (): Promise => { + for (const permission of permissions) { + await Permissions.create(permission._id, permission.roles); + } + + const defaultRoles = [ + { name: 'admin', scope: 'Users', description: 'Admin' }, + { name: 'moderator', scope: 'Subscriptions', description: 'Moderator' }, + { name: 'leader', scope: 'Subscriptions', description: 'Leader' }, + { name: 'owner', scope: 'Subscriptions', description: 'Owner' }, + { name: 'user', scope: 'Users', description: '' }, + { name: 'federated-external', scope: 'Users', description: '' }, + { name: 'bot', scope: 'Users', description: '' }, + { name: 'app', scope: 'Users', description: '' }, + { name: 'guest', scope: 'Users', description: '' }, + { name: 'anonymous', scope: 'Users', description: '' }, + { name: 'livechat-agent', scope: 'Users', description: 'Livechat Agent' }, + { name: 'livechat-manager', scope: 'Users', description: 'Livechat Manager' }, + ] as const; + + for (const role of defaultRoles) { + await createOrUpdateProtectedRoleAsync(role.name, role); + } + + const getPreviousPermissions = async function (settingId?: string): Promise> { + const previousSettingPermissions: { + [key: string]: IPermission; + } = {}; + + await Permissions.findByLevel('settings', settingId).forEach((permission: IPermission) => { + previousSettingPermissions[permission._id] = permission; + }); + return previousSettingPermissions; + }; + + const createSettingPermission = async function ( + setting: ISetting, + previousSettingPermissions: { + [key: string]: IPermission; + }, + ): Promise { + const permissionId = getSettingPermissionId(setting._id); + const permission: Omit = { + level: CONSTANTS.SETTINGS_LEVEL as 'settings' | undefined, + // copy those setting-properties which are needed to properly publish the setting-based permissions + settingId: setting._id, + // TODO: migrate settings with group and section with null to undefined + ...(setting.group && { group: setting.group }), + ...(setting.section && { section: setting.section }), + sorter: setting.sorter, + roles: [], + }; + // copy previously assigned roles if available + if (previousSettingPermissions[permissionId]?.roles) { + permission.roles = previousSettingPermissions[permissionId].roles; + } + if (setting.group) { + permission.groupPermissionId = getSettingPermissionId(setting.group); + } + if (setting.section) { + permission.sectionPermissionId = getSettingPermissionId(setting.section); + } + + const existent = await Permissions.findOne( + { + _id: permissionId, + ...permission, + }, + { projection: { _id: 1 } }, + ); + + if (!existent) { + try { + await Permissions.updateOne({ _id: permissionId }, { $set: permission }, { upsert: true }); + } catch (e) { + if (!(e as Error).message.includes('E11000')) { + // E11000 refers to a MongoDB error that can occur when using unique indexes for upserts + // https://docs.mongodb.com/manual/reference/method/db.collection.update/#use-unique-indexes + await Permissions.updateOne({ _id: permissionId }, { $set: permission }, { upsert: true }); + } + } + } + + delete previousSettingPermissions[permissionId]; + }; + + const createPermissionsForExistingSettings = async function (): Promise { + const previousSettingPermissions = await getPreviousPermissions(); + + const settings = await Settings.findNotHidden().toArray(); + for await (const setting of settings) { + await createSettingPermission(setting, previousSettingPermissions); + } + + // remove permissions for non-existent settings + for (const obsoletePermission of Object.keys(previousSettingPermissions)) { + if (previousSettingPermissions.hasOwnProperty(obsoletePermission)) { + await Permissions.deleteOne({ _id: obsoletePermission }); + } + } + }; + + // for each setting which already exists, create a permission to allow changing just this one setting + await createPermissionsForExistingSettings(); + + // register a callback for settings for be create in higher-level-packages + settings.on('*', async ([settingId]) => { + const previousSettingPermissions = await getPreviousPermissions(settingId); + const setting = await Settings.findOneById(settingId); + if (setting && !setting.hidden) { + await createSettingPermission(setting, previousSettingPermissions); + } + }); +}; diff --git a/apps/meteor/server/lib/autotranslate/README.md b/apps/meteor/server/lib/autotranslate/README.md new file mode 100644 index 0000000..41921fb --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/README.md @@ -0,0 +1,5 @@ +# Rocket.Chat Auto Translate + +Rocket.Chat supports auto translate through Google Cloud Translation API +Through this you will be able to read messages in your native languages +regardless of language the message was written on. diff --git a/apps/meteor/server/lib/autotranslate/autotranslate.ts b/apps/meteor/server/lib/autotranslate/autotranslate.ts new file mode 100644 index 0000000..ffb3992 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/autotranslate.ts @@ -0,0 +1,387 @@ +import type { + IMessage, + IRoom, + MessageAttachment, + IProviderMetadata, + ISupportedLanguage, + ITranslationResult, +} from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Messages, Subscriptions } from '@rocket.chat/models'; +import { escapeHTML, isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { Markdown } from '../messaging/markdown'; +import { notifyOnMessageChange } from '../notifyListener'; + +const translationLogger = new Logger('AutoTranslate'); + +// Constant tokenizer patterns, compiled once instead of on every message. +// Safe to share with /g flags because String.prototype.replace resets +// lastIndex on each call. +const urlSchemes = 'http,https'; +const markdownLinkRegex = new RegExp(`(!?\\[)([^\\]]+)(\\]\\((?:${urlSchemes}):\\/\\/[^\\)]+\\))`, 'gm'); +const pipedLinkRegex = new RegExp(`((?:<|<)(?:${urlSchemes}):\\/\\/[^\\|]+\\|)(.+?)(?=>|>)((?:>|>))`, 'gm'); +const wrappedParagraphRegex = new RegExp('^\\s*

|

\\s*$', 'gm'); + +const Providers = Symbol('Providers'); +const Provider = Symbol('Provider'); + +/** + * This class allows translation providers to + * register,load and also returns the active provider. + */ +export class TranslationProviderRegistry { + static [Providers]: { [k: string]: AutoTranslate } = {}; + + static enabled = false; + + static [Provider]: string | null = null; + + /** + * Registers the translation provider into the registry. + * @param {*} provider + */ + static registerProvider(provider: AutoTranslate): void { + // get provider information + const metadata = provider._getProviderMetadata(); + if (!metadata) { + translationLogger.error('Provider metadata is not defined'); + return; + } + + TranslationProviderRegistry[Providers][metadata.name] = provider; + } + + /** + * Return the active Translation provider + */ + static getActiveProvider(): AutoTranslate | null { + if (!TranslationProviderRegistry.enabled) { + return null; + } + const provider = TranslationProviderRegistry[Provider]; + if (!provider) { + return null; + } + + return TranslationProviderRegistry[Providers][provider]; + } + + static async getSupportedLanguages(target: string): Promise { + return TranslationProviderRegistry.enabled ? TranslationProviderRegistry.getActiveProvider()?.getSupportedLanguages(target) : undefined; + } + + static async translateMessage(message: IMessage, room: IRoom, targetLanguage?: string): Promise { + if (!TranslationProviderRegistry.enabled) { + return null; + } + + const provider = TranslationProviderRegistry.getActiveProvider(); + if (!provider) { + return null; + } + + return provider.translateMessage(message, { room, targetLanguage }); + } + + static getProviders(): AutoTranslate[] { + return Object.values(TranslationProviderRegistry[Providers]); + } + + static setCurrentProvider(provider: string): void { + TranslationProviderRegistry[Provider] = provider; + } + + static setEnable(enabled: boolean): void { + TranslationProviderRegistry.enabled = enabled; + + TranslationProviderRegistry.registerCallbacks(); + } + + static registerCallbacks(): void { + if (!TranslationProviderRegistry.enabled) { + callbacks.remove('afterSaveMessage', 'autotranslate'); + return; + } + + callbacks.add( + 'afterSaveMessage', + (message, { room }) => TranslationProviderRegistry.translateMessage(message, room), + callbacks.priority.MEDIUM, + 'autotranslate', + ); + } +} + +/** + * Generic auto translate base implementation. + * This class provides generic parts of implementation for + * tokenization, detokenization, call back register and unregister. + * @abstract + * @class + */ +export abstract class AutoTranslate { + name: string; + + languages: string[]; + + supportedLanguages: { + [language: string]: ISupportedLanguage[]; + }; + + /** + * Encapsulate the api key and provider settings. + * @constructor + */ + constructor() { + this.name = ''; + this.languages = []; + this.supportedLanguages = {}; + } + + /** + * Extracts non-translatable parts of a message + * @param {object} message + * @return {object} message + */ + tokenize(message: IMessage): IMessage { + if (!message.tokens || !Array.isArray(message.tokens)) { + message.tokens = []; + } + message = this.tokenizeEmojis(message); + message = this.tokenizeCode(message); + message = this.tokenizeURLs(message); + message = this.tokenizeMentions(message); + return message; + } + + tokenizeEmojis(message: IMessage): IMessage { + let count = message.tokens?.length || 0; + message.msg = message.msg.replace(/:[+\w\d]+:/g, (match) => { + const token = `{${count++}}`; + message.tokens?.push({ + token, + text: match, + }); + return token; + }); + + return message; + } + + tokenizeURLs(message: IMessage): IMessage { + let count = message.tokens?.length || 0; + + // Support ![alt text](http://image url) and [text](http://link) + message.msg = message.msg.replace(markdownLinkRegex, (_match, pre, text, post) => { + const pretoken = `{${count++}}`; + message.tokens?.push({ + token: pretoken, + text: pre, + }); + + const posttoken = `{${count++}}`; + message.tokens?.push({ + token: posttoken, + text: post, + }); + + return pretoken + text + posttoken; + }); + + // Support + message.msg = message.msg.replace(pipedLinkRegex, (_match, pre, text, post) => { + const pretoken = `{${count++}}`; + message.tokens?.push({ + token: pretoken, + text: pre, + }); + + const posttoken = `{${count++}}`; + message.tokens?.push({ + token: posttoken, + text: post, + }); + + return pretoken + text + posttoken; + }); + + return message; + } + + tokenizeCode(message: IMessage): IMessage { + let count = message.tokens?.length || 0; + message.html = message.msg; + message = Markdown.parseMessageNotEscaped(message); + + // Some parsers (e. g. Marked) wrap the complete message in a

- this is unnecessary and should be ignored with respect to translations + message.msg = message.msg.replace(wrappedParagraphRegex, ''); + + for (const [tokenIndex, value] of message.tokens?.entries() ?? []) { + const { token } = value; + if (token.indexOf('notranslate') === -1) { + const newToken = `{${count++}}`; + message.msg = message.msg.replace(token, newToken); + message.tokens ? (message.tokens[tokenIndex].token = newToken) : undefined; + } + } + + return message; + } + + tokenizeMentions(message: IMessage): IMessage { + let count = message.tokens?.length || 0; + + if (message.mentions && message.mentions.length > 0) { + message.mentions.forEach((mention) => { + message.msg = message.msg.replace(new RegExp(`(@${mention.username})`, 'gm'), (match) => { + const token = `{${count++}}`; + message.tokens?.push({ + token, + text: match, + }); + return token; + }); + }); + } + + if (message.channels && message.channels.length > 0) { + message.channels.forEach((channel) => { + message.msg = message.msg.replace(new RegExp(`(#${channel.name})`, 'gm'), (match) => { + const token = `{${count++}}`; + message.tokens?.push({ + token, + text: match, + }); + return token; + }); + }); + } + + return message; + } + + deTokenize(message: IMessage): string { + if (message.tokens && message.tokens?.length > 0) { + for (const { token, text, noHtml } of message.tokens) { + message.msg = message.msg.replace(token, () => noHtml || text); + } + } + return message.msg; + } + + /** + * Triggers the translation of the prepared (tokenized) message + * and persists the result + * @public + * @param {object} message + * @param {object} room + * @param {object} targetLanguage + * @returns {object} unmodified message object. + */ + async translateMessage(message: IMessage, { room, targetLanguage }: { room: IRoom; targetLanguage?: string }): Promise { + let targetLanguages: string[]; + if (targetLanguage) { + targetLanguages = [targetLanguage]; + } else { + targetLanguages = (await Subscriptions.getAutoTranslateLanguagesByRoomAndNotUser(room._id, message.u?._id)).filter(isTruthy); + } + if (message.msg) { + setImmediate(async () => { + let targetMessage = Object.assign({}, message); + targetMessage.html = escapeHTML(String(targetMessage.msg)); + targetMessage = this.tokenize(targetMessage); + + const translations = await this._translateMessage(targetMessage, targetLanguages); + if (!_.isEmpty(translations)) { + await Messages.addTranslations(message._id, translations, TranslationProviderRegistry[Provider] || ''); + this.notifyTranslatedMessage(message._id); + } + }); + } + + if (message.attachments && message.attachments.length > 0) { + setImmediate(async () => { + for (const [index, attachment] of message.attachments?.entries() ?? []) { + if (attachment.description || attachment.text) { + // Removes the initial link `[ ](quoterl)` from quote message before translation + const translatedText = attachment?.text?.replace(/\[(.*?)\]\(.*?\)/g, '$1') || attachment?.text; + const attachmentMessage = { ...attachment, text: translatedText }; + const translations = await this._translateAttachmentDescriptions(attachmentMessage, targetLanguages); + + if (!_.isEmpty(translations)) { + await Messages.addAttachmentTranslations(message._id, String(index), translations); + this.notifyTranslatedMessage(message._id); + } + } + } + }); + } + return Messages.findOneById(message._id); + } + + private notifyTranslatedMessage(messageId: string): void { + void notifyOnMessageChange({ + id: messageId, + }); + } + + /** + * Returns metadata information about the service provider which is used by + * the generic implementation + * @abstract + * @protected + * @returns { name, displayName, settings } + }; + */ + abstract _getProviderMetadata(): IProviderMetadata; + + /** + * Provides the possible languages _from_ which a message can be translated into a target language + * @abstract + * @protected + * @param {string} target - the language into which shall be translated + * @returns [{ language, name }] + */ + abstract getSupportedLanguages(target: string): Promise; + + /** + * Performs the actual translation of a message, + * usually by sending a REST API call to the service provider. + * @abstract + * @protected + * @param {object} message + * @param {object} targetLanguages + * @return {object} + */ + abstract _translateMessage(message: IMessage, targetLanguages: string[]): Promise; + + /** + * Performs the actual translation of an attachment (precisely its description), + * usually by sending a REST API call to the service provider. + * @abstract + * @param {object} attachment + * @param {object} targetLanguages + * @returns {object} translated messages for each target language + */ + abstract _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise; +} + +Meteor.startup(() => { + /** Register the active service provider on the 'AfterSaveMessage' callback. + * So the registered provider will be invoked when a message is saved. + * All the other inactive service provider must be deactivated. + */ + settings.watch('AutoTranslate_ServiceProvider', (providerName) => { + TranslationProviderRegistry.setCurrentProvider(providerName); + }); + + // Get Auto Translate Active flag + settings.watch('AutoTranslate_Enabled', (value) => { + TranslationProviderRegistry.setEnable(value); + }); +}); diff --git a/apps/meteor/server/lib/autotranslate/deeplTranslate.ts b/apps/meteor/server/lib/autotranslate/deeplTranslate.ts new file mode 100644 index 0000000..792bbf0 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/deeplTranslate.ts @@ -0,0 +1,220 @@ +/** + * @author Vigneshwaran Odayappan + */ + +import type { IMessage, MessageAttachment, IProviderMetadata, ITranslationResult, ISupportedLanguage } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import _ from 'underscore'; + +import { TranslationProviderRegistry, AutoTranslate } from './autotranslate'; +import { settings } from '../../settings'; +import { i18n } from '../i18n'; +import { SystemLogger } from '../logger/system'; + +const proApiEndpoint = 'https://api.deepl.com/v2/translate'; +const freeApiEndpoint = 'https://api-free.deepl.com/v2/translate'; + +interface IDeepLTranslation { + detected_source_language: string; + text: string; +} + +/** + * DeepL translation service provider class representation. + * Encapsulates the service provider settings and information. + * Provides languages supported by the service provider. + * Resolves API call to service provider to resolve the translation request. + * @class + * @augments AutoTranslate + */ +class DeeplAutoTranslate extends AutoTranslate { + apiKey: string; + + apiEndPointUrl: string; + + private supportedLanguageEndpointUrl: string; + + /** + * setup api reference to deepl translate to be used as message translation provider. + * @constructor + */ + constructor() { + super(); + this.name = 'deepl-translate'; + this.apiEndPointUrl = proApiEndpoint; + + // Get the service provide API key. + settings.watch('AutoTranslate_DeepLAPIKey', (value) => { + this.apiKey = value; + + // if the api key ends with `:fx` it is a free api key + if (/:fx$/.test(value)) { + this.apiEndPointUrl = freeApiEndpoint; + this.supportedLanguageEndpointUrl = `https://api-free.deepl.com/v2/languages`; + return; + } + this.apiEndPointUrl = proApiEndpoint; + this.supportedLanguageEndpointUrl = `https://api.deepl.com/v2/languages`; + }); + } + + /** + * Returns metadata information about the service provide + * @private implements super abstract method. + * @return {object} + */ + _getProviderMetadata(): IProviderMetadata { + return { + name: this.name, + displayName: i18n.t('AutoTranslate_DeepL'), + settings: this._getSettings(), + }; + } + + /** + * Returns necessary settings information about the translation service provider. + * @private implements super abstract method. + * @return {object} + */ + _getSettings(): IProviderMetadata['settings'] { + return { + apiKey: this.apiKey, + apiEndPointUrl: this.apiEndPointUrl, + }; + } + + /** + * Returns supported languages for translation by the active service provider. + * Deepl does not provide an endpoint yet to retrieve the supported languages. + * So each supported languages are explicitly maintained. + * @private implements super abstract method. + * @param {string} target + * @returns {object} code : value pair + */ + async getSupportedLanguages(target: string): Promise { + if (!this.apiKey) { + return []; + } + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + let result: (ISupportedLanguage & { supports_formality?: boolean })[] = []; + + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(this.supportedLanguageEndpointUrl, { + ignoreSsrfValidation: true, + params: { type: 'target' }, + headers: { + Authorization: `DeepL-Auth-Key ${this.apiKey}`, + }, + }); + if (!request.ok) { + throw new Error('Failed to fetch supported languages'); + } + + result = (await request.json()) as typeof result; + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + this.supportedLanguages[target || 'en'] = result.map(({ language, ...other }) => ({ + ...other, + language: new Intl.Locale(language).toString(), + })); + return this.supportedLanguages[target || 'en']; + } + + /** + * Send Request REST API call to the service provider. + * Returns translated message for each target language in target languages. + * @private + * @param {object} message + * @param {object} targetLanguages + * @returns {object} translations: Translated messages for each language + */ + async _translateMessage(message: IMessage, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + const msgs = message.msg.split('\n'); + const supportedLanguages = await this.getSupportedLanguages('en'); + for (let language of targetLanguages) { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, + params: { target_lang: language, text: msgs }, + headers: { + Authorization: `DeepL-Auth-Key ${this.apiKey}`, + }, + method: 'POST', + }); + + if (!result.ok) { + throw new Error(result.statusText); + } + + const body = await result.json(); + + if (result.status === 200 && body.translations && Array.isArray(body.translations) && body.translations.length > 0) { + // store translation only when the source and target language are different. + // multiple lines might contain different languages => Mix the text between source and detected target if neccessary + const translatedText = body.translations + .map((translation: IDeepLTranslation, index: number) => + translation.detected_source_language !== language ? translation.text : msgs[index], + ) + .join('\n'); + translations[language] = this.deTokenize(Object.assign({}, message, { msg: translatedText })); + } + } catch (err) { + SystemLogger.error({ msg: 'Error translating message', err }); + } + } + return translations; + } + + /** + * Returns translated message attachment description in target languages. + * @private + * @param {object} attachment + * @param {object} targetLanguages + * @returns {object} translated messages for each target language + */ + async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + const supportedLanguages = await this.getSupportedLanguages('en'); + for (let language of targetLanguages) { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, + params: { + auth_key: this.apiKey, + target_lang: language, + text: attachment.description || attachment.text || '', + }, + }); + if (!result.ok) { + throw new Error(result.statusText); + } + const body = await result.json(); + if (result.status === 200 && body.translations && Array.isArray(body.translations) && body.translations.length > 0) { + if (body.translations.map((translation: IDeepLTranslation) => translation.detected_source_language).join() !== language) { + translations[language] = body.translations.map((translation: IDeepLTranslation) => translation.text); + } + } + } catch (err) { + SystemLogger.error({ msg: 'Error translating message attachment', err }); + } + } + return translations; + } +} + +// Register DeepL translation provider to the registry. +TranslationProviderRegistry.registerProvider(new DeeplAutoTranslate()); diff --git a/apps/meteor/server/lib/autotranslate/functions/getSupportedLanguages.ts b/apps/meteor/server/lib/autotranslate/functions/getSupportedLanguages.ts new file mode 100644 index 0000000..5a78001 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/functions/getSupportedLanguages.ts @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { TranslationProviderRegistry } from '../index'; + +export const getSupportedLanguages = async (userId: string, targetLanguage: string) => { + if (!settings.get('AutoTranslate_Enabled')) { + throw new Meteor.Error('error-autotranslate-disabled', 'Auto-Translate is disabled'); + } + + if (!(await hasPermissionAsync(userId, 'auto-translate'))) { + throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', { + method: 'autoTranslate.getSupportedLanguages', + }); + } + + return TranslationProviderRegistry.getSupportedLanguages(targetLanguage); +}; diff --git a/apps/meteor/server/lib/autotranslate/functions/saveSettings.ts b/apps/meteor/server/lib/autotranslate/functions/saveSettings.ts new file mode 100644 index 0000000..7346996 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/functions/saveSettings.ts @@ -0,0 +1,78 @@ +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { notifyOnSubscriptionChangedById } from '../../notifyListener'; + +export const saveAutoTranslateSettings = async ( + userId: string, + rid: string, + field: string, + value: string, + options: { defaultLanguage: string }, +) => { + if (!(await hasPermissionAsync(userId, 'auto-translate'))) { + throw new Meteor.Error('error-action-not-allowed', 'Auto-Translate is not allowed', { + method: 'autoTranslate.saveSettings', + }); + } + + check(rid, String); + check(field, String); + check(value, String); + + if (['autoTranslate', 'autoTranslateLanguage'].indexOf(field) === -1) { + throw new Meteor.Error('error-invalid-settings', 'Invalid settings field', { + method: 'saveAutoTranslateSettings', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { + method: 'saveAutoTranslateSettings', + }); + } + + let shouldNotifySubscriptionChanged = false; + + switch (field) { + case 'autoTranslate': + const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } }); + if (room && value === '1') { + throw new Meteor.Error('error-e2e-enabled', 'Enabling auto-translation in E2E encrypted rooms is not allowed', { + method: 'saveAutoTranslateSettings', + }); + } + + const updateAutoTranslateResponse = await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); + if (updateAutoTranslateResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } + + if (!subscription.autoTranslateLanguage && options.defaultLanguage) { + const updateAutoTranslateLanguageResponse = await Subscriptions.updateAutoTranslateLanguageById( + subscription._id, + options.defaultLanguage, + ); + if (updateAutoTranslateLanguageResponse.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } + } + + break; + case 'autoTranslateLanguage': + const updateAutoTranslateLanguage = await Subscriptions.updateAutoTranslateLanguageById(subscription._id, value); + if (updateAutoTranslateLanguage.modifiedCount) { + shouldNotifySubscriptionChanged = true; + } + break; + } + + if (shouldNotifySubscriptionChanged) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + return true; +}; diff --git a/apps/meteor/server/lib/autotranslate/functions/translateMessage.ts b/apps/meteor/server/lib/autotranslate/functions/translateMessage.ts new file mode 100644 index 0000000..c7c8e79 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/functions/translateMessage.ts @@ -0,0 +1,26 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +import { TranslationProviderRegistry } from '../index'; + +export const translateMessage = async (targetLanguage?: string, message?: IMessage) => { + if (!TranslationProviderRegistry.enabled) { + return; + } + if (!message?.rid) { + return; + } + + const room = await Rooms.findOneById(message?.rid); + let translatedMessage; + + if (message && room) { + translatedMessage = await TranslationProviderRegistry.translateMessage(message, room, targetLanguage); + } + + if (!translatedMessage) { + return; + } + + return translatedMessage; +}; diff --git a/apps/meteor/server/lib/autotranslate/googleTranslate.ts b/apps/meteor/server/lib/autotranslate/googleTranslate.ts new file mode 100644 index 0000000..d4d76c4 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/googleTranslate.ts @@ -0,0 +1,223 @@ +/** + * @author Vigneshwaran Odayappan + */ + +import type { IMessage, IProviderMetadata, ISupportedLanguage, ITranslationResult, MessageAttachment } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import _ from 'underscore'; + +import { AutoTranslate, TranslationProviderRegistry } from './autotranslate'; +import { settings } from '../../settings'; +import { i18n } from '../i18n'; +import { SystemLogger } from '../logger/system'; + +interface IGoogleTranslation { + translatedText: string; +} + +/** + * Represents google translate class + * @class + * @augments AutoTranslate + */ + +class GoogleAutoTranslate extends AutoTranslate { + apiKey: string; + + apiEndPointUrl: string; + + /** + * setup api reference to Google translate to be used as message translation provider. + * @constructor + */ + constructor() { + super(); + this.name = 'google-translate'; + this.apiEndPointUrl = 'https://translation.googleapis.com/language/translate/v2'; + // Get the service provide API key. + settings.watch('AutoTranslate_GoogleAPIKey', (value) => { + this.apiKey = value; + }); + } + + /** + * Returns metadata information about the service provider + * @private implements super abstract method. + * @returns {object} + */ + _getProviderMetadata(): IProviderMetadata { + return { + name: this.name, + displayName: i18n.t('AutoTranslate_Google'), + settings: this._getSettings(), + }; + } + + /** + * Returns necessary settings information about the translation service provider. + * @private implements super abstract method. + * @returns {object} + */ + _getSettings(): IProviderMetadata['settings'] { + return { + apiKey: this.apiKey, + apiEndPointUrl: this.apiEndPointUrl, + }; + } + + /** + * Returns supported languages for translation by the active service provider. + * Google Translate api provides the list of supported languages. + * @private implements super abstract method. + * @param {string} target : user language setting or 'en' + * @returns {object} code : value pair + */ + async getSupportedLanguages(target: string): Promise { + if (!this.apiKey) { + return []; + } + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + + let result: { data?: { languages: ISupportedLanguage[] } } = {}; + const params = { + key: this.apiKey, + ...(target && { target }), + }; + + try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { + ignoreSsrfValidation: true, + params, + }); + if (!request.ok && request.status === 400 && request.statusText === 'INVALID_ARGUMENT') { + throw new Error('Failed to fetch supported languages'); + } + + result = (await request.json()) as typeof result; + } catch (e: any) { + // Fallback: Get the English names of the target languages + if (e.message === 'Failed to fetch supported languages') { + params.target = 'en'; + target = 'en'; + if (!this.supportedLanguages[target]) { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(`https://translation.googleapis.com/language/translate/v2/languages`, { + ignoreSsrfValidation: true, + params, + }); + result = (await request.json()) as typeof result; + } + } + } + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + this.supportedLanguages[target || 'en'] = result?.data?.languages || []; + return this.supportedLanguages[target || 'en']; + } + + /** + * Send Request REST API call to the service provider. + * Returns translated message for each target language in target languages. + * @private + * @param {object} message + * @param {object} targetLanguages + * @returns {object} translations: Translated messages for each language + */ + async _translateMessage(message: IMessage, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + + const supportedLanguages = await this.getSupportedLanguages('en'); + + for (let language of targetLanguages) { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.slice(0, 2); + } + + try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, + params: { + key: this.apiKey, + target: language, + format: 'text', + q: message.msg.split('\n'), + }, + }); + if (!result.ok) { + throw new Error(result.statusText); + } + const body = await result.json(); + + if ( + result.status === 200 && + body.data?.translations && + Array.isArray(body.data.translations) && + body.data.translations.length > 0 + ) { + const txt = body.data.translations.map((translation: IGoogleTranslation) => translation.translatedText).join('\n'); + translations[language] = this.deTokenize(Object.assign({}, message, { msg: txt })); + } + } catch (err) { + SystemLogger.error({ msg: 'Error translating message', err }); + } + } + return translations; + } + + /** + * Returns translated message attachment description in target languages. + * @private + * @param {object} attachment + * @param {object} targetLanguages + * @returns {object} translated attachment descriptions for each target language + */ + async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + const supportedLanguages = await this.getSupportedLanguages('en'); + + for (let language of targetLanguages) { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.slice(0, 2); + } + + try { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const result = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, + params: { + key: this.apiKey, + target: language, + format: 'text', + q: attachment.description || attachment.text || '', + }, + }); + if (!result.ok) { + throw new Error(result.statusText); + } + const body = await result.json(); + + if ( + result.status === 200 && + body.data?.translations && + Array.isArray(body.data.translations) && + body.data.translations.length > 0 + ) { + translations[language] = body.data.translations.map((translation: IGoogleTranslation) => translation.translatedText).join('\n'); + } + } catch (err) { + SystemLogger.error({ msg: 'Error translating message', err }); + } + } + return translations; + } +} + +// Register Google translation provider. +TranslationProviderRegistry.registerProvider(new GoogleAutoTranslate()); diff --git a/apps/meteor/server/lib/autotranslate/index.ts b/apps/meteor/server/lib/autotranslate/index.ts new file mode 100644 index 0000000..443fc16 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/index.ts @@ -0,0 +1,13 @@ +/** + * This file contains the exported members of the package shall be re-used. + * @module AutoTranslate, TranslationProviderRegistry + */ + +import { TranslationProviderRegistry } from './autotranslate'; +import './permissions'; +import './googleTranslate'; +import './deeplTranslate'; +import './libreTranslate'; +import './msTranslate'; + +export { TranslationProviderRegistry }; diff --git a/apps/meteor/server/lib/autotranslate/libreTranslate.ts b/apps/meteor/server/lib/autotranslate/libreTranslate.ts new file mode 100644 index 0000000..6ea0aaf --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/libreTranslate.ts @@ -0,0 +1,179 @@ +import type { IMessage, MessageAttachment, IProviderMetadata, ITranslationResult, ISupportedLanguage } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { TranslationProviderRegistry, AutoTranslate } from './autotranslate'; +import { libreLogger } from './logger'; +import { settings } from '../../settings'; +import { i18n } from '../i18n'; + +interface ILibreTranslateLanguage { + code: string; + name: string; + targets?: string[]; +} + +const REQUEST_TIMEOUT = 10000; + +const toLanguageTag = (code: string): string => { + try { + return new Intl.Locale(code).toString(); + } catch (err) { + libreLogger.error({ msg: 'Invalid language code returned by LibreTranslate', code, err }); + return code; + } +}; + +/** + * LibreTranslate translation service provider class representation. + * @class + * @augments AutoTranslate + */ +class LibreTranslateAutoTranslate extends AutoTranslate { + apiKey: string; + + apiEndPointUrl: string; + + constructor() { + super(); + this.name = 'libre-translate'; + this.apiEndPointUrl = ''; + this.apiKey = ''; + + settings.watch('AutoTranslate_LibreTranslateAPIURL', (value) => { + this.apiEndPointUrl = (value || '').replace(/\/$/, ''); + }); + + settings.watch('AutoTranslate_LibreTranslateAPIKey', (value) => { + this.apiKey = value; + }); + } + + _getProviderMetadata(): IProviderMetadata { + return { + name: this.name, + displayName: i18n.t('AutoTranslate_LibreTranslate'), + settings: this._getSettings(), + }; + } + + _getSettings(): IProviderMetadata['settings'] { + return { + apiKey: this.apiKey, + apiEndPointUrl: this.apiEndPointUrl, + }; + } + + async getSupportedLanguages(target: string): Promise { + if (!this.apiEndPointUrl) { + return []; + } + + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + + try { + // SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here. + const request = await fetch(`${this.apiEndPointUrl}/languages`, { + ignoreSsrfValidation: true, + timeout: REQUEST_TIMEOUT, + }); + if (!request.ok) { + throw new Error('Failed to fetch supported languages'); + } + + const result = (await request.json()) as ILibreTranslateLanguage[]; + + this.supportedLanguages[target || 'en'] = result.map(({ code, name }) => ({ + language: toLanguageTag(code), + name, + })); + return this.supportedLanguages[target || 'en']; + } catch (err) { + libreLogger.error({ msg: 'Error fetching supported languages', err }); + return []; + } + } + + private resolveTargetLanguage(language: string, supportedLanguages: ISupportedLanguage[]): string { + if (language.includes('-') && !supportedLanguages.find((supported) => supported.language === language)) { + return language.slice(0, 2); + } + return language; + } + + // `q` is sent as an array of lines; LibreTranslate returns `translatedText` as an array (one entry per line). + private async _query(lines: string[], targetLanguage: string): Promise { + const body: Record = { + q: lines, + source: 'auto', + target: targetLanguage, + format: 'text', + ...(this.apiKey && { api_key: this.apiKey }), + }; + + // SECURITY: the URL comes from a setting only an admin can change, so disabling SSRF validation is safe here. + const result = await fetch(`${this.apiEndPointUrl}/translate`, { + method: 'POST', + ignoreSsrfValidation: true, + timeout: REQUEST_TIMEOUT, + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!result.ok) { + throw new Error(result.statusText); + } + + const json = (await result.json()) as { translatedText?: string | string[] }; + if (json.translatedText === undefined) { + return null; + } + return Array.isArray(json.translatedText) ? json.translatedText.join('\n') : json.translatedText; + } + + async _translateMessage(message: IMessage, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + if (!this.apiEndPointUrl) { + return translations; + } + + const msgs = message.msg.split('\n'); + const supportedLanguages = await this.getSupportedLanguages('en'); + for (const targetLanguage of targetLanguages) { + const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages); + try { + const translatedText = await this._query(msgs, language); + if (translatedText !== null) { + translations[language] = this.deTokenize(Object.assign({}, message, { msg: translatedText })); + } + } catch (err) { + libreLogger.error({ msg: 'Error translating message', err }); + } + } + return translations; + } + + async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise { + const translations: { [k: string]: string } = {}; + if (!this.apiEndPointUrl) { + return translations; + } + + const supportedLanguages = await this.getSupportedLanguages('en'); + for (const targetLanguage of targetLanguages) { + const language = this.resolveTargetLanguage(targetLanguage, supportedLanguages); + try { + const translatedText = await this._query([attachment.description || attachment.text || ''], language); + if (translatedText !== null) { + translations[language] = translatedText; + } + } catch (err) { + libreLogger.error({ msg: 'Error translating message attachment', err }); + } + } + return translations; + } +} + +TranslationProviderRegistry.registerProvider(new LibreTranslateAutoTranslate()); diff --git a/apps/meteor/server/lib/autotranslate/logger.ts b/apps/meteor/server/lib/autotranslate/logger.ts new file mode 100644 index 0000000..895a9d7 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/logger.ts @@ -0,0 +1,7 @@ +import { Logger } from '@rocket.chat/logger'; + +const logger = new Logger('AutoTranslate'); + +export const msLogger = logger.section('Microsoft'); + +export const libreLogger = logger.section('LibreTranslate'); diff --git a/apps/meteor/server/lib/autotranslate/msTranslate.ts b/apps/meteor/server/lib/autotranslate/msTranslate.ts new file mode 100644 index 0000000..3f68089 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/msTranslate.ts @@ -0,0 +1,208 @@ +/** + * @author Vigneshwaran Odayappan + */ + +import type { IMessage, IProviderMetadata, ISupportedLanguage, ITranslationResult, MessageAttachment } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import _ from 'underscore'; + +import { TranslationProviderRegistry, AutoTranslate } from './autotranslate'; +import { msLogger } from './logger'; +import { settings } from '../../settings'; +import { i18n } from '../i18n'; + +/** + * Microsoft translation service provider class representation. + * Encapsulates the service provider settings and information. + * Provides languages supported by the service provider. + * Resolves API call to service provider to resolve the translation request. + * @class + * @augments AutoTranslate + */ +class MsAutoTranslate extends AutoTranslate { + apiKey: string; + + apiEndPointUrl: string; + + apiDetectText: string; + + apiGetLanguages: string; + + breakSentence: string; + + /** + * setup api reference to Microsoft translate to be used as message translation provider. + * @constructor + */ + constructor() { + super(); + this.name = 'microsoft-translate'; + this.apiEndPointUrl = 'https://api.cognitive.microsofttranslator.com/translate?api-version=3.0'; + this.apiDetectText = 'https://api.cognitive.microsofttranslator.com/detect?api-version=3.0'; + this.apiGetLanguages = 'https://api.cognitive.microsofttranslator.com/languages?api-version=3.0'; + this.breakSentence = 'https://api.cognitive.microsofttranslator.com/breaksentence?api-version=3.0'; + // Get the service provide API key. + settings.watch('AutoTranslate_MicrosoftAPIKey', (value) => { + this.apiKey = value; + }); + } + + /** + * Returns metadata information about the service provide + * @private implements super abstract method. + * @return {object} + */ + _getProviderMetadata(): IProviderMetadata { + return { + name: this.name, + displayName: i18n.t('AutoTranslate_Microsoft'), + settings: this._getSettings(), + }; + } + + /** + * Returns necessary settings information about the translation service provider. + * @private implements super abstract method. + * @return {object} + */ + _getSettings(): IProviderMetadata['settings'] { + return { + apiKey: this.apiKey, + apiEndPointUrl: this.apiEndPointUrl, + }; + } + + /** + * Returns supported languages for translation by the active service provider. + * Microsoft does not provide an endpoint yet to retrieve the supported languages. + * So each supported languages are explicitly maintained. + * @private implements super abstract method. + * @param {string} target + * @returns {object} code : value pair + */ + async getSupportedLanguages(target: string): Promise { + if (!this.apiKey) { + return []; + } + if (this.supportedLanguages[target]) { + return this.supportedLanguages[target]; + } + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(this.apiGetLanguages, { + ignoreSsrfValidation: true, + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const languages = await request.json(); + this.supportedLanguages[target] = Object.keys(languages.translation).map((language) => ({ + language, + name: languages.translation[language].name, + })); + return this.supportedLanguages[target || 'en']; + } + + /** + * Re-use method for REST API consumption of MS translate. + * @private + * @param {object} message + * @param {object} targetLanguages + * @throws Communication Errors + * @returns {object} translations: Translated messages for each language + */ + async _translate( + data: { + Text: string; + }[], + targetLanguages: string[], + ): Promise { + let translations: { [k: string]: string } = {}; + const supportedLanguages = await this.getSupportedLanguages('en'); + targetLanguages = targetLanguages.map((language) => { + if (language.indexOf('-') !== -1 && !_.findWhere(supportedLanguages, { language })) { + language = language.substr(0, 2); + } + return language; + }); + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + const request = await fetch(this.apiEndPointUrl, { + ignoreSsrfValidation: true, + method: 'POST', + headers: { + 'Ocp-Apim-Subscription-Key': this.apiKey, + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: data, + params: { + to: targetLanguages, + }, + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const result = await request.json(); + + if (request.status === 200 && result.length > 0) { + // store translation only when the source and target language are different. + translations = Object.assign( + {}, + ...targetLanguages.map((language) => ({ + [language]: result + .map( + (line: { translations: { to: string; text: string }[] }) => + line.translations.find((translation) => translation.to === language)?.text, + ) + .join('\n'), + })), + ); + } + + return translations; + } + + /** + * Returns translated message for each target language. + * @private + * @param {object} message + * @param {object} targetLanguages + * @returns {object} translations: Translated messages for each language + */ + async _translateMessage(message: IMessage, targetLanguages: string[]): Promise { + // There are multi-sentence-messages where multiple sentences come from different languages + // This is a problem for translation services since the language detection fails. + // Thus, we'll split the message in sentences, get them translated, and join them again after translation + const msgs = message.msg.split('\n').map((msg) => ({ Text: msg })); + try { + return this._translate(msgs, targetLanguages); + } catch (e) { + msLogger.error({ err: e, msg: 'Error translating message' }); + } + return {}; + } + + /** + * Returns translated message attachment description in target languages. + * @private + * @param {object} attachment + * @param {object} targetLanguages + * @returns {object} translated messages for each target language + */ + async _translateAttachmentDescriptions(attachment: MessageAttachment, targetLanguages: string[]): Promise { + try { + return this._translate( + [ + { + Text: attachment.description || attachment.text || '', + }, + ], + targetLanguages, + ); + } catch (e) { + msLogger.error({ err: e, msg: 'Error translating message attachment' }); + } + return {}; + } +} + +// Register Microsoft translation provider to the registry. +TranslationProviderRegistry.registerProvider(new MsAutoTranslate()); diff --git a/apps/meteor/server/lib/autotranslate/permissions.ts b/apps/meteor/server/lib/autotranslate/permissions.ts new file mode 100644 index 0000000..7fee3b3 --- /dev/null +++ b/apps/meteor/server/lib/autotranslate/permissions.ts @@ -0,0 +1,8 @@ +import { Permissions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +Meteor.startup(async () => { + if (!(await Permissions.findOne({ _id: 'auto-translate' }))) { + await Permissions.create('auto-translate', ['admin']); + } +}); diff --git a/apps/meteor/server/lib/banUserFromRoom.ts b/apps/meteor/server/lib/banUserFromRoom.ts new file mode 100644 index 0000000..1bd8dfd --- /dev/null +++ b/apps/meteor/server/lib/banUserFromRoom.ts @@ -0,0 +1,60 @@ +import { isBannedSubscription } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users, Roles } from '@rocket.chat/models'; + +import { canAccessRoomAsync } from './authorization'; +import { hasPermissionAsync } from './authorization/hasPermission'; +import { hasRoleAsync } from './authorization/hasRole'; +import { banUserFromRoom } from './rooms/banUserFromRoom'; +import { roomCoordinator } from './rooms/roomCoordinator'; +import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; + +export const banUserFromRoomMethod = async (fromId: string, data: { rid: string; username: string }): Promise => { + if (!(await hasPermissionAsync(fromId, 'ban-user', data.rid))) { + throw new Error('Not allowed'); + } + + const room = await Rooms.findOneById(data.rid); + + if (!room || !(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BAN, fromId))) { + throw new Error('Not allowed'); + } + + const fromUser = await Users.findOneById(fromId); + if (!fromUser) { + throw new Error('Invalid user'); + } + + if (!(await canAccessRoomAsync(room, fromUser))) { + throw new Error('The required "roomId" or "roomName" param provided does not match any group'); + } + + const bannedUser = await Users.findOneByUsernameIgnoringCase(data.username); + if (!bannedUser) { + throw new Error('User not found'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, bannedUser._id, { + projection: { _id: 1, status: 1 }, + }); + if (!subscription) { + throw new Error('User is not in this room'); + } + + // Cannot ban a user who is already banned + if (isBannedSubscription(subscription)) { + throw new Error('User is already banned from this room'); + } + + // Cannot ban the last owner + if (await hasRoleAsync(bannedUser._id, 'owner', room._id)) { + const numOwners = await Roles.countUsersInRole('owner', room._id); + + if (numOwners === 1) { + throw new Error('You are the last owner. Please set new owner before banning the user.'); + } + } + + await banUserFromRoom(data.rid, bannedUser, fromUser); + + return true; +}; diff --git a/apps/meteor/server/lib/bot-helpers/README.md b/apps/meteor/server/lib/bot-helpers/README.md new file mode 100644 index 0000000..83c4aba --- /dev/null +++ b/apps/meteor/server/lib/bot-helpers/README.md @@ -0,0 +1,54 @@ +# Rocket.chat bot helpers + +This package provides some extra shortcut Meteor methods intended to help Hubot scripts. + +## Approach + +This was an experiment in how to extend Hubot and Rocket.chat integration. + +Keeping controller logic out of Hubot scripts made sense to me, but its not necessarily the best approach. + +For bots to use these methods they obviously need this package added to Rocket.chat as well as the [rocketchat adapter](https://github.com/RocketChat/hubot-rocketchat). + +## Usage + +In Hubot scripts, call bot helper methods using Meteor.call with the first argument as the helper type, then the method name as second argument. + +At present, there's only one type of helper (`botRequest`) for simple getters, more utility methods may be added in future. + +e.g. Below uses `onlineNames` method, which returns array of names of online users (not including bots). + +Listens for *"who is online"* and replies with something like *"Robert, Desmond and Billie are currently online"*. + +``` +# Use Bot Helpers class to check who's online +robot.hear /who is online/i, (res) -> + promise = robot.adapter.callMethod('botRequest', 'onlineNames') + promise.then (result) -> + if result.length > 0 + names = result.join(', ').replace(/,(?=[^,]*$)/, ' and ') # convert last comma to 'and' + res.send "#{ names } #{ if result.length == 1 then 'is' else 'are' } currently online" + else + res.send "Nobody is currently online... \*cricket sound\*" + return + , (error) -> + res.send "Uh, sorry I don't know, something's not working" + return +``` + +Method names that include fields like `allUsernames` return an 1D array of just that property. + +First two methods return 2D array with properties defined in a defaults object (currently id, name, username, status, emails). + +- `allUsers` +- `onlineUsers` +- `allUsernames` +- `onlineUsernames` +- `allNames` +- `onlineNames` +- `allIDs` +- `onlineIDs` + +## Tests + +None yet, PR adding tests would be much apreciated. diff --git a/apps/meteor/server/lib/bot-helpers/index.ts b/apps/meteor/server/lib/bot-helpers/index.ts new file mode 100644 index 0000000..4040bef --- /dev/null +++ b/apps/meteor/server/lib/bot-helpers/index.ts @@ -0,0 +1,214 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { Filter, FindCursor } from 'mongodb'; + +import { addUserToRole } from '../../meteor-methods/auth/addUserToRole'; +import { removeUserFromRole } from '../../meteor-methods/auth/removeUserFromRole'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; +import { removeUserFromRoomMethod } from '../../meteor-methods/rooms/removeUserFromRoom'; +import { settings } from '../../settings'; +import { hasRoleAsync } from '../authorization/hasRole'; + +/** + * BotHelpers helps bots + * "private" properties use meteor collection cursors, so they stay reactive + * "public" properties use getters to fetch and filter collections as array + */ +class BotHelpers { + private queries: { + online: Filter; + users: Filter; + }; + + private userFields: Record; + + private _allUsers: FindCursor; + + private _onlineUsers: FindCursor; + + constructor() { + this.queries = { + online: { status: { $ne: UserStatus.OFFLINE } }, + users: { roles: { $not: { $all: ['bot'] } } }, + }; + } + + // setup collection cursors with array of fields from setting + setupCursors(fieldsSetting: string | string[]) { + this.userFields = {}; + if (typeof fieldsSetting === 'string') { + fieldsSetting = fieldsSetting.split(','); + } + fieldsSetting.forEach((n) => { + this.userFields[n.trim()] = 1; + }); + this._allUsers = Users.find(this.queries.users, { projection: this.userFields }); + this._onlineUsers = Users.find({ $and: [this.queries.users, this.queries.online] }, { projection: this.userFields }); + } + + // request methods or props as arguments to Meteor.call + async request(prop: keyof this, ...params: unknown[]): Promise { + const p = this[prop]; + + if (typeof p === 'undefined') { + return null; + } + if (typeof p === 'function') { + return p(...params); + } + + return p; + } + + async addUserToRole(userName: string, roleId: string, userId: string): Promise { + await addUserToRole(userId, roleId, userName); + } + + async removeUserFromRole(userName: string, roleId: string, userId: string): Promise { + await removeUserFromRole(userId, roleId, userName); + } + + async addUserToRoom(userName: string, room: string): Promise { + const foundRoom = await Rooms.findOneByIdOrName(room); + + if (!foundRoom) { + throw new Meteor.Error('invalid-channel'); + } + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addUserToRoom' }); + } + await addUsersToRoomMethod(userId, { + rid: foundRoom._id, + users: [userName], + }); + } + + async removeUserFromRoom(userName: string, room: string) { + const foundRoom = await Rooms.findOneByIdOrName(room); + + if (!foundRoom) { + throw new Meteor.Error('invalid-channel'); + } + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + await removeUserFromRoomMethod(userId, { rid: foundRoom._id, username: userName }); + } + + // generic error whenever property access insufficient to fill request + requestError() { + throw new Meteor.Error('error-not-allowed', 'Bot request not allowed', { + method: 'botRequest', + action: 'bot_request', + }); + } + + // "public" properties accessed by getters + // allUsers / onlineUsers return whichever properties are enabled by settings + get allUsers() { + if (!Object.keys(this.userFields).length) { + this.requestError(); + return false; + } + return this._allUsers.toArray(); + } + + get onlineUsers() { + if (!Object.keys(this.userFields).length) { + this.requestError(); + return false; + } + return this._onlineUsers.toArray(); + } + + get allUsernames() { + return (async () => { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return (await this._allUsers.toArray()).map((user) => user.username); + })(); + } + + get onlineUsernames() { + return (async () => { + if (!this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return (await this._onlineUsers.toArray()).map((user) => user.username); + })(); + } + + get allNames() { + return (async () => { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } + return (await this._allUsers.toArray()).map((user) => user.name); + })(); + } + + get onlineNames() { + return (async () => { + if (!this.userFields.hasOwnProperty('name')) { + this.requestError(); + return false; + } + return (await this._onlineUsers.toArray()).map((user) => user.name); + })(); + } + + get allIDs() { + return (async () => { + if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return (await this._allUsers.toArray()).map((user) => ({ id: user._id, name: user.username })); + })(); + } + + get onlineIDs() { + return (async () => { + if (!this.userFields.hasOwnProperty('_id') || !this.userFields.hasOwnProperty('username')) { + this.requestError(); + return false; + } + return (await this._onlineUsers.toArray()).map((user) => ({ id: user._id, name: user.username })); + })(); + } +} + +// add class to meteor methods +const botHelpers = new BotHelpers(); + +// init cursors with fields setting and update on setting change +settings.watch('BotHelpers_userFields', (value) => { + botHelpers.setupCursors(value); +}); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + botRequest: (prop: keyof BotHelpers, ...params: unknown[]) => Promise; + } +} + +Meteor.methods({ + async botRequest(...args) { + const userID = Meteor.userId(); + if (userID && (await hasRoleAsync(userID, 'bot'))) { + return botHelpers.request(...args, userID); + } + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'botRequest' }); + }, +}); diff --git a/apps/meteor/server/lib/bugsnag.ts b/apps/meteor/server/lib/bugsnag.ts new file mode 100644 index 0000000..98fea20 --- /dev/null +++ b/apps/meteor/server/lib/bugsnag.ts @@ -0,0 +1,43 @@ +import Bugsnag from '@bugsnag/js'; +import { Logger } from '@rocket.chat/logger'; +import { Meteor } from 'meteor/meteor'; + +import { Info } from '../../app/utils/rocketchat.info'; +import { settings } from '../settings'; + +const logger = new Logger('bugsnag'); + +const originalMeteorDebug = Meteor._debug; + +function _bugsnagDebug(message: any, stack: any, ...args: any): void { + if (stack instanceof Error) { + Bugsnag.notify(stack, (event) => { + event.context = message; + }); + } else { + if (typeof stack === 'string') { + message += ` ${stack}`; + } + + const error = new Error(message); + error.stack = stack; + Bugsnag.notify(error); + } + + return originalMeteorDebug(message, stack, ...args); +} + +settings.watch('Bugsnag_api_key', (value) => { + if (!value) { + return; + } + + Bugsnag.start({ + apiKey: value as string, + appVersion: Info.version, + logger, + metadata: Info, + }); + + Meteor._debug = _bugsnagDebug; +}); diff --git a/apps/meteor/server/lib/callbacks.spec.ts b/apps/meteor/server/lib/callbacks.spec.ts new file mode 100644 index 0000000..e5b4195 --- /dev/null +++ b/apps/meteor/server/lib/callbacks.spec.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { callbacks } from './callbacks'; +import { Callbacks } from './callbacks/callbacksBase'; + +describe('callbacks legacy', () => { + it("if the callback doesn't return any value should return the original", async () => { + callbacks.add('test', () => undefined, callbacks.priority.LOW, '1'); + + const result = await callbacks.run('test', true); + + expect(result).to.be.true; + + callbacks.remove('test', '1'); + }); + + it('should return the value returned by the callback', async () => { + callbacks.add('test', () => false, callbacks.priority.LOW, '1'); + + const result = await callbacks.run('test', true); + + expect(result).to.be.false; + + callbacks.remove('test', '1'); + }); + + it('should accumulate the values returned by the callbacks', async () => { + callbacks.add('test', (old: number) => old * 5); + + callbacks.add('test', (old: number) => old * 2); + + expect(await callbacks.run('test', 3)).to.be.equal(30); + + expect(await callbacks.run('test', 2)).to.be.equal(20); + }); +}); + +describe('callbacks', () => { + it("if the callback doesn't return any value should return the original", async () => { + const test = Callbacks.create<(data: boolean) => boolean>('test'); + + test.add(() => undefined, callbacks.priority.LOW, '1'); + + const result = await test.run(true); + + expect(result).to.be.true; + }); + + it('should return the value returned by the callback', async () => { + const test = Callbacks.create<(data: boolean) => boolean>('test'); + + test.add(() => false, callbacks.priority.LOW, '1'); + + const result = await test.run(true); + + expect(result).to.be.false; + }); + + it('should accumulate the values returned by the callbacks', async () => { + const test = Callbacks.create<(data: number) => number>('test'); + + test.add((old) => old * 5); + + test.add((old) => old * 2); + + expect(await test.run(3)).to.be.equal(30); + + expect(await test.run(2)).to.be.equal(20); + }); +}); diff --git a/apps/meteor/server/lib/callbacks.ts b/apps/meteor/server/lib/callbacks.ts new file mode 100644 index 0000000..cb328d6 --- /dev/null +++ b/apps/meteor/server/lib/callbacks.ts @@ -0,0 +1,230 @@ +import type { ICreateRoomOptions } from '@rocket.chat/core-services'; +import type { + IMessage, + IRoom, + IUser, + ILivechatDepartmentRecord, + ILivechatAgent, + OmnichannelAgentStatus, + ILivechatInquiryRecord, + ILivechatVisitor, + VideoConference, + IOmnichannelRoom, + ILivechatTag, + ILivechatTagRecord, + TransferData, + AtLeast, + UserStatus, + ILivechatDepartment, + MessageMention, + IOmnichannelInquiryExtraData, +} from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import type { FilterOperators } from 'mongodb'; + +import type { ILoginAttempt } from './auth/ILoginAttempt'; +import { Callbacks } from './callbacks/callbacksBase'; +import type { SendMessageOptions } from './messages/sendMessage'; +import type { IBusinessHourBehavior } from './omnichannel/business-hour/AbstractBusinessHour'; +import type { CloseRoomParams } from './omnichannel/localTypes'; + +/** + * Callbacks returning void, like event listeners. + * + * TODO: move those to event-based systems + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +interface EventLikeCallbackSignatures { + 'afterActivateUser': (user: IUser) => void; + 'afterCreateChannel': (owner: IUser, room: IRoom) => void; + 'afterCreatePrivateGroup': (owner: IUser, room: IRoom) => void; + 'afterDeactivateUser': (user: IUser) => void; + 'afterDeleteMessage': (message: IMessage, params: { room: IRoom; user: IUser }) => void; + 'workspaceLicenseChanged': (license: string) => void; + 'workspaceLicenseRemoved': () => void; + 'afterReadMessages': (room: IRoom, params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => void; + 'beforeReadMessages': (rid: IRoom['_id'], uid: IUser['_id']) => void; + 'afterDeleteUser': (user: IUser) => void; + 'afterFileUpload': (params: { user: IUser; room: IRoom; message: IMessage }) => void; + 'afterRoomNameChange': (params: { room: IRoom; name: string; oldName: string; user: IUser }) => void; + 'afterSaveMessage': ( + message: IMessage, + params: { room: IRoom; user: IUser; roomUpdater?: Updater; options?: SendMessageOptions }, + ) => void; + 'afterOmnichannelSaveMessage': (message: IMessage, constant: { room: IOmnichannelRoom; roomUpdater: Updater }) => void; + 'livechat.removeAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; + 'livechat.saveAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; + 'livechat.closeRoom': (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => void; + 'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void; + 'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; + 'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: UserStatus }) => void; + 'livechat.onNewAgentCreated': (agentId: string) => void; + 'livechat.afterAgentRemoved': (params: { agent: Pick }) => void; + 'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void; + 'beforeAddedToRoom': (params: { + user: AtLeast; + inviter: AtLeast; + }) => void; + 'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id']; mrid?: string }) => void; + 'beforeDeleteRoom': (params: IRoom) => void; + 'beforeJoinDefaultChannels': (user: IUser) => void; + 'beforeCreateChannel': (owner: IUser, room: IRoom) => void; + 'afterCreateRoom': (owner: IUser, room: IRoom) => void; + 'onValidateLogin': (login: ILoginAttempt) => void; + 'federation.afterCreateFederatedRoom': ( + room: IRoom, + second: { + owner: IUser; + originalMemberList: string[]; + options?: ICreateRoomOptions; + }, + ) => void; + 'beforeCreateDirectRoom': (members: string[], room: IRoom) => void; + 'federation.beforeCreateDirectMessage': (members: IUser[], extraData: Record) => void; + 'afterSetReaction': (message: IMessage, params: { user: IUser; reaction: string; shouldReact: boolean; room: IRoom }) => void; + 'afterUnsetReaction': ( + message: IMessage, + params: { user: IUser; reaction: string; shouldReact: boolean; oldMessage: IMessage; room: IRoom }, + ) => void; + 'onJoinVideoConference': (callId: VideoConference['_id'], userId?: IUser['_id']) => Promise; + 'beforeJoinRoom': (user: IUser, room: IRoom) => void; + 'beforeMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; + 'afterMuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; + 'beforeUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; + 'afterUnmuteUser': (users: { mutedUser: IUser; fromUser: IUser }, room: IRoom) => void; + 'afterValidateLogin': (login: { user: IUser }) => void; + 'afterJoinRoom': (user: IUser, room: IRoom) => void; + 'livechat.afterDepartmentDisabled': (department: ILivechatDepartmentRecord) => void; + 'livechat.afterDepartmentArchived': (department: Pick) => void; + 'beforeSaveUser': ({ user, oldUser }: { user: IUser; oldUser?: IUser }) => void; + 'afterCreateUser': (user: AtLeast) => void; + 'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser?: IUser | null }) => void; + 'livechat.afterTagRemoved': (tag: ILivechatTagRecord) => void; + 'afterUserImport': (data: { inserted: IUser['_id'][]; updated: IUser['_id']; skipped: number; failed: number }) => void; +} + +/** + * Callbacks that are supposed to be composed like a chain. + * + * TODO: develop a middleware alternative and grant independence of execution order + */ +type ChainedCallbackSignatures = { + 'livechat.beforeForwardRoomToDepartment': ( + options: T, + ) => Promise; + 'livechat.onLoadForwardDepartmentRestrictions': (params: { departmentId: string }) => Record; + 'livechat.saveInfo': ( + newRoom: IOmnichannelRoom, + props: { user: Required>; oldRoom: IOmnichannelRoom }, + ) => IOmnichannelRoom; + 'afterDeleteRoom': (rid: IRoom['_id']) => IRoom['_id']; + 'livechat:afterOnHold': (room: Pick) => Pick; + 'livechat:afterOnHoldChatResumed': (room: Pick) => Pick; + 'livechat:onTransferFailure': ( + room: IRoom, + params: { + guest: ILivechatVisitor; + transferData: TransferData; + department: AtLeast; + }, + ) => IOmnichannelRoom | Promise; + 'livechat.afterForwardChatToAgent': (params: { + rid: IRoom['_id']; + servedBy: { _id: string; ts: Date; username?: string }; + oldServedBy: { _id: string; ts: Date; username?: string }; + }) => { + rid: IRoom['_id']; + servedBy: { _id: string; ts: Date; username?: string }; + oldServedBy: { _id: string; ts: Date; username?: string }; + }; + 'livechat.afterForwardChatToDepartment': (params: { + rid: IRoom['_id']; + newDepartmentId: ILivechatDepartmentRecord['_id']; + oldDepartmentId: ILivechatDepartmentRecord['_id']; + }) => { + rid: IRoom['_id']; + newDepartmentId: ILivechatDepartmentRecord['_id']; + oldDepartmentId: ILivechatDepartmentRecord['_id']; + }; + 'livechat.afterRemoveDepartment': (params: { + department: AtLeast; + agentsId: ILivechatAgent['_id'][]; + }) => { + department: AtLeast; + agentsId: ILivechatAgent['_id'][]; + }; + 'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined; + 'livechat.applyRoomRestrictions': ( + query: FilterOperators, + params?: { + unitsFilter?: string[]; + userId?: string; + }, + ) => FilterOperators; + 'livechat.onMaxNumberSimultaneousChatsReached': (inquiry: ILivechatInquiryRecord) => ILivechatInquiryRecord; + 'on-business-hour-start': (params: { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior } }) => { + BusinessHourBehaviorClass: { new (): IBusinessHourBehavior }; + }; + 'renderMessage': (message: T) => T; + 'livechat.beforeListTags': () => ILivechatTag[]; + 'livechat.offlineMessage': (data: { name: string; email: string; message: string; department?: string; host?: string }) => void; + 'livechat.leadCapture': (room: IOmnichannelRoom) => IOmnichannelRoom; + 'beforeSendMessageNotifications': (message: string) => string; + 'livechat.onAgentAssignmentFailed': ( + room: IOmnichannelRoom, + params: { + inquiry: { + _id: string; + rid: string; + status: string; + }; + options: { forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any }; clientAction?: boolean }; + }, + ) => Promise<(IOmnichannelRoom & { chatQueued: boolean }) | false>; + 'beforeGetMentions': (mentionIds: string[], teamMentions: MessageMention[]) => Promise; + 'livechat.manageDepartmentUnit': (params: { userId: string; departmentId: string; unitId?: string }) => void; + 'afterRoomTopicChange': ( + params: undefined, + { room, topic, user }: { room: IRoom; topic: string; user: Pick }, + ) => void; + 'livechat.beforeInquiry': (data: IOmnichannelInquiryExtraData) => Partial; +}; + +export type Hook = + | keyof EventLikeCallbackSignatures + | keyof ChainedCallbackSignatures + | 'afterProcessOAuthUser' + | 'afterRoomArchived' + | 'afterSaveUser' + | 'afterValidateNewOAuthUser' + | 'beforeActivateUser' + | 'beforeReadMessages' + | 'beforeRemoveFromRoom' + | 'beforeValidateLogin' + | 'livechat.beforeForwardRoomToDepartment' + | 'livechat.checkAgentBeforeTakeInquiry' + | 'livechat.sendTranscript' + | 'livechat.closeRoom' + | 'livechat.offlineMessage' + | 'livechat.manageDepartmentUnit' + | 'mapLDAPUserData' + | 'onCreateUser' + | 'onLDAPLogin' + | 'renderNotification' + | 'userRegistered' + | 'test'; + +/** + * Callback hooks provide an easy way to add extra steps to common operations. + * @deprecated + */ + +export const callbacks = new Callbacks< + { + [key in keyof ChainedCallbackSignatures]: ChainedCallbackSignatures[key]; + }, + { + [key in keyof EventLikeCallbackSignatures]: EventLikeCallbackSignatures[key]; + }, + Hook +>(); diff --git a/apps/meteor/server/lib/callbacks/afterBanFromRoomCallback.ts b/apps/meteor/server/lib/callbacks/afterBanFromRoomCallback.ts new file mode 100644 index 0000000..7bfd375 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/afterBanFromRoomCallback.ts @@ -0,0 +1,6 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const afterBanFromRoomCallback = + Callbacks.create<(data: { bannedUser: IUser; userWhoBanned: IUser }, room: IRoom) => void>('afterBanFromRoom'); diff --git a/apps/meteor/server/lib/callbacks/afterLeaveRoomCallback.ts b/apps/meteor/server/lib/callbacks/afterLeaveRoomCallback.ts new file mode 100644 index 0000000..a171193 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/afterLeaveRoomCallback.ts @@ -0,0 +1,5 @@ +import type { IUser, IRoom } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const afterLeaveRoomCallback = Callbacks.create<(data: { user: IUser; kicker?: IUser }, room: IRoom) => void>('afterLeaveRoom'); diff --git a/apps/meteor/server/lib/callbacks/afterLogoutCleanUpCallback.ts b/apps/meteor/server/lib/callbacks/afterLogoutCleanUpCallback.ts new file mode 100644 index 0000000..9b6e77b --- /dev/null +++ b/apps/meteor/server/lib/callbacks/afterLogoutCleanUpCallback.ts @@ -0,0 +1,4 @@ +import { Callbacks } from './callbacksBase'; + +/** @deprecated - replace with UserProvider onLogout callback */ +export const afterLogoutCleanUpCallback = Callbacks.create('afterLogoutCleanUp'); diff --git a/apps/meteor/server/lib/callbacks/afterRemoveFromRoomCallback.ts b/apps/meteor/server/lib/callbacks/afterRemoveFromRoomCallback.ts new file mode 100644 index 0000000..0a62bdb --- /dev/null +++ b/apps/meteor/server/lib/callbacks/afterRemoveFromRoomCallback.ts @@ -0,0 +1,6 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const afterRemoveFromRoomCallback = + Callbacks.create<(data: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom) => void>('afterRemoveFromRoom'); diff --git a/apps/meteor/server/lib/callbacks/afterUnbanFromRoomCallback.ts b/apps/meteor/server/lib/callbacks/afterUnbanFromRoomCallback.ts new file mode 100644 index 0000000..74118e0 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/afterUnbanFromRoomCallback.ts @@ -0,0 +1,6 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const afterUnbanFromRoomCallback = + Callbacks.create<(data: { unbannedUser: IUser; userWhoUnbanned: IUser }, room: IRoom) => void>('afterUnbanFromRoom'); diff --git a/apps/meteor/server/lib/callbacks/beforeAddUserToRoom.ts b/apps/meteor/server/lib/callbacks/beforeAddUserToRoom.ts new file mode 100644 index 0000000..2ec6758 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/beforeAddUserToRoom.ts @@ -0,0 +1,7 @@ +import type { IUser, IRoom } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const beforeAddUserToRoom = Callbacks.create<(args: { user: IUser; inviter?: IUser }, room: IRoom) => void>('beforeAddUserToRoom'); +export const beforeAddUsersToRoom = + Callbacks.create<(args: { usernames: string[]; inviter?: IUser }, room: IRoom) => void>('beforeAddUsersToRoom'); diff --git a/apps/meteor/server/lib/callbacks/beforeChangeRoomRole.ts b/apps/meteor/server/lib/callbacks/beforeChangeRoomRole.ts new file mode 100644 index 0000000..f83d083 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/beforeChangeRoomRole.ts @@ -0,0 +1,8 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const beforeChangeRoomRole = + Callbacks.create<(args: { fromUserId: string; userId: string; room: IRoom; role: 'moderator' | 'owner' | 'leader' | 'user' }) => void>( + 'beforeChangeRoomRole', + ); diff --git a/apps/meteor/server/lib/callbacks/beforeCreateRoomCallback.ts b/apps/meteor/server/lib/callbacks/beforeCreateRoomCallback.ts new file mode 100644 index 0000000..fe41628 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/beforeCreateRoomCallback.ts @@ -0,0 +1,9 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const beforeCreateRoomCallback = + Callbacks.create<(data: { owner: IUser; room: Omit }) => void>('beforeCreateRoom'); + +export const prepareCreateRoomCallback = + Callbacks.create<(data: { type: IRoom['t']; extraData: Partial }) => void>('prepareCreateRoom'); diff --git a/apps/meteor/server/lib/callbacks/beforeCreateUserCallback.ts b/apps/meteor/server/lib/callbacks/beforeCreateUserCallback.ts new file mode 100644 index 0000000..e8e8dda --- /dev/null +++ b/apps/meteor/server/lib/callbacks/beforeCreateUserCallback.ts @@ -0,0 +1,3 @@ +import { Callbacks } from './callbacksBase'; + +export const beforeCreateUserCallback = Callbacks.create('beforeCreateUser'); diff --git a/apps/meteor/server/lib/callbacks/beforeLeaveRoomCallback.ts b/apps/meteor/server/lib/callbacks/beforeLeaveRoomCallback.ts new file mode 100644 index 0000000..3ade5a8 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/beforeLeaveRoomCallback.ts @@ -0,0 +1,5 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Callbacks } from './callbacksBase'; + +export const beforeLeaveRoomCallback = Callbacks.create<(user: IUser, room: IRoom) => void>('beforeLeaveRoom'); diff --git a/apps/meteor/server/lib/callbacks/callbacksBase.ts b/apps/meteor/server/lib/callbacks/callbacksBase.ts new file mode 100644 index 0000000..b1d89a7 --- /dev/null +++ b/apps/meteor/server/lib/callbacks/callbacksBase.ts @@ -0,0 +1,248 @@ +import type { Logger } from '@rocket.chat/logger'; +import { Random } from '@rocket.chat/random'; + +import { compareByRanking } from '../../../lib/utils/comparisons'; + +enum CallbackPriority { + HIGH = -1000, + MEDIUM = 0, + LOW = 1000, +} + +type Callback = { + (item: unknown, constant?: unknown): Promise; + hook: H; + id: string; + priority: CallbackPriority; + stack: string; +}; + +type CallbackTracker = (callback: Callback) => () => void; + +type HookTracker = (params: { hook: H; length: number }) => () => void; + +export class Callbacks< + TChainedCallbackSignatures extends { + [key: string]: (item: any, constant?: any) => any; + }, + TEventLikeCallbackSignatures extends { + [key: string]: (item: any, constant?: any) => any; + }, + THook extends string = keyof TChainedCallbackSignatures & keyof TEventLikeCallbackSignatures & string, +> { + private logger: Logger | undefined = undefined; + + private trackCallback: CallbackTracker | undefined = undefined; + + private trackHook: HookTracker | undefined = undefined; + + private callbacks = new Map[]>(); + + private sequentialRunners = new Map Promise>(); + + private asyncRunners = new Map unknown>(); + + readonly priority = CallbackPriority; + + setLogger(logger: Logger): void { + this.logger = logger; + } + + setMetricsTrackers({ trackCallback, trackHook }: { trackCallback?: CallbackTracker; trackHook?: HookTracker }): void { + this.trackCallback = trackCallback; + this.trackHook = trackHook; + } + + private runOne(callback: Callback, item: unknown, constant: unknown): Promise { + const stopTracking = this.trackCallback?.(callback); + + return Promise.resolve(callback(item, constant)).finally(stopTracking); + } + + private createSequentialRunner(hook: THook, callbacks: Callback[]): (item: unknown, constant?: unknown) => Promise { + const wrapCallback = + (callback: Callback) => + async (item: unknown, constant?: unknown): Promise => { + this.logger?.debug({ msg: 'Executing callback', id: callback.id, hook: callback.hook }); + + return (await this.runOne(callback, item, constant)) ?? item; + }; + + const identity = (item: TItem): Promise => Promise.resolve(item); + + const pipe = + (curr: (item: unknown, constant?: unknown) => Promise, next: (item: unknown, constant?: unknown) => Promise) => + async (item: unknown, constant?: unknown): Promise => + next(await curr(item, constant), constant); + + const fn = callbacks.map(wrapCallback).reduce(pipe, identity); + + return async (item: unknown, constant?: unknown): Promise => { + const stopTracking = this.trackHook?.({ hook, length: callbacks.length }); + + return fn(item, constant).finally(() => stopTracking?.()); + }; + } + + private createAsyncRunner(_: THook, callbacks: Callback[]) { + return (item: unknown, constant?: unknown): unknown => { + if (typeof window !== 'undefined') { + throw new Error('callbacks.runAsync on client server not allowed'); + } + + for (const callback of callbacks) { + setTimeout(() => { + void this.runOne(callback, item, constant); + }, 0); + } + + return item; + }; + } + + getCallbacks(hook: THook): Callback[] { + return this.callbacks.get(hook) ?? []; + } + + setCallbacks(hook: THook, callbacks: Callback[]): void { + this.callbacks.set(hook, callbacks); + this.sequentialRunners.set(hook, this.createSequentialRunner(hook, callbacks)); + this.asyncRunners.set(hook, this.createAsyncRunner(hook, callbacks)); + } + + /** + * Add a callback function to a hook + * + * @param hook the name of the hook + * @param callback the callback function + * @param priority the callback run priority (order) + * @param id human friendly name for this callback + */ + add( + hook: Hook, + callback: TEventLikeCallbackSignatures[Hook], + priority?: CallbackPriority, + id?: string, + ): () => void; + + add( + hook: Hook, + callback: TChainedCallbackSignatures[Hook], + priority?: CallbackPriority, + id?: string, + ): () => void; + + add( + hook: THook, + callback: (item: TItem, constant?: TConstant) => TNextItem, + priority?: CallbackPriority, + id?: string, + ): () => void; + + add( + hook: THook, + callback: (item: unknown, constant?: unknown) => unknown, + priority = this.priority.MEDIUM, + id = Random.id(), + ): () => void { + const callbacks = this.getCallbacks(hook); + + if (callbacks.some((cb) => cb.id === id)) { + return () => { + this.remove(hook, id); + }; + } + + callbacks.push( + Object.assign(callback as Callback, { + hook, + priority, + id, + stack: new Error().stack, + }), + ); + callbacks.sort(compareByRanking((callback: Callback): number => callback.priority ?? this.priority.MEDIUM)); + + this.setCallbacks(hook, callbacks); + + return () => { + this.remove(hook, id); + }; + } + + /** + * Remove a callback from a hook + * + * @param hook the name of the hook + * @param id the callback's id + */ + remove(hook: THook, id: string): void { + const hooks = this.getCallbacks(hook).filter((callback) => callback.id !== id); + this.setCallbacks(hook, hooks); + } + + run(hook: Hook, ...args: Parameters): Promise; + + run( + hook: Hook, + ...args: Parameters + ): Promise>; + + run(hook: THook, item: TItem, constant?: TConstant): Promise; + + /** + * Successively run all of a hook's callbacks on an item + * + * @param hook the name of the hook + * @param item the post, comment, modifier, etc. on which to run the callbacks + * @param constant an optional constant that will be passed along to each callback + * @returns returns the item after it's been through all the callbacks for this hook + */ + run(hook: THook, item: unknown, constant?: unknown): Promise { + const runner = this.sequentialRunners.get(hook) ?? (async (item: unknown, _constant?: unknown): Promise => item); + return runner(item, constant); + } + + runAsync(hook: Hook, ...args: Parameters): void; + + /** + * Successively run all of a hook's callbacks on an item, in async mode (only works on server) + * + * @param hook the name of the hook + * @param item the post, comment, modifier, etc. on which to run the callbacks + * @param constant an optional constant that will be passed along to each callback + * @returns the post, comment, modifier, etc. on which to run the callbacks + */ + runAsync(hook: THook, item: unknown, constant?: unknown): unknown { + const runner = this.asyncRunners.get(hook) ?? ((item: unknown, _constant?: unknown): unknown => item); + return runner(item, constant); + } + + static create any | Promise>( + hook: string, + ): Cb[0], ReturnType, Parameters[1]>; + + static create(hook: string): Cb { + const callbacks = new Callbacks(); + + return { + add: (callback, priority, id) => callbacks.add(hook as any, callback, priority, id), + remove: (id) => callbacks.remove(hook as any, id), + run: (item, constant) => callbacks.run(hook as any, item, constant) as any, + }; + } +} + +/** + * Callback hooks provide an easy way to add extra steps to common operations. + * @deprecated + */ +type Cb = { + add: ( + callback: (item: I, constant: C) => Promise | R | undefined | void, + priority?: CallbackPriority, + id?: string, + ) => void; + remove: (id: string) => void; + run: (item: I, constant?: C) => Promise; +}; diff --git a/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts b/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts new file mode 100644 index 0000000..97b4e3c --- /dev/null +++ b/apps/meteor/server/lib/callbacks/checkUsernameAvailabilityCallback.ts @@ -0,0 +1,17 @@ +import { Callbacks } from './callbacksBase'; + +/** + * Discriminates what kind of handle is being validated, so handlers can check the right + * namespace: `user` for a person's username, `room` for a room/team name. Rooms, teams + * and users share one local handle namespace. + */ +export type UsernameAvailabilityCheckType = 'user' | 'room'; + +/** + * Runs while validating whether a handle may be used (user creation, SSO/LDAP assignment and + * renames, plus room renames and team creation, all funnel through `checkUsernameAvailability`). + * Handlers should throw to reject the handle. The `type` tells the handler whether a user or + * a room/team is being validated. With no handler registered this is a no-op. + */ +export const checkUsernameAvailabilityCallback = + Callbacks.create<(username: string, type: UsernameAvailabilityCheckType) => void>('checkUsernameAvailability'); diff --git a/apps/meteor/server/lib/cas/createNewUser.ts b/apps/meteor/server/lib/cas/createNewUser.ts new file mode 100644 index 0000000..b3cf5f7 --- /dev/null +++ b/apps/meteor/server/lib/cas/createNewUser.ts @@ -0,0 +1,63 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { logger } from './logger'; +import { createRoom } from '../rooms/createRoom'; + +type CASUserOptions = { + attributes: Record; + casVersion: number; + flagEmailAsVerified: boolean; +}; + +export const createNewUser = async (username: string, { attributes, casVersion, flagEmailAsVerified }: CASUserOptions): Promise => { + // Define new user + const newUser = { + username: attributes.username || username, + active: true, + globalRoles: ['user'], + emails: [attributes.email] + .filter((e) => e) + .map((address) => ({ + address, + verified: flagEmailAsVerified, + })), + services: { + cas: { + external_id: username, + version: casVersion, + attrs: attributes, + }, + }, + ...pick(attributes, 'name'), + }; + + // Create the user + logger.debug({ msg: 'User does not exist yet, creating it', username }); + const userId = await Accounts.insertUserDoc({}, newUser); + + // Fetch and use it + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Unexpected error: Unable to find user after its creation.'); + } + + logger.debug({ msg: 'Created new user', username, userId: user._id }); + + logger.debug({ msg: 'Joining user to attribute channels', rooms: attributes.rooms }); + if (attributes.rooms) { + const roomNames = attributes.rooms.split(','); + for await (const roomName of roomNames) { + if (roomName) { + let room = await Rooms.findOneByNameAndType(roomName, 'c'); + if (!room) { + room = await createRoom('c', roomName, user); + } + } + } + } + + return user; +}; diff --git a/apps/meteor/server/lib/cas/findExistingCASUser.ts b/apps/meteor/server/lib/cas/findExistingCASUser.ts new file mode 100644 index 0000000..9a041ad --- /dev/null +++ b/apps/meteor/server/lib/cas/findExistingCASUser.ts @@ -0,0 +1,21 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../settings'; + +export const findExistingCASUser = async (username: string): Promise => { + const casUser = await Users.findOne({ 'services.cas.external_id': username }); + if (casUser) { + return casUser; + } + + if (!settings.get('CAS_trust_username')) { + return; + } + + // If that user was not found, check if there's any Rocket.Chat user with that username + // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. + // It'll also allow non-CAS users to switch to CAS based login + // Update the user's external_id to reflect this new username. + return (await Users.setCasExternalIdByUsername(username)) ?? undefined; +}; diff --git a/apps/meteor/server/lib/cas/logger.ts b/apps/meteor/server/lib/cas/logger.ts new file mode 100644 index 0000000..c2b4abe --- /dev/null +++ b/apps/meteor/server/lib/cas/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('CAS'); diff --git a/apps/meteor/server/lib/cas/loginHandler.spec.ts b/apps/meteor/server/lib/cas/loginHandler.spec.ts new file mode 100644 index 0000000..c5c4154 --- /dev/null +++ b/apps/meteor/server/lib/cas/loginHandler.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const removeNotExpiredById = sinon.stub().resolves(null); +const findExistingCASUser = sinon.stub().resolves(null); +const settingsGet = sinon.stub().returns(true); + +const { loginHandlerCAS: handler } = proxyquire.noCallThru().load('./loginHandler', { + '@rocket.chat/models': { + CredentialTokens: { removeNotExpiredById }, + Users: { updateOne: sinon.stub().resolves() }, + }, + 'meteor/accounts-base': { + Accounts: { + LoginCancelledError: { numericError: 403 }, + }, + }, + 'meteor/meteor': { + Meteor: { Error }, + }, + './createNewUser': { createNewUser: sinon.stub().resolves({ _id: 'newUserId' }) }, + './findExistingCASUser': { findExistingCASUser }, + './logger': { logger: { debug: sinon.stub(), error: sinon.stub() } }, + '../users/setRealName': { setRealName: sinon.stub().resolves() }, + '../../settings': { settings: { get: settingsGet } }, +}); + +describe('loginHandlerCAS', () => { + beforeEach(() => { + removeNotExpiredById.reset(); + removeNotExpiredById.resolves(null); + findExistingCASUser.reset(); + settingsGet.reset(); + settingsGet.returns(true); + }); + + it('should reject non-string credentialToken and never query the database (NoSQL injection prevention)', async () => { + expect(await handler({ cas: { credentialToken: { $gt: '' } } })).to.be.undefined; + expect(await handler({ cas: { credentialToken: { $ne: null } } })).to.be.undefined; + expect(await handler({ cas: { credentialToken: 123 } })).to.be.undefined; + expect(await handler({ cas: { credentialToken: ['a'] } })).to.be.undefined; + expect(await handler({ cas: { credentialToken: null } })).to.be.undefined; + + expect(removeNotExpiredById.called).to.be.false; + }); + + it('should consume the credential token and reject when no matching login attempt is found', async () => { + await expect(handler({ cas: { credentialToken: 'valid-token' } })).to.be.rejected; + expect(removeNotExpiredById.calledOnceWith('valid-token')).to.be.true; + }); + + it('should return undefined when CAS is disabled', async () => { + settingsGet.returns(false); + + expect(await handler({ cas: { credentialToken: 'valid-token' } })).to.be.undefined; + expect(removeNotExpiredById.called).to.be.false; + }); +}); diff --git a/apps/meteor/server/lib/cas/loginHandler.ts b/apps/meteor/server/lib/cas/loginHandler.ts new file mode 100644 index 0000000..12fb913 --- /dev/null +++ b/apps/meteor/server/lib/cas/loginHandler.ts @@ -0,0 +1,121 @@ +import { CredentialTokens, Users } from '@rocket.chat/models'; +import { getObjectKeys, wrapExceptions } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { createNewUser } from './createNewUser'; +import { findExistingCASUser } from './findExistingCASUser'; +import { logger } from './logger'; +import { settings } from '../../settings'; +import { setRealName } from '../users/setRealName'; + +export const loginHandlerCAS = async (options: any): Promise => { + if (!settings.get('CAS_enabled') || !options.cas || typeof options.cas.credentialToken !== 'string') { + return undefined; + } + + const credentials = await CredentialTokens.removeNotExpiredById(options.cas.credentialToken); + if (credentials === undefined || credentials === null) { + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); + } + + const result = credentials.userInfo; + const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); + const casVersion = parseFloat(settings.get('CAS_version') ?? '1.0'); + const syncEnabled = settings.get('CAS_Sync_User_Data_Enabled'); + const flagEmailAsVerified = settings.get('Accounts_Verify_Email_For_External_Accounts'); + const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); + + const { username, attributes: credentialsAttributes } = result as { username: string; attributes: Record }; + + // We have these + const externalAttributes: Record = { + username, + }; + + // We need these + const internalAttributes: Record = { + email: undefined, + name: undefined, + username: undefined, + rooms: undefined, + }; + + // Import response attributes + if (casVersion >= 2.0) { + // Clean & import external attributes + for await (const [externalName, value] of Object.entries(credentialsAttributes)) { + if (value) { + externalAttributes[externalName] = value[0]; + } + } + } + + // Source internal attributes + if (syncUserDataFieldMap) { + // Our mapping table: key(int_attr) -> value(ext_attr) + // Spoken: Source this internal attribute from these external attributes + const attributeMap = wrapExceptions(() => JSON.parse(syncUserDataFieldMap) as Record).catch((err) => { + logger.error({ msg: 'Invalid JSON for attribute mapping', err }); + throw err; + }); + + for await (const [internalName, source] of Object.entries(attributeMap)) { + if (!source || typeof source.valueOf() !== 'string') { + continue; + } + + let replacedValue = source as string; + for await (const externalName of getObjectKeys(externalAttributes)) { + replacedValue = replacedValue.replace(`%${externalName}%`, externalAttributes[externalName]); + } + + if (source !== replacedValue) { + internalAttributes[internalName] = replacedValue; + logger.debug({ msg: 'Sourced internal attribute', internalName, value: replacedValue }); + } else { + logger.debug({ msg: 'Sourced internal attribute skipped', internalName }); + } + } + } + + // Search existing user by its external service id + logger.debug({ msg: 'Looking up user by id', username }); + // First, look for a user that has logged in from CAS with this username before + const user = await findExistingCASUser(username); + + if (user) { + logger.debug({ msg: 'Using existing user', username, userId: user._id }); + if (syncEnabled) { + logger.debug('Syncing user attributes'); + // Update name + if (internalAttributes.name) { + await setRealName(user._id, internalAttributes.name); + } + + // Update email + if (internalAttributes.email) { + await Users.updateOne( + { _id: user._id }, + { $set: { emails: [{ address: internalAttributes.email, verified: flagEmailAsVerified }] } }, + ); + } + } + + return { userId: user._id }; + } + + if (!userCreationEnabled) { + // Should fail as no user exist and can't be created + logger.debug({ msg: 'User does not exist yet, will fail as no user creation is enabled', username }); + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); + } + + const newUser = await createNewUser(username, { + attributes: internalAttributes, + casVersion, + flagEmailAsVerified, + }); + + return { userId: newUser._id }; +}; diff --git a/apps/meteor/server/lib/cas/middleware.ts b/apps/meteor/server/lib/cas/middleware.ts new file mode 100644 index 0000000..13e9454 --- /dev/null +++ b/apps/meteor/server/lib/cas/middleware.ts @@ -0,0 +1,98 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import url from 'node:url'; + +import { validate } from '@rocket.chat/cas-validate'; +import type { ICredentialToken, RequiredField } from '@rocket.chat/core-typings'; +import { CredentialTokens } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { logger } from './logger'; +import { settings } from '../../settings'; + +const closePopup = function (res: ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'text/html' }); + const content = ''; + res.end(content, 'utf-8'); +}; + +type IncomingMessageWithUrl = RequiredField; + +const casTicket = function (req: IncomingMessageWithUrl, token: string, callback: () => void): void { + // get configuration + if (!settings.get('CAS_enabled')) { + logger.error('Got ticket validation request, but CAS is not enabled'); + callback(); + } + + // get ticket and validate. + const parsedUrl = url.parse(req.url, true); + const ticketId = parsedUrl.query.ticket as string; + const baseUrl = settings.get('CAS_base_url'); + const version = parseFloat(settings.get('CAS_version') ?? '1.0') as 1.0 | 2.0; + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + logger.debug({ msg: 'Using CAS_base_url', baseUrl }); + + validate( + { + base_url: baseUrl, + version, + service: `${appUrl}/_cas/${token}`, + }, + ticketId, + async (err, status, username, details) => { + if (err) { + logger.error({ msg: 'error when trying to validate', err }); + } else if (status) { + logger.info({ msg: 'Validated user', username }); + const userInfo: Partial = { username: username as string }; + + // CAS 2.0 attributes handling + if (details?.attributes) { + _.extend(userInfo, { attributes: details.attributes }); + } + await CredentialTokens.create(token, userInfo); + } else { + logger.error({ msg: 'Unable to validate ticket', ticketId }); + } + // logger.debug("Received response: " + JSON.stringify(details, null , 4)); + + callback(); + }, + ); +}; + +export const middlewareCAS = function (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + if (!req.url) { + throw new Error('Invalid request url'); + } + + const barePath = req.url.substring(0, req.url.indexOf('?')); + const splitPath = barePath.split('/'); + + // Any non-cas request will continue down the default + // middlewares. + if (splitPath[1] !== '_cas') { + next(); + return; + } + + // get auth token + const credentialToken = splitPath[2]; + if (!credentialToken) { + closePopup(res); + return; + } + + // validate ticket + casTicket(req as IncomingMessageWithUrl, credentialToken, () => { + closePopup(res); + }); + } catch (err) { + logger.error({ msg: 'Unexpected error', err }); + closePopup(res); + } +}; diff --git a/apps/meteor/server/lib/cas/updateCasService.ts b/apps/meteor/server/lib/cas/updateCasService.ts new file mode 100644 index 0000000..8cf8d83 --- /dev/null +++ b/apps/meteor/server/lib/cas/updateCasService.ts @@ -0,0 +1,30 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { logger } from './logger'; +import { settings } from '../../settings/cached'; + +export async function updateCasServices(): Promise { + const data: Partial = { + // These will pe passed to 'node-cas' as options + enabled: settings.get('CAS_enabled'), + base_url: settings.get('CAS_base_url'), + login_url: settings.get('CAS_login_url'), + // Rocketchat Visuals + buttonLabelText: settings.get('CAS_button_label_text'), + buttonLabelColor: settings.get('CAS_button_label_color'), + buttonColor: settings.get('CAS_button_color'), + width: settings.get('CAS_popup_width'), + height: settings.get('CAS_popup_height'), + autoclose: settings.get('CAS_autoclose'), + }; + + // Either register or deregister the CAS login service based upon its configuration + if (data.enabled) { + logger.info('Enabling CAS login service'); + await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); + } else { + logger.info('Disabling CAS login service'); + await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); + } +} diff --git a/apps/meteor/server/lib/cloud/buildRegistrationData.ts b/apps/meteor/server/lib/cloud/buildRegistrationData.ts new file mode 100644 index 0000000..90f0761 --- /dev/null +++ b/apps/meteor/server/lib/cloud/buildRegistrationData.ts @@ -0,0 +1,110 @@ +import { LivechatContacts, Statistics, Users } from '@rocket.chat/models'; +import moment from 'moment'; + +import { LICENSE_VERSION } from './license'; +import { Info } from '../../../app/utils/rocketchat.info'; +import { settings } from '../../settings'; +import { statistics } from '../statistics'; + +export type WorkspaceRegistrationData = { + uniqueId: string; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; + workspaceId: string; + address: string; + contactName: string; + contactEmail: T; + seats: number; + + organizationType: string; + industry: string; + orgSize: string; + country: string; + language: string; + allowMarketing: string; + accountName: string; + agreePrivacyTerms: string; + website: string; + siteName: string; + workspaceType: unknown; + deploymentMethod: string; + deploymentPlatform: string; + version: string; + licenseVersion: number; + license?: string; + enterpriseReady: boolean; + setupComplete: boolean; + connectionDisable: boolean; + npsEnabled: string; + // TODO: Evaluate naming + MAC: number; + // activeContactsBillingMonth: number; + // activeContactsYesterday: number; + statsToken?: string; +}; + +export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { + const stats = (await Statistics.findLast()) || (await statistics.get()); + + const address = settings.get('Site_Url'); + const siteName = settings.get('Site_Name'); + const workspaceId = settings.get('Cloud_Workspace_Id'); + const allowMarketing = settings.get('Allow_Marketing_Emails'); + const accountName = settings.get('Organization_Name'); + const website = settings.get('Website'); + const npsEnabled = settings.get('NPS_survey_enabled'); + const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); + const setupWizardState = settings.get('Show_Setup_Wizard'); + const deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + const deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); + + const firstUser = await Users.getOldest({ projection: { name: 1, emails: 1 } }); + const contactName = firstUser?.name || ''; + + const country = settings.get('Country'); + const language = settings.get('Language'); + const organizationType = settings.get('Organization_Type'); + const industry = settings.get('Industry'); + const orgSize = settings.get('Size'); + const workspaceType = settings.get('Server_Type'); + + const seats = await Users.getActiveLocalUserCount(); + const MAC = await LivechatContacts.countContactsOnPeriod(moment.utc().format('YYYY-MM')); + + const license = settings.get('Enterprise_License'); + + return { + uniqueId: stats.uniqueId, + deploymentFingerprintHash, + deploymentFingerprintVerified, + workspaceId, + address, + contactName, + contactEmail, + seats, + allowMarketing, + accountName, + organizationType: String(organizationType), + industry: String(industry), + orgSize: String(orgSize), + country: String(country), + language: String(language), + agreePrivacyTerms, + website, + siteName, + workspaceType: String(workspaceType), + deploymentMethod: stats.deploy.method, + deploymentPlatform: stats.deploy.platform, + version: stats.version ?? Info.version, + licenseVersion: LICENSE_VERSION, + ...(license && { license }), + enterpriseReady: true, + setupComplete: setupWizardState === 'completed', + connectionDisable: false, + npsEnabled, + MAC, + // activeContactsBillingMonth: stats.omnichannelContactsBySource.contactsCount, + // activeContactsYesterday: stats.uniqueContactsOfYesterday.contactsCount, + statsToken: stats.statsToken, + }; +} diff --git a/apps/meteor/server/lib/cloud/checkUserHasCloudLogin.ts b/apps/meteor/server/lib/cloud/checkUserHasCloudLogin.ts new file mode 100644 index 0000000..a17c71d --- /dev/null +++ b/apps/meteor/server/lib/cloud/checkUserHasCloudLogin.ts @@ -0,0 +1,23 @@ +import { Users } from '@rocket.chat/models'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; + +export async function checkUserHasCloudLogin(userId: string) { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + if (!workspaceRegistered) { + return false; + } + + if (!userId) { + return false; + } + + const user = await Users.findOneById(userId); + + if (user?.services?.cloud?.accessToken) { + return true; + } + + return false; +} diff --git a/apps/meteor/server/lib/cloud/connectWorkspace.ts b/apps/meteor/server/lib/cloud/connectWorkspace.ts new file mode 100644 index 0000000..c8f62bb --- /dev/null +++ b/apps/meteor/server/lib/cloud/connectWorkspace.ts @@ -0,0 +1,84 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { getRedirectUri } from './getRedirectUri'; +import { assertNotOfflineLicense } from './offlineLicense'; +import { saveRegistrationData } from './saveRegistrationData'; +import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +const fetchRegistrationDataPayload = async ({ + token, + body, +}: { + token: string; + body: { + email: string; + client_name: string; + redirect_uris: string[]; + }; +}) => { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/clients`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + if (!payload) { + return undefined; + } + + return payload; +}; + +export async function connectWorkspace(token: string) { + assertNotOfflineLicense(); + + if (!token) { + throw new CloudWorkspaceConnectionError('Invalid registration token'); + } + + try { + const redirectUri = getRedirectUri(); + + const body = { + email: settings.get('Organization_Email'), + client_name: settings.get('Site_Name'), + redirect_uris: [redirectUri], + }; + + const payload = await fetchRegistrationDataPayload({ token, body }); + + if (!payload) { + return false; + } + + await saveRegistrationData(payload); + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to Connect with Rocket.Chat Cloud', + url: '/api/oauth/clients', + err, + }); + + return false; + } +} diff --git a/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts new file mode 100644 index 0000000..cd0e1d3 --- /dev/null +++ b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts @@ -0,0 +1,84 @@ +import { Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Meteor } from 'meteor/meteor'; + +import { getRedirectUri } from './getRedirectUri'; +import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export async function finishOAuthAuthorization(code: string, state: string) { + assertNotOfflineLicense(); + + if (settings.get('Cloud_Workspace_Registration_State') !== state) { + throw new Meteor.Error('error-invalid-state', 'Invalid state provided', { + method: 'cloud:finishOAuthAuthorization', + }); + } + + const clientId = settings.get('Cloud_Workspace_Client_Id'); + const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); + + const scope = userScopes.join(' '); + + let payload; + try { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + scope, + code, + redirect_uri: getRedirectUri(), + }), + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error((await response.json()).error); + } + + payload = await response.json(); + } catch (err) { + SystemLogger.error({ + msg: 'Failed to finish OAuth authorization with Rocket.Chat Cloud', + url: '/api/oauth/token', + err, + }); + + return false; + } + + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:finishOAuthAuthorization', + }); + } + + await Users.updateOne( + { _id: uid }, + { + $set: { + 'services.cloud': { + accessToken: payload.access_token, + expiresAt, + scope: payload.scope, + tokenType: payload.token_type, + refreshToken: payload.refresh_token, + }, + }, + }, + ); + + return true; +} diff --git a/apps/meteor/server/lib/cloud/getCheckoutUrl.ts b/apps/meteor/server/lib/cloud/getCheckoutUrl.ts new file mode 100644 index 0000000..6f1d3b1 --- /dev/null +++ b/apps/meteor/server/lib/cloud/getCheckoutUrl.ts @@ -0,0 +1,56 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { getWorkspaceAccessTokenOrThrow } from './getWorkspaceAccessToken'; +import { syncWorkspace } from './syncWorkspace'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; +import { getURL } from '../utils/getURL'; + +export const fallback = 'https://go.rocket.chat/i/contact-sales'; + +export const getCheckoutUrl = async (): Promise<{ + url: string; +}> => { + try { + await syncWorkspace(); + + const token = await getWorkspaceAccessTokenOrThrow(true, 'workspace:billing', false); + + const subscriptionURL = getURL('admin/subscription', { + full: true, + }); + + const body = { + okCallback: `${subscriptionURL}?subscriptionSuccess=true`, + cancelCallback: subscriptionURL, + }; + + const billingUrl = settings.get('Cloud_Billing_Url'); + + const response = await fetch(`${billingUrl}/api/v2/checkout`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error(await response.json()); + } + + return response.json(); + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to get Checkout URL with Rocket.Chat Billing Service', + url: '/api/v2/checkout', + err, + }); + + return { + url: fallback, + }; + } +}; diff --git a/apps/meteor/server/lib/cloud/getConfirmationPoll.ts b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts new file mode 100644 index 0000000..1c965f5 --- /dev/null +++ b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts @@ -0,0 +1,37 @@ +import type { CloudConfirmationPollData } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { assertNotOfflineLicense } from './offlineLicense'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export async function getConfirmationPoll(deviceCode: string): Promise { + assertNotOfflineLicense(); + + try { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { + params: { token: deviceCode }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + try { + if (!response.ok) { + throw new Error((await response.json()).error); + } + + return await response.json(); + } catch (err) { + throw new Error(`Failed to retrieve registration confirmation poll data: ${response.statusText}`); + } + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to get confirmation poll from Rocket.Chat Cloud', + url: '/api/v2/register/workspace/poll', + err, + }); + + throw err; + } +} diff --git a/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts new file mode 100644 index 0000000..38ebac8 --- /dev/null +++ b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts @@ -0,0 +1,29 @@ +import { Settings } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { getRedirectUri } from './getRedirectUri'; +import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; +import { settings } from '../../settings'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; +import { notifyOnSettingChangedById } from '../notifyListener'; + +export async function getOAuthAuthorizationUrl() { + assertNotOfflineLicense(); + + const state = Random.id(); + + await updateAuditedBySystem({ + reason: 'getOAuthAuthorizationUrl', + })(Settings.updateValueById, 'Cloud_Workspace_Registration_State', state); + + void notifyOnSettingChangedById('Cloud_Workspace_Registration_State'); + + const cloudUrl = settings.get('Cloud_Url'); + const clientId = settings.get('Cloud_Workspace_Client_Id'); + const redirectUri = getRedirectUri(); + + const scope = userScopes.join(' '); + + return `${cloudUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`; +} diff --git a/apps/meteor/server/lib/cloud/getRedirectUri.ts b/apps/meteor/server/lib/cloud/getRedirectUri.ts new file mode 100644 index 0000000..5465960 --- /dev/null +++ b/apps/meteor/server/lib/cloud/getRedirectUri.ts @@ -0,0 +1,5 @@ +import { settings } from '../../settings'; + +export function getRedirectUri() { + return `${settings.get('Site_Url')}/admin/cloud/oauth-callback`.replace(/\/\/admin+/g, '/admin'); +} diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts new file mode 100644 index 0000000..e49913b --- /dev/null +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts @@ -0,0 +1,122 @@ +import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { WorkspaceCredentials } from '@rocket.chat/models'; + +import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; +import { workspaceScopes } from './oauthScopes'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { SystemLogger } from '../logger/system'; + +const hasWorkspaceAccessTokenExpired = (credentials: IWorkspaceCredentials): boolean => new Date() >= credentials.expirationDate; + +/** + * Returns the access token for the workspace, if it is expired or forceNew is true, it will get a new one + * and save it to the database, therefore if this function does not throw an error, it will always return a valid token. + * + * @param {boolean} forceNew - If true, it will get a new token even if the current one is not expired + * @param {string} scope - The scope of the token to get + * @param {boolean} save - If true, it will save the new token to the database + * @throws {CloudWorkspaceAccessTokenError} If the workspace is not registered (no credentials in the database) + * + * @returns string - A valid access token for the workspace + */ +export async function getWorkspaceAccessToken(forceNew = false, scope = '', save = true, throwOnError = false): Promise { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + if (!workspaceRegistered) { + return ''; + } + + if (License.hasOfflineLicense()) { + return ''; + } + + // Note: If no scope is given, it means we should assume the default scope, we store the default scopes + // in the global variable workspaceScopes. + if (scope === '') { + scope = workspaceScopes.join(' '); + } + + const workspaceCredentials = await WorkspaceCredentials.getCredentialByScope(scope); + if (workspaceCredentials && !hasWorkspaceAccessTokenExpired(workspaceCredentials) && !forceNew) { + SystemLogger.debug({ + msg: 'Workspace credentials cache hit. Avoiding generating a new access token from cloud services.', + scope, + }); + return workspaceCredentials.accessToken; + } + + SystemLogger.debug({ + msg: 'Workspace credentials cache miss, fetching new access token from cloud services.', + scope, + }); + + const accessToken = await getWorkspaceAccessTokenWithScope({ scope, throwOnError }); + + if (save) { + await WorkspaceCredentials.updateCredentialByScope({ + scope: accessToken.scope, + accessToken: accessToken.token, + expirationDate: accessToken.expiresAt, + }); + } + + return accessToken.token; +} + +export class CloudWorkspaceAccessTokenError extends Error { + constructor() { + super('Could not get workspace access token'); + } +} + +export const isAbortError = (error: unknown): error is { type: 'AbortError' } => { + if (typeof error !== 'object' || error === null) { + return false; + } + + return 'type' in error && error.type === 'AbortError'; +}; + +export class CloudWorkspaceAccessTokenEmptyError extends Error { + constructor() { + super('Workspace access token is empty'); + } +} + +export async function getWorkspaceAccessTokenOrThrow(forceNew = false, scope = '', save = true): Promise { + const token = await getWorkspaceAccessToken(forceNew, scope, save, true); + + if (!token) { + throw new CloudWorkspaceAccessTokenEmptyError(); + } + + return token; +} + +export const generateWorkspaceBearerHttpHeaderOrThrow = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string }> => { + const token = await getWorkspaceAccessTokenOrThrow(forceNew, scope, save); + return { + Authorization: `Bearer ${token}`, + }; +}; + +export const generateWorkspaceBearerHttpHeader = async ( + forceNew = false, + scope = '', + save = true, +): Promise<{ Authorization: string } | undefined> => { + const token = await getWorkspaceAccessToken(forceNew, scope, save); + + if (!token) { + return undefined; + } + + return { + Authorization: `Bearer ${token}`, + }; +}; diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts new file mode 100644 index 0000000..bf8693e --- /dev/null +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts @@ -0,0 +1,98 @@ +import { License } from '@rocket.chat/license'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { getRedirectUri } from './getRedirectUri'; +import { CloudWorkspaceAccessTokenError } from './getWorkspaceAccessToken'; +import { workspaceScopes } from './oauthScopes'; +import { removeWorkspaceRegistrationInfo } from './removeWorkspaceRegistrationInfo'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +type WorkspaceAccessTokenWithScope = { + token: string; + expiresAt: Date; + scope: string; +}; + +type GetWorkspaceAccessTokenWithScopeParams = { + scope?: string; + throwOnError?: boolean; +}; + +export async function getWorkspaceAccessTokenWithScope({ + scope = '', + throwOnError = false, +}: GetWorkspaceAccessTokenWithScopeParams): Promise { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + const tokenResponse = { token: '', expiresAt: new Date(), scope: '' }; + + if (!workspaceRegistered) { + return tokenResponse; + } + + if (License.hasOfflineLicense()) { + return tokenResponse; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + const client_id = settings.get('Cloud_Workspace_Client_Id'); + if (!client_id) { + return tokenResponse; + } + + if (scope === '') { + scope = workspaceScopes.join(' '); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + const client_secret = settings.get('Cloud_Workspace_Client_Secret'); + const redirectUri = getRedirectUri(); + + let payload; + try { + const body = new URLSearchParams(); + body.append('client_id', client_id); + body.append('client_secret', client_secret); + body.append('scope', scope); + body.append('grant_type', 'client_credentials'); + body.append('redirect_uri', redirectUri); + + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', + body, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + timeout: 5000, + }); + + payload = await response.json(); + + if (response.status >= 400) { + if (payload.error === 'oauth_invalid_client_credentials') { + throw new CloudWorkspaceAccessTokenError(); + } + } + + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); + + return { + token: payload.access_token, + expiresAt, + scope: payload.scope, + }; + } catch (err: any) { + if (err instanceof CloudWorkspaceAccessTokenError) { + SystemLogger.error('Server has been unregistered from cloud'); + void removeWorkspaceRegistrationInfo(); + if (throwOnError) { + throw err; + } + } + } + return tokenResponse; +} diff --git a/apps/meteor/server/lib/cloud/getWorkspaceKey.ts b/apps/meteor/server/lib/cloud/getWorkspaceKey.ts new file mode 100644 index 0000000..f387668 --- /dev/null +++ b/apps/meteor/server/lib/cloud/getWorkspaceKey.ts @@ -0,0 +1,18 @@ +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { settings } from '../../settings'; + +export async function getWorkspaceKey() { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + if (!workspaceRegistered) { + return false; + } + + const publicKey = settings.get('Cloud_Workspace_PublicKey'); + + if (!publicKey) { + return false; + } + + return publicKey; +} diff --git a/apps/meteor/server/lib/cloud/getWorkspaceLicense.ts b/apps/meteor/server/lib/cloud/getWorkspaceLicense.ts new file mode 100644 index 0000000..e793c9e --- /dev/null +++ b/apps/meteor/server/lib/cloud/getWorkspaceLicense.ts @@ -0,0 +1,78 @@ +import { Cloud } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import * as z from 'zod'; + +import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; +import { LICENSE_VERSION } from './license'; +import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceLicenseError } from '../../../lib/errors/CloudWorkspaceLicenseError'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { SystemLogger } from '../logger/system'; + +const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): Promise => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/license`, { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + version: LICENSE_VERSION, + }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + const result = Cloud.WorkspaceLicensePayloadSchema.safeParse(payload); + + if (!result.success) { + throw new CloudWorkspaceLicenseError('failed type validation', { + cause: z.prettifyError(result.error), + }); + } + + return result.data; +}; + +export async function getWorkspaceLicense() { + const currentLicense = await Settings.findOne('Cloud_Workspace_License'); + // it should never happen, since even if the license is not found, it will return an empty settings + + if (!currentLicense?._updatedAt) { + throw new CloudWorkspaceLicenseError('Failed to retrieve current license'); + } + + try { + const token = await getWorkspaceAccessToken(); + if (!token) { + return; + } + + const payload = await fetchCloudWorkspaceLicensePayload({ token }); + + if (currentLicense.value && payload.updatedAt.getTime() <= currentLicense._updatedAt.getTime()) { + return; + } + await callbacks.run('workspaceLicenseChanged', payload.license); + + return { updated: true, license: payload.license }; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to update license from Rocket.Chat Cloud', + url: '/license', + err, + }); + } +} diff --git a/apps/meteor/server/lib/cloud/index.ts b/apps/meteor/server/lib/cloud/index.ts new file mode 100644 index 0000000..9650996 --- /dev/null +++ b/apps/meteor/server/lib/cloud/index.ts @@ -0,0 +1,59 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { Meteor } from 'meteor/meteor'; + +import { connectWorkspace } from './connectWorkspace'; +import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from './getWorkspaceAccessToken'; +import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { syncWorkspace } from './syncWorkspace'; +import { SystemLogger } from '../logger/system'; + +const licenseCronName = 'Cloud Workspace Sync'; + +Meteor.startup(async () => { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + if (process.env.REG_TOKEN && process.env.REG_TOKEN !== '' && !workspaceRegistered) { + try { + SystemLogger.info('REG_TOKEN Provided. Attempting to register'); + + if (!(await connectWorkspace(process.env.REG_TOKEN))) { + throw new Error("Couldn't register with token. Please make sure token is valid or hasn't already been used"); + } + + SystemLogger.info('Successfully registered with token provided by REG_TOKEN!'); + } catch (err: any) { + SystemLogger.error({ msg: 'An error occurred registering with token.', err }); + } + } + + setImmediate(async () => { + try { + await syncWorkspace(); + } catch (err: any) { + if (err instanceof CloudWorkspaceAccessTokenEmptyError) { + return; + } + if (err.type && err.type === 'AbortError') { + return; + } + SystemLogger.error({ msg: 'An error occurred syncing workspace.', err }); + } + }); + const minute = Math.floor(Math.random() * 60); + await cronJobs.add(licenseCronName, `${minute} */12 * * *`, async () => { + try { + await syncWorkspace(); + } catch (err: any) { + if (err instanceof CloudWorkspaceAccessTokenEmptyError) { + return; + } + if (err.type && err.type === 'AbortError') { + return; + } + SystemLogger.error({ msg: 'An error occurred syncing workspace.', err }); + } + }); +}); + +export { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope }; diff --git a/apps/meteor/server/lib/cloud/license.ts b/apps/meteor/server/lib/cloud/license.ts new file mode 100644 index 0000000..49c03c5 --- /dev/null +++ b/apps/meteor/server/lib/cloud/license.ts @@ -0,0 +1 @@ +export const LICENSE_VERSION = 3 as const; diff --git a/apps/meteor/server/lib/cloud/license/airGappedRestrictionsWrapper.ts b/apps/meteor/server/lib/cloud/license/airGappedRestrictionsWrapper.ts new file mode 100644 index 0000000..f6f05c0 --- /dev/null +++ b/apps/meteor/server/lib/cloud/license/airGappedRestrictionsWrapper.ts @@ -0,0 +1,5 @@ +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const applyAirGappedRestrictionsValidation = makeFunction(async (fn: () => Promise): Promise => { + return fn(); +}); diff --git a/apps/meteor/server/lib/cloud/oauthScopes.ts b/apps/meteor/server/lib/cloud/oauthScopes.ts new file mode 100644 index 0000000..d0b6965 --- /dev/null +++ b/apps/meteor/server/lib/cloud/oauthScopes.ts @@ -0,0 +1,13 @@ +// These are the scopes we by default request access to +export const workspaceScopes = [ + 'workspace:license:read', + 'workspace:client:write', + 'workspace:stats:write', + 'workspace:push:send', + 'marketplace:read', + 'marketplace:download', + 'fedhub:register', +] as const; + +// These are the scopes we use for the user +export const userScopes = ['openid', 'offline_access'] as const; diff --git a/apps/meteor/server/lib/cloud/offlineLicense.ts b/apps/meteor/server/lib/cloud/offlineLicense.ts new file mode 100644 index 0000000..7a3b5f5 --- /dev/null +++ b/apps/meteor/server/lib/cloud/offlineLicense.ts @@ -0,0 +1,13 @@ +import { License } from '@rocket.chat/license'; + +import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; + +/** + * Guard for interactive cloud flows (registration, OAuth, billing). Background + * jobs should instead skip silently by checking {@link License.hasOfflineLicense}. + */ +export function assertNotOfflineLicense(): void { + if (License.hasOfflineLicense()) { + throw new CloudOfflineLicenseError('Cloud connectivity is disabled by the offline license applied to this workspace'); + } +} diff --git a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts new file mode 100644 index 0000000..b85311f --- /dev/null +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -0,0 +1,53 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export async function registerPreIntentWorkspaceWizard(): Promise { + if (License.hasOfflineLicense()) { + return false; + } + + const firstUser = (await Users.getOldest({ projection: { name: 1, emails: 1 } })) as IUser | undefined; + const email = firstUser?.emails?.find((address) => address)?.address; + + if (!email) { + return false; + } + + const regInfo = await buildWorkspaceRegistrationData(email); + + // Re-validated at dispatch time: an offline license applied while the + // registration data was being built must still suppress the request. + if (License.hasOfflineLicense()) { + return false; + } + + try { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { + method: 'POST', + body: regInfo, + timeout: 3 * 1000, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + if (!response.ok) { + throw new Error((await response.json()).error); + } + + return true; + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to register workspace pre-intent with Rocket.Chat Cloud', + url: '/api/v2/register/workspace/pre-intent', + err, + }); + + return false; + } +} diff --git a/apps/meteor/server/lib/cloud/removeLicense.ts b/apps/meteor/server/lib/cloud/removeLicense.ts new file mode 100644 index 0000000..9788809 --- /dev/null +++ b/apps/meteor/server/lib/cloud/removeLicense.ts @@ -0,0 +1,54 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from './getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { syncWorkspace } from './syncWorkspace'; +import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../lib/errors/CloudWorkspaceRegistrationError'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; + +export async function removeLicense() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessTokenEmptyError(); + } + + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/client/downgrade`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + await syncWorkspace(); + } catch (err) { + switch (true) { + case err instanceof CloudWorkspaceConnectionError: + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof CloudWorkspaceAccessTokenEmptyError: + await callbacks.run('workspaceLicenseRemoved'); + break; + default: + throw err; + } + } +} diff --git a/apps/meteor/server/lib/cloud/removeWorkspaceRegistrationInfo.ts b/apps/meteor/server/lib/cloud/removeWorkspaceRegistrationInfo.ts new file mode 100644 index 0000000..22f8218 --- /dev/null +++ b/apps/meteor/server/lib/cloud/removeWorkspaceRegistrationInfo.ts @@ -0,0 +1,43 @@ +import { Settings, WorkspaceCredentials } from '@rocket.chat/models'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; +import { notifyOnSettingChangedById } from '../notifyListener'; + +export async function removeWorkspaceRegistrationInfo() { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + return true; + } + + await WorkspaceCredentials.removeAllCredentials(); + + const settingsIds = [ + 'Cloud_Workspace_Id', + 'Cloud_Workspace_Name', + 'Cloud_Workspace_Client_Id', + 'Cloud_Workspace_Client_Secret', + 'Cloud_Workspace_Client_Secret_Expires_At', + 'Cloud_Workspace_PublicKey', + 'Cloud_Workspace_Registration_Client_Uri', + 'Show_Setup_Wizard', + ]; + + const promises = settingsIds.map((settingId) => { + if (settingId === 'Show_Setup_Wizard') { + return updateAuditedBySystem({ + reason: 'removeWorkspaceRegistrationInfo', + })(Settings.updateValueById, 'Show_Setup_Wizard', 'in_progress'); + } + + return updateAuditedBySystem({ reason: 'removeWorkspaceRegistrationInfo' })(Settings.resetValueById, settingId, null); + }); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]); + } + }); + + return true; +} diff --git a/apps/meteor/server/lib/cloud/retrieveRegistrationStatus.ts b/apps/meteor/server/lib/cloud/retrieveRegistrationStatus.ts new file mode 100644 index 0000000..408fc1f --- /dev/null +++ b/apps/meteor/server/lib/cloud/retrieveRegistrationStatus.ts @@ -0,0 +1,26 @@ +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../settings'; + +export async function retrieveRegistrationStatus(): Promise<{ + workspaceRegistered: boolean; + workspaceId: string; + uniqueId: string; + token: string; + email: string; +}> { + const info = { + workspaceRegistered: !!settings.get('Cloud_Workspace_Client_Id') && !!settings.get('Cloud_Workspace_Client_Secret'), + workspaceId: settings.get('Cloud_Workspace_Id'), + uniqueId: settings.get('uniqueID'), + token: '', + email: settings.get('Organization_Email') || '', + }; + + if (!info.email) { + const firstUser = await Users.getOldest({ projection: { emails: 1 } }); + info.email = firstUser?.emails?.[0]?.address || info.email; + } + + return info; +} diff --git a/apps/meteor/server/lib/cloud/saveRegistrationData.ts b/apps/meteor/server/lib/cloud/saveRegistrationData.ts new file mode 100644 index 0000000..590d810 --- /dev/null +++ b/apps/meteor/server/lib/cloud/saveRegistrationData.ts @@ -0,0 +1,124 @@ +import { applyLicense } from '@rocket.chat/license'; +import { Settings } from '@rocket.chat/models'; + +import { settings } from '../../settings'; +import { syncCloudData } from './syncWorkspace/syncCloudData'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; +import { notifyOnSettingChangedById } from '../notifyListener'; + +type SaveRegistrationDataDTO = { + workspaceId: string; + client_name: string; + client_id: string; + client_secret: string; + client_secret_expires_at: number; + publicKey: string; + registration_client_uri: string; +}; + +type ManualSaveRegistrationDataDTO = SaveRegistrationDataDTO & { licenseData: { license: string } }; + +export async function saveRegistrationData({ + workspaceId, + client_name, + client_id, + client_secret, + client_secret_expires_at, + publicKey, + registration_client_uri, +}: SaveRegistrationDataDTO) { + await saveRegistrationDataBase({ + workspaceId, + client_name, + client_id, + client_secret, + client_secret_expires_at, + publicKey, + registration_client_uri, + }); + + await syncCloudData(); +} + +async function saveRegistrationDataBase({ + workspaceId, + client_name, + client_id, + client_secret, + client_secret_expires_at, + publicKey, + registration_client_uri, +}: SaveRegistrationDataDTO) { + const settingsData = [ + { _id: 'Register_Server', value: true }, + { _id: 'Cloud_Workspace_Id', value: workspaceId }, + { _id: 'Cloud_Workspace_Name', value: client_name }, + { _id: 'Cloud_Workspace_Client_Id', value: client_id }, + { _id: 'Cloud_Workspace_Client_Secret', value: client_secret }, + { _id: 'Cloud_Workspace_Client_Secret_Expires_At', value: client_secret_expires_at }, + { _id: 'Cloud_Workspace_PublicKey', value: publicKey }, + { _id: 'Cloud_Workspace_Registration_Client_Uri', value: registration_client_uri }, + ]; + + const promises = [ + ...settingsData.map(({ _id, value }) => + updateAuditedBySystem({ + reason: 'saveRegistrationDataBase', + })(Settings.updateValueById, _id, value), + ), + ]; + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsData[index]._id); + } + }); + + // Question: Why is this taking so long that needs a timeout? + // Answer: we use cache that requires a 'roundtrip' through the db and the application + // we need to make sure that the cache is updated before we continue the procedures + // we don't actually need to wait a whole second for this, but look this is just a retry mechanism it doesn't mean that actually takes all this time + for (const retry of Array.from({ length: 10 })) { + const isSettingsUpdated = + settings.get('Register_Server') === true && + settings.get('Cloud_Workspace_Id') === workspaceId && + settings.get('Cloud_Workspace_Name') === client_name && + settings.get('Cloud_Workspace_Client_Id') === client_id && + settings.get('Cloud_Workspace_Client_Secret') === client_secret && + settings.get('Cloud_Workspace_Client_Secret_Expires_At') === client_secret_expires_at && + settings.get('Cloud_Workspace_PublicKey') === publicKey && + settings.get('Cloud_Workspace_Registration_Client_Uri') === registration_client_uri; + + if (isSettingsUpdated) { + return; + } + + if (retry === 9) { + throw new Error('Failed to save registration data'); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +export async function saveRegistrationDataManual({ + workspaceId, + client_name, + client_id, + client_secret, + client_secret_expires_at, + publicKey, + registration_client_uri, + licenseData, +}: ManualSaveRegistrationDataDTO) { + await saveRegistrationDataBase({ + workspaceId, + client_name, + client_id, + client_secret, + client_secret_expires_at, + publicKey, + registration_client_uri, + }); + await applyLicense(licenseData.license, true); +} diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts new file mode 100644 index 0000000..399c54c --- /dev/null +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts @@ -0,0 +1,61 @@ +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { syncWorkspace } from './syncWorkspace'; +import { settings } from '../../settings'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; +import { SystemLogger } from '../logger/system'; +import { notifyOnSettingChangedById } from '../notifyListener'; + +export async function startRegisterWorkspace(resend = false) { + assertNotOfflineLicense(); + + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (workspaceRegistered || process.env.TEST_MODE) { + await syncWorkspace(); + + return true; + } + (await Settings.updateValueById('Register_Server', true)).modifiedCount && void notifyOnSettingChangedById('Register_Server'); + + const regInfo = await buildWorkspaceRegistrationData(undefined); + + let payload; + try { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace`, { + method: 'POST', + body: regInfo, + params: { + resend, + }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + if (!response.ok) { + throw new Error((await response.json()).error); + } + + payload = await response.json(); + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to register with Rocket.Chat Cloud', + url: '/api/v2/register/workspace', + err, + }); + + return false; + } + if (!payload) { + return false; + } + + await updateAuditedBySystem({ + reason: 'startRegisterWorkspace', + })(Settings.updateValueById, 'Cloud_Workspace_Id', payload.id); + + return true; +} diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts new file mode 100644 index 0000000..9e461af --- /dev/null +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts @@ -0,0 +1,46 @@ +import type { CloudRegistrationIntentData } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise { + assertNotOfflineLicense(); + + const regInfo = await buildWorkspaceRegistrationData(email); + + let payload; + try { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/intent`, { + body: regInfo, + method: 'POST', + params: { + resent: resend, + }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + if (!response.ok) { + throw new Error((await response.json()).error); + } + + payload = await response.json(); + } catch (err: any) { + SystemLogger.error({ + msg: 'Failed to register workspace intent with Rocket.Chat Cloud', + url: '/api/v2/register/workspace', + err, + }); + + throw err; + } + + if (!payload) { + throw new Error('Failed to fetch registration intent endpoint'); + } + + return payload; +} diff --git a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts new file mode 100644 index 0000000..183065f --- /dev/null +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.spec.ts @@ -0,0 +1,23 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; + +describe('supportedVersionsChooseLatest', () => { + test('should return the latest version', async () => { + const versionFromLicense: SignedSupportedVersions = { + signed: 'signed____', + timestamp: '2021-08-31T18:00:00.000Z', + versions: [], + }; + + const versionFromCloud: SignedSupportedVersions = { + signed: 'signed_------', + timestamp: '2021-08-31T19:00:00.000Z', + versions: [], + }; + + const result = await supportedVersionsChooseLatest(versionFromLicense, versionFromCloud); + + expect(result.timestamp).toBe(versionFromCloud.timestamp); + }); +}); diff --git a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.ts new file mode 100644 index 0000000..32753ba --- /dev/null +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsChooseLatest.ts @@ -0,0 +1,9 @@ +import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; + +export const supportedVersionsChooseLatest = async (...tokens: (SignedSupportedVersions | undefined)[]) => { + const [token] = (tokens.filter((r) => r?.timestamp != null) as SignedSupportedVersions[]).sort((a, b) => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + }); + + return token; +}; diff --git a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts new file mode 100644 index 0000000..dd9118a --- /dev/null +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts @@ -0,0 +1,208 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Settings } from '@rocket.chat/models'; +import type { SignedSupportedVersions, SupportedVersions } from '@rocket.chat/server-cloud-communication'; +import type { Response } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; +import { supportedVersions as supportedVersionsFromBuild } from '../../../../app/utils/rocketchat-supported-versions.info'; +import { settings } from '../../../settings'; +import { updateAuditedBySystem } from '../../../settings/lib/auditedSettingUpdates'; +import { SystemLogger } from '../../logger/system'; +import { notifyOnSettingChangedById } from '../../notifyListener'; +import { generateWorkspaceBearerHttpHeader } from '../getWorkspaceAccessToken'; +import { buildVersionUpdateMessage } from '../version-check/functions/buildVersionUpdateMessage'; + +declare module '@rocket.chat/core-typings' { + interface ILicenseV3 { + supportedVersions?: SignedSupportedVersions; + } +} + +/** HELPERS */ + +export const wrapPromise = ( + promise: Promise, +): Promise< + | { + success: true; + result: T; + } + | { + success: false; + error: any; + } +> => + promise + .then((result) => ({ success: true, result }) as const) + .catch((error) => ({ + success: false, + error, + })); + +export const handleResponse = async (promise: Promise) => { + return wrapPromise( + (async () => { + const request = await promise; + if (!request.ok) { + if (request.size > 0) { + throw new Error((await request.json()).error); + } + throw new Error(request.statusText); + } + + return request.json(); + })(), + ); +}; + +const cacheValueInSettings = ( + key: string, + fn: (retry?: number) => Promise, +): (() => Promise) & { + reset: (retry?: number) => Promise; +} => { + const reset = async (retry?: number) => { + SystemLogger.debug({ + msg: 'Resetting cached value in settings', + key, + }); + const value = await fn(retry); + + if ( + ( + await updateAuditedBySystem({ + reason: 'cacheValueInSettings reset', + })(Settings.updateValueById, key, value) + ).modifiedCount + ) { + void notifyOnSettingChangedById(key); + } + + return value; + }; + + return Object.assign( + async () => { + const storedValue = settings.get(key); + + if (storedValue) { + return storedValue; + } + + return reset(); + }, + { + reset, + }, + ); +}; + +const releaseEndpoint = process.env.OVERWRITE_INTERNAL_RELEASE_URL?.trim() + ? process.env.OVERWRITE_INTERNAL_RELEASE_URL.trim() + : 'https://releases.rocket.chat/v2/server/supportedVersions'; + +const getSupportedVersionsFromCloud = async () => { + if (process.env.CLOUD_SUPPORTED_VERSIONS_TOKEN) { + return { + success: true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result: JSON.parse(process.env.CLOUD_SUPPORTED_VERSIONS!), + }; + } + + const headers = await generateWorkspaceBearerHttpHeader(); + + // Re-validated at dispatch time: an offline license applied while this async + // operation was in flight must still suppress the request. + if (License.hasOfflineLicense()) { + return { success: true, result: undefined } as const; + } + + const response = await handleResponse( + fetch(releaseEndpoint, { + headers, + timeout: 5000, + // SECURITY: the URL is a default hardcoded value or an envvar set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }), + ); + + if (!response.success) { + SystemLogger.error({ + msg: 'Failed to communicate with Rocket.Chat Cloud', + url: releaseEndpoint, + err: response.error, + }); + } + + return response; +}; + +const getSupportedVersionsToken = async (retry = 0) => { + /** + * Gets the supported versions from the license + * Gets the supported versions from the cloud + * Gets the latest version + * return the token + */ + const [versionsFromLicense, cloudResponse] = await Promise.all([ + License.getLicense(), + License.hasOfflineLicense() ? ({ success: true, result: undefined } as const) : getSupportedVersionsFromCloud(), + ]); + + const supportedVersions = await supportedVersionsChooseLatest( + supportedVersionsFromBuild, + versionsFromLicense?.supportedVersions, + (cloudResponse.success && cloudResponse.result) || undefined, + ); + + SystemLogger.debug({ + msg: 'Supported versions', + supportedVersionsFromBuild: supportedVersionsFromBuild.timestamp, + versionsFromLicense: versionsFromLicense?.supportedVersions?.timestamp, + response: cloudResponse.success && cloudResponse.result?.timestamp, + }); + + switch (supportedVersions) { + case supportedVersionsFromBuild: + SystemLogger.info({ + msg: 'Using supported versions from build', + }); + break; + case versionsFromLicense?.supportedVersions: + SystemLogger.info({ + msg: 'Using supported versions from license', + }); + break; + case cloudResponse.success && cloudResponse.result: + SystemLogger.info({ + msg: 'Using supported versions from cloud', + }); + break; + } + + // to avoid a possibly wrong message, we only send the message if the cloud response was successful + if (cloudResponse.success) { + await buildVersionUpdateMessage(supportedVersions?.versions); + } else if (retry < 5) { + // in case of failure we'll try again later + setTimeout( + async () => { + await getCachedSupportedVersionsToken.reset(retry + 1); + }, + 5000 * Math.pow(2, retry), + ); + } else { + SystemLogger.error({ + msg: 'Failed to get supported versions from cloud after retries.', + retry, + }); + await buildVersionUpdateMessage(supportedVersions?.versions); + } + + return supportedVersions?.signed; +}; + +export const getCachedSupportedVersionsToken = cacheValueInSettings('Cloud_Workspace_Supported_Versions_Token', getSupportedVersionsToken); diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/announcementSync.ts b/apps/meteor/server/lib/cloud/syncWorkspace/announcementSync.ts new file mode 100644 index 0000000..c9d7221 --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/announcementSync.ts @@ -0,0 +1,104 @@ +import { Cloud } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import * as z from 'zod'; + +import { handleAnnouncementsOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; +import { CloudWorkspaceConnectionError } from '../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { settings } from '../../../settings'; +import { SystemLogger } from '../../logger/system'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; + +const fetchCloudAnnouncementsSync = async ({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise => { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v3/comms/workspace`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + const result = Cloud.WorkspaceCommsResponsePayloadSchema.safeParse(payload); + + if (!result.success) { + throw new CloudWorkspaceConnectionError('failed type validation', { + cause: z.prettifyError(result.error), + }); + } + + return result.data; +}; + +export async function announcementSync() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessTokenEmptyError(); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const { nps, announcements } = await fetchCloudAnnouncementsSync({ + token, + data: workspaceRegistrationData, + }); + + if (nps) { + await handleNpsOnWorkspaceSync(nps); + } + + if (announcements) { + await handleAnnouncementsOnWorkspaceSync(announcements); + } + + return true; + } catch (err) { + switch (true) { + case err instanceof CloudWorkspaceConnectionError: + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof CloudWorkspaceAccessTokenEmptyError: { + SystemLogger.info({ + msg: 'Failed to sync with Rocket.Chat Cloud', + function: 'announcementSync', + err, + }); + break; + } + default: { + SystemLogger.error({ + msg: 'Error during workspace sync', + function: 'announcementSync', + err, + }); + } + } + throw err; + } +} diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/fetchWorkspaceSyncPayload.ts b/apps/meteor/server/lib/cloud/syncWorkspace/fetchWorkspaceSyncPayload.ts new file mode 100644 index 0000000..e7be1cd --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/fetchWorkspaceSyncPayload.ts @@ -0,0 +1,42 @@ +import { Cloud } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import * as z from 'zod'; + +import { CloudWorkspaceConnectionError } from '../../../../lib/errors/CloudWorkspaceConnectionError'; +import { settings } from '../../../settings'; + +export async function fetchWorkspaceSyncPayload({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/sync`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } + + const payload = await response.json(); + + const result = Cloud.WorkspaceSyncResponseSchema.safeParse(payload); + + if (!result.success) { + throw new CloudWorkspaceConnectionError('failed type validation', { + cause: z.prettifyError(result.error), + }); + } + + return result.data; +} diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/handleCommsSync.ts b/apps/meteor/server/lib/cloud/syncWorkspace/handleCommsSync.ts new file mode 100644 index 0000000..2427183 --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/handleCommsSync.ts @@ -0,0 +1,56 @@ +import { NPS, Banner } from '@rocket.chat/core-services'; +import type { Cloud, IBanner, Optional } from '@rocket.chat/core-typings'; + +import { getAndCreateNpsSurvey } from '../../../services/nps/getAndCreateNpsSurvey'; + +export const handleNpsOnWorkspaceSync = async (nps: Cloud.NpsSurveyAnnouncement) => { + const { id: npsId, startAt, expireAt } = nps; + + await NPS.create({ + npsId, + startAt, + expireAt, + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + }); + + const now = new Date(); + + if (startAt.getFullYear() === now.getFullYear() && startAt.getMonth() === now.getMonth() && startAt.getDate() === now.getDate()) { + await getAndCreateNpsSurvey(npsId); + } +}; + +export const handleBannerOnWorkspaceSync = async (banners: Optional[]) => { + for (const banner of banners) { + await Banner.create(banner); + } +}; + +export const handleAnnouncementsOnWorkspaceSync = async (announcements: { + create: Cloud.Announcement[]; + delete?: Cloud.Announcement['_id'][]; +}) => { + const { create, delete: deleteIds } = announcements; + + if (deleteIds) { + await Promise.all(deleteIds.map((announcementId) => Banner.disable(announcementId))); + } + + await Promise.all( + create.map((announcement) => { + const { view, selector } = announcement; + + return Banner.create({ + ...announcement, + ...(selector?.roles ? { roles: selector.roles } : {}), + view: { + ...view, + appId: 'cloud-announcements-core', + }, + }); + }), + ); +}; diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts new file mode 100644 index 0000000..aa76435 --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts @@ -0,0 +1,64 @@ +import { License } from '@rocket.chat/license'; + +import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../logger/system'; +import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; +import { announcementSync } from './announcementSync'; +import { legacySyncWorkspace } from './legacySyncWorkspace'; +import { syncCloudData } from './syncCloudData'; +import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/supportedVersionsToken'; + +/** + * Syncs the workspace with the cloud + * @returns {Promise} + * @throws {Error} - If there is an unexpected error during sync like a network error + */ +export async function syncWorkspace() { + if (License.hasOfflineLicense()) { + SystemLogger.debug({ msg: 'Skipping cloud sync: workspace has an offline license', function: 'syncWorkspace' }); + await getCachedSupportedVersionsToken.reset(); + return; + } + + try { + await announcementSync(); + await syncCloudData(); + } catch (err) { + switch (true) { + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof CloudWorkspaceAccessTokenError: { + // There is no access token, so we can't sync + SystemLogger.info('Workspace does not have a valid access token, sync aborted'); + break; + } + default: { + if (!(err instanceof CloudWorkspaceAccessTokenEmptyError) && !isAbortError(err)) { + SystemLogger.error({ msg: 'Error during workspace sync', err }); + } + SystemLogger.info({ + msg: 'Falling back to legacy sync', + function: 'syncCloudData', + }); + try { + await legacySyncWorkspace(); + } catch (err) { + switch (true) { + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof CloudWorkspaceAccessTokenError: { + // There is no access token, so we can't sync + break; + } + default: { + if (!(err instanceof CloudWorkspaceAccessTokenEmptyError) && !isAbortError(err)) { + SystemLogger.error({ msg: 'Error during fallback workspace sync', err }); + } + throw err; + } + } + } + } + } + } finally { + await getCachedSupportedVersionsToken.reset(); + } +} diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/legacySyncWorkspace.ts b/apps/meteor/server/lib/cloud/syncWorkspace/legacySyncWorkspace.ts new file mode 100644 index 0000000..239cb34 --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/legacySyncWorkspace.ts @@ -0,0 +1,108 @@ +import { Cloud } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import * as z from 'zod'; + +import { CloudWorkspaceConnectionError } from '../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { settings } from '../../../settings'; +import { notifyOnSettingChangedById } from '../../notifyListener'; +import type { WorkspaceRegistrationData } from '../buildRegistrationData'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { getWorkspaceLicense } from '../getWorkspaceLicense'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { handleBannerOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; + +/** @deprecated */ +const fetchWorkspaceClientPayload = async ({ + token, + workspaceRegistrationData, +}: { + token: string; + workspaceRegistrationData: WorkspaceRegistrationData; +}): Promise => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/client`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: workspaceRegistrationData, + timeout: 5000, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + if (!payload) { + return undefined; + } + + const result = Cloud.WorkspaceSyncPayloadSchema.safeParse(payload); + + if (!result.success) { + throw new CloudWorkspaceConnectionError('Invalid response from Rocket.Chat Cloud', { + cause: z.prettifyError(result.error), + }); + } + + return result.data; +}; + +/** @deprecated */ +const consumeWorkspaceSyncPayload = async (result: Cloud.WorkspaceSyncPayload) => { + if (result.publicKey) { + (await Settings.updateValueById('Cloud_Workspace_PublicKey', result.publicKey)).modifiedCount && + void notifyOnSettingChangedById('Cloud_Workspace_PublicKey'); + } + + if (result.trial?.trialID) { + (await Settings.updateValueById('Cloud_Workspace_Had_Trial', true)).modifiedCount && + void notifyOnSettingChangedById('Cloud_Workspace_Had_Trial'); + } + + // add banners + if (result.banners) { + await handleBannerOnWorkspaceSync(result.banners); + } + + if (result.nps) { + await handleNpsOnWorkspaceSync(result.nps); + } +}; + +/** @deprecated */ +export async function legacySyncWorkspace() { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessTokenEmptyError(); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const payload = await fetchWorkspaceClientPayload({ token, workspaceRegistrationData }); + + if (payload) { + await consumeWorkspaceSyncPayload(payload); + } + + await getWorkspaceLicense(); + + return true; +} diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/syncCloudData.ts b/apps/meteor/server/lib/cloud/syncWorkspace/syncCloudData.ts new file mode 100644 index 0000000..785e70e --- /dev/null +++ b/apps/meteor/server/lib/cloud/syncWorkspace/syncCloudData.ts @@ -0,0 +1,78 @@ +import { DuplicatedLicenseError } from '@rocket.chat/license'; +import { Settings } from '@rocket.chat/models'; + +import { fetchWorkspaceSyncPayload } from './fetchWorkspaceSyncPayload'; +import { CloudWorkspaceAccessError } from '../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { callbacks } from '../../callbacks'; +import { SystemLogger } from '../../logger/system'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { CloudWorkspaceAccessTokenEmptyError, getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; + +export async function syncCloudData() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessTokenEmptyError(); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const { + license, + removeLicense = false, + cloudSyncAnnouncement, + } = await fetchWorkspaceSyncPayload({ + token, + data: workspaceRegistrationData, + }); + + await Settings.updateValueById('Cloud_Sync_Announcement_Payload', JSON.stringify(cloudSyncAnnouncement ?? null)); + + if (removeLicense) { + await callbacks.run('workspaceLicenseRemoved'); + } else { + await callbacks.run('workspaceLicenseChanged', license); + } + + SystemLogger.info({ + msg: 'Synced with Rocket.Chat Cloud', + function: 'syncCloudData', + }); + + return true; + } catch (err) { + /** + * If some of CloudWorkspaceAccessError and CloudWorkspaceRegistrationError happens, makes no sense to run the legacySyncWorkspace + * because it will fail too. + * The DuplicatedLicenseError license error is also ignored because it is not a problem. the Cloud is allowed to send the same license twice. + */ + switch (true) { + case err instanceof DuplicatedLicenseError: + return; + case err instanceof CloudWorkspaceAccessError: + case err instanceof CloudWorkspaceRegistrationError: + case err instanceof CloudWorkspaceAccessTokenEmptyError: + SystemLogger.info({ + msg: 'Failed to sync with Rocket.Chat Cloud', + function: 'syncCloudData', + err, + }); + break; + + default: + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + function: 'syncCloudData', + err, + }); + } + throw err; + } +} diff --git a/apps/meteor/server/lib/cloud/userLoggedOut.ts b/apps/meteor/server/lib/cloud/userLoggedOut.ts new file mode 100644 index 0000000..7b4dfc9 --- /dev/null +++ b/apps/meteor/server/lib/cloud/userLoggedOut.ts @@ -0,0 +1,22 @@ +import { Users } from '@rocket.chat/models'; + +export async function userLoggedOut(userId: string) { + if (!userId) { + return false; + } + + const user = await Users.findOneById(userId); + + if (user?.services?.cloud) { + await Users.updateOne( + { _id: user._id }, + { + $unset: { + 'services.cloud': 1, + }, + }, + ); + } + + return true; +} diff --git a/apps/meteor/server/lib/cloud/userLogout.ts b/apps/meteor/server/lib/cloud/userLogout.ts new file mode 100644 index 0000000..ed2f0be --- /dev/null +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -0,0 +1,63 @@ +import { License } from '@rocket.chat/license'; +import { Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { userLoggedOut } from './userLoggedOut'; +import { settings } from '../../settings'; +import { SystemLogger } from '../logger/system'; + +export async function userLogout(userId: string): Promise { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + + if (!workspaceRegistered) { + return ''; + } + + if (!userId) { + return ''; + } + + // Offline (air-gapped) licenses forbid the outbound token-revocation call, but + // the local cloud credentials must still be destroyed on logout. + if (License.hasOfflineLicense()) { + return userLoggedOut(userId); + } + + const user = await Users.findOneById(userId); + + if (user?.services?.cloud?.refreshToken) { + try { + const clientId = settings.get('Cloud_Workspace_Client_Id'); + if (!clientId) { + return ''; + } + + const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); + + const { refreshToken } = user.services.cloud; + + const cloudUrl = settings.get('Cloud_Url'); + await fetch(`${cloudUrl}/api/oauth/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: { + client_id: clientId, + client_secret: clientSecret, + token: refreshToken, + token_type_hint: 'refresh_token', + }, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + } catch (err) { + SystemLogger.error({ + msg: 'Failed to get Revoke refresh token to logout of Rocket.Chat Cloud', + url: '/api/oauth/revoke', + err, + }); + } + } + + return userLoggedOut(userId); +} diff --git a/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.spec.ts b/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.spec.ts new file mode 100644 index 0000000..5e611ba --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.spec.ts @@ -0,0 +1,123 @@ +import { buildVersionUpdateMessage } from './buildVersionUpdateMessage'; +import { sendMessagesToAdmins } from '../../../sendMessagesToAdmins'; + +const originalTestMode = process.env.TEST_MODE; + +const mockInfoVersion = jest.fn(() => '7.5.0'); + +jest.mock('../../../../../app/utils/rocketchat.info', () => ({ + Info: { + get version() { + return mockInfoVersion(); + }, + }, +})); + +const mockSetBannersInBulk = jest.fn(); +const mockFindUsersInRolesWithQuery = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Settings: { + updateValueById: jest.fn().mockResolvedValue({ modifiedCount: 0 }), + }, + Users: { + findUsersInRolesWithQuery: () => mockFindUsersInRolesWithQuery(), + setBannersInBulk: (updates: unknown) => mockSetBannersInBulk(updates), + }, +})); + +const mockSettingsGet = jest.fn(); + +jest.mock('../../../../settings', () => ({ + settings: { + get: (key: string) => mockSettingsGet(key), + }, +})); + +jest.mock('../../../i18n', () => ({ + i18n: { + t: jest.fn((key) => key), + }, +})); + +jest.mock('../../../sendMessagesToAdmins', () => ({ + sendMessagesToAdmins: jest.fn(), +})); + +jest.mock('../../../../settings/lib/auditedSettingUpdates', () => ({ + updateAuditedBySystem: jest.fn(() => () => Promise.resolve({ modifiedCount: 0 })), +})); + +jest.mock('../../../notifyListener', () => ({ + notifyOnSettingChangedById: jest.fn(), +})); + +describe('buildVersionUpdateMessage', () => { + // Delete the TEST_MODE environment variable so buildVersionUpdateMessage() + // doesn't return early (see line 40 in buildVersionUpdateMessage.ts) + beforeAll(() => { + delete process.env.TEST_MODE; + }); + + afterAll(() => { + process.env.TEST_MODE = originalTestMode; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockInfoVersion.mockReturnValue('7.5.0'); + mockSettingsGet.mockReturnValue('7.0.0'); + }); + + describe('cleanupOutdatedVersionUpdateBanners', () => { + it('should remove outdated version banners (<= current installed)', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-6_2_0': { id: 'versionUpdate-6_2_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + + it('should keep version banners > current installed', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-8_0_0': { id: 'versionUpdate-8_0_0' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).not.toHaveBeenCalled(); + }); + + it('should remove banners with invalid semver version IDs', async () => { + const admin = { _id: 'admin1', banners: { 'versionUpdate-invalid_version': { id: 'versionUpdate-invalid_version' } } }; + mockFindUsersInRolesWithQuery.mockReturnValue([admin]); + + await buildVersionUpdateMessage([]); + + expect(mockSetBannersInBulk).toHaveBeenCalledWith([{ userId: 'admin1', banners: {} }]); + }); + }); + + describe('version sorting', () => { + it('should process versions in descending order (highest first)', async () => { + mockFindUsersInRolesWithQuery.mockReturnValue([]); + + await buildVersionUpdateMessage([ + { version: '7.6.0', security: false, infoUrl: 'https://example.com/7.6.0' }, + { version: '8.0.0', security: false, infoUrl: 'https://example.com/8.0.0' }, + { version: '7.8.0', security: false, infoUrl: 'https://example.com/7.8.0' }, + ]); + + expect(sendMessagesToAdmins).toHaveBeenCalledTimes(1); + expect(sendMessagesToAdmins).toHaveBeenCalledWith( + expect.objectContaining({ + banners: expect.arrayContaining([ + expect.objectContaining({ + id: 'versionUpdate-8_0_0', + }), + ]), + }), + ); + }); + }); +}); diff --git a/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.ts b/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.ts new file mode 100644 index 0000000..a95af5f --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/functions/buildVersionUpdateMessage.ts @@ -0,0 +1,111 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Settings, Users } from '@rocket.chat/models'; +import semver from 'semver'; + +import { Info } from '../../../../../app/utils/rocketchat.info'; +import { settings } from '../../../../settings'; +import { updateAuditedBySystem } from '../../../../settings/lib/auditedSettingUpdates'; +import { i18n } from '../../../i18n'; +import { notifyOnSettingChangedById } from '../../../notifyListener'; +import { sendMessagesToAdmins } from '../../../sendMessagesToAdmins'; + +const cleanupOutdatedVersionUpdateBanners = async (): Promise => { + const admins = Users.findUsersInRolesWithQuery('admin', { banners: { $exists: true } }, { projection: { _id: 1, banners: 1 } }); + + const updates: { userId: IUser['_id']; banners: NonNullable }[] = []; + + for await (const admin of admins) { + if (!admin.banners) { + continue; + } + + const filteredBanners = Object.fromEntries( + Object.entries(admin.banners).filter(([bannerId]) => { + if (!bannerId.startsWith('versionUpdate-')) { + return true; + } + const version = bannerId.replace('versionUpdate-', '').replace(/_/g, '.'); + if (!semver.valid(version) || semver.lte(version, Info.version)) { + return false; + } + return true; + }), + ); + + if (Object.keys(filteredBanners).length !== Object.keys(admin.banners).length) { + updates.push({ userId: admin._id, banners: filteredBanners }); + } + } + + if (updates.length > 0) { + await Users.setBannersInBulk(updates); + } +}; + +export const buildVersionUpdateMessage = async ( + versions: { + version: string; + security: boolean; + infoUrl: string; + }[] = [], +) => { + if (process.env.TEST_MODE) { + return; + } + + const lastCheckedVersion = settings.get('Update_LatestAvailableVersion'); + + if (!lastCheckedVersion) { + return; + } + + const sortedVersions = [...versions].sort((a, b) => semver.rcompare(a.version, b.version)); + + await cleanupOutdatedVersionUpdateBanners(); + + for await (const version of sortedVersions) { + if (semver.prerelease(version.version)) { + continue; + } + + if (semver.lte(version.version, lastCheckedVersion)) { + continue; + } + + if (semver.lte(version.version, Info.version)) { + continue; + } + + ( + await updateAuditedBySystem({ + reason: 'buildVersionUpdateMessage', + })(Settings.updateValueById, 'Update_LatestAvailableVersion', version.version) + ).modifiedCount && void notifyOnSettingChangedById('Update_LatestAvailableVersion'); + + await sendMessagesToAdmins({ + msgs: async ({ adminUser }) => [ + { + msg: `*${i18n.t('Update_your_RocketChat', { ...(adminUser.language && { lng: adminUser.language }) })}*\n${i18n.t( + 'New_version_available_(s)', + { + postProcess: 'sprintf', + sprintf: [version.version], + }, + )}\n${version.infoUrl}`, + }, + ], + banners: [ + { + id: `versionUpdate-${version.version}`.replace(/\./g, '_'), + priority: 10, + title: 'Update_your_RocketChat', + text: 'New_version_available_(s)', + textArguments: [version.version], + link: version.infoUrl, + modifiers: [], + }, + ], + }); + break; + } +}; diff --git a/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts new file mode 100644 index 0000000..f238c8c --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts @@ -0,0 +1,86 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../../i18n'; +import { sendMessagesToAdmins } from '../../../sendMessagesToAdmins'; +import logger from '../logger'; +import { buildVersionUpdateMessage } from './buildVersionUpdateMessage'; +import { getNewUpdates } from './getNewUpdates'; + +const getMessagesToSendToAdmins = async ( + alerts: { + id: string; + priority: number; + title: string; + text: string; + textArguments?: string[]; + modifiers: string[]; + infoUrl: string; + }[], + adminUser: IUser, +): Promise<{ msg: string }[]> => { + const msgs = []; + for await (const alert of alerts) { + if (!(await Users.bannerExistsById(adminUser._id, `alert-${alert.id}`))) { + continue; + } + msgs.push({ + msg: `*${i18n.t('Rocket_Chat_Alert', { ...(adminUser.language && { lng: adminUser.language }) })}:*\n\n*${i18n.t(alert.title, { + ...(adminUser.language && { lng: adminUser.language }), + })}*\n${i18n.t(alert.text, { + ...(adminUser.language && { lng: adminUser.language }), + ...(Array.isArray(alert.textArguments) && { + postProcess: 'sprintf', + sprintf: alert.textArguments, + }), + ...((!Array.isArray(alert.textArguments) && alert.textArguments) || {}), // bien dormido + })}\n${alert.infoUrl}`, + }); + } + return msgs; +}; +/** + * @deprecated + */ +export const checkVersionUpdate = async () => { + if (License.hasOfflineLicense()) { + return; + } + + logger.info('Checking for version updates'); + + const { versions, alerts } = await getNewUpdates(); + + await buildVersionUpdateMessage(versions); + + await showAlertsFromCloud(alerts); +}; + +const showAlertsFromCloud = async ( + alerts?: { + id: string; + priority: number; + title: string; + text: string; + textArguments?: string[]; + modifiers: string[]; + infoUrl: string; + }[], +) => { + if (!alerts?.length) { + return; + } + return sendMessagesToAdmins({ + msgs: async ({ adminUser }) => getMessagesToSendToAdmins(alerts, adminUser), + banners: alerts.map((alert) => ({ + id: `alert-${alert.id}`.replace(/\./g, '_'), + priority: 10, + title: alert.title, + text: alert.text, + textArguments: alert.textArguments, + modifiers: alert.modifiers, + link: alert.infoUrl, + })), + }); +}; diff --git a/apps/meteor/server/lib/cloud/version-check/functions/getNewUpdates.ts b/apps/meteor/server/lib/cloud/version-check/functions/getNewUpdates.ts new file mode 100644 index 0000000..e9b8dd0 --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/functions/getNewUpdates.ts @@ -0,0 +1,97 @@ +import os from 'node:os'; + +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { check, Match } from 'meteor/check'; + +import { getWorkspaceAccessToken } from '../..'; +import { Info } from '../../../../../app/utils/rocketchat.info'; + +/** @deprecated */ + +export const getNewUpdates = async () => { + try { + const uniqueID = await Settings.findOne('uniqueID'); + + if (!uniqueID) { + throw new Error('uniqueID not found'); + } + + const params = { + uniqueId: String(uniqueID.value), + installedAt: uniqueID.createdAt.toJSON(), + version: Info.version, + osType: os.type(), + osPlatform: os.platform(), + osArch: os.arch(), + osRelease: os.release(), + nodeVersion: process.version, + deployMethod: process.env.DEPLOY_METHOD || 'tar', + deployPlatform: process.env.DEPLOY_PLATFORM || 'selfinstall', + }; + + const token = await getWorkspaceAccessToken(); + const headers = { + ...(token && { Authorization: `Bearer ${token}` }), + }; + const url = 'https://releases.rocket.chat/updates/check'; + const response = await fetch(url, { + headers, + params, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + const data = await response.json(); + + check( + data, + Match.ObjectIncluding({ + versions: [ + Match.ObjectIncluding({ + version: String, + security: Match.Optional(Boolean), + infoUrl: String, + }), + ], + alerts: Match.Optional([ + Match.ObjectIncluding({ + id: String, + title: String, + text: String, + textArguments: [Match.Any], + modifiers: [String] as [StringConstructor], + infoUrl: String, + }), + ]), + }), + ); + + return data as { + versions: { + version: string; + security: boolean; + infoUrl: string; + }[]; + + alerts: { + id: string; + priority: number; + title: string; + text: string; + textArguments?: string[]; + modifiers: string[]; + infoUrl: string; + }[]; + }; + } catch (error) { + // There's no need to log this error + // as it's pointless and the user + // can't do anything about it anyways + + return { + versions: [], + alerts: [], + }; + } +}; diff --git a/apps/meteor/server/lib/cloud/version-check/index.ts b/apps/meteor/server/lib/cloud/version-check/index.ts new file mode 100644 index 0000000..8415a5a --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/index.ts @@ -0,0 +1,38 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { Meteor } from 'meteor/meteor'; + +import { checkVersionUpdate } from './functions/checkVersionUpdate'; +import { settings } from '../../../settings'; + +const jobName = 'version_check'; + +if (await cronJobs.has(jobName)) { + await cronJobs.remove(jobName); +} + +const addVersionCheckJob = async () => { + await cronJobs.add(jobName, '0 2 * * *', async () => checkVersionUpdate()); +}; + +Meteor.startup(() => { + setImmediate(() => { + if (settings.get('Update_EnableChecker')) { + void checkVersionUpdate(); + } + }); +}); + +settings.watch('Update_EnableChecker', async () => { + const checkForUpdates = settings.get('Update_EnableChecker'); + + if (checkForUpdates && (await cronJobs.has(jobName))) { + return; + } + + if (checkForUpdates) { + await addVersionCheckJob(); + return; + } + + await cronJobs.remove(jobName); +}); diff --git a/apps/meteor/server/lib/cloud/version-check/logger.ts b/apps/meteor/server/lib/cloud/version-check/logger.ts new file mode 100644 index 0000000..b12e06d --- /dev/null +++ b/apps/meteor/server/lib/cloud/version-check/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export default new Logger('VersionCheck'); diff --git a/apps/meteor/server/lib/compareUserPassword.ts b/apps/meteor/server/lib/compareUserPassword.ts new file mode 100644 index 0000000..315a207 --- /dev/null +++ b/apps/meteor/server/lib/compareUserPassword.ts @@ -0,0 +1,31 @@ +import type { IUser, IPassword } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; + +/** + * Check if a given password is the one user by given user or if the user doesn't have a password + * @param {object} user User object + * @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' } + */ +export async function compareUserPassword(user: IUser, pass: IPassword): Promise { + if (!user?.services?.password?.bcrypt?.trim()) { + return false; + } + + if (!pass || (!pass.plain && !pass.sha256)) { + return false; + } + + const password = pass.plain || { + digest: pass.sha256?.toLowerCase() || '', + algorithm: 'sha-256' as const, + }; + + const passCheck = await Accounts._checkPasswordAsync(user as Meteor.User, password); + + if (passCheck.error) { + return false; + } + + return true; +} diff --git a/apps/meteor/server/lib/compareUserPasswordHistory.ts b/apps/meteor/server/lib/compareUserPasswordHistory.ts new file mode 100644 index 0000000..fb30ed4 --- /dev/null +++ b/apps/meteor/server/lib/compareUserPasswordHistory.ts @@ -0,0 +1,46 @@ +import type { IUser, IPassword } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; + +import { settings } from '../settings'; + +/** + * Check if a given password is the one user by given user or if the user doesn't have a password + * @param {object} user User object + * @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' } + */ +export async function compareUserPasswordHistory(user: IUser, pass: IPassword): Promise { + if (!user?.services?.passwordHistory || !settings.get('Accounts_Password_History_Enabled')) { + return true; + } + + if (!pass || (!pass.plain && !pass.sha256) || !user?.services?.password?.bcrypt) { + return false; + } + + const currentPassword = user.services.password.bcrypt; + const passwordHistory = user.services.passwordHistory.slice(-Number(settings.get('Accounts_Password_History_Amount'))); + + for await (const password of passwordHistory) { + if (!password.trim()) { + user.services.password.bcrypt = currentPassword; + return false; + } + user.services.password.bcrypt = password; + + const historyPassword = pass.plain || { + digest: pass.sha256 ? pass.sha256.toLowerCase() : '', + algorithm: 'sha-256' as const, + }; + + const passCheck = await Accounts._checkPasswordAsync(user as Meteor.User, historyPassword); + + if (!passCheck.error) { + user.services.password.bcrypt = currentPassword; + return false; + } + } + + user.services.password.bcrypt = currentPassword; + return true; +} diff --git a/apps/meteor/server/lib/cors/cors.ts b/apps/meteor/server/lib/cors/cors.ts new file mode 100644 index 0000000..30ea26a --- /dev/null +++ b/apps/meteor/server/lib/cors/cors.ts @@ -0,0 +1,166 @@ +import type http from 'node:http'; +import url from 'node:url'; + +import { Logger } from '@rocket.chat/logger'; +import { Meteor } from 'meteor/meteor'; +import type { StaticFiles } from 'meteor/webapp'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; + +import { getWebAppHash } from '../../configuration/configureBoilerplate'; +import { settings } from '../../settings'; + +// Taken from 'connect' types +type NextFunction = (err?: any) => void; + +const logger = new Logger('CORS'); + +let templatePromise: Promise | void; +export async function setInlineScriptsAllowed(allowed: boolean): Promise { + templatePromise = WebAppInternals.setInlineScriptsAllowed(allowed); +} + +WebApp.rawConnectHandlers.use(async (_req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) => { + if (templatePromise) { + await templatePromise; + templatePromise = void 0; + } + + // XSS Protection for old browsers (IE) + res.setHeader('X-XSS-Protection', '1'); + + // X-Content-Type-Options header to prevent MIME Sniffing + res.setHeader('X-Content-Type-Options', 'nosniff'); + + if (settings.get('Iframe_Restrict_Access')) { + res.setHeader('X-Frame-Options', settings.get('Iframe_X_Frame_Options')); + } + + if (settings.get('Enable_CSP')) { + // eslint-disable-next-line @typescript-eslint/naming-convention + const cdn_prefixes = [ + settings.get('CDN_PREFIX'), + settings.get('CDN_PREFIX_ALL') ? null : settings.get('CDN_JSCSS_PREFIX'), + 'https://cdn.zapier.com', + ] + .filter(Boolean) + .join(' '); + + const inlineHashes = [ + // Hash for `window.close()`, required by the CAS login popup. + "'sha256-jqxtvDkBbRAl9Hpqv68WdNOieepg8tJSYu1xIy7zT34='", + // Hash for /apps/meteor/packages/rocketchat-livechat/assets/demo.html:25 + "'sha256-aui5xYk3Lu1dQcnsPlNZI+qDTdfzdUv3fzsw80VLJgw='", + ] + .filter(Boolean) + .join(' '); + const external = [ + settings.get('Accounts_OAuth_Apple') && 'https://appleid.cdn-apple.com', + settings.get('PiwikAnalytics_enabled') && settings.get('PiwikAnalytics_url'), + settings.get('GoogleAnalytics_enabled') && 'https://www.google-analytics.com', + 'https://zapier.com', + ...settings + .get('Extra_CSP_Domains') + .split(/[ \n\,]/gim) + .filter((e) => Boolean(e.trim())), + ] + .filter(Boolean) + .join(' '); + res.setHeader( + 'Content-Security-Policy', + [ + `default-src 'self' ${cdn_prefixes}`, + 'connect-src *', + `font-src 'self' ${cdn_prefixes} data:`, + 'frame-src *', + 'img-src * data: blob:', + 'media-src * data:', + `script-src 'self' 'unsafe-eval' ${inlineHashes} ${cdn_prefixes} ${external}`, + `style-src 'self' 'unsafe-inline' ${cdn_prefixes}`, + ].join('; '), + ); + } + + return next(); +}); + +const _staticFilesMiddleware = WebAppInternals.staticFilesMiddleware; + +let cachingVersion = ''; +export function setCachingVersion(value: string): void { + cachingVersion = value.trim(); +} + +// @ts-expect-error - accessing internal property of webapp +WebAppInternals.staticFilesMiddleware = function ( + staticFiles: StaticFiles, + req: http.IncomingMessage & { cookies: Record }, + res: http.ServerResponse & { cookie: (cookie: string, value: string) => void }, + next: NextFunction, +) { + res.setHeader('Access-Control-Allow-Origin', '*'); + const { arch, path, url } = WebApp.categorizeRequest(req); + + if (cachingVersion && req.cookies.cache_version !== cachingVersion) { + res.cookie('cache_version', cachingVersion); + res.setHeader('Clear-Site-Data', '"cache"'); + } + + // Prevent meteor_runtime_config.js to load from a different expected hash possibly causing + // a cache of the file for the wrong hash and start a client loop due to the mismatch + // of the hashes of ui versions which would be checked against a websocket response + if (path === '/meteor_runtime_config.js') { + const hash = getWebAppHash(arch); + + if (!hash || hash !== url.query.hash) { + res.writeHead(404); + return res.end(); + } + + res.setHeader('Cache-Control', 'public, max-age=3600'); + } + + return _staticFilesMiddleware(staticFiles, req, res, next); +}; + +const oldHttpServerListeners = WebApp.httpServer.listeners('request').slice(0); + +WebApp.httpServer.removeAllListeners('request'); + +WebApp.httpServer.addListener('request', (req, res, ...args) => { + const next = () => { + for (const oldListener of oldHttpServerListeners) { + oldListener.apply(WebApp.httpServer, [req, res, ...args]); + } + }; + + if (settings.get('Force_SSL') !== true) { + next(); + return; + } + + const remoteAddress = req.connection.remoteAddress || req.socket.remoteAddress || ''; + const localhostRegexp = /^\s*(127\.0\.0\.1|::1)\s*$/; + const localhostTest = function (x: string) { + return localhostRegexp.test(x); + }; + + const isLocal = + localhostRegexp.test(remoteAddress) && + (!req.headers['x-forwarded-for'] || (req.headers['x-forwarded-for'] as string).split(',').every(localhostTest)); + // @ts-expect-error - `pair` is valid, but doesnt exists on types + const isSsl = req.connection.pair || (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'].indexOf('https') !== -1); + + logger.debug({ msg: 'CORS request info', url: req.url, remoteAddress, isLocal, isSsl, headers: req.headers }); + + if (!isLocal && !isSsl) { + let host = req.headers.host || url.parse(Meteor.absoluteUrl()).hostname || ''; + host = host.replace(/:\d+$/, ''); + res.writeHead(302, { + Location: `https://${host}${req.url}`, + }); + res.end(); + return; + } + + return next(); +}); diff --git a/apps/meteor/server/lib/cors/index.ts b/apps/meteor/server/lib/cors/index.ts new file mode 100644 index 0000000..9b07878 --- /dev/null +++ b/apps/meteor/server/lib/cors/index.ts @@ -0,0 +1,10 @@ +import './cors'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(() => { + settings.watch('Force_SSL', (value) => { + Meteor.absoluteUrl.defaultOptions.secure = Boolean(value); + }); +}); diff --git a/apps/meteor/server/lib/dataExport/copyFileUpload.ts b/apps/meteor/server/lib/dataExport/copyFileUpload.ts new file mode 100644 index 0000000..072b8f3 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/copyFileUpload.ts @@ -0,0 +1,14 @@ +import type { FileProp } from '@rocket.chat/core-typings'; +import { Uploads } from '@rocket.chat/models'; + +import { joinPath } from '../fileUtils'; +import { FileUpload } from '../media/file-upload'; + +export const copyFileUpload = async (attachmentData: Pick, assetsPath: string): Promise => { + const file = await Uploads.findOneById(attachmentData._id); + if (!file) { + return; + } + + await FileUpload.copy(file, joinPath(assetsPath, `${attachmentData._id}-${attachmentData.name}`)); +}; diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts new file mode 100644 index 0000000..05c3e8a --- /dev/null +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -0,0 +1,322 @@ +import { mkdir, writeFile } from 'node:fs/promises'; + +import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; + +import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; +import { settings } from '../../settings'; +import { joinPath } from '../fileUtils'; +import { i18n } from '../i18n'; + +const hideUserName = (username: string, userData: Pick | undefined, usersMap: Record) => { + if (!usersMap[username]) { + if (userData && username === userData.username) { + usersMap[username] = username; + } else { + usersMap[username] = `User_${Object.keys(usersMap).length + 1}`; + } + } + + return usersMap[username]; +}; + +const getAttachmentData = (attachment: MessageAttachment, message: IMessage) => { + return { + type: 'type' in attachment ? attachment.type : undefined, + title: attachment.title, + title_link: attachment.title_link, + image_url: 'image_url' in attachment ? attachment.image_url : undefined, + audio_url: 'audio_url' in attachment ? attachment.audio_url : undefined, + video_url: 'video_url' in attachment ? attachment.video_url : undefined, + message_link: 'message_link' in attachment ? attachment.message_link : undefined, + image_type: 'image_type' in attachment ? attachment.image_type : undefined, + image_size: 'image_size' in attachment ? attachment.image_size : undefined, + video_size: 'video_size' in attachment ? attachment.video_size : undefined, + video_type: 'video_type' in attachment ? attachment.video_type : undefined, + audio_size: 'audio_size' in attachment ? attachment.audio_size : undefined, + audio_type: 'audio_type' in attachment ? attachment.audio_type : undefined, + url: + attachment.title_link || + ('image_url' in attachment ? attachment.image_url : undefined) || + ('audio_url' in attachment ? attachment.audio_url : undefined) || + ('video_url' in attachment ? attachment.video_url : undefined) || + ('message_link' in attachment ? attachment.message_link : undefined) || + null, + remote: !message.file?._id, + fileId: message.file?._id, + fileName: message.file?.name, + }; +}; + +export type MessageData = Pick & { + username?: IUser['username']; + attachments?: ReturnType[]; + type?: IMessage['t']; +}; + +export const getMessageData = ( + msg: IMessage, + hideUsers: boolean, + userData: Pick | undefined, + usersMap: IExportOperation['userNameTable'], +): MessageData => { + const username = hideUsers ? hideUserName(msg.u.username || msg.u.name || '', userData, usersMap) : msg.u.username; + + const messageObject = { + msg: msg.msg, + username, + ts: msg.ts, + ...(msg.attachments && { + attachments: msg.attachments.map((attachment) => getAttachmentData(attachment, msg)), + }), + ...(msg.t && { type: msg.t }), + }; + + switch (msg.t) { + case 'uj': + messageObject.msg = i18n.t('User_joined_the_channel'); + break; + case 'ul': + messageObject.msg = i18n.t('User_left_this_channel'); + break; + case 'ui': + messageObject.msg = i18n.t('User_invited_to_room', { + user_invited: hideUserName(msg.msg, userData, usersMap), + }); + break; + case 'uir': + messageObject.msg = i18n.t('User_rejected_invitation_to_room'); + break; + case 'ult': + messageObject.msg = i18n.t('User_left_this_team'); + break; + case 'user-added-room-to-team': + messageObject.msg = i18n.t('added__roomName__to_this_team', { + roomName: msg.msg, + }); + break; + case 'user-converted-to-team': + messageObject.msg = i18n.t('Converted__roomName__to_a_team', { + roomName: msg.msg, + }); + break; + case 'user-converted-to-channel': + messageObject.msg = i18n.t('Converted__roomName__to_a_channel', { + roomName: msg.msg, + }); + break; + case 'user-deleted-room-from-team': + messageObject.msg = i18n.t('Deleted__roomName__room', { + roomName: msg.msg, + }); + break; + case 'user-removed-room-from-team': + messageObject.msg = i18n.t('Removed__roomName__from_the_team', { + roomName: msg.msg, + }); + break; + case 'ujt': + messageObject.msg = i18n.t('User_joined_the_team'); + break; + case 'au': + messageObject.msg = i18n.t('User_added_to', { + user_added: hideUserName(msg.msg, userData, usersMap), + user_by: username, + }); + break; + case 'added-user-to-team': + messageObject.msg = i18n.t('Added__username__to_this_team', { + user_added: msg.msg, + }); + break; + case 'r': + messageObject.msg = i18n.t('Room_name_changed_to', { + room_name: msg.msg, + user_by: username, + }); + break; + case 'ru': + messageObject.msg = i18n.t('User_has_been_removed', { + user_removed: hideUserName(msg.msg, userData, usersMap), + user_by: username, + }); + break; + case 'removed-user-from-team': + messageObject.msg = i18n.t('Removed__username__from_the_team', { + user_removed: hideUserName(msg.msg, userData, usersMap), + }); + break; + case 'wm': + messageObject.msg = i18n.t('Welcome', { interpolation: { escapeValue: false }, user: username }); + break; + case 'livechat-close': + messageObject.msg = i18n.t('Conversation_finished'); + break; + case 'livechat-started': + messageObject.msg = i18n.t('Chat_started'); + break; + case 'abac-removed-user-from-room': + messageObject.msg = i18n.t('abac_removed_user_from_the_room'); + break; + } + + return messageObject; +}; + +export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFiles: FileProp[] = []): string => { + if (type === 'json') { + return JSON.stringify(messageObject); + } + + const file = []; + + const messageType = messageObject.type; + const timestamp = messageObject.ts ? new Date(messageObject.ts).toUTCString() : ''; + + const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close']; + + const safeMsg = escapeHTML(messageObject.msg ?? ''); + const message = italicTypes.includes(messageType) ? `${safeMsg}` : safeMsg; + + file.push(`

${escapeHTML(messageObject.username ?? '')} (${timestamp}):
`); + file.push(message); + + for (const messageFile of messageFiles) { + if (messageFile?._id) { + const attachment = messageObject.attachments?.find((att) => att.type === 'file' && att.title_link?.includes(messageFile._id)); + + const description = attachment?.title || i18n.t('Message_Attachments'); + + const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`; + const link = `
${escapeHTML(description)}`; + file.push(link); + } + } + + file.push('

'); + + return file.join('\n'); +}; + +export const exportRoomMessages = async ( + rid: IRoom['_id'], + exportType: 'json' | 'html', + skip: number, + limit: number, + userData: any, + filter: any = {}, + usersMap: IExportOperation['userNameTable'] = {}, + hideUsers = true, +) => { + const readPreference = readSecondaryPreferred(); + + const { cursor, totalCount } = Messages.findPaginated( + { ...filter, rid }, + { + sort: { ts: 1 }, + skip, + limit, + readPreference, + }, + ); + + const [results, total] = await Promise.all([cursor.toArray(), totalCount]); + + const result = { + total, + exported: results.length, + messages: [] as string[], + uploads: [] as FileProp[], + }; + + results.forEach((msg) => { + const messageObject = getMessageData(msg, hideUsers, userData, usersMap); + + // handle both new format (msg.files array) and old format (msg.file) for backward compatibility + // and filter out thumbnails (typeGroup === 'thumb') to only include actual files + const files = (msg.files || (msg.file ? [msg.file] : [])).filter((file) => file && file.typeGroup !== 'thumb'); + + result.uploads.push(...files); + result.messages.push(exportMessageObject(exportType, messageObject, files)); + }); + + return result; +}; + +export const exportRoomMessagesToFile = async function ( + exportPath: string, + assetsPath: string, + exportType: 'json' | 'html', + roomList: ( + | { + roomId: string; + roomName: string; + userId: string | undefined; + exportedCount: number; + status: string; + type: RoomType; + targetFile: string; + } + | Record + )[], + userData: IUser, + messagesFilter = {}, + usersMap: IExportOperation['userNameTable'] = {}, + hideUsers = true, +) { + await mkdir(exportPath, { recursive: true }); + await mkdir(assetsPath, { recursive: true }); + + const result = { + fileList: [] as FileProp[], + }; + + const limit = + settings.get('UserData_MessageLimitPerRequest') > 0 ? settings.get('UserData_MessageLimitPerRequest') : 1000; + for await (const exportOpRoomData of roomList) { + if (!('targetFile' in exportOpRoomData)) { + continue; + } + + const filePath = joinPath(exportPath, exportOpRoomData.targetFile); + if (exportOpRoomData.status === 'pending') { + exportOpRoomData.status = 'exporting'; + if (exportType === 'html') { + await writeFile( + filePath, + [ + '', + ``, + ].join('\n'), + { encoding: 'utf8' }, + ); + } + } + + const skip = exportOpRoomData.exportedCount; + + const { total, exported, uploads, messages } = await exportRoomMessages( + exportOpRoomData.roomId, + exportType, + skip, + limit, + userData, + messagesFilter, + usersMap, + hideUsers, + ); + + result.fileList.push(...uploads); + + exportOpRoomData.exportedCount += exported; + + if (total <= exportOpRoomData.exportedCount) { + exportOpRoomData.status = 'completed'; + } + + await writeFile(filePath, `${messages.join('\n')}\n`, { encoding: 'utf8', flag: 'a' }); + } + + return result; +}; diff --git a/apps/meteor/server/lib/dataExport/getPath.ts b/apps/meteor/server/lib/dataExport/getPath.ts new file mode 100644 index 0000000..20d786e --- /dev/null +++ b/apps/meteor/server/lib/dataExport/getPath.ts @@ -0,0 +1,3 @@ +export const getPath = (path: string): string => { + return `/data-export/${path}`; +}; diff --git a/apps/meteor/server/lib/dataExport/getRoomData.ts b/apps/meteor/server/lib/dataExport/getRoomData.ts new file mode 100644 index 0000000..48afe2d --- /dev/null +++ b/apps/meteor/server/lib/dataExport/getRoomData.ts @@ -0,0 +1,37 @@ +import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +export const getRoomData = async ( + roomId: IRoom['_id'], + ownUserId?: IUser['_id'], +): Promise< + | { + roomId: string; + roomName: string; + userId: string | undefined; + exportedCount: number; + status: string; + type: RoomType; + targetFile: string; + } + | Record +> => { + const roomData = await Rooms.findOneById(roomId); + + if (!roomData) { + return {}; + } + + const roomName = roomData.name && roomData.t !== 'd' ? roomData.name : roomId; + const userId = roomData.t === 'd' ? roomData.uids?.find((uid) => uid !== ownUserId) : undefined; + + return { + roomId, + roomName, + userId, + exportedCount: 0, + status: 'pending', + type: roomData.t, + targetFile: '', + }; +}; diff --git a/apps/meteor/server/lib/dataExport/index.ts b/apps/meteor/server/lib/dataExport/index.ts new file mode 100644 index 0000000..2fa0f19 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/index.ts @@ -0,0 +1,4 @@ +export { sendViaEmail } from './sendViaEmail'; +export { sendFile } from './sendFile'; +export { processDataDownloads } from './processDataDownloads'; +export { getPath } from './getPath'; diff --git a/apps/meteor/server/lib/dataExport/makeZipFile.ts b/apps/meteor/server/lib/dataExport/makeZipFile.ts new file mode 100644 index 0000000..5447546 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/makeZipFile.ts @@ -0,0 +1,19 @@ +import { createWriteStream } from 'node:fs'; + +import archiver from 'archiver'; + +export const makeZipFile = (folderToZip: string, targetFile: string): Promise => { + return new Promise(async (resolve, reject) => { + const output = createWriteStream(targetFile); + + const archive = archiver('zip'); + + output.on('close', () => resolve()); + + archive.on('error', (error) => reject(error)); + + archive.pipe(output); + archive.directory(folderToZip, false); + await archive.finalize(); + }); +}; diff --git a/apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts b/apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts new file mode 100644 index 0000000..a2bd4dc --- /dev/null +++ b/apps/meteor/server/lib/dataExport/processDataDownloads.spec.ts @@ -0,0 +1,251 @@ +import fs from 'node:fs'; + +import type { IExportOperation } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import Sinon from 'sinon'; + +let exportOperation: IExportOperation | null = null; + +const modelsMock = { + ExportOperations: { + findLastOperationByUser: async (userId: string, fullExport = false) => { + if (exportOperation?.userId === userId && exportOperation?.fullExport === fullExport) { + return exportOperation; + } + }, + countAllPendingBeforeMyRequest: async (requestDay: Date) => { + if ( + exportOperation && + exportOperation.createdAt < requestDay && + exportOperation.status !== 'completed' && + exportOperation.status !== 'skipped' + ) { + return 1; + } + return 0; + }, + create: async (data: any) => { + exportOperation = { + userNameTable: null, // need to keep this null for testing purposes + ...data, + _id: 'exportOp1', + createdAt: new Date(), + }; + return exportOperation?._id as IExportOperation['_id']; + }, + updateOperation: async (data: IExportOperation) => { + if (exportOperation && exportOperation._id === data._id) { + exportOperation = { ...exportOperation, ...data }; + } + return { modifiedCount: 1 }; + }, + + findOnePending: async () => { + if (exportOperation && exportOperation.status !== 'completed' && exportOperation.status !== 'skipped') { + return exportOperation; + } + return null; + }, + }, + UserDataFiles: { + findOneById: async (fileId: string) => { + if (exportOperation?.fileId === fileId) { + return { + _id: fileId, + }; + } + }, + findLastFileByUser: async (userId: string) => { + if (exportOperation?.userId === userId && exportOperation.fileId) { + return { + _id: exportOperation.fileId, + }; + } + }, + }, + Avatars: { + findOneByName: async (_name: string) => { + return null; + }, + }, + Subscriptions: { + findByUserId: (_userId: string) => { + return [ + { + rid: 'general', + }, + ]; + }, + }, + Messages: { + findPaginated: (_query: object, _options: object) => { + return { + cursor: { + toArray: async () => [ + { + _id: 'msg1', + rid: 'general', + ts: new Date(), + msg: 'Hello World', + u: { _id: 'user1', username: 'userone' }, + }, + { + _id: 'msg2', + rid: 'general', + ts: new Date(), + msg: 'Second message', + u: { _id: 'user2', username: 'usertwo' }, + }, + ], + }, + totalCount: Promise.resolve(0), + }; + }, + }, +}; + +const { exportRoomMessagesToFile } = proxyquire.noCallThru().load('./exportRoomMessagesToFile.ts', { + '@rocket.chat/models': modelsMock, + '../../settings': { + settings: { + get: (_key: string) => { + return undefined; + }, + }, + }, + '../i18n': { + i18n: { + t: (key: string) => key, + }, + }, +}); + +const { requestDataDownload } = proxyquire.noCallThru().load('../../meteor-methods/platform/requestDataDownload.ts', { + '@rocket.chat/models': modelsMock, + '../../settings': { + settings: { + get: (_key: string) => { + return undefined; + }, + }, + }, + '../../lib/dataExport': { + getPath: (fileId: string) => `/data-download/${fileId}`, + }, + 'meteor/meteor': { + Meteor: { + methods: Sinon.stub(), + }, + }, +}) as { + requestDataDownload: (args: { userData: { _id: string }; fullExport?: boolean }) => Promise<{ + requested: boolean; + exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; + }>; +}; + +const { processDataDownloads } = proxyquire.noCallThru().load('./processDataDownloads.ts', { + '@rocket.chat/models': modelsMock, + '../media/file-upload': { + FileUpload: { + copy: async (fileId: string, _options: any) => { + return `copied-${fileId}`; + }, + }, + }, + '../../settings': { + settings: { + get: (_key: string) => { + return undefined; + }, + }, + }, + '../utils/getURL': { + getURL: (path: string) => `https://example.com${path}`, + }, + '../i18n': { + i18n: { + t: (key: string) => key, + }, + }, + './copyFileUpload': { + copyFileUpload: (_attachmentData: { _id: string; name: string }, _assetsPath: string) => { + return Promise.resolve(); + }, + }, + './exportRoomMessagesToFile': { + exportRoomMessagesToFile, + }, + './getRoomData': { + getRoomData: Sinon.stub().resolves({ + roomId: 'GENERAL', + roomName: 'general', + type: 'c', + exportedCount: 0, + status: 'pending', + userId: 'user1', + targetFile: 'general.json', + }), + }, + './sendEmail': { + sendEmail: Sinon.stub().resolves(), + }, + './uploadZipFile': { + uploadZipFile: Sinon.stub().resolves({ _id: 'file1' }), + }, +}) as { + processDataDownloads: () => Promise; +}; + +const userData = { _id: 'user1', username: 'userone' }; + +describe('requestDataDownload', () => { + beforeEach(() => { + exportOperation = null; + }); + + it('should create a new export operation if none exists', async () => { + const result = await requestDataDownload({ userData, fullExport: false }); + + expect(result.requested).to.be.true; + expect(result.exportOperation).to.exist; + expect(result.exportOperation.userId).to.equal('user1'); + expect(result.exportOperation.fullExport).to.be.false; + expect(result.url).to.be.null; + expect(result.pendingOperationsBeforeMyRequest).to.equal(0); + expect(result.exportOperation.status).to.equal('pending'); + }); +}); + +describe('export user data', async () => { + beforeEach(() => { + exportOperation = null; + }); + it('should process data download for pending export operations', async () => { + await requestDataDownload({ userData, fullExport: true }); + + expect(exportOperation).to.not.be.null; + expect(exportOperation?.userId).to.equal('user1'); + expect(exportOperation?.fullExport).to.be.true; + expect(exportOperation?.status).to.equal('pending'); + + await processDataDownloads(); + + expect(exportOperation?.status).to.equal('completed'); + expect(exportOperation?.fileId).to.equal('file1'); + expect(exportOperation?.generatedUserFile).to.be.true; + expect(exportOperation?.roomList).to.have.lengthOf(1); + expect(exportOperation?.roomList?.[0].roomId).to.equal('GENERAL'); + expect(exportOperation?.roomList?.[0].exportedCount).to.equal(2); + expect(exportOperation?.exportPath).to.be.string; + + expect(fs.readFileSync(`${exportOperation?.exportPath}/${exportOperation?.roomList?.[0].targetFile}`, 'utf-8')).to.contain( + 'Hello World', + ); + expect(exportOperation?.generatedFile).to.be.string; + expect(fs.existsSync(exportOperation?.generatedFile as string)).to.be.true; + }); +}); diff --git a/apps/meteor/server/lib/dataExport/processDataDownloads.ts b/apps/meteor/server/lib/dataExport/processDataDownloads.ts new file mode 100644 index 0000000..2a2cb9a --- /dev/null +++ b/apps/meteor/server/lib/dataExport/processDataDownloads.ts @@ -0,0 +1,287 @@ +import { randomUUID } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; + +import type { IExportOperation, IUser, RoomType } from '@rocket.chat/core-typings'; +import { Avatars, ExportOperations, UserDataFiles, Subscriptions } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import moment from 'moment'; + +import { settings } from '../../settings'; +import { joinPath } from '../fileUtils'; +import { i18n } from '../i18n'; +import { copyFileUpload } from './copyFileUpload'; +import { exportRoomMessagesToFile } from './exportRoomMessagesToFile'; +import { getPath } from './getPath'; +import { getRoomData } from './getRoomData'; +import { makeZipFile } from './makeZipFile'; +import { sendEmail } from './sendEmail'; +import { uploadZipFile } from './uploadZipFile'; +import { FileUpload } from '../media/file-upload'; +import { getURL } from '../utils/getURL'; + +const loadUserSubscriptions = async (_exportOperation: IExportOperation, fileType: 'json' | 'html', userId: IUser['_id']) => { + const roomList: ( + | { + roomId: string; + roomName: string; + userId: string | undefined; + exportedCount: number; + status: string; + type: RoomType; + targetFile: string; + } + | Record + )[] = []; + + const cursor = Subscriptions.findByUserId(userId); + for await (const subscription of cursor) { + const roomData = await getRoomData(subscription.rid, userId); + roomData.targetFile = `${(fileType === 'json' && roomData.roomName) || subscription.rid}.${fileType}`; + + roomList.push(roomData); + } + + return roomList; +}; + +const generateUserFile = async (exportOperation: IExportOperation, userData?: IUser) => { + if (!userData) { + return; + } + + await mkdir(exportOperation.exportPath, { recursive: true }); + + const { username, name, statusText, emails, roles, services } = userData; + + const dataToSave = { + username, + name, + statusText, + emails: emails?.map(({ address }) => address), + roles, + services: services ? Object.keys(services) : undefined, + }; + + const fileName = joinPath(exportOperation.exportPath, exportOperation.fullExport ? 'user.json' : 'user.html'); + + if (exportOperation.fullExport) { + await writeFile(fileName, JSON.stringify(dataToSave), { encoding: 'utf8' }); + + exportOperation.generatedUserFile = true; + return; + } + + return new Promise((resolve, reject) => { + const stream = createWriteStream(fileName, { encoding: 'utf8' }); + + stream.on('finish', resolve); + stream.on('error', reject); + + stream.write('\n'); + stream.write('\n'); + stream.write(`\n`); + for (const [key, value] of Object.entries(dataToSave)) { + stream.write(`

${escapeHTML(key)}:`); + if (typeof value === 'string') { + stream.write(escapeHTML(value)); + } else if (Array.isArray(value)) { + stream.write('
'); + + for (const item of value) { + stream.write(`${escapeHTML(String(item))}
`); + } + } + + stream.write('

\n'); + } + + stream.end(); + }); +}; + +const generateUserAvatarFile = async (exportOperation: IExportOperation, userData?: IUser) => { + if (!userData?.username) { + return; + } + + await mkdir(exportOperation.exportPath, { recursive: true }); + + const file = await Avatars.findOneByName(userData.username); + if (!file) { + return; + } + + const filePath = joinPath(exportOperation.exportPath, 'avatar'); + if (await FileUpload.copy?.(file, filePath)) { + exportOperation.generatedAvatar = true; + } +}; + +const generateChannelsFile = async (type: 'json' | 'html', exportPath: string, exportOperation: IExportOperation) => { + if (type !== 'json') { + return; + } + + await mkdir(exportOperation.exportPath, { recursive: true }); + + const fileName = joinPath(exportPath, 'channels.json'); + await writeFile( + fileName, + exportOperation.roomList + ?.map((roomData) => + JSON.stringify({ + roomId: roomData.roomId, + roomName: roomData.roomName, + type: roomData.type, + }), + ) + ?.join('\n') ?? '', + ); +}; + +const isExportComplete = (exportOperation: IExportOperation) => { + const incomplete = exportOperation.roomList?.some((exportOpRoomData) => exportOpRoomData.status !== 'completed') ?? false; + + return !incomplete; +}; + +const continueExportOperation = async function (exportOperation: IExportOperation): Promise { + if (exportOperation.status === 'completed') { + return; + } + + const exportType = exportOperation.fullExport ? 'json' : 'html'; + + if (!exportOperation.roomList) { + exportOperation.roomList = await loadUserSubscriptions(exportOperation, exportType, exportOperation.userId); + + if (exportOperation.fullExport) { + exportOperation.status = 'exporting-rooms'; + } else { + exportOperation.status = 'exporting'; + } + } + + try { + if (!exportOperation.generatedUserFile) { + await generateUserFile(exportOperation, exportOperation.userData); + } + + if (!exportOperation.generatedAvatar) { + await generateUserAvatarFile(exportOperation, exportOperation.userData); + } + + if (exportOperation.status === 'exporting-rooms') { + await generateChannelsFile(exportType, exportOperation.exportPath, exportOperation); + + exportOperation.status = 'exporting'; + } + + // Run every room on every request, to avoid missing new messages on the rooms that finished first. + if (exportOperation.status === 'exporting') { + if (!exportOperation.userNameTable) { + exportOperation.userNameTable = {}; + } + const { fileList } = await exportRoomMessagesToFile( + exportOperation.exportPath, + exportOperation.assetsPath, + exportType, + exportOperation.roomList, + exportOperation.userData, + {}, + exportOperation.userNameTable, + ); + if (!exportOperation.fileList) { + exportOperation.fileList = []; + } + exportOperation.fileList.push(...fileList); + + if (isExportComplete(exportOperation)) { + exportOperation.status = 'downloading'; + } + } + + const generatedFileName = randomUUID(); + const zipFolder = settings.get('UserData_FileSystemZipPath')?.trim() || '/tmp/zipFiles'; + + if (exportOperation.status === 'downloading') { + for await (const attachmentData of exportOperation.fileList) { + await copyFileUpload(attachmentData, exportOperation.assetsPath); + } + + const targetFile = joinPath(zipFolder, `${generatedFileName}.zip`); + await rm(targetFile, { force: true }); + + exportOperation.status = 'compressing'; + } + + if (exportOperation.status === 'compressing') { + await mkdir(zipFolder, { recursive: true }); + + exportOperation.generatedFile = joinPath(zipFolder, `${generatedFileName}.zip`); + try { + await access(exportOperation.generatedFile); + } catch (error) { + await makeZipFile(exportOperation.exportPath, exportOperation.generatedFile); + } + + exportOperation.status = 'uploading'; + } + + if (exportOperation.status === 'uploading') { + if (!exportOperation.generatedFile) { + throw new Error('No generated file'); + } + + const { _id: fileId } = await uploadZipFile(exportOperation.generatedFile, exportOperation.userId, exportType); + exportOperation.fileId = fileId; + + exportOperation.status = 'completed'; + await ExportOperations.updateOperation(exportOperation); + } + + await ExportOperations.updateOperation(exportOperation); + } catch (e) { + console.error(e); + } +}; + +export async function processDataDownloads(): Promise { + const operation = await ExportOperations.findOnePending(); + if (!operation) { + return; + } + + if (operation.status === 'completed') { + return; + } + + if (operation.status !== 'pending') { + // If the operation has started but was not updated in over a day, then skip it + if (operation._updatedAt && moment().diff(moment(operation._updatedAt), 'days') > 1) { + operation.status = 'skipped'; + await ExportOperations.updateOperation(operation); + return processDataDownloads(); + } + } + + await continueExportOperation(operation); + await ExportOperations.updateOperation(operation); + + if (operation.status === 'completed') { + const file = operation.fileId + ? await UserDataFiles.findOneById(operation.fileId) + : await UserDataFiles.findLastFileByUser(operation.userId); + if (!file) { + return; + } + + const subject = i18n.t('UserDataDownload_EmailSubject'); + const body = i18n.t('UserDataDownload_EmailBody', { + download_link: getURL(getPath(file._id), { cdn: false, full: true }), + }); + + await sendEmail(operation.userData, subject, body); + } +} diff --git a/apps/meteor/server/lib/dataExport/sendEmail.ts b/apps/meteor/server/lib/dataExport/sendEmail.ts new file mode 100644 index 0000000..123e8d6 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/sendEmail.ts @@ -0,0 +1,27 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { getUserEmailAddress } from '../../../lib/getUserEmailAddress'; +import { settings } from '../../settings'; +import * as Mailer from '../notifications/email/api'; + +export const sendEmail = async (userData: Pick, subject: string, body: string): Promise => { + const emailAddress = getUserEmailAddress(userData); + + if (!emailAddress) { + throw new Error('no email address found for user'); + } + + const to = `${userData.name} <${emailAddress}>`; + const from = settings.get('From_Email') as string; + + if (!Mailer.checkAddressFormat(emailAddress)) { + return; + } + + await Mailer.send({ + to, + from, + subject, + html: body, + }); +}; diff --git a/apps/meteor/server/lib/dataExport/sendFile.ts b/apps/meteor/server/lib/dataExport/sendFile.ts new file mode 100644 index 0000000..ac100c2 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/sendFile.ts @@ -0,0 +1,85 @@ +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path, { join } from 'node:path'; + +import type { IUser } from '@rocket.chat/core-typings'; + +import { i18n } from '../i18n'; +import { copyFileUpload } from './copyFileUpload'; +import { exportRoomMessagesToFile } from './exportRoomMessagesToFile'; +import { getPath } from './getPath'; +import { getRoomData } from './getRoomData'; +import { makeZipFile } from './makeZipFile'; +import { sendEmail } from './sendEmail'; +import { uploadZipFile } from './uploadZipFile'; +import { getURL } from '../utils/getURL'; + +type ExportFile = { + rid: string; + dateFrom: Date; + dateTo: Date; + format: 'html' | 'json'; +}; + +export const sendFile = async (data: ExportFile, user: IUser): Promise => { + const exportType = data.format; + + const baseDir = await mkdtemp(join(tmpdir(), 'exportFile-')); + + const exportPath = baseDir; + const assetsPath = path.join(baseDir, 'assets'); + + await mkdir(exportPath, { recursive: true }); + await mkdir(assetsPath, { recursive: true }); + + const roomData = await getRoomData(data.rid); + + roomData.targetFile = `${(data.format === 'json' && roomData.roomName) || roomData.roomId}.${data.format}`; + + const fullFileList: any[] = []; + + const roomsToExport = [roomData]; + + const filter = + !data.dateFrom && !data.dateTo + ? {} + : { + ts: { + ...(data.dateFrom && { $gte: data.dateFrom }), + ...(data.dateTo && { $lte: data.dateTo }), + }, + }; + + const exportMessages = async (): Promise => { + const { fileList } = await exportRoomMessagesToFile(exportPath, assetsPath, exportType, roomsToExport, user, filter, {}, false); + + fullFileList.push(...fileList); + + const [roomData] = roomsToExport; + + if (roomData.status !== 'completed') { + await exportMessages(); + } + }; + + await exportMessages(); + + const promises: Promise[] = []; + for await (const attachmentData of fullFileList) { + promises.push(copyFileUpload(attachmentData, assetsPath)); + } + await Promise.all(promises); + + const exportFile = `${baseDir}-export.zip`; + await makeZipFile(exportPath, exportFile); + + const file = await uploadZipFile(exportFile, user._id, exportType); + + const subject = i18n.t('Channel_Export'); + + const body = i18n.t('UserDataDownload_EmailBody', { + download_link: getURL(getPath(file._id), { cdn: false, full: true }), + }); + + await sendEmail(user, subject, body); +}; diff --git a/apps/meteor/server/lib/dataExport/sendViaEmail.ts b/apps/meteor/server/lib/dataExport/sendViaEmail.ts new file mode 100644 index 0000000..2015fa5 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/sendViaEmail.ts @@ -0,0 +1,86 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Messages, Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import moment from 'moment'; + +import { settings } from '../../settings'; +import { getMomentLocale } from '../getMomentLocale'; +import { Message } from '../messaging/Message'; +import * as Mailer from '../notifications/email/api'; + +export async function sendViaEmail( + data: { + rid: string; + toUsers: string[]; + toEmails: string[]; + subject: string; + messages: string[]; + language: string; + }, + user: IUser, +): Promise<{ + missing: string[]; +}> { + const emails = data.toEmails.map((email) => email.trim()).filter(Boolean); + + const missing = [...data.toUsers].filter(Boolean); + + ( + await Users.findUsersByUsernames(data.toUsers, { + projection: { 'username': 1, 'emails.address': 1 }, + }).toArray() + ).forEach((user: IUser) => { + const emailAddress = user.emails?.[0].address; + + if (!emailAddress) { + return; + } + + if (!Mailer.checkAddressFormat(emailAddress)) { + throw new Error('error-invalid-email'); + } + + const found = missing.indexOf(String(user.username)); + if (found !== -1) { + missing.splice(found, 1); + } + + emails.push(emailAddress); + }); + + const email = user.emails?.[0]?.address; + const lang = data.language || user.language || 'en'; + + const localMoment = moment(); + + if (lang !== 'en') { + const localeFn = await getMomentLocale(lang); + if (localeFn) { + Function(localeFn).call({ moment }); + localMoment.locale(lang); + } + } + + const html = ( + await Messages.findByRoomIdAndMessageIds(data.rid, data.messages, { + sort: { ts: 1 }, + }).toArray() + ) + .map((message: IMessage) => { + const dateTime = moment(message.ts).locale(lang).format('L LT'); + return `

${escapeHTML( + message.u.username ?? '', + )} ${dateTime}
${Message.parse(message, data.language)}

`; + }) + .join(''); + + await Mailer.send({ + to: emails, + from: settings.get('From_Email'), + replyTo: email, + subject: data.subject, + html, + }); + + return { missing }; +} diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts new file mode 100644 index 0000000..50b7493 --- /dev/null +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -0,0 +1,41 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; + +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { FileUpload } from '../media/file-upload'; + +export const uploadZipFile = async (filePath: string, userId: IUser['_id'], exportType: 'json' | 'html'): Promise => { + const contentType = 'application/zip'; + const { size } = await stat(filePath); + + const user = await Users.findOneById(userId); + let userDisplayName = userId; + if (user) { + userDisplayName = user.name || user.username || userId; + } + + const utcDate = new Date().toISOString().split('T')[0]; + const fileSuffix = exportType === 'json' ? '-data' : ''; + const fileId = Random.id(); + + const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}-${fileId}.zip`); + + const details = { + _id: fileId, + userId, + type: contentType, + size, + name: newFileName, + }; + + const stream = createReadStream(filePath); + + const userDataStore = FileUpload.getStore('UserDataFiles'); + + const file = await userDataStore.insert(details, stream); + + return file; +}; diff --git a/apps/meteor/server/lib/debug.js b/apps/meteor/server/lib/debug.js new file mode 100644 index 0000000..068b9e1 --- /dev/null +++ b/apps/meteor/server/lib/debug.js @@ -0,0 +1,138 @@ +import { InstanceStatus } from '@rocket.chat/instance-status'; +import { Logger } from '@rocket.chat/logger'; +import { tracerActiveSpan } from '@rocket.chat/tracing'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import _ from 'underscore'; + +import { getMethodArgs } from './logger/logPayloads'; +import { metrics } from './metrics'; +import { settings } from '../settings'; +import { getModifiedHttpHeaders } from './shared/getModifiedHttpHeaders'; + +const logger = new Logger('Meteor'); + +let Log_Trace_Methods; +let Log_Trace_Subscriptions; +settings.watch('Log_Trace_Methods', (value) => { + Log_Trace_Methods = value; +}); +settings.watch('Log_Trace_Subscriptions', (value) => { + Log_Trace_Subscriptions = value; +}); + +let Log_Trace_Methods_Filter; +let Log_Trace_Subscriptions_Filter; +settings.watch('Log_Trace_Methods_Filter', (value) => { + Log_Trace_Methods_Filter = value ? new RegExp(value) : undefined; +}); +settings.watch('Log_Trace_Subscriptions_Filter', (value) => { + Log_Trace_Subscriptions_Filter = value ? new RegExp(value) : undefined; +}); + +const traceConnection = (enable, filter, prefix, name, connection, userId) => { + if (!enable) { + return; + } + + if (filter && !filter.test(name)) { + return; + } + + if (connection) { + console.log(name, { + id: connection.id, + clientAddress: connection.clientAddress, + httpHeaders: getModifiedHttpHeaders(connection.httpHeaders), + userId, + }); + } else { + console.log(name, 'no-connection'); + } +}; + +const wrapMethods = function (name, originalHandler, methodsMap) { + methodsMap[name] = function (...originalArgs) { + traceConnection(Log_Trace_Methods, Log_Trace_Methods_Filter, 'method', name, this.connection, this.userId); + + const method = name === 'stream' ? `${name}:${originalArgs[0]}` : name; + + const meteorMethodLabels = { + method, + has_connection: this.connection != null, + has_user: this.userId != null, + }; + + const end = metrics.meteorMethods.startTimer(meteorMethodLabels); + const endHistogram = metrics.meteorMethodsSeconds.startTimer(meteorMethodLabels); + + logger.method({ + method, + userId: Meteor.userId(), + userAgent: this.connection?.httpHeaders['user-agent'], + referer: this.connection?.httpHeaders.referer, + remoteIP: this.connection?.clientAddress, + instanceId: InstanceStatus.id(), + ...getMethodArgs(name, originalArgs), + }); + + return tracerActiveSpan( + `Method ${name}`, + { + attributes: { + method: name, + userId: this.userId, + }, + }, + async () => { + const result = await originalHandler.apply(this, originalArgs); + end(); + endHistogram(); + return result; + }, + ); + }; +}; + +const originalMeteorMethods = Meteor.methods; + +Meteor.methods = function (methodMap) { + _.each(methodMap, (handler, name) => { + wrapMethods(name, handler, methodMap); + }); + originalMeteorMethods(methodMap); +}; + +const originalMeteorPublish = Meteor.publish; + +Meteor.publish = function (name, func) { + return originalMeteorPublish(name, function (...args) { + traceConnection(Log_Trace_Subscriptions, Log_Trace_Subscriptions_Filter, 'subscription', name, this.connection, this.userId); + + logger.subscription({ + publication: name, + userId: this.userId, + userAgent: this.connection?.httpHeaders['user-agent'], + referer: this.connection?.httpHeaders.referer, + remoteIP: this.connection?.clientAddress, + instanceId: InstanceStatus.id(), + }); + + const end = metrics.meteorSubscriptions.startTimer({ subscription: name }); + const endHistogram = metrics.meteorSubscriptionsSeconds.startTimer({ subscription: name }); + + const originalReady = this.ready; + this.ready = function () { + end(); + endHistogram(); + return originalReady.apply(this, args); + }; + + return func.apply(this, args); + }); +}; + +WebApp.rawConnectHandlers.use((req, res, next) => { + res.setHeader('X-Instance-ID', InstanceStatus.id()); + return next(); +}); diff --git a/apps/meteor/server/lib/defaultBlockedDomainsList.ts b/apps/meteor/server/lib/defaultBlockedDomainsList.ts new file mode 100644 index 0000000..2d5e73d --- /dev/null +++ b/apps/meteor/server/lib/defaultBlockedDomainsList.ts @@ -0,0 +1,945 @@ +export const emailDomainDefaultBlackList = [ + '0-mail.com', + '0815.ru', + '0815.su', + '0clickemail.com', + '0wnd.net', + '0wnd.org', + '10mail.org', + '10minut.com.pl', + '10minutemail.co.za', + '10minutemail.com', + '10minutemail.de', + '123-m.com', + '1chuan.com', + '1fsdfdsfsdf.tk', + '1pad.de', + '1zhuan.com', + '20email.eu', + '20mail.eu', + '20mail.it', + '20minutemail.com', + '21cn.com', + '2fdgdfgdfgdf.tk', + '2prong.com', + '30minutemail.com', + '33mail.com', + '3d-painting.com', + '3trtretgfrfe.tk', + '4gfdsgfdgfd.tk', + '4warding.com', + '4warding.net', + '4warding.org', + '5ghgfhfghfgh.tk', + '60minutemail.com', + '675hosting.com', + '675hosting.net', + '675hosting.org', + '6hjgjhgkilkj.tk', + '6ip.us', + '6paq.com', + '6url.com', + '75hosting.com', + '75hosting.net', + '75hosting.org', + '7days-printing.com', + '7tags.com', + '99experts.com', + '9ox.net', + 'a-bc.net', + 'a45.in', + 'abcmail.email', + 'abyssmail.com', + 'acentri.com', + 'advantimo.com', + 'afrobacon.com', + 'ag.us.to', + 'agedmail.com', + 'ahk.jp', + 'ajaxapp.net', + 'alivance.com', + 'ama-trade.de', + 'amail.com', + 'amilegit.com', + 'amiri.net', + 'amiriindustries.com', + 'anappthat.com', + 'ano-mail.net', + 'anonbox.net', + 'anonmails.de', + 'anonymail.dk', + 'anonymbox.com', + 'antichef.com', + 'antichef.net', + 'antireg.ru', + 'antispam.de', + 'antispammail.de', + 'appixie.com', + 'armyspy.com', + 'artman-conception.com', + 'aver.com', + 'azmeil.tk', + 'baxomale.ht.cx', + 'beddly.com', + 'beefmilk.com', + 'bigprofessor.so', + 'bigstring.com', + 'binkmail.com', + 'bio-muesli.net', + 'blogmyway.org', + 'bobmail.info', + 'bofthew.com', + 'bootybay.de', + 'boun.cr', + 'bouncr.com', + 'boxformail.in', + 'breakthru.com', + 'brefmail.com', + 'brennendesreich.de', + 'broadbandninja.com', + 'bsnow.net', + 'bspamfree.org', + 'bu.mintemail.com', + 'buffemail.com', + 'bugmenot.com', + 'bumpymail.com', + 'bund.us', + 'bundes-li.ga', + 'burnthespam.info', + 'burstmail.info', + 'buymoreplays.com', + 'buyusedlibrarybooks.org', + 'byom.de', + 'c2.hu', + 'cachedot.net', + 'card.zp.ua', + 'casualdx.com', + 'cbair.com', + 'cek.pm', + 'cellurl.com', + 'centermail.com', + 'centermail.net', + 'chammy.info', + 'cheatmail.de', + 'childsavetrust.org', + 'chogmail.com', + 'choicemail1.com', + 'chong-mail.com', + 'chong-mail.net', + 'chong-mail.org', + 'clixser.com', + 'cmail.com', + 'cmail.net', + 'cmail.org', + 'coldemail.info', + 'consumerriot.com', + 'cool.fr.nf', + 'correo.blogos.net', + 'cosmorph.com', + 'courriel.fr.nf', + 'courrieltemporaire.com', + 'crapmail.org', + 'crazymailing.com', + 'cubiclink.com', + 'curryworld.de', + 'cust.in', + 'cuvox.de', + 'd3p.dk', + 'dacoolest.com', + 'daintly.com', + 'dandikmail.com', + 'dayrep.com', + 'dbunker.com', + 'dcemail.com', + 'deadaddress.com', + 'deadspam.com', + 'deagot.com', + 'dealja.com', + 'delikkt.de', + 'despam.it', + 'despammed.com', + 'devnullmail.com', + 'dfgh.net', + 'digitalsanctuary.com', + 'dingbone.com', + 'discard.email', + 'discardmail.com', + 'discardmail.de', + 'disposableaddress.com', + 'disposableemailaddresses.com', + 'disposableemailaddresses.emailmiser.com', + 'disposableinbox.com', + 'dispose.it', + 'disposeamail.com', + 'disposemail.com', + 'dispostable.com', + 'dlemail.ru', + 'dm.w3internet.co.uk', + 'dm.w3internet.co.ukexample.com', + 'dodgeit.com', + 'dodgit.com', + 'dodgit.org', + 'doiea.com', + 'domozmail.com', + 'donemail.ru', + 'dontreg.com', + 'dontsendmespam.de', + 'dotmsg.com', + 'drdrb.com', + 'drdrb.net', + 'droplar.com', + 'dropmail.me', + 'dt.com', + 'duam.net', + 'dudmail.com', + 'dump-email.info', + 'dumpandjunk.com', + 'dumpmail.de', + 'dumpyemail.com', + 'duskmail.com', + 'e-mail.com', + 'e-mail.org', + 'e4ward.com', + 'easytrashmail.com', + 'einmalmail.de', + 'einrot.com', + 'einrot.de', + 'eintagsmail.de', + 'email60.com', + 'emaildienst.de', + 'emailgo.de', + 'emailias.com', + 'emailigo.de', + 'emailinfive.com', + 'emaillime.com', + 'emailmiser.com', + 'emailproxsy.com', + 'emailsensei.com', + 'emailtemporanea.com', + 'emailtemporanea.net', + 'emailtemporar.ro', + 'emailtemporario.com.br', + 'emailthe.net', + 'emailtmp.com', + 'emailto.de', + 'emailwarden.com', + 'emailx.at.hm', + 'emailxfer.com', + 'emeil.in', + 'emeil.ir', + 'emil.com', + 'emz.net', + 'enterto.com', + 'ephemail.net', + 'ero-tube.org', + 'etranquil.com', + 'etranquil.net', + 'etranquil.org', + 'evopo.com', + 'explodemail.com', + 'express.net.ua', + 'eyepaste.com', + 'fakeinbox.com', + 'fakeinformation.com', + 'fakemail.fr', + 'fakemailz.com', + 'fammix.com', + 'fansworldwide.de', + 'fantasymail.de', + 'fastacura.com', + 'fastchevy.com', + 'fastchrysler.com', + 'fastkawasaki.com', + 'fastmazda.com', + 'fastmitsubishi.com', + 'fastnissan.com', + 'fastsubaru.com', + 'fastsuzuki.com', + 'fasttoyota.com', + 'fastyamaha.com', + 'fatflap.com', + 'fdfdsfds.com', + 'fightallspam.com', + 'figjs.com', + 'fiifke.de', + 'filzmail.com', + 'fivemail.de', + 'fixmail.tk', + 'fizmail.com', + 'fleckens.hu', + 'flemail.ru', + 'flyspam.com', + 'footard.com', + 'forgetmail.com', + 'fr33mail.info', + 'frapmail.com', + 'freundin.ru', + 'friendlymail.co.uk', + 'front14.org', + 'fuckingduh.com', + 'fudgerub.com', + 'fux0ringduh.com', + 'fyii.de', + 'garliclife.com', + 'gehensiemirnichtaufdensack.de', + 'gelitik.in', + 'get1mail.com', + 'get2mail.fr', + 'getairmail.com', + 'getmails.eu', + 'getonemail.com', + 'getonemail.net', + 'ghosttexter.de', + 'giantmail.de', + 'girlsundertheinfluence.com', + 'gishpuppy.com', + 'gmial.com', + 'goemailgo.com', + 'gorillaswithdirtyarmpits.com', + 'gotmail.com', + 'gotmail.net', + 'gotmail.org', + 'gotti.otherinbox.com', + 'gowikibooks.com', + 'gowikicampus.com', + 'gowikicars.com', + 'gowikifilms.com', + 'gowikigames.com', + 'gowikimusic.com', + 'gowikimusic.great-host.in', + 'gowikinetwork.com', + 'gowikitravel.com', + 'gowikitv.com', + 'grandmamail.com', + 'grandmasmail.com', + 'great-host.in', + 'greensloth.com', + 'grr.la', + 'gsrv.co.uk', + 'guerillamail.biz', + 'guerillamail.com', + 'guerillamail.net', + 'guerillamail.org', + 'guerrillamail.biz', + 'guerrillamail.com', + 'guerrillamail.de', + 'guerrillamail.info', + 'guerrillamail.net', + 'guerrillamail.org', + 'guerrillamailblock.com', + 'gustr.com', + 'h.mintemail.com', + 'h8s.org', + 'hacccc.com', + 'haltospam.com', + 'harakirimail.com', + 'hartbot.de', + 'hat-geld.de', + 'hatespam.org', + 'hellodream.mobi', + 'herp.in', + 'hidemail.de', + 'hidzz.com', + 'hmamail.com', + 'hochsitze.com', + 'hopemail.biz', + 'hotpop.com', + 'hulapla.de', + 'iaoss.com', + 'ieatspam.eu', + 'ieatspam.info', + 'ieh-mail.de', + 'ihateyoualot.info', + 'iheartspam.org', + 'ikbenspamvrij.nl', + 'imails.info', + 'imgof.com', + 'imstations.com', + 'inbax.tk', + 'inbox.si', + 'inboxalias.com', + 'inboxclean.com', + 'inboxclean.org', + 'inboxproxy.com', + 'incognitomail.com', + 'incognitomail.net', + 'incognitomail.org', + 'infocom.zp.ua', + 'inoutmail.de', + 'inoutmail.eu', + 'inoutmail.info', + 'inoutmail.net', + 'insorg-mail.info', + 'instant-mail.de', + 'ip6.li', + 'ipoo.org', + 'irish2me.com', + 'iwi.net', + 'jamit.com.au', + 'jetable.com', + 'jetable.fr.nf', + 'jetable.net', + 'jetable.org', + 'jnxjn.com', + 'jourrapide.com', + 'jsrsolutions.com', + 'junk1e.com', + 'kasmail.com', + 'kaspop.com', + 'keepmymail.com', + 'killmail.com', + 'killmail.net', + 'kimsdisk.com', + 'kingsq.ga', + 'kir.ch.tc', + 'klassmaster.com', + 'klassmaster.net', + 'klzlk.com', + 'kook.ml', + 'koszmail.pl', + 'kulturbetrieb.info', + 'kurzepost.de', + 'l33r.eu', + 'lackmail.net', + 'lags.us', + 'lawlita.com', + 'lazyinbox.com', + 'letthemeatspam.com', + 'lhsdv.com', + 'lifebyfood.com', + 'link2mail.net', + 'litedrop.com', + 'loadby.us', + 'login-email.ml', + 'lol.ovpn.to', + 'lolfreak.net', + 'lookugly.com', + 'lopl.co.cc', + 'lortemail.dk', + 'lovemeleaveme.com', + 'lr78.com', + 'lroid.com', + 'lukop.dk', + 'm21.cc', + 'm4ilweb.info', + 'maboard.com', + 'mail-filter.com', + 'mail-temporaire.fr', + 'mail.by', + 'mail.mezimages.net', + 'mail.zp.ua', + 'mail114.net', + 'mail1a.de', + 'mail21.cc', + 'mail2rss.org', + 'mail333.com', + 'mail4trash.com', + 'mailbidon.com', + 'mailbiz.biz', + 'mailblocks.com', + 'mailbucket.org', + 'mailcat.biz', + 'mailcatch.com', + 'mailde.de', + 'mailde.info', + 'maildrop.cc', + 'maildx.com', + 'maileater.com', + 'mailed.ro', + 'maileimer.de', + 'mailexpire.com', + 'mailfa.tk', + 'mailforspam.com', + 'mailfreeonline.com', + 'mailfs.com', + 'mailguard.me', + 'mailimate.com', + 'mailin8r.com', + 'mailinater.com', + 'mailinator.com', + 'mailinator.net', + 'mailinator.org', + 'mailinator.us', + 'mailinator2.com', + 'mailincubator.com', + 'mailismagic.com', + 'mailmate.com', + 'mailme.ir', + 'mailme.lv', + 'mailme24.com', + 'mailmetrash.com', + 'mailmetrash.comilzilla.org', + 'mailmoat.com', + 'mailms.com', + 'mailnator.com', + 'mailnesia.com', + 'mailnull.com', + 'mailorg.org', + 'mailpick.biz', + 'mailproxsy.com', + 'mailquack.com', + 'mailrock.biz', + 'mailscrap.com', + 'mailshell.com', + 'mailsiphon.com', + 'mailslapping.com', + 'mailslite.com', + 'mailtemp.info', + 'mailtome.de', + 'mailtothis.com', + 'mailtrash.net', + 'mailtv.net', + 'mailtv.tv', + 'mailzilla.com', + 'mailzilla.org', + 'mailzilla.orgmbx.cc', + 'makemetheking.com', + 'manifestgenerator.com', + 'manybrain.com', + 'mbx.cc', + 'mega.zik.dj', + 'meinspamschutz.de', + 'meltmail.com', + 'messagebeamer.de', + 'mezimages.net', + 'mierdamail.com', + 'migumail.com', + 'ministry-of-silly-walks.de', + 'mintemail.com', + 'misterpinball.de', + 'mjukglass.nu', + 'mmailinater.com', + 'moakt.com', + 'mobi.web.id', + 'mobileninja.co.uk', + 'moburl.com', + 'mohmal.com', + 'moncourrier.fr.nf', + 'monemail.fr.nf', + 'monmail.fr.nf', + 'monumentmail.com', + 'msa.minsmail.com', + 'mt2009.com', + 'mt2014.com', + 'mx0.wwwnew.eu', + 'my10minutemail.com', + 'mycard.net.ua', + 'mycleaninbox.net', + 'myemailboxy.com', + 'mymail-in.net', + 'mymailoasis.com', + 'mynetstore.de', + 'mypacks.net', + 'mypartyclip.de', + 'myphantomemail.com', + 'mysamp.de', + 'myspaceinc.com', + 'myspaceinc.net', + 'myspaceinc.org', + 'myspacepimpedup.com', + 'myspamless.com', + 'mytemp.email', + 'mytempemail.com', + 'mytempmail.com', + 'mytrashmail.com', + 'nabuma.com', + 'neomailbox.com', + 'nepwk.com', + 'nervmich.net', + 'nervtmich.net', + 'netmails.com', + 'netmails.net', + 'netzidiot.de', + 'neverbox.com', + 'nice-4u.com', + 'nincsmail.com', + 'nincsmail.hu', + 'nnh.com', + 'no-spam.ws', + 'noblepioneer.com', + 'nobulk.com', + 'noclickemail.com', + 'nogmailspam.info', + 'nomail.pw', + 'nomail.xl.cx', + 'nomail2me.com', + 'nomorespamemails.com', + 'nonspam.eu', + 'nonspammer.de', + 'noref.in', + 'nospam.ze.tc', + 'nospam4.us', + 'nospamfor.us', + 'nospammail.net', + 'nospamthanks.info', + 'notmailinator.com', + 'notsharingmy.info', + 'nowhere.org', + 'nowmymail.com', + 'nurfuerspam.de', + 'nus.edu.sg', + 'nwldx.com', + 'objectmail.com', + 'obobbo.com', + 'odaymail.com', + 'odnorazovoe.ru', + 'one-time.email', + 'oneoffemail.com', + 'oneoffmail.com', + 'one2mail.info', + 'onewaymail.com', + 'onlatedotcom.info', + 'online.ms', + 'oopi.org', + 'opayq.com', + 'ordinaryamerican.net', + 'otherinbox.codupmyspace.com', + 'otherinbox.com', + 'ourklips.com', + 'outlawspam.com', + 'ovpn.to', + 'owlpic.com', + 'pancakemail.com', + 'paplease.com', + 'pcusers.otherinbox.com', + 'pepbot.com', + 'pfui.ru', + 'pimpedupmyspace.com', + 'pjjkp.com', + 'plexolan.de', + 'poczta.onet.pl', + 'politikerclub.de', + 'pooae.com', + 'poofy.org', + 'pookmail.com', + 'privacy.net', + 'privatdemail.net', + 'privy-mail.com', + 'privymail.de', + 'proxymail.eu', + 'prtnx.com', + 'prtz.eu', + 'punkass.com', + 'putthisinyourspamdatabase.com', + 'pwrby.com', + 'quickinbox.com', + 'quickmail.nl', + 'rcpt.at', + 'reallymymail.com', + 'realtyalerts.ca', + 'recode.me', + 'recursor.net', + 'recyclemail.dk', + 'regbypass.com', + 'regbypass.comsafe-mail.net', + 'rejectmail.com', + 'reliable-mail.com', + 'rhyta.com', + 'rklips.com', + 'rmqkr.net', + 'royal.net', + 'rppkn.com', + 'rtrtr.com', + 's0ny.net', + 'safe-mail.net', + 'safersignup.de', + 'safetymail.info', + 'safetypost.de', + 'sandelf.de', + 'saynotospams.com', + 'schafmail.de', + 'schrott-email.de', + 'secretemail.de', + 'secure-mail.biz', + 'selfdestructingmail.com', + 'selfdestructingmail.org', + 'sendspamhere.com', + 'sendspamhere.com', + 'senseless-entertainment.com', + 'services391.com', + 'sharedmailbox.org', + 'sharklasers.com', + 'shieldedmail.com', + 'shieldemail.com', + 'shiftmail.com', + 'shitmail.me', + 'shitmail.org', + 'shitware.nl', + 'shmeriously.com', + 'shortmail.net', + 'shotmail.ru', + 'showslow.de', + 'sibmail.com', + 'sinnlos-mail.de', + 'siteposter.net', + 'skeefmail.com', + 'slapsfromlastnight.com', + 'slaskpost.se', + 'slipry.net', + 'slopsbox.com', + 'slushmail.com', + 'smashmail.de', + 'smellfear.com', + 'smellrear.com', + 'snakemail.com', + 'sneakemail.com', + 'sneakmail.de', + 'snkmail.com', + 'sofimail.com', + 'sofort-mail.de', + 'softpls.asia', + 'sogetthis.com', + 'sohu.com', + 'solvemail.info', + 'soodonims.com', + 'spa.com', + 'spaereplease.com', + 'spam.la', + 'spam.su', + 'spam4.me', + 'spamail.de', + 'spamarrest.com', + 'spamavert.com', + 'spambob.com', + 'spambob.net', + 'spambob.org', + 'spambog.com', + 'spambog.de', + 'spambog.net', + 'spambog.ru', + 'spambox.info', + 'spambox.irishspringrealty.com', + 'spambox.us', + 'spamcannon.com', + 'spamcannon.net', + 'spamcero.com', + 'spamcon.org', + 'spamcorptastic.com', + 'spamcowboy.com', + 'spamcowboy.net', + 'spamcowboy.org', + 'spamday.com', + 'spamex.com', + 'spamfree.eu', + 'spamfree24.com', + 'spamfree24.de', + 'spamfree24.eu', + 'spamfree24.info', + 'spamfree24.net', + 'spamfree24.org', + 'spamgoes.in', + 'spamgourmet.com', + 'spamgourmet.net', + 'spamgourmet.org', + 'spamherelots.com', + 'spamhereplease.com', + 'spamhole.com', + 'spamify.com', + 'spaminator.de', + 'spamkill.info', + 'spaml.com', + 'spaml.de', + 'spammotel.com', + 'spamobox.com', + 'spamoff.de', + 'spamsalad.in', + 'spamslicer.com', + 'spamspot.com', + 'spamstack.net', + 'spamthis.co.uk', + 'spamthisplease.com', + 'spamtrail.com', + 'spamtroll.net', + 'speed.1s.fr', + 'spikio.com', + 'spoofmail.de', + 'squizzy.de', + 'ssoia.com', + 'startkeys.com', + 'stinkefinger.net', + 'stop-my-spam.com', + 'stuffmail.de', + 'super-auswahl.de', + 'supergreatmail.com', + 'supermailer.jp', + 'superrito.com', + 'superstachel.de', + 'suremail.info', + 'svk.jp', + 'sweetxxx.de', + 'tagyourself.com', + 'talkinator.com', + 'tapchicuoihoi.com', + 'teewars.org', + 'teleosaurs.xyz', + 'teleworm.com', + 'teleworm.us', + 'temp-mail.org', + 'temp-mail.ru', + 'temp.emeraldwebmail.com', + 'temp.headstrong.de', + 'tempalias.com', + 'tempe-mail.com', + 'tempemail.biz', + 'tempemail.co.za', + 'tempemail.com', + 'tempemail.net', + 'tempemail.net', + 'tempinbox.co.uk', + 'tempinbox.com', + 'tempmail.eu', + 'tempmail.it', + 'tempmail2.com', + 'tempmaildemo.com', + 'tempmailer.com', + 'tempmailer.de', + 'tempomail.fr', + 'temporarily.de', + 'temporarioemail.com.br', + 'temporaryemail.net', + 'temporaryemail.us', + 'temporaryforwarding.com', + 'temporaryinbox.com', + 'temporarymailaddress.com', + 'tempsky.com', + 'tempthe.net', + 'tempymail.com', + 'thanksnospam.info', + 'thankyou2010.com', + 'thc.st', + 'thecloudindex.com', + 'thelimestones.com', + 'thisisnotmyrealemail.com', + 'thismail.net', + 'thrma.com', + 'throwawayemailaddress.com', + 'tilien.com', + 'tittbit.in', + 'tizi.com', + 'tmail.ws', + 'tmailinator.com', + 'toiea.com', + 'toomail.biz', + 'topranklist.de', + 'tradermail.info', + 'trash-amil.com', + 'trash-mail.at', + 'trash-mail.com', + 'trash-mail.de', + 'trash2009.com', + 'trash2010.com', + 'trash2011.com', + 'trashdevil.com', + 'trashdevil.de', + 'trashemail.de', + 'trashmail.at', + 'trashmail.com', + 'trashmail.de', + 'trashmail.me', + 'trashmail.net', + 'trashmail.org', + 'trashmail.ws', + 'trashmailer.com', + 'trashymail.com', + 'trashymail.net', + 'trbvm.com', + 'trbvn.com', + 'trialmail.de', + 'trillianpro.com', + 'tryalert.com', + 'turual.com', + 'twinmail.de', + 'twoweirdtricks.com', + 'tyldd.com', + 'uggsrock.com', + 'umail.net', + 'upliftnow.com', + 'uplipht.com', + 'uroid.com', + 'us.af', + 'username.e4ward.com', + 'venompen.com', + 'veryrealemail.com', + 'vidchart.com', + 'viditag.com', + 'viewcastmedia.com', + 'viewcastmedia.net', + 'viewcastmedia.org', + 'viewcastmediae', + 'viralplays.com', + 'vkcode.ru', + 'vomoto.com', + 'vpn.st', + 'vsimcard.com', + 'vubby.com', + 'walala.org', + 'walkmail.net', + 'walkmail.ru', + 'wasteland.rfc822.org', + 'webemail.me', + 'webm4il.info', + 'webuser.in', + 'wee.my', + 'weg-werf-email.de', + 'wegwerf-email-addressen.de', + 'wegwerf-emails.de', + 'wegwerfadresse.de', + 'wegwerfemail.com', + 'wegwerfemail.de', + 'wegwerfmail.de', + 'wegwerfmail.info', + 'wegwerfmail.net', + 'wegwerfmail.org', + 'wetrainbayarea.com', + 'wetrainbayarea.org', + 'wh4f.org', + 'whatiaas.com', + 'whatpaas.com', + 'whatsaas.com', + 'whopy.com', + 'whtjddn.33mail.com', + 'whyspam.me', + 'wilemail.com', + 'willhackforfood.biz', + 'willselfdestruct.com', + 'winemaven.info', + 'wronghead.com', + 'wuzup.net', + 'wuzupmail.net', + 'www.e4ward.com', + 'www.gishpuppy.com', + 'www.mailinator.com', + 'wwwnew.eu', + 'x.ip6.li', + 'xagloo.com', + 'xemaps.com', + 'xents.com', + 'xmaily.com', + 'xoxy.net', + 'xyzfree.net', + 'yapped.net', + 'yeah.net', + 'yep.it', + 'yogamaven.com', + 'yomail.info', + 'yopmail.com', + 'yopmail.fr', + 'yopmail.net', + 'yourdomain.com', + 'ypmail.webarnak.fr.eu.org', + 'yuurok.com', + 'z1p.biz', + 'za.com', + 'zehnminuten.de', + 'zehnminutenmail.de', + 'zetmail.com', + 'zippymail.info', + 'zoaxe.com', + 'zoemail.com', + 'zoemail.net', + 'zoemail.org', + 'zomg.info', + 'zxcv.com', + 'zxcvbnm.com', + 'zzz.com', +]; diff --git a/apps/meteor/server/lib/deprecationWarningLogger.ts b/apps/meteor/server/lib/deprecationWarningLogger.ts new file mode 100644 index 0000000..b1b512b --- /dev/null +++ b/apps/meteor/server/lib/deprecationWarningLogger.ts @@ -0,0 +1,183 @@ +import { Logger } from '@rocket.chat/logger'; +import type { PathPattern } from '@rocket.chat/rest-typings'; +import semver from 'semver'; + +import { metrics } from './metrics/lib/metrics'; + +const deprecationLogger = new Logger('DeprecationWarning'); + +type MessageFn = (params: { parameter: string; version: string } & T) => string; + +const throwErrorsForVersionsUnder = process.env.ROCKET_CHAT_DEPRECATION_THROW_ERRORS_FOR_VERSIONS_UNDER; + +const writeDeprecationHeader = (res: Response | undefined, type: string, message: string, version: string) => { + if (res) { + res.headers.set('x-deprecation-type', type); + res.headers.set('x-deprecation-message', message); + res.headers.set('x-deprecation-version', version); + } +}; + +const compareVersions = (version: string, message: string) => { + if (throwErrorsForVersionsUnder && semver.lte(version, throwErrorsForVersionsUnder)) { + throw new Error(message); + } +}; + +export type DeprecationLoggerNextPlannedVersion = '9.0.0'; + +// `TEST_MODE=true` makes the logger throw so accidental usage of a deprecated +// method/endpoint surfaces immediately in the test run. The API end-to-end +// suite sets `TEST_MODE=api` instead — every other test-mode behavior stays on +// (rate limiter bypass, short cache TTLs, etc.) but the deprecation logger +// only logs because the suite intentionally exercises deprecated DDP methods +// through `/v1/method.call/:method` and the streamer. +const shouldThrowOnDeprecation = process.env.TEST_MODE === 'true'; + +export const apiDeprecationLogger = ((logger) => { + return { + endpoint: (endpoint: string, version: DeprecationLoggerNextPlannedVersion, res: Response, info = '') => { + const message = `The endpoint "${endpoint}" is deprecated and will be removed on version ${version}${info ? ` (${info})` : ''}`; + + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + compareVersions(version, message); + + writeDeprecationHeader(res, 'endpoint-deprecation', message, version); + + metrics.deprecations.inc({ type: 'deprecation', kind: 'endpoint', name: endpoint }); + metrics.deprecationsTotal.inc({ type: 'deprecation', kind: 'endpoint', name: endpoint }); + + logger.warn({ msg: message, endpoint, version, info }); + }, + parameter: ( + endpoint: string, + parameter: string, + version: DeprecationLoggerNextPlannedVersion, + res: Response, + messageGenerator?: MessageFn<{ endpoint: string }>, + ) => { + const message = + messageGenerator?.({ + parameter, + endpoint, + version, + }) ?? `The parameter "${parameter}" in the endpoint "${endpoint}" is deprecated and will be removed on version ${version}`; + compareVersions(version, message); + + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + metrics.deprecations.inc({ type: 'parameter-deprecation', kind: 'endpoint', name: endpoint, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'parameter-deprecation', kind: 'endpoint', name: endpoint, params: parameter }); + + writeDeprecationHeader(res, 'parameter-deprecation', message, version); + + logger.warn({ msg: message, endpoint, parameter, version }); + }, + + deprecatedParameterUsage: ( + endpoint: string, + parameter: string, + version: DeprecationLoggerNextPlannedVersion, + res: Response, + messageGenerator?: MessageFn<{ + endpoint: string; + }>, + ) => { + const message = + messageGenerator?.({ + parameter, + endpoint, + version, + }) ?? `The usage of the endpoint "${endpoint}" is deprecated and will be removed on version ${version}`; + + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + compareVersions(version, message); + + metrics.deprecations.inc({ type: 'invalid-usage', kind: 'endpoint', name: endpoint, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'invalid-usage', kind: 'endpoint', name: endpoint, params: parameter }); + + writeDeprecationHeader(res, 'invalid-usage', message, version); + + logger.warn({ msg: message, endpoint, parameter, version }); + }, + }; +})(deprecationLogger.section('API')); + +type DeprecationPath = T extends `/${string}` ? (T extends PathPattern ? T : never) : string; + +type DeprecationInfo = DeprecationPath | Array>; + +export const methodDeprecationLogger = ((logger) => { + return { + method: (method: string, version: DeprecationLoggerNextPlannedVersion, info: DeprecationInfo) => { + const infoArray = Array.isArray(info) ? info : [info]; + const paths = infoArray.map((p) => (typeof p === 'string' && p.startsWith('/') ? `"${p}"` : p)).join(' or '); + const replacement = infoArray.length > 0 ? `Use the ${paths} endpoint${infoArray.length > 1 ? 's' : ''} instead` : ''; + const message = `The method "${method}" is deprecated and will be removed on version ${version}${replacement ? ` (${replacement})` : ''}`; + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + compareVersions(version, message); + metrics.deprecations.inc({ type: 'deprecation', name: method, kind: 'method' }); + metrics.deprecationsTotal.inc({ type: 'deprecation', name: method, kind: 'method' }); + logger.warn({ msg: message, method, version, replacement }); + }, + parameter: (method: string, parameter: string, version: DeprecationLoggerNextPlannedVersion) => { + const message = `The parameter "${parameter}" in the method "${method}" is deprecated and will be removed on version ${version}`; + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + metrics.deprecations.inc({ type: 'parameter-deprecation', name: method, params: parameter }); + metrics.deprecationsTotal.inc({ type: 'parameter-deprecation', name: method, params: parameter }); + + compareVersions(version, message); + logger.warn({ msg: message, method, parameter, version }); + }, + deprecatedParameterUsage: ( + method: string, + parameter: string, + version: DeprecationLoggerNextPlannedVersion, + messageGenerator?: MessageFn<{ + method: string; + }>, + ) => { + const message = + messageGenerator?.({ + parameter, + method, + version, + }) ?? `The usage of the method "${method}" is deprecated and will be removed on version ${version}`; + + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + compareVersions(version, message); + + metrics.deprecations.inc({ type: 'invalid-usage', name: method, params: parameter, kind: 'method' }); + metrics.deprecationsTotal.inc({ type: 'invalid-usage', name: method, params: parameter, kind: 'method' }); + + logger.warn({ msg: message, method, parameter, version }); + }, + /** @deprecated */ + warn: (message: string) => { + if (shouldThrowOnDeprecation) { + throw new Error(message); + } + + compareVersions('0.0.0', message); + logger.warn({ msg: message }); + }, + }; +})(deprecationLogger.section('METHOD')); + +export const streamDeprecationLogger = deprecationLogger.section('STREAM'); diff --git a/apps/meteor/server/lib/e2e/beforeCreateRoom.ts b/apps/meteor/server/lib/e2e/beforeCreateRoom.ts new file mode 100644 index 0000000..d18d484 --- /dev/null +++ b/apps/meteor/server/lib/e2e/beforeCreateRoom.ts @@ -0,0 +1,23 @@ +import { MeteorError } from '@rocket.chat/core-services'; + +import { settings } from '../../settings'; +import { prepareCreateRoomCallback } from '../callbacks/beforeCreateRoomCallback'; + +prepareCreateRoomCallback.add(({ type, extraData }) => { + if (!settings.get('E2E_Enable')) { + return; + } + + if ( + (type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) || + (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms')) + ) { + extraData.encrypted = extraData.encrypted ?? true; + } + if (type === 'p' && extraData.federated !== true && settings.get('E2E_Force_Encryption_For_Private_Rooms')) { + if (extraData.encrypted === false) { + throw new MeteorError('error-encrypted-private-rooms-enforced', 'Workspace policy requires all private rooms to be encrypted.'); + } + extraData.encrypted = true; + } +}); diff --git a/apps/meteor/server/lib/e2e/functions/handleSuggestedGroupKey.ts b/apps/meteor/server/lib/e2e/functions/handleSuggestedGroupKey.ts new file mode 100644 index 0000000..3e8c721 --- /dev/null +++ b/apps/meteor/server/lib/e2e/functions/handleSuggestedGroupKey.ts @@ -0,0 +1,48 @@ +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { notifyOnSubscriptionChangedById, notifyOnRoomChangedById } from '../../notifyListener'; + +export async function handleSuggestedGroupKey( + handle: 'accept' | 'reject', + rid: string, + userId: string | null, + method: string, +): Promise { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method }); + } + + const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + if (!sub) { + throw new Meteor.Error('error-subscription-not-found', 'Subscription not found', { method }); + } + + const suggestedKey = String(sub.E2ESuggestedKey ?? '').trim(); + if (!suggestedKey) { + throw new Meteor.Error('error-no-suggested-key-available', 'No suggested key available', { method }); + } + + if (handle === 'accept') { + // A merging process can happen here, but we're not doing that for now + // If a user already has oldRoomKeys, we will ignore the suggested ones + const oldKeys = sub.oldRoomKeys ? undefined : sub.suggestedOldRoomKeys; + await Subscriptions.setGroupE2EKeyAndOldRoomKeys(sub._id, suggestedKey, oldKeys); + const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } + } + + if (handle === 'reject') { + const { modifiedCount } = await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } + } + + const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKeyAndOldRoomKeys(sub._id); + if (modifiedCount) { + void notifyOnSubscriptionChangedById(sub._id); + } +} diff --git a/apps/meteor/server/lib/e2e/functions/provideUsersSuggestedGroupKeys.ts b/apps/meteor/server/lib/e2e/functions/provideUsersSuggestedGroupKeys.ts new file mode 100644 index 0000000..cdaf18b --- /dev/null +++ b/apps/meteor/server/lib/e2e/functions/provideUsersSuggestedGroupKeys.ts @@ -0,0 +1,44 @@ +import type { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; + +import { canAccessRoomIdAsync } from '../../authorization/canAccessRoom'; +import { notifyOnSubscriptionChanged, notifyOnRoomChangedById } from '../../notifyListener'; + +export const provideUsersSuggestedGroupKeys = async ( + userId: IUser['_id'], + usersSuggestedGroupKeys: Record, +) => { + const roomIds = Object.keys(usersSuggestedGroupKeys); + + if (!roomIds.length) { + return; + } + + // Process should try to process all rooms i have access instead of dying if one is not + for (const roomId of roomIds) { + if (!(await canAccessRoomIdAsync(roomId, userId))) { + continue; + } + + const usersWithSuggestedKeys = []; + for (const user of usersSuggestedGroupKeys[roomId]) { + const value = await Subscriptions.setGroupE2ESuggestedKeyAndOldRoomKeys(user._id, roomId, user.key, parseOldKeysDates(user.oldKeys)); + if (!value) { + continue; + } + void notifyOnSubscriptionChanged(value); + usersWithSuggestedKeys.push(user._id); + } + + await Rooms.removeUsersFromE2EEQueueByRoomId(roomId, usersWithSuggestedKeys); + void notifyOnRoomChangedById(roomId); + } +}; + +const parseOldKeysDates = (oldKeys: ISubscription['oldRoomKeys']) => { + if (!oldKeys) { + return; + } + + return oldKeys.map((key) => ({ ...key, ts: new Date(key.ts) })); +}; diff --git a/apps/meteor/server/lib/e2e/functions/resetRoomKey.ts b/apps/meteor/server/lib/e2e/functions/resetRoomKey.ts new file mode 100644 index 0000000..2f8397e --- /dev/null +++ b/apps/meteor/server/lib/e2e/functions/resetRoomKey.ts @@ -0,0 +1,123 @@ +import type { ISubscription, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { AnyBulkWriteOperation } from 'mongodb'; + +import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../notifyListener'; + +export async function resetRoomKey(roomId: string, userId: string, newRoomKey: string, newRoomKeyId: string) { + const user = await Users.findOneById>(userId, { projection: { e2e: 1 } }); + if (!user) { + throw new Error('error-user-not-found'); + } + + if (!user.e2e?.private_key || !user.e2e?.public_key) { + throw new Error('error-user-has-no-keys'); + } + + const room = await Rooms.findOneById>(roomId, { projection: { e2eKeyId: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + + if (!room.e2eKeyId) { + throw new Error('error-room-not-encrypted'); + } + + // We will update the subs of everyone who has a key for the room. For the ones that don't have, we will do nothing + const notifySubs = []; + const updateOps: AnyBulkWriteOperation[] = []; + const e2eQueue: IRoom['usersWaitingForE2EKeys'] = []; + + for await (const sub of Subscriptions.find({ + rid: roomId, + $or: [{ E2EKey: { $exists: true } }, { E2ESuggestedKey: { $exists: true } }], + })) { + // This replicates the oldRoomKeys array modifications allowing us to have the changes locally without finding them again + // which allows for quicker notifying + const keys = replicateMongoSlice(room.e2eKeyId, sub); + delete sub.E2ESuggestedKey; + delete sub.E2EKey; + delete sub.suggestedOldRoomKeys; + + const updateSet = { + $set: { + ...(keys && { oldRoomKeys: keys }), + }, + }; + updateOps.push({ + updateOne: { + filter: { _id: sub._id }, + update: { + $unset: { E2EKey: 1, E2ESuggestedKey: 1, suggestedOldRoomKeys: 1 }, + ...(Object.keys(updateSet.$set).length && updateSet), + }, + }, + }); + + if (userId !== sub.u._id) { + // Avoid notifying requesting user as notify will happen at the end + notifySubs.push({ + ...sub, + ...(keys && { oldRoomKeys: keys }), + }); + + // This is for allowing the key distribution process to start inmediately + pushToLimit(e2eQueue, { userId: sub.u._id, ts: new Date() }); + } + + if (updateOps.length >= 100) { + await writeAndNotify(updateOps, notifySubs); + } + } + + if (updateOps.length > 0) { + await writeAndNotify(updateOps, notifySubs); + } + + // after the old keys have been moved to the new prop, store room key on room + the e2e queue so key can be exchanged + // todo move to model method + const roomResult = await Rooms.resetRoomKeyAndSetE2EEQueueByRoomId(roomId, newRoomKeyId, e2eQueue); + // And set the new key to the user that called the func + const result = await Subscriptions.setE2EKeyByUserIdAndRoomId(userId, roomId, newRoomKey); + + if (result) { + void notifyOnSubscriptionChanged(result); + } + if (roomResult) { + void notifyOnRoomChanged(roomResult); + } +} + +export function pushToLimit( + arr: NonNullable, + item: NonNullable[number], + limit = 50, +) { + if (arr.length < limit) { + arr.push(item); + } +} + +async function writeAndNotify(updateOps: AnyBulkWriteOperation[], notifySubs: ISubscription[]) { + await Subscriptions.col.bulkWrite([...updateOps]); + notifySubs.forEach((sub) => { + void notifyOnSubscriptionChanged(sub); + }); + notifySubs.length = 0; + updateOps.length = 0; +} + +export function replicateMongoSlice(keyId: string, sub: ISubscription) { + if (!sub.E2EKey) { + return; + } + + if (!sub.oldRoomKeys) { + return [{ e2eKeyId: keyId, ts: new Date(), E2EKey: sub.E2EKey }]; + } + + const sortedKeys = sub.oldRoomKeys.toSorted((a, b) => b.ts.getTime() - a.ts.getTime()); + sortedKeys.unshift({ e2eKeyId: keyId, ts: new Date(), E2EKey: sub.E2EKey }); + + return sortedKeys.slice(0, 10); +} diff --git a/apps/meteor/server/lib/e2e/index.ts b/apps/meteor/server/lib/e2e/index.ts new file mode 100644 index 0000000..64051a5 --- /dev/null +++ b/apps/meteor/server/lib/e2e/index.ts @@ -0,0 +1,14 @@ +import { api } from '@rocket.chat/core-services'; + +import { callbacks } from '../callbacks'; + +import './beforeCreateRoom'; + +callbacks.add( + 'afterJoinRoom', + (_user, room) => { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + }, + callbacks.priority.MEDIUM, + 'e2e', +); diff --git a/apps/meteor/server/lib/eraseRoom.ts b/apps/meteor/server/lib/eraseRoom.ts new file mode 100644 index 0000000..6ebaaa4 --- /dev/null +++ b/apps/meteor/server/lib/eraseRoom.ts @@ -0,0 +1,59 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from './authorization/hasPermission'; +import { deleteRoom } from './rooms/deleteRoom'; +import { roomCoordinator } from './rooms/roomCoordinator'; + +export async function eraseRoom(roomOrId: string | IRoom, user: AtLeast): Promise { + const room = typeof roomOrId === 'string' ? await Rooms.findOneById(roomOrId) : roomOrId; + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'eraseRoom', + }); + } + + if (room.federated) { + throw new Meteor.Error('error-cannot-delete-federated-room', 'Cannot delete federated room', { + method: 'eraseRoom', + }); + } + + if ( + !(await roomCoordinator + .getRoomDirectives(room.t) + ?.canBeDeleted((permissionId, rid) => hasPermissionAsync(user, permissionId, rid), room)) + ) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'eraseRoom', + }); + } + + const team = room.teamId && (await Team.getOneById(room.teamId, { projection: { roomId: 1 } })); + if (team && !(await hasPermissionAsync(user, `delete-team-${room.t === 'c' ? 'channel' : 'group'}`, team.roomId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'eraseRoom', + }); + } + + if (Apps.self?.isLoaded()) { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomDeletePrevent, room); + if (prevent) { + throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the room erasing.'); + } + } + + await deleteRoom(room._id); + + if (team) { + await Message.saveSystemMessage('user-deleted-room-from-team', team.roomId, room.name || '', user); + } + + if (Apps.self?.isLoaded()) { + void Apps.self?.triggerEvent(AppEvents.IPostRoomDeleted, room); + } +} diff --git a/apps/meteor/server/lib/error-handler/RocketChat.ErrorHandler.ts b/apps/meteor/server/lib/error-handler/RocketChat.ErrorHandler.ts new file mode 100644 index 0000000..f544936 --- /dev/null +++ b/apps/meteor/server/lib/error-handler/RocketChat.ErrorHandler.ts @@ -0,0 +1,131 @@ +import { Settings, Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { throttledCounter } from '../../../lib/utils/throttledCounter'; +import { settings } from '../../settings'; +import { sendMessage } from '../messages/sendMessage'; + +const incException = throttledCounter((counter) => { + Settings.incrementValueById('Uncaught_Exceptions_Count', counter, { returnDocument: 'after' }) + .then((value) => { + if (value) { + settings.set(value); + } + }) + .catch(console.error); +}, 10000); + +class ErrorHandler { + reporting: boolean; + + rid: string | null; + + lastError: string | null; + + constructor() { + this.reporting = false; + this.rid = null; + this.lastError = null; + } + + async getRoomId(roomName: string): Promise { + if (!roomName) { + return null; + } + const room = await Rooms.findOneByName(roomName.replace('#', ''), { projection: { _id: 1, t: 1 } }); + if (!room || (room.t !== 'c' && room.t !== 'p')) { + return null; + } + return room._id; + } + + async trackError(message: string, stack?: string): Promise { + if (!this.reporting || !this.rid || this.lastError === message) { + return; + } + this.lastError = message; + const user = await Users.findOneById('rocket.cat'); + + if (stack) { + message = `${message}\n\`\`\`\n${stack}\n\`\`\``; + } + + await sendMessage(user, { msg: message }, { _id: this.rid }); + } +} + +const errorHandler = new ErrorHandler(); + +Meteor.startup(async () => { + settings.watch('Log_Exceptions_to_Channel', async (value) => { + errorHandler.rid = null; + const roomName = value.trim(); + + const rid = await errorHandler.getRoomId(roomName); + + errorHandler.reporting = Boolean(rid); + errorHandler.rid = rid; + }); +}); + +const originalMeteorDebug = Meteor._debug; + +Meteor._debug = function (message, stack, ...args) { + if (!errorHandler.reporting) { + return originalMeteorDebug.call(this, message, stack); + } + void errorHandler.trackError(message, stack); + return originalMeteorDebug.apply(this, [message, stack, ...args]); +}; + +/** + * If some promise is rejected and doesn't have a catch (unhandledRejection) it may cause this finally + * here https://github.com/meteor/meteor/blob/be6e529a739f47446950e045f4547ee60e5de7ae/packages/mongo/oplog_tailing.js#L348 + * to not be executed never ending the oplog worker and freezing the entire process. + * + * The only way to release the process is executing the following code via inspect: + * MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle._workerActive = false + * + * Since unhandled rejections are deprecated in NodeJS: + * (node:83382) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections + * that are not handled will terminate the Node.js process with a non-zero exit code. + * we will start respecting this and exit the process to prevent these kind of problems. + */ + +process.on('unhandledRejection', (error) => { + incException(); + + if (error instanceof Error) { + void errorHandler.trackError(error.message, error.stack); + } + + console.error('=== UnHandledPromiseRejection ==='); + console.error(error); + console.error('---------------------------------'); + console.error('Errors like this can cause oplog processing errors.'); + console.error( + 'Setting EXIT_UNHANDLEDPROMISEREJECTION will cause the process to exit allowing your service to automatically restart the process', + ); + console.error('Future node.js versions will automatically exit the process'); + console.error('================================='); + + if (process.env.TEST_MODE || process.env.NODE_ENV === 'development' || process.env.EXIT_UNHANDLEDPROMISEREJECTION) { + process.exit(1); + } +}); + +process.on('uncaughtException', async (error) => { + incException(); + + console.error('=== UnCaughtException ==='); + console.error(error); + console.error('-------------------------'); + console.error('Errors like this can cause oplog processing errors.'); + console.error('==========================='); + + void errorHandler.trackError(error.message, error.stack); + + if (process.env.TEST_MODE || process.env.NODE_ENV === 'development' || process.env.EXIT_UNHANDLEDPROMISEREJECTION) { + process.exit(1); + } +}); diff --git a/apps/meteor/server/lib/error-handler/index.ts b/apps/meteor/server/lib/error-handler/index.ts new file mode 100644 index 0000000..12b3196 --- /dev/null +++ b/apps/meteor/server/lib/error-handler/index.ts @@ -0,0 +1 @@ +import './RocketChat.ErrorHandler'; diff --git a/apps/meteor/server/lib/fileUtils.ts b/apps/meteor/server/lib/fileUtils.ts new file mode 100644 index 0000000..c60fbb7 --- /dev/null +++ b/apps/meteor/server/lib/fileUtils.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; + +import filenamify from 'filenamify'; + +export function fileName(name: string): string { + return filenamify(name, { replacement: '-' }); +} + +export function joinPath(base: string, name: string): string { + return path.join(base, fileName(name)); +} diff --git a/apps/meteor/server/lib/findUsersOfRoom.ts b/apps/meteor/server/lib/findUsersOfRoom.ts new file mode 100644 index 0000000..e0ab97b --- /dev/null +++ b/apps/meteor/server/lib/findUsersOfRoom.ts @@ -0,0 +1,60 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { FindPaginated } from '@rocket.chat/model-typings'; +import { Users } from '@rocket.chat/models'; +import type { FindCursor, FindOptions } from 'mongodb'; + +import { settings } from '../settings'; +import { effectiveStatusFilter, excludingOfflineFilter } from './statusVisibility/effectiveStatus'; + +type FindUsersParam = { + rid: string; + status?: UserStatus[] | 'not-offline'; + hidden?: Set; + skip?: number; + limit?: number; + filter?: string; + sort?: Record; +}; + +export async function findUsersOfRoom({ + rid, + status, + hidden, + skip = 0, + limit = 0, + filter = '', + sort, +}: FindUsersParam): Promise>> { + const hiddenCanAppear = Boolean(hidden?.size) && (!status || (Array.isArray(status) && status.includes(UserStatus.OFFLINE))); + const hiddenInRoom = + hiddenCanAppear && + (await Users.countDocuments({ __rooms: rid, active: true, username: { $exists: true }, _id: { $in: [...(hidden ?? [])] } })) > 0; + + const options: FindOptions = { + projection: { + name: 1, + username: 1, + nickname: 1, + status: 1, + avatarETag: 1, + _updatedAt: 1, + federated: 1, + }, + sort: { + ...(hiddenInRoom ? {} : { statusConnection: -1 }), + ...(sort || { ...(settings.get('UI_Use_Real_Name') && { name: 1 }), username: 1 }), + }, + ...(skip > 0 && { skip }), + ...(limit > 0 && { limit }), + }; + + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + + const statusFilter = Array.isArray(status) ? effectiveStatusFilter(status, hidden) : status && excludingOfflineFilter(hidden); + + return Users.findPaginatedByActiveUsersExcept(filter, undefined, options, searchFields, [ + { __rooms: rid }, + ...(statusFilter ? [statusFilter] : []), + ]); +} diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts new file mode 100644 index 0000000..f96dd92 --- /dev/null +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -0,0 +1,145 @@ +import { type IUser, ROOM_ROLE_PRIORITY_MAP, type ISubscription, type UserStatus } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { Document } from 'mongodb'; + +import { settings } from '../settings'; +import { effectiveStatusExpression, effectiveStatusFilter } from './statusVisibility/effectiveStatus'; + +type FindUsersParam = { + rid: string; + status?: UserStatus[]; + hidden?: Set; + skip?: number; + limit?: number; + filter?: string; + sort?: Record; + exceptions?: string[]; + extraQuery?: Document[]; +}; + +type UserWithRoleAndSubscriptionData = IUser & { + subscription: Pick; +}; + +export async function findUsersOfRoomOrderedByRole({ + rid, + status, + hidden, + skip = 0, + limit = 0, + filter = '', + sort = {}, + exceptions = [], + extraQuery = [], +}: FindUsersParam): Promise<{ members: UserWithRoleAndSubscriptionData[]; total: number }> { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + const termRegex = new RegExp(escapeRegExp(filter), 'i'); + const orStmt = filter && searchFields.length ? searchFields.map((field) => ({ [field.trim()]: termRegex })) : []; + + const { rolePriority: rolePrioritySort, username: usernameSort } = sort; + + const sortCriteria = { + rolePriority: rolePrioritySort ?? 1, + statusSortKey: -1, + ...(usernameSort + ? { username: usernameSort } + : { + ...(settings.get('UI_Use_Real_Name') ? { name: 1 } : { username: 1 }), + }), + }; + + const matchUserFilter = { + $and: [ + { + __rooms: rid, + active: true, + username: { + $exists: true, + ...(exceptions.length > 0 && { $nin: exceptions }), + }, + ...(filter && orStmt.length > 0 && { $or: orStmt }), + }, + ...(status ? [effectiveStatusFilter(status, hidden)] : []), + ...extraQuery, + ], + }; + + const visibleStatus = hidden?.size ? effectiveStatusExpression(hidden) : '$status'; + + const membersResult = Users.col.aggregate( + [ + { + $match: matchUserFilter, + }, + { + $project: { + _id: 1, + name: 1, + username: 1, + nickname: 1, + status: visibleStatus, + avatarETag: 1, + _updatedAt: 1, + federated: 1, + statusSortKey: { + // Adding this because offline users should come last + $cond: [{ $eq: [visibleStatus, 'offline'] }, null, visibleStatus], + }, + rolePriority: { + $ifNull: [`$roomRolePriorities.${rid}`, ROOM_ROLE_PRIORITY_MAP.default], + }, + }, + }, + { $sort: sortCriteria }, + ...(skip > 0 ? [{ $skip: skip }] : []), + ...(limit > 0 ? [{ $limit: limit }] : []), + { + $lookup: { + from: Subscriptions.getCollectionName(), + as: 'subscription', + let: { userId: '$_id', roomId: rid }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ['$rid', '$$roomId'] }, { $eq: ['$u._id', '$$userId'] }], + }, + }, + }, + { $project: { roles: 1, status: 1, ts: 1 } }, + ], + }, + }, + { + $addFields: { + roles: { $arrayElemAt: ['$subscription.roles', 0] }, + }, + }, + { + $unwind: { + path: '$subscription', + preserveNullAndEmptyArrays: true, + }, + }, + { + $project: { + statusSortKey: 0, + }, + }, + ], + { + allowDiskUse: true, + }, + ); + + const [members, totalCount] = await Promise.all([membersResult.toArray(), Users.countDocuments(matchUserFilter)]); + + return { + members: members.map((member: any) => { + delete member.rolePriority; + return member; + }), + total: totalCount, + }; +} diff --git a/apps/meteor/server/lib/getClientAddress.ts b/apps/meteor/server/lib/getClientAddress.ts new file mode 100644 index 0000000..88daac8 --- /dev/null +++ b/apps/meteor/server/lib/getClientAddress.ts @@ -0,0 +1,11 @@ +import type { ISocketConnection } from '@rocket.chat/core-typings'; + +export function getClientAddress(connection: Pick): string { + if (!connection) { + return ''; + } + + const { clientAddress, httpHeaders } = connection; + + return clientAddress || (httpHeaders?.['x-real-ip'] as string); +} diff --git a/apps/meteor/server/lib/getMaxLoginTokens.ts b/apps/meteor/server/lib/getMaxLoginTokens.ts new file mode 100644 index 0000000..8a9333f --- /dev/null +++ b/apps/meteor/server/lib/getMaxLoginTokens.ts @@ -0,0 +1,5 @@ +const maxLoginTokens = parseInt(String(process.env.MAX_RESUME_LOGIN_TOKENS)) || 50; + +export function getMaxLoginTokens(): number { + return maxLoginTokens; +} diff --git a/apps/meteor/server/lib/getMomentLocale.ts b/apps/meteor/server/lib/getMomentLocale.ts new file mode 100644 index 0000000..2f1a816 --- /dev/null +++ b/apps/meteor/server/lib/getMomentLocale.ts @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; + +const mapLocaleToMomentLocale: Record = { + ug: 'ug-cn', + zh: 'zh-cn', +}; + +export async function getMomentLocale(locale: string): Promise { + const localeLower = locale.toLowerCase(); + const localesPaths = [ + `moment-locales/${localeLower}.js`, + `moment-locales/${String(localeLower.split('-').shift())}.js`, + `moment-locales/${mapLocaleToMomentLocale[localeLower]}.js`, + ]; + for await (const localePath of localesPaths) { + try { + return await Assets.getTextAsync(localePath); + } catch (error) { + continue; + } + } + throw new Meteor.Error('moment-locale-not-found', `Moment locale not found: ${locale}`); +} diff --git a/apps/meteor/server/lib/getNestedProp.spec.ts b/apps/meteor/server/lib/getNestedProp.spec.ts new file mode 100644 index 0000000..b751f44 --- /dev/null +++ b/apps/meteor/server/lib/getNestedProp.spec.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; + +import { getNestedProp } from './getNestedProp'; + +describe('LDAP getNestedProp', () => { + it('should find shallow values', () => { + const customFields = { + foo: 'bar', + }; + + expect(getNestedProp(customFields, 'foo')).to.equal('bar'); + }); + + it('should find deep values', () => { + const customFields = { + foo: { + bar: 'baz', + }, + }; + + expect(getNestedProp(customFields, 'foo.bar')).to.equal('baz'); + }); +}); diff --git a/apps/meteor/server/lib/getNestedProp.ts b/apps/meteor/server/lib/getNestedProp.ts new file mode 100644 index 0000000..9836683 --- /dev/null +++ b/apps/meteor/server/lib/getNestedProp.ts @@ -0,0 +1,7 @@ +export const getNestedProp = (customFields: Record, property: string): unknown => { + try { + return property.split('.').reduce((acc, el) => acc[el], customFields); + } catch { + // ignore errors + } +}; diff --git a/apps/meteor/server/lib/getSetupWizardParameters.ts b/apps/meteor/server/lib/getSetupWizardParameters.ts new file mode 100644 index 0000000..a921331 --- /dev/null +++ b/apps/meteor/server/lib/getSetupWizardParameters.ts @@ -0,0 +1,16 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +import { settings } from '../settings'; + +export type SetupWizardParameters = { + settings: ISetting[]; + serverAlreadyRegistered: boolean; +}; + +export const isSetupWizardCompleted = (): boolean => settings.get('Show_Setup_Wizard') === 'completed'; + +export const getSetupWizardParameters = async (): Promise => ({ + settings: await Settings.findSetupWizardSettings().toArray(), + serverAlreadyRegistered: !!settings.get('Cloud_Workspace_Client_Id') || process.env.DEPLOY_PLATFORM === 'rocket-cloud', +}); diff --git a/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts b/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts new file mode 100644 index 0000000..82e74c1 --- /dev/null +++ b/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts @@ -0,0 +1,23 @@ +import type { AtLeast, IUser } from '@rocket.chat/core-typings'; + +import { settings } from '../settings'; + +export function getSubscriptionAutotranslateDefaultConfig(user: AtLeast): + | { + autoTranslate: boolean; + autoTranslateLanguage: string; + } + | undefined { + if (!settings.get('AutoTranslate_AutoEnableOnJoinRoom')) { + return; + } + + const languageSetting = settings.get('Language'); + + const { language: userLanguage } = user.settings?.preferences || {}; + if (!userLanguage || userLanguage === 'default' || languageSetting === userLanguage) { + return; + } + + return { autoTranslate: true, autoTranslateLanguage: userLanguage }; +} diff --git a/apps/meteor/server/lib/http/call.ts b/apps/meteor/server/lib/http/call.ts new file mode 100644 index 0000000..95da4c4 --- /dev/null +++ b/apps/meteor/server/lib/http/call.ts @@ -0,0 +1,205 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { HTTP } from 'meteor/http'; +import { URL } from 'meteor/url'; + +import { truncate } from '../../../lib/utils/stringUtils'; + +// Code extracted from https://github.com/meteor/meteor/blob/master/packages/deprecated/http +// Modified to: +// - Respect proxy envvars such as HTTP_PROXY and NO_PROXY +// - Respect HTTP_DEFAULT_TIMEOUT envvar or use 20s when it is not set + +const envTimeout = parseInt(process.env.HTTP_DEFAULT_TIMEOUT || '', 10); +const defaultTimeout = !isNaN(envTimeout) ? envTimeout : 20000; + +type HttpCallOptions = { + content?: string | URLSearchParams; + data?: Record; + query?: string; + params?: Record; + auth?: string; + headers?: Record; + timeout?: number; + followRedirects?: boolean; + referrer?: string; + integrity?: string; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +interface HTTPResponse { + statusCode?: number; + headers?: { [id: string]: string }; + content?: string; + data?: any; + ok?: boolean; + redirected?: boolean; +} + +type callbackFn = (error: Error | undefined, result?: HTTPResponse) => void; + +// Fill in `response.data` if the content-type is JSON. +function populateData(response: Record): void { + // Read Content-Type header, up to a ';' if there is one. + // A typical header might be "application/json; charset=utf-8" + // or just "application/json". + const contentType = (response.headers['content-type'] || ';').split(';')[0]; + + // Only try to parse data as JSON if server sets correct content type. + if (['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].indexOf(contentType) >= 0) { + try { + response.data = JSON.parse(response.content); + } catch (err) { + response.data = null; + } + } else { + response.data = null; + } +} + +function makeErrorByStatus(statusCode: number, content: string): Error { + let message = `failed [${statusCode}]`; + + if (content) { + message += `${truncate(content.replace(/\n/g, ' '), 500)}`; + } + + return new Error(message); +} + +function _call(httpMethod: string, url: string, options: HttpCallOptions, callback: callbackFn): void { + const method = (httpMethod || '').toUpperCase(); + + if (!/^https?:\/\//.test(url)) { + throw new Error('url must be absolute and start with http:// or https://'); + } + + const headers: Record = {}; + let { content } = options; + + if (!('timeout' in options)) { + options.timeout = defaultTimeout; + } + + if (options.data) { + content = JSON.stringify(options.data); + headers['Content-Type'] = 'application/json'; + } + + let paramsForUrl; + let paramsForBody; + + if (content || method === 'GET' || method === 'HEAD') { + paramsForUrl = options.params; + } else { + paramsForBody = options.params; + } + + const newUrl = URL._constructUrl(url, options.query, paramsForUrl); + + if (options.auth) { + if (options.auth.indexOf(':') < 0) { + throw new Error('auth option should be of the form "username:password"'); + } + + const base64 = Buffer.from(options.auth, 'ascii').toString('base64'); + headers.Authorization = `Basic ${base64}`; + } + + if (paramsForBody) { + const data = new URLSearchParams(); + Object.entries(paramsForBody).forEach(([key, value]) => { + data.append(key, value); + }); + content = data; + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + const { headers: receivedHeaders } = options; + + if (receivedHeaders) { + Object.keys(receivedHeaders).forEach((key) => { + headers[key] = receivedHeaders[key]; + }); + } + + // wrap callback to add a 'response' property on an error, in case + // we have both (http 4xx/5xx error, which has a response payload) + const wrappedCallback = ((cb: callbackFn): { (error: Error | undefined, response?: HTTPResponse): void } => { + let called = false; + return (error: Error | undefined, response?: HTTPResponse): void => { + if (!called) { + called = true; + if (error && response) { + (error as any).response = response; + } + cb(error, response); + } + }; + })(callback); + + // is false if false, otherwise always true + const followRedirects = options.followRedirects === false ? 'manual' : 'follow'; + + const requestOptions = { + method, + jar: false, + timeout: options.timeout, + body: content, + redirect: followRedirects, + referrer: options.referrer, + integrity: options.integrity, + headers, + ignoreSsrfValidation: true, + } as const; + + fetch(newUrl, requestOptions) + .then(async (res) => { + const content = await res.text(); + const response: HTTPResponse = {}; + response.statusCode = res.status; + response.content = `${content}`; + + // fetch headers don't allow simple read using bracket notation + // so we iterate their entries and assign them to a new Object + response.headers = {}; + for (const entry of (res.headers as any).entries()) { + const [key, val] = entry; + response.headers[key] = val; + } + + response.ok = res.ok; + response.redirected = res.redirected; + + populateData(response); + + if (response.statusCode >= 400) { + const error = makeErrorByStatus(response.statusCode, response.content); + wrappedCallback(error, response); + } else { + wrappedCallback(undefined, response); + } + }) + .catch((err) => wrappedCallback(err)); +} + +function httpCallAsync(httpMethod: string, url: string, options: HttpCallOptions, callback: callbackFn): void; +function httpCallAsync(httpMethod: string, url: string, callback: callbackFn): void; +function httpCallAsync(httpMethod: string, url: string, optionsOrCallback: HttpCallOptions | callbackFn = {}, callback?: callbackFn): void { + // If the options argument was omitted, adjust the arguments: + if (!callback && typeof optionsOrCallback === 'function') { + return _call(httpMethod, url, {}, optionsOrCallback); + } + + return _call(httpMethod, url, optionsOrCallback as HttpCallOptions, callback as callbackFn); +} + +export const httpCall = async (httpMethod: string, url: string, options: HttpCallOptions) => { + return new Promise((resolve, reject) => { + httpCallAsync.bind(HTTP)(httpMethod, url, options, (error, result) => { + if (error) { + return reject(error); + } + resolve(result); + }); + }); +}; diff --git a/apps/meteor/server/lib/i18n.ts b/apps/meteor/server/lib/i18n.ts new file mode 100644 index 0000000..7ec51e0 --- /dev/null +++ b/apps/meteor/server/lib/i18n.ts @@ -0,0 +1,99 @@ +import { availableTranslationNamespaces, defaultTranslationNamespace, extractTranslationNamespaces } from '@rocket.chat/i18n'; +import languages from '@rocket.chat/i18n/dist/languages'; + +import { i18n } from '../../app/utils/lib/i18n'; + +// Meteor bundler is incredible (derogatory) +// eslint-disable-next-line no-constant-condition +if (false) { + // @ts-expect-error unreachable code + import('@rocket.chat/i18n/dist/resources/af.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ar.i18n.json'); + import('@rocket.chat/i18n/dist/resources/az.i18n.json'); + import('@rocket.chat/i18n/dist/resources/be-BY.i18n.json'); + import('@rocket.chat/i18n/dist/resources/bg.i18n.json'); + import('@rocket.chat/i18n/dist/resources/bn-BD.i18n.json'); + import('@rocket.chat/i18n/dist/resources/bn-IN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/bs.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ca.i18n.json'); + import('@rocket.chat/i18n/dist/resources/cs.i18n.json'); + import('@rocket.chat/i18n/dist/resources/cy.i18n.json'); + import('@rocket.chat/i18n/dist/resources/da.i18n.json'); + import('@rocket.chat/i18n/dist/resources/de-AT.i18n.json'); + import('@rocket.chat/i18n/dist/resources/de-IN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/de.i18n.json'); + import('@rocket.chat/i18n/dist/resources/el.i18n.json'); + import('@rocket.chat/i18n/dist/resources/en.i18n.json'); + import('@rocket.chat/i18n/dist/resources/eo.i18n.json'); + import('@rocket.chat/i18n/dist/resources/es.i18n.json'); + import('@rocket.chat/i18n/dist/resources/et.i18n.json'); + import('@rocket.chat/i18n/dist/resources/eu.i18n.json'); + import('@rocket.chat/i18n/dist/resources/fa.i18n.json'); + import('@rocket.chat/i18n/dist/resources/fi.i18n.json'); + import('@rocket.chat/i18n/dist/resources/fr.i18n.json'); + import('@rocket.chat/i18n/dist/resources/gl.i18n.json'); + import('@rocket.chat/i18n/dist/resources/he.i18n.json'); + import('@rocket.chat/i18n/dist/resources/hi-IN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/hi.i18n.json'); + import('@rocket.chat/i18n/dist/resources/hr.i18n.json'); + import('@rocket.chat/i18n/dist/resources/hu.i18n.json'); + import('@rocket.chat/i18n/dist/resources/id.i18n.json'); + import('@rocket.chat/i18n/dist/resources/it.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ja.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ka-GE.i18n.json'); + import('@rocket.chat/i18n/dist/resources/km.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ko.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ku.i18n.json'); + import('@rocket.chat/i18n/dist/resources/lo.i18n.json'); + import('@rocket.chat/i18n/dist/resources/lt.i18n.json'); + import('@rocket.chat/i18n/dist/resources/lv.i18n.json'); + import('@rocket.chat/i18n/dist/resources/mn.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ms-MY.i18n.json'); + import('@rocket.chat/i18n/dist/resources/nb.i18n.json'); + import('@rocket.chat/i18n/dist/resources/nl.i18n.json'); + import('@rocket.chat/i18n/dist/resources/nn.i18n.json'); + import('@rocket.chat/i18n/dist/resources/pa-IN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/pl.i18n.json'); + import('@rocket.chat/i18n/dist/resources/pt-BR.i18n.json'); + import('@rocket.chat/i18n/dist/resources/pt.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ro.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ru.i18n.json'); + import('@rocket.chat/i18n/dist/resources/se.i18n.json'); + import('@rocket.chat/i18n/dist/resources/si.i18n.json'); + import('@rocket.chat/i18n/dist/resources/sk-SK.i18n.json'); + import('@rocket.chat/i18n/dist/resources/sl-SI.i18n.json'); + import('@rocket.chat/i18n/dist/resources/sq.i18n.json'); + import('@rocket.chat/i18n/dist/resources/sr.i18n.json'); + import('@rocket.chat/i18n/dist/resources/sv.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ta-IN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/th-TH.i18n.json'); + import('@rocket.chat/i18n/dist/resources/tl.i18n.json'); + import('@rocket.chat/i18n/dist/resources/tr.i18n.json'); + import('@rocket.chat/i18n/dist/resources/ug.i18n.json'); + import('@rocket.chat/i18n/dist/resources/uk.i18n.json'); + import('@rocket.chat/i18n/dist/resources/vi-VN.i18n.json'); + import('@rocket.chat/i18n/dist/resources/zh-HK.i18n.json'); + import('@rocket.chat/i18n/dist/resources/zh-TW.i18n.json'); + import('@rocket.chat/i18n/dist/resources/zh.i18n.json'); +} + +void i18n.init({ + lng: 'en', + fallbackLng: 'en', + defaultNS: defaultTranslationNamespace, + ns: availableTranslationNamespaces, + nsSeparator: '.', + resources: Object.fromEntries( + languages.map((language) => [ + language, + extractTranslationNamespaces( + // TODO: commonjs is terrible but we don't have esm build yet + // eslint-disable-next-line import-x/no-dynamic-require + require(`@rocket.chat/i18n/dist/resources/${language}.i18n.json`) as unknown as Record, + ), + ]), + ), + initImmediate: false, +}); + +export { i18n }; diff --git a/apps/meteor/server/lib/import/classes/ImportDataConverter.ts b/apps/meteor/server/lib/import/classes/ImportDataConverter.ts new file mode 100644 index 0000000..6027520 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImportDataConverter.ts @@ -0,0 +1,180 @@ +import type { IImportRecord, IImportUser, IImportMessage, IImportChannel, IImportContact } from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; +import { ImportData } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; + +import type { IConversionCallbacks } from '../definitions/IConversionCallbacks'; +import { ContactConverter } from './converters/ContactConverter'; +import { ConverterCache } from './converters/ConverterCache'; +import { type MessageConversionCallbacks, MessageConverter } from './converters/MessageConverter'; +import type { RecordConverter, RecordConverterOptions } from './converters/RecordConverter'; +import { RoomConverter } from './converters/RoomConverter'; +import { UserConverter, type UserConverterOptions } from './converters/UserConverter'; + +export type ConverterOptions = UserConverterOptions & Omit; + +export class ImportDataConverter { + protected _options: ConverterOptions; + + protected _userConverter: UserConverter; + + protected _roomConverter: RoomConverter; + + protected _messageConverter: MessageConverter; + + protected _contactConverter: ContactConverter; + + protected _cache = new ConverterCache(); + + public get options(): ConverterOptions { + return this._options; + } + + constructor(logger: Logger, options?: ConverterOptions) { + this._options = { + workInMemory: false, + ...(options || {}), + }; + + this.initializeUserConverter(logger); + this.initializeContactConverter(logger); + this.initializeRoomConverter(logger); + this.initializeMessageConverter(logger); + } + + protected getRecordConverterOptions(): RecordConverterOptions { + return { + ...pick(this._options, 'workInMemory'), + // DbData is deleted by this class directly, so the converters don't need to do it individually + deleteDbData: false, + }; + } + + protected getUserConverterOptions(): UserConverterOptions { + return { + flagEmailsAsVerified: false, + skipExistingUsers: false, + skipNewUsers: false, + + ...pick( + this._options, + 'flagEmailsAsVerified', + 'skipExistingUsers', + 'skipNewUsers', + 'skipUserCallbacks', + 'skipDefaultChannels', + 'quickUserInsertion', + 'enableEmail2fa', + ), + }; + } + + protected initializeUserConverter(logger: Logger): void { + const userOptions = { + ...this.getRecordConverterOptions(), + ...this.getUserConverterOptions(), + }; + + this._userConverter = new UserConverter(userOptions, logger, this._cache); + } + + protected initializeContactConverter(logger: Logger): void { + const contactOptions = { + ...this.getRecordConverterOptions(), + }; + + this._contactConverter = new ContactConverter(contactOptions, logger, this._cache); + } + + protected initializeRoomConverter(logger: Logger): void { + const roomOptions = { + ...this.getRecordConverterOptions(), + }; + + this._roomConverter = new RoomConverter(roomOptions, logger, this._cache); + } + + protected initializeMessageConverter(logger: Logger): void { + const messageOptions = { + ...this.getRecordConverterOptions(), + }; + + this._messageConverter = new MessageConverter(messageOptions, logger, this._cache); + } + + async addContact(data: IImportContact): Promise { + return this._contactConverter.addObject(data); + } + + async addUser(data: IImportUser): Promise { + return this._userConverter.addObject(data); + } + + async addChannel(data: IImportChannel): Promise { + return this._roomConverter.addObject(data); + } + + async addMessage(data: IImportMessage, useQuickInsert = false): Promise { + return this._messageConverter.addObject(data, { + useQuickInsert: useQuickInsert || undefined, + }); + } + + async convertContacts(callbacks: IConversionCallbacks): Promise { + return this._contactConverter.convertData(callbacks); + } + + async convertUsers(callbacks: IConversionCallbacks): Promise { + return this._userConverter.convertData(callbacks); + } + + async convertChannels(startedByUserId: string, callbacks: IConversionCallbacks): Promise { + return this._roomConverter.convertChannels(startedByUserId, callbacks); + } + + async convertMessages(callbacks: MessageConversionCallbacks): Promise { + return this._messageConverter.convertData(callbacks); + } + + async convertData(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise { + await this.convertUsers(callbacks); + await this.convertContacts(callbacks); + await this.convertChannels(startedByUserId, callbacks); + await this.convertMessages(callbacks); + + process.nextTick(async () => { + await this.clearSuccessfullyImportedData(); + }); + } + + protected getAllConverters(): RecordConverter[] { + return [this._userConverter, this._roomConverter, this._messageConverter]; + } + + public async clearImportData(): Promise { + if (!this._options.workInMemory) { + // Using raw collection since its faster + await ImportData.col.deleteMany({}); + } + + await Promise.all(this.getAllConverters().map((converter) => converter.clearImportData())); + } + + async clearSuccessfullyImportedData(): Promise { + if (!this._options.workInMemory) { + await ImportData.col.deleteMany({ + errors: { + $exists: false, + }, + }); + } + + await Promise.all(this.getAllConverters().map((converter) => converter.clearSuccessfullyImportedData())); + } + + public abort(): void { + this.getAllConverters().forEach((converter) => { + converter.aborted = true; + }); + } +} diff --git a/apps/meteor/server/lib/import/classes/Importer.ts b/apps/meteor/server/lib/import/classes/Importer.ts new file mode 100644 index 0000000..517f7a2 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/Importer.ts @@ -0,0 +1,420 @@ +import { api } from '@rocket.chat/core-services'; +import type { + IImport, + IImportRecord, + IImportChannel, + IImportUser, + IImportProgress, + IImporterShortSelection, + IImportContact, +} from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Settings, ImportData, Imports } from '@rocket.chat/models'; +import AdmZip from 'adm-zip'; +import type { MatchKeysAndValues, MongoServerError } from 'mongodb'; + +import { ImportDataConverter } from './ImportDataConverter'; +import type { ConverterOptions } from './ImportDataConverter'; +import { ImporterProgress } from './ImporterProgress'; +import { ImporterWebsocket } from './ImporterWebsocket'; +import { ProgressStep, ImportPreparingStartedStates } from '../../../../app/importer/lib/ImporterProgressStep'; +import { t } from '../../../../app/utils/lib/i18n'; +import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../notifyListener'; +import type { ImporterInfo } from '../definitions/ImporterInfo'; +import { Selection, SelectionChannel, SelectionUser } from '../index'; + +type OldSettings = { + allowedDomainList?: string | null; + allowUsernameChange?: boolean | null; + maxFileSize?: number | null; + mediaTypeWhiteList?: string | null; + mediaTypeBlackList?: string | null; +}; + +/** + * Base class for all of the importers. + */ +export class Importer { + private _reportProgressHandler: ReturnType | undefined; + + protected AdmZip = AdmZip; + + protected converter: ImportDataConverter; + + protected info: ImporterInfo; + + protected logger: Logger; + + protected oldSettings: OldSettings; + + protected _lastProgressReportTotal = 0; + + public importRecord: IImport; + + public progress: ImporterProgress; + + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + if (!info.key || !info.importer) { + throw new Error('Information passed in must be a valid ImporterInfo instance.'); + } + + this.info = info; + this.logger = new Logger(`${this.info.name} Importer`); + + this.converter = new ImportDataConverter(this.logger, converterOptions); + + this.importRecord = importRecord; + this.progress = new ImporterProgress(this.info.key, this.info.name); + this.oldSettings = {}; + + this.progress.step = this.importRecord.status; + this._lastProgressReportTotal = 0; + this.reloadCount(); + + this.logger.debug({ msg: 'Constructed a new Importer.', importerName: this.info.name }); + } + + /** + * Registers the file name and content type on the import operation + */ + async startFileUpload(fileName: string, contentType?: string): Promise { + await this.updateProgress(ProgressStep.UPLOADING); + return this.updateRecord({ file: fileName, ...(contentType ? { contentType } : {}) }); + } + + /** + * Takes the uploaded file and extracts the users, channels, and messages from it. + * + * @param {string} _fullFilePath the full path of the uploaded file + * @returns {ImporterProgress} The progress record of the import. + */ + async prepareUsingLocalFile(_fullFilePath: string): Promise { + return this.updateProgress(ProgressStep.PREPARING_STARTED); + } + + /** + * Starts the import process. The implementing method should defer + * as soon as the selection is set, so the user who started the process + * doesn't end up with a "locked" UI while Meteor waits for a response. + * The returned object should be the progress. + * + * @param {IImporterShortSelection} importSelection The selection data. + * @returns {ImporterProgress} The progress record of the import. + */ + async startImport(importSelection: IImporterShortSelection, startedByUserId: string): Promise { + await this.updateProgress(ProgressStep.IMPORTING_STARTED); + this.reloadCount(); + const started = Date.now(); + + const beforeImportFn = async ({ data, dataType: type }: IImportRecord) => { + if (this.importRecord.valid === false) { + this.converter.abort(); + throw new Error('The import operation is no longer valid.'); + } + + switch (type) { + case 'channel': { + if (importSelection.channels?.all) { + return true; + } + if (!importSelection.channels?.list?.length) { + return false; + } + + const channelData = data as IImportChannel; + + const id = channelData.t === 'd' ? '__directMessages__' : channelData.importIds[0]; + return importSelection.channels.list?.includes(id); + } + case 'user': { + if (importSelection.users?.all) { + return true; + } + if (!importSelection.users?.list?.length) { + return false; + } + + const userData = data as IImportUser; + const id = userData.importIds[0]; + return importSelection.users.list.includes(id); + } + + case 'contact': { + if (importSelection.contacts?.all) { + return true; + } + if (!importSelection.contacts?.list?.length) { + return false; + } + + const contactData = data as IImportContact; + + const id = contactData.importIds[0]; + return importSelection.contacts.list.includes(id); + } + } + + return false; + }; + + const afterImportFn = async () => { + await this.addCountCompleted(1); + + if (this.importRecord.valid === false) { + this.converter.abort(); + throw new Error('The import operation is no longer valid.'); + } + }; + + const afterImportAllMessagesFn = async (importedRoomIds: string[]): Promise => + api.broadcast('notify.importedMessages', { roomIds: importedRoomIds }); + + const afterBatchFn = async (successCount: number, errorCount: number) => { + if (successCount) { + await this.addCountCompleted(successCount); + } + if (errorCount) { + await this.addCountError(errorCount); + } + + if (this.importRecord.valid === false) { + this.converter.abort(); + throw new Error('The import operation is no longer valid.'); + } + }; + + const afterContactsBatchFn = async (successCount: number) => { + const value = await Settings.incrementValueById('Contacts_Importer_Count', successCount, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + }; + + const onErrorFn = async () => { + await this.addCountCompleted(1); + }; + + process.nextTick(async () => { + await this.backupSettingValues(); + + try { + await this.applySettingValues({}); + + await this.updateProgress(ProgressStep.IMPORTING_USERS); + await this.converter.convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn }); + + await this.updateProgress(ProgressStep.IMPORTING_CONTACTS); + await this.converter.convertContacts({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn: afterContactsBatchFn }); + + await this.updateProgress(ProgressStep.IMPORTING_CHANNELS); + await this.converter.convertChannels(startedByUserId, { beforeImportFn, afterImportFn, onErrorFn }); + + await this.updateProgress(ProgressStep.IMPORTING_MESSAGES); + await this.converter.convertMessages({ afterImportFn, onErrorFn, afterImportAllMessagesFn }); + + await this.updateProgress(ProgressStep.FINISHING); + + process.nextTick(async () => { + await this.converter.clearSuccessfullyImportedData(); + }); + + await this.updateProgress(ProgressStep.DONE); + } catch (e) { + this.logger.error({ msg: 'Importer encountered an error during import', err: e }); + await this.updateProgress(ProgressStep.ERROR); + } finally { + await this.applySettingValues(this.oldSettings); + } + + const timeTook = Date.now() - started; + this.logger.log({ msg: 'Import completed', durationMs: timeTook }); + }); + + return this.getProgress(); + } + + async backupSettingValues() { + const allowUsernameChange = (await Settings.findOneById('Accounts_AllowUsernameChange'))?.value as boolean | null; + const maxFileSize = (await Settings.findOneById('FileUpload_MaxFileSize'))?.value as number | null; + const mediaTypeWhiteList = (await Settings.findOneById('FileUpload_MediaTypeWhiteList'))?.value as string | null; + const mediaTypeBlackList = (await Settings.findOneById('FileUpload_MediaTypeBlackList'))?.value as string | null; + + this.oldSettings = { + allowUsernameChange, + maxFileSize, + mediaTypeWhiteList, + mediaTypeBlackList, + }; + } + + async applySettingValues(settingValues: OldSettings) { + const settingsIds = [ + { _id: 'Accounts_AllowUsernameChange', value: settingValues.allowUsernameChange ?? true }, + { _id: 'FileUpload_MaxFileSize', value: settingValues.maxFileSize ?? -1 }, + { _id: 'FileUpload_MediaTypeWhiteList', value: settingValues.mediaTypeWhiteList ?? '*' }, + { _id: 'FileUpload_MediaTypeBlackList', value: settingValues.mediaTypeBlackList ?? '' }, + ]; + + const promises = settingsIds.map((setting) => Settings.updateValueById(setting._id, setting.value)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(settingsIds[index]._id); + } + }); + } + + getProgress(): ImporterProgress { + return this.progress; + } + + /** + * Updates the progress step of this importer. + * It also changes some internal settings at various stages of the import. + * This way the importer can adjust user/room information at will. + * + * @param {ProgressStep} step The progress step which this import is currently at. + * @returns {ImporterProgress} The progress record of the import. + */ + async updateProgress(step: IImportProgress['step']): Promise { + this.progress.step = step; + + this.logger.debug({ msg: 'Importer progress step updated', importerName: this.info.name, step }); + await this.updateRecord({ status: this.progress.step }); + + // Do not send the default progress report during the preparing stage - the classes are sending their own report in a different format. + if (!ImportPreparingStartedStates.includes(this.progress.step)) { + this.reportProgress(); + } + + return this.progress; + } + + reloadCount() { + this.progress.count.total = this.importRecord.count?.total || 0; + this.progress.count.completed = this.importRecord.count?.completed || 0; + this.progress.count.error = this.importRecord.count?.error || 0; + } + + /** + * Adds the passed in value to the total amount of items needed to complete. + * + * @param {number} count The amount to add to the total count of items. + * @returns {ImporterProgress} The progress record of the import. + */ + async addCountToTotal(count: number): Promise { + this.progress.count.total += count; + await this.updateRecord({ 'count.total': this.progress.count.total }); + + return this.progress; + } + + /** + * Adds the passed in value to the total amount of items completed. + * + * @param {number} count The amount to add to the total count of finished items. + * @returns {ImporterProgress} The progress record of the import. + */ + async addCountCompleted(count: number): Promise { + this.progress.count.completed += count; + + return this.maybeUpdateRecord(); + } + + async addCountError(count: number): Promise { + this.progress.count.error += count; + + return this.maybeUpdateRecord(); + } + + async maybeUpdateRecord() { + // Only update the database every 500 messages (or 50 for other records) + // Or the completed is greater than or equal to the total amount + const count = this.progress.count.completed + this.progress.count.error; + const range = this.progress.step === ProgressStep.IMPORTING_MESSAGES ? 500 : 50; + + if (count % range === 0 || count >= this.progress.count.total || count - this._lastProgressReportTotal > range) { + this._lastProgressReportTotal = this.progress.count.completed + this.progress.count.error; + await this.updateRecord({ 'count.completed': this.progress.count.completed, 'count.error': this.progress.count.error }); + this.reportProgress(); + } else if (!this._reportProgressHandler) { + this._reportProgressHandler = setTimeout(() => { + this.reportProgress(); + }, 250); + } + + this.logger.log({ + msg: 'Import progress update', + completed: this.progress.count.completed, + failed: this.progress.count.error, + }); + + return this.progress; + } + + /** + * Sends an updated progress to the websocket + */ + reportProgress() { + if (this._reportProgressHandler) { + clearTimeout(this._reportProgressHandler); + this._reportProgressHandler = undefined; + } + ImporterWebsocket.progressUpdated(this.progress); + } + + /** + * Updates the import record with the given fields being `set`. + */ + async updateRecord(fields: MatchKeysAndValues): Promise { + if (!this.importRecord) { + return this.importRecord; + } + + await Imports.update({ _id: this.importRecord._id }, { $set: fields }); + // #TODO: Remove need for the typecast + this.importRecord = (await Imports.findOne(this.importRecord._id)) as IImport; + + return this.importRecord; + } + + async buildSelection(): Promise { + await this.updateProgress(ProgressStep.USER_SELECTION); + + const users = await ImportData.getAllUsersForSelection(); + const channels = await ImportData.getAllChannelsForSelection(); + const contacts = await ImportData.getAllContactsForSelection(); + const hasDM = await ImportData.checkIfDirectMessagesExists(); + + const selectionUsers = users.map( + (u) => + new SelectionUser(u.data.importIds[0], u.data.username, u.data.emails[0], Boolean(u.data.deleted), u.data.type === 'bot', true), + ); + const selectionChannels = channels.map( + (c) => new SelectionChannel(c.data.importIds[0], c.data.name, Boolean(c.data.archived), true, c.data.t === 'p', c.data.t === 'd'), + ); + const selectionContacts = contacts.map((c) => ({ + id: c.data.importIds[0], + name: c.data.name || '', + emails: c.data.emails || [], + phones: c.data.phones || [], + do_import: true, + })); + const selectionMessages = await ImportData.countMessages(); + + if (hasDM) { + selectionChannels.push(new SelectionChannel('__directMessages__', t('Direct_Messages'), false, true, true, true)); + } + + const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages, selectionContacts); + + return results; + } + + /** + * Utility method to check if the passed in error is a `MongoServerError` with the `codeName` of `'CursorNotFound'`. + */ + protected isCursorNotFoundError(error: unknown): error is MongoServerError & { codeName: 'CursorNotFound' } { + return typeof error === 'object' && error !== null && 'codeName' in error && error.codeName === 'CursorNotFound'; + } +} diff --git a/apps/meteor/server/lib/import/classes/ImporterProgress.ts b/apps/meteor/server/lib/import/classes/ImporterProgress.ts new file mode 100644 index 0000000..1a82d2c --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImporterProgress.ts @@ -0,0 +1,30 @@ +import type { IImportProgress } from '@rocket.chat/core-typings'; + +import { ProgressStep } from '../../../../app/importer/lib/ImporterProgressStep'; + +export class ImporterProgress implements IImportProgress { + public key: string; + + public name: string; + + public step: IImportProgress['step']; + + public count: { + completed: number; + total: number; + error: number; + }; + + /** + * Creates a new progress container for the importer. + * + * @param {string} key The unique key of the importer. + * @param {string} name The name of the importer. + */ + constructor(key: string, name: string) { + this.key = key; + this.name = name; + this.step = ProgressStep.NEW; + this.count = { completed: 0, total: 0, error: 0 }; + } +} diff --git a/apps/meteor/server/lib/import/classes/ImporterSelection.ts b/apps/meteor/server/lib/import/classes/ImporterSelection.ts new file mode 100644 index 0000000..d955bd3 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImporterSelection.ts @@ -0,0 +1,40 @@ +import type { + IImporterSelection, + IImporterSelectionChannel, + IImporterSelectionUser, + IImporterSelectionContact, +} from '@rocket.chat/core-typings'; + +export class ImporterSelection implements IImporterSelection { + public name: string; + + public users: IImporterSelectionUser[]; + + public channels: IImporterSelectionChannel[]; + + public contacts: IImporterSelectionContact[]; + + public message_count: number; + + /** + * Constructs a new importer selection object. + * + * @param name the name of the importer + * @param users the users which can be selected + * @param channels the channels which can be selected + * @param messageCount the number of messages + */ + constructor( + name: string, + users: IImporterSelectionUser[], + channels: IImporterSelectionChannel[], + messageCount: number, + contacts: IImporterSelectionContact[], + ) { + this.name = name; + this.users = users; + this.channels = channels; + this.message_count = messageCount; + this.contacts = contacts; + } +} diff --git a/apps/meteor/server/lib/import/classes/ImporterSelectionChannel.ts b/apps/meteor/server/lib/import/classes/ImporterSelectionChannel.ts new file mode 100644 index 0000000..359a18d --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImporterSelectionChannel.ts @@ -0,0 +1,34 @@ +import type { IImporterSelectionChannel } from '@rocket.chat/core-typings'; + +export class SelectionChannel implements IImporterSelectionChannel { + public channel_id: string; + + public name: string | undefined; + + public is_archived: boolean; + + public do_import: boolean; + + public is_private: boolean; + + public is_direct: boolean; + + public creator: undefined; + + /** + * Constructs a new selection channel. + * + * @param channelId the unique identifier of the channel + * @param name the name of the channel + * @param isArchived whether the channel was archived or not + * @param doImport whether we will be importing the channel or not + */ + constructor(channelId: string, name: string | undefined, isArchived: boolean, doImport: boolean, isPrivate: boolean, isDirect: boolean) { + this.channel_id = channelId; + this.name = name; + this.is_archived = isArchived; + this.do_import = doImport; + this.is_private = isPrivate; + this.is_direct = isDirect; + } +} diff --git a/apps/meteor/server/lib/import/classes/ImporterSelectionUser.ts b/apps/meteor/server/lib/import/classes/ImporterSelectionUser.ts new file mode 100644 index 0000000..5dc9cb9 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImporterSelectionUser.ts @@ -0,0 +1,46 @@ +import type { IImporterSelectionUser } from '@rocket.chat/core-typings'; + +export class SelectionUser implements IImporterSelectionUser { + public user_id: string; + + public username: string | undefined; + + public email: string; + + public is_deleted: boolean; + + public is_bot: boolean; + + public do_import: boolean; + + public is_email_taken: boolean; + + /** + * Constructs a new selection user. + * + * @param userId the unique user identifier + * @param username the user's username + * @param email the user's email + * @param isDeleted whether the user was deleted or not + * @param isBot whether the user is a bot or not + * @param doImport whether we are going to import this user or not + * @param isEmailTaken whether there's an existing user with the same email + */ + constructor( + userId: string, + username: string | undefined, + email: string, + isDeleted: boolean, + isBot: boolean, + doImport: boolean, + isEmailTaken = false, + ) { + this.user_id = userId; + this.username = username; + this.email = email; + this.is_deleted = isDeleted; + this.is_bot = isBot; + this.do_import = doImport; + this.is_email_taken = isEmailTaken; + } +} diff --git a/apps/meteor/server/lib/import/classes/ImporterWebsocket.ts b/apps/meteor/server/lib/import/classes/ImporterWebsocket.ts new file mode 100644 index 0000000..41aeee5 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImporterWebsocket.ts @@ -0,0 +1,23 @@ +import type { IImportProgress } from '@rocket.chat/core-typings'; + +import type { IStreamer } from '../../../modules/streamer/types'; +import notifications from '../../notifications/core/lib/Notifications'; + +class ImporterWebsocketDef { + private streamer: IStreamer<'importers'>; + + constructor() { + this.streamer = notifications.streamImporters; + } + + /** + * Called when the progress is updated. + * + * @param {Progress} progress The progress of the import. + */ + progressUpdated(progress: { rate: number } | IImportProgress) { + this.streamer.emit('progress', progress); + } +} + +export const ImporterWebsocket = new ImporterWebsocketDef(); diff --git a/apps/meteor/server/lib/import/classes/ImportersContainer.ts b/apps/meteor/server/lib/import/classes/ImportersContainer.ts new file mode 100644 index 0000000..97c9870 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/ImportersContainer.ts @@ -0,0 +1,27 @@ +import type { ImporterInfo } from '../definitions/ImporterInfo'; + +/** Container class which holds all of the importer details. */ +export class ImportersContainer { + private importers: Map; + + constructor() { + this.importers = new Map(); + } + + add({ key, name, importer, visible }: Omit & { visible?: boolean }) { + this.importers.set(key, { + key, + name, + visible: visible !== false, + importer, + }); + } + + get(key: string): ImporterInfo | undefined { + return this.importers.get(key); + } + + getAllVisible(): ImporterInfo[] { + return Array.from(this.importers.values()).filter(({ visible }) => visible); + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/ContactConverter.ts b/apps/meteor/server/lib/import/classes/converters/ContactConverter.ts new file mode 100644 index 0000000..d8abe4d --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/ContactConverter.ts @@ -0,0 +1,42 @@ +import type { IImportContact, IImportContactRecord } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +import { RecordConverter } from './RecordConverter'; +import { createContact } from '../../../omnichannel/contacts/createContact'; +import { getAllowedCustomFields } from '../../../omnichannel/contacts/getAllowedCustomFields'; +import { validateCustomFields } from '../../../omnichannel/contacts/validateCustomFields'; + +export class ContactConverter extends RecordConverter { + protected async convertCustomFields(customFields: IImportContact['customFields']): Promise { + if (!customFields) { + return; + } + + const allowedCustomFields = await getAllowedCustomFields(); + return validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true }); + } + + protected override async convertRecord(record: IImportContactRecord): Promise { + const { data } = record; + + await createContact({ + name: data.name || (await this.generateNewContactName()), + emails: data.emails, + phones: data.phones, + customFields: await this.convertCustomFields(data.customFields), + contactManager: await this._cache.getIdOfUsername(data.contactManager), + unknown: false, + importIds: data.importIds, + }); + + return true; + } + + protected async generateNewContactName(): Promise { + return LivechatVisitors.getNextVisitorUsername(); + } + + protected override getDataType(): 'contact' { + return 'contact'; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/ConverterCache.ts b/apps/meteor/server/lib/import/classes/converters/ConverterCache.ts new file mode 100644 index 0000000..3dec0ab --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/ConverterCache.ts @@ -0,0 +1,224 @@ +import type { IImportUser, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; + +export type UserIdentification = { + _id: string; + username: string | undefined; +}; + +export type MentionedChannel = { + _id: string; + name: string; +}; + +export class ConverterCache { + private _userCache = new Map(); + + // display name uses a different cache because it's only used on mentions so we don't need to load it every time we load an user + private _userDisplayNameCache = new Map(); + + private _userNameToIdCache = new Map(); + + private _roomCache = new Map(); + + private _roomNameCache = new Map(); + + addUser(importId: string, _id: string, username: string | undefined): UserIdentification { + const cache = { + _id, + username, + }; + + this._userCache.set(importId, cache); + if (username) { + this._userNameToIdCache.set(username, _id); + } + return cache; + } + + addUserDisplayName(importId: string, name: string): string { + this._userDisplayNameCache.set(importId, name); + return name; + } + + addRoom(importId: string, rid: string): string { + this._roomCache.set(importId, rid); + return rid; + } + + addRoomName(importId: string, name: string): string { + this._roomNameCache.set(importId, name); + return name; + } + + addUserData(userData: IImportUser): void { + if (!userData._id) { + return; + } + if (!userData.importIds.length) { + return; + } + + this.addUser(userData.importIds[0], userData._id, userData.username); + } + + addUsernameToId(username: string, id: string): void { + this._userNameToIdCache.set(username, id); + } + + async findImportedRoomId(importId: string): Promise { + if (this._roomCache.has(importId)) { + return this._roomCache.get(importId) as string; + } + + const options = { + projection: { + _id: 1, + }, + }; + + const room = await Rooms.findOneByImportId(importId, options); + if (room) { + return this.addRoom(importId, room._id); + } + + return null; + } + + async findImportedRoomName(importId: string): Promise { + if (this._roomNameCache.has(importId)) { + return this._roomNameCache.get(importId) as string; + } + + const options = { + projection: { + _id: 1, + name: 1, + }, + }; + + const room = await Rooms.findOneByImportId(importId, options); + if (room) { + if (!this._roomCache.has(importId)) { + this.addRoom(importId, room._id); + } + if (room?.name) { + return this.addRoomName(importId, room.name); + } + } + } + + async findImportedUser(importId: string): Promise { + if (importId === 'rocket.cat') { + return { + _id: 'rocket.cat', + username: 'rocket.cat', + }; + } + + const options = { + projection: { + _id: 1, + username: 1, + }, + }; + + if (this._userCache.has(importId)) { + return this._userCache.get(importId) as UserIdentification; + } + + const user = await Users.findOneByImportId(importId, options); + if (user) { + return this.addUser(importId, user._id, user.username); + } + + return null; + } + + async findImportedUserId(_id: string): Promise { + const data = await this.findImportedUser(_id); + return data?._id; + } + + async findImportedUsername(_id: string): Promise { + const data = await this.findImportedUser(_id); + return data?.username; + } + + async findImportedUserDisplayName(importId: string): Promise { + const options = { + projection: { + _id: 1, + name: 1, + username: 1, + }, + }; + + if (this._userDisplayNameCache.has(importId)) { + return this._userDisplayNameCache.get(importId); + } + + const user = + importId === 'rocket.cat' ? await Users.findOneById('rocket.cat', options) : await Users.findOneByImportId(importId, options); + if (user) { + if (!this._userCache.has(importId)) { + this.addUser(importId, user._id, user.username); + } + + if (!user.name) { + return; + } + + return this.addUserDisplayName(importId, user.name); + } + } + + async convertImportedIdsToUsernames(importedIds: Array, idToRemove: string | undefined = undefined): Promise> { + return ( + await Promise.all( + importedIds.map(async (user) => { + if (user === 'rocket.cat') { + return user; + } + + if (this._userCache.has(user)) { + const cache = this._userCache.get(user); + if (cache) { + return cache.username; + } + } + + const obj = await Users.findOneByImportId(user, { projection: { _id: 1, username: 1 } }); + if (obj) { + this.addUser(user, obj._id, obj.username); + + if (idToRemove && obj._id === idToRemove) { + return false; + } + + return obj.username; + } + + return false; + }), + ) + ).filter((user) => user) as string[]; + } + + async getIdOfUsername(username: string | undefined): Promise { + if (!username) { + return; + } + + if (this._userNameToIdCache.has(username)) { + return this._userNameToIdCache.get(username); + } + + const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); + if (user) { + this.addUsernameToId(username, user._id); + } + + return user?._id; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/MessageConverter.ts b/apps/meteor/server/lib/import/classes/converters/MessageConverter.ts new file mode 100644 index 0000000..bb0d995 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/MessageConverter.ts @@ -0,0 +1,263 @@ +import type { IImportMessageRecord, IMessage as IDBMessage, IImportMessage, IImportMessageReaction } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { removeEmpty } from '@rocket.chat/tools'; +import limax from 'limax'; + +import type { UserIdentification, MentionedChannel } from './ConverterCache'; +import { RecordConverter } from './RecordConverter'; +import { insertMessage } from '../../../messages/insertMessage'; +import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks'; + +export type MessageConversionCallbacks = IConversionCallbacks & { afterImportAllMessagesFn?: (roomIds: string[]) => Promise }; + +type MessageObject = Record; + +type MentionedUser = { + _id: string; + username: string; + name?: string; +}; + +type IMessageReaction = { + name: string; + usernames: string[]; +}; + +type IMessageReactions = Record; + +export class MessageConverter extends RecordConverter { + private rids: string[] = []; + + override async convertData({ afterImportAllMessagesFn, ...callbacks }: MessageConversionCallbacks = {}): Promise { + this.rids = []; + await super.convertData(callbacks); + + await this.resetLastMessages(); + if (afterImportAllMessagesFn) { + await afterImportAllMessagesFn(this.rids); + } + } + + protected async resetLastMessages(): Promise { + for (const rid of this.rids) { + try { + await Rooms.resetLastMessageById(rid, null); + } catch (err) { + this._logger.error({ msg: 'Failed to update last message of room', roomId: rid, err }); + } + } + } + + protected async insertMessage(data: IImportMessage): Promise { + if (!data.ts || isNaN(data.ts as unknown as number)) { + throw new Error('importer-message-invalid-timestamp'); + } + + const creator = await this._cache.findImportedUser(data.u._id); + if (!creator) { + this._logger.warn({ msg: 'Imported user not found', userId: data.u._id }); + throw new Error('importer-message-unknown-user'); + } + const rid = await this._cache.findImportedRoomId(data.rid); + if (!rid) { + throw new Error('importer-message-unknown-room'); + } + if (!this.rids.includes(rid)) { + this.rids.push(rid); + } + + const msgObj = await this.buildMessageObject(data, rid, creator); + + try { + await insertMessage(creator, msgObj as unknown as IDBMessage, rid, true); + } catch (err) { + this._logger.error({ msg: 'Failed to import message', timestamp: msgObj.ts, roomId: rid, err }); + } + } + + protected override async convertRecord(record: IImportMessageRecord): Promise { + await this.insertMessage(record.data); + return true; + } + + protected async buildMessageObject(data: IImportMessage, rid: string, creator: UserIdentification): Promise { + // Convert the mentions and channels first because these conversions can also modify the msg in the message object + const mentions = data.mentions && (await this.convertMessageMentions(data)); + const channels = data.channels && (await this.convertMessageChannels(data)); + + return removeEmpty({ + rid, + u: { + _id: creator._id, + username: creator.username, + }, + msg: data.msg, + ts: data.ts, + t: data.t || undefined, + groupable: data.groupable, + tmid: data.tmid, + tlm: data.tlm, + tcount: data.tcount, + replies: data.replies && (await this.convertMessageReplies(data.replies)), + editedAt: data.editedAt, + editedBy: data.editedBy && ((await this._cache.findImportedUser(data.editedBy)) || undefined), + mentions, + channels, + _importFile: data._importFile, + _hidden: data._hidden, + url: data.url, + attachments: data.attachments, + bot: data.bot, + emoji: data.emoji, + alias: data.alias, + ...(data._id ? { _id: data._id } : {}), + ...(data.reactions ? { reactions: await this.convertMessageReactions(data.reactions) } : {}), + }); + } + + protected async convertMessageChannels(message: IImportMessage): Promise { + const { channels } = message; + if (!channels) { + return; + } + + const result: MentionedChannel[] = []; + for (const importId of channels) { + const { name, _id } = (await this.getMentionedChannelData(importId)) || {}; + + if (!_id || !name) { + this._logger.warn({ msg: 'Mentioned room not found', importId }); + continue; + } + + message.msg = message.msg.replace(new RegExp(`\#${importId}`, 'gi'), `#${name}`); + + result.push({ + _id, + name, + }); + } + + return result; + } + + protected async convertMessageMentions(message: IImportMessage): Promise { + const { mentions } = message; + if (!mentions) { + return undefined; + } + + const result: MentionedUser[] = []; + for (const importId of mentions) { + if (importId === ('all' as 'string') || importId === 'here') { + result.push({ + _id: importId, + username: importId, + }); + continue; + } + + // Loading the name will also store the remaining data on the cache if it's missing, so this won't run two queries + const name = await this._cache.findImportedUserDisplayName(importId); + const data = await this._cache.findImportedUser(importId); + + if (!data) { + this._logger.warn({ msg: 'Mentioned user not found', importId }); + continue; + } + + if (!data.username) { + this._logger.debug({ msg: 'Mentioned user has no username', importId }); + throw new Error('importer-message-mentioned-username-not-found'); + } + + message.msg = message.msg.replace(new RegExp(`\@${importId}`, 'gi'), `@${data.username}`); + + result.push({ + _id: data._id, + username: data.username as 'string', + name, + }); + } + return result; + } + + protected async convertMessageReactions( + importedReactions: Record, + ): Promise { + const reactions: IMessageReactions = {}; + + for (const name of Object.keys(importedReactions)) { + if (!importedReactions.hasOwnProperty(name)) { + continue; + } + const { users } = importedReactions[name]; + + if (!users.length) { + continue; + } + + const reaction: IMessageReaction = { + name, + usernames: [], + }; + + for (const importId of users) { + const username = await this._cache.findImportedUsername(importId); + if (username && !reaction.usernames.includes(username)) { + reaction.usernames.push(username); + } + } + + if (reaction.usernames.length) { + reactions[name] = reaction; + } + } + + if (Object.keys(reactions).length > 0) { + return reactions; + } + } + + protected async convertMessageReplies(replies: string[]): Promise { + const result: string[] = []; + for (const importId of replies) { + const userId = await this._cache.findImportedUserId(importId); + if (userId && !result.includes(userId)) { + result.push(userId); + } + } + return result; + } + + protected async getMentionedChannelData(importId: string): Promise { + // loading the name will also store the id on the cache if it's missing, so this won't run two queries + const name = await this._cache.findImportedRoomName(importId); + const _id = await this._cache.findImportedRoomId(importId); + + if (name && _id) { + return { + name, + _id, + }; + } + + // If the importId was not found, check if we have a room with that name + const roomName = limax(importId.trim(), { maintainCase: true }); + + const room = await Rooms.findOneByNonValidatedName(roomName, { projection: { name: 1 } }); + if (room?.name) { + this._cache.addRoom(importId, room._id); + this._cache.addRoomName(importId, room.name); + + return { + name: room.name, + _id: room._id, + }; + } + } + + protected override getDataType(): 'message' { + return 'message'; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/RecordConverter.ts b/apps/meteor/server/lib/import/classes/converters/RecordConverter.ts new file mode 100644 index 0000000..56b49df --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/RecordConverter.ts @@ -0,0 +1,252 @@ +import type { IImportRecord } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { ImportData } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { type FindCursor, ObjectId } from 'mongodb'; + +import { ConverterCache } from './ConverterCache'; +import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks'; + +export type RecordConverterOptions = { + workInMemory?: boolean; + deleteDbData?: boolean; +}; + +export class RecordConverter { + protected _logger: Logger; + + protected _cache: ConverterCache; + + protected _converterOptions: RecordConverterOptions; + + protected _options: Omit; + + protected _records: R[]; + + protected skippedCount = 0; + + protected failedCount = 0; + + protected newCount = 0; + + public aborted = false; + + constructor(options?: T, logger?: Logger, cache?: ConverterCache) { + const { workInMemory = false, deleteDbData = false, ...customOptions } = options || ({} as T); + this._converterOptions = { + workInMemory, + deleteDbData, + }; + this._options = customOptions; + + this._logger = logger || new Logger(`Data Importer - ${this.constructor.name}`); + this._cache = cache || new ConverterCache(); + this._records = []; + } + + private skipMemoryRecord(_id: string): void { + const record = this.getMemoryRecordById(_id); + if (!record) { + return; + } + + record.skipped = true; + } + + private async skipDatabaseRecord(_id: string): Promise { + await ImportData.updateOne( + { + _id, + }, + { + $set: { + skipped: true, + }, + }, + ); + } + + protected async skipRecord(_id: string): Promise { + this.skippedCount++; + this.skipMemoryRecord(_id); + if (!this._converterOptions.workInMemory) { + return this.skipDatabaseRecord(_id); + } + } + + private saveErrorToMemory(importId: string, error: Error): void { + const record = this.getMemoryRecordById(importId); + + if (!record) { + return; + } + + if (!record.errors) { + record.errors = []; + } + + record.errors.push({ + message: error.message, + stack: error.stack, + }); + } + + private async saveErrorToDatabase(importId: string, error: Error): Promise { + await ImportData.updateOne( + { + _id: importId, + }, + { + $push: { + errors: { + message: error.message, + stack: error.stack, + }, + }, + }, + ); + } + + protected async saveError(importId: string, error: Error): Promise { + this._logger.error({ + msg: 'Import record conversion failed', + importId, + err: error, + }); + this.saveErrorToMemory(importId, error); + + if (!this._converterOptions.workInMemory) { + return this.saveErrorToDatabase(importId, error); + } + } + + public async clearImportData(): Promise { + this._records = []; + + // On regular import operations this data will be deleted by the importer class with one single operation for all dataTypes (aka with no filter) + if (!this._converterOptions.workInMemory && this._converterOptions.deleteDbData) { + await ImportData.col.deleteMany({ dataType: this.getDataType() }); + } + } + + public async clearSuccessfullyImportedData(): Promise { + this._records = this._records.filter((record) => !record.errors?.length); + + // On regular import operations this data will be deleted by the importer class with one single operation for all dataTypes (aka with no filter) + if (!this._converterOptions.workInMemory && this._converterOptions.deleteDbData) { + await ImportData.col.deleteMany({ dataType: this.getDataType(), error: { $exists: false } }); + } + } + + private getMemoryRecordById(id: string): R | undefined { + for (const record of this._records) { + if (record._id === id) { + return record; + } + } + + return undefined; + } + + protected getDataType(): R['dataType'] { + throw new Error('Unspecified type'); + } + + protected async addObjectToDatabase(data: R['data'], options: R['options'] = {}): Promise { + await ImportData.col.insertOne({ + _id: new ObjectId().toHexString(), + data, + dataType: this.getDataType(), + options, + _updatedAt: new Date(), + }); + } + + public addObjectToMemory(data: R['data'], options: R['options'] = {}): void { + this._records.push({ + _id: Random.id(), + data, + dataType: this.getDataType(), + options, + } as R); + } + + public async addObject(data: R['data'], options: R['options'] = {}): Promise { + if (this._converterOptions.workInMemory) { + return this.addObjectToMemory(data, options); + } + + return this.addObjectToDatabase(data, options); + } + + protected getDatabaseDataToImport(): Promise { + return (ImportData.find({ dataType: this.getDataType() }) as FindCursor).toArray(); + } + + protected async getDataToImport(): Promise { + if (this._converterOptions.workInMemory) { + return this._records; + } + + const dbRecords = await this.getDatabaseDataToImport(); + if (this._records.length) { + return [...this._records, ...dbRecords]; + } + + return dbRecords; + } + + protected async iterateRecords({ + beforeImportFn, + afterImportFn, + onErrorFn, + processRecord, + afterBatchFn, + }: IConversionCallbacks & { processRecord?: (record: R) => Promise } = {}): Promise { + const records = await this.getDataToImport(); + + this.skippedCount = 0; + this.failedCount = 0; + this.newCount = 0; + + for (const record of records) { + const { _id } = record; + if (this.aborted) { + return; + } + + try { + if (beforeImportFn && !(await beforeImportFn(record))) { + await this.skipRecord(_id); + continue; + } + + const isNew = await (processRecord || this.convertRecord).call(this, record); + + if (typeof isNew === 'boolean') { + this.newCount++; + if (afterImportFn) { + await afterImportFn(record, isNew); + } + } + } catch (e) { + this.failedCount++; + await this.saveError(_id, e instanceof Error ? e : new Error(String(e))); + if (onErrorFn) { + await onErrorFn(); + } + } + } + if (afterBatchFn) { + await afterBatchFn(this.newCount, this.failedCount); + } + } + + async convertData(callbacks: IConversionCallbacks = {}): Promise { + return this.iterateRecords(callbacks); + } + + protected async convertRecord(_record: R): Promise { + return undefined; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/RoomConverter.ts b/apps/meteor/server/lib/import/classes/converters/RoomConverter.ts new file mode 100644 index 0000000..90d41b9 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/RoomConverter.ts @@ -0,0 +1,201 @@ +import type { IImportChannel, IImportChannelRecord, IRoom } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { removeEmpty } from '@rocket.chat/tools'; +import limax from 'limax'; + +import { RecordConverter } from './RecordConverter'; +import { createDirectMessage } from '../../../../meteor-methods/messages/createDirectMessage'; +import { createChannelMethod } from '../../../../meteor-methods/rooms/createChannel'; +import { createPrivateGroupMethod } from '../../../../meteor-methods/rooms/createPrivateGroup'; +import { saveRoomSettings } from '../../../../meteor-methods/rooms/saveRoomSettings'; +import { notifyOnSubscriptionChangedByRoomId } from '../../../notifyListener'; +import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks'; + +export class RoomConverter extends RecordConverter { + public startedByUserId: string; + + async convertChannels(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise { + this.startedByUserId = startedByUserId; + + return this.convertData(callbacks); + } + + protected override async convertRecord(record: IImportChannelRecord): Promise { + const { data } = record; + + if (!data.name && data.t !== 'd') { + throw new Error('importer-channel-missing-name'); + } + + data.importIds = data.importIds.filter((item) => item); + data.users = [...new Set(data.users)]; + + if (!data.importIds.length) { + throw new Error('importer-channel-missing-import-id'); + } + + const existingRoom = await this.findExistingRoom(data); + await this.insertOrUpdateRoom(existingRoom, data, this.startedByUserId); + + return !existingRoom; + } + + async insertOrUpdateRoom(existingRoom: IRoom | null, data: IImportChannel, startedByUserId: string): Promise { + if (existingRoom) { + await this.updateRoom(existingRoom, data, startedByUserId); + } else { + await this.insertRoom(data, startedByUserId); + } + + if (data.archived && data._id) { + await this.archiveRoomById(data._id); + } + } + + async findExistingRoom(data: IImportChannel): Promise { + if (data._id && data._id.toUpperCase() === 'GENERAL') { + const room = await Rooms.findOneById('GENERAL', {}); + // Prevent the importer from trying to create a new general + if (!room) { + throw new Error('importer-channel-general-not-found'); + } + + return room; + } + + if (data.t === 'd') { + const users = await this._cache.convertImportedIdsToUsernames(data.users); + if (users.length !== data.users.length) { + throw new Error('importer-channel-missing-users'); + } + + return Rooms.findDirectRoomContainingAllUsernames(users, {}); + } + + if (!data.name) { + return null; + } + + // Imported room names always allow special chars + const roomName = limax(data.name.trim(), { maintainCase: true }); + return Rooms.findOneByNonValidatedName(roomName, {}); + } + + async updateRoom(room: IRoom, roomData: IImportChannel, startedByUserId: string): Promise { + roomData._id = room._id; + + if ((roomData._id as string).toUpperCase() === 'GENERAL' && roomData.name !== room.name) { + await saveRoomSettings(startedByUserId, 'GENERAL', 'roomName', roomData.name); + } + + await this.updateRoomId(room._id, roomData); + } + + async insertRoom(roomData: IImportChannel, startedByUserId: string): Promise { + // Find the rocketchatId of the user who created this channel + const creatorId = await this.getRoomCreatorId(roomData, startedByUserId); + const members = await this._cache.convertImportedIdsToUsernames(roomData.users, roomData.t !== 'd' ? creatorId : undefined); + + if (roomData.t === 'd') { + if (members.length < roomData.users.length) { + this._logger.warn({ + msg: 'One or more imported users not found', + users: roomData.users, + }); + throw new Error('importer-channel-missing-users'); + } + } + + // Create the channel + try { + let roomInfo; + if (roomData.t === 'd') { + roomInfo = await createDirectMessage(members, startedByUserId, true); + } else { + if (!roomData.name) { + return; + } + if (roomData.t === 'p') { + const user = await Users.findOneById(creatorId); + if (!user) { + throw new Error('importer-channel-invalid-creator'); + } + roomInfo = await createPrivateGroupMethod(user, roomData.name, members, false, {}, {}); + } else { + roomInfo = await createChannelMethod(creatorId, roomData.name, members, false, {}, {}); + } + } + + roomData._id = roomInfo.rid; + } catch (e) { + this._logger.warn({ msg: 'Failed to create new room', name: roomData.name, members, err: e }); + throw e; + } + + await this.updateRoomId(roomData._id as 'string', roomData); + } + + async archiveRoomById(rid: string) { + const responses = await Promise.all([Rooms.archiveById(rid), Subscriptions.archiveByRoomId(rid)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + } + + async updateRoomId(_id: string, roomData: IImportChannel): Promise { + const set = { + ts: roomData.ts, + topic: roomData.topic, + description: roomData.description, + }; + + const roomUpdate: { $set?: Record; $addToSet?: Record } = {}; + + if (Object.keys(set).length > 0) { + roomUpdate.$set = removeEmpty(set); + } + + if (roomData.importIds.length) { + roomUpdate.$addToSet = { + importIds: { + $each: roomData.importIds, + }, + }; + } + + if (roomUpdate.$set || roomUpdate.$addToSet) { + await Rooms.updateOne({ _id: roomData._id }, roomUpdate); + } + } + + async getRoomCreatorId(roomData: IImportChannel, startedByUserId: string): Promise { + if (roomData.u) { + const creatorId = await this._cache.findImportedUserId(roomData.u._id); + if (creatorId) { + return creatorId; + } + + if (roomData.t !== 'd') { + return startedByUserId; + } + + throw new Error('importer-channel-invalid-creator'); + } + + if (roomData.t === 'd') { + for await (const member of roomData.users) { + const userId = await this._cache.findImportedUserId(member); + if (userId) { + return userId; + } + } + } + + throw new Error('importer-channel-invalid-creator'); + } + + protected override getDataType(): 'channel' { + return 'channel'; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/UserConverter.ts b/apps/meteor/server/lib/import/classes/converters/UserConverter.ts new file mode 100644 index 0000000..b4ce447 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/UserConverter.ts @@ -0,0 +1,430 @@ +import type { IImportUser, IImportUserRecord, IUser, IUserEmail } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { SHA256 } from '@rocket.chat/sha256'; +import { hash as bcryptHash } from 'bcrypt'; +import { Accounts } from 'meteor/accounts-base'; + +import { RecordConverter, type RecordConverterOptions } from './RecordConverter'; +import { generateTempPassword } from './generateTempPassword'; +import { callbacks as systemCallbacks } from '../../../callbacks'; +import { notifyOnUserChange } from '../../../notifyListener'; +import { addUserToDefaultChannels } from '../../../rooms/addUserToDefaultChannels'; +import { generateUsernameSuggestion } from '../../../users/getUsernameSuggestion'; +import { saveUserIdentity } from '../../../users/saveUserIdentity'; +import { setUserActiveStatus } from '../../../users/setUserActiveStatus'; +import type { IConversionCallbacks } from '../../definitions/IConversionCallbacks'; + +export type UserConverterOptions = { + flagEmailsAsVerified?: boolean; + skipExistingUsers?: boolean; + skipNewUsers?: boolean; + skipUserCallbacks?: boolean; + skipDefaultChannels?: boolean; + + quickUserInsertion?: boolean; + enableEmail2fa?: boolean; + syncVoipExtension?: boolean; +}; + +export class UserConverter extends RecordConverter { + private insertedIds = new Set(); + + private updatedIds = new Set(); + + protected override async convertRecord(record: IImportUserRecord): Promise { + const { data, _id } = record; + + data.importIds = data.importIds.filter((item) => item); + + if (!data.emails.length && !data.username) { + throw new Error('importer-user-missing-email-and-username'); + } + + const existingUser = await this.findExistingUser(data); + if (existingUser && this._options.skipExistingUsers) { + await this.skipRecord(_id); + return; + } + if (!existingUser && this._options.skipNewUsers) { + await this.skipRecord(_id); + return; + } + + await this.insertOrUpdateUser(existingUser, data); + return !existingUser; + } + + override async convertData(userCallbacks: IConversionCallbacks = {}): Promise { + this.insertedIds.clear(); + this.updatedIds.clear(); + + if (this._options.quickUserInsertion) { + await this.batchConversion(userCallbacks); + } else { + await super.convertData(userCallbacks); + } + + await systemCallbacks.run('afterUserImport', { + inserted: [...this.insertedIds], + updated: [...this.updatedIds], + skipped: this.skippedCount, + failed: this.failedCount, + }); + } + + public async batchConversion({ afterBatchFn, ...callbacks }: IConversionCallbacks = {}): Promise { + const batchToInsert = new Set(); + + await this.iterateRecords({ + ...callbacks, + processRecord: async (record: IImportUserRecord) => { + const { data } = record; + + data.importIds = data.importIds.filter((item) => item); + + if (!data.emails.length && !data.username) { + throw new Error('importer-user-missing-email-and-username'); + } + + batchToInsert.add(data); + + if (batchToInsert.size >= 50) { + const usersToInsert = await this.buildUserBatch([...batchToInsert]); + batchToInsert.clear(); + + const newIds = await this.insertUserBatch(usersToInsert, { afterBatchFn }); + newIds.forEach((id) => this.insertedIds.add(id)); + } + + return undefined; + }, + }); + + if (batchToInsert.size > 0) { + const usersToInsert = await this.buildUserBatch([...batchToInsert]); + const newIds = await this.insertUserBatch(usersToInsert, { afterBatchFn }); + newIds.forEach((id) => this.insertedIds.add(id)); + } + } + + private async insertUserBatch(users: IUser[], { afterBatchFn }: IConversionCallbacks): Promise { + let newIds: string[] | null = null; + + try { + newIds = Object.values((await Users.insertMany(users, { ordered: false })).insertedIds); + if (afterBatchFn) { + await afterBatchFn(newIds.length, 0); + } + } catch (e: any) { + newIds = (e.result?.result?.insertedIds || []) as string[]; + const errorCount = users.length - (e.result?.result?.nInserted || 0); + + if (afterBatchFn) { + await afterBatchFn(Math.min(newIds.length, users.length - errorCount), errorCount); + } + } + + return newIds; + } + + async findExistingUser(data: IImportUser): Promise { + if (data.emails.length) { + const emailUser = await Users.findOneByEmailAddress(data.emails[0], {}); + + if (emailUser) { + return emailUser; + } + } + + // If we couldn't find one by their email address, try to find an existing user by their username + if (data.username) { + return Users.findOneByUsernameIgnoringCase(data.username, {}); + } + } + + addUserImportId(updateData: Record, userData: IImportUser): void { + if (userData.importIds?.length) { + updateData.$addToSet = { + importIds: { + $each: userData.importIds, + }, + }; + } + } + + addUserEmails(updateData: Record, userData: IImportUser, existingEmails: Array): void { + if (!userData.emails?.length) { + return; + } + + const verifyEmails = Boolean(this._options.flagEmailsAsVerified); + const newEmailList: Array = []; + + for (const email of userData.emails) { + const verified = verifyEmails || existingEmails.find((ee) => ee.address === email)?.verified || false; + + newEmailList.push({ + address: email, + verified, + }); + } + + updateData.$set.emails = newEmailList; + } + + addUserServices(updateData: Record, userData: IImportUser): void { + if (!userData.services) { + return; + } + + for (const serviceKey in userData.services) { + if (!userData.services[serviceKey]) { + continue; + } + + const service = userData.services[serviceKey]; + + for (const key in service) { + if (!service[key]) { + continue; + } + + updateData.$set[`services.${serviceKey}.${key}`] = service[key]; + } + } + } + + addCustomFields(updateData: Record, userData: IImportUser): void { + if (!userData.customFields) { + return; + } + + const subset = (source: Record, currentPath: string): void => { + for (const key in source) { + if (!source.hasOwnProperty(key) || source[key] === undefined) { + continue; + } + + const keyPath = `${currentPath}.${key}`; + if (typeof source[key] === 'object' && !Array.isArray(source[key])) { + subset(source[key], keyPath); + continue; + } + + updateData.$set = { + ...updateData.$set, + ...{ [keyPath]: source[key] }, + }; + } + }; + + subset(userData.customFields, 'customFields'); + } + + async insertOrUpdateUser(existingUser: IUser | null | undefined, data: IImportUser): Promise { + if (!data.username && !existingUser?.username) { + const emails = data.emails.filter(Boolean).map((email) => ({ address: email })); + data.username = await generateUsernameSuggestion({ + name: data.name, + emails, + }); + } + + if (existingUser) { + await this.updateUser(existingUser, data); + this.updatedIds.add(existingUser._id); + } else { + if (!data.name && data.username) { + data.name = this.guessNameFromUsername(data.username); + } + + const userId = await this.insertUser(data); + data._id = userId; + this.insertedIds.add(userId); + + if (!this._options.skipDefaultChannels) { + const insertedUser = await Users.findOneById(userId, {}); + if (!insertedUser) { + throw new Error(`User not found: ${userId}`); + } + + await addUserToDefaultChannels(insertedUser, true); + } + } + } + + async updateUser(existingUser: IUser, userData: IImportUser): Promise { + const { _id } = existingUser; + if (!_id) { + return; + } + + if (Boolean(userData.federated) !== Boolean(existingUser.federated)) { + throw new Error("Local and Federated users can't be converted to each other."); + } + + userData._id = _id; + + if (!userData.roles && !existingUser.roles) { + userData.roles = ['user']; + } + if (!userData.type && !existingUser.type) { + userData.type = 'user'; + } + + const updateData: Record = Object.assign(Object.create(null), { + $set: Object.assign(Object.create(null), { + ...(userData.roles && { roles: userData.roles }), + ...(userData.type && { type: userData.type }), + ...(userData.statusText && { statusText: userData.statusText }), + ...(userData.bio && { bio: userData.bio }), + ...(userData.services?.ldap && { ldap: true }), + ...(userData.avatarUrl && { _pendingAvatarUrl: userData.avatarUrl }), + ...(this._options.syncVoipExtension && userData.voipExtension && { freeSwitchExtension: userData.voipExtension }), + }), + ...(this._options.syncVoipExtension && + !userData.voipExtension && { + $unset: { + freeSwitchExtension: 1, + }, + }), + }); + + this.addCustomFields(updateData, userData); + this.addUserServices(updateData, userData); + this.addUserImportId(updateData, userData); + if (!userData.federated) { + this.addUserEmails(updateData, userData, existingUser.emails || []); + } + + if (Object.keys(updateData.$set).length === 0) { + delete updateData.$set; + } + if (Object.keys(updateData).length > 0) { + await Users.updateOne({ _id }, updateData); + } + + if (userData.utcOffset) { + await Users.setUtcOffset(_id, userData.utcOffset); + } + + const localUsername = userData.federated ? undefined : userData.username; + if (userData.name || localUsername) { + await saveUserIdentity({ _id, name: userData.name, username: localUsername } as Parameters[0]); + } + + if (userData.importIds.length) { + this._cache.addUser(userData.importIds[0], existingUser._id, existingUser.username || userData.username); + } + + // Deleted users are 'inactive' users in Rocket.Chat + if (userData.deleted && existingUser?.active) { + await setUserActiveStatus(_id, false, true); + } else if (userData.deleted === false && existingUser?.active === false) { + await setUserActiveStatus(_id, true); + } + + void notifyOnUserChange({ clientAction: 'updated', id: _id, diff: updateData.$set }); + } + + async hashPassword(password: string): Promise { + return bcryptHash(SHA256(password), Accounts._bcryptRounds()); + } + + generateTempPassword(userData: IImportUser): string { + return generateTempPassword(userData); + } + + async buildNewUserObject(userData: IImportUser): Promise> { + return { + type: userData.type || 'user', + ...(userData.username && { username: userData.username }), + ...(userData.emails.length && { + emails: userData.emails.map((email) => ({ address: email, verified: !!this._options.flagEmailsAsVerified })), + }), + ...(userData.statusText && { statusText: userData.statusText }), + ...(userData.name && { name: userData.name }), + ...(userData.bio && { bio: userData.bio }), + ...(userData.avatarUrl && { _pendingAvatarUrl: userData.avatarUrl }), + ...(userData.utcOffset !== undefined && { utcOffset: userData.utcOffset }), + ...{ + services: { + // Add a password service if there's a password string, or if there's no service at all + ...((!!userData.password || !userData.services || !Object.keys(userData.services).length) && { + password: { bcrypt: await this.hashPassword(userData.password || this.generateTempPassword(userData)) }, + }), + ...(userData.services || {}), + }, + }, + ...(userData.services?.ldap && { ldap: true }), + ...(userData.importIds?.length && { importIds: userData.importIds }), + ...(!!userData.customFields && { customFields: userData.customFields }), + ...(userData.deleted !== undefined && { active: !userData.deleted }), + ...(this._options.syncVoipExtension && userData.voipExtension && { freeSwitchExtension: userData.voipExtension }), + ...(userData.federated !== undefined && { federated: userData.federated }), + }; + } + + private async buildUserBatch(usersData: IImportUser[]): Promise { + return Promise.all( + usersData.map(async (userData) => { + const user = await this.buildNewUserObject(userData); + return { + createdAt: new Date(), + _id: Random.id(), + + status: 'offline', + ...user, + roles: userData.roles?.length ? userData.roles : ['user'], + active: !userData.deleted, + services: { + ...user.services, + ...(this._options.enableEmail2fa + ? { + email2fa: { + enabled: true, + changedAt: new Date(), + }, + } + : {}), + }, + } as IUser; + }), + ); + } + + async insertUser(userData: IImportUser): Promise { + const user = await this.buildNewUserObject(userData); + + return Accounts.insertUserDoc( + { + joinDefaultChannels: false, + skipEmailValidation: true, + skipAdminCheck: true, + skipAdminEmail: true, + skipOnCreateUserCallback: this._options.skipUserCallbacks, + skipBeforeCreateUserCallback: this._options.skipUserCallbacks, + skipAfterCreateUserCallback: this._options.skipUserCallbacks, + skipDefaultAvatar: true, + skipAppsEngineEvent: !!process.env.IMPORTER_SKIP_APPS_EVENT, + }, + { + ...user, + ...(userData.roles?.length ? { globalRoles: userData.roles } : {}), + }, + ); + } + + protected guessNameFromUsername(username: string): string { + return username + .replace(/\W/g, ' ') + .replace(/\s(.)/g, (u) => u.toUpperCase()) + .replace(/^(.)/, (u) => u.toLowerCase()) + .replace(/^\w/, (u) => u.toUpperCase()); + } + + protected override getDataType(): 'user' { + return 'user'; + } +} diff --git a/apps/meteor/server/lib/import/classes/converters/generateTempPassword.ts b/apps/meteor/server/lib/import/classes/converters/generateTempPassword.ts new file mode 100644 index 0000000..689c982 --- /dev/null +++ b/apps/meteor/server/lib/import/classes/converters/generateTempPassword.ts @@ -0,0 +1,5 @@ +import type { IImportUser } from '@rocket.chat/core-typings'; + +export function generateTempPassword(userData: IImportUser): string { + return `${Date.now()}${userData.name || ''}${userData.emails.length ? userData.emails[0].toUpperCase() : ''}`; +} diff --git a/apps/meteor/server/lib/import/csv/CsvImporter.ts b/apps/meteor/server/lib/import/csv/CsvImporter.ts new file mode 100644 index 0000000..6deb5be --- /dev/null +++ b/apps/meteor/server/lib/import/csv/CsvImporter.ts @@ -0,0 +1,286 @@ +import type { IImport } from '@rocket.chat/core-typings'; +import { Settings, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { parse } from 'csv-parse/lib/sync'; + +import { Importer, ProgressStep, ImporterWebsocket } from '..'; +import { notifyOnSettingChanged } from '../../notifyListener'; +import type { ConverterOptions } from '../classes/ImportDataConverter'; +import type { ImporterProgress } from '../classes/ImporterProgress'; +import type { ImporterInfo } from '../definitions/ImporterInfo'; +import { addParsedContacts } from '../omnichannel-contacts/addParsedContacts'; + +export class CsvImporter extends Importer { + private csvParser: (csv: string) => string[][]; + + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + super(info, importRecord, converterOptions); + + this.csvParser = parse; + } + + override async prepareUsingLocalFile(fullFilePath: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + + const zip = new this.AdmZip(fullFilePath); + const totalEntries = zip.getEntryCount(); + + ImporterWebsocket.progressUpdated({ rate: 0 }); + + let count = 0; + let oldRate = 0; + + const increaseProgressCount = () => { + try { + count++; + const rate = Math.floor((count * 1000) / totalEntries) / 10; + if (rate > oldRate) { + ImporterWebsocket.progressUpdated({ rate }); + oldRate = rate; + } + } catch (e) { + this.logger.error({ msg: 'Error while increasing CSV import progress', err: e }); + } + }; + + let messagesCount = 0; + let usersCount = 0; + let channelsCount = 0; + let contactsCount = 0; + const dmRooms = new Set(); + const roomIds = new Map(); + const usedUsernames = new Set(); + const availableUsernames = new Set(); + + const getRoomId = (roomName: string) => { + const roomId = roomIds.get(roomName); + + if (roomId === undefined) { + const fallbackRoomId = Random.id(); + roomIds.set(roomName, fallbackRoomId); + return fallbackRoomId; + } + + return roomId; + }; + + for (const entry of zip.getEntries()) { + this.logger.debug({ msg: 'Entry', entryName: entry.entryName }); + + // Ignore anything that has `__MACOSX` in it's name, as sadly these things seem to mess everything up + if (entry.entryName.indexOf('__MACOSX') > -1) { + this.logger.debug({ msg: 'Ignoring the file', entryName: entry.entryName }); + increaseProgressCount(); + continue; + } + + // Directories are ignored, since they are "virtual" in a zip file + if (entry.isDirectory) { + this.logger.debug({ msg: 'Ignoring the directory entry', entryName: entry.entryName }); + increaseProgressCount(); + continue; + } + + // Parse the channels + if (entry.entryName.toLowerCase() === 'channels.csv') { + await super.updateProgress(ProgressStep.PREPARING_CHANNELS); + const parsedChannels = this.csvParser(entry.getData().toString()); + channelsCount = parsedChannels.length; + + for (const c of parsedChannels) { + const name = c[0].trim(); + const id = getRoomId(name); + const creator = c[1].trim(); + const isPrivate = c[2].trim().toLowerCase() === 'private'; + const members = c[3] + .trim() + .split(';') + .map((m) => m.trim()) + .filter((m) => m); + + await this.converter.addChannel({ + importIds: [id], + u: { + _id: creator, + }, + name, + users: members, + t: isPrivate ? 'p' : 'c', + }); + } + + await super.updateRecord({ 'count.channels': channelsCount }); + increaseProgressCount(); + continue; + } + + // Parse the users + if (entry.entryName.toLowerCase() === 'users.csv') { + await super.updateProgress(ProgressStep.PREPARING_USERS); + const parsedUsers = this.csvParser(entry.getData().toString()); + usersCount = parsedUsers.length; + + for (const u of parsedUsers) { + const username = u[0].trim(); + availableUsernames.add(username); + + const email = u[1].trim(); + const name = u[2].trim(); + + await this.converter.addUser({ + type: 'user', + importIds: [username], + emails: [email], + username, + name, + }); + } + + await super.updateRecord({ 'count.users': usersCount }); + increaseProgressCount(); + continue; + } + + // Parse the contacts + if (entry.entryName.toLowerCase() === 'contacts.csv') { + await super.updateProgress(ProgressStep.PREPARING_CONTACTS); + const parsedContacts = this.csvParser(entry.getData().toString()); + + contactsCount = await addParsedContacts.call(this.converter, parsedContacts); + + await super.updateRecord({ 'count.contacts': contactsCount }); + increaseProgressCount(); + continue; + } + + // Parse the messages + if (entry.entryName.indexOf('/') > -1) { + if (this.progress.step !== ProgressStep.PREPARING_MESSAGES) { + await super.updateProgress(ProgressStep.PREPARING_MESSAGES); + } + + const item = entry.entryName.split('/'); // random/messages.csv + const folderName = item[0]; // random + + let msgs = []; + + try { + msgs = this.csvParser(entry.getData().toString()); + } catch (e) { + this.logger.warn({ msg: 'The file contains invalid syntax', entryName: entry.entryName, err: e }); + increaseProgressCount(); + continue; + } + + let data: { username: string; ts: string; text: string; otherUsername?: string; isDirect?: true }[]; + const msgGroupData = item[1].split('.')[0]; // messages + let isDirect = false; + + if (folderName.toLowerCase() === 'directmessages') { + isDirect = true; + data = msgs.map((m) => ({ + username: m[0], + ts: m[2], + text: m[3], + otherUsername: m[1], + isDirect: true, + })); + } else { + data = msgs.map((m) => ({ username: m[0], ts: m[1], text: m[2] })); + } + + messagesCount += data.length; + const channelName = `${folderName}/${msgGroupData}`; + + await super.updateRecord({ messagesstatus: channelName }); + + if (isDirect) { + for (const msg of data) { + if (!msg.otherUsername) { + continue; + } + + const sourceId = [msg.username, msg.otherUsername].sort().join('/'); + + if (!dmRooms.has(sourceId)) { + await this.converter.addChannel({ + importIds: [sourceId], + users: [msg.username, msg.otherUsername], + t: 'd', + }); + + dmRooms.add(sourceId); + } + + const newMessage = { + rid: sourceId, + u: { + _id: msg.username, + }, + ts: new Date(parseInt(msg.ts)), + msg: msg.text, + }; + + usedUsernames.add(msg.username); + usedUsernames.add(msg.otherUsername); + await this.converter.addMessage(newMessage); + } + } else { + const rid = getRoomId(folderName); + + for (const msg of data) { + const newMessage = { + rid, + u: { + _id: msg.username, + }, + ts: new Date(parseInt(msg.ts)), + msg: msg.text, + }; + + usedUsernames.add(msg.username); + await this.converter.addMessage(newMessage); + } + } + + await super.updateRecord({ 'count.messages': messagesCount, 'messagesstatus': null }); + } + + increaseProgressCount(); + } + + if (usersCount) { + const value = await Settings.incrementValueById('CSV_Importer_Count', usersCount, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + } + + // Check if any of the message usernames was not in the imported list of users + for (const username of usedUsernames) { + if (availableUsernames.has(username)) { + continue; + } + + // Check if an user with that username already exists + const user = await Users.findOneByUsername(username); + if (user && !user.importIds?.includes(username)) { + // Add the username to the local user's importIds so it can be found by the import process + // This way we can support importing new messages for existing users + await Users.addImportIds(user._id, username); + } + } + + await super.addCountToTotal(messagesCount + usersCount + channelsCount + contactsCount); + ImporterWebsocket.progressUpdated({ rate: 100 }); + + // Ensure we have at least a single record of any kind + if (usersCount === 0 && channelsCount === 0 && messagesCount === 0 && contactsCount === 0) { + this.logger.error({ msg: 'No valid record found in the import file.' }); + await super.updateProgress(ProgressStep.ERROR); + } + + return super.getProgress(); + } +} diff --git a/apps/meteor/server/lib/import/csv/index.ts b/apps/meteor/server/lib/import/csv/index.ts new file mode 100644 index 0000000..9d4a37f --- /dev/null +++ b/apps/meteor/server/lib/import/csv/index.ts @@ -0,0 +1,8 @@ +import { Importers } from '..'; +import { CsvImporter } from './CsvImporter'; + +Importers.add({ + key: 'csv', + name: 'CSV', + importer: CsvImporter, +}); diff --git a/apps/meteor/server/lib/import/definitions/IConversionCallbacks.ts b/apps/meteor/server/lib/import/definitions/IConversionCallbacks.ts new file mode 100644 index 0000000..262970d --- /dev/null +++ b/apps/meteor/server/lib/import/definitions/IConversionCallbacks.ts @@ -0,0 +1,16 @@ +import type { IImportRecord } from '@rocket.chat/core-typings'; + +export type ImporterBeforeImportCallback = { + (data: IImportRecord): Promise; +}; + +export type ImporterAfterImportCallback = { + (data: IImportRecord, isNewRecord: boolean): Promise; +}; + +export interface IConversionCallbacks { + beforeImportFn?: ImporterBeforeImportCallback; + afterImportFn?: ImporterAfterImportCallback; + onErrorFn?: () => Promise; + afterBatchFn?: (successCount: number, errorCount: number) => Promise; +} diff --git a/apps/meteor/server/lib/import/definitions/ImporterInfo.ts b/apps/meteor/server/lib/import/definitions/ImporterInfo.ts new file mode 100644 index 0000000..4741b3b --- /dev/null +++ b/apps/meteor/server/lib/import/definitions/ImporterInfo.ts @@ -0,0 +1,10 @@ +import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; + +import type { Importer } from '../classes/Importer'; + +export type ImporterInfo = { + key: string; + name: RocketchatI18nKeys; + visible: boolean; // Determines if this importer can be selected by the user in the New Import screen. + importer: typeof Importer; +}; diff --git a/apps/meteor/server/lib/import/index.ts b/apps/meteor/server/lib/import/index.ts new file mode 100644 index 0000000..71266bf --- /dev/null +++ b/apps/meteor/server/lib/import/index.ts @@ -0,0 +1,20 @@ +import { Importer } from './classes/Importer'; +import { ImporterSelection } from './classes/ImporterSelection'; +import { SelectionChannel } from './classes/ImporterSelectionChannel'; +import { SelectionUser } from './classes/ImporterSelectionUser'; +import { ImporterWebsocket } from './classes/ImporterWebsocket'; +import { ImportersContainer } from './classes/ImportersContainer'; +import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep'; +import './startup/setImportsToInvalid'; +import './startup/store'; + +export { Importer, ImporterWebsocket, ProgressStep, ImporterSelection as Selection, SelectionChannel, SelectionUser }; + +export const Importers = new ImportersContainer(); + +Importers.add({ + key: 'api', + name: 'API', + visible: false, + importer: Importer, +}); diff --git a/apps/meteor/server/lib/import/omnichannel-contacts/ContactImporter.ts b/apps/meteor/server/lib/import/omnichannel-contacts/ContactImporter.ts new file mode 100644 index 0000000..e8bea6e --- /dev/null +++ b/apps/meteor/server/lib/import/omnichannel-contacts/ContactImporter.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; + +import type { IImport } from '@rocket.chat/core-typings'; +import { parse } from 'csv-parse/lib/sync'; + +import { Importer, ProgressStep, ImporterWebsocket } from '..'; +import { addParsedContacts } from './addParsedContacts'; +import type { ConverterOptions } from '../classes/ImportDataConverter'; +import type { ImporterProgress } from '../classes/ImporterProgress'; +import type { ImporterInfo } from '../definitions/ImporterInfo'; + +export class ContactImporter extends Importer { + private csvParser: (csv: string) => string[][]; + + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + super(info, importRecord, converterOptions); + + this.csvParser = parse; + } + + override async prepareUsingLocalFile(fullFilePath: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + + ImporterWebsocket.progressUpdated({ rate: 0 }); + + await super.updateProgress(ProgressStep.PREPARING_CONTACTS); + // Reading the whole file at once for compatibility with the code written for the other importers + // We can change this to a stream once we improve the rest of the importer classes + const fileContents = fs.readFileSync(fullFilePath, { encoding: 'utf8' }); + if (!fileContents || typeof fileContents !== 'string') { + throw new Error('Failed to load file contents.'); + } + + const parsedContacts = this.csvParser(fileContents); + const contactsCount = await addParsedContacts.call(this.converter, parsedContacts); + + if (contactsCount === 0) { + this.logger.error('No contacts found in the import file.'); + await super.updateProgress(ProgressStep.ERROR); + } else { + await super.updateRecord({ 'count.contacts': contactsCount, 'count.total': contactsCount }); + ImporterWebsocket.progressUpdated({ rate: 100 }); + } + + return super.getProgress(); + } +} diff --git a/apps/meteor/server/lib/import/omnichannel-contacts/addParsedContacts.ts b/apps/meteor/server/lib/import/omnichannel-contacts/addParsedContacts.ts new file mode 100644 index 0000000..55bbb6a --- /dev/null +++ b/apps/meteor/server/lib/import/omnichannel-contacts/addParsedContacts.ts @@ -0,0 +1,39 @@ +import { Random } from '@rocket.chat/random'; + +import type { ImportDataConverter } from '../classes/ImportDataConverter'; + +export async function addParsedContacts(this: ImportDataConverter, parsedContacts: string[][]): Promise { + const columnNames = parsedContacts.shift(); + let addedContacts = 0; + + for (const parsedData of parsedContacts) { + const contactData = parsedData.reduce( + (acc, value, index) => { + const columnName = columnNames && index < columnNames.length ? columnNames[index] : `column${index}`; + return { + ...acc, + [columnName]: value, + }; + }, + {} as Record, + ); + + if (!contactData.emails && !contactData.phones && !contactData.name) { + continue; + } + + const { emails = '', phones = '', name = '', manager: contactManager = undefined, id = Random.id(), ...customFields } = contactData; + + await this.addContact({ + importIds: [id], + emails: emails.split(';'), + phones: phones.split(';'), + name, + contactManager, + customFields, + }); + addedContacts++; + } + + return addedContacts; +} diff --git a/apps/meteor/server/lib/import/omnichannel-contacts/index.ts b/apps/meteor/server/lib/import/omnichannel-contacts/index.ts new file mode 100644 index 0000000..68d0ce3 --- /dev/null +++ b/apps/meteor/server/lib/import/omnichannel-contacts/index.ts @@ -0,0 +1,12 @@ +import { License } from '@rocket.chat/license'; + +import { Importers } from '..'; +import { ContactImporter } from './ContactImporter'; + +License.onValidFeature('contact-id-verification', () => { + Importers.add({ + key: 'omnichannel_contact', + name: 'omnichannel_contacts_importer', + importer: ContactImporter, + }); +}); diff --git a/apps/meteor/server/lib/import/pending-avatars/PendingAvatarImporter.ts b/apps/meteor/server/lib/import/pending-avatars/PendingAvatarImporter.ts new file mode 100644 index 0000000..caa81ba --- /dev/null +++ b/apps/meteor/server/lib/import/pending-avatars/PendingAvatarImporter.ts @@ -0,0 +1,75 @@ +import type { IImporterShortSelection } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { Importer, ProgressStep } from '..'; +import { setAvatarFromServiceWithValidation } from '../../users/setUserAvatar'; +import type { ImporterProgress } from '../classes/ImporterProgress'; + +export class PendingAvatarImporter extends Importer { + async prepareFileCount() { + this.logger.debug({ msg: 'start preparing import operation' }); + await super.updateProgress(ProgressStep.PREPARING_STARTED); + + const fileCount = await Users.countAllUsersWithPendingAvatar(); + + if (fileCount === 0) { + await super.updateProgress(ProgressStep.DONE); + return 0; + } + + this.progress.count.total += fileCount; + await this.updateRecord({ + 'count.messages': fileCount, + 'count.total': fileCount, + 'messagesstatus': null, + 'status': ProgressStep.IMPORTING_FILES, + }); + this.reportProgress(); + + setImmediate(() => { + void this.startImport({}); + }); + + return fileCount; + } + + override async startImport(importSelection: IImporterShortSelection): Promise { + const pendingFileUserList = Users.findAllUsersWithPendingAvatar(); + try { + for await (const user of pendingFileUserList) { + try { + const { _pendingAvatarUrl: url, name, _id } = user; + + try { + if (!url?.startsWith('http')) { + continue; + } + + try { + await setAvatarFromServiceWithValidation(_id, url, undefined, 'url'); + await Users.updateOne({ _id }, { $unset: { _pendingAvatarUrl: '' } }); + } catch (error) { + this.logger.warn({ msg: 'Failed to set user avatar from pending URL', name, url }); + } + } finally { + await this.addCountCompleted(1); + } + } catch (error) { + this.logger.error({ msg: 'Failed to process pending avatar for user', err: error }); + } + } + } catch (error) { + // If the cursor expired, restart the method + if (this.isCursorNotFoundError(error)) { + this.logger.info('CursorNotFound'); + return this.startImport(importSelection); + } + + await super.updateProgress(ProgressStep.ERROR); + throw error; + } + + await super.updateProgress(ProgressStep.DONE); + return this.getProgress(); + } +} diff --git a/apps/meteor/server/lib/import/pending-avatars/index.ts b/apps/meteor/server/lib/import/pending-avatars/index.ts new file mode 100644 index 0000000..663b583 --- /dev/null +++ b/apps/meteor/server/lib/import/pending-avatars/index.ts @@ -0,0 +1,9 @@ +import { Importers } from '..'; +import { PendingAvatarImporter } from './PendingAvatarImporter'; + +Importers.add({ + key: 'pending-avatars', + name: 'Pending Avatars', + visible: false, + importer: PendingAvatarImporter, +}); diff --git a/apps/meteor/server/lib/import/pending-files/PendingFileImporter.ts b/apps/meteor/server/lib/import/pending-files/PendingFileImporter.ts new file mode 100644 index 0000000..fa3522b --- /dev/null +++ b/apps/meteor/server/lib/import/pending-files/PendingFileImporter.ts @@ -0,0 +1,237 @@ +import http from 'node:http'; +import https from 'node:https'; + +import { api } from '@rocket.chat/core-services'; +import type { IImport, MessageAttachment, IUpload, IImporterShortSelection } from '@rocket.chat/core-typings'; +import { Messages, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { Importer, ProgressStep } from '..'; +import { parseFileIntoMessageAttachments } from '../../../meteor-methods/messages/sendFileMessage'; +import { FileUpload } from '../../media/file-upload'; +import type { ConverterOptions } from '../classes/ImportDataConverter'; +import type { ImporterProgress } from '../classes/ImporterProgress'; +import type { ImporterInfo } from '../definitions/ImporterInfo'; + +export class PendingFileImporter extends Importer { + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + super(info, importRecord, converterOptions); + } + + async prepareFileCount() { + this.logger.debug('start preparing import operation'); + await super.updateProgress(ProgressStep.PREPARING_STARTED); + + const fileCount = await Messages.countAllImportedMessagesWithFilesToDownload(); + if (fileCount === 0) { + await super.updateProgress(ProgressStep.DONE); + return 0; + } + + this.progress.count.total += fileCount; + await this.updateRecord({ + 'count.messages': fileCount, + 'count.total': fileCount, + 'messagesstatus': null, + 'status': ProgressStep.IMPORTING_FILES, + }); + this.reportProgress(); + + setImmediate(() => { + void this.startImport({}); + }); + + return fileCount; + } + + override async startImport(importSelection: IImporterShortSelection): Promise { + const downloadedFileIds: string[] = []; + const maxFileCount = 10; + const maxFileSize = 1024 * 1024 * 500; + + let count = 0; + let currentSize = 0; + let nextSize = 0; + + const waitForFiles = async () => { + if (count + 1 < maxFileCount && currentSize + nextSize < maxFileSize) { + return; + } + + return new Promise((resolve) => { + const handler = setInterval(() => { + if (count + 1 >= maxFileCount) { + return; + } + + if (currentSize + nextSize >= maxFileSize && count > 0) { + return; + } + + clearInterval(handler); + resolve(); + }, 1000); + }); + }; + + const completeFile = async (details: { size: number }) => { + await this.addCountCompleted(1); + count--; + currentSize -= details.size; + }; + + const failFile = async (details: { size: number }) => { + await this.addCountError(1); + count--; + currentSize -= details.size; + }; + + const logError = this.logger.error.bind(this.logger); + + try { + const pendingFileMessageList = Messages.findAllImportedMessagesWithFilesToDownload(); + const importedRoomIds = new Set(); + for await (const message of pendingFileMessageList) { + try { + const { _importFile } = message; + + if (!_importFile || _importFile.downloaded || downloadedFileIds.includes(_importFile.id)) { + await this.addCountCompleted(1); + continue; + } + + const url = _importFile.downloadUrl; + if (!url?.startsWith('http')) { + await this.addCountCompleted(1); + continue; + } + + const details: { message_id: string; name: string; size: number; userId: string; rid: string; type?: string } = { + message_id: `${message._id}-file-${_importFile.id}`, + name: _importFile.name || Random.id(), + size: _importFile.size || 0, + userId: message.u._id, + rid: message.rid, + }; + + const fileStore = FileUpload.getStore('Uploads'); + + nextSize = details.size; + await waitForFiles(); + count++; + currentSize += nextSize; + downloadedFileIds.push(_importFile.id); + + void this.downloadFile(url, details) + .then(async (rawData) => { + // Bypass the fileStore filters + const file = await fileStore._doInsert(details, rawData); + + const rocketChatUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); + const user = await Users.findOneById(message.u._id); + const attachment = user + ? (await parseFileIntoMessageAttachments(file, message.rid, user)).attachments[0] + : this.getMessageAttachment(file, rocketChatUrl); + + await Messages.setImportFileRocketChatAttachment(_importFile.id, rocketChatUrl, attachment); + await completeFile(details); + importedRoomIds.add(message.rid); + }) + .catch(async (err) => { + logError({ msg: 'Failed to download pending file', url, err }); + await failFile(details); + }); + } catch (err) { + this.logger.error({ err }); + } + } + + void api.broadcast('notify.importedMessages', { roomIds: Array.from(importedRoomIds) }); + } catch (error) { + // If the cursor expired, restart the method + if (this.isCursorNotFoundError(error)) { + this.logger.info('CursorNotFound'); + return this.startImport(importSelection); + } + + await super.updateProgress(ProgressStep.ERROR); + throw error; + } + + await super.updateProgress(ProgressStep.DONE); + return this.getProgress(); + } + + private downloadFile(url: string, details: { type?: string }): Promise { + return new Promise((resolve, reject) => { + const requestModule = /https/i.test(url) ? https : http; + + requestModule + .get(url, (res) => { + res.on('error', reject); + + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + // Error responses (e.g. a redirect to a login page) would otherwise be saved as the file's content. + res.resume(); // discard the body and free the socket + reject(new Error(`Unexpected response status ${res.statusCode}`)); + return; + } + + const contentType = res.headers['content-type']; + if (!details.type && contentType) { + details.type = contentType; + } + + const rawData: Uint8Array[] = []; + res.on('data', (chunk) => { + rawData.push(chunk); + + // Update progress more often on large files + this.reportProgress(); + }); + res.on('end', () => resolve(Buffer.concat(rawData))); + }) + .on('error', reject); + }); + } + + getMessageAttachment(file: IUpload, url: string): MessageAttachment { + if (file.type) { + if (/^image\/.+/.test(file.type)) { + return { + title: file.name, + title_link: url, + image_url: url, + image_type: file.type, + image_size: file.size, + image_dimensions: file.identify ? file.identify.size : undefined, + }; + } + + if (/^audio\/.+/.test(file.type)) { + return { + title: file.name, + title_link: url, + audio_url: url, + audio_type: file.type, + audio_size: file.size, + }; + } + + if (/^video\/.+/.test(file.type)) { + return { + title: file.name, + title_link: url, + video_url: url, + video_type: file.type, + video_size: file.size, + }; + } + } + + return { + title: file.name, + title_link: url, + }; + } +} diff --git a/apps/meteor/server/lib/import/pending-files/index.ts b/apps/meteor/server/lib/import/pending-files/index.ts new file mode 100644 index 0000000..a27f979 --- /dev/null +++ b/apps/meteor/server/lib/import/pending-files/index.ts @@ -0,0 +1,9 @@ +import { Importers } from '..'; +import { PendingFileImporter } from './PendingFileImporter'; + +Importers.add({ + key: 'pending-files', + name: 'Pending Files', + visible: false, + importer: PendingFileImporter, +}); diff --git a/apps/meteor/server/lib/import/slack-users/SlackUsersImporter.ts b/apps/meteor/server/lib/import/slack-users/SlackUsersImporter.ts new file mode 100644 index 0000000..9ac45f7 --- /dev/null +++ b/apps/meteor/server/lib/import/slack-users/SlackUsersImporter.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; + +import type { IImport, IImportUser } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { parse } from 'csv-parse/lib/sync'; + +import { Importer, ProgressStep } from '..'; +import { RocketChatFile } from '../../media/file'; +import { notifyOnSettingChanged } from '../../notifyListener'; +import type { ConverterOptions } from '../classes/ImportDataConverter'; +import type { ImporterProgress } from '../classes/ImporterProgress'; +import type { ImporterInfo } from '../definitions/ImporterInfo'; + +export class SlackUsersImporter extends Importer { + private csvParser: (csv: string) => string[]; + + constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) { + super(info, importRecord, converterOptions); + + this.csvParser = parse; + } + + override async prepareUsingLocalFile(fullFilePath: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + + const file = fs.readFileSync(fullFilePath); + const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file); + + const { contentType } = this.importRecord; + const fileName = this.importRecord.file; + + const data = buffer.toString('base64'); + const dataURI = `data:${contentType};base64,${data}`; + + return this.prepare(dataURI, fileName || ''); + } + + async prepare(dataURI: string, fileName: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + await this.updateRecord({ file: fileName }); + + await super.updateProgress(ProgressStep.PREPARING_USERS); + const uriResult = RocketChatFile.dataURIParse(dataURI); + const buf = Buffer.from(uriResult.image, 'base64'); + const parsed = this.csvParser(buf.toString()); + + let userCount = 0; + for (const [index, user] of parsed.entries()) { + // Ignore the first column + if (index === 0) { + continue; + } + + const username = user[0]; + const email = user[1]; + if (!email) { + continue; + } + + const name = user[7] || user[8] || username; + + const newUser: IImportUser = { + emails: [email], + importIds: [email], + username, + name, + type: 'user', + }; + + switch (user[2]) { + case 'Admin': + newUser.roles = ['admin']; + break; + case 'Bot': + newUser.roles = ['bot']; + newUser.type = 'bot'; + break; + case 'Deactivated': + newUser.deleted = true; + break; + } + + await this.converter.addUser(newUser); + userCount++; + } + + if (userCount === 0) { + this.logger.error('No users found in the import file.'); + await super.updateProgress(ProgressStep.ERROR); + return super.getProgress(); + } + + await super.updateProgress(ProgressStep.USER_SELECTION); + await super.addCountToTotal(userCount); + + const value = await Settings.incrementValueById('Slack_Users_Importer_Count', userCount, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + + await super.updateRecord({ 'count.users': userCount }); + return super.getProgress(); + } +} diff --git a/apps/meteor/server/lib/import/slack-users/index.ts b/apps/meteor/server/lib/import/slack-users/index.ts new file mode 100644 index 0000000..007809b --- /dev/null +++ b/apps/meteor/server/lib/import/slack-users/index.ts @@ -0,0 +1,8 @@ +import { Importers } from '..'; +import { SlackUsersImporter } from './SlackUsersImporter'; + +Importers.add({ + key: 'slack-users', + name: 'Slack_Users', + importer: SlackUsersImporter, +}); diff --git a/apps/meteor/server/lib/import/slack/SlackImporter.ts b/apps/meteor/server/lib/import/slack/SlackImporter.ts new file mode 100644 index 0000000..ca76588 --- /dev/null +++ b/apps/meteor/server/lib/import/slack/SlackImporter.ts @@ -0,0 +1,730 @@ +import type { IImportUser, IImportMessage, IImportPendingFile } from '@rocket.chat/core-typings'; +import { Messages, Settings, ImportData } from '@rocket.chat/models'; +import type { IZipEntry } from 'adm-zip'; + +import { Importer, ProgressStep, ImporterWebsocket } from '..'; +import { MentionsParser } from '../../../../app/mentions/lib/MentionsParser'; +import { settings } from '../../../settings'; +import { notifyOnSettingChanged } from '../../notifyListener'; +import { getUserAvatarURL } from '../../utils/getUserAvatarURL'; +import type { ImporterProgress } from '../classes/ImporterProgress'; + +type SlackChannel = { + id: string; + name: string; + topic?: { + value: string; + creator: string; + last_set: number; + }; + members: string[]; + purpose?: { + value: string; + creator: string; + last_set: number; + }; + created: number; + creator: string | null; + is_general: boolean; + is_archived: boolean; +}; + +type SlackUser = { + id: string; + name: string; + profile: { + real_name: string; + email: string; + image_512: string; + image_original: string; + status_text: string; + title: string; + }; + tz_offset: number; + deleted: boolean; + is_bot: boolean; +}; + +type SlackFile = { + id: string; + url_private_download: string; + size: number; + name: string; + is_external: boolean; +}; + +type SlackMessage = { + id: string; + ts: string; + user: string; + reactions?: { + name: string; + users: string[]; + }[]; + type: 'message'; + subtype?: string; + files?: SlackFile[]; + text: string; + edited?: { + ts: string; + user: string; + }; + thread_ts?: string; + reply_users?: string[]; + reply_count?: number; + replies?: { + user: string; + }[]; + latest_reply: string; + icons?: { + emoji: string; + }; + attachments?: SlackAttachment[]; +} & ( + | { + subtype: 'channel_purpose' | 'group_purpose'; + purpose: string; + } + | { + subtype: 'channel_join' | 'group_join' | 'channel_leave' | 'group_leave'; + } + | { + subtype: 'channel_topic' | 'group_topic'; + topic: string; + } + | { + subtype: 'channel_name' | 'group_name'; + name: string; + } + | { + subtype: 'pinned_item'; + attachments: SlackAttachment[]; + } + | { + subtype: 'file_share'; + file: SlackFile; + } + | { + subtype: 'me_message'; + } +); + +type SlackAttachment = { + text: string; + title: string; + fallback: string; + author_subname: string; +}; + +export class SlackImporter extends Importer { + private _useUpsert = false; + + async prepareChannelsFile(entry: IZipEntry): Promise { + await super.updateProgress(ProgressStep.PREPARING_CHANNELS); + const data = (JSON.parse(entry.getData().toString()) as SlackChannel[]).filter( + (channel): channel is SlackChannel & { creator: string } => 'creator' in channel && channel.creator != null, + ); + + this.logger.debug({ msg: 'loaded channels', count: data.length }); + + await this.addCountToTotal(data.length); + + for (const channel of data) { + await this.converter.addChannel({ + _id: channel.is_general ? 'general' : undefined, + u: { + _id: this._replaceSlackUserId(channel.creator), + }, + importIds: [channel.id], + name: channel.name, + users: this._replaceSlackUserIds(channel.members), + t: 'c', + topic: channel.topic?.value || undefined, + description: channel.purpose?.value || undefined, + ts: channel.created ? new Date(channel.created * 1000) : undefined, + archived: channel.is_archived, + }); + } + + return data.length; + } + + async prepareGroupsFile(entry: IZipEntry): Promise { + await super.updateProgress(ProgressStep.PREPARING_CHANNELS); + const data = (JSON.parse(entry.getData().toString()) as SlackChannel[]).filter( + (channel): channel is SlackChannel & { creator: string } => 'creator' in channel && channel.creator != null, + ); + + this.logger.debug({ msg: 'loaded groups', count: data.length }); + + await this.addCountToTotal(data.length); + + for (const channel of data) { + await this.converter.addChannel({ + u: { + _id: this._replaceSlackUserId(channel.creator), + }, + importIds: [channel.id], + name: channel.name, + users: this._replaceSlackUserIds(channel.members), + t: 'p', + topic: channel.topic?.value || undefined, + description: channel.purpose?.value || undefined, + ts: channel.created ? new Date(channel.created * 1000) : undefined, + archived: channel.is_archived, + }); + } + + return data.length; + } + + async prepareMpimpsFile(entry: IZipEntry): Promise { + await super.updateProgress(ProgressStep.PREPARING_CHANNELS); + const data = (JSON.parse(entry.getData().toString()) as SlackChannel[]).filter( + (channel): channel is SlackChannel & { creator: string } => 'creator' in channel && channel.creator != null, + ); + + this.logger.debug({ msg: 'loaded mpims', count: data.length }); + + await this.addCountToTotal(data.length); + + const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; + + for (const channel of data) { + await this.converter.addChannel({ + u: { + _id: this._replaceSlackUserId(channel.creator), + }, + importIds: [channel.id], + name: channel.name, + users: this._replaceSlackUserIds(channel.members), + t: channel.members.length > maxUsers ? 'p' : 'd', + topic: channel.topic?.value || undefined, + description: channel.purpose?.value || undefined, + ts: channel.created ? new Date(channel.created * 1000) : undefined, + archived: channel.is_archived, + }); + } + + return data.length; + } + + async prepareDMsFile(entry: IZipEntry): Promise { + await super.updateProgress(ProgressStep.PREPARING_CHANNELS); + const data = JSON.parse(entry.getData().toString()) as SlackChannel[]; + + this.logger.debug({ msg: 'loaded dms', count: data.length }); + + await this.addCountToTotal(data.length); + for (const channel of data) { + await this.converter.addChannel({ + importIds: [channel.id], + users: this._replaceSlackUserIds(channel.members), + t: 'd', + ts: channel.created ? new Date(channel.created * 1000) : undefined, + }); + } + + return data.length; + } + + async prepareUsersFile(entry: IZipEntry): Promise { + await super.updateProgress(ProgressStep.PREPARING_USERS); + const data = JSON.parse(entry.getData().toString()) as SlackUser[]; + + this.logger.debug({ msg: 'loaded users', count: data.length }); + + // Insert the users record + await this.updateRecord({ 'count.users': data.length }); + await this.addCountToTotal(data.length); + + for (const user of data) { + const newUser: IImportUser = { + emails: [], + importIds: [user.id], + username: user.name, + name: user.profile.real_name, + utcOffset: user.tz_offset && user.tz_offset / 3600, + avatarUrl: user.profile.image_original || user.profile.image_512, + deleted: user.deleted, + statusText: user.profile.status_text || undefined, + bio: user.profile.title || undefined, + type: 'user', + }; + + if (user.profile.email) { + newUser.emails.push(user.profile.email); + } + + if (user.is_bot) { + newUser.roles = ['bot']; + newUser.type = 'bot'; + } + + await this.converter.addUser(newUser); + } + + return data.length; + } + + override async prepareUsingLocalFile(fullFilePath: string): Promise { + this.logger.debug('start preparing import operation'); + await this.converter.clearImportData(); + + const zip = new this.AdmZip(fullFilePath); + const totalEntries = zip.getEntryCount(); + + let userCount = 0; + let messagesCount = 0; + let channelCount = 0; + let count = 0; + + ImporterWebsocket.progressUpdated({ rate: 0 }); + let oldRate = 0; + + const increaseProgress = () => { + try { + count++; + const rate = Math.floor((count * 1000) / totalEntries) / 10; + if (rate > oldRate) { + ImporterWebsocket.progressUpdated({ rate }); + oldRate = rate; + } + } catch (err) { + this.logger.error({ msg: 'Error updating progress', err }); + } + }; + + try { + // we need to iterate the zip file twice so that all channels are loaded before the messages + for (const entry of zip.getEntries()) { + try { + if (entry.entryName === 'channels.json') { + channelCount += await this.prepareChannelsFile(entry); + await this.updateRecord({ 'count.channels': channelCount }); + increaseProgress(); + continue; + } + + if (entry.entryName === 'groups.json') { + channelCount += await this.prepareGroupsFile(entry); + await this.updateRecord({ 'count.channels': channelCount }); + increaseProgress(); + continue; + } + + if (entry.entryName === 'mpims.json') { + channelCount += await this.prepareMpimpsFile(entry); + await this.updateRecord({ 'count.channels': channelCount }); + increaseProgress(); + continue; + } + + if (entry.entryName === 'dms.json') { + channelCount += await this.prepareDMsFile(entry); + await this.updateRecord({ 'count.channels': channelCount }); + increaseProgress(); + continue; + } + + if (entry.entryName === 'users.json') { + userCount = await this.prepareUsersFile(entry); + increaseProgress(); + continue; + } + } catch (err) { + this.logger.error({ msg: 'Error adding missed type', err }); + } + } + + if (userCount) { + const value = await Settings.incrementValueById('Slack_Importer_Count', userCount, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + } + + const missedTypes: Record = {}; + // If we have no slack message yet, then we can insert them instead of upserting + this._useUpsert = !(await Messages.findOne({ _id: /slack\-.*/ })); + + for (const entry of zip.getEntries()) { + try { + if (entry.entryName.includes('__MACOSX') || entry.entryName.includes('.DS_Store')) { + count++; + this.logger.debug({ msg: 'Ignoring the file', entryName: entry.entryName }); + continue; + } + + if (['channels.json', 'groups.json', 'mpims.json', 'dms.json', 'users.json'].includes(entry.entryName)) { + continue; + } + + if (!entry.isDirectory && entry.entryName.includes('/')) { + const item = entry.entryName.split('/'); + + const channel = item[0]; + const date = item[1].split('.')[0]; + + try { + // Insert the messages records + if (this.progress.step !== ProgressStep.PREPARING_MESSAGES) { + await super.updateProgress(ProgressStep.PREPARING_MESSAGES); + } + + const tempMessages = JSON.parse(entry.getData().toString()) as SlackMessage[]; + messagesCount += tempMessages.length; + await this.updateRecord({ messagesstatus: `${channel}/${date}` }); + await this.addCountToTotal(tempMessages.length); + + const slackChannelId = await ImportData.findChannelImportIdByNameOrImportId(channel); + + if (slackChannelId) { + for (const message of tempMessages) { + await this.prepareMessageObject(message, missedTypes, slackChannelId); + } + } + } catch (error) { + this.logger.warn({ msg: 'Entry is not a valid JSON file; unable to import', entryName: entry.entryName, err: error }); + } + } + } catch (err) { + this.logger.error({ msg: 'Error processing message entry', err }); + } + + increaseProgress(); + } + + if (Object.keys(missedTypes).length > 0) { + this.logger.info({ msg: 'Missed import types', missedTypes }); + } + } catch (err) { + this.logger.error({ msg: 'Error preparing import using local file', err }); + throw err; + } + + ImporterWebsocket.progressUpdated({ rate: 100 }); + await this.updateRecord({ 'count.messages': messagesCount, 'messagesstatus': null }); + + return this.progress; + } + + parseMentions(newMessage: IImportMessage): void { + const mentionsParser = new MentionsParser({ + pattern: () => '[0-9a-zA-Z]+', + useRealName: () => settings.get('UI_Use_Real_Name'), + me: () => 'me', + }); + + const users = mentionsParser + .getUserMentions(newMessage.msg) + .filter((u: string) => u) + .map((uid: string) => this._replaceSlackUserId(uid.slice(1, uid.length))); + if (users.length) { + if (!newMessage.mentions) { + newMessage.mentions = []; + } + newMessage.mentions.push(...users); + } + + const channels = mentionsParser + .getChannelMentions(newMessage.msg) + .filter((c: string) => c) + .map((name: string) => name.slice(1, name.length)); + if (channels.length) { + if (!newMessage.channels) { + newMessage.channels = []; + } + newMessage.channels.push(...channels); + } + } + + async processMessageSubType( + message: SlackMessage, + slackChannelId: string, + newMessage: IImportMessage, + missedTypes: Record, + ): Promise { + const ignoreTypes: Record = { bot_add: true, file_comment: true, file_mention: true }; + + switch (message.subtype) { + case 'channel_join': + case 'group_join': + newMessage.t = 'uj'; + newMessage.groupable = false; + return true; + case 'channel_leave': + case 'group_leave': + newMessage.t = 'ul'; + newMessage.groupable = false; + return true; + case 'channel_purpose': + case 'group_purpose': + newMessage.t = 'room_changed_description'; + newMessage.groupable = false; + newMessage.msg = message.purpose; + return true; + case 'channel_topic': + case 'group_topic': + newMessage.t = 'room_changed_topic'; + newMessage.groupable = false; + newMessage.msg = message.topic; + return true; + case 'channel_name': + case 'group_name': + newMessage.t = 'r'; + newMessage.msg = message.name; + newMessage.groupable = false; + return true; + case 'pinned_item': + if (message.attachments) { + if (!newMessage.attachments) { + newMessage.attachments = []; + } + newMessage.attachments.push({ + text: this.convertSlackMessageToRocketChat(message.attachments[0].text), + author_name: message.attachments[0].author_subname, + author_icon: getUserAvatarURL(message.attachments[0].author_subname), + }); + newMessage.t = 'message_pinned'; + } + break; + case 'file_share': + if (message.file?.url_private_download) { + const fileId = this.makeSlackMessageId(slackChannelId, message.ts, 'share'); + const fileMessage: IImportMessage = { + _id: fileId, + rid: newMessage.rid, + ts: newMessage.ts, + msg: '', + _hidden: true, + _importFile: this.convertSlackFileToPendingFile(message.file), + u: { + _id: newMessage.u._id, + }, + }; + + if (message.thread_ts && message.thread_ts !== message.ts) { + fileMessage.tmid = this.makeSlackMessageId(slackChannelId, message.thread_ts); + } + + await this.converter.addMessage(fileMessage, this._useUpsert); + } + break; + + default: + if (!missedTypes[message.subtype] && !ignoreTypes[message.subtype]) { + missedTypes[message.subtype] = message; + } + break; + } + + return false; + } + + makeSlackMessageId(channelId: string, ts: string, fileIndex?: string): string { + const base = `slack-${channelId}-${ts.replace(/\./g, '-')}`; + + if (fileIndex) { + return `${base}-file${fileIndex}`; + } + + return base; + } + + async prepareMessageObject(message: SlackMessage, missedTypes: Record, slackChannelId: string): Promise { + const id = this.makeSlackMessageId(slackChannelId, message.ts); + const newMessage: IImportMessage = { + _id: id, + rid: slackChannelId, + ts: new Date(parseInt(message.ts.split('.')[0]) * 1000), + msg: '', + u: { + _id: this._replaceSlackUserId(message.user), + }, + }; + + // Process the reactions + if (message.reactions && message.reactions.length > 0) { + newMessage.reactions = message.reactions.reduce( + (newReactions, reaction) => { + const name = `:${reaction.name}:`; + return { + ...newReactions, + ...(reaction.users?.length ? { name: { name, users: this._replaceSlackUserIds(reaction.users) } } : {}), + }; + }, + {} as Required['reactions'], + ); + } + + if (message.type === 'message') { + if (message.files) { + let fileIndex = 0; + const promises = message.files.map(async (file) => { + fileIndex++; + + const fileId = this.makeSlackMessageId(slackChannelId, message.ts, String(fileIndex)); + const fileMessage: IImportMessage = { + _id: fileId, + rid: slackChannelId, + ts: newMessage.ts, + msg: '', + _hidden: true, + _importFile: this.convertSlackFileToPendingFile(file), + u: { + _id: this._replaceSlackUserId(message.user), + }, + }; + + if (message.thread_ts && message.thread_ts !== message.ts) { + fileMessage.tmid = this.makeSlackMessageId(slackChannelId, message.thread_ts); + } + + await this.converter.addMessage(fileMessage, this._useUpsert); + }); + await Promise.all(promises); + } + + const regularTypes = ['me_message', 'thread_broadcast']; + + const isBotMessage = message.subtype && ['bot_message', 'slackbot_response'].includes(message.subtype); + + if (message.subtype && !regularTypes.includes(message.subtype) && !isBotMessage) { + if (await this.processMessageSubType(message, slackChannelId, newMessage, missedTypes)) { + await this.converter.addMessage(newMessage, this._useUpsert); + } + } else { + const text = this.convertSlackMessageToRocketChat(message.text); + + if (isBotMessage) { + newMessage.bot = true; + } + + if (message.subtype === 'me_message') { + newMessage.msg = `_${text}_`; + } else { + newMessage.msg = text; + } + + if (message.thread_ts) { + if (message.thread_ts === message.ts) { + if (message.reply_users) { + const replies = new Set(); + message.reply_users.forEach((item: string) => { + replies.add(this._replaceSlackUserId(item)); + }); + + if (replies.size) { + newMessage.replies = Array.from(replies); + } + } else if (message.replies) { + const replies = new Set(); + message.replies.forEach((item: { user: string }) => { + replies.add(this._replaceSlackUserId(item.user)); + }); + + if (replies.size) { + newMessage.replies = Array.from(replies); + } + } else { + this.logger.warn({ + msg: 'Failed to import the parent comment; missing replies/reply_users field', + messageId: newMessage._id, + }); + } + + newMessage.tcount = message.reply_count; + newMessage.tlm = new Date(parseInt(message.latest_reply.split('.')[0]) * 1000); + } else { + newMessage.tmid = this.makeSlackMessageId(slackChannelId, message.thread_ts); + } + } + + if (message.edited) { + newMessage.editedAt = new Date(parseInt(message.edited.ts.split('.')[0]) * 1000); + if (message.edited.user) { + newMessage.editedBy = this._replaceSlackUserId(message.edited.user); + } + } + + if (message.attachments) { + newMessage.attachments = this.convertMessageAttachments(message.attachments); + } + + if (message.icons?.emoji) { + newMessage.emoji = message.icons.emoji; + } + + this.parseMentions(newMessage); + await this.converter.addMessage(newMessage, this._useUpsert); + } + } + } + + _replaceSlackUserId(userId: string): string { + if (userId === 'USLACKBOT') { + return 'rocket.cat'; + } + + return userId; + } + + _replaceSlackUserIds(members: string[]) { + if (!members?.length) { + return []; + } + return members.map((userId) => this._replaceSlackUserId(userId)); + } + + convertSlackMessageToRocketChat(message: string): string { + if (message) { + message = message.replace(//g, '@all'); + message = message.replace(//g, '@all'); + message = message.replace(//g, '@here'); + message = message.replace(/>/g, '>'); + message = message.replace(/</g, '<'); + message = message.replace(/&/g, '&'); + message = message.replace(/:simple_smile:/g, ':smile:'); + message = message.replace(/:memo:/g, ':pencil:'); + message = message.replace(/:piggy:/g, ':pig:'); + message = message.replace(/:uk:/g, ':gb:'); + message = message.replace(/<(http[s]?:[^>|]*)>/g, '$1'); + message = message.replace(/<(http[s]?:[^|]*)\|([^>]*)>/g, '[$2]($1)'); + message = message.replace(/<#([^|]*)\|([^>]*)>/g, '#$2'); + message = message.replace(/<@([^|]*)\|([^>]*)>/g, '@$1'); + message = message.replace(/<@([^|>]*)>/g, '@$1'); + } else { + message = ''; + } + + return message; + } + + convertSlackFileToPendingFile(file: SlackFile): IImportPendingFile { + return { + downloadUrl: file.url_private_download, + id: file.id, + size: file.size, + name: file.name, + external: file.is_external, + source: 'slack', + original: { + ...file, + }, + }; + } + + convertMessageAttachments(attachments: SlackAttachment[]): IImportMessage['attachments'] { + if (!attachments?.length) { + return undefined; + } + + return attachments.map((attachment) => ({ + ...attachment, + text: this.convertSlackMessageToRocketChat(attachment.text), + title: this.convertSlackMessageToRocketChat(attachment.title), + fallback: this.convertSlackMessageToRocketChat(attachment.fallback), + })); + } +} diff --git a/apps/meteor/server/lib/import/slack/index.ts b/apps/meteor/server/lib/import/slack/index.ts new file mode 100644 index 0000000..d795904 --- /dev/null +++ b/apps/meteor/server/lib/import/slack/index.ts @@ -0,0 +1,8 @@ +import { Importers } from '..'; +import { SlackImporter } from './SlackImporter'; + +Importers.add({ + key: 'slack', + name: 'Slack', + importer: SlackImporter, +}); diff --git a/apps/meteor/server/lib/import/startup/setImportsToInvalid.js b/apps/meteor/server/lib/import/startup/setImportsToInvalid.js new file mode 100644 index 0000000..1524809 --- /dev/null +++ b/apps/meteor/server/lib/import/startup/setImportsToInvalid.js @@ -0,0 +1,16 @@ +import { Imports } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { ProgressStep } from '../../../../app/importer/lib/ImporterProgressStep'; + +Meteor.startup(async () => { + const lastOperation = await Imports.findLastImport(); + + // If the operation is still on "ready to start" state, we don't need to invalidate it. + if (lastOperation && [ProgressStep.USER_SELECTION].includes(lastOperation.status)) { + await Imports.invalidateOperationsExceptId(lastOperation._id); + return; + } + + await Imports.invalidateAllOperations(); +}); diff --git a/apps/meteor/server/lib/import/startup/store.js b/apps/meteor/server/lib/import/startup/store.js new file mode 100644 index 0000000..ec6ec66 --- /dev/null +++ b/apps/meteor/server/lib/import/startup/store.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { RocketChatFile } from '../../media/file'; + +export let RocketChatImportFileInstance; + +Meteor.startup(() => { + const RocketChatStore = RocketChatFile.FileSystem; + + let path = '/tmp/rocketchat-importer'; + if (settings.get('ImportFile_FileSystemPath') != null) { + if (settings.get('ImportFile_FileSystemPath').trim() !== '') { + path = settings.get('ImportFile_FileSystemPath'); + } + } + + RocketChatImportFileInstance = new RocketChatStore({ + name: 'import_files', + absolutePath: path, + }); +}); diff --git a/apps/meteor/server/lib/integrations/functions/clearIntegrationHistory.ts b/apps/meteor/server/lib/integrations/functions/clearIntegrationHistory.ts new file mode 100644 index 0000000..405b409 --- /dev/null +++ b/apps/meteor/server/lib/integrations/functions/clearIntegrationHistory.ts @@ -0,0 +1,62 @@ +import type { IOutgoingIntegration, IIntegration } from '@rocket.chat/core-typings'; +import { Integrations, IntegrationHistory } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import notifications from '../../notifications/core/lib/Notifications'; +import { triggerHandler } from '../lib/triggerHandler'; + +export const clearIntegrationHistoryMethod = async (userId: string, integrationId: string): Promise => { + let integration: IIntegration | null = null; + + if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) { + integration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) { + integration = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + } else { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' }); + } + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'clearIntegrationHistory' }); + } + + await IntegrationHistory.removeByIntegrationId(integrationId); + + notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed', id: integrationId }); +}; + +export const replayOutgoingIntegrationMethod = async ( + userId: string, + { integrationId, historyId }: { integrationId: string; historyId: string }, +): Promise => { + let integration: IOutgoingIntegration | null = null; + + if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) { + integration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) { + const found = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + + if (found && 'event' in found) { + integration = found; + } + } + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'replayOutgoingIntegration' }); + } + + const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId); + + if (!history) { + throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { method: 'replayOutgoingIntegration' }); + } + + await triggerHandler.replay(integration, history); +}; diff --git a/apps/meteor/server/lib/integrations/lib/ScriptEngine.ts b/apps/meteor/server/lib/integrations/lib/ScriptEngine.ts new file mode 100644 index 0000000..7778d42 --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/ScriptEngine.ts @@ -0,0 +1,399 @@ +import type { + IUser, + IRoom, + IMessage, + IOutgoingIntegration, + IIncomingIntegration, + IIntegration, + IIntegrationHistory, +} from '@rocket.chat/core-typings'; +import type { Logger } from '@rocket.chat/logger'; +import type { serverFetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { incomingLogger, outgoingLogger } from '../logger'; +import type { IScriptClass, CompiledScript } from './definition'; +import { updateHistory } from './updateHistory'; + +type OutgoingRequestBaseData = { + token: IOutgoingIntegration['token']; + bot: boolean; + trigger_word: string; +}; + +type OutgoingRequestSendMessageData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + siteUrl: string; + alias?: string; + bot?: boolean; + isEdited?: true; + tmid?: string; +}; + +type OutgoingRequestUploadedFileData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + message_id: string; + timestamp: Date; + user_id: string; + user_name: string; + text: string; + + user: IUser; + room: IRoom; + message: IMessage; + + alias?: string; + bot?: boolean; +}; + +type OutgoingRequestRoomCreatedData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; +}; + +type OutgoingRequestRoomData = OutgoingRequestBaseData & { + channel_id: string; + channel_name: string; + timestamp: Date; + user_id: string; + user_name: string; + owner: IUser; + room: IRoom; + bot?: boolean; +}; + +type OutgoingRequestUserCreatedData = OutgoingRequestBaseData & { + timestamp: Date; + user_id: string; + user_name: string; + user: IUser; + bot?: boolean; +}; + +export type OutgoingRequestData = + | OutgoingRequestSendMessageData + | OutgoingRequestUploadedFileData + | OutgoingRequestRoomCreatedData + | OutgoingRequestRoomData + | OutgoingRequestUserCreatedData; + +type OutgoingRequest = { + params: Record; + method: 'POST'; + url: string; + data: OutgoingRequestData; + auth: undefined; + headers: Record; +}; + +type OutgoingRequestFromScript = { + url?: string; + headers?: Record; + method?: string; + message?: { + text?: string; + channel?: string; + attachments?: { + color?: string; + author_name?: string; + author_link?: string; + author_icon?: string; + title?: string; + title_link?: string; + text?: string; + fields?: { + title?: string; + value?: string; + short?: boolean; + }[]; + image_url?: string; + thumb_url?: string; + }[]; + }; + + auth?: string; + data?: Record; +}; + +type OutgoingRequestContext = { + integration: IOutgoingIntegration; + data: OutgoingRequestData; + historyId: IIntegrationHistory['_id']; + url: string; +}; + +type ProcessedOutgoingRequest = OutgoingRequest | OutgoingRequestFromScript; + +type OutgoingResponseContext = { + integration: IOutgoingIntegration; + request: ProcessedOutgoingRequest; + response: Awaited>; + content: string; + historyId: IIntegrationHistory['_id']; +}; + +type IncomingIntegrationRequest = { + url: { + hash: string | null | undefined; + search: string | null | undefined; + query: Record; + pathname: string | null | undefined; + path: string | null | undefined; + }; + url_raw: string; + url_params: Record; + content: Record; + content_raw: string; + headers: Record; + body: Record; + user: Pick, '_id' | 'name' | 'username'>; +}; + +export abstract class IntegrationScriptEngine { + protected compiledScripts: Record; + + public get disabled(): boolean { + return this.isDisabled(); + } + + public get incoming(): IsIncoming { + return this.isIncoming; + } + + constructor(private isIncoming: IsIncoming) { + this.compiledScripts = {}; + } + + public integrationHasValidScript(integration: IIntegration): boolean { + return Boolean(!this.disabled && integration.scriptEnabled && integration.scriptCompiled && integration.scriptCompiled.trim() !== ''); + } + + // PrepareOutgoingRequest will execute a script to build the request object that will be used for the actual integration request + // It may also return a message object to be sent to the room where the integration was triggered + public async prepareOutgoingRequest({ integration, data, historyId, url }: OutgoingRequestContext): Promise { + const request: OutgoingRequest = { + params: {}, + method: 'POST', + url, + data, + auth: undefined, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', + }, + }; + + if (!(await this.hasScriptAndMethod(integration, 'prepare_outgoing_request'))) { + return request; + } + + return this.executeOutgoingScript(integration, 'prepare_outgoing_request', { request }, historyId); + } + + public async processOutgoingResponse({ + integration, + request, + response, + content, + historyId, + }: OutgoingResponseContext): Promise { + if (!(await this.hasScriptAndMethod(integration, 'process_outgoing_response'))) { + return; + } + + const sandbox = { + request, + response: { + error: null, + status_code: response.status, + content, + content_raw: content, + headers: Object.fromEntries(response.headers), + }, + }; + + const scriptResult = await this.executeOutgoingScript(integration, 'process_outgoing_response', sandbox, historyId); + + if (scriptResult === false) { + return scriptResult; + } + + if (scriptResult?.content) { + return scriptResult.content; + } + } + + public async processIncomingRequest({ + integration, + request, + }: { + integration: IIncomingIntegration; + request: IncomingIntegrationRequest; + }): Promise { + return this.executeIncomingScript(integration, 'process_incoming_request', { request }); + } + + protected get logger(): ReturnType { + if (this.isIncoming) { + return incomingLogger; + } + + return outgoingLogger; + } + + protected async executeOutgoingScript( + integration: IOutgoingIntegration, + method: keyof IScriptClass, + params: Record, + historyId: IIntegrationHistory['_id'], + ): Promise { + if (this.disabled) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).suppress((e: any) => + updateHistory({ + historyId, + step: 'execute-script-getting-script', + error: true, + errorStack: e, + }), + ); + + if (!script) { + return; + } + + if (!script[method]) { + this.logger.error({ + msg: 'Script method not found in the Integration', + method, + integration: integration.name, + }); + await updateHistory({ historyId, step: `execute-script-no-method-${method}` }); + return; + } + + try { + await updateHistory({ historyId, step: `execute-script-before-running-${method}` }); + + const result = await this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }); + + this.logger.debug({ + msg: 'Script method result of the Integration', + method, + integration: integration.name, + result, + }); + + return result; + } catch (err: any) { + await updateHistory({ + historyId, + step: `execute-script-error-running-${method}`, + error: true, + errorStack: err.stack.replace(/^/gm, ' '), + }); + this.logger.error({ + msg: 'Error running Script in the Integration', + integration: integration.name, + err, + }); + this.logger.debug({ + msg: 'Error running Script in the Integration', + integration: integration.name, + script: integration.scriptCompiled, + }); + } + } + + protected async executeIncomingScript( + integration: IIncomingIntegration, + method: keyof IScriptClass, + params: Record, + ): Promise { + if (!this.integrationHasValidScript(integration)) { + return; + } + + const script = await wrapExceptions(() => this.getIntegrationScript(integration)).catch((e) => { + this.logger.error({ + msg: 'Error getting Integration script', + integration: integration.name, + err: e, + }); + throw e; + }); + + if (!script[method]) { + this.logger.error({ + msg: 'Script method not found in the Integration', + method, + integration: integration.name, + }); + return; + } + + return wrapExceptions(() => + this.runScriptMethod({ + integrationId: integration._id, + script, + method, + params, + }), + ).catch((err: any) => { + this.logger.error({ + msg: 'Error running Script in Trigger', + integration: integration.name, + script: integration.scriptCompiled, + err, + }); + throw new Error('error-running-script'); + }); + } + + protected async hasScriptAndMethod(integration: IIntegration, method: keyof IScriptClass): Promise { + const script = await this.getScriptSafely(integration); + return typeof script?.[method] === 'function'; + } + + protected async getScriptSafely(integration: IIntegration): Promise | undefined> { + if (this.disabled || integration.scriptEnabled !== true || !integration.scriptCompiled || integration.scriptCompiled.trim() === '') { + return; + } + + return wrapExceptions(() => this.getIntegrationScript(integration)).suppress(); + } + + protected abstract isDisabled(): boolean; + + protected abstract runScriptMethod({ + integrationId, + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: IScriptClass; + method: keyof IScriptClass; + params: Record; + }): Promise; + + protected abstract getIntegrationScript(integration: IIntegration): Promise>; +} diff --git a/apps/meteor/server/lib/integrations/lib/compileIntegrationScript.ts b/apps/meteor/server/lib/integrations/lib/compileIntegrationScript.ts new file mode 100644 index 0000000..5e366cd --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/compileIntegrationScript.ts @@ -0,0 +1,71 @@ +import vm from 'node:vm'; + +import { transformSync } from '@babel/core'; +import presetEnv from '@babel/preset-env'; + +/** + * Compile or validate a user-supplied integration script for storage in + * `scriptCompiled`. + * + * When `transpile` is `true` (the default, controlled by each integration's + * `skipTranspile` flag), the script is transpiled with `@babel/core + + * @babel/preset-env` — the historical behavior. When `false`, the script is + * validated with Node's built-in `vm.Script` and stored as-is, matching the + * 9.0.0 default where Babel transpilation is removed entirely. + * + * Integration scripts run inside `isolated-vm`, which embeds modern V8 and + * handles ES2023+ natively. The transpilation only exists to preserve the + * sloppy-mode semantics (implicit globals in class methods, `this` in nested + * functions, etc.) that early scripts relied on. Admins can flip + * `skipTranspile: true` per integration to test strict-mode compatibility + * before the 9.0.0 upgrade. + * + * Returns `{ script }` on success or `{ error }` with the same + * `{ name, message, stack }` shape persisted in `scriptError`. + */ +export function compileIntegrationScript( + script: string, + { transpile }: { transpile: boolean }, +): { script: string; error?: undefined } | { script?: undefined; error: Pick } { + if (!transpile) { + return validateOnly(script); + } + + return transpileWithBabel(script); +} + +function validateOnly( + script: string, +): { script: string; error?: undefined } | { script?: undefined; error: Pick } { + try { + new vm.Script(`(function(){${script}})`); + return { script }; + } catch (e) { + if (e instanceof SyntaxError) { + const { name, message, stack } = e; + return { error: { name, message, stack } }; + } + throw e; + } +} + +function transpileWithBabel( + script: string, +): { script: string; error?: undefined } | { script?: undefined; error: Pick } { + try { + const result = transformSync(script, { + presets: [presetEnv], + compact: true, + minified: true, + comments: false, + }); + + return { script: result?.code ?? script }; + } catch (e) { + if (e instanceof Error) { + const { name, message, stack } = e; + return { error: { name, message, stack } }; + } + throw e; + } +} diff --git a/apps/meteor/server/lib/integrations/lib/definition.ts b/apps/meteor/server/lib/integrations/lib/definition.ts new file mode 100644 index 0000000..5b6d4bd --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/definition.ts @@ -0,0 +1,24 @@ +import type { IIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings'; + +export interface IScriptClass { + prepare_outgoing_request?: (params: Record) => any; + process_outgoing_response?: (params: Record) => any; + process_incoming_request?: (params: Record) => any; +} + +export type FullScriptClass = Required; + +export type CompiledScript = { + script: Partial; + store: Record; + _updatedAt: IIntegration['_updatedAt']; +}; + +export type CompatibilityScriptResult = IScriptClass & { + availableFunctions: (keyof IScriptClass)[]; +}; + +export const isOutgoingIntegration = ( + integration: Partial, +): integration is IOutgoingIntegration & { type: 'webhook-outgoing' } => + integration.type === 'webhook-outgoing' && 'event' in integration && integration.event !== undefined; diff --git a/apps/meteor/server/lib/integrations/lib/isolated-vm/buildSandbox.ts b/apps/meteor/server/lib/integrations/lib/isolated-vm/buildSandbox.ts new file mode 100644 index 0000000..a2387ad --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/isolated-vm/buildSandbox.ts @@ -0,0 +1,128 @@ +import { EventEmitter } from 'node:events'; + +import { serverFetch as fetch, Response } from '@rocket.chat/server-fetch'; +import ivm, { type Context } from 'isolated-vm'; + +import * as s from '../../../../../lib/utils/stringUtils'; + +const proxyObject = (obj: Record, forbiddenKeys: string[] = []): Record => { + return copyObject({ + isProxy: true, + get: (key: string) => { + if (forbiddenKeys.includes(key)) { + return undefined; + } + + const value = obj[key]; + + if (typeof value === 'function') { + return new ivm.Reference(async (...args: any[]) => { + const result = (obj[key] as any)(...args); + + if (result && result instanceof Promise) { + return new Promise(async (resolve, reject) => { + try { + const awaitedResult = await result; + resolve(makeTransferable(awaitedResult)); + } catch (e) { + reject(e); + } + }); + } + + return makeTransferable(result); + }); + } + + return makeTransferable(value); + }, + }); +}; + +const copyObject = (obj: Record | any[]): Record | any[] => { + if (Array.isArray(obj)) { + return obj.map((data) => copyData(data)); + } + + if (obj instanceof Response) { + return proxyObject(obj, ['clone']); + } + + if (isSemiTransferable(obj)) { + return obj; + } + + if (typeof obj[Symbol.iterator as any] === 'function') { + return copyObject(Array.from(obj as any)); + } + + if (obj instanceof EventEmitter) { + return {}; + } + + const keys = Object.keys(obj); + + return { + ...Object.fromEntries( + keys.map((key) => { + const data = obj[key]; + + if (typeof data === 'function') { + return [key, new ivm.Callback((...args: any[]) => obj[key](...args))]; + } + + return [key, copyData(data)]; + }), + ), + }; +}; + +// Transferable data can be passed to isolates directly +const isTransferable = (data: any): data is ivm.Transferable => { + const dataType = typeof data; + + if (data === ivm) { + return true; + } + + if (['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes(dataType)) { + return true; + } + + if (dataType !== 'object') { + return false; + } + + return ( + data instanceof ivm.Isolate || + data instanceof ivm.Context || + data instanceof ivm.Script || + data instanceof ivm.ExternalCopy || + data instanceof ivm.Callback || + data instanceof ivm.Reference + ); +}; + +// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation. +const isSemiTransferable = (data: any) => data instanceof ArrayBuffer; + +const copyData = | any[]>(data: T) => (isTransferable(data) ? data : copyObject(data)); +const makeTransferable = (data: any) => (isTransferable(data) ? data : new ivm.ExternalCopy(copyObject(data)).copyInto()); + +export const buildSandbox = (context: Context) => { + const { global: jail } = context; + jail.setSync('global', jail.derefInto()); + jail.setSync('ivm', ivm); + + jail.setSync('s', makeTransferable(s)); + jail.setSync('console', makeTransferable(console)); + + jail.setSync( + 'serverFetch', + new ivm.Reference(async (url: string, options?: any, allowSelfSignedCerts?: boolean) => { + // SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here. + const result = await fetch(url, { ...(options ?? {}), ignoreSsrfValidation: true }, allowSelfSignedCerts); + return makeTransferable(result); + }), + ); +}; diff --git a/apps/meteor/server/lib/integrations/lib/isolated-vm/getCompatibilityScript.ts b/apps/meteor/server/lib/integrations/lib/isolated-vm/getCompatibilityScript.ts new file mode 100644 index 0000000..77ce247 --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/isolated-vm/getCompatibilityScript.ts @@ -0,0 +1,60 @@ +export const getCompatibilityScript = (customScript?: string) => ` + const Store = (function() { + const store = {}; + return { + set(key, val) { + store[key] = val; + return val; + }, + get(key) { + return store[key]; + }, + }; + })(); + + const reproxy = (reference) => { + return new Proxy(reference, { + get(target, p, receiver) { + if (target !== reference || p === 'then') { + return Reflect.get(target, p, receiver); + } + + const data = reference.get(p); + + if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') { + return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + } + + return data; + } + }); + }; + + //url, options, allowSelfSignedCertificate + const fetch = async (...args) => { + const result = await serverFetch.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + + if (result && typeof result === 'object' && result.isProxy) { + return reproxy(result); + } + + return result; + }; + + ${customScript} + + (function() { + const instance = new Script(); + + const functions = { + ...(typeof instance['prepare_outgoing_request'] === 'function' ? { prepare_outgoing_request : (...args) => instance.prepare_outgoing_request(...args) } : {}), + ...(typeof instance['process_outgoing_response'] === 'function' ? { process_outgoing_response : (...args) => instance.process_outgoing_response(...args) } : {}), + ...(typeof instance['process_incoming_request'] === 'function' ? { process_incoming_request : (...args) => instance.process_incoming_request(...args) } : {}), + }; + + return { + ...functions, + availableFunctions: Object.keys(functions), + } + })(); +`; diff --git a/apps/meteor/server/lib/integrations/lib/isolated-vm/isolated-vm.ts b/apps/meteor/server/lib/integrations/lib/isolated-vm/isolated-vm.ts new file mode 100644 index 0000000..4204469 --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/isolated-vm/isolated-vm.ts @@ -0,0 +1,99 @@ +import type { IIntegration, ValueOf } from '@rocket.chat/core-typings'; +import { pick } from '@rocket.chat/tools'; +import ivm, { type Reference } from 'isolated-vm'; + +import { IntegrationScriptEngine } from '../ScriptEngine'; +import type { IScriptClass, CompatibilityScriptResult, FullScriptClass } from '../definition'; +import { buildSandbox } from './buildSandbox'; +import { getCompatibilityScript } from './getCompatibilityScript'; + +const DISABLE_INTEGRATION_SCRIPTS = ['yes', 'true', 'ivm'].includes(String(process.env.DISABLE_INTEGRATION_SCRIPTS).toLowerCase()); + +export class IsolatedVMScriptEngine extends IntegrationScriptEngine { + protected isDisabled(): boolean { + return DISABLE_INTEGRATION_SCRIPTS; + } + + protected async callScriptFunction( + scriptReference: Reference>, + ...params: Parameters> + ): Promise { + return scriptReference.applySync(undefined, params, { + arguments: { copy: true }, + result: { copy: true, promise: true }, + }); + } + + protected async runScriptMethod({ + script, + method, + params, + }: { + integrationId: IIntegration['_id']; + script: Partial; + method: keyof IScriptClass; + params: Record; + }): Promise { + const fn = script[method]; + + if (typeof fn !== 'function') { + throw new Error('integration-method-not-found'); + } + + return fn(params); + } + + protected async getIntegrationScript(integration: IIntegration): Promise> { + if (this.disabled) { + throw new Error('integration-scripts-disabled'); + } + + const compiledScript = this.compiledScripts[integration._id]; + if (compiledScript && +compiledScript._updatedAt === +integration._updatedAt) { + return compiledScript.script; + } + + const script = integration.scriptCompiled; + try { + this.logger.info({ msg: 'Will evaluate the integration script', integration: pick(integration, 'name', '_id') }); + this.logger.debug({ script }); + + const isolate = new ivm.Isolate({ memoryLimit: 8 }); + + const ivmScript = await isolate.compileScript(getCompatibilityScript(script)); + + const ivmContext = isolate.createContextSync(); + buildSandbox(ivmContext); + + const ivmResult: Reference = await ivmScript.run(ivmContext, { + reference: true, + timeout: 3000, + }); + + const availableFunctions = await ivmResult.get('availableFunctions', { copy: true }); + const scriptFunctions = Object.fromEntries( + availableFunctions.map((functionName) => { + const fnReference = ivmResult.getSync(functionName, { reference: true }); + return [functionName, (...params: Parameters>) => this.callScriptFunction(fnReference, ...params)]; + }), + ) as Partial; + + this.compiledScripts[integration._id] = { + script: scriptFunctions, + store: {}, + _updatedAt: integration._updatedAt, + }; + + return scriptFunctions; + } catch (err: any) { + this.logger.error({ + msg: 'Error evaluating integration script', + integration: integration.name, + script, + err, + }); + + throw new Error('error-evaluating-script'); + } + } +} diff --git a/apps/meteor/server/lib/integrations/lib/mountQueriesBasedOnPermission.ts b/apps/meteor/server/lib/integrations/lib/mountQueriesBasedOnPermission.ts new file mode 100644 index 0000000..834f441 --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/mountQueriesBasedOnPermission.ts @@ -0,0 +1,55 @@ +import type { DeepWritable } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +export const mountIntegrationQueryBasedOnPermissions = async (userId: string) => { + if (!userId) { + throw new Meteor.Error('You must provide the userId to the "mountIntegrationQueryBasedOnPermissions" function.'); + } + const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations'); + const canViewAllIncomingIntegrations = await hasPermissionAsync(userId, 'manage-incoming-integrations'); + const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations'); + const canViewOnlyOwnIncomingIntegrations = await hasPermissionAsync(userId, 'manage-own-incoming-integrations'); + + const query: DeepWritable> = {}; + + if (canViewAllOutgoingIntegrations && canViewAllIncomingIntegrations) { + return query; + } + if (canViewOnlyOwnOutgoingIntegrations && canViewOnlyOwnIncomingIntegrations) { + return { '_createdBy._id': userId }; + } + query.$or = []; + + if (canViewAllOutgoingIntegrations) { + query.$or.push({ type: 'webhook-outgoing' }); + } + if (canViewAllIncomingIntegrations) { + query.$or.push({ type: 'webhook-incoming' }); + } + if (canViewOnlyOwnOutgoingIntegrations) { + query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-outgoing' }); + } + if (canViewOnlyOwnIncomingIntegrations) { + query.$or.push({ '_createdBy._id': userId, 'type': 'webhook-incoming' }); + } + return query; +}; + +export const mountIntegrationHistoryQueryBasedOnPermissions = async (userId: string, integrationId: string) => { + if (!userId) { + throw new Meteor.Error('You must provide the userId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.'); + } + if (!integrationId) { + throw new Meteor.Error('You must provide the integrationId to the "mountIntegrationHistoryQueryBasedOnPermissions" fucntion.'); + } + + const canViewOnlyOwnOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-own-outgoing-integrations'); + const canViewAllOutgoingIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations'); + if (!canViewAllOutgoingIntegrations && canViewOnlyOwnOutgoingIntegrations) { + return { 'integration._id': integrationId, 'integration._createdBy._id': userId }; + } + return { 'integration._id': integrationId }; +}; diff --git a/apps/meteor/server/lib/integrations/lib/triggerHandler.ts b/apps/meteor/server/lib/integrations/lib/triggerHandler.ts new file mode 100644 index 0000000..4fbdc45 --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/triggerHandler.ts @@ -0,0 +1,840 @@ +import type { + IIntegrationHistory, + IMessage, + IOutgoingIntegration, + IUser, + OutgoingIntegrationEvent, + IRoom, + RequiredField, + AtLeast, +} from '@rocket.chat/core-typings'; +import { Integrations, Users, Rooms, Messages } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { wrapExceptions } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import type { OutgoingRequestData } from './ScriptEngine'; +import { IsolatedVMScriptEngine } from './isolated-vm/isolated-vm'; +import { updateHistory } from './updateHistory'; +import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents'; +import { settings } from '../../../settings'; +import { processWebhookMessage } from '../../messages/processWebhookMessage'; +import { notifyOnIntegrationChangedById } from '../../notifyListener'; +import { getRoomByNameOrIdWithOptionToJoin } from '../../rooms/getRoomByNameOrIdWithOptionToJoin'; +import { outgoingLogger } from '../logger'; + +type Trigger = Record>; + +type MessageWithEditedAt = IMessage & { editedAt?: Date }; +type ArgumentsObject = { + event?: OutgoingIntegrationEvent; + message?: MessageWithEditedAt; + room?: IRoom; + owner?: IUser; + user?: IUser; +}; +type IntegrationData = { + token?: string; + bot: boolean; + trigger_word?: string; + channel_id?: string; + channel_name?: string; + message_id?: string; + timestamp?: Date; + user_id?: string; + user_name?: string; + text?: string; + siteUrl?: string; + alias?: string; + isEdited?: boolean; + tmid?: string; + user?: Partial; + room?: IRoom; + message?: IMessage; + owner?: Partial; +}; + +class RocketChatIntegrationHandler { + private successResults: number[]; + + private triggers: Trigger; + + private ivmEngine: IsolatedVMScriptEngine; + + constructor() { + this.successResults = [200, 201, 202]; + this.triggers = {}; + this.ivmEngine = new IsolatedVMScriptEngine(false); + } + + addIntegration(record: IOutgoingIntegration): void { + outgoingLogger.debug({ msg: 'Adding integration', integrationName: record.name, event: record.event }); + let channels = []; + if (record.event && !outgoingEvents[record.event].use.channel) { + outgoingLogger.debug('The integration doesnt rely on channels.'); + // We don't use any channels, so it's special ;) + channels = ['__any']; + } else if (_.isEmpty(record.channel)) { + outgoingLogger.debug('The integration had an empty channel property, so it is going on all the public channels.'); + channels = ['all_public_channels']; + } else { + outgoingLogger.debug({ msg: 'The integration is going on these channels', channels: record.channel }); + channels = ([] as string[]).concat(record.channel); + } + + for (const channel of channels) { + if (!this.triggers[channel]) { + this.triggers[channel] = {}; + } + + this.triggers[channel][record._id] = record; + } + } + + getEngine(_integration: any): IsolatedVMScriptEngine { + return this.ivmEngine; + } + + removeIntegration(record: AtLeast): void { + for (const trigger of Object.values(this.triggers)) { + delete trigger[record._id]; + } + } + + isTriggerEnabled(trigger: IOutgoingIntegration): boolean { + for (const trig of Object.values(this.triggers)) { + if (trig[trigger._id]) { + return trig[trigger._id].enabled; + } + } + + return false; + } + + // Trigger is the trigger, nameOrId is a string which is used to try and find a room, room is a room, message is a message, and data contains "user_name" if trigger.impersonateUser is truthful. + async sendMessage({ + trigger, + nameOrId = '', + room, + message, + data, + }: { + trigger: IOutgoingIntegration; + nameOrId?: string; + room?: IRoom; + message: { channel: string; bot?: Record; message: Partial }; + data: IntegrationData; + }): Promise<{ channel: string; message: Partial }[] | undefined> { + let user: IUser | null = null; + // Try to find the user who we are impersonating + if (trigger.impersonateUser) { + user = await Users.findOneByUsernameIgnoringCase(data.user_name); + } + + // If they don't exist (aka the trigger didn't contain a user) then we set the user based upon the + // configured username for the integration since this is required at all times. + if (!user) { + user = await Users.findOneByUsernameIgnoringCase(trigger.username); + } + + if (!user) { + outgoingLogger.error({ msg: 'Integration user not found', username: trigger.username }); + return; + } + + let tmpRoom; + if (nameOrId || trigger.targetRoom || message.channel) { + tmpRoom = + (await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: nameOrId || message.channel || trigger.targetRoom || '', + errorOnEmpty: false, + })) || room; + } else { + tmpRoom = room; + } + + // If no room could be found, we won't be sending any messages but we'll warn in the logs + if (!tmpRoom) { + outgoingLogger.warn({ + msg: 'The Integration doesnt have a room configured nor did it provide a room to send the message to.', + integrationName: trigger.name, + }); + return; + } + + outgoingLogger.debug({ msg: 'Found room for integration', integrationName: trigger.name, roomName: tmpRoom.name, roomType: tmpRoom.t }); + + message.bot = { i: trigger._id }; + + const defaultValues: { + alias: string; + avatar: string; + emoji: string; + channel: string; + } = { + alias: trigger.alias || '', + avatar: trigger.avatar || '', + emoji: trigger.emoji || '', + channel: tmpRoom.t === 'd' ? `@${tmpRoom._id}` : `#${tmpRoom._id}`, + }; + + return processWebhookMessage(message, user as RequiredField, defaultValues); + } + + eventNameArgumentsToObject(...args: unknown[]) { + const argObject: ArgumentsObject = { + event: args[0] as OutgoingIntegrationEvent | undefined, + }; + + switch (argObject.event) { + case 'sendMessage': + if (args.length >= 3) { + argObject.message = args[1] as IMessage; + argObject.room = args[2] as IRoom; + } + break; + case 'fileUploaded': + if (args.length >= 2) { + const arghhh: Record = args[1] as Record; + argObject.user = arghhh.user as IUser; + argObject.room = arghhh.room as IRoom; + argObject.message = arghhh.message as IMessage; + } + break; + case 'roomArchived': + if (args.length >= 3) { + argObject.room = args[1] as IRoom; + argObject.user = args[2] as IUser; + } + break; + case 'roomCreated': + if (args.length >= 3) { + argObject.owner = args[1] as IUser; + argObject.room = args[2] as IRoom; + } + break; + case 'roomJoined': + if (args.length >= 3) { + argObject.user = args[1] as IUser; + argObject.room = args[2] as IRoom; + } + break; + case 'roomLeft': + if (args.length >= 3) { + argObject.user = (args[1] as { user: IUser })?.user; + argObject.room = args[2] as IRoom; + } + break; + case 'userCreated': + if (args.length >= 2) { + argObject.user = args[1] as IUser; + } + break; + default: + outgoingLogger.warn({ msg: 'Unhandled trigger event', event: argObject.event }); + argObject.event = undefined; + break; + } + + outgoingLogger.debug({ + msg: 'Got the event arguments for the event', + event: argObject.event, + messageId: argObject.message?._id, + roomId: argObject.room?._id, + userId: argObject.user?._id || argObject.owner?._id, + }); + + return argObject; + } + + mapEventArgsToData(data: IntegrationData, { event, message, room, owner, user }: ArgumentsObject) { + /* The "services" field contains sensitive information such as + the user's password hash. To prevent this information from being + sent to the webhook, we're checking and removing it by destructuring + the user and owner objects while discarding the "services" field. + */ + + const omitServicesField = (object: IUser) => { + const { services, ...objectWithoutServicesField } = object; + return objectWithoutServicesField; + }; + + const userWithoutServicesField = user?.services ? omitServicesField(user) : user; + const ownerWithoutServicesField = owner?.services ? omitServicesField(owner) : owner; + + if (!room || !message) { + outgoingLogger.warn({ msg: 'Integration called without room or message', event }); + return; + } + + switch (event) { + case 'sendMessage': + data.channel_id = room._id; + data.channel_name = room.name; + data.message_id = message._id; + data.timestamp = message.ts; + data.user_id = message.u._id; + data.user_name = message.u.username; + data.text = message.msg; + data.siteUrl = settings.get('Site_Url'); + + if (message.alias) { + data.alias = message.alias; + } + + if (message.bot) { + data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense + } + + if (message.editedAt) { + data.isEdited = true; + } + + if (message.tmid) { + data.tmid = message.tmid; + } + break; + case 'fileUploaded': + data.channel_id = room._id; + data.channel_name = room.name; + data.message_id = message._id; + data.timestamp = message.ts; + data.user_id = message.u._id; + data.user_name = message.u.username; + data.text = message.msg; + data.user = userWithoutServicesField; + data.room = room; + data.message = message; + + if (message.alias) { + data.alias = message.alias; + } + + if (message.bot) { + data.bot = Boolean(message.bot); // TODO: need to double check this, since it makes no sense + } + break; + case 'roomCreated': + if (!owner) { + outgoingLogger.warn({ msg: 'Integration called without owner data', event }); + return; + } + data.channel_id = room._id; + data.channel_name = room.name; + data.timestamp = room.ts; + data.user_id = owner._id; + data.user_name = owner.username; + data.owner = ownerWithoutServicesField; + data.room = room; + break; + case 'roomArchived': + case 'roomJoined': + case 'roomLeft': + if (!user) { + outgoingLogger.warn({ msg: 'Integration called without owner data', event }); + return; + } + data.timestamp = new Date(); + data.channel_id = room._id; + data.channel_name = room.name; + data.user_id = user._id; + data.user_name = user.username; + data.user = userWithoutServicesField; + data.room = room; + + if (user.type === 'bot') { + data.bot = true; + } + break; + case 'userCreated': + if (!user) { + outgoingLogger.warn({ msg: 'Integration called without owner data', event }); + return; + } + data.timestamp = user.createdAt; + data.user_id = user._id; + data.user_name = user.username; + data.user = userWithoutServicesField; + + if (user.type === 'bot') { + data.bot = true; + } + break; + default: + break; + } + } + + getTriggersToExecute(room?: IRoom, message?: MessageWithEditedAt) { + const triggersToExecute = new Set(); + if (room) { + switch (room.t) { + case 'd': + if (this.triggers.all_direct_messages) { + for (const trigger of Object.values(this.triggers.all_direct_messages)) { + triggersToExecute.add(trigger); + } + } + + room.uids + ?.filter((uid) => this.triggers[`@${uid}`]) + .forEach((uid) => { + for (const trigger of Object.values(this.triggers[`@${uid}`])) { + triggersToExecute.add(trigger); + } + }); + + room.usernames + ?.filter((username) => username !== message?.u?.username && this.triggers[`@${username}`]) + .forEach((username) => { + for (const trigger of Object.values(this.triggers[`@${username}`])) { + triggersToExecute.add(trigger); + } + }); + break; + case 'c': + if (this.triggers.all_public_channels) { + for (const trigger of Object.values(this.triggers.all_public_channels)) { + triggersToExecute.add(trigger); + } + } + + if (this.triggers[`#${room._id}`]) { + for (const trigger of Object.values(this.triggers[`#${room._id}`])) { + triggersToExecute.add(trigger); + } + } + + if (room._id !== room.name && this.triggers[`#${room.name}`]) { + for (const trigger of Object.values(this.triggers[`#${room.name}`])) { + triggersToExecute.add(trigger); + } + } + break; + + default: + if (this.triggers.all_private_groups) { + for (const trigger of Object.values(this.triggers.all_private_groups)) { + triggersToExecute.add(trigger); + } + } + + if (this.triggers[`#${room._id}`]) { + for (const trigger of Object.values(this.triggers[`#${room._id}`])) { + triggersToExecute.add(trigger); + } + } + + if (room._id !== room.name && this.triggers[`#${room.name}`]) { + for (const trigger of Object.values(this.triggers[`#${room.name}`])) { + triggersToExecute.add(trigger); + } + } + break; + } + } + return [...triggersToExecute]; + } + + async executeTriggers(...args: unknown[]) { + outgoingLogger.debug({ msg: 'Execute Trigger:', arg: args[0] }); + + const argObject = this.eventNameArgumentsToObject(...args); + const { event, message, room } = argObject; + + // Each type of event should have an event and a room attached, otherwise we + // wouldn't know how to handle the trigger nor would we have anywhere to send the + // result of the integration + if (!event) { + return; + } + + outgoingLogger.debug({ msg: 'Starting search for triggers for room', roomId: room ? room._id : '__any' }); + + const triggersToExecute = this.getTriggersToExecute(room, message); + + if (this.triggers.__any) { + // For outgoing integration which don't rely on rooms. + for (const trigger of Object.values(this.triggers.__any)) { + triggersToExecute.push(trigger); + } + } + + outgoingLogger.debug({ msg: 'Found triggers to iterate over', triggerCount: triggersToExecute.length, event }); + + for (const triggerToExecute of triggersToExecute) { + outgoingLogger.debug({ + msg: 'Checking trigger execution eligibility', + triggerName: triggerToExecute.name, + isEnabled: triggerToExecute.enabled, + triggerEvent: triggerToExecute.event, + }); + if (triggerToExecute.enabled === true && triggerToExecute.event === event) { + await this.executeTrigger(triggerToExecute, argObject); + } + } + } + + async executeTrigger(trigger: IOutgoingIntegration, argObject: ArgumentsObject) { + if (!trigger.urls) { + return; + } + for (const url of trigger.urls) { + await this.executeTriggerUrl(url, trigger, argObject, 0); + } + } + + // Ensure that any errors thrown by the script engine will contibue to be compatible with Meteor.Error + async wrapScriptEngineCall(getter: () => Promise) { + return wrapExceptions(getter).catch((error) => { + if (error instanceof Error) { + throw new Meteor.Error(error.message); + } + + throw error; + }); + } + + async executeTriggerUrl(url: string, trigger: IOutgoingIntegration, { event, message, room, owner, user }: ArgumentsObject, tries = 0) { + if (!this.isTriggerEnabled(trigger)) { + outgoingLogger.warn({ msg: 'Trigger is not enabled', triggerName: trigger.name, tries }); + return; + } + + outgoingLogger.debug({ msg: 'Starting to execute trigger', triggerName: trigger.name, triggerId: trigger._id }); + + let word; + // Not all triggers/events support triggerWords + if (event && outgoingEvents[event].use.triggerWords) { + if (trigger.triggerWords && trigger.triggerWords.length > 0) { + for (const triggerWord of trigger.triggerWords) { + if (!trigger.triggerWordAnywhere && message?.msg.indexOf(triggerWord) === 0) { + word = triggerWord; + break; + } else if (trigger.triggerWordAnywhere && message?.msg.includes(triggerWord)) { + word = triggerWord; + break; + } + } + + // Stop if there are triggerWords but none match + if (!word) { + outgoingLogger.debug({ msg: 'Trigger word not found', triggerName: trigger.name }); + return; + } + } + } + + if (message && message.editedAt && !trigger.runOnEdits) { + outgoingLogger.debug({ msg: 'Trigger run on edits disabled and message was edited', triggerName: trigger.name }); + return; + } + + const historyId = await updateHistory({ + step: 'start-execute-trigger-url', + integration: trigger, + event, + historyId: '', + }); + + const data: IntegrationData = { + token: trigger.token, + bot: false, + }; + + if (word) { + data.trigger_word = word; + } + + this.mapEventArgsToData(data, { event, message, room, owner, user }); + await updateHistory({ historyId, step: 'mapped-args-to-data', data, triggerWord: word }); + + outgoingLogger.info({ msg: 'Will be executing integration', integrationName: trigger.name, url }); + outgoingLogger.debug({ data }); + + const scriptEngine = this.getEngine(trigger); + + const opts = await this.wrapScriptEngineCall(() => + scriptEngine.prepareOutgoingRequest({ + integration: trigger, + data: data as OutgoingRequestData, + url, + historyId, + }), + ); + + await updateHistory({ historyId, step: 'after-maybe-ran-prepare', ranPrepareScript: true }); + + if (!opts) { + await updateHistory({ historyId, step: 'after-prepare-no-opts', finished: true }); + return; + } + + if (opts.message) { + const prepareMessage = await this.sendMessage({ trigger, room, message: opts.message, data }); + if (!prepareMessage) { + await updateHistory({ historyId, step: 'after-prepare-send-message-failed', finished: true }); + return; + } + await updateHistory({ + historyId, + step: 'after-prepare-send-message', + prepareSentMessage: prepareMessage, + }); + } + + if (!opts.url || !opts.method) { + await updateHistory({ historyId, step: 'after-prepare-no-url_or_method', finished: true }); + return; + } + + // based on HTTP.call implementation + if (opts.auth) { + if (opts.auth.indexOf(':') < 0) { + throw new Error('auth option should be of the form "username:password"'); + } + + const base64 = Buffer.from(opts.auth, 'ascii').toString('base64'); + opts.headers.Authorization = `Basic ${base64}`; + } + + await updateHistory({ + historyId, + step: 'pre-http-call', + url: opts.url, + httpCallData: opts.data, + }); + + if (opts.data) { + opts.headers['Content-Type'] = 'application/json'; + } + + fetch( + opts.url, + { + method: opts.method, + headers: opts.headers, + ...(opts.timeout && { timeout: opts.timeout }), + ...(opts.data && { body: opts.data }), + // SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, + }, + settings.get('Allow_Invalid_SelfSigned_Certs'), + ) + .then(async (res) => { + const content = await res.text(); + if (!content) { + outgoingLogger.warn({ msg: 'Integration result is empty', integrationName: trigger.name, url }); + } else { + outgoingLogger.info({ msg: 'Integration HTTP status', integrationName: trigger.name, url, status: res.status }); + } + + const data = (() => { + const contentType = (res.headers.get('content-type') || '').split(';')[0]; + if (!['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].includes(contentType)) { + return null; + } + + try { + return JSON.parse(content); + } catch (_error) { + return null; + } + })(); + + await updateHistory({ + historyId, + step: 'after-http-call', + httpError: null, + httpResult: content, + }); + + const responseContent = await this.wrapScriptEngineCall(() => + scriptEngine.processOutgoingResponse({ + integration: trigger, + request: opts, + response: res, + content, + historyId, + }), + ); + + if (responseContent) { + const resultMessage = await this.sendMessage({ + trigger, + room, + message: responseContent, + data, + }); + + if (!resultMessage) { + await updateHistory({ historyId, step: 'after-process-send-message-failed', finished: true }); + return; + } + + await updateHistory({ + historyId, + step: 'after-process-send-message', + processSentMessage: resultMessage, + finished: true, + }); + return; + } + + if (responseContent === false) { + await updateHistory({ historyId, step: 'after-process-false-result', finished: true }); + return; + } + + // if the result contained nothing or wasn't a successful statusCode + if (!content || !this.successResults.includes(res.status)) { + if (content) { + outgoingLogger.error({ + msg: 'Error for integration request', + integrationName: trigger.name, + url, + result: content, + }); + + if (res.status === 410) { + await updateHistory({ historyId, step: 'after-process-http-status-410', error: true }); + outgoingLogger.error({ msg: 'Disabling integration due to 410 (Gone) status', integrationName: trigger.name }); + await Integrations.updateOne({ _id: trigger._id }, { $set: { enabled: false } }); + void notifyOnIntegrationChangedById(trigger._id); + return; + } + + if (res.status === 500) { + await updateHistory({ historyId, step: 'after-process-http-status-500', error: true }); + outgoingLogger.error({ + msg: 'Error 500 for integration request', + integrationName: trigger.name, + url, + content, + }); + return; + } + } + + if (trigger.retryFailedCalls && trigger.retryCount) { + if (tries < trigger.retryCount && trigger.retryDelay) { + await updateHistory({ historyId, error: true, step: `going-to-retry-${tries + 1}` }); + + let waitTime; + + switch (trigger.retryDelay) { + case 'powers-of-ten': + // Try again in 0.1s, 1s, 10s, 1m40s, 16m40s, 2h46m40s, 27h46m40s, etc + waitTime = Math.pow(10, tries + 2); + break; + case 'powers-of-two': + // 2 seconds, 4 seconds, 8 seconds + waitTime = Math.pow(2, tries + 1) * 1000; + break; + case 'increments-of-two': + // 2 second, 4 seconds, 6 seconds, etc + waitTime = (tries + 1) * 2 * 1000; + break; + default: + const er = new Error("The integration's retryDelay setting is invalid."); + await updateHistory({ + historyId, + step: 'failed-and-retry-delay-is-invalid', + error: true, + errorStack: er.stack, + }); + return; + } + + outgoingLogger.info({ msg: 'Retrying integration', integrationName: trigger.name, url, waitTime }); + setTimeout(() => { + void this.executeTriggerUrl(url, trigger, { event, message, room, owner, user }, tries + 1); + }, waitTime); + } else { + await updateHistory({ historyId, step: 'too-many-retries', error: true }); + } + } else { + await updateHistory({ + historyId, + step: 'failed-and-not-configured-to-retry', + error: true, + }); + } + + return; + } + + // process outgoing webhook response as a new message + if (content && this.successResults.includes(res.status)) { + if (data?.text || data?.attachments) { + const resultMsg = await this.sendMessage({ trigger, room, message: data, data }); + if (!resultMsg) { + await updateHistory({ historyId, step: 'after-http-call-send-message-failed', finished: true }); + return; + } + await updateHistory({ + historyId, + step: 'url-response-sent-message', + resultMessage: resultMsg, + finished: true, + }); + } + } + }) + .catch(async (err) => { + outgoingLogger.error({ err }); + await updateHistory({ + historyId, + step: 'after-http-call', + httpError: err, + httpResult: null, + }); + }); + } + + async replay(integration: IOutgoingIntegration, history: IIntegrationHistory) { + if (integration?.type !== 'webhook-outgoing') { + throw new Meteor.Error('integration-type-must-be-outgoing', 'The integration type to replay must be an outgoing webhook.'); + } + + if (!history?.data) { + throw new Meteor.Error('history-data-must-be-defined', 'The history data must be defined to replay an integration.'); + } + + const { event } = history; + let owner; + let message; + let room; + let user; + + if (history.data.owner?._id) { + owner = await Users.findOneById(history.data.owner._id); + } + if (history.data.message_id) { + message = await Messages.findOneById(history.data.message_id); + } + if (history.data.channel_id) { + room = await Rooms.findOneById(history.data.channel_id); + } + if (history.data.user_id) { + user = await Users.findOneById(history.data.user_id); + } + + if (!history.url) { + return; + } + + return this.executeTriggerUrl(history.url, integration, { + event, + message: message || undefined, + room: room || undefined, + owner: owner || undefined, + user: user || undefined, + }); + } +} +const triggerHandler = new RocketChatIntegrationHandler(); +export { triggerHandler }; diff --git a/apps/meteor/server/lib/integrations/lib/updateHistory.ts b/apps/meteor/server/lib/integrations/lib/updateHistory.ts new file mode 100644 index 0000000..cf52beb --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/updateHistory.ts @@ -0,0 +1,105 @@ +import type { IIntegrationHistory, OutgoingIntegrationEvent, IIntegration, IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { IntegrationHistory } from '@rocket.chat/models'; + +import { omit } from '../../../../lib/utils/omit'; +import { notifyOnIntegrationHistoryChangedById, notifyOnIntegrationHistoryChanged } from '../../notifyListener'; + +export const updateHistory = async ({ + historyId, + step, + integration, + event, + data, + triggerWord, + ranPrepareScript, + prepareSentMessage, + processSentMessage, + resultMessage, + finished, + url, + httpCallData, + httpError, + httpResult, + error, + errorStack, +}: { + historyId: IIntegrationHistory['_id']; + step: IIntegrationHistory['step']; + integration?: IIntegration; + event?: OutgoingIntegrationEvent; + triggerWord?: string; + ranPrepareScript?: boolean; + prepareSentMessage?: { channel: string; message: Partial }[]; + processSentMessage?: { channel: string; message: Partial }[]; + resultMessage?: { channel: string; message: Partial }[]; + finished?: boolean; + url?: string; + httpCallData?: Record; // ProcessedOutgoingRequest.data + httpError?: any; // null or whatever error type `fetch` may throw + httpResult?: string | null; + + error?: boolean; + errorStack?: any; // Error | Error['stack'] + + data?: Record; +}) => { + const { user: userData, room: roomData, ...fullData } = data || {}; + + const history: AtLeast = { + type: 'outgoing-webhook', + step, + + // Usually is only added on initial insert + ...(integration ? { integration } : {}), + // Usually is only added on initial insert + ...(event ? { event } : {}), + ...(fullData + ? { + data: { + ...fullData, + ...(userData ? { user: omit(userData, 'services') } : {}), + ...(roomData ? { room: roomData } : {}), + }, + } + : {}), + ...(triggerWord ? { triggerWord } : {}), + ...(typeof ranPrepareScript !== 'undefined' ? { ranPrepareScript } : {}), + ...(prepareSentMessage ? { prepareSentMessage } : {}), + ...(processSentMessage ? { processSentMessage } : {}), + ...(resultMessage ? { resultMessage } : {}), + ...(typeof finished !== 'undefined' ? { finished } : {}), + ...(url ? { url } : {}), + ...(typeof httpCallData !== 'undefined' ? { httpCallData } : {}), + ...(httpError ? { httpError } : {}), + ...(typeof httpResult !== 'undefined' ? { httpResult: JSON.stringify(httpResult, null, 2) } : {}), + ...(typeof error !== 'undefined' ? { error } : {}), + ...(typeof errorStack !== 'undefined' ? { errorStack } : {}), + }; + + if (historyId) { + // Projecting just integration field to comply with existing listener behaviour + const integrationHistory = await IntegrationHistory.updateById(historyId, history, { projection: { 'integration._id': 1 } }); + if (!integrationHistory) { + throw new Error('error-updating-integration-history'); + } + void notifyOnIntegrationHistoryChanged(integrationHistory, 'updated', history); + return historyId; + } + + // Can't create a new history without there being an integration + if (!history.integration) { + throw new Error('error-invalid-integration'); + } + + // TODO: Had to force type cast here because of function's signature + // It would be easier if we separate into create and update functions + const { insertedId } = await IntegrationHistory.create(history as IIntegrationHistory); + + if (!insertedId) { + throw new Error('error-creating-integration-history'); + } + + void notifyOnIntegrationHistoryChangedById(insertedId, 'inserted'); + + return insertedId; +}; diff --git a/apps/meteor/server/lib/integrations/lib/validateOutgoingIntegration.ts b/apps/meteor/server/lib/integrations/lib/validateOutgoingIntegration.ts new file mode 100644 index 0000000..d6ed64a --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/validateOutgoingIntegration.ts @@ -0,0 +1,198 @@ +import type { IUser, INewOutgoingIntegration, IOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { compileIntegrationScript } from './compileIntegrationScript'; +import { isScriptEngineFrozen } from './validateScriptEngine'; +import { outgoingEvents } from '../../../../app/integrations/lib/outgoingEvents'; +import { parseCSV } from '../../../../lib/utils/parseCSV'; +import { hasPermissionAsync, hasAllPermissionAsync } from '../../authorization/hasPermission'; + +const scopedChannels = ['all_public_channels', 'all_private_groups', 'all_direct_messages']; +const validChannelChars = ['@', '#']; + +function _verifyRequiredFields(integration: INewOutgoingIntegration | IUpdateOutgoingIntegration): void { + if ( + !integration.event || + !Match.test(integration.event, String) || + integration.event.trim() === '' || + !outgoingEvents[integration.event] + ) { + throw new Meteor.Error('error-invalid-event-type', 'Invalid event type', { + function: 'validateOutgoing._verifyRequiredFields', + }); + } + + if (!integration.username || !Match.test(integration.username, String) || integration.username.trim() === '') { + throw new Meteor.Error('error-invalid-username', 'Invalid username', { + function: 'validateOutgoing._verifyRequiredFields', + }); + } + + if (outgoingEvents[integration.event].use.targetRoom && !integration.targetRoom) { + throw new Meteor.Error('error-invalid-targetRoom', 'Invalid Target Room', { + function: 'validateOutgoing._verifyRequiredFields', + }); + } + + if (!Match.test(integration.urls, [String])) { + throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', { + function: 'validateOutgoing._verifyRequiredFields', + }); + } + + integration.urls = integration.urls.filter((url) => url && url.trim() !== ''); + + if (integration.urls.length === 0) { + throw new Meteor.Error('error-invalid-urls', 'Invalid URLs', { + function: 'validateOutgoing._verifyRequiredFields', + }); + } +} + +async function _verifyUserHasPermissionForChannels(userId: IUser['_id'], channels: string[]): Promise { + for (let channel of channels) { + if (scopedChannels.includes(channel)) { + if (channel === 'all_public_channels') { + // No special permissions needed to add integration to public channels + } else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) { + throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', { + function: 'validateOutgoing._verifyUserHasPermissionForChannels', + }); + } + } else { + let record; + const channelType = channel[0]; + channel = channel.substr(1); + + switch (channelType) { + case '#': + record = await Rooms.findOne({ + $or: [{ _id: channel }, { name: channel }], + }); + break; + case '@': + record = await Users.findOne({ + $or: [{ _id: channel }, { username: channel }], + }); + break; + } + + if (!record) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + function: 'validateOutgoing._verifyUserHasPermissionForChannels', + }); + } + + if ( + !(await hasAllPermissionAsync(userId, ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'])) && + !(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } })) + ) { + throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', { + function: 'validateOutgoing._verifyUserHasPermissionForChannels', + }); + } + } + } +} + +function _verifyRetryInformation(integration: IOutgoingIntegration): void { + if (!integration.retryFailedCalls) { + return; + } + + // Don't allow negative retry counts + integration.retryCount = + integration.retryCount && parseInt(String(integration.retryCount)) > 0 ? parseInt(String(integration.retryCount)) : 4; + integration.retryDelay = !integration.retryDelay?.trim() ? 'powers-of-ten' : integration.retryDelay.toLowerCase(); +} + +export const validateOutgoingIntegration = async function ( + integration: INewOutgoingIntegration | IUpdateOutgoingIntegration, + userId: IUser['_id'], +): Promise { + if (integration.channel && Match.test(integration.channel, String) && integration.channel.trim() === '') { + delete integration.channel; + } + + // Moved to it's own function to satisfy the complexity rule + _verifyRequiredFields(integration); + + let channels: string[] = []; + if (outgoingEvents[integration.event].use.channel) { + if (!Match.test(integration.channel, String)) { + throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', { + function: 'validateOutgoing', + }); + } else { + channels = parseCSV(integration.channel); + + for (const channel of channels) { + if (!validChannelChars.includes(channel[0]) && !scopedChannels.includes(channel.toLowerCase())) { + throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', { + function: 'validateOutgoing', + }); + } + } + } + } else if (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations'))) { + throw new Meteor.Error('error-invalid-permissions', 'Invalid permission for required Integration creation.', { + function: 'validateOutgoing', + }); + } + + const user = await Users.findOne({ username: integration.username }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user (did you delete the `rocket.cat` user?)', { function: 'validateOutgoing' }); + } + + const integrationData: IOutgoingIntegration = { + ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', + type: 'webhook-outgoing', + channel: channels, + userId: user._id, + _createdAt: new Date(), + _createdBy: await Users.findOne(userId, { projection: { username: 1 } }), + }; + + if (outgoingEvents[integration.event].use.triggerWords && integration.triggerWords) { + if (!Match.test(integration.triggerWords, [String])) { + throw new Meteor.Error('error-invalid-triggerWords', 'Invalid triggerWords', { + function: 'validateOutgoing', + }); + } + + integrationData.triggerWords = integration.triggerWords.filter((word) => word && word.trim() !== ''); + } else { + delete integrationData.triggerWords; + } + + // Default to transpiling with Babel for backwards compatibility; integrations + // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0). + const skipTranspile = integration.skipTranspile === true; + integrationData.skipTranspile = skipTranspile; + + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + integrationData.scriptCompiled = script; + integrationData.scriptError = error; + } + + if (typeof integration.runOnEdits !== 'undefined') { + // Verify this value is only true/false + integrationData.runOnEdits = integration.runOnEdits === true; + } + + await _verifyUserHasPermissionForChannels(userId, channels); + _verifyRetryInformation(integrationData); + + return integrationData; +}; diff --git a/apps/meteor/server/lib/integrations/lib/validateScriptEngine.ts b/apps/meteor/server/lib/integrations/lib/validateScriptEngine.ts new file mode 100644 index 0000000..5b74eac --- /dev/null +++ b/apps/meteor/server/lib/integrations/lib/validateScriptEngine.ts @@ -0,0 +1,26 @@ +import type { IntegrationScriptEngine } from '@rocket.chat/core-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; + +const FREEZE_INTEGRATION_SCRIPTS_VALUE = String(process.env.FREEZE_INTEGRATION_SCRIPTS).toLowerCase(); +const FREEZE_INTEGRATION_SCRIPTS = ['yes', 'true'].includes(FREEZE_INTEGRATION_SCRIPTS_VALUE); + +export const validateScriptEngine = (engine?: IntegrationScriptEngine) => { + if (FREEZE_INTEGRATION_SCRIPTS) { + throw new Error('integration-scripts-disabled'); + } + + if (engine && engine !== 'isolated-vm') { + throw new Error('integration-scripts-unknown-engine'); + } + + const engineCode = 'ivm'; + + if (engineCode === FREEZE_INTEGRATION_SCRIPTS_VALUE) { + throw new Error('integration-scripts-isolated-vm-disabled'); + } + + return true; +}; + +export const isScriptEngineFrozen = (engine?: IntegrationScriptEngine) => + wrapExceptions(() => !validateScriptEngine(engine)).catch(() => true); diff --git a/apps/meteor/server/lib/integrations/logger.ts b/apps/meteor/server/lib/integrations/logger.ts new file mode 100644 index 0000000..73c77cc --- /dev/null +++ b/apps/meteor/server/lib/integrations/logger.ts @@ -0,0 +1,6 @@ +import { Logger } from '@rocket.chat/logger'; + +export const integrationLogger = new Logger('Integrations'); + +export const incomingLogger = integrationLogger.section('Incoming WebHook'); +export const outgoingLogger = integrationLogger.section('Outgoing WebHook'); diff --git a/apps/meteor/server/lib/integrations/startup.ts b/apps/meteor/server/lib/integrations/startup.ts new file mode 100644 index 0000000..a581063 --- /dev/null +++ b/apps/meteor/server/lib/integrations/startup.ts @@ -0,0 +1,11 @@ +import type { IOutgoingIntegration } from '@rocket.chat/core-typings'; +import { Integrations } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { triggerHandler } from './lib/triggerHandler'; + +Meteor.startup(async () => { + await Integrations.find({ type: 'webhook-outgoing' }).forEach((data) => + triggerHandler.addIntegration(data as IOutgoingIntegration), + ); +}); diff --git a/apps/meteor/server/lib/integrations/triggers.ts b/apps/meteor/server/lib/integrations/triggers.ts new file mode 100644 index 0000000..871dbd3 --- /dev/null +++ b/apps/meteor/server/lib/integrations/triggers.ts @@ -0,0 +1,23 @@ +import { callbacks } from '../callbacks'; +import { triggerHandler } from './lib/triggerHandler'; +import { afterLeaveRoomCallback } from '../callbacks/afterLeaveRoomCallback'; + +const callbackHandler = function _callbackHandler(eventType: string) { + return function _wrapperFunction(...args: any[]) { + return triggerHandler.executeTriggers(eventType, ...args); + }; +}; + +callbacks.add( + 'afterSaveMessage', + (message, { room }) => callbackHandler('sendMessage')(message, room), + callbacks.priority.LOW, + 'integrations-sendMessage', +); +callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); +callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); +callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW, 'integrations-userCreated'); +callbacks.add('afterJoinRoom', callbackHandler('roomJoined'), callbacks.priority.LOW, 'integrations-roomJoined'); +afterLeaveRoomCallback.add(callbackHandler('roomLeft'), callbacks.priority.LOW, 'integrations-roomLeft'); +callbacks.add('afterRoomArchived', callbackHandler('roomArchived'), callbacks.priority.LOW, 'integrations-roomArchived'); +callbacks.add('afterFileUpload', callbackHandler('fileUploaded'), callbacks.priority.LOW, 'integrations-fileUploaded'); diff --git a/apps/meteor/server/lib/isRoomCompatibleWithVideoConfRinging.ts b/apps/meteor/server/lib/isRoomCompatibleWithVideoConfRinging.ts new file mode 100644 index 0000000..3bd4f81 --- /dev/null +++ b/apps/meteor/server/lib/isRoomCompatibleWithVideoConfRinging.ts @@ -0,0 +1,4 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +export const isRoomCompatibleWithVideoConfRinging = (roomType: IRoom['t'], roomUids: IRoom['uids']): boolean => + Boolean(roomType === 'd' && roomUids && roomUids.length <= 2); diff --git a/apps/meteor/server/lib/isRunningMs.ts b/apps/meteor/server/lib/isRunningMs.ts new file mode 100644 index 0000000..09f487e --- /dev/null +++ b/apps/meteor/server/lib/isRunningMs.ts @@ -0,0 +1,7 @@ +/** + * Checks if the server is running in micro services mode + * @returns {boolean} + */ +export function isRunningMs(): boolean { + return !!process.env.TRANSPORTER?.match(/^(?:nats|TCP)/); +} diff --git a/apps/meteor/server/lib/isUserIdFederated.ts b/apps/meteor/server/lib/isUserIdFederated.ts new file mode 100644 index 0000000..f66d251 --- /dev/null +++ b/apps/meteor/server/lib/isUserIdFederated.ts @@ -0,0 +1,11 @@ +import { isUserFederated } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export const isUserIdFederated = async (userId: string): Promise => { + const user = await Users.findOneById(userId, { projection: { federated: 1 } }); + + if (!user) { + return false; + } + return isUserFederated(user); +}; diff --git a/apps/meteor/server/lib/ldap/Connection.spec.ts b/apps/meteor/server/lib/ldap/Connection.spec.ts new file mode 100644 index 0000000..4a65822 --- /dev/null +++ b/apps/meteor/server/lib/ldap/Connection.spec.ts @@ -0,0 +1,109 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const loggerStub = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() }; + +const { LDAPConnection } = proxyquire.noCallThru().load('./Connection', { + '../../settings': { settings: { get: sinon.stub() } }, + './getLDAPConditionalSetting': { getLDAPConditionalSetting: sinon.stub().returns('') }, + './Logger': { + logger: loggerStub, + connLogger: loggerStub, + bindLogger: loggerStub, + searchLogger: loggerStub, + authLogger: loggerStub, + mapLogger: loggerStub, + }, +}); + +describe('LDAPConnection', () => { + describe('synchronous errors from client.search (e.g. invalid filters)', () => { + const parseError = new Error('invalid attribute name'); + let connection: any; + + beforeEach(() => { + connection = new LDAPConnection(); + connection.options.userSearchField = 'uid'; + connection.client = { search: sinon.stub().throws(parseError) }; + }); + + it('should reject doCustomSearch with the error', async () => { + const error = await connection + .doCustomSearch('dc=test', { filter: '(&(=*))' }, () => undefined) + .then( + () => undefined, + (e: unknown) => e, + ); + expect(error).to.equal(parseError); + }); + + it('should route the error to endCallback on paged searchAllUsers instead of leaking the throw', async () => { + const endCallback = sinon.stub(); + await connection.searchAllUsers({ endCallback }); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(endCallback.calledOnceWithExactly(parseError)).to.be.true; + }); + + it('should route the error to endCallback on non-paged searchAllUsers instead of leaking the throw', async () => { + connection.options.searchPageSize = 0; + const endCallback = sinon.stub(); + await connection.searchAllUsers({ endCallback }); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(endCallback.calledOnceWithExactly(parseError)).to.be.true; + }); + }); + + describe('getUserFilter', () => { + let connection: any; + + beforeEach(() => { + connection = new LDAPConnection(); + }); + + it('should compose the filter for a single search field', () => { + connection.options.userSearchField = 'uid'; + expect(connection.getUserFilter('john')).to.equal('(&(uid=john))'); + }); + + it('should compose an OR filter for multiple search fields', () => { + connection.options.userSearchField = 'uid,sAMAccountName'; + expect(connection.getUserFilter('john')).to.equal('(&(|(uid=john)(sAMAccountName=john)))'); + }); + + it('should trim whitespace and ignore empty segments (e.g. trailing commas)', () => { + connection.options.userSearchField = ' uid , sAMAccountName ,'; + expect(connection.getUserFilter('john')).to.equal('(&(|(uid=john)(sAMAccountName=john)))'); + }); + + it('should include the user search filter when configured', () => { + connection.options.userSearchField = 'uid'; + connection.options.userSearchFilter = '(objectclass=user)'; + expect(connection.getUserFilter('*')).to.equal('(&(objectclass=user)(uid=*))'); + }); + + it('should throw a configuration error when the search field is empty', () => { + connection.options.userSearchField = ''; + expect(() => connection.getUserFilter('*')).to.throw('LDAP User Search Field is not configured'); + }); + + it('should throw a configuration error when the search field only has empty segments', () => { + connection.options.userSearchField = ' , ,'; + expect(() => connection.getUserFilter('*')).to.throw('LDAP User Search Field is not configured'); + }); + + it('should reject searchAllUsers with the configuration error instead of composing an invalid filter', async () => { + connection.options.userSearchField = ''; + connection.client = { search: sinon.stub() }; + const endCallback = sinon.stub(); + const error = await connection.searchAllUsers({ endCallback }).then( + () => undefined, + (e: unknown) => e, + ); + expect(error).to.be.an('error').with.property('message', 'LDAP User Search Field is not configured'); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(connection.client.search.called).to.be.false; + }); + }); +}); diff --git a/apps/meteor/server/lib/ldap/Connection.ts b/apps/meteor/server/lib/ldap/Connection.ts new file mode 100644 index 0000000..316ef29 --- /dev/null +++ b/apps/meteor/server/lib/ldap/Connection.ts @@ -0,0 +1,861 @@ +import type { + ILDAPConnectionOptions, + LDAPEncryptionType, + LDAPSearchScope, + ILDAPEntry, + ILDAPCallback, + ILDAPPageCallback, +} from '@rocket.chat/core-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; +import ldapjs from 'ldapjs'; + +import { logger, connLogger, searchLogger, authLogger, bindLogger, mapLogger } from './Logger'; +import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; +import { processLdapVariables, type LDAPVariableMap } from './processLdapVariables'; +import { ensureArray } from '../../../lib/utils/arrayUtils'; +import { settings } from '../../settings'; + +interface ILDAPEntryCallback { + (entry: ldapjs.SearchEntry): T | undefined; +} + +interface ILDAPSearchEndCallback { + (error?: any): void; +} + +interface ILDAPSearchPageCallback { + (result: ldapjs.SearchEntry[]): void; +} + +interface ILDAPSearchAllCallbacks { + dataCallback?: ILDAPSearchPageCallback; + endCallback?: ILDAPSearchEndCallback; + entryCallback?: ILDAPEntryCallback; +} + +type ILDAPExtractedValue = string | Array; + +export class LDAPConnection { + public ldapjs: any; + + public connected: boolean; + + public options: ILDAPConnectionOptions; + + public client: ldapjs.Client; + + private _receivedResponse: boolean; + + private _connectionTimedOut: boolean; + + private _connectionCallback: ILDAPCallback; + + private usingAuthentication: boolean; + + private _variableMap: LDAPVariableMap; + + constructor() { + this.ldapjs = ldapjs; + + this.connected = false; + this._receivedResponse = false; + this._connectionTimedOut = false; + + this.options = { + host: settings.get('LDAP_Host') ?? '', + port: settings.get('LDAP_Port') ?? 389, + reconnect: settings.get('LDAP_Reconnect') ?? false, + timeout: settings.get('LDAP_Timeout') ?? 60000, + connectionTimeout: settings.get('LDAP_Connect_Timeout') ?? 1000, + idleTimeout: settings.get('LDAP_Idle_Timeout') ?? 1000, + encryption: settings.get('LDAP_Encryption') ?? 'plain', + caCert: settings.get('LDAP_CA_Cert'), + rejectUnauthorized: settings.get('LDAP_Reject_Unauthorized') || false, + baseDN: settings.get('LDAP_BaseDN') ?? '', + userSearchFilter: settings.get('LDAP_User_Search_Filter') ?? '', + userSearchScope: settings.get('LDAP_User_Search_Scope') ?? 'sub', + userSearchField: getLDAPConditionalSetting('LDAP_User_Search_Field') ?? '', + searchPageSize: settings.get('LDAP_Search_Page_Size') ?? 250, + searchSizeLimit: settings.get('LDAP_Search_Size_Limit') ?? 1000, + uniqueIdentifierField: settings.get('LDAP_Unique_Identifier_Field'), + groupFilterEnabled: settings.get('LDAP_Group_Filter_Enable') ?? false, + groupFilterObjectClass: settings.get('LDAP_Group_Filter_ObjectClass'), + groupFilterGroupIdAttribute: settings.get('LDAP_Group_Filter_Group_Id_Attribute'), + groupFilterGroupMemberAttribute: settings.get('LDAP_Group_Filter_Group_Member_Attribute'), + groupFilterGroupMemberFormat: settings.get('LDAP_Group_Filter_Group_Member_Format'), + groupFilterGroupName: settings.get('LDAP_Group_Filter_Group_Name'), + authentication: settings.get('LDAP_Authentication') ?? false, + authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', + authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', + useVariables: settings.get('LDAP_DataSync_UseVariables') ?? false, + variableMap: settings.get('LDAP_DataSync_VariableMap') ?? '{}', + attributesToQuery: this.parseAttributeList(settings.get('LDAP_User_Search_AttributesToQuery')), + }; + + this._variableMap = + (this.options.useVariables && + wrapExceptions(() => JSON.parse(this.options.variableMap)).suppress(() => { + mapLogger.error({ msg: 'Failed to parse LDAP Variable Map', map: this.options.variableMap }); + })) || + {}; + + if (!this.options.host) { + logger.warn('LDAP Host is not configured.'); + } + if (!this.options.baseDN) { + logger.warn('LDAP Search BaseDN is not configured.'); + } + } + + public async connect(): Promise { + return new Promise((resolve, reject) => { + this.initializeConnection((error, result) => { + if (error) { + return reject(error); + } + + return resolve(result); + }); + }); + } + + public disconnect(): void { + this.usingAuthentication = false; + this.connected = false; + connLogger.info('Disconnecting'); + + if (this.client) { + this.client.unbind(); + } + } + + public async testConnection(): Promise { + try { + await this.connect(); + await this.maybeBindDN(); + } finally { + this.disconnect(); + } + } + + public async searchByUsername(escapedUsername: string): Promise { + const searchOptions: ldapjs.SearchOptions = { + filter: this.getUserFilter(escapedUsername), + scope: this.options.userSearchScope || 'sub', + sizeLimit: this.options.searchSizeLimit, + attributes: this.options.attributesToQuery, + }; + + if (this.options.searchPageSize > 0) { + searchOptions.paged = { + pageSize: this.options.searchPageSize, + pagePause: false, + }; + } + + searchLogger.info({ + msg: 'Searching by username', + username: escapedUsername, + baseDN: this.options.baseDN, + searchOptions, + }); + return this.search(this.options.baseDN, searchOptions); + } + + public async findOneByUsername(username: string): Promise { + const results = await this.searchByUsername(username); + + if (results.length === 1) { + return results[0]; + } + } + + public async searchById(id: string, attribute?: string): Promise { + const searchOptions: ldapjs.SearchOptions = { + scope: this.options.userSearchScope || 'sub', + attributes: this.options.attributesToQuery, + }; + + if (attribute) { + searchOptions.filter = new this.ldapjs.filters.EqualityFilter({ + attribute, + value: Buffer.from(id, 'hex'), + }); + } else if (this.options.uniqueIdentifierField) { + // If we don't know what attribute the id came from, we have to look for all of them. + const possibleFields = this.options.uniqueIdentifierField.split(',').concat(this.options.userSearchField.split(',')); + const filters = []; + for (const field of possibleFields) { + if (!field) { + continue; + } + + filters.push( + new this.ldapjs.filters.EqualityFilter({ + attribute: field, + value: Buffer.from(id, 'hex'), + }), + ); + } + searchOptions.filter = new this.ldapjs.filters.OrFilter({ filters }); + } else { + throw new Error('Unique Identifier Field is not configured.'); + } + + searchLogger.info({ msg: 'Searching by id', id }); + searchLogger.debug({ msg: 'search filter', searchOptions, baseDN: this.options.baseDN }); + + return this.search(this.options.baseDN, searchOptions); + } + + public async findOneById(id: string, attribute?: string): Promise { + const results = await this.searchById(id, attribute); + if (results.length === 1) { + return results[0]; + } + } + + public async searchAllUsers({ + dataCallback, + endCallback, + entryCallback, + }: ILDAPSearchAllCallbacks): Promise { + searchLogger.info('Searching all users'); + + const searchOptions: ldapjs.SearchOptions = { + filter: this.getUserFilter('*'), + scope: this.options.userSearchScope || 'sub', + sizeLimit: this.options.searchSizeLimit, + attributes: this.options.attributesToQuery, + }; + + if (this.options.searchPageSize > 0) { + let count = 0; + await this.doPagedSearch( + this.options.baseDN, + searchOptions, + this.options.searchPageSize, + (error, entries: ldapjs.SearchEntry[], { end, next } = { end: false, next: undefined }) => { + if (error) { + endCallback?.(error); + return; + } + + count += entries.length; + dataCallback?.(entries); + if (end) { + endCallback?.(); + } + + if (next) { + next(count); + } + }, + entryCallback, + ); + return; + } + + await this.doAsyncSearch( + this.options.baseDN, + searchOptions, + (error, result) => { + dataCallback?.(result); + endCallback?.(error); + }, + entryCallback, + ); + } + + public async authenticate(dn: string, password: string): Promise { + authLogger.info({ msg: 'Authenticating', dn }); + + try { + await this.bindDN(dn, password); + + authLogger.info({ msg: 'Authenticated', dn }); + return true; + } catch (error) { + authLogger.info({ msg: 'Not authenticated', dn }); + authLogger.debug({ msg: 'error', error }); + return false; + } + } + + public async search(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { + return this.doCustomSearch(baseDN, searchOptions, (entry) => this.extractLdapEntryData(entry)); + } + + public async searchRaw(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { + return this.doCustomSearch(baseDN, searchOptions, (entry) => entry); + } + + public async searchAndCount(baseDN: string, searchOptions: ldapjs.SearchOptions): Promise { + let count = 0; + await this.doCustomSearch(baseDN, searchOptions, async () => { + count++; + }); + + return count; + } + + public extractLdapAttribute(value: Buffer | Buffer[] | string): ILDAPExtractedValue { + if (Array.isArray(value)) { + return value.map((item) => this.extractLdapAttribute(item)); + } + + if (typeof value === 'string') { + return value; + } + + return value.toString(); + } + + public extractLdapEntryData(entry: ldapjs.SearchEntry): ILDAPEntry { + const values: ILDAPEntry = { + _raw: entry.raw, + }; + + Object.keys(values._raw).forEach((key) => { + values[key] = this.extractLdapAttribute(values._raw[key]); + + const dataType = typeof values[key]; + // eslint-disable-next-line no-control-regex + if (dataType === 'string' && values[key].length > 100 && /[\x00-\x1F]/.test(values[key])) { + mapLogger.debug({ + msg: 'Extracted Attribute', + key, + type: dataType, + length: values[key].length, + value: `${values[key].substr(0, 100)}...`, + }); + return; + } + + mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, value: values[key] }); + }); + + return processLdapVariables(values, this._variableMap); + } + + public async doCustomSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, entryCallback: ILDAPEntryCallback): Promise { + await this.runBeforeSearch(searchOptions); + + if (!searchOptions.scope) { + searchOptions.scope = this.options.userSearchScope || 'sub'; + } + searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); + + let realEntries = 0; + + return new Promise((resolve, reject) => { + this.clientSearch(baseDN, searchOptions, (err, res: ldapjs.SearchCallbackResponse) => { + if (err) { + searchLogger.error({ err }); + reject(err); + return; + } + + res.on('error', (err) => { + searchLogger.error({ err }); + reject(err); + }); + + const entries: T[] = []; + + res.on('searchEntry', (entry) => { + try { + const result = entryCallback(entry); + if (result) { + entries.push(result as T); + } + realEntries++; + } catch (err) { + searchLogger.error({ err }); + } + }); + + res.on('end', () => { + searchLogger.info({ + msg: 'LDAP search completed', + foundEntries: realEntries, + loadedEntries: entries.length, + }); + resolve(entries); + }); + }); + }); + } + + /* + Create an LDAP search filter based on the username + */ + public getUserFilter(username: string): string { + const filter: string[] = []; + + this.addUserFilters(filter, username); + + const fields = this.options.userSearchField + .split(',') + .map((field) => field.trim()) + .filter(Boolean); + + if (!fields.length) { + throw new Error('LDAP User Search Field is not configured'); + } + + const usernameFilter = fields.map((field) => `(${field}=${username})`); + + if (usernameFilter.length === 1) { + filter.push(`${usernameFilter[0]}`); + } else { + filter.push(`(|${usernameFilter.join('')})`); + } + + return `(&${filter.join('')})`; + } + + public async searchMembersOfGroupFilter(): Promise { + if (!this.options.groupFilterEnabled) { + return []; + } + + if (!this.options.groupFilterGroupMemberAttribute) { + return []; + } + + if (!this.options.groupFilterGroupMemberFormat) { + searchLogger.debug('LDAP Group Filter is enabled but no group member format is set.'); + return []; + } + + const filter = ['(&']; + + if (this.options.groupFilterObjectClass) { + filter.push(`(objectclass=${this.options.groupFilterObjectClass})`); + } + + if (this.options.groupFilterGroupIdAttribute) { + filter.push(`(${this.options.groupFilterGroupIdAttribute}=${this.options.groupFilterGroupName})`); + } + filter.push(')'); + const searchOptions: ldapjs.SearchOptions = { + filter: filter.join(''), + scope: 'sub', + }; + + searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter }); + + const result = await this.searchRaw(this.options.baseDN, searchOptions); + + if (!Array.isArray(result) || result.length === 0) { + searchLogger.debug({ msg: 'No groups found', result }); + return []; + } + + const members = this.extractLdapAttribute(result[0].raw[this.options.groupFilterGroupMemberAttribute]) as string | string[]; + + return ensureArray(members); + } + + public async isUserAcceptedByGroupFilter(username: string, userdn: string): Promise { + if (!this.options.groupFilterEnabled) { + return true; + } + + const filter = ['(&']; + + if (this.options.groupFilterObjectClass) { + filter.push(`(objectclass=${this.options.groupFilterObjectClass})`); + } + + if (this.options.groupFilterGroupMemberAttribute) { + filter.push(`(${this.options.groupFilterGroupMemberAttribute}=${this.options.groupFilterGroupMemberFormat})`); + } + + if (this.options.groupFilterGroupIdAttribute) { + filter.push(`(${this.options.groupFilterGroupIdAttribute}=${this.options.groupFilterGroupName})`); + } + filter.push(')'); + + const searchOptions: ldapjs.SearchOptions = { + filter: filter + .join('') + .replace(/#{username}/g, username) + .replace(/#{userdn}/g, userdn), + scope: 'sub', + }; + + searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter }); + + const result = await this.searchAndCount(this.options.baseDN, searchOptions); + + if (result === 0) { + return false; + } + return true; + } + + protected addUserFilters(filters: string[], _username: string): void { + const { userSearchFilter } = this.options; + + if (userSearchFilter !== '') { + if (userSearchFilter[0] === '(') { + filters.push(`${userSearchFilter}`); + } else { + filters.push(`(${userSearchFilter})`); + } + } + } + + public async bindDN(dn: string, password: string): Promise { + return new Promise((resolve, reject) => { + try { + this.client.bind(dn, password, (error) => { + if (error) { + return reject(error); + } + + resolve(); + }); + } catch (error) { + reject(error); + } + }); + } + + private clientSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, callback: ldapjs.SearchCallBack): void { + try { + this.client.search(baseDN, searchOptions, callback); + } catch (err) { + callback(err as ldapjs.Error, undefined as unknown as ldapjs.SearchCallbackResponse); + } + } + + private async doAsyncSearch( + baseDN: string, + searchOptions: ldapjs.SearchOptions, + callback: ILDAPCallback, + entryCallback?: ILDAPEntryCallback, + ): Promise { + await this.runBeforeSearch(searchOptions); + + searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); + + this.clientSearch(baseDN, searchOptions, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => { + if (err) { + searchLogger.error({ err }); + callback(err); + return; + } + + res.on('error', (err) => { + searchLogger.error({ err }); + callback(err); + }); + + const entries: T[] = []; + + res.on('searchEntry', (entry) => { + try { + const result = entryCallback ? entryCallback(entry) : entry; + entries.push(result as T); + } catch (err) { + searchLogger.error({ err }); + } + }); + + res.on('end', () => { + searchLogger.info({ msg: 'Search result count', count: entries.length }); + callback(null, entries); + }); + }); + } + + private processSearchPage( + { entries, title, end, next }: { entries: T[]; title: string; end: boolean; next?: () => void }, + callback: ILDAPPageCallback, + ): void { + searchLogger.info(title); + // Force LDAP idle to wait the record processing + this._updateIdle(true); + + callback(null, entries, { + end, + next: () => { + // Reset idle timer + this._updateIdle(); + next?.(); + }, + }); + } + + private async doPagedSearch( + baseDN: string, + searchOptions: ldapjs.SearchOptions, + pageSize: number, + callback: ILDAPPageCallback, + entryCallback?: ILDAPEntryCallback, + ): Promise { + searchOptions.paged = { + pageSize, + pagePause: true, + }; + + await this.runBeforeSearch(searchOptions); + + searchLogger.debug({ msg: 'searchOptions', searchOptions, baseDN }); + + this.clientSearch(baseDN, searchOptions, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse): void => { + if (err) { + searchLogger.error({ err }); + callback(err); + return; + } + + res.on('error', (err) => { + searchLogger.error({ err }); + callback(err); + }); + + let entries: T[] = []; + const internalPageSize = pageSize * 2; + + res.on('searchEntry', (entry) => { + try { + const result = entryCallback ? entryCallback(entry) : entry; + entries.push(result as T); + + if (entries.length >= internalPageSize) { + this.processSearchPage( + { + entries, + title: 'Internal Page', + end: false, + }, + callback, + ); + entries = []; + } + } catch (err) { + searchLogger.error({ err }); + } + }); + + res.on('page', (_result, next) => { + if (!next) { + this._updateIdle(true); + this.processSearchPage( + { + entries, + title: 'Final Page', + end: true, + }, + callback, + ); + entries = []; + } else if (entries.length) { + this.processSearchPage( + { + entries, + title: 'Page', + end: false, + next, + }, + callback, + ); + entries = []; + } + }); + + res.on('end', () => { + if (entries.length) { + this.processSearchPage( + { + entries, + title: 'Final Page', + end: true, + }, + callback, + ); + entries = []; + } + }); + }); + } + + private _updateIdle(override?: boolean): void { + // @ts-expect-error use a private function to signal to the lib that we're still working + this.client._updateIdle(override); + } + + protected async maybeBindDN({ forceBindAuthenticationUser = false } = {}): Promise { + if (!forceBindAuthenticationUser && (this.usingAuthentication || !this.options.authentication)) { + return; + } + + if (!this.options.authenticationUserDN) { + logger.error('Invalid UserDN for authentication'); + return; + } + + bindLogger.info({ msg: 'Binding UserDN', userDN: this.options.authenticationUserDN }); + try { + await this.bindDN(this.options.authenticationUserDN, this.options.authenticationPassword); + this.usingAuthentication = true; + } catch (error) { + authLogger.error({ + msg: 'Base Authentication Issue', + err: error, + dn: this.options.authenticationUserDN, + }); + this.usingAuthentication = false; + } + } + + protected async runBeforeSearch(_searchOptions: ldapjs.SearchOptions): Promise { + return this.maybeBindDN(); + } + + public async bindAuthenticationUser(): Promise { + return this.maybeBindDN({ forceBindAuthenticationUser: true }); + } + + /* + Get list of options to initialize a new ldapjs Client + */ + private getClientOptions(): { + clientOptions: ldapjs.ClientOptions; + tlsOptions: Record; + } { + const clientOptions: ldapjs.ClientOptions = { + url: `${this.options.host}:${this.options.port}`, + timeout: this.options.timeout, + connectTimeout: this.options.connectionTimeout, + idleTimeout: this.options.idleTimeout, + reconnect: this.options.reconnect, + }; + + const tlsOptions: Record = { + rejectUnauthorized: this.options.rejectUnauthorized, + }; + + if (this.options.caCert) { + // Split CA cert into array of strings + const chainLines = this.options.caCert.split('\n'); + let cert: string[] = []; + const ca: string[] = []; + chainLines.forEach((line) => { + cert.push(line); + if (line.match(/-END CERTIFICATE-/)) { + ca.push(cert.join('\n')); + cert = []; + } + }); + tlsOptions.ca = ca; + } + + if (this.options.encryption === 'ssl') { + clientOptions.url = `ldaps://${clientOptions.url}`; + clientOptions.tlsOptions = tlsOptions; + } else { + clientOptions.url = `ldap://${clientOptions.url}`; + } + + return { + clientOptions, + tlsOptions, + }; + } + + private handleConnectionResponse(err: any, response?: any): void { + if (!this._receivedResponse) { + this._receivedResponse = true; + this._connectionCallback(err, response); + return; + } + + if (this._connectionTimedOut && !err) { + connLogger.info('Received a response after the connection timedout.'); + } else { + logger.debug('Ignored error/response:'); + } + + connLogger.debug({ err, response }); + } + + private initializeConnection(callback: ILDAPCallback): void { + connLogger.info('Init Setup'); + this._receivedResponse = false; + this._connectionTimedOut = false; + this._connectionCallback = callback; + + const { clientOptions, tlsOptions } = this.getClientOptions(); + connLogger.info({ msg: 'Connecting', url: clientOptions.url }); + connLogger.debug({ msg: 'clientOptions', clientOptions }); + + this.client = ldapjs.createClient(clientOptions); + + this.client.on('error', (error) => { + connLogger.error({ err: error }); + this.handleConnectionResponse(error, null); + }); + + this.client.on('idle', () => { + searchLogger.info('Idle'); + this.disconnect(); + }); + + this.client.on('close', () => { + searchLogger.info('Closed'); + }); + + if (this.options.encryption === 'tls') { + // Set host parameter for tls.connect which is used by ldapjs starttls. This may not be needed anymore + // https://github.com/RocketChat/Rocket.Chat/issues/2035 + // https://github.com/mcavage/node-ldapjs/issues/349 + tlsOptions.host = this.options.host; + + connLogger.info('Starting TLS'); + connLogger.debug({ msg: 'tlsOptions', tlsOptions }); + + this.client.starttls(tlsOptions, null, (err, response) => { + if (err) { + connLogger.error({ msg: 'TLS connection', err }); + return this.handleConnectionResponse(err, null); + } + + connLogger.info('TLS connected'); + this.connected = true; + this.handleConnectionResponse(null, response); + }); + } else { + this.client.on('connect', (response) => { + connLogger.info('LDAP connected'); + this.connected = true; + this.handleConnectionResponse(null, response); + }); + } + + setTimeout(() => { + if (!this._receivedResponse) { + connLogger.error({ msg: 'connection time out', timeout: clientOptions.connectTimeout }); + this.handleConnectionResponse(new Error('Timeout')); + this._connectionTimedOut = true; + } + }, clientOptions.connectTimeout); + } + + private parseAttributeList(csv: string | undefined): Array { + if (!csv) { + return ['*', '+']; + } + + const list = csv.split(',').map((item) => item.trim()); + if (!list?.length) { + return ['*', '+']; + } + + return list; + } +} diff --git a/apps/meteor/server/lib/ldap/Logger.ts b/apps/meteor/server/lib/ldap/Logger.ts new file mode 100644 index 0000000..f7dd872 --- /dev/null +++ b/apps/meteor/server/lib/ldap/Logger.ts @@ -0,0 +1,8 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('LDAP'); +export const connLogger = logger.section('Connection'); +export const bindLogger = logger.section('Bind'); +export const searchLogger = logger.section('Search'); +export const authLogger = logger.section('Auth'); +export const mapLogger = logger.section('Mapping'); diff --git a/apps/meteor/server/lib/ldap/Manager.ts b/apps/meteor/server/lib/ldap/Manager.ts new file mode 100644 index 0000000..0c3951d --- /dev/null +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -0,0 +1,562 @@ +import type { ILDAPEntry, LDAPLoginResult, ILDAPUniqueIdentifierField, IUser, LoginUsername, IImportUser } from '@rocket.chat/core-typings'; +import { Users as UsersRaw } from '@rocket.chat/models'; +import { SHA256 } from '@rocket.chat/sha256'; +import ldapEscape from 'ldap-escape'; +import limax from 'limax'; +// #ToDo: #TODO: Remove Meteor dependencies +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { LDAPConnection } from './Connection'; +import { logger, authLogger, connLogger } from './Logger'; +import { LDAPUserConverter } from './UserConverter'; +import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; +import { omit } from '../../../lib/utils/omit'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import type { UserConverterOptions } from '../import/classes/converters/UserConverter'; +import { setUserAvatar } from '../users/setUserAvatar'; + +export class LDAPManager { + public static async login(username: string, password: string): Promise { + logger.debug({ msg: 'Init LDAP login', username }); + + if (settings.get('LDAP_Enable') !== true) { + return this.fallbackToDefaultLogin(username, password); + } + + let ldapUser: ILDAPEntry | undefined; + + const ldap = new LDAPConnection(); + try { + try { + await ldap.connect(); + ldapUser = await this.findUser(ldap, username, password); + } catch (err) { + logger.error({ err }); + } + + if (ldapUser === undefined) { + return this.fallbackToDefaultLogin(username, password); + } + + const homeServer = this.getFederationHomeServer(ldapUser); + if (homeServer) { + return this.fallbackToDefaultLogin(username, password); + } + + const slugifiedUsername = this.slugifyUsername(ldapUser, username); + const user = await this.findExistingUser(ldapUser, slugifiedUsername); + + // Bind connection to the admin user so that RC has full access to groups in the next steps + await ldap.bindAuthenticationUser(); + if (user) { + return await this.loginExistingUser(ldap, user, ldapUser, password); + } + + return await this.loginNewUserFromLDAP(slugifiedUsername, ldap, ldapUser, password); + } finally { + ldap.disconnect(); + } + } + + public static async loginAuthenticatedUser(username: string): Promise { + logger.debug({ msg: 'Init LDAP login', username }); + + if (settings.get('LDAP_Enable') !== true) { + return; + } + + let ldapUser: ILDAPEntry | undefined; + + const ldap = new LDAPConnection(); + try { + try { + await ldap.connect(); + ldapUser = await this.findAuthenticatedUser(ldap, username); + } catch (err) { + logger.error({ err }); + } + + if (ldapUser === undefined) { + return; + } + + const homeServer = this.getFederationHomeServer(ldapUser); + if (homeServer) { + return; + } + + const slugifiedUsername = this.slugifyUsername(ldapUser, username); + const user = await this.findExistingUser(ldapUser, slugifiedUsername); + + if (user) { + return await this.loginExistingUser(ldap, user, ldapUser); + } + + return await this.loginNewUserFromLDAP(slugifiedUsername, ldap, ldapUser); + } finally { + ldap.disconnect(); + } + } + + public static async testConnection(): Promise { + try { + const ldap = new LDAPConnection(); + await ldap.testConnection(); + } catch (err) { + connLogger.error({ err }); + throw err; + } + } + + public static async testSearch(username: string): Promise { + const escapedUsername = ldapEscape.filter`${username}`; + const ldap = new LDAPConnection(); + + try { + await ldap.connect(); + + const users = await ldap.searchByUsername(escapedUsername); + if (users.length !== 1) { + logger.debug({ msg: 'Search results', count: users.length, username: escapedUsername }); + throw new Error('User not found'); + } + } catch (err) { + logger.error({ err }); + throw err; + } + } + + public static async syncUserAvatar(user: IUser, ldapUser: ILDAPEntry): Promise { + if (!user?._id || settings.get('LDAP_Sync_User_Avatar') !== true) { + return; + } + + const avatar = this.getAvatarFromUser(ldapUser); + if (!avatar) { + return; + } + + const hash = SHA256(avatar.toString()); + if (user.avatarETag === hash) { + return; + } + + logger.debug({ msg: 'Syncing user avatar', username: user.username }); + + await setUserAvatar(user, avatar, 'image/jpeg', 'rest', hash); + } + + // This method will only find existing users that are already linked to LDAP + protected static async findExistingLDAPUser(ldapUser: ILDAPEntry): Promise { + const uniqueIdentifierField = this.getLdapUserUniqueID(ldapUser); + + if (uniqueIdentifierField) { + logger.debug({ msg: 'Querying user', uniqueId: uniqueIdentifierField.value }); + return UsersRaw.findOneByLDAPId(uniqueIdentifierField.value, uniqueIdentifierField.attribute); + } + } + + protected static getConverterOptions(): UserConverterOptions { + return { + flagEmailsAsVerified: settings.get('Accounts_Verify_Email_For_External_Accounts') ?? false, + skipExistingUsers: false, + skipUserCallbacks: false, + syncVoipExtension: Boolean(settings.get('LDAP_Extension_Field')), + }; + } + + protected static mapUserData(ldapUser: ILDAPEntry, usedUsername?: string | undefined): IImportUser { + const uniqueId = this.getLdapUserUniqueID(ldapUser); + if (!uniqueId) { + throw new Error('Failed to generate unique identifier for ldap entry'); + } + + const { attribute: idAttribute, value: id } = uniqueId; + const username = this.slugifyUsername(ldapUser, usedUsername || id || '') || undefined; + const homeServer = this.getFederationHomeServer(ldapUser); + const emails = homeServer ? [] : this.getLdapEmails(ldapUser, username).map((email) => email.trim()); + const name = this.getLdapName(ldapUser) || undefined; + const voipExtension = this.getLdapExtension(ldapUser); + + const userData: IImportUser = { + type: 'user', + emails, + importIds: [ldapUser.dn], + username, + name, + voipExtension, + services: { + ldap: { + idAttribute, + id, + }, + }, + ...(homeServer && { + username: `@${username}:${homeServer}`, + federated: true, + federation: { + version: 1, + mui: `@${username}:${homeServer}`, + origin: homeServer, + }, + }), + }; + + this.onMapUserData(ldapUser, userData); + return userData; + } + + private static onMapUserData(ldapUser: ILDAPEntry, userData: IImportUser): void { + void callbacks.run('mapLDAPUserData', userData, ldapUser); + } + + private static async findUser(ldap: LDAPConnection, username: string, password: string): Promise { + const escapedUsername = ldapEscape.filter`${username}`; + + try { + const users = await ldap.searchByUsername(escapedUsername); + + if (users.length !== 1) { + logger.debug({ msg: 'Search results', count: users.length, username: escapedUsername }); + throw new Error('User not found'); + } + + const [ldapUser] = users; + if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { + throw new Error('User not found'); + } + + if (!(await ldap.authenticate(ldapUser.dn, password))) { + logger.debug({ msg: 'Wrong password', username: escapedUsername }); + throw new Error('Invalid user or wrong password'); + } + + if (settings.get('LDAP_Find_User_After_Login')) { + // Do a search as the user and check if they have any result + authLogger.debug('User authenticated successfully, performing additional search.'); + if ((await ldap.searchAndCount(ldapUser.dn, {})) === 0) { + authLogger.debug({ msg: 'Bind successful but user was not found via search', dn: ldapUser.dn }); + } + } + return ldapUser; + } catch (err) { + logger.error({ err }); + } + } + + private static async findAuthenticatedUser(ldap: LDAPConnection, username: string): Promise { + const escapedUsername = ldapEscape.filter`${username}`; + + try { + const users = await ldap.searchByUsername(escapedUsername); + + if (users.length !== 1) { + logger.debug({ msg: 'Search results', count: users.length, username: escapedUsername }); + return; + } + + const [ldapUser] = users; + + if (settings.get('LDAP_Find_User_After_Login')) { + // Do a search as the user and check if they have any result + authLogger.debug('User authenticated successfully, performing additional search.'); + if ((await ldap.searchAndCount(ldapUser.dn, {})) === 0) { + authLogger.debug({ msg: 'Bind successful but user was not found via search', dn: ldapUser.dn }); + } + } + + if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { + throw new Error('User not in a valid group'); + } + + return ldapUser; + } catch (err) { + logger.error({ err }); + } + } + + private static async loginNewUserFromLDAP( + slugifiedUsername: string, + ldap: LDAPConnection, + ldapUser: ILDAPEntry, + ldapPass?: string, + ): Promise { + logger.debug({ msg: 'User does not exist, creating', username: slugifiedUsername }); + + let username: string | undefined; + + if (getLDAPConditionalSetting('LDAP_Username_Field') !== '') { + username = slugifiedUsername; + } + + // Create new user + return this.addLdapUser(ldapUser, username, ldapPass, ldap); + } + + private static async addLdapUser( + ldapUser: ILDAPEntry, + username: string | undefined, + password: string | undefined, + ldap: LDAPConnection, + ): Promise { + const user = await this.syncUserForLogin(ldapUser, undefined, username); + + if (!user) { + return; + } + + await this.onLogin(ldapUser, user, password, ldap, true); + + return { + userId: user._id, + }; + } + + private static async onLogin( + ldapUser: ILDAPEntry, + user: IUser, + password: string | undefined, + ldap: LDAPConnection, + isNewUser: boolean, + ): Promise { + logger.debug('running onLDAPLogin'); + if (settings.get('LDAP_Login_Fallback') && typeof password === 'string' && password.trim() !== '') { + await Accounts.setPasswordAsync(user._id, password, { logout: false }); + } + + await this.syncUserAvatar(user, ldapUser); + await callbacks.run('onLDAPLogin', { user, ldapUser, isNewUser }, ldap); + } + + private static async loginExistingUser( + ldap: LDAPConnection, + user: IUser, + ldapUser: ILDAPEntry, + password?: string, + ): Promise { + if (user.ldap !== true && settings.get('LDAP_Merge_Existing_Users') !== true) { + logger.debug('User exists without "ldap: true"'); + throw new Meteor.Error( + 'LDAP-login-error', + `LDAP Authentication succeeded, but there's already an existing user with provided username [${user.username}] in Mongo.`, + ); + } + + // If we're merging an ldap user with a local user, then we need to sync the data even if 'update data on login' is off. + const forceUserSync = !user.ldap; + + const syncData = forceUserSync || (settings.get('LDAP_Update_Data_On_Login') ?? true); + logger.debug({ msg: 'Logging user in', syncData }); + const updatedUser = (syncData && (await this.syncUserForLogin(ldapUser, user))) || user; + + await this.onLogin(ldapUser, updatedUser, password, ldap, false); + return { + userId: user._id, + }; + } + + private static async syncUserForLogin( + ldapUser: ILDAPEntry, + existingUser?: IUser, + usedUsername?: string | undefined, + ): Promise { + logger.debug({ + msg: 'Syncing user data', + ldapUser: omit(ldapUser, '_raw'), + user: { ...(existingUser && { email: existingUser.emails, _id: existingUser._id }) }, + }); + + const userData = this.mapUserData(ldapUser, usedUsername); + + // make sure to persist existing user data when passing to sync/convert + // TODO this is only needed because ImporterDataConverter assigns a default role and type if nothing is set. we might need to figure out a better way and stop doing that there + if (existingUser) { + if (!userData.roles && existingUser.roles) { + userData.roles = existingUser.roles; + } + if (!userData.type && existingUser.type) { + userData.type = existingUser.type as IImportUser['type']; + } + } + + const options = this.getConverterOptions(); + await LDAPUserConverter.convertSingleUser(userData, options); + + return existingUser || this.findExistingLDAPUser(ldapUser); + } + + private static getLdapUserUniqueID(ldapUser: ILDAPEntry): ILDAPUniqueIdentifierField | undefined { + let uniqueIdentifierField: string | string[] | undefined = settings.get('LDAP_Unique_Identifier_Field'); + + if (uniqueIdentifierField) { + uniqueIdentifierField = uniqueIdentifierField.replace(/\s/g, '').split(','); + } else { + uniqueIdentifierField = []; + } + + let userSearchField: string | string[] | undefined = getLDAPConditionalSetting('LDAP_User_Search_Field'); + + if (userSearchField) { + userSearchField = userSearchField.replace(/\s/g, '').split(','); + } else { + userSearchField = []; + } + + uniqueIdentifierField = uniqueIdentifierField.concat(userSearchField); + if (!uniqueIdentifierField.length) { + uniqueIdentifierField.push('dn'); + } + + const key = uniqueIdentifierField.find((field) => !_.isEmpty(ldapUser._raw[field])); + if (key) { + return { + attribute: key, + value: ldapUser._raw[key].toString('hex'), + }; + } + + connLogger.warn('Failed to generate unique identifier for ldap entry'); + connLogger.debug(ldapUser); + } + + private static getLdapName(ldapUser: ILDAPEntry): string | undefined { + const nameAttributes = getLDAPConditionalSetting('LDAP_Name_Field'); + return getLdapDynamicValue(ldapUser, nameAttributes); + } + + private static getLdapExtension(ldapUser: ILDAPEntry): string | undefined { + const extensionAttribute = settings.get('LDAP_Extension_Field'); + if (!extensionAttribute) { + return; + } + + return getLdapString(ldapUser, extensionAttribute); + } + + private static getLdapEmails(ldapUser: ILDAPEntry, username?: string): string[] { + const emailAttributes = getLDAPConditionalSetting('LDAP_Email_Field'); + if (emailAttributes) { + const attributeList: string[] = emailAttributes.replace(/\s/g, '').split(','); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); + + const emails: string[] = [].concat(key ? ldapUser[key.trim()] : []); + const filteredEmails = emails.filter((email) => email.includes('@')); + + if (filteredEmails.length) { + return filteredEmails; + } + } + + if (settings.get('LDAP_Default_Domain') !== '' && username) { + return [`${username}@${settings.get('LDAP_Default_Domain')}`]; + } + + if (ldapUser.mail?.includes('@')) { + return [ldapUser.mail]; + } + + logger.debug(ldapUser); + throw new Error('Failed to get email address from LDAP user'); + } + + private static slugify(text: string): string { + if (settings.get('UTF8_Names_Slugify') !== true) { + return text; + } + + text = limax(text, { replacement: '.' }); + return text.replace(/[^0-9a-z-_.]/g, ''); + } + + private static slugifyUsername(ldapUser: ILDAPEntry, requestUsername: string): string { + if (getLDAPConditionalSetting('LDAP_Username_Field') !== '') { + const username = this.getLdapUsername(ldapUser); + if (username) { + return this.slugify(username); + } + } + + return this.slugify(requestUsername); + } + + protected static getLdapUsername(ldapUser: ILDAPEntry): string | undefined { + const usernameField = getLDAPConditionalSetting('LDAP_Username_Field') as string; + return getLdapDynamicValue(ldapUser, usernameField); + } + + protected static getFederationHomeServer(ldapUser: ILDAPEntry): string | undefined { + if (!settings.get('Federation_Service_Enabled')) { + return; + } + + const homeServerField = settings.get('LDAP_FederationHomeServer_Field'); + const homeServer = getLdapDynamicValue(ldapUser, homeServerField); + + if (!homeServer) { + return; + } + + logger.debug({ msg: 'User has a federation home server', homeServer }); + + const localServer = settings.get('Federation_Service_Domain'); + if (localServer === homeServer) { + return; + } + + return homeServer; + } + + // This method will find existing users by LDAP id or by username. + private static async findExistingUser(ldapUser: ILDAPEntry, slugifiedUsername: string): Promise { + const user = await this.findExistingLDAPUser(ldapUser); + if (user) { + return user; + } + + // If we don't have that ldap user linked yet, check if there's any non-ldap user with the same username + return UsersRaw.findOneWithoutLDAPByUsernameIgnoringCase(slugifiedUsername); + } + + private static fallbackToDefaultLogin(username: LoginUsername, password: string): LDAPLoginResult { + if (typeof username === 'string') { + if (username.indexOf('@') === -1) { + username = { username }; + } else { + username = { email: username }; + } + } + + logger.debug({ msg: 'Fallback to default account system', username }); + + const loginRequest = { + user: username, + password: { + digest: SHA256(password), + algorithm: 'sha-256', + }, + }; + + return Accounts._runLoginHandlers(this, loginRequest); + } + + private static getAvatarFromUser(ldapUser: ILDAPEntry): any | undefined { + const avatarField = String(settings.get('LDAP_Avatar_Field') || '').trim(); + if (avatarField && ldapUser._raw[avatarField]) { + return ldapUser._raw[avatarField]; + } + + if (ldapUser._raw.thumbnailPhoto) { + return ldapUser._raw.thumbnailPhoto; + } + + if (ldapUser._raw.jpegPhoto) { + return ldapUser._raw.jpegPhoto; + } + } +} diff --git a/apps/meteor/server/lib/ldap/UserConverter.ts b/apps/meteor/server/lib/ldap/UserConverter.ts new file mode 100644 index 0000000..5ce9fda --- /dev/null +++ b/apps/meteor/server/lib/ldap/UserConverter.ts @@ -0,0 +1,67 @@ +import type { IImportUser, IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import type { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; + +import { logger } from './Logger'; +import { settings } from '../../settings'; +import type { ConverterCache } from '../import/classes/converters/ConverterCache'; +import type { RecordConverterOptions } from '../import/classes/converters/RecordConverter'; +import { UserConverter, type UserConverterOptions } from '../import/classes/converters/UserConverter'; + +export class LDAPUserConverter extends UserConverter { + private mergeExistingUsers: boolean; + + constructor(options?: UserConverterOptions & RecordConverterOptions, logger?: Logger, cache?: ConverterCache) { + const ldapOptions = { + workInMemory: true, + ...(options || {}), + }; + + super(ldapOptions, logger, cache); + this.mergeExistingUsers = settings.get('LDAP_Merge_Existing_Users') ?? true; + } + + override async findExistingUser(data: IImportUser): Promise { + if (data.services?.ldap?.id) { + const importedUser = await Users.findOneByLDAPId(data.services.ldap.id, data.services.ldap.idAttribute); + if (importedUser) { + return importedUser; + } + } + + if (!this.mergeExistingUsers) { + return; + } + + if (data.emails.length) { + const emailUser = await Users.findOneWithoutLDAPByEmailAddress(data.emails[0], {}); + + if (emailUser) { + return emailUser; + } + } + + if (data.username) { + return Users.findOneWithoutLDAPByUsernameIgnoringCase(data.username); + } + } + + override async insertUser(userData: IImportUser): Promise { + if (!userData.deleted) { + // #TODO: Change the LDAP sync process to split the inserts and updates into two stages so that we can validate this only once for all insertions + if (await License.shouldPreventAction('activeUsers')) { + logger.warn({ msg: 'Max users allowed reached, creating new LDAP users in inactive state ', username: userData.username }); + userData.deleted = true; + } + } + + return super.insertUser(userData); + } + + static async convertSingleUser(userData: IImportUser, options?: UserConverterOptions): Promise { + const converter = new LDAPUserConverter(options); + await converter.addObject(userData); + await converter.convertData(); + } +} diff --git a/apps/meteor/server/lib/ldap/getLDAPConditionalSetting.ts b/apps/meteor/server/lib/ldap/getLDAPConditionalSetting.ts new file mode 100644 index 0000000..243f85a --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLDAPConditionalSetting.ts @@ -0,0 +1,9 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; + +import { settings } from '../../settings'; + +export function getLDAPConditionalSetting(settingName: string): T | undefined { + const isActiveDirectory = settings.get('LDAP_Server_Type') === 'ad'; + const key = isActiveDirectory ? settingName.replace('LDAP_', 'LDAP_AD_') : settingName; + return settings.get(key); +} diff --git a/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts b/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts new file mode 100644 index 0000000..90b25c9 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts @@ -0,0 +1,65 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import { getLdapDynamicValue } from './getLdapDynamicValue'; + +describe('getLdapDynamicValue', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + displayName: 'John Doe', + email: 'john.doe@example.com', + uid: 'johndoe', + emptyField: '', + }; + + it('should return undefined if attributeSetting is undefined', () => { + const result = getLdapDynamicValue(ldapUser, undefined); + expect(result).to.be.undefined; + }); + + it('should return the correct value from a single valid attribute', () => { + const result = getLdapDynamicValue(ldapUser, 'displayName'); + expect(result).to.equal('John Doe'); + }); + + it('should return the correct value from a template attribute', () => { + const result = getLdapDynamicValue(ldapUser, 'Hello, #{displayName}!'); + expect(result).to.equal('Hello, John Doe!'); + }); + + it('should replace missing keys with an empty string in a template', () => { + const result = getLdapDynamicValue(ldapUser, 'Hello, #{nonExistentField}!'); + expect(result).to.equal('Hello, !'); + }); + + it('should return the first valid key from a CSV list of attributes', () => { + const result = getLdapDynamicValue(ldapUser, 'nonExistentField,email,uid'); + expect(result).to.equal('john.doe@example.com'); + }); + + it('should return undefined if none of the keys in CSV list exist', () => { + const result = getLdapDynamicValue(ldapUser, 'nonExistentField,anotherNonExistentField'); + expect(result).to.be.undefined; + }); + + it('should handle attribute keys with surrounding whitespace correctly', () => { + const result = getLdapDynamicValue(ldapUser, ' email '); + expect(result).to.equal('john.doe@example.com'); + }); + + it('should correctly resolve multiple variables in a template', () => { + const result = getLdapDynamicValue(ldapUser, 'User: #{displayName}, Email: #{email}, UID: #{uid}'); + expect(result).to.equal('User: John Doe, Email: john.doe@example.com, UID: johndoe'); + }); + + it('should return undefined if the attribute has an empty value', () => { + const result = getLdapDynamicValue(ldapUser, 'emptyField'); + expect(result).to.be.undefined; + }); + + it('should return an empty string if using only a template attribute that has an empty value', () => { + const result = getLdapDynamicValue(ldapUser, '#{emptyField}'); + expect(result).to.be.equal(''); + }); +}); diff --git a/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts new file mode 100644 index 0000000..a0f4c2e --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts @@ -0,0 +1,31 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; + +export function getLdapDynamicValue(ldapUser: ILDAPEntry, attributeSetting: string | undefined): string | undefined { + if (!attributeSetting) { + return; + } + + // If the attribute setting is a template, then convert the variables in it + if (attributeSetting.includes('#{')) { + return attributeSetting.replace(/#{(.+?)}/g, (_match, field) => { + const key = field.trim(); + + if (ldapKeyExists(ldapUser, key)) { + // We've already validated so it won't ever return undefined, but add a fallback to ensure it doesn't break if something gets changed + return getLdapString(ldapUser, key) || ''; + } + + return ''; + }); + } + + // If it's not a template, then treat the setting as a CSV list of possible attribute names and return the first valid one. + const attributeList: string[] = attributeSetting.replace(/\s/g, '').split(','); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); + if (key) { + return getLdapString(ldapUser, key); + } +} diff --git a/apps/meteor/server/lib/ldap/getLdapString.spec.ts b/apps/meteor/server/lib/ldap/getLdapString.spec.ts new file mode 100644 index 0000000..9f684a7 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapString.spec.ts @@ -0,0 +1,47 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import { getLdapString } from './getLdapString'; + +const ldapUser: ILDAPEntry = { + _raw: {}, + username: 'john_doe', + email: 'john.doe@example.com', + phoneNumber: '123-456-7890', + memberOf: 'group1,group2', +}; + +describe('getLdapString', () => { + it('should return the correct value for a given key', () => { + expect(getLdapString(ldapUser, 'username')).to.equal('john_doe'); + expect(getLdapString(ldapUser, 'email')).to.equal('john.doe@example.com'); + expect(getLdapString(ldapUser, 'phoneNumber')).to.equal('123-456-7890'); + expect(getLdapString(ldapUser, 'memberOf')).to.equal('group1,group2'); + }); + + it('should trim the key and return the correct value', () => { + expect(getLdapString(ldapUser, ' username ')).to.equal('john_doe'); + expect(getLdapString(ldapUser, ' email ')).to.equal('john.doe@example.com'); + }); + + it('should return undefined for non-existing keys', () => { + expect(getLdapString(ldapUser, 'nonExistingKey')).to.be.undefined; + expect(getLdapString(ldapUser, 'foo')).to.be.undefined; + }); + + it('should handle empty keys and return an empty string', () => { + expect(getLdapString(ldapUser, '')).to.be.undefined; + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + }); + + it('should handle keys with only whitespace', () => { + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + }); + + it('should handle case-sensitive keys accurately', () => { + expect(getLdapString(ldapUser, 'Username')).to.be.undefined; + expect(getLdapString(ldapUser, 'EMAIL')).to.be.undefined; + }); +}); diff --git a/apps/meteor/server/lib/ldap/getLdapString.ts b/apps/meteor/server/lib/ldap/getLdapString.ts new file mode 100644 index 0000000..caad9d2 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapString.ts @@ -0,0 +1,5 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +export function getLdapString(ldapUser: ILDAPEntry, key: string): string | undefined { + return ldapUser[key.trim()]; +} diff --git a/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts b/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts new file mode 100644 index 0000000..933c661 --- /dev/null +++ b/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts @@ -0,0 +1,94 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { ldapKeyExists } from './ldapKeyExists'; + +describe('ldapKeyExists', () => { + it('should return true when key exists and is not empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + mail: 'john.doe@example.com', + }; + + expect(ldapKeyExists(ldapUser, 'cn')).to.be.true; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.true; + }); + + it('should return false when key exists but is empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: '', + mail: '', + }; + + expect(ldapKeyExists(ldapUser, 'cn')).to.be.false; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.false; + }); + + it('should return false when key does not exist', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.false; + }); + + it('should trim the key before checking', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, ' cn ')).to.be.true; + expect(ldapKeyExists(ldapUser, ' mail ')).to.be.false; + }); + + it('should return false for empty keys', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, '')).to.be.false; + expect(ldapKeyExists(ldapUser, ' ')).to.be.false; + }); + + it('should handle keys with different casing', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + CN: 'John Doe', + }; + + expect(ldapKeyExists(ldapUser, 'CN')).to.be.true; + expect(ldapKeyExists(ldapUser, 'cn')).to.be.false; + }); + + // #TODO: We only work with strings so this doesn't matter, but why are numbers and booleans being considered "empty"? + it('should treat primitive non-string values as empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + numberValue: 123, + booleanValue: true, + anotherBooleanValue: false, + }; + + expect(ldapKeyExists(ldapUser, 'numberValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'booleanValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'anotherBooleanValue')).to.be.false; + }); + + it('should treat non-string values as empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + nullValue: null, + undefinedValue: undefined, + objectValue: {}, + arrayValue: [], + }; + + expect(ldapKeyExists(ldapUser, 'nullValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'undefinedValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'objectValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'arrayValue')).to.be.false; + }); +}); diff --git a/apps/meteor/server/lib/ldap/ldapKeyExists.ts b/apps/meteor/server/lib/ldap/ldapKeyExists.ts new file mode 100644 index 0000000..1b68b71 --- /dev/null +++ b/apps/meteor/server/lib/ldap/ldapKeyExists.ts @@ -0,0 +1,6 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import _ from 'underscore'; + +export function ldapKeyExists(ldapUser: ILDAPEntry, key: string): boolean { + return !_.isEmpty(ldapUser[key.trim()]); +} diff --git a/apps/meteor/server/lib/ldap/operations/executeOperation.ts b/apps/meteor/server/lib/ldap/operations/executeOperation.ts new file mode 100644 index 0000000..1508b7e --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/executeOperation.ts @@ -0,0 +1,27 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { executeFallback, type LDAPVariableFallback } from './fallback'; +import { executeMatch, type LDAPVariableMatch } from './match'; +import { executeReplace, type LDAPVariableReplace } from './replace'; +import { executeSplit, type LDAPVariableSplit } from './split'; +import { executeSubstring, type LDAPVariableSubString } from './substring'; + +export type LDAPVariableOperation = + LDAPVariableReplace | LDAPVariableMatch | LDAPVariableSubString | LDAPVariableFallback | LDAPVariableSplit; + +export function executeOperation(ldapUser: ILDAPEntry, input: string, operation?: LDAPVariableOperation): string | undefined { + switch (operation?.operation) { + case 'replace': + return executeReplace(input, operation); + case 'match': + return executeMatch(input, operation); + case 'substring': + return executeSubstring(input, operation); + case 'fallback': + return executeFallback(ldapUser, input, operation); + case 'split': + return executeSplit(input, operation); + } + + return input; +} diff --git a/apps/meteor/server/lib/ldap/operations/fallback.spec.ts b/apps/meteor/server/lib/ldap/operations/fallback.spec.ts new file mode 100644 index 0000000..8cbdc58 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/fallback.spec.ts @@ -0,0 +1,61 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableFallback } from './fallback'; +import { executeFallback } from './fallback'; + +describe('executeFallback function', () => { + const mockUser: ILDAPEntry = { + _raw: {}, + defaultFallback: 'defaultFallbackValue', + }; + + it('should return the input value when it is valid and no minLength is provided', () => { + const input = 'validInput'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal(input); + }); + + it('should return the input value when it is valid and meets minLength requirement', () => { + const input = 'validInput'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 5 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal(input); + }); + + it('should return fallback when input is invalid', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is too short', () => { + const input = 'short'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 10 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is undefined', () => { + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, undefined as any, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is an empty string and minLength is zero', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 0 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is an empty string and minLength is undefined', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: undefined }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/fallback.ts b/apps/meteor/server/lib/ldap/operations/fallback.ts new file mode 100644 index 0000000..ecd8c19 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/fallback.ts @@ -0,0 +1,24 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapDynamicValue } from '../getLdapDynamicValue'; + +export type LDAPVariableFallback = { + operation: 'fallback'; + fallback: string; + + minLength?: number; +}; + +export function executeFallback(ldapUser: ILDAPEntry, input: string, operation: LDAPVariableFallback): string | undefined { + let valid = Boolean(input); + + if (valid && typeof operation.minLength === 'number') { + valid = input.length >= operation.minLength; + } + + if (valid) { + return input; + } + + return getLdapDynamicValue(ldapUser, operation.fallback); +} diff --git a/apps/meteor/server/lib/ldap/operations/match.spec.ts b/apps/meteor/server/lib/ldap/operations/match.spec.ts new file mode 100644 index 0000000..ad1deb1 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/match.spec.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableMatch } from './match'; +import { executeMatch } from './match'; + +describe('executeMatch function', () => { + describe('Validation', () => { + it('throws an error when pattern is missing', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '', + regex: true, + }; + expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.'); + }); + + it('throws an error when neither valueIfTrue nor indexToUse is provided', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'pattern', + }; + expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.'); + }); + }); + + describe('Non-Regex Matching', () => { + it('returns valueIfTrue when input matches pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'hello', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + + it('returns valueIfFalse when input does not match pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'hello', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('world', operation)).to.be.equal('not matched'); + }); + }); + + describe('Regex Matching', () => { + it('returns valueIfTrue when input matches regex pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^hello$', + regex: true, + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + + it('returns valueIfFalse when input does not match regex pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^hello$', + regex: true, + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('world', operation)).to.be.equal('not matched'); + }); + + it('uses flags when provided', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^HELLO$', + regex: true, + flags: 'i', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + }); + + describe('IndexToUse', () => { + it('returns the matched group at indexToUse', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '(hello) (world)', + regex: true, + indexToUse: 1, + }; + expect(executeMatch('hello world', operation)).to.be.equal('hello'); + }); + + it('returns undefined when indexToUse is out of range', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '(hello)', + regex: true, + indexToUse: 2, + }; + expect(executeMatch('hello', operation)).to.be.undefined; + }); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/match.ts b/apps/meteor/server/lib/ldap/operations/match.ts new file mode 100644 index 0000000..422b134 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/match.ts @@ -0,0 +1,28 @@ +export type LDAPVariableMatch = { + operation: 'match'; + pattern: string; + regex?: boolean; + flags?: string; + indexToUse?: number; + valueIfTrue?: string; + valueIfFalse?: string; +}; + +export function executeMatch(input: string, operation: LDAPVariableMatch): string | undefined { + if (!operation.pattern || (typeof operation.valueIfTrue !== 'string' && typeof operation.indexToUse !== 'number')) { + throw new Error('Invalid MATCH operation.'); + } + + const pattern = operation.regex ? new RegExp(operation.pattern, operation.flags) : operation.pattern; + + const result = input.match(pattern); + if (!result) { + return operation.valueIfFalse; + } + + if (typeof operation.indexToUse === 'number' && result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return operation.valueIfTrue; +} diff --git a/apps/meteor/server/lib/ldap/operations/replace.spec.ts b/apps/meteor/server/lib/ldap/operations/replace.spec.ts new file mode 100644 index 0000000..b03bbf6 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/replace.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableReplace } from './replace'; +import { executeReplace } from './replace'; + +describe('executeReplace', () => { + describe('Validation', () => { + it('throws an error when pattern is missing', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: '', + replacement: 'new-value', + }; + expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.'); + }); + + it('throws an error when replacement is not a string', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old-value', + replacement: 123 as any, + }; + expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.'); + }); + }); + + describe('String Replacement', () => { + it('replaces the first occurrence of the pattern', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('replaces all occurrences of the pattern when `all` is true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + }); + + describe('Regex Replacement', () => { + it('replaces the first occurrence of the pattern', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('replaces all occurrences of the pattern when `regex` and `all` are true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + + it('uses the provided flags', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'OLD', + replacement: 'new', + regex: true, + flags: 'i', + }; + expect(executeReplace('OLD-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('adds the `g` flag when `all` is true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + + it('does not duplicate the `g` flag when already present', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + flags: 'g', + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/replace.ts b/apps/meteor/server/lib/ldap/operations/replace.ts new file mode 100644 index 0000000..49460a8 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/replace.ts @@ -0,0 +1,23 @@ +export type LDAPVariableReplace = { + operation: 'replace'; + pattern: string; + regex?: boolean; + flags?: string; + all?: boolean; + replacement: string; +}; + +export function executeReplace(input: string, operation: LDAPVariableReplace): string { + if (!operation.pattern || typeof operation.replacement !== 'string') { + throw new Error('Invalid REPLACE operation.'); + } + + const flags = operation.regex && operation.all ? `${operation.flags || ''}${operation.flags?.includes('g') ? '' : 'g'}` : operation.flags; + const pattern = operation.regex ? new RegExp(operation.pattern, flags) : operation.pattern; + + if (operation.all) { + return input.replaceAll(pattern, operation.replacement); + } + + return input.replace(pattern, operation.replacement); +} diff --git a/apps/meteor/server/lib/ldap/operations/split.spec.ts b/apps/meteor/server/lib/ldap/operations/split.spec.ts new file mode 100644 index 0000000..fa447a3 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/split.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableSplit } from './split'; +import { executeSplit } from './split'; + +describe('executeSplit function', () => { + it('should throw an error if the pattern is empty', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: '' }; + expect(() => executeSplit('input', operation)).to.throw('Invalid SPLIT operation.'); + }); + + it('should split the input string by the pattern and return the first element by default', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.equal('hello'); + }); + + it('should split the input string by the pattern and return the element at the specified index', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 1 }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.equal('world'); + }); + + it('should return undefined if the index is out of range', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 2 }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.undefined; + }); + + it('should return undefined if the input string does not contain the pattern', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('helloworld', operation); + expect(result).to.be.equal('helloworld'); + }); + + it('should return the first element if the input string is empty', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('', operation); + expect(result).to.be.equal(''); + }); + + it('should return undefined if the input string is undefined', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit(undefined as any, operation); + expect(result).to.be.undefined; + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/split.ts b/apps/meteor/server/lib/ldap/operations/split.ts new file mode 100644 index 0000000..e6c68e7 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/split.ts @@ -0,0 +1,30 @@ +export type LDAPVariableSplit = { + operation: 'split'; + pattern: string; + indexToUse?: number; +}; + +export function executeSplit(input: string, operation: LDAPVariableSplit): string | undefined { + if (!operation.pattern) { + throw new Error('Invalid SPLIT operation.'); + } + + if (!input) { + return input; + } + + const result = input.split(operation.pattern); + if (!result) { + return; + } + + if (typeof operation.indexToUse === 'number') { + if (result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return; + } + + return result.shift(); +} diff --git a/apps/meteor/server/lib/ldap/operations/substring.spec.ts b/apps/meteor/server/lib/ldap/operations/substring.spec.ts new file mode 100644 index 0000000..18e55b6 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/substring.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableSubString } from './substring'; +import { executeSubstring } from './substring'; + +describe('executeSubstring function', () => { + it('should throw an error if the start is missing', () => { + const operation: LDAPVariableSubString = { operation: 'substring' } as unknown as LDAPVariableSubString; + expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.'); + }); + + it('should throw an error if the start is invalid', () => { + const operation: LDAPVariableSubString = { operation: 'substring', start: 0, end: null } as unknown as LDAPVariableSubString; + expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.'); + }); + + it('should get the substring of the input, using the start param', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 6 }); + expect(result).to.be.equal('world'); + }); + + it('should get the whole string when the start is zero', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 0 }); + expect(result).to.be.equal('hello world'); + }); + + it('should get the substring of the input, using the start and end param', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 6, end: 8 }); + expect(result).to.be.equal('wo'); + }); + + it('should work backwards if end is smaller than start', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 5, end: 0 }); + expect(result).to.be.equal('hello'); + }); + + it('should get an empty string if start and end are the same', () => { + expect(executeSubstring('hello world', { operation: 'substring', start: 0, end: 0 })).to.be.equal(''); + expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: 5 })).to.be.equal(''); + }); + + it('should treat negative values as zero', () => { + expect(executeSubstring('hello world', { operation: 'substring', start: -4, end: 5 })).to.be.equal('hello'); + expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: -4 })).to.be.equal('hello'); + expect(executeSubstring('hello world', { operation: 'substring', start: -5, end: -4 })).to.be.equal(''); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/substring.ts b/apps/meteor/server/lib/ldap/operations/substring.ts new file mode 100644 index 0000000..3af2287 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/substring.ts @@ -0,0 +1,13 @@ +export type LDAPVariableSubString = { + operation: 'substring'; + start: number; + end?: number; +}; + +export function executeSubstring(input: string, operation: LDAPVariableSubString): string | undefined { + if (typeof operation.start !== 'number' || (operation.end !== undefined && typeof operation.end !== 'number')) { + throw new Error('Invalid SUBSTRING operation.'); + } + + return input.substring(operation.start, operation.end); +} diff --git a/apps/meteor/server/lib/ldap/processLdapVariables.ts b/apps/meteor/server/lib/ldap/processLdapVariables.ts new file mode 100644 index 0000000..4587bc6 --- /dev/null +++ b/apps/meteor/server/lib/ldap/processLdapVariables.ts @@ -0,0 +1,38 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { mapLogger } from './Logger'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { executeOperation, type LDAPVariableOperation } from './operations/executeOperation'; + +export type LDAPVariableConfiguration = { + input: string; + output?: LDAPVariableOperation; +}; +export type LDAPVariableMap = Record; + +export function processLdapVariables(entry: ILDAPEntry, variableMap: LDAPVariableMap): ILDAPEntry { + if (!variableMap || !Object.keys(variableMap).length) { + mapLogger.debug('No LDAP variables to process.'); + return entry; + } + + for (const variableName in variableMap) { + if (!variableMap.hasOwnProperty(variableName)) { + continue; + } + + const variableData = variableMap[variableName]; + if (!variableData?.input) { + continue; + } + + const input = getLdapDynamicValue(entry, variableData.input) || ''; + const output = executeOperation(entry, input, variableData.output) || ''; + + mapLogger.debug({ msg: 'Processed LDAP variable.', variableName, input, output }); + + entry[variableName] = output; + } + + return entry; +} diff --git a/apps/meteor/server/lib/logger/logPayloads.ts b/apps/meteor/server/lib/logger/logPayloads.ts new file mode 100644 index 0000000..8035521 --- /dev/null +++ b/apps/meteor/server/lib/logger/logPayloads.ts @@ -0,0 +1,41 @@ +import type { HonoRequest } from 'hono'; + +import { omit } from '../../../lib/utils/omit'; + +const { LOG_METHOD_PAYLOAD = 'false', LOG_REST_PAYLOAD = 'false', LOG_REST_METHOD_PAYLOADS = 'false' } = process.env; + +export const getMethodArgs = + LOG_METHOD_PAYLOAD === 'false' && LOG_REST_METHOD_PAYLOADS === 'false' + ? (): null => null + : (method: string, args: any[]): { arguments: any } => { + const params = method === 'ufsWrite' ? args.slice(1) : args; + + if (method === 'saveSettings') { + return { arguments: [args[0].map((arg: any) => omit(arg, 'value'))] }; + } + + if (method === 'saveSetting') { + return { arguments: [args[0], args[2]] }; + } + + return { + arguments: params.map((arg) => (typeof arg !== 'object' ? arg : omit(arg, 'password', 'msg', 'pass', 'username', 'message'))), + }; + }; + +export const getRestPayload = + LOG_REST_PAYLOAD === 'false' && LOG_REST_METHOD_PAYLOADS === 'false' + ? (): Promise => Promise.resolve(null) + : async (request: HonoRequest): Promise<{ payload: unknown } | null> => { + if (request.header('content-type')?.includes('multipart/form-data')) { + return { payload: '[multipart/form-data]' }; + } + + try { + return { + payload: await request.raw.clone().json(), + }; + } catch { + return { payload: null }; + } + }; diff --git a/apps/meteor/server/lib/logger/showBox.ts b/apps/meteor/server/lib/logger/showBox.ts new file mode 100644 index 0000000..a5125e3 --- /dev/null +++ b/apps/meteor/server/lib/logger/showBox.ts @@ -0,0 +1,47 @@ +import { createColors } from 'colorette'; + +import { lrpad } from '../../../lib/utils/stringUtils'; + +// force enable colors on dev env +const colors = createColors({ + useColor: process.env.NODE_ENV !== 'production', +}); + +type LogColors = 'white' | 'blue' | 'green' | 'magenta' | 'red'; + +function showBox(title: string, message: string, color?: LogColors): void { + const msgLines = message.split('\n'); + + const len = Math.max.apply( + null, + msgLines.map((line) => line.length), + ); + + const topLine = `+--${'-'.repeat(len)}--+`; + const separator = `| ${' '.repeat(len)} |`; + + const lines = []; + + lines.push(topLine); + if (title) { + lines.push(`| ${lrpad(title, len)} |`); + lines.push(topLine); + } + lines.push(separator); + + [...lines, ...msgLines.map((line) => `| ${line.padEnd(len)} |`), separator, topLine].forEach((line) => + console.log(color ? colors[color](line) : line), + ); +} + +export function showErrorBox(title: string, message: string): void { + showBox(title, message, 'red'); +} + +export function showSuccessBox(title: string, message: string): void { + showBox(title, message, 'green'); +} + +export function showWarningBox(title: string, message: string): void { + showBox(title, message, 'magenta'); +} diff --git a/apps/meteor/server/lib/logger/system.ts b/apps/meteor/server/lib/logger/system.ts new file mode 100644 index 0000000..a4ae954 --- /dev/null +++ b/apps/meteor/server/lib/logger/system.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const SystemLogger = new Logger('System'); diff --git a/apps/meteor/server/lib/media/assets/assets.ts b/apps/meteor/server/lib/media/assets/assets.ts new file mode 100644 index 0000000..933e0b7 --- /dev/null +++ b/apps/meteor/server/lib/media/assets/assets.ts @@ -0,0 +1,480 @@ +import crypto from 'node:crypto'; +import type { ServerResponse, IncomingMessage } from 'node:http'; + +import type { IRocketChatAssets, IRocketChatAsset, ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import type { NextHandleFunction } from 'connect'; +import sizeOf from 'image-size'; +import { Meteor } from 'meteor/meteor'; +import { WebApp, WebAppInternals } from 'meteor/webapp'; +import sharp from 'sharp'; + +import { getExtension } from '../../../../app/utils/lib/mimeTypes'; +import { settings, settingsRegistry } from '../../../settings'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { notifyOnSettingChangedById } from '../../notifyListener'; +import { getURL } from '../../utils/getURL'; +import { RocketChatFile } from '../file'; + +const RocketChatAssetsInstance = new RocketChatFile.GridFS({ + name: 'assets', +}); + +type IRocketChatAssetsConfig = Record }>; + +const assets: IRocketChatAssetsConfig = { + logo: { + label: 'logo (svg, png, jpg)', + defaultUrl: 'images/logo/logo.svg', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + }, + wizard: { + step: 3, + order: 2, + }, + }, + logo_dark: { + label: 'logo - dark theme (svg, png, jpg)', + defaultUrl: 'images/logo/logo_dark.svg', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + }, + }, + background: { + label: 'login background (svg, png, jpg)', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + }, + }, + background_dark: { + label: 'login background - dark theme (svg, png, jpg)', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + }, + }, + favicon_ico: { + label: 'favicon (ico)', + defaultUrl: 'favicon.ico', + constraints: { + type: 'image', + extensions: ['ico'], + }, + }, + favicon: { + label: 'favicon (svg)', + defaultUrl: 'images/logo/icon.svg', + constraints: { + type: 'image', + extensions: ['svg'], + }, + }, + favicon_16: { + label: 'favicon 16x16 (png)', + defaultUrl: 'images/logo/favicon-16x16.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 16, + height: 16, + }, + }, + favicon_32: { + label: 'favicon 32x32 (png)', + defaultUrl: 'images/logo/favicon-32x32.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 32, + height: 32, + }, + }, + favicon_192: { + label: 'android-chrome 192x192 (png)', + defaultUrl: 'images/logo/android-chrome-192x192.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 192, + height: 192, + }, + }, + favicon_512: { + label: 'android-chrome 512x512 (png)', + defaultUrl: 'images/logo/android-chrome-512x512.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 512, + height: 512, + }, + }, + touchicon_180: { + label: 'apple-touch-icon 180x180 (png)', + defaultUrl: 'images/logo/apple-touch-icon.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 180, + height: 180, + }, + }, + touchicon_180_pre: { + label: 'apple-touch-icon-precomposed 180x180 (png)', + defaultUrl: 'images/logo/apple-touch-icon-precomposed.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 180, + height: 180, + }, + }, + tile_70: { + label: 'mstile 70x70 (png)', + defaultUrl: 'images/logo/mstile-70x70.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 70, + height: 70, + }, + }, + tile_144: { + label: 'mstile 144x144 (png)', + defaultUrl: 'images/logo/mstile-144x144.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 144, + height: 144, + }, + }, + tile_150: { + label: 'mstile 150x150 (png)', + defaultUrl: 'images/logo/mstile-150x150.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 150, + height: 150, + }, + }, + tile_310_square: { + label: 'mstile 310x310 (png)', + defaultUrl: 'images/logo/mstile-310x310.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 310, + height: 310, + }, + }, + tile_310_wide: { + label: 'mstile 310x150 (png)', + defaultUrl: 'images/logo/mstile-310x150.png', + constraints: { + type: 'image', + extensions: ['png'], + width: 310, + height: 150, + }, + }, + safari_pinned: { + label: 'safari pinned tab (svg)', + defaultUrl: 'images/logo/safari-pinned-tab.svg', + constraints: { + type: 'image', + extensions: ['svg'], + }, + }, + livechat_widget_logo: { + label: 'widget logo (svg, png, jpg)', + constraints: { + type: 'image', + extensions: ['svg', 'png', 'jpg', 'jpeg'], + }, + settingOptions: { + section: 'Livechat', + group: 'Omnichannel', + invalidValue: { + defaultUrl: undefined, + }, + enableQuery: { _id: 'Livechat_enabled', value: true }, + enterprise: true, + modules: ['livechat-enterprise'], + sorter: 999 + 1, + }, + }, +}; + +function getAssetByKey(key: string) { + return assets[key as keyof IRocketChatAssets]; +} + +class RocketChatAssetsClass { + get assets(): IRocketChatAssets { + return assets; + } + + public async setAssetWithBuffer( + file: Buffer, + contentType: string, + asset: string, + ): Promise<{ + key: string; + value: IRocketChatAsset; + }> { + const assetInstance = getAssetByKey(asset); + if (!assetInstance) { + throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { + function: 'RocketChat.Assets.setAsset', + }); + } + + const extension = getExtension(contentType); + if (assetInstance.constraints.extensions.includes(extension) === false) { + throw new Meteor.Error('error-invalid-file-type', `Invalid file type: ${contentType}`, { + function: 'RocketChat.Assets.setAsset', + }); + } + + if (assetInstance.constraints.width || assetInstance.constraints.height) { + const dimensions = sizeOf(file); + if (assetInstance.constraints.width && assetInstance.constraints.width !== dimensions.width) { + throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', { + function: 'Invalid file width', + }); + } + if (assetInstance.constraints.height && assetInstance.constraints.height !== dimensions.height) { + throw new Meteor.Error('error-invalid-file-height'); + } + } + + const rs = RocketChatFile.bufferToStream(file); + await RocketChatAssetsInstance.deleteFile(asset); + + const ws = RocketChatAssetsInstance.createWriteStream(asset, contentType); + + return new Promise((resolve) => { + ws.on('end', () => { + return setTimeout(async () => { + const key = `Assets_${asset}`; + const value = { + url: `assets/${asset}.${extension}`, + defaultUrl: assetInstance.defaultUrl, + } as IRocketChatAsset; + + await RocketChatAssets.processAsset(key, value); + resolve({ + key, + value, + }); + }, 200); + }); + + rs.pipe(ws); + }); + } + + public async unsetAsset(asset: string) { + if (!getAssetByKey(asset)) { + throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { + function: 'RocketChat.Assets.unsetAsset', + }); + } + + await RocketChatAssetsInstance.deleteFile(asset); + const key = `Assets_${asset}`; + const value = { + defaultUrl: getAssetByKey(asset).defaultUrl, + }; + + await RocketChatAssets.processAsset(key, value); + + return { + key, + value, + }; + } + + public refreshClients(): boolean { + return process.emit('message', { + refresh: 'client', + }); + } + + public async processAsset(settingKey: string, settingValue: any): Promise | undefined> { + if (settingKey.indexOf('Assets_') !== 0) { + return; + } + + const assetKey = settingKey.replace(/^Assets_/, ''); + const assetValue = getAssetByKey(assetKey); + + if (!assetValue) { + return; + } + + if (!settingValue?.url) { + assetValue.cache = undefined; + return; + } + + const file = await RocketChatAssetsInstance.getFile(assetKey); + if (!file) { + assetValue.cache = undefined; + return; + } + + const hash = crypto.createHash('sha1').update(file.buffer).digest('hex'); + const extension = settingValue.url.split('.').pop(); + + assetValue.cache = { + path: `assets/${assetKey}.${extension}`, + cacheable: false, + sourceMapUrl: undefined, + where: 'client', + type: 'asset', + content: file.buffer, + extension, + url: `/assets/${assetKey}.${extension}?${hash}`, + size: file.length, + uploadDate: file.uploadDate, + contentType: file.contentType, + hash, + }; + + return assetValue.cache; + } + + public getURL(assetName: string, options = { cdn: false, full: true }): string { + const asset = settings.get(assetName); + const url = asset.url || asset.defaultUrl; + + return getURL(url as string, options); + } +} + +export const RocketChatAssets = new RocketChatAssetsClass(); + +export async function addAssetToSetting(asset: string, value: IRocketChatAsset, options?: Partial): Promise { + const key = `Assets_${asset}`; + + await settingsRegistry.add( + key, + { + defaultUrl: value.defaultUrl, + }, + { + type: 'asset', + group: 'Assets', + fileConstraints: value.constraints, + i18nLabel: value.label, + asset, + public: true, + ...options, + }, + ); + + const currentValue = settings.get(key); + + if (currentValue && typeof currentValue === 'object' && currentValue.defaultUrl !== getAssetByKey(asset).defaultUrl) { + currentValue.defaultUrl = getAssetByKey(asset).defaultUrl; + + (await Settings.updateValueById(key, currentValue)).modifiedCount && void notifyOnSettingChangedById(key); + } +} + +void (async () => { + for (const key of Object.keys(assets)) { + const { wizard, settingOptions, ...value } = getAssetByKey(key); + await addAssetToSetting(key, value, { ...settingOptions, wizard }); + } +})(); + +Meteor.startup(() => { + setTimeout(() => { + process.emit('message', { + refresh: 'client', + }); + }, 200); +}); + +export const refreshClients = async (userId: string) => { + if (!userId) { + throw new Error('Invalid user'); + } + + const _hasPermission = await hasPermissionAsync(userId, 'manage-assets'); + if (!_hasPermission) { + throw new Error('Managing assets not allowed'); + } + + return RocketChatAssets.refreshClients(); +}; + +const listener = (req: IncomingMessage, res: ServerResponse, next: NextHandleFunction) => { + if (!req.url) { + return; + } + const params = { + asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), + }; + + const asset = getAssetByKey(params.asset); + const file = asset?.cache; + + const format = req.url.replace(/.*\.([a-z]+)(?:$|\?.*)/i, '$1'); + + if (asset && Array.isArray(asset.constraints.extensions) && !asset.constraints.extensions.includes(format)) { + res.writeHead(403); + res.end(); + return; + } + if (!file) { + const defaultUrl = asset?.defaultUrl; + if (defaultUrl) { + const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; + req.url = `/${assetUrl}`; + WebAppInternals.staticFilesMiddleware((WebAppInternals as Record).staticFilesByArch, req, res, next); + } else { + res.writeHead(404); + res.end(); + } + + return; + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader) { + if (reqModifiedHeader === file.uploadDate?.toUTCString()) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); + return; + } + } + + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + + if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${format}`); + sharp(file.content) + .toFormat(format as any) + .pipe(res); + return; + } + + res.setHeader('Last-Modified', file.uploadDate?.toUTCString() || new Date().toUTCString()); + if (file.contentType) res.setHeader('Content-Type', file.contentType); + if (file.size) res.setHeader('Content-Length', file.size); + res.writeHead(200); + res.end(file.content); +}; + +WebApp.connectHandlers.use('/assets/', listener); diff --git a/apps/meteor/server/lib/media/assets/index.ts b/apps/meteor/server/lib/media/assets/index.ts new file mode 100644 index 0000000..6802fad --- /dev/null +++ b/apps/meteor/server/lib/media/assets/index.ts @@ -0,0 +1 @@ +export { RocketChatAssets, refreshClients } from './assets'; diff --git a/apps/meteor/server/lib/media/custom-sounds/lib/deleteCustomSound.ts b/apps/meteor/server/lib/media/custom-sounds/lib/deleteCustomSound.ts new file mode 100644 index 0000000..ec57211 --- /dev/null +++ b/apps/meteor/server/lib/media/custom-sounds/lib/deleteCustomSound.ts @@ -0,0 +1,20 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const deleteCustomSound = async (_id: string): Promise => { + const sound = await CustomSounds.findOneById(_id); + + if (!sound) { + throw new Meteor.Error('Custom_Sound_Error_Invalid_Sound', 'Invalid sound', { + method: 'deleteCustomSound', + }); + } + + await RocketChatFileCustomSoundsInstance.deleteFile(`${sound._id}.${sound.extension}`); + await CustomSounds.removeById(_id); + + void api.broadcast('notify.deleteCustomSound', { soundData: sound }); +}; diff --git a/apps/meteor/server/lib/media/custom-sounds/lib/insertOrUpdateSound.ts b/apps/meteor/server/lib/media/custom-sounds/lib/insertOrUpdateSound.ts new file mode 100644 index 0000000..b20eddb --- /dev/null +++ b/apps/meteor/server/lib/media/custom-sounds/lib/insertOrUpdateSound.ts @@ -0,0 +1,61 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import type { ICustomSoundData } from '../../../../meteor-methods/media/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise => { + // silently strip colon; this allows for uploading :soundname: as soundname + soundData.name = (soundData.name || '').replace(/:/g, ''); + + if (!soundData.name.trim()) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateSound', + field: 'Name', + }); + } + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + + if (nameValidation.test(soundData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { + method: 'insertOrUpdateSound', + input: soundData.name, + field: 'Name', + }); + } + + const conflictingSound = await CustomSounds.findOneByName(soundData.name, soundData._id, { projection: { _id: 1 } }); + + if (conflictingSound) { + throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { + method: 'insertOrUpdateSound', + }); + } + + if (!soundData._id) { + return ( + await CustomSounds.create({ + name: soundData.name, + extension: soundData.extension, + }) + ).insertedId; + } + + if (soundData.newFile) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + if (soundData.name !== soundData.previousName || soundData.extension !== soundData.previousExtension) { + await CustomSounds.updateById(soundData._id, { name: soundData.name, extension: soundData.extension }); + const updatedSound = await CustomSounds.findOneById(soundData._id); + if (updatedSound) { + void api.broadcast('notify.updateCustomSound', { soundData: updatedSound }); + } + } + + return soundData._id; +}; diff --git a/apps/meteor/server/lib/media/custom-sounds/lib/uploadCustomSound.ts b/apps/meteor/server/lib/media/custom-sounds/lib/uploadCustomSound.ts new file mode 100644 index 0000000..aaa237b --- /dev/null +++ b/apps/meteor/server/lib/media/custom-sounds/lib/uploadCustomSound.ts @@ -0,0 +1,44 @@ +import { api } from '@rocket.chat/core-services'; +import type { RequiredField } from '@rocket.chat/core-typings'; +import { CustomSounds } from '@rocket.chat/models'; + +import type { ICustomSoundData } from '../../../../meteor-methods/media/insertOrUpdateSound'; +import { RocketChatFile } from '../../file'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const uploadCustomSound = async ( + buffer: Buffer, + contentType: string, + soundData: RequiredField, +): Promise => { + const rs = RocketChatFile.bufferToStream(buffer); + + if (soundData.previousExtension) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + return new Promise((resolve, reject) => { + const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); + + ws.on('error', (err: Error) => { + reject(err); + }); + + rs.on('error', (err: Error) => { + ws.destroy(); + reject(err); + }); + + ws.on('end', () => { + setTimeout(async () => { + const sound = await CustomSounds.findOneById(soundData._id); + if (sound) { + void api.broadcast('notify.updateCustomSound', { soundData: sound }); + } + }, 500); + resolve(); + }); + + rs.pipe(ws); + }); +}; diff --git a/apps/meteor/server/lib/media/custom-sounds/startup/custom-sounds.js b/apps/meteor/server/lib/media/custom-sounds/startup/custom-sounds.js new file mode 100644 index 0000000..e1f68ed --- /dev/null +++ b/apps/meteor/server/lib/media/custom-sounds/startup/custom-sounds.js @@ -0,0 +1,104 @@ +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '../../../../settings'; +import { SystemLogger } from '../../../logger/system'; +import { RocketChatFile } from '../../file'; + +export let RocketChatFileCustomSoundsInstance; + +const initializeCustomSoundsStorage = () => { + let storeType = 'GridFS'; + + if (settings.get('CustomSounds_Storage_Type')) { + storeType = settings.get('CustomSounds_Storage_Type'); + } + + const RocketChatStore = RocketChatFile[storeType]; + + if (RocketChatStore == null) { + throw new Error(`Invalid RocketChatStore type [${storeType}]`); + } + + SystemLogger.info({ + msg: 'Using custom sounds storage', + storeType, + }); + + let path = '~/uploads'; + if (settings.get('CustomSounds_FileSystemPath') != null) { + if (settings.get('CustomSounds_FileSystemPath').trim() !== '') { + path = settings.get('CustomSounds_FileSystemPath'); + } + } + + RocketChatFileCustomSoundsInstance = new RocketChatStore({ + name: 'custom_sounds', + absolutePath: path, + }); +}; + +Meteor.startup(() => { + initializeCustomSoundsStorage(); + return WebApp.connectHandlers.use('/custom-sounds/', async (req, res /* , next*/) => { + const fileId = decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')); + + if (!fileId) { + res.writeHead(403); + res.write('Forbidden'); + res.end(); + return; + } + + const sound = await CustomSounds.findOneById(fileId.split('.')[0], { projection: { _id: 1 } }); + + if (!sound) { + res.writeHead(404); + res.write('Not found'); + res.end(); + return; + } + + const file = await RocketChatFileCustomSoundsInstance.getFileWithReadStream(fileId); + + if (!file) { + res.writeHead(404); + res.write('Not found'); + res.end(); + return; + } + + res.setHeader('Content-Disposition', 'inline'); + + let fileUploadDate = undefined; + if (file.uploadDate != null) { + fileUploadDate = file.uploadDate.toUTCString(); + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader != null) { + if (reqModifiedHeader === fileUploadDate) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); + return; + } + } + + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + if (fileUploadDate != null) { + res.setHeader('Last-Modified', fileUploadDate); + } else { + res.setHeader('Last-Modified', new Date().toUTCString()); + } + + res.setHeader('Content-Type', file.contentType); + res.setHeader('Content-Length', file.length); + + file.readStream.pipe(res); + }); +}); + +settings.watchMultiple(['CustomSounds_Storage_Type', 'CustomSounds_FileSystemPath'], initializeCustomSoundsStorage); diff --git a/apps/meteor/server/lib/media/emoji-custom/lib/insertOrUpdateEmoji.ts b/apps/meteor/server/lib/media/emoji-custom/lib/insertOrUpdateEmoji.ts new file mode 100644 index 0000000..ecb5d13 --- /dev/null +++ b/apps/meteor/server/lib/media/emoji-custom/lib/insertOrUpdateEmoji.ts @@ -0,0 +1,141 @@ +import { api } from '@rocket.chat/core-services'; +import { EmojiCustom } from '@rocket.chat/models'; +import limax from 'limax'; +import { Meteor } from 'meteor/meteor'; + +import { trim } from '../../../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../../../authorization/hasPermission'; +import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; + +export type EmojiData = { + _id?: string; + name: string; + aliases?: string; + extension: string; + previousName?: string; + previousExtension?: string; + newFile?: boolean; +}; + +type EmojiDataWithParsedAliases = Omit & { _id: string; aliases: string[] }; + +export async function insertOrUpdateEmoji(userId: string | null, emojiData: EmojiData): Promise { + if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) { + throw new Meteor.Error('not_authorized'); + } + + if (!trim(emojiData.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateEmoji', + field: 'Name', + }); + } + + emojiData.name = limax(emojiData.name, { replacement: '_' }); + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + const aliasValidation = /[:><&\|"'\/\\\(\)]/; + + // silently strip colon; this allows for uploading :emojiname: as emojiname + emojiData.name = emojiData.name.replace(/:/g, ''); + emojiData.aliases = emojiData.aliases?.replace(/:/g, ''); + + if (nameValidation.test(emojiData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.name} is not a valid name`, { + method: 'insertOrUpdateEmoji', + input: emojiData.name, + field: 'Name', + }); + } + + let aliases: string[] = []; + if (emojiData.aliases) { + if (aliasValidation.test(emojiData.aliases)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.aliases} is not a valid alias set`, { + method: 'insertOrUpdateEmoji', + input: emojiData.aliases, + field: 'Alias_Set', + }); + } + aliases = emojiData.aliases + .split(/\s*,\s*/) + .filter(Boolean) + .map((alias) => limax(alias, { replacement: '_' })) + .filter((alias) => alias !== emojiData.name); + } + + emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; + + const conflictingEmoji = await EmojiCustom.findOneByNamesOrAliases([emojiData.name, ...aliases], emojiData._id, { + projection: { _id: 1 }, + }); + + if (conflictingEmoji) { + throw new Meteor.Error('Custom_Emoji_Error_Name_Or_Alias_Already_In_Use', 'The custom emoji or one of its aliases is already in use', { + method: 'insertOrUpdateEmoji', + }); + } + + if (typeof emojiData.extension === 'undefined') { + throw new Meteor.Error('error-the-field-is-required', 'The custom emoji file is required', { + method: 'insertOrUpdateEmoji', + }); + } + + if (!emojiData._id) { + // insert emoji + const createEmoji = { + name: emojiData.name, + aliases, + extension: emojiData.extension, + }; + + const _id = (await EmojiCustom.create(createEmoji)).insertedId; + + void api.broadcast('emoji.updateCustom', createEmoji); + + return { ...emojiData, ...createEmoji, _id }; + } + + // update emoji + if (emojiData.newFile) { + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.extension}`)); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)); + + await EmojiCustom.setExtension(emojiData._id, emojiData.extension); + } else if (emojiData.name !== emojiData.previousName) { + const rs = await RocketChatFileEmojiCustomInstance.getFileWithReadStream( + encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`), + ); + + if (rs) { + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + const ws = RocketChatFileEmojiCustomInstance.createWriteStream( + encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`), + rs.contentType, + ); + ws.on('end', () => + RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)), + ); + rs.readStream.pipe(ws); + } + } + + if (emojiData.name !== emojiData.previousName) { + await EmojiCustom.setName(emojiData._id, emojiData.name); + } + + if (emojiData.aliases) { + await EmojiCustom.setAliases(emojiData._id, aliases); + } else { + await EmojiCustom.setAliases(emojiData._id, []); + } + + void api.broadcast('emoji.updateCustom', { ...emojiData, aliases }); + + return { ...emojiData, aliases } as EmojiDataWithParsedAliases; +} diff --git a/apps/meteor/server/lib/media/emoji-custom/lib/uploadEmojiCustom.ts b/apps/meteor/server/lib/media/emoji-custom/lib/uploadEmojiCustom.ts new file mode 100644 index 0000000..e39405c --- /dev/null +++ b/apps/meteor/server/lib/media/emoji-custom/lib/uploadEmojiCustom.ts @@ -0,0 +1,82 @@ +import { api, Media } from '@rocket.chat/core-services'; +import { EmojiCustom } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import limax from 'limax'; +import { Meteor } from 'meteor/meteor'; +import sharp from 'sharp'; + +import type { EmojiData } from './insertOrUpdateEmoji'; +import { hasPermissionAsync } from '../../../authorization/hasPermission'; +import { RocketChatFile } from '../../file'; +import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; + +const getFile = async (file: Buffer, extension: string) => { + if (extension !== 'svg+xml') { + return file; + } + + return sharp(file).png().toBuffer(); +}; + +export type EmojiDataWithAliases = Omit & { aliases?: string | string[] }; + +export async function uploadEmojiCustom( + userId: string | null, + binaryContent: string, + contentType: string, + emojiData: EmojiDataWithAliases, +) { + return uploadEmojiCustomWithBuffer(userId, Buffer.from(binaryContent, 'binary'), contentType, emojiData); +} + +export async function uploadEmojiCustomWithBuffer( + userId: string | null, + buffer: Buffer, + contentType: string, + emojiData: EmojiDataWithAliases, +): Promise { + // technically, since this method doesnt have any datatype validations, users can + // upload videos as emojis. The FE won't play them, but they will waste space for sure. + if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) { + throw new Meteor.Error('not_authorized'); + } + + if (!Array.isArray(emojiData.aliases)) { + // delete aliases for notification purposes. here, it is a string or undefined rather than an array + delete emojiData.aliases; + } + + emojiData.name = limax(emojiData.name, { replacement: '_' }); + + const file = await getFile(buffer, emojiData.extension); + emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension; + + let fileBuffer; + // sharp doesn't support these formats without imagemagick or libvips installed + // so they will be stored as they are :( + if (['gif', 'x-icon', 'bmp', 'webm'].includes(emojiData.extension)) { + fileBuffer = file; + } else { + // This is to support the idea of having "sticker-like" emojis + const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 512, 512, true, false, false, 'inside'); + fileBuffer = resizedEmojiBuffer; + } + + const rs = RocketChatFile.bufferToStream(fileBuffer); + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`)); + + return new Promise((resolve) => { + const ws = RocketChatFileEmojiCustomInstance.createWriteStream( + encodeURIComponent(`${emojiData.name}.${emojiData.extension}`), + contentType, + ); + ws.on('end', async () => { + const etag = Random.hexString(6); + await EmojiCustom.setETagByName(emojiData.name, etag); + setTimeout(() => api.broadcast('emoji.updateCustom', { ...emojiData, etag }), 500); + resolve(); + }); + + rs.pipe(ws); + }); +} diff --git a/apps/meteor/server/lib/media/emoji-custom/startup/emoji-custom.js b/apps/meteor/server/lib/media/emoji-custom/startup/emoji-custom.js new file mode 100644 index 0000000..394fee3 --- /dev/null +++ b/apps/meteor/server/lib/media/emoji-custom/startup/emoji-custom.js @@ -0,0 +1,125 @@ +import { EmojiCustom } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import _ from 'underscore'; + +import { settings } from '../../../../settings'; +import { SystemLogger } from '../../../logger/system'; +import { RocketChatFile } from '../../file'; + +export let RocketChatFileEmojiCustomInstance; + +const writeSvgFallback = (res, req) => { + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); + res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader != null) { + if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') { + res.writeHead(304); + res.end(); + return; + } + } + + const color = '#000'; + const initials = '?'; + + const svg = ` + + + ${initials} + +`; + + res.write(svg); + res.end(); +}; + +const initializeEmojiCustomStorage = () => { + let storeType = 'GridFS'; + + if (settings.get('EmojiUpload_Storage_Type')) { + storeType = settings.get('EmojiUpload_Storage_Type'); + } + + const RocketChatStore = RocketChatFile[storeType]; + + if (RocketChatStore == null) { + throw new Error(`Invalid RocketChatStore type [${storeType}]`); + } + + SystemLogger.info({ + msg: 'Using custom emoji storage', + storeType, + }); + + let path = '~/uploads'; + if (settings.get('EmojiUpload_FileSystemPath') != null) { + if (settings.get('EmojiUpload_FileSystemPath').trim() !== '') { + path = settings.get('EmojiUpload_FileSystemPath'); + } + } + + RocketChatFileEmojiCustomInstance = new RocketChatStore({ + name: 'custom_emoji', + absolutePath: path, + }); +}; + +Meteor.startup(() => { + initializeEmojiCustomStorage(); + return WebApp.connectHandlers.use('/emoji-custom/', async (req, res /* , next*/) => { + const params = { emoji: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')) }; + + if (_.isEmpty(params.emoji)) { + res.writeHead(403); + res.write('Forbidden'); + res.end(); + return; + } + + res.setHeader('Content-Disposition', 'inline'); + + const emoji = await EmojiCustom.findOneByName(params.emoji.split('.')[0], { projection: { _id: 1 } }); + + if (!emoji) { + return writeSvgFallback(res, req); + } + + const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji)); + + if (!file) { + // use code from username initials renderer until file upload is complete + return writeSvgFallback(res, req); + } + + const fileUploadDate = file.uploadDate != null ? file.uploadDate.toUTCString() : undefined; + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader != null && reqModifiedHeader === fileUploadDate) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); + return; + } + + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Last-Modified', fileUploadDate || new Date().toUTCString()); + res.setHeader('Content-Length', file.length); + + if (/^svg$/i.test(params.emoji.split('.').pop())) { + res.setHeader('Content-Type', 'image/svg+xml'); + } else if (/^png$/i.test(params.emoji.split('.').pop())) { + res.setHeader('Content-Type', 'image/png'); + } else { + res.setHeader('Content-Type', 'image/jpeg'); + } + + file.readStream.pipe(res); + }); +}); + +settings.watchMultiple(['EmojiUpload_Storage_Type', 'EmojiUpload_FileSystemPath'], initializeEmojiCustomStorage); diff --git a/apps/meteor/server/lib/media/emoji-native/callbacks.ts b/apps/meteor/server/lib/media/emoji-native/callbacks.ts new file mode 100644 index 0000000..9c49341 --- /dev/null +++ b/apps/meteor/server/lib/media/emoji-native/callbacks.ts @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +import { shortnameToUnicode } from '../../../../app/emoji-native/lib/shortnameToUnicode'; +import { callbacks } from '../../callbacks'; + +Meteor.startup(() => { + callbacks.add( + 'beforeSendMessageNotifications', + (message) => shortnameToUnicode(message), + callbacks.priority.MEDIUM, + 'native-emoji-shortnameToUnicode', + ); +}); diff --git a/apps/meteor/server/lib/media/emoji-native/lib.ts b/apps/meteor/server/lib/media/emoji-native/lib.ts new file mode 100644 index 0000000..49f449f --- /dev/null +++ b/apps/meteor/server/lib/media/emoji-native/lib.ts @@ -0,0 +1,42 @@ +import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig'; +import { legacyEmojioneMap } from '../../../../app/emoji-native/lib/legacyEmojioneMap'; +import { emoji } from '../../messaging/emoji'; + +const config = getEmojiConfig(emoji); + +emoji.packages.native = { + emojiCategories: config.emojiCategories as typeof emoji.packages.native.emojiCategories, + emojisByCategory: config.emojisByCategory, + toneList: config.toneList, + render: config.render, + renderPicker: config.renderPicker, + sprites: config.sprites, +}; + +for (const [key, currentEmoji] of Object.entries(config.emojiList)) { + currentEmoji.emojiPackage = 'native'; + emoji.list[key] = currentEmoji; + + if (currentEmoji.shortnames) { + currentEmoji.shortnames.forEach((shortname: string) => { + emoji.list[shortname] = currentEmoji; + }); + } +} + +// Register legacy emojione shortcodes so old reactions and stored shortcodes resolve correctly +for (const [shortcode, unicode] of Object.entries(legacyEmojioneMap)) { + const key = `:${shortcode}:`; + if (emoji.list[key]) continue; // already registered by emojibase + + emoji.list[key] = { + uc_base: '', + uc_output: '', + uc_match: '', + uc_greedy: '', + shortnames: [], + category: '', + emojiPackage: 'native', + unicode, + }; +} diff --git a/apps/meteor/server/lib/media/file-upload/config/AmazonS3.ts b/apps/meteor/server/lib/media/file-upload/config/AmazonS3.ts new file mode 100644 index 0000000..19ed48d --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/AmazonS3.ts @@ -0,0 +1,129 @@ +import http from 'node:http'; +import https from 'node:https'; + +import _ from 'underscore'; + +import { forceDownload } from './helper'; +import { settings } from '../../../../settings'; +import { SystemLogger } from '../../../logger/system'; +import { FileUploadClass, FileUpload } from '../lib/FileUpload'; +import type { S3Options } from '../ufs/AmazonS3/server'; +import '../ufs/AmazonS3/server'; + +const hasScheme = (value: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value); + +const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, req, res) { + const forcedDownload = forceDownload(req); + + const fileUrl = await this.store.getRedirectURL(file, forcedDownload); + if (!fileUrl || !file.store) { + res.end(); + return; + } + + const storeType = file.store.split(':').pop(); + if (settings.get(`FileUpload_S3_Proxy_${storeType}`)) { + const request = /^https:/.test(fileUrl) ? https : http; + + FileUpload.proxyFile(file.name || '', fileUrl, forcedDownload, request, req, res); + return; + } + + FileUpload.redirectToFile(fileUrl, req, res); +}; + +const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { + const fileUrl = await this.store.getRedirectURL(file); + if (!fileUrl) { + out.end(); + return; + } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); +}; + +const AmazonS3Uploads = new FileUploadClass({ + name: 'AmazonS3:Uploads', + get, + copy, + // store setted bellow +}); + +const AmazonS3Avatars = new FileUploadClass({ + name: 'AmazonS3:Avatars', + get, + copy, + // store setted bellow +}); + +const AmazonS3UserDataFiles = new FileUploadClass({ + name: 'AmazonS3:UserDataFiles', + get, + copy, + // store setted bellow +}); + +const configure = _.debounce(() => { + const Bucket = settings.get('FileUpload_S3_Bucket'); + const Acl = settings.get('FileUpload_S3_Acl'); + const AWSAccessKeyId = settings.get('FileUpload_S3_AWSAccessKeyId'); + const AWSSecretAccessKey = settings.get('FileUpload_S3_AWSSecretAccessKey'); + const URLExpiryTimeSpan = settings.get('FileUpload_S3_URLExpiryTimeSpan'); + const Region = settings.get('FileUpload_S3_Region'); + const ForcePathStyle = settings.get('FileUpload_S3_ForcePathStyle'); + // const CDN = RocketChat.settings.get('FileUpload_S3_CDN'); + const BucketURL = settings.get('FileUpload_S3_BucketURL'); + + if (!Bucket) { + return; + } + + const config: Omit = { + connection: { + forcePathStyle: ForcePathStyle, + followRegionRedirects: true, + }, + params: { + Bucket, + ACL: Acl, + }, + URLExpiryTimeSpan, + }; + + if (Region) { + config.connection.region = Region; + } + + // Back-compat: AWS SDK v2 defaulted unset region to us-east-1; v3 throws. + if (!Region && !process.env.AWS_REGION) { + config.connection.region = 'us-east-1'; + SystemLogger.warn( + 'FileUpload_S3_Region is empty and AWS_REGION is not set; defaulting to us-east-1. Set FileUpload_S3_Region or AWS_REGION to silence this warning.', + ); + } + + if (AWSAccessKeyId && AWSSecretAccessKey) { + config.connection.credentials = { + accessKeyId: AWSAccessKeyId, + secretAccessKey: AWSSecretAccessKey, + }; + } + + // Back-compat: AWS SDK v2 accepted scheme-less endpoints; v3 throws. + if (BucketURL) { + const isValidScheme = hasScheme(BucketURL); + config.connection.endpoint = isValidScheme ? BucketURL : `https://${BucketURL}`; + if (!isValidScheme) { + SystemLogger.warn(`FileUpload_S3_BucketURL "${BucketURL}" has no scheme; defaulting to "https://${BucketURL}".`); + } + } + + AmazonS3Uploads.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3Uploads.name, config); + AmazonS3Avatars.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3Avatars.name, config); + AmazonS3UserDataFiles.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3UserDataFiles.name, config); +}, 500); + +settings.watchByRegex(/^FileUpload_S3_/, configure); diff --git a/apps/meteor/server/lib/media/file-upload/config/FileSystem.ts b/apps/meteor/server/lib/media/file-upload/config/FileSystem.ts new file mode 100644 index 0000000..eabb89d --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/FileSystem.ts @@ -0,0 +1,144 @@ +import fsp from 'node:fs/promises'; + +import { getContentDisposition } from './helper'; +import { settings } from '../../../../settings'; +import { UploadFS } from '../../../../ufs'; +import { FileUploadClass, FileUpload } from '../lib/FileUpload'; +import { getFileRange, setRangeHeaders } from '../lib/ranges'; + +const FileSystemUploads = new FileUploadClass({ + name: 'FileSystem:Uploads', + // store setted bellow + + async get(file, req, res) { + if (!this.store || !file) { + return; + } + const filePath = await this.store.getFilePath(file._id, file); + + const options: { start?: number; end?: number } = {}; + + try { + const stat = await fsp.stat(filePath); + if (!stat?.isFile()) { + res.writeHead(404); + res.end(); + return; + } + + file = FileUpload.addExtensionTo(file); + + res.setHeader('Content-Disposition', `${getContentDisposition(req)}; filename*=UTF-8''${encodeURIComponent(file.name || '')}`); + file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); + res.setHeader('Content-Type', file.type || 'application/octet-stream'); + res.setHeader('Accept-Ranges', 'bytes'); + + if (req.headers.range) { + const range = getFileRange(file, req); + + if (range) { + setRangeHeaders(range, file, res); + if (range.outOfRange) { + return; + } + options.start = range.start; + options.end = range.stop; + } + } + + // set content-length if range has not set + if (!res.getHeader('Content-Length')) { + res.setHeader('Content-Length', file.size || 0); + } + + (await this.store.getReadStream(file._id, file, options)).pipe(res); + } catch (e) { + res.writeHead(404); + res.end(); + } + }, + + async copy(file, out) { + if (!this.store) { + return; + } + const filePath = await this.store.getFilePath(file._id, file); + try { + const stat = await fsp.stat(filePath); + + if (stat?.isFile()) { + file = FileUpload.addExtensionTo(file); + + (await this.store.getReadStream(file._id, file)).pipe(out); + } + } catch (e) { + out.end(); + } + }, +}); + +const FileSystemAvatars = new FileUploadClass({ + name: 'FileSystem:Avatars', + // store setted bellow + + async get(file, _req, res) { + if (!this.store) { + return; + } + const filePath = await this.store.getFilePath(file._id, file); + + try { + const stat = await fsp.stat(filePath); + + if (stat?.isFile()) { + file = FileUpload.addExtensionTo(file); + + (await this.store.getReadStream(file._id, file)).pipe(res); + } + } catch (e) { + res.writeHead(404); + res.end(); + } + }, +}); + +const FileSystemUserDataFiles = new FileUploadClass({ + name: 'FileSystem:UserDataFiles', + + async get(file, _req, res) { + if (!this.store) { + return; + } + const filePath = await this.store.getFilePath(file._id, file); + + try { + const stat = await fsp.stat(filePath); + + if (stat?.isFile()) { + file = FileUpload.addExtensionTo(file); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.name || '')}`); + file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); + res.setHeader('Content-Type', file.type || ''); + res.setHeader('Content-Length', file.size || 0); + + (await this.store.getReadStream(file._id, file)).pipe(res); + } + } catch (e) { + res.writeHead(404); + res.end(); + } + }, +}); + +settings.watch('FileUpload_FileSystemPath', () => { + const options = { + path: settings.get('FileUpload_FileSystemPath'), // '/tmp/uploads/photos', + }; + + FileSystemUploads.store = FileUpload.configureUploadsStore('Local', FileSystemUploads.name, options); + FileSystemAvatars.store = FileUpload.configureUploadsStore('Local', FileSystemAvatars.name, options); + FileSystemUserDataFiles.store = FileUpload.configureUploadsStore('Local', FileSystemUserDataFiles.name, options); + + // DEPRECATED backwards compatibility (remove) + UploadFS.getStores().fileSystem = UploadFS.getStores()[FileSystemUploads.name]; +}); diff --git a/apps/meteor/server/lib/media/file-upload/config/GoogleStorage.ts b/apps/meteor/server/lib/media/file-upload/config/GoogleStorage.ts new file mode 100644 index 0000000..3095190 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/GoogleStorage.ts @@ -0,0 +1,94 @@ +import http from 'node:http'; +import https from 'node:https'; + +import _ from 'underscore'; + +import { forceDownload } from './helper'; +import { settings } from '../../../../settings'; +import { FileUploadClass, FileUpload } from '../lib/FileUpload'; +import '../ufs/GoogleStorage/server'; + +const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, req, res) { + const forcedDownload = forceDownload(req); + + const fileUrl = await this.store.getRedirectURL(file, forcedDownload); + if (!fileUrl || !file.store) { + res.end(); + return; + } + + const storeType = file.store.split(':').pop(); + if (settings.get(`FileUpload_GoogleStorage_Proxy_${storeType}`)) { + const request = /^https:/.test(fileUrl) ? https : http; + + FileUpload.proxyFile(file.name || '', fileUrl, forcedDownload, request, req, res); + return; + } + + FileUpload.redirectToFile(fileUrl, req, res); +}; + +const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { + const fileUrl = await this.store.getRedirectURL(file, false); + + if (!fileUrl) { + out.end(); + return; + } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); +}; + +const GoogleCloudStorageUploads = new FileUploadClass({ + name: 'GoogleCloudStorage:Uploads', + get, + copy, + // store setted bellow +}); + +const GoogleCloudStorageAvatars = new FileUploadClass({ + name: 'GoogleCloudStorage:Avatars', + get, + copy, + // store setted bellow +}); + +const GoogleCloudStorageUserDataFiles = new FileUploadClass({ + name: 'GoogleCloudStorage:UserDataFiles', + get, + copy, + // store setted bellow +}); + +const configure = _.debounce(() => { + const bucket = settings.get('FileUpload_GoogleStorage_Bucket'); + const projectId = settings.get('FileUpload_GoogleStorage_ProjectId'); + const accessId = settings.get('FileUpload_GoogleStorage_AccessId'); + const secret = settings.get('FileUpload_GoogleStorage_Secret'); + const URLExpiryTimeSpan = settings.get('FileUpload_GoogleStorage_URLExpiryTimeSpan'); + + if (!bucket || !accessId || !secret) { + return; + } + + const config = { + connection: { + credentials: { + client_email: accessId, + private_key: secret, + }, + projectId, + }, + bucket, + URLExpiryTimeSpan, + }; + + GoogleCloudStorageUploads.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageUploads.name, config); + GoogleCloudStorageAvatars.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageAvatars.name, config); + GoogleCloudStorageUserDataFiles.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageUserDataFiles.name, config); +}, 500); + +settings.watchByRegex(/^FileUpload_GoogleStorage_/, configure); diff --git a/apps/meteor/server/lib/media/file-upload/config/GridFS.ts b/apps/meteor/server/lib/media/file-upload/config/GridFS.ts new file mode 100644 index 0000000..933767c --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/GridFS.ts @@ -0,0 +1,211 @@ +import type * as http from 'node:http'; +import type { TransformCallback, TransformOptions } from 'node:stream'; +import stream from 'node:stream'; +import zlib from 'node:zlib'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; + +import { getContentDisposition } from './helper'; +import { UploadFS } from '../../../../ufs'; +import { FileUploadClass, FileUpload } from '../lib/FileUpload'; +import { getFileRange, setRangeHeaders } from '../lib/ranges'; + +const logger = new Logger('FileUpload'); + +class ExtractRange extends stream.Transform { + private start: number; + + private stop: number; + + private bytes_read: number; + + constructor(options: TransformOptions & { start: number; stop: number }) { + super(options); + + this.start = options.start; + this.stop = options.stop; + this.bytes_read = 0; + } + + override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) { + if (this.bytes_read > this.stop) { + // done reading + this.end(); + } else if (this.bytes_read + chunk.length < this.start) { + // this chunk is still before the start byte + } else { + let start; + let stop; + + if (this.start <= this.bytes_read) { + start = 0; + } else { + start = this.start - this.bytes_read; + } + if (this.stop - this.bytes_read + 1 < chunk.length) { + stop = this.stop - this.bytes_read + 1; + } else { + stop = chunk.length; + } + const newchunk = chunk.slice(start, stop); + this.push(newchunk); + } + this.bytes_read += chunk.length; + cb(); + } +} + +// code from: https://github.com/jalik/jalik-ufs/blob/master/ufs-server.js#L310 +const readFromGridFS = async function ( + storeName: string | undefined, + fileId: string, + file: IUpload, + req: http.IncomingMessage, + res: http.ServerResponse, +) { + if (!storeName) { + return; + } + const store = UploadFS.getStore(storeName); + const rs = await store.getReadStream(fileId, file); + const ws = new stream.PassThrough(); + + res.setHeader('Accept-Ranges', 'bytes'); + + [rs, ws].forEach((stream) => + stream.on('error', (err) => { + store.onReadError.call(store, err, fileId, file); + res.end(); + }), + ); + + ws.on('close', () => { + // Close output stream at the end + ws.emit('end'); + }); + + // Transform stream + store.transformRead(rs, ws, fileId, file, req); + + const range = getFileRange(file, req); + if (range) { + setRangeHeaders(range, file, res); + + if (range.outOfRange) { + return; + } + + logger.debug('File upload extracting range'); + ws.pipe(new ExtractRange({ start: range.start, stop: range.stop })).pipe(res); + return; + } + + const accept = (Array.isArray(req.headers['accept-encoding']) ? req.headers['accept-encoding'][0] : req.headers['accept-encoding']) || ''; + + // Compress data using gzip + if (accept.match(/\bgzip\b/)) { + res.setHeader('Content-Encoding', 'gzip'); + res.removeHeader('Content-Length'); + res.writeHead(200); + ws.pipe(zlib.createGzip()).pipe(res); + return; + } + + // Compress data using deflate + if (accept.match(/\bdeflate\b/)) { + res.setHeader('Content-Encoding', 'deflate'); + res.removeHeader('Content-Length'); + res.writeHead(200); + ws.pipe(zlib.createDeflate()).pipe(res); + return; + } + + res.writeHead(200); + ws.pipe(res); +}; + +const copyFromGridFS = async function (storeName: string | undefined, fileId: string, file: IUpload, out: stream.Writable) { + if (!storeName) { + return; + } + + const store = UploadFS.getStore(storeName); + const rs = await store.getReadStream(fileId, file); + + [rs, out].forEach((stream) => + stream.on('error', (err) => { + store.onReadError.call(store, err, fileId, file); + out.end(); + }), + ); + + rs.pipe(out); +}; + +FileUpload.configureUploadsStore('GridFS', 'GridFS:Uploads', { + collectionName: 'rocketchat_uploads', +}); + +FileUpload.configureUploadsStore('GridFS', 'GridFS:UserDataFiles', { + collectionName: 'rocketchat_userDataFiles', +}); + +// DEPRECATED: backwards compatibility (remove) +UploadFS.getStores().rocketchat_uploads = UploadFS.getStores()['GridFS:Uploads']; + +FileUpload.configureUploadsStore('GridFS', 'GridFS:Avatars', { + collectionName: 'rocketchat_avatars', +}); + +new FileUploadClass({ + name: 'GridFS:Uploads', + + async get(file, req, res) { + file = FileUpload.addExtensionTo(file); + + res.setHeader('Content-Disposition', `${getContentDisposition(req)}; filename*=UTF-8''${encodeURIComponent(file.name || '')}`); + file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); + res.setHeader('Content-Type', file.type || 'application/octet-stream'); + res.setHeader('Content-Length', file.size || 0); + + await readFromGridFS(file.store, file._id, file, req, res); + }, + + async copy(file, out) { + await copyFromGridFS(file.store, file._id, file, out); + }, +}); + +new FileUploadClass({ + name: 'GridFS:UserDataFiles', + + async get(file, req, res) { + file = FileUpload.addExtensionTo(file); + + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.name || '')}`); + file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString()); + res.setHeader('Content-Type', file.type || ''); + res.setHeader('Content-Length', file.size || 0); + + await readFromGridFS(file.store, file._id, file, req, res); + }, + + async copy(file, out) { + await copyFromGridFS(file.store, file._id, file, out); + }, +}); + +new FileUploadClass({ + name: 'GridFS:Avatars', + + async get(file, req, res) { + file = FileUpload.addExtensionTo(file); + + await readFromGridFS(file.store, file._id, file, req, res); + }, + + async copy(file, out) { + await copyFromGridFS(file.store, file._id, file, out); + }, +}); diff --git a/apps/meteor/server/lib/media/file-upload/config/Webdav.ts b/apps/meteor/server/lib/media/file-upload/config/Webdav.ts new file mode 100644 index 0000000..8f3af1f --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/Webdav.ts @@ -0,0 +1,74 @@ +import _ from 'underscore'; + +import { settings } from '../../../../settings'; +import { SystemLogger } from '../../../logger/system'; +import { FileUploadClass, FileUpload } from '../lib/FileUpload'; +import '../ufs/Webdav/server'; + +const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, _req, res) { + (await this.store.getReadStream(file._id, file)) + .on('error', () => { + SystemLogger.error('An error ocurred when fetching the file'); + res.writeHead(503); + res.end(); + }) + .on('data', (chunk) => { + res.write(chunk); + }) + .on('end', res.end.bind(res)); +}; + +const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { + return new Promise(async (resolve) => { + (await this.store.getReadStream(file._id, file)).pipe(out).on('finish', () => resolve()); + }); +}; + +const WebdavUploads = new FileUploadClass({ + name: 'Webdav:Uploads', + get, + copy, + // store setted bellow +}); + +const WebdavAvatars = new FileUploadClass({ + name: 'Webdav:Avatars', + get, + copy, + // store setted bellow +}); + +const WebdavUserDataFiles = new FileUploadClass({ + name: 'Webdav:UserDataFiles', + get, + copy, + // store setted bellow +}); + +const configure = _.debounce(() => { + const uploadFolderPath = settings.get('FileUpload_Webdav_Upload_Folder_Path'); + const server = settings.get('FileUpload_Webdav_Server_URL'); + const username = settings.get('FileUpload_Webdav_Username'); + const password = settings.get('FileUpload_Webdav_Password'); + + if (!server || !username || !password) { + return; + } + + const config = { + connection: { + credentials: { + server, + username, + password, + }, + }, + uploadFolderPath, + }; + + WebdavUploads.store = FileUpload.configureUploadsStore('Webdav', WebdavUploads.name, config); + WebdavAvatars.store = FileUpload.configureUploadsStore('Webdav', WebdavAvatars.name, config); + WebdavUserDataFiles.store = FileUpload.configureUploadsStore('Webdav', WebdavUserDataFiles.name, config); +}, 500); + +settings.watchByRegex(/^FileUpload_Webdav_/, configure); diff --git a/apps/meteor/server/lib/media/file-upload/config/_configUploadStorage.ts b/apps/meteor/server/lib/media/file-upload/config/_configUploadStorage.ts new file mode 100644 index 0000000..fcaec0b --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/_configUploadStorage.ts @@ -0,0 +1,26 @@ +import _ from 'underscore'; + +import { settings } from '../../../../settings'; +import { UploadFS } from '../../../../ufs'; +import { SystemLogger } from '../../../logger/system'; +import './AmazonS3'; +import './FileSystem'; +import './GoogleStorage'; +import './GridFS'; +import './Webdav'; + +const configStore = _.debounce(() => { + const store = settings.get('FileUpload_Storage_Type'); + + if (store) { + SystemLogger.info({ + msg: 'Setting default file store', + store, + }); + UploadFS.getStores().Avatars = UploadFS.getStore(`${store}:Avatars`); + UploadFS.getStores().Uploads = UploadFS.getStore(`${store}:Uploads`); + UploadFS.getStores().UserDataFiles = UploadFS.getStore(`${store}:UserDataFiles`); + } +}, 1000); + +settings.watchByRegex(/^FileUpload_/, configStore); diff --git a/apps/meteor/server/lib/media/file-upload/config/helper.ts b/apps/meteor/server/lib/media/file-upload/config/helper.ts new file mode 100644 index 0000000..df07a2a --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/config/helper.ts @@ -0,0 +1,21 @@ +import type http from 'node:http'; +import URL from 'node:url'; + +export const forceDownload = (req: http.IncomingMessage): boolean => { + const { query } = URL.parse(req.url || '', true); + + const forceDownload = typeof query.download !== 'undefined'; + if (forceDownload) { + return true; + } + + return query.contentDisposition === 'attachment'; +}; + +export const getContentDisposition = (req: http.IncomingMessage): string => { + const { query } = URL.parse(req.url || '', true); + if (query.contentDisposition === 'inline') { + return 'inline'; + } + return 'attachment'; +}; diff --git a/apps/meteor/server/lib/media/file-upload/index.ts b/apps/meteor/server/lib/media/file-upload/index.ts new file mode 100644 index 0000000..d721331 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/index.ts @@ -0,0 +1,6 @@ +import './lib/FileUploadBase'; +import { FileUpload } from './lib/FileUpload'; +import './lib/requests'; +import './config/_configUploadStorage'; + +export { FileUpload }; diff --git a/apps/meteor/server/lib/media/file-upload/isImagePreviewSupported.ts b/apps/meteor/server/lib/media/file-upload/isImagePreviewSupported.ts new file mode 100644 index 0000000..0ed3e41 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/isImagePreviewSupported.ts @@ -0,0 +1,14 @@ +export function isImagePreviewSupported(mimeType: string): boolean { + // Only attempt preview generation for image types that can be processed by Sharp + // This excludes vendor-specific formats like image/vnd.dwg that cannot be rendered + return ( + mimeType === 'image/bmp' || + mimeType === 'image/x-windows-bmp' || + mimeType === 'image/jpeg' || + mimeType === 'image/pjpeg' || + mimeType === 'image/png' || + mimeType === 'image/gif' || + mimeType === 'image/webp' || + mimeType === 'image/svg+xml' + ); +} diff --git a/apps/meteor/server/lib/media/file-upload/lib/FileUpload.spec.ts b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.spec.ts new file mode 100644 index 0000000..0f8b599 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.spec.ts @@ -0,0 +1,471 @@ +import { expect } from 'chai'; +import { before, beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeMessageWithAttachment } from '../../../../../tests/mocks/data'; + +const fakeStorageModel = { findOneById: sinon.stub(), deleteFile: sinon.stub() }; +const settingsStub = { watch: sinon.stub(), get: sinon.stub() }; +const settingsGetMap = new Map(); +const messagesModelStub = { + find: sinon.stub(), +}; +const usersModelStub = { + findOneByIdAndLoginToken: sinon.stub(), +}; +const subscriptionsModelStub = { + findOneByRoomIdAndUserId: sinon.stub(), +}; +const validateAndDecodeJWTStub = sinon.stub(); +const systemLoggerStub = { + error: sinon.stub(), +}; +const roomCoordinatorStub = { + getRoomDirectives: sinon.stub(), +}; + +const { FileUpload, FileUploadClass } = proxyquire.noCallThru().load('./FileUpload', { + '@rocket.chat/models': { + Messages: messagesModelStub, + Users: usersModelStub, + Subscriptions: subscriptionsModelStub, + }, + 'meteor/check': sinon.stub(), + 'meteor/meteor': sinon.stub(), + 'meteor/ostrio:cookies': { Cookies: sinon.stub() }, + 'sharp': sinon.stub(), + 'stream-buffers': sinon.stub(), + '@rocket.chat/tools': sinon.stub(), + '../../../i18n': sinon.stub(), + '../../../logger/system': { SystemLogger: systemLoggerStub }, + '../../../rooms/roomCoordinator': { roomCoordinator: roomCoordinatorStub }, + '../../../../ufs': sinon.stub(), + '../../../../ufs/ufs-methods': sinon.stub(), + '../../../../settings': { settings: settingsStub }, + '../../../../../app/utils/lib/mimeTypes': sinon.stub(), + '../../../utils/lib/JWTHelper': { + validateAndDecodeJWT: validateAndDecodeJWTStub, + generateJWT: sinon.stub(), + }, + '../../../utils/restrictions': sinon.stub(), + '../../../../api/lib/MultipartUploadHandler': sinon.stub(), + '@rocket.chat/account-utils': { hashLoginToken: sinon.stub().callsFake((token) => `hashed_${token}`) }, +}); + +describe('FileUpload', () => { + before(() => { + new FileUploadClass({ name: 'fakeStorage:Uploads', model: fakeStorageModel, store: {} }); + settingsGetMap.set('FileUpload_Storage_Type', 'fakeStorage'); + settingsStub.get.callsFake((settingName) => settingsGetMap.get(settingName)); + }); + + beforeEach(() => { + messagesModelStub.find.reset(); + fakeStorageModel.findOneById.reset(); + fakeStorageModel.deleteFile.reset(); + usersModelStub.findOneByIdAndLoginToken.reset(); + subscriptionsModelStub.findOneByRoomIdAndUserId.reset(); + validateAndDecodeJWTStub.reset(); + systemLoggerStub.error.reset(); + roomCoordinatorStub.getRoomDirectives.reset(); + settingsGetMap.clear(); + settingsGetMap.set('FileUpload_Storage_Type', 'fakeStorage'); + }); + + it('should not remove any file if no room id is provided', async () => { + expect(await FileUpload.removeFilesByRoomId()).to.be.undefined; + + expect(messagesModelStub.find.called).to.be.false; + expect(fakeStorageModel.findOneById.called).to.be.false; + }); + + it('should not remove any file if an empty room id is provided', async () => { + expect(await FileUpload.removeFilesByRoomId('')).to.be.undefined; + + expect(messagesModelStub.find.called).to.be.false; + expect(fakeStorageModel.findOneById.called).to.be.false; + }); + + it('should not remove any file if an invalid room id is provided', async () => { + messagesModelStub.find.returns([]); + expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined; + + expect(messagesModelStub.find.called).to.be.true; + expect(fakeStorageModel.findOneById.called).to.be.false; + }); + + it('should delete file from storage if message contains a single file', async () => { + fakeStorageModel.findOneById.resolves({ _id: 'file-id', store: 'fakeStorage:Uploads' }); + + const fakeMessage = createFakeMessageWithAttachment(); + messagesModelStub.find.returns([fakeMessage]); + expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined; + + expect(messagesModelStub.find.called).to.be.true; + expect(fakeStorageModel.findOneById.calledOnceWith(fakeMessage.files?.[0]._id)).to.be.true; + expect(fakeStorageModel.deleteFile.calledOnceWith('file-id')).to.be.true; + }); + + it('should delete multiple files from storage if message contains many files (e.g. image and thumbnail)', async () => { + fakeStorageModel.findOneById.callsFake((_id) => ({ _id, store: 'fakeStorage:Uploads' })); + + const fakeMessage = createFakeMessageWithAttachment({ + files: [ + { _id: 'file-id', name: 'image', size: 100, type: 'image/png', format: 'png' }, + { _id: 'thumbnail-id', name: 'thumbnail-image', size: 25, type: 'image/png', format: 'png' }, + ], + }); + messagesModelStub.find.returns([fakeMessage]); + expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined; + + expect(messagesModelStub.find.called).to.be.true; + expect(fakeStorageModel.findOneById.calledTwice).to.be.true; + expect(fakeStorageModel.findOneById.calledWith('file-id')).to.be.true; + expect(fakeStorageModel.findOneById.calledWith('thumbnail-id')).to.be.true; + expect(fakeStorageModel.deleteFile.calledTwice).to.be.true; + expect(fakeStorageModel.deleteFile.calledWith('file-id')).to.be.true; + expect(fakeStorageModel.deleteFile.calledWith('thumbnail-id')).to.be.true; + }); + + describe('requestCanAccessFiles', () => { + it('should allow access if FileUpload_ProtectFiles is false', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', false); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request); + expect(result).to.be.true; + }); + + it('should allow access if no url is provided', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + + const request = { + headers: {}, + url: undefined, + } as any; + + const result = await FileUpload.requestCanAccessFiles(request); + expect(result).to.be.true; + }); + + it('should deny access if FileUpload_Enable_json_web_token_for_files is true but no token is provided', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should deny access if FileUpload_json_web_token_secret_for_files is not configured', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', ''); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=some-token', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + expect(systemLoggerStub.error.calledOnce).to.be.true; + }); + + it('should deny access if an invalid token is provided', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret'); + validateAndDecodeJWTStub.returns(null); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=invalid-token', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + expect(validateAndDecodeJWTStub.calledOnce).to.be.true; + }); + + it('should deny access if token is invalid or payload cannot be decoded', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret'); + validateAndDecodeJWTStub.returns(null); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=valid-token', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should deny access if the fileId and rid in the token do not match the requested file', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret'); + validateAndDecodeJWTStub.returns({ fileId: 'different-file-id', rid: 'different-room-id', userId: 'test-user-id' }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=valid-token', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should deny access if file object is not provided when using JWT', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret'); + validateAndDecodeJWTStub.returns({ fileId: 'test-file-id', rid: 'test-room-id', userId: 'test-user-id' }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=valid-token', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, undefined); + expect(result).to.be.false; + }); + + it('should allow access when everything is valid: token is valid, secret configured, and file/room match', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true); + settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret'); + validateAndDecodeJWTStub.returns({ fileId: 'test-file-id', rid: 'test-room-id', userId: 'test-user-id' }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?token=valid-token', + } as any; + + const file = { + _id: 'test-file-id', + rid: 'test-room-id', + } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.true; + expect(validateAndDecodeJWTStub.calledOnceWith('valid-token', 'test-secret')).to.be.true; + }); + + describe('livechat room-based authorization (rc_room_type=l)', () => { + it('should allow access when livechat credentials are valid and file belongs to the same room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.true; + expect(canAccessUploadedFileStub.calledOnce).to.be.true; + }); + + it('should deny access when livechat credentials are valid but file belongs to a different room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/victim-file-id/secret.txt?rc_room_type=l&rc_rid=room-attacker&rc_token=attacker-token', + } as any; + + // File belongs to victim's room, not the attacker's room + const file = { _id: 'victim-file-id', rid: 'room-victim' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should pass the file object to canAccessUploadedFile', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + await FileUpload.requestCanAccessFiles(request, file); + + const callArgs = canAccessUploadedFileStub.firstCall.args; + expect(callArgs[1]).to.deep.equal(file); + }); + + it('should deny access when rc_room_type is provided but canAccessUploadedFile returns false', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=invalid-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + }); + }); + + describe('getRequestUserId', () => { + it('should return undefined when no url is provided', async () => { + const request = { headers: {}, url: undefined } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when no credentials are provided', async () => { + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when a uid is provided without a token', async () => { + const request = { headers: { 'x-user-id': 'user-1' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when the login token is invalid', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves(null); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'bad-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_bad-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via headers', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'good-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via query string', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id?rc_uid=user-1&rc_token=good-token' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + }); + + describe('UserDataFiles.onRead', () => { + // eslint-disable-next-line new-cap + const getOnRead = () => FileUpload.defaults.UserDataFiles().onRead; + + const createResponse = () => { + const res = { writeHead: sinon.stub(), setHeader: sinon.stub() }; + res.writeHead.returns(res); + return res as any; + }; + + it('should deny access to an unauthenticated request', async () => { + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should deny access to an authenticated user who is not the owner', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'attacker-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'attacker-1', 'x-auth-token': 'attacker-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should allow access to the owner of the export', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'owner-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'owner-1', 'x-auth-token': 'owner-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.true; + expect(res.writeHead.called).to.be.false; + expect(res.setHeader.calledOnceWith('content-disposition', 'attachment; filename="export.zip"')).to.be.true; + }); + }); +}); diff --git a/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts new file mode 100644 index 0000000..ab47d16 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts @@ -0,0 +1,936 @@ +import { Buffer } from 'node:buffer'; +import type { WriteStream } from 'node:fs'; +import fs from 'node:fs'; +import { unlink, rename, writeFile } from 'node:fs/promises'; +import type * as http from 'node:http'; +import type * as https from 'node:https'; +import stream from 'node:stream'; +import { finished } from 'node:stream/promises'; +import URL from 'node:url'; +import { isArrayBufferView } from 'node:util/types'; + +import { hashLoginToken } from '@rocket.chat/account-utils'; +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { isE2EEUpload, type IUpload } from '@rocket.chat/core-typings'; +import { Users, Avatars, UserDataFiles, Uploads, Settings, Subscriptions, Messages, Rooms } from '@rocket.chat/models'; +import { streamToBuffer } from '@rocket.chat/tools'; +import type { NextFunction } from 'connect'; +import filesize from 'filesize'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import { Cookies } from 'meteor/ostrio:cookies'; +import type { ClientSession, OptionalId } from 'mongodb'; +import sharp from 'sharp'; +import type { WritableStreamBuffer } from 'stream-buffers'; +import streamBuffers from 'stream-buffers'; + +import { mime } from '../../../../../app/utils/lib/mimeTypes'; +import { isRenderableImageType } from '../../../../../lib/renderableImageTypes'; +import { MultipartUploadHandler } from '../../../../api/lib/MultipartUploadHandler'; +import { settings } from '../../../../settings'; +import { UploadFS } from '../../../../ufs'; +import { ufsComplete } from '../../../../ufs/ufs-methods'; +import type { Store, StoreOptions } from '../../../../ufs/ufs-store'; +import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/canAccessRoom'; +import { i18n } from '../../../i18n'; +import { SystemLogger } from '../../../logger/system'; +import { roomCoordinator } from '../../../rooms/roomCoordinator'; +import { validateAndDecodeJWT, generateJWT } from '../../../utils/lib/JWTHelper'; +import { fileUploadIsValidContentType } from '../../../utils/restrictions'; + +const cookie = new Cookies(); + +// Caps GIF frames decoded per thumbnail; limitInputPixels doesn't bound frame count. +const MAX_ANIMATED_THUMBNAIL_PAGES = 100; + +let maxFileSize = 0; + +settings.watch('FileUpload_MaxFileSize', async (value: string) => { + try { + maxFileSize = parseInt(value); + } catch (e) { + maxFileSize = (await Settings.findOneById('FileUpload_MaxFileSize'))?.packageValue as number; + } +}); + +const handlers: Record = {}; + +const defaults: Record Partial> = { + Uploads() { + return { + collection: Uploads, + filter: new UploadFS.Filter({ + onCheck: FileUpload.validateFileUpload, + }), + getPath(file: IUpload) { + return `${settings.get('uniqueID')}/uploads/${file.rid}/${file.userId}/${file._id}`; + }, + onValidate: FileUpload.uploadsOnValidate, + async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) { + // Deprecated: Remove support to usf path + if (!(await FileUpload.requestCanAccessFiles(req, file))) { + res.writeHead(403); + return false; + } + + res.setHeader('content-disposition', `attachment; filename="${encodeURIComponent(file.name || '')}"`); + return true; + }, + }; + }, + + Avatars() { + return { + collection: Avatars, + filter: new UploadFS.Filter({ + onCheck: FileUpload.validateAvatarUpload, + }), + getPath(file: IUpload) { + const avatarFile = file.rid ? `room-${file.rid}` : file.userId; + return `${settings.get('uniqueID')}/avatars/${avatarFile}`; + }, + onValidate: FileUpload.avatarsOnValidate, + onFinishUpload: FileUpload.avatarsOnFinishUpload, + }; + }, + + UserDataFiles() { + return { + collection: UserDataFiles, + getPath(file: IUpload) { + return `${settings.get('uniqueID')}/uploads/userData/${file.userId}/${file._id}`; + }, + onValidate: FileUpload.uploadsOnValidate, + async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) { + // UserDataFiles are GDPR data exports — only the owner of the export may download it. + const uid = await FileUpload.getRequestUserId(req); + if (!uid || uid !== file.userId) { + res.writeHead(403); + return false; + } + + res.setHeader('content-disposition', `attachment; filename="${encodeURIComponent(file.name || '')}"`); + return true; + }, + }; + }, +}; + +export const FileUpload = { + handlers, + + getPath(path = '') { + return `/file-upload/${path}`; + }, + + configureUploadsStore(store: string, name: string, options: any) { + const type = name.split(':').pop(); + if (!type || !(type in FileUpload.defaults)) { + throw new Error('Store type undefined'); + } + const stores = UploadFS.getStores(); + delete stores[name]; + + return new UploadFS.store[store]( + Object.assign( + { + name, + }, + options, + FileUpload.defaults[type](), + ), + ); + }, + + async validateFileUpload(file: IUpload, content?: Buffer | string) { + const isFederationUpload = Boolean(file.federation?.mxcUri); + + if (!isFederationUpload && !Match.test(file.rid, String)) { + return false; + } + + // livechat users can upload files but they don't have an userId + const user = (file.userId && (await Users.findOne(file.userId))) || undefined; + + const room = file.rid ? await Rooms.findOneById(file.rid) : undefined; + if (!isFederationUpload && !room) { + return false; + } + const directMessageAllowed = settings.get('FileUpload_Enabled_Direct'); + const fileUploadAllowed = settings.get('FileUpload_Enabled'); + if (!isFederationUpload && room && user?.type !== 'app' && (await canAccessRoomAsync(room, user, file)) !== true) { + return false; + } + const language = user?.language || 'en'; + if (!fileUploadAllowed) { + const reason = i18n.t('FileUpload_Disabled', { lng: language }); + throw new Meteor.Error('error-file-upload-disabled', reason); + } + + if (room && !directMessageAllowed && room.t === 'd') { + const reason = i18n.t('File_not_allowed_direct_messages', { lng: language }); + throw new Meteor.Error('error-direct-message-file-upload-not-allowed', reason); + } + + // -1 maxFileSize means there is no limit + if (maxFileSize > -1 && (file.size || 0) > maxFileSize) { + const reason = i18n.t('File_exceeds_allowed_size_of_bytes', { + size: filesize(maxFileSize), + lng: language, + }); + throw new Meteor.Error('error-file-too-large', reason); + } + + if (!settings.get('E2E_Enable_Encrypt_Files') && isE2EEUpload(file)) { + const reason = i18n.t('Encrypted_file_not_allowed', { lng: language }); + throw new Meteor.Error('error-invalid-file-type', reason); + } + + // E2EE files should be of type application/octet-stream. no information about them should be disclosed on upload if they are encrypted + if (isE2EEUpload(file)) { + file.type = 'application/octet-stream'; + } + + // E2EE files are of type application/octet-stream, which is whitelisted for E2EE files + if (!fileUploadIsValidContentType(file?.type, isE2EEUpload(file) ? 'application/octet-stream' : undefined)) { + const reason = i18n.t('File_type_is_not_accepted', { lng: language }); + throw new Meteor.Error('error-invalid-file-type', reason); + } + + // App IPreFileUpload event hook + try { + await Apps.self?.triggerEvent(AppEvents.IPreFileUpload, { file, content }); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + return true; + }, + + async validateAvatarUpload(file: IUpload) { + if (!Match.test(file.rid, String) && !Match.test(file.userId, String)) { + return false; + } + + const user = file.uid ? await Users.findOne(file.uid, { projection: { language: 1 } }) : null; + const language = user?.language || 'en'; + + // accept only images the browser can display as an avatar + if (!isRenderableImageType(file.type)) { + const reason = i18n.t('File_type_is_not_accepted', { lng: language }); + throw new Meteor.Error('error-invalid-file-type', reason); + } + + // -1 maxFileSize means there is no limit + if (maxFileSize > -1 && (file.size || 0) > maxFileSize) { + const reason = i18n.t('File_exceeds_allowed_size_of_bytes', { + size: filesize(maxFileSize), + lng: language, + }); + throw new Meteor.Error('error-file-too-large', reason); + } + + return true; + }, + + defaults, + + async avatarsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) { + if (settings.get('Accounts_AvatarResize') !== true) { + return; + } + + const tempFilePath = UploadFS.getTempFilePath(file._id); + + const height = settings.get('Accounts_AvatarSize') as number; + const width = height as number; + + const s = sharp(tempFilePath); + if (settings.get('FileUpload_RotateImages') === true) { + s.rotate(); + } + + const metadata = await s.metadata(); + // if (!metadata) { + // metadata = {}; + // } + + const { data, info } = await s + .resize({ + width, + height, + fit: metadata.hasAlpha ? sharp.fit.contain : sharp.fit.cover, + background: { r: 255, g: 255, b: 255, alpha: metadata.hasAlpha ? 0 : 1 }, + }) + // Use buffer to get the result in memory then replace the existing file + // There is no option to override a file using this library + // + // BY THE SHARP DOCUMENTATION: + // toBuffer: Write output to a Buffer. JPEG, PNG, WebP, TIFF and RAW output are supported. + // By default, the format will match the input image, except GIF and SVG input which become PNG output. + .toBuffer({ resolveWithObject: true }); + + try { + await writeFile(tempFilePath, data); + } catch (err: any) { + SystemLogger.error({ err }); + } + + await this.getCollection().updateOne( + { _id: file._id }, + { + $set: { + size: info.size, + ...(['gif', 'svg'].includes(metadata.format || '') ? { type: 'image/png' } : {}), + }, + }, + options, + ); + }, + + async resizeImagePreview(fileParam: Pick) { + let file = await Uploads.findOneById(fileParam._id); + if (!file) { + return; + } + file = FileUpload.addExtensionTo(file); + const image = await FileUpload.getStore('Uploads')._store.getReadStream(file._id, file); + + const transformer = sharp().resize({ width: 32, height: 32, fit: 'inside' }).jpeg().blur(); + const result = transformer.toBuffer().then((out) => out.toString('base64')); + image.pipe(transformer); + return result; + }, + + async extractMetadata(file: IUpload) { + return sharp(await FileUpload.getBuffer(file)).metadata(); + }, + + async createImageThumbnail(fileParam: Pick) { + if (!settings.get('Message_Attachments_Thumbnails_Enabled')) { + return; + } + + const width = settings.get('Message_Attachments_Thumbnails_Width') as number; + const height = settings.get('Message_Attachments_Thumbnails_Height') as number; + + if (fileParam.identify?.size && fileParam.identify.size.height < height && fileParam.identify?.size.width < width) { + return; + } + + let file = await Uploads.findOneById(fileParam._id); + if (!file) { + return; + } + + file = FileUpload.addExtensionTo(file); + const store = FileUpload.getStore('Uploads'); + const image = await store._store.getReadStream(file._id, file); + + let transformer = ( + file.type === 'image/gif' ? sharp({ pages: Math.min(file.identify?.pages ?? 1, MAX_ANIMATED_THUMBNAIL_PAGES) }) : sharp() + ).resize({ width, height, fit: 'inside' }); + + if (file.type === 'image/svg+xml') { + transformer = transformer.png(); + } + // pageHeight is the per-frame height; info.height is the full stacked height for animated input. + const result = transformer.toBuffer({ resolveWithObject: true }).then(({ data, info: { width, height, pageHeight, format } }) => ({ + data, + width, + height: pageHeight ?? height, + thumbFileType: (mime.lookup(format) as string) || '', + thumbFileName: file?.name as string, + originalFileId: file?._id as string, + })); + image.pipe(transformer); + + return result; + }, + + async uploadImageThumbnail( + { thumbFileName, thumbFileType, originalFileId }: { thumbFileName: string; thumbFileType: string; originalFileId: string }, + buffer: Buffer, + rid: string, + userId: string, + ) { + const store = FileUpload.getStore('Uploads'); + const details = { + name: `thumb-${thumbFileName}`, + size: buffer.length, + type: thumbFileType, + originalFileId, + typeGroup: 'thumb', + uploadedAt: new Date(), + _updatedAt: new Date(), + rid, + userId, + }; + + return store.insert(details, buffer); + }, + + async uploadsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) { + if (!file.type || !/^image\/((x-windows-)?bmp|p?jpeg|png|gif|webp)$/.test(file.type)) { + return; + } + + const tmpFile = UploadFS.getTempFilePath(file._id); + + const s = sharp(tmpFile); + const metadata = await s.metadata(); + + const rotated = typeof metadata.orientation !== 'undefined' && metadata.orientation !== 1; + const width = rotated ? metadata.height : metadata.width; + const height = rotated ? metadata.width : metadata.height; + + const identify = { + format: metadata.format, + size: + width != null && height != null + ? { + width, + height, + } + : undefined, + pages: metadata.pages, + }; + + const shouldRotate = settings.get('FileUpload_RotateImages'); + const shouldStripExif = settings.get('Message_Attachments_Strip_Exif') === true; + + let size = file.size || 0; + + const reorientation = async () => { + // sharp rotates AND removes metadata + const transform = s.rotate(); + + if (!shouldStripExif) { + transform.withMetadata(); + } + + const result = await transform.toFile(`${tmpFile}.sharp`); + + size = result.size; + + await unlink(tmpFile); + + await rename(`${tmpFile}.sharp`, tmpFile); + }; + + if (rotated && shouldRotate) { + // If there is EXIF orientation and the setting is enabled, rotate the image (which removes metadata) + await reorientation(); + } else if (shouldStripExif) { + // If there is no EXIF orientation but the setting is enabled, still strip any metadata + size = await MultipartUploadHandler.stripExifFromFile(tmpFile); + } + + await this.getCollection().updateOne( + { _id: file._id }, + { + $set: { size, identify }, + }, + options, + ); + }, + + async avatarsOnFinishUpload(file: IUpload) { + if (file.rid) { + return; + } + + if (!file.userId) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed'); + } + + // update file record to match user's username + const user = await Users.findOneById(file.userId); + if (!user?.username) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed'); + } + const oldAvatar = await Avatars.findOneByName(user.username); + if (oldAvatar) { + await Avatars.deleteFile(oldAvatar._id); + } + await Avatars.updateFileNameById(file._id, user.username); + }, + + async getRequestUserId({ headers = {}, url }: http.IncomingMessage): Promise { + if (!url) { + return undefined; + } + + const { query } = URL.parse(url, true); + + let { rc_uid, rc_token } = query as Record; + + if (!rc_uid && headers.cookie) { + rc_uid = cookie.get('rc_uid', headers.cookie); + rc_token = cookie.get('rc_token', headers.cookie); + } + + const uid = rc_uid || (headers['x-user-id'] as string); + const authToken = rc_token || (headers['x-auth-token'] as string); + + if (!uid || !authToken) { + return undefined; + } + + const user = await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } }); + return user?._id; + }, + + async requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) { + if (!url || !settings.get('FileUpload_ProtectFiles')) { + return true; + } + + const { query } = URL.parse(url, true); + + let { rc_uid, rc_token, rc_rid, rc_room_type } = query as Record; + const { token } = query; + + if (!rc_uid && headers.cookie) { + rc_uid = cookie.get('rc_uid', headers.cookie); + rc_token = cookie.get('rc_token', headers.cookie); + rc_rid = cookie.get('rc_rid', headers.cookie); + rc_room_type = cookie.get('rc_room_type', headers.cookie); + } + + const isAuthorizedByRoom = async () => + rc_room_type && + roomCoordinator + .getRoomDirectives(rc_room_type) + .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }, file); + + const isAuthorizedByJWT: () => boolean = () => { + if (!token || typeof token !== 'string' || !settings.get('FileUpload_Enable_json_web_token_for_files')) { + return false; + } + + if (!settings.get('FileUpload_json_web_token_secret_for_files')) { + SystemLogger.error('FileUpload_json_web_token_secret_for_files is not configured. Cannot validate JWT for file access.'); + return false; + } + + const payload = validateAndDecodeJWT(token, settings.get('FileUpload_json_web_token_secret_for_files')); + + if (!payload) { + return false; + } + + const { fileId, rid } = payload as { fileId: string; rid: string }; + + if (!fileId || !rid) { + return false; + } + + if (fileId !== file?._id || rid !== file?.rid) { + return false; + } + + return true; + }; + + if (isAuthorizedByJWT() || (await isAuthorizedByRoom())) { + return true; + } + + const uid = rc_uid || (headers['x-user-id'] as string); + const authToken = rc_token || (headers['x-auth-token'] as string); + + const user = uid && authToken && (await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } })); + + if (!user) { + return false; + } + + if (!file?.rid) { + return true; + } + + const fileUploadRestrictedToMembers = settings.get('FileUpload_Restrict_to_room_members'); + const fileUploadRestrictToUsersWhoCanAccessRoom = settings.get('FileUpload_Restrict_to_users_who_can_access_room'); + + if (!fileUploadRestrictToUsersWhoCanAccessRoom && !fileUploadRestrictedToMembers) { + return true; + } + + if (fileUploadRestrictedToMembers && !fileUploadRestrictToUsersWhoCanAccessRoom) { + const sub = await Subscriptions.findOneByRoomIdAndUserId(file.rid, user._id, { projection: { _id: 1 } }); + return !!sub; + } + + if (fileUploadRestrictToUsersWhoCanAccessRoom && !fileUploadRestrictedToMembers) { + return canAccessRoomIdAsync(file.rid, user._id); + } + + return false; + }, + + addExtensionTo(file: IUpload) { + if (mime.lookup(file.name || '') === file.type) { + return file; + } + + // This file type can be pretty much anything, so it's better if we don't mess with the file extension + if (file.type !== 'application/octet-stream') { + const ext = mime.extension(file.type || ''); + if (ext && new RegExp(`\\.${ext}$`, 'i').test(file.name || '') === false) { + file.name = `${file.name}.${ext}`; + } + } + + return file; + }, + + getStore(modelName: string) { + const storageType = settings.get('FileUpload_Storage_Type'); + const handlerName = `${storageType}:${modelName}`; + + return this.getStoreByName(handlerName); + }, + + getStoreByName(handlerName?: string) { + if (!handlerName) { + SystemLogger.error({ + msg: 'Empty Upload handler does not exists', + }); + throw new Error(`Empty Upload handler does not exists`); + } + + if (this.handlers[handlerName] == null) { + SystemLogger.error({ + msg: 'Upload handler does not exists', + handlerName, + }); + } + return this.handlers[handlerName]; + }, + + get(file: IUpload, req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) { + const store = this.getStoreByName(file.store); + if (store?.get) { + return store.get(file, req, res, next); + } + res.writeHead(404); + res.end(); + }, + + async getBuffer(file: IUpload): Promise { + const store = this.getStoreByName(file.store); + + if (!store?.get) { + throw new Error('Store is invalid'); + } + + const buffer = new streamBuffers.WritableStreamBuffer({ + initialSize: file.size, + }); + + return new Promise((resolve, reject) => { + buffer.on('error', reject); + buffer.on('finish', () => { + const contents = buffer.getContents(); + if (contents === false) { + return reject(); + } + resolve(contents); + }); + + void store.copy?.(file, buffer); + }); + }, + + async copy(file: IUpload, targetFile: string) { + const store = this.getStoreByName(file.store); + const out = fs.createWriteStream(targetFile); + + file = FileUpload.addExtensionTo(file); + + if (store.copy) { + await store.copy(file, out); + return true; + } + + return false; + }, + + redirectToFile(fileUrl: string, _req: http.IncomingMessage, res: http.ServerResponse) { + res.removeHeader('Content-Length'); + res.removeHeader('Cache-Control'); + res.setHeader('Location', fileUrl); + res.writeHead(302); + res.end(); + }, + + respondWithRedirectUrlInfo( + redirectUrl: string | false, + file: IUpload, + _req: http.IncomingMessage, + res: http.ServerResponse, + expiresInSeconds?: number | null, + ) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.writeHead(200); + res.end( + JSON.stringify({ + redirectUrl, + name: file.name, + type: file.type, + size: file.size, + ...(expiresInSeconds && { expires: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }), + }), + ); + }, + + proxyFile( + fileName: string, + fileUrl: string, + forceDownload: boolean, + request: typeof http | typeof https, + _req: http.IncomingMessage, + res: http.ServerResponse, + ) { + res.setHeader('Content-Disposition', `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(fileName)}"`); + + request.get(fileUrl, (fileRes) => { + if (fileRes.statusCode !== 200) { + res.setHeader('x-rc-proxyfile-status', String(fileRes.statusCode)); + res.setHeader('content-length', 0); + res.writeHead(500); + res.end(); + return; + } + + const headersToProxy = ['age', 'cache-control', 'content-length', 'content-type', 'date', 'expired', 'last-modified']; + + headersToProxy.forEach((header) => { + fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header])); + }); + + fileRes.pipe(res); + }); + }, + + generateJWTToFileUrls({ rid, userId, fileId }: { rid: string; userId: string; fileId: string }) { + if ( + !settings.get('FileUpload_ProtectFiles') || + !settings.get('FileUpload_Enable_json_web_token_for_files') || + !settings.get('FileUpload_json_web_token_secret_for_files') + ) { + return; + } + return generateJWT( + { + rid, + userId, + fileId, + }, + settings.get('FileUpload_json_web_token_secret_for_files'), + ); + }, + + async removeFilesByRoomId(rid: string) { + if (typeof rid !== 'string' || rid.trim().length === 0) { + return; + } + const cursor = Messages.find( + { + rid, + 'files._id': { + $exists: true, + }, + }, + { + projection: { + 'files._id': 1, + }, + }, + ); + + for await (const document of cursor) { + if (document.files) { + await Promise.all(document.files.map((file) => FileUpload.getStore('Uploads').deleteById(file._id))); + } + } + }, +}; + +type FileUploadClassOptions = { + name: string; + model?: typeof Avatars | typeof Uploads | typeof UserDataFiles; + store?: Store; + get: (file: IUpload, req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) => Promise; + insert?: () => Promise; + getStore?: () => Store; + copy?: (file: IUpload, out: WriteStream | WritableStreamBuffer) => Promise; +}; + +export class FileUploadClass { + public name: FileUploadClassOptions['name']; + + public model: typeof Avatars | typeof Uploads | typeof UserDataFiles; + + public _store: Store; + + public get: FileUploadClassOptions['get']; + + public copy: FileUploadClassOptions['copy']; + + constructor({ name, model, store, get, insert, getStore, copy }: FileUploadClassOptions) { + this.name = name; + this.model = model || this.getModelFromName(); + this._store = store || UploadFS.getStore(name); + this.get = get; + this.copy = copy; + + if (insert) { + this.insert = insert; + } + + if (getStore) { + this.getStore = getStore; + } + + FileUpload.handlers[name] = this; + } + + getStore() { + return this._store; + } + + get store() { + return this.getStore(); + } + + set store(store) { + this._store = store; + } + + getModelFromName(): typeof this.model { + const modelsAvailable: Record = { + Avatars, + Uploads, + UserDataFiles, + }; + const modelName = this.name.split(':')[1]; + if (!modelsAvailable[modelName]) { + throw new Error('Invalid Model for FileUpload'); + } + return modelsAvailable[modelName]; + } + + async delete(fileId: string, options?: { session?: ClientSession }) { + // TODO: Remove this method + if (this.store?.delete) { + await this.store.delete(fileId, { session: options?.session }); + } + + return this.model.deleteFile(fileId, { session: options?.session }); + } + + async deleteById(fileId: string) { + const file = await this.model.findOneById(fileId); + + if (!file) { + return; + } + + const store = FileUpload.getStoreByName(file.store); + + return store.delete(file._id); + } + + async deleteByName(fileName: string, options?: { session?: ClientSession }) { + const file = await this.model.findOneByName(fileName, { session: options?.session }); + + if (!file) { + return; + } + + const store = FileUpload.getStoreByName(file.store); + + return store.delete(file._id); + } + + async deleteByRoomId(rid: string) { + const file = await this.model.findOneByRoomId(rid); + + if (!file) { + return; + } + + const store = FileUpload.getStoreByName(file.store); + + return store.delete(file._id); + } + + private async _validateFile( + fileData: Omit, '_updatedAt'>, + content: stream.Readable | Buffer | string, + ): Promise { + const filter = this.store.getFilter(); + if (!filter?.check) { + return content; + } + + if (content instanceof stream.Readable) { + // Currently, only the Slack Adapter passes a stream.Readable here + // We can't use _streamToTmpFile at this stage since the file hasn't been validated yet, + // and for security reasons we must not write it to disk before validation + content = await streamToBuffer(content); + } else if (content instanceof Uint8Array && !(content instanceof Buffer)) { + // Services compat - create a view into the underlying ArrayBuffer without copying the data + content = Buffer.from(content.buffer, content.byteOffset, content.byteLength); + } + + try { + await filter.check(fileData, content); + return content; + } catch (e) { + throw e; + } + } + + async _doInsert( + fileData: Omit, '_updatedAt'>, + content: stream.Readable | Buffer | string, + options?: { session?: ClientSession }, + ): Promise { + const fileId = await this.store.create(fileData, { session: options?.session }); + const tmpFile = UploadFS.getTempFilePath(fileId); + + try { + if (typeof content === 'string') { + await fs.promises.rename(content, tmpFile); + } else if (isArrayBufferView(content)) { + await fs.promises.writeFile(tmpFile, content); + } else if (content instanceof stream.Readable) { + await finished(content.pipe(fs.createWriteStream(tmpFile)), { cleanup: true }); + } else { + throw new Error('Invalid file type'); + } + + return ufsComplete(fileId, this.name, { session: options?.session }); + } catch (e) { + throw e; + } + } + + async insert( + fileData: Omit, '_updatedAt'>, + streamOrBuffer: stream.Readable | Buffer | string, + options?: { session?: ClientSession }, + ): Promise { + streamOrBuffer = await this._validateFile(fileData, streamOrBuffer); + return this._doInsert(fileData, streamOrBuffer, options); + } +} diff --git a/apps/meteor/server/lib/media/file-upload/lib/FileUploadBase.ts b/apps/meteor/server/lib/media/file-upload/lib/FileUploadBase.ts new file mode 100644 index 0000000..976c85f --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/FileUploadBase.ts @@ -0,0 +1,8 @@ +import path from 'node:path'; + +import { UploadFS } from '../../../../ufs'; + +// set ufs temp dir to $TMPDIR/ufs instead of /tmp/ufs if the variable is set +if ('TMPDIR' in process.env) { + UploadFS.config.tmpDir = path.join(process.env.TMPDIR || '', 'ufs'); +} diff --git a/apps/meteor/server/lib/media/file-upload/lib/ranges.spec.ts b/apps/meteor/server/lib/media/file-upload/lib/ranges.spec.ts new file mode 100644 index 0000000..26c1f1d --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/ranges.spec.ts @@ -0,0 +1,63 @@ +import type http from 'node:http'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { getFileRange } from './ranges'; + +const req = (range?: string) => ({ headers: { range } }) as http.IncomingMessage; +const file = (size: number) => ({ size }) as IUpload; + +describe('getFileRange', () => { + it('returns undefined when no Range header is present', () => { + expect(getFileRange(file(1000), req())).to.equal(undefined); + }); + + it('returns undefined for a malformed Range header', () => { + expect(getFileRange(file(1000), req('bytes=abc'))).to.equal(undefined); + expect(getFileRange(file(1000), req('items=0-99'))).to.equal(undefined); + expect(getFileRange(file(1000), req('bytes=-'))).to.equal(undefined); + }); + + it('returns undefined when the file has no size', () => { + expect(getFileRange(file(0), req('bytes=0-'))).to.equal(undefined); + }); + + it('serves an open-ended range `bytes=0-` as the whole file', () => { + expect(getFileRange(file(1000), req('bytes=0-'))).to.deep.equal({ outOfRange: false, start: 0, stop: 999 }); + }); + + it('serves an open-ended range from a mid-file offset to EOF', () => { + expect(getFileRange(file(1000), req('bytes=77-'))).to.deep.equal({ outOfRange: false, start: 77, stop: 999 }); + }); + + it('serves an explicit closed range', () => { + expect(getFileRange(file(1000), req('bytes=100-199'))).to.deep.equal({ outOfRange: false, start: 100, stop: 199 }); + }); + + it('clamps an end that runs past EOF', () => { + expect(getFileRange(file(1000), req('bytes=100-5000'))).to.deep.equal({ outOfRange: false, start: 100, stop: 999 }); + }); + + it('serves a suffix range `bytes=-N` as the last N bytes', () => { + expect(getFileRange(file(1000), req('bytes=-200'))).to.deep.equal({ outOfRange: false, start: 800, stop: 999 }); + }); + + it('clamps a suffix range larger than the file to the whole file', () => { + expect(getFileRange(file(1000), req('bytes=-5000'))).to.deep.equal({ outOfRange: false, start: 0, stop: 999 }); + }); + + it('flags a start at or beyond EOF as out of range', () => { + expect(getFileRange(file(1000), req('bytes=1000-'))).to.deep.equal({ outOfRange: true, start: 1000, stop: 999 }); + expect(getFileRange(file(1000), req('bytes=1500-1600'))).to.deep.equal({ outOfRange: true, start: 1500, stop: 999 }); + }); + + it('flags an inverted closed range as out of range', () => { + expect(getFileRange(file(1000), req('bytes=200-100'))).to.deep.equal({ outOfRange: true, start: 200, stop: 100 }); + }); + + it('serves the final byte of the file', () => { + expect(getFileRange(file(1000), req('bytes=999-'))).to.deep.equal({ outOfRange: false, start: 999, stop: 999 }); + }); +}); diff --git a/apps/meteor/server/lib/media/file-upload/lib/ranges.ts b/apps/meteor/server/lib/media/file-upload/lib/ranges.ts new file mode 100644 index 0000000..725cec3 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/ranges.ts @@ -0,0 +1,81 @@ +import type http from 'node:http'; + +import type { IUpload } from '@rocket.chat/core-typings'; + +type ByteRange = { start?: number; stop?: number }; + +function parseByteRange(header?: string): ByteRange | undefined { + if (!header) { + return; + } + const matches = header.match(/^bytes=(\d*)-(\d*)$/); + if (!matches) { + return; + } + const [, startStr, stopStr] = matches; + if (startStr === '' && stopStr === '') { + return; + } + return { + start: startStr === '' ? undefined : parseInt(startStr, 10), + stop: stopStr === '' ? undefined : parseInt(stopStr, 10), + }; +} + +export function getFileRange(file: IUpload, req: http.IncomingMessage) { + const parsed = parseByteRange(req.headers.range); + if (!parsed) { + return; + } + + const size = file.size || 0; + if (size <= 0) { + return; + } + + let { start, stop } = parsed; + + if (start === undefined) { + if (!stop) { + return { outOfRange: true, start: 0, stop: size - 1 }; + } + start = Math.max(0, size - stop); + stop = size - 1; + } else if (stop === undefined || stop > size - 1) { + stop = size - 1; + } + + if (start > stop || start >= size) { + return { outOfRange: true, start, stop }; + } + + return { outOfRange: false, start, stop }; +} + +// code from: https://github.com/jalik/jalik-ufs/blob/master/ufs-server.js#L310 +export const setRangeHeaders = function ( + range: { start: number; stop: number; outOfRange?: boolean } | undefined, + file: IUpload, + res: http.ServerResponse, +) { + if (!range) { + return; + } + + if (range.outOfRange) { + // out of range request, return 416 + res.removeHeader('Content-Length'); + res.removeHeader('Content-Type'); + res.removeHeader('Content-Disposition'); + res.removeHeader('Last-Modified'); + res.setHeader('Content-Range', `bytes */${file.size}`); + res.writeHead(416); + res.end(); + return; + } + + res.setHeader('Content-Range', `bytes ${range.start}-${range.stop}/${file.size}`); + res.removeHeader('Content-Length'); + res.setHeader('Content-Length', range.stop - range.start + 1); + res.statusCode = 206; +}; diff --git a/apps/meteor/server/lib/media/file-upload/lib/requests.ts b/apps/meteor/server/lib/media/file-upload/lib/requests.ts new file mode 100644 index 0000000..8f509a8 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/requests.ts @@ -0,0 +1,62 @@ +import type { IncomingMessage } from 'node:http'; + +import { Uploads } from '@rocket.chat/models'; +import { WebApp } from 'meteor/webapp'; + +import { FileUpload } from './FileUpload'; +import { SystemLogger } from '../../../logger/system'; + +const hasReplyWithRedirectUrlParam = (req: IncomingMessage) => { + if (!req.url) { + return false; + } + const [, params] = req.url.split('?'); + if (!params) { + return false; + } + const searchParams = new URLSearchParams(params); + const replyWithRedirectUrl = searchParams.get('replyWithRedirectUrl'); + return replyWithRedirectUrl === 'true' || replyWithRedirectUrl === '1'; +}; + +WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => { + const match = /^\/([^\/]+)\/(.*)/.exec(req.url || ''); + + if (match?.[1]) { + const file = await Uploads.findOneById(match[1]); + + if (file) { + if (!(await FileUpload.requestCanAccessFiles(req, file))) { + res.writeHead(403); + res.end(); + return; + } + + if (hasReplyWithRedirectUrlParam(req)) { + if (!file.store) { + res.writeHead(404); + res.end(); + return; + } + const store = FileUpload.getStoreByName(file.store); + let url: string | false = false; + let expiryTimespan: number | null = null; + try { + url = await store.getStore().getRedirectURL(file, false); + expiryTimespan = await store.getStore().getUrlExpiryTimeSpan(); + } catch (err) { + SystemLogger.debug({ err }); + } + return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan); + } + + res.setHeader('Content-Security-Policy', "default-src 'none'"); + res.setHeader('Cache-Control', 'max-age=31536000'); + await FileUpload.get(file, req, res, next); + return; + } + } + + res.writeHead(404); + res.end(); +}); diff --git a/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.spec.ts b/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.spec.ts new file mode 100644 index 0000000..69a4da8 --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.spec.ts @@ -0,0 +1,16 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { MIN_URL_EXPIRY_TIME_SPAN_SECONDS, URL_EXPIRY_FALLBACK_SECONDS, getUrlExpiryTimeSpanWithFallback } from './urlExpiry'; + +describe('getUrlExpiryTimeSpanWithFallback', () => { + it('uses fallback when configured value is below minimum', () => { + expect(getUrlExpiryTimeSpanWithFallback(0)).to.equal(URL_EXPIRY_FALLBACK_SECONDS); + expect(getUrlExpiryTimeSpanWithFallback(3)).to.equal(URL_EXPIRY_FALLBACK_SECONDS); + }); + + it('uses configured value when at or above minimum', () => { + expect(getUrlExpiryTimeSpanWithFallback(MIN_URL_EXPIRY_TIME_SPAN_SECONDS)).to.equal(MIN_URL_EXPIRY_TIME_SPAN_SECONDS); + expect(getUrlExpiryTimeSpanWithFallback(300)).to.equal(300); + }); +}); diff --git a/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.ts b/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.ts new file mode 100644 index 0000000..2d6336b --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/lib/urlExpiry.ts @@ -0,0 +1,6 @@ +export const MIN_URL_EXPIRY_TIME_SPAN_SECONDS = 5; +export const URL_EXPIRY_FALLBACK_SECONDS = 900; + +export const getUrlExpiryTimeSpanWithFallback = (configuredValue: number): number => { + return configuredValue >= MIN_URL_EXPIRY_TIME_SPAN_SECONDS ? configuredValue : URL_EXPIRY_FALLBACK_SECONDS; +}; diff --git a/apps/meteor/server/lib/media/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/server/lib/media/file-upload/ufs/AmazonS3/server.ts new file mode 100644 index 0000000..9b3004a --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/ufs/AmazonS3/server.ts @@ -0,0 +1,212 @@ +import stream from 'node:stream'; + +import { + DeleteObjectCommand, + GetObjectCommand, + S3Client, + type GetObjectCommandInput, + type PutObjectCommandInput, + type S3ClientConfig, +} from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { IUpload } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { check } from 'meteor/check'; +import _ from 'underscore'; + +import { UploadFS } from '../../../../../ufs'; +import type { StoreOptions } from '../../../../../ufs/ufs-store'; +import { SystemLogger } from '../../../../logger/system'; +import { getUrlExpiryTimeSpanWithFallback } from '../../lib/urlExpiry'; + +export type S3Options = StoreOptions & { + connection: S3ClientConfig; + params: { + Bucket: string; + ACL: string; + }; + URLExpiryTimeSpan: number; + getPath: (file: Omit) => string; +}; + +class AmazonS3Store extends UploadFS.Store { + protected getPath: (file: Omit) => string; + + constructor(options: S3Options) { + // Default options + // options.secretAccessKey, + // options.accessKeyId, + // options.region, + // options.sslEnabled // optional + + options = _.extend( + { + httpOptions: { + timeout: 6000, + agent: false, + }, + }, + options, + ); + + super(options); + + const classOptions = options; + + const customUserAgent = process.env.FILE_STORAGE_CUSTOM_USER_AGENT?.trim(); + + const s3 = new S3Client({ + ...options.connection, + ...(customUserAgent && { customUserAgent }), + }); + + options.getPath = + options.getPath || + function (file) { + return file._id; + }; + + this.getPath = function (file) { + if (file.AmazonS3) { + return file.AmazonS3.path; + } + // Compatibility + // TODO: Migration + if (file.s3) { + return file.s3.path + file._id; + } + + return file._id; + }; + + this.getRedirectURL = async (file, forceDownload = false) => { + const expiresIn = getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan); + return getSignedUrl( + s3, + new GetObjectCommand({ + Key: this.getPath(file), + ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, + Bucket: classOptions.params.Bucket, + }), + { expiresIn }, + ); + }; + + /** + * Creates the file in the collection + */ + this.create = async (file) => { + check(file, Object); + + if (file._id == null) { + file._id = Random.id(); + } + + file.AmazonS3 = { + path: classOptions.getPath(file as Omit), + }; + + file.store = this.options.name; // assign store to file + + return (await this.getCollection().insertOne(file)).insertedId; + }; + + /** + * Removes the file + */ + this.delete = async function (fileId) { + const file = await this.getCollection().findOne({ _id: fileId }); + if (!file) { + throw new Error('File not found'); + } + + try { + return await s3.send( + new DeleteObjectCommand({ + Key: this.getPath(file), + Bucket: classOptions.params.Bucket, + }), + ); + } catch (error) { + SystemLogger.error({ error, key: this.getPath(file), bucket: classOptions.params.Bucket }); + throw error; + } + }; + + /** + * Returns the file read stream + */ + this.getReadStream = async function (_fileId, file, options = {}) { + const params: GetObjectCommandInput = { + Key: this.getPath(file), + Bucket: classOptions.params.Bucket, + }; + + if (options.start != null && options.end != null) { + params.Range = `bytes=${options.start}-${options.end}`; + } + + const response = await s3.send(new GetObjectCommand(params)); + + if (!response.Body) { + throw new Error('File not found'); + } + + if (!('readable' in response.Body)) { + throw new Error('Response body is not a readable stream'); + } + + return response.Body; + }; + + /** + * Returns the file write stream + */ + this.getWriteStream = async function (_fileId, file /* , options*/) { + const writeStream = new stream.PassThrough(); + + writeStream.on('newListener', (event, listener) => { + if (event === 'finish') { + process.nextTick(() => { + writeStream.removeListener(event, listener); + writeStream.on('real_finish', listener); + }); + } + }); + + const uploadParams: PutObjectCommandInput = { + Key: this.getPath(file), + Body: writeStream, + Bucket: classOptions.params.Bucket, + ...(file.type && { ContentType: file.type }), + ...(file.size != null && { ContentLength: file.size }), + ...(classOptions.params.ACL && { ACL: classOptions.params.ACL as PutObjectCommandInput['ACL'] }), + }; + + const upload = new Upload({ + client: s3, + params: uploadParams, + }); + + upload + .done() + .then(() => { + writeStream.emit('real_finish'); + }) + .catch((error) => { + SystemLogger.error({ err: error }); + writeStream.emit('error', error); + }); + + return writeStream; + }; + + this.getUrlExpiryTimeSpan = async () => { + return getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan); + }; + } +} + +// Add store to UFS namespace +UploadFS.store.AmazonS3 = AmazonS3Store; diff --git a/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts new file mode 100644 index 0000000..945790f --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/ufs/GoogleStorage/server.ts @@ -0,0 +1,163 @@ +import type { GetSignedUrlConfig } from '@google-cloud/storage'; +import { Storage } from '@google-cloud/storage'; +import type { IUpload } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { check } from 'meteor/check'; + +import { UploadFS } from '../../../../../ufs'; +import type { StoreOptions } from '../../../../../ufs/ufs-store'; +import { SystemLogger } from '../../../../logger/system'; +import { getUrlExpiryTimeSpanWithFallback } from '../../lib/urlExpiry'; + +type GStoreOptions = StoreOptions & { + connection: { + credentials: { + client_email: string; + private_key: string; + projectId: string; + }; + }; + bucket: string; + URLExpiryTimeSpan: number; + getPath: (file: Omit) => string; +}; + +class GoogleStorageStore extends UploadFS.Store { + protected getPath: (file: Omit) => string; + + constructor(options: GStoreOptions) { + super(options); + + const userAgent = process.env.FILE_STORAGE_CUSTOM_USER_AGENT?.trim(); + + const gcs = new Storage({ + ...(userAgent && { userAgent }), + ...options.connection, + }); + const bucket = gcs.bucket(options.bucket); + + options.getPath = + options.getPath || + function (file) { + return file._id; + }; + + this.getPath = function (file) { + if (file.GoogleStorage) { + return file.GoogleStorage.path; + } + // Compatibility + // TODO: Migration + if (file.googleCloudStorage) { + return file.googleCloudStorage.path + file._id; + } + + return file._id; + }; + + this.getRedirectURL = async (file, forceDownload = false) => { + const expirySeconds = getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan); + const params: GetSignedUrlConfig = { + action: 'read', + responseDisposition: forceDownload ? 'attachment' : 'inline', + version: 'v2', + expires: Date.now() + expirySeconds * 1000, + }; + + const res = await bucket.file(this.getPath(file)).getSignedUrl(params); + return res[0]; + }; + + this.getUrlExpiryTimeSpan = async () => { + return getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan); + }; + + /** + * Creates the file in the collection + * @param file + * @param callback + * @return {string} + */ + this.create = async function (file) { + check(file, Object); + + if (file._id == null) { + file._id = Random.id(); + } + + file.GoogleStorage = { + path: options.getPath(file as Omit), + }; + + file.store = this.options.name; // assign store to file + + return (await this.getCollection().insertOne(file)).insertedId; + }; + + /** + * Removes the file + * @param fileId + * @param callback + */ + this.delete = async function (fileId) { + // TODO + const file = await this.getCollection().findOne({ _id: fileId }); + if (!file) { + throw new Error('File not found'); + } + + try { + return bucket.file(this.getPath(file)).delete(); + } catch (err: any) { + SystemLogger.error({ err }); + } + }; + + /** + * Returns the file read stream + * @param fileId + * @param file + * @param options + * @return {*} + */ + this.getReadStream = async function (_fileId, file, options = {}) { + const config: { + start?: number; + end?: number; + } = {}; + + if (options.start != null) { + config.start = options.start; + } + + if (options.end != null) { + config.end = options.end; + } + + return bucket.file(this.getPath(file)).createReadStream(config); + }; + + /** + * Returns the file write stream + * @param fileId + * @param file + * @param options + * @return {*} + */ + this.getWriteStream = async function (_fileId, file /* , options*/) { + return bucket.file(this.getPath(file)).createWriteStream({ + gzip: false, + metadata: { + contentType: file.type, + contentDisposition: `inline; filename=${file.name}`, + // metadata: { + // custom: 'metadata' + // } + }, + }); + }; + } +} + +// Add store to UFS namespace +UploadFS.store.GoogleStorage = GoogleStorageStore; diff --git a/apps/meteor/server/lib/media/file-upload/ufs/Webdav/server.ts b/apps/meteor/server/lib/media/file-upload/ufs/Webdav/server.ts new file mode 100644 index 0000000..57352ea --- /dev/null +++ b/apps/meteor/server/lib/media/file-upload/ufs/Webdav/server.ts @@ -0,0 +1,154 @@ +import stream from 'node:stream'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { check } from 'meteor/check'; + +import { WebdavClientAdapter } from '../../../../../bridges/webdav/lib/webdavClientAdapter'; +import { UploadFS } from '../../../../../ufs'; +import type { StoreOptions } from '../../../../../ufs/ufs-store'; +import { SystemLogger } from '../../../../logger/system'; + +type WebdavOptions = StoreOptions & { + connection: { + credentials: { + server: string; + username: string; + password: string; + }; + }; + uploadFolderPath: string; + getPath: (file: Omit) => string; +}; + +class WebdavStore extends UploadFS.Store { + protected getPath: (file: Omit) => string; + + constructor(options: WebdavOptions) { + super(options); + const { server, username, password } = options.connection.credentials; + const client = new WebdavClientAdapter(server, { username, password }); + + options.getPath = function (file) { + if (options.uploadFolderPath[options.uploadFolderPath.length - 1] !== '/') { + options.uploadFolderPath += '/'; + } + return options.uploadFolderPath + file._id; + }; + + client.stat(options.uploadFolderPath).catch((err) => { + if (err.message.toLowerCase() === 'not found') { + void client.createDirectory(options.uploadFolderPath); + } else if (err.message.toLowerCase() === 'unauthorized') { + console.warn('File upload is unauthorized to connect on Webdav, please verify your credentials'); + } + }); + + /** + * Returns the file path + * @param file + * @return {string} + */ + this.getPath = function (file) { + if (file.Webdav) { + return file.Webdav.path; + } + + return file._id; + }; + + /** + * Creates the file in the col lection + * @param file + * @param callback + * @return {string} + */ + this.create = async function (file) { + check(file, Object); + + if (file._id == null) { + file._id = Random.id(); + } + + file.Webdav = { + path: options.getPath(file as Omit), + }; + + file.store = this.options.name; + + return (await this.getCollection().insertOne(file)).insertedId; + }; + + /** + * Removes the file + * @param fileId + * @param callback + */ + this.delete = async function (fileId) { + const file = await this.getCollection().findOne({ _id: fileId }); + if (!file) { + throw new Error('File no found'); + } + + try { + return client.deleteFile(this.getPath(file)); + } catch (err: any) { + SystemLogger.error({ err }); + } + }; + + /** + * Returns the file read stream + * @param fileId + * @param file + * @param options + * @return {*} + */ + this.getReadStream = async function (_fileId, file, options = {}) { + const range: { + start?: number; + end?: number; + } = {}; + + if (options.start != null) { + range.start = options.start; + } + + if (options.end != null) { + range.end = options.end; + } + return client.createReadStream(this.getPath(file), options); + }; + + /** + * Returns the file write stream + * @param fileId + * @param file + * @return {*} + */ + this.getWriteStream = async function (_fileId, file) { + const writeStream = new stream.PassThrough(); + const webdavStream = client.createWriteStream(this.getPath(file), file.size || 0); + + // TODO remove timeout when UploadFS bug resolved + const newListenerCallback = (event: string, listener: () => any) => { + if (event === 'finish') { + process.nextTick(() => { + writeStream.removeListener(event, listener); + writeStream.removeListener('newListener', newListenerCallback); + writeStream.on(event, () => { + setTimeout(listener, 500); + }); + }); + } + }; + writeStream.on('newListener', newListenerCallback); + + writeStream.pipe(webdavStream); + return writeStream; + }; + } +} + +// Add store to UFS namespace +UploadFS.store.Webdav = WebdavStore; diff --git a/apps/meteor/server/lib/media/file/file.server.ts b/apps/meteor/server/lib/media/file/file.server.ts new file mode 100644 index 0000000..02a5a5b --- /dev/null +++ b/apps/meteor/server/lib/media/file/file.server.ts @@ -0,0 +1,227 @@ +import type { ReadStream } from 'node:fs'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; + +import type { ObjectId } from 'bson'; +import { MongoInternals } from 'meteor/mongo'; +import { NpmModuleMongodb } from 'meteor/npm-mongo'; +import mime from 'mime-type/with-db'; +import mkdirp from 'mkdirp'; + +import { sanitizeFileName } from './functions/sanitizeFileName'; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +type IFile = { + buffer: Buffer; + contentType?: string; + length: number; + uploadDate?: Date; +}; + +interface IRocketChatFileStore { + remove(fileId: string): Promise; + + createWriteStream(fileName: string, contentType: string): void; + + createReadStream(fileName: string): void; + + getFileWithReadStream(fileName: string): Promise< + | { + readStream: NpmModuleMongodb.GridFSBucketReadStream | ReadStream; + contentType?: string; + length: number; + uploadDate?: Date; + } + | undefined + >; + + getFile(fileName: string): Promise; + + deleteFile(fileName: string): Promise; +} + +class GridFS implements IRocketChatFileStore { + private name: string; + + private bucket: NpmModuleMongodb.GridFSBucket; + + constructor({ name = 'file' } = {}) { + this.name = name; + + this.bucket = new NpmModuleMongodb.GridFSBucket(db as any, { bucketName: this.name }); + } + + private async findOne(filename: string) { + const file = await this.bucket.find({ filename }).limit(1).toArray(); + if (!file) { + return; + } + return file[0]; + } + + async remove(fileId: string) { + await this.bucket.delete(fileId as unknown as ObjectId); + } + + createWriteStream(fileName: string, contentType: string) { + const ws = this.bucket.openUploadStream(fileName, { + contentType, + }); + + ws.on('close', () => { + return ws.emit('end'); + }); + return ws; + } + + createReadStream(fileName: string) { + return this.bucket.openDownloadStreamByName(fileName); + } + + async getFileWithReadStream(fileName: string) { + const file = await this.findOne(fileName); + if (!file) { + return; + } + const rs = this.createReadStream(fileName); + return { + readStream: rs, + contentType: file.contentType, + length: file.length, + uploadDate: file.uploadDate, + }; + } + + async getFile(fileName: string) { + const file = await this.getFileWithReadStream(fileName); + if (!file) { + return; + } + return new Promise((resolve) => { + const data: Buffer[] = []; + file.readStream.on('data', (chunk) => { + return data.push(chunk); + }); + + file.readStream.on('end', () => { + resolve({ + buffer: Buffer.concat(data), + contentType: file.contentType, + length: file.length, + uploadDate: file.uploadDate, + }); + }); + }); + } + + async deleteFile(fileName: string) { + const file = await this.findOne(fileName); + if (file == null) { + return undefined; + } + return this.remove(file._id as unknown as string); + } +} + +class FileSystem implements IRocketChatFileStore { + private absolutePath: string; + + constructor({ absolutePath = '~/uploads' } = {}) { + if (absolutePath.split(path.sep)[0] === '~') { + const homepath = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; + if (homepath != null) { + absolutePath = absolutePath.replace('~', homepath); + } else { + throw new Error('Unable to resolve "~" in path'); + } + } + this.absolutePath = path.resolve(absolutePath); + mkdirp.sync(this.absolutePath); + } + + createWriteStream(fileName: string) { + const ws = fs.createWriteStream(path.join(this.absolutePath, sanitizeFileName(fileName))); + ws.on('close', () => { + return ws.emit('end'); + }); + return ws; + } + + createReadStream(fileName: string) { + return fs.createReadStream(path.join(this.absolutePath, sanitizeFileName(fileName))); + } + + stat(fileName: string) { + return fsp.stat(path.join(this.absolutePath, sanitizeFileName(fileName))); + } + + async remove(fileName: string) { + return fsp.unlink(path.join(this.absolutePath, sanitizeFileName(fileName))); + } + + async getFileWithReadStream(fileName: string) { + try { + const stat = await this.stat(fileName); + const rs = this.createReadStream(fileName); + return { + readStream: rs, + // We currently don't store the content type of uploaded custom sounds when using + // The filesystem storage. We will use mime to infer its type from the extension. + contentType: (mime.lookup(fileName) as string) || 'application/octet-stream', + length: stat.size, + }; + } catch (error1) { + console.error(error1); + } + } + + async getFile(fileName: string) { + const file = await this.getFileWithReadStream(fileName); + if (!file) { + return; + } + return new Promise((resolve) => { + const data: Buffer[] = []; + file.readStream.on('data', (chunk) => { + if (Buffer.isBuffer(chunk)) { + return data.push(chunk); + } + return data.push(Buffer.from(chunk)); + }); + file.readStream.on('end', () => { + resolve({ + buffer: Buffer.concat(data), + length: file.length, + }); + }); + }); + } + + async deleteFile(fileName: string) { + try { + return await this.remove(fileName); + } catch (error1) { + console.error(error1); + } + } +} + +export const RocketChatFile = { + bufferToStream(buffer: Buffer) { + return Readable.from(buffer); + }, + + dataURIParse(dataURI: string | Buffer) { + const imageData = (Buffer.isBuffer(dataURI) ? dataURI : Buffer.from(dataURI)).toString().split(';base64,'); + return { + image: imageData[1], + contentType: imageData[0].replace('data:', ''), + }; + }, + + GridFS, + FileSystem, +}; diff --git a/apps/meteor/server/lib/media/file/functions/sanitizeFileName.spec.ts b/apps/meteor/server/lib/media/file/functions/sanitizeFileName.spec.ts new file mode 100644 index 0000000..bbf09eb --- /dev/null +++ b/apps/meteor/server/lib/media/file/functions/sanitizeFileName.spec.ts @@ -0,0 +1,55 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { sanitizeFileName } from './sanitizeFileName'; + +describe('sanitizeFileName', () => { + describe('valid filenames', () => { + it('should allow simple filenames', () => { + const result = sanitizeFileName('sound.mp3'); + expect(result).to.equal('sound.mp3'); + }); + + it('should allow filenames with dashes, underscores and dots', () => { + const result = sanitizeFileName('alert_test-01.wav'); + expect(result).to.equal('alert_test-01.wav'); + }); + + it('should allow filenames without extension', () => { + const result = sanitizeFileName('beep'); + expect(result).to.equal('beep'); + }); + }); + + describe('invalid paths', () => { + it('should reject path traversal using ../', () => { + expect(() => sanitizeFileName('../etc/passwd')).to.throw(); + }); + + it('should reject path traversal using ./', () => { + expect(() => sanitizeFileName('./folder/sounds')).to.throw(); + }); + + it('should reject nested paths', () => { + expect(() => sanitizeFileName('sounds/alert.mp3')).to.throw(); + }); + + it('should reject absolute paths', () => { + expect(() => sanitizeFileName('/etc/passwd')).to.throw(); + }); + }); + + describe('invalid characters', () => { + it('should reject filenames with spaces', () => { + expect(() => sanitizeFileName('my sound.mp3')).to.throw(); + }); + + it('should reject filenames with special characters', () => { + expect(() => sanitizeFileName('sound$.mp3')).to.throw(); + }); + + it('should reject filenames with backslashes', () => { + expect(() => sanitizeFileName('..\\passwd')).to.throw(); + }); + }); +}); diff --git a/apps/meteor/server/lib/media/file/functions/sanitizeFileName.ts b/apps/meteor/server/lib/media/file/functions/sanitizeFileName.ts new file mode 100644 index 0000000..fb19617 --- /dev/null +++ b/apps/meteor/server/lib/media/file/functions/sanitizeFileName.ts @@ -0,0 +1,19 @@ +import path from 'node:path'; + +export function sanitizeFileName(fileName: string) { + const base = path.basename(fileName); + + if (base !== fileName) { + throw new Error('error-invalid-file-name'); + } + + if (base === '.' || base.startsWith('..')) { + throw new Error('error-invalid-file-name'); + } + + if (!/^[a-zA-Z0-9._-]+$/.test(base)) { + throw new Error('error-invalid-characters-in-file-name'); + } + + return base; +} diff --git a/apps/meteor/server/lib/media/file/index.ts b/apps/meteor/server/lib/media/file/index.ts new file mode 100644 index 0000000..dee3df2 --- /dev/null +++ b/apps/meteor/server/lib/media/file/index.ts @@ -0,0 +1,3 @@ +import { RocketChatFile } from './file.server'; + +export { RocketChatFile }; diff --git a/apps/meteor/server/lib/messages/attachMessage.ts b/apps/meteor/server/lib/messages/attachMessage.ts new file mode 100644 index 0000000..d232812 --- /dev/null +++ b/apps/meteor/server/lib/messages/attachMessage.ts @@ -0,0 +1,35 @@ +import { getUserDisplayName } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, MessageAttachment } from '@rocket.chat/core-typings'; + +import { settings } from '../../settings/cached'; +import { roomCoordinator } from '../rooms/roomCoordinator'; +import { getUserAvatarURL } from '../utils/getUserAvatarURL'; + +export const attachMessage = function ( + message: IMessage, + room: IRoom, +): { + text: string; + author_name?: string; + author_icon: string; + message_link: string; + attachments?: MessageAttachment[]; + ts: Date; +} { + const useRealName = Boolean(settings.get('UI_Use_Real_Name')); + const { + msg, + u: { username, name }, + ts, + attachments, + _id, + } = message; + return { + text: msg, + author_name: getUserDisplayName(name, username, useRealName), + author_icon: getUserAvatarURL(username) as string, + message_link: `${roomCoordinator.getRouteLink(room.t, room)}?msg=${_id}`, + ...(attachments && { attachments }), + ts, + }; +}; diff --git a/apps/meteor/server/lib/messages/deleteMessage.ts b/apps/meteor/server/lib/messages/deleteMessage.ts new file mode 100644 index 0000000..3331a32 --- /dev/null +++ b/apps/meteor/server/lib/messages/deleteMessage.ts @@ -0,0 +1,129 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { api, Message } from '@rocket.chat/core-services'; +import { isThreadMessage, type AtLeast, type IMessage, type IRoom, type IThreadMessage, type IUser } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Uploads, Users, ReadReceipts, ReadReceiptsArchive, Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { canDeleteMessageAsync } from '../authorization/canDeleteMessage'; +import { callbacks } from '../callbacks'; +import { FileUpload } from '../media/file-upload'; +import { notifyOnRoomChangedById, notifyOnMessageChange, notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener'; + +export const deleteMessageValidatingPermission = async (message: AtLeast, userId: IUser['_id']): Promise => { + if (!message?._id) { + throw new Meteor.Error('error-invalid-message', 'Invalid message'); + } + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const user = await Users.findOneById(userId); + const originalMessage = await Messages.findOneById(message._id); + + if (!originalMessage || !user || !(await canDeleteMessageAsync(user, originalMessage))) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed'); + } + + return deleteMessage(originalMessage, user); +}; + +export async function deleteMessage(message: IMessage, user: IUser): Promise { + const deletedMsg: IMessage | null = await Messages.findOneById(message._id); + const isThread = (deletedMsg?.tcount || 0) > 0; + const keepHistory = settings.get('Message_KeepHistory') || isThread; + const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread; + + const room = await Rooms.findOneById(message.rid, { projection: { lastMessage: 1, prid: 1, mid: 1, federated: 1, federation: 1 } }); + + if (deletedMsg) { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageDeletePrevent, deletedMsg); + if (prevent) { + throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.'); + } + + if (room) { + await Message.beforeDelete(deletedMsg, room); + } + } + + if (deletedMsg && isThreadMessage(deletedMsg)) { + await deleteThreadMessage(deletedMsg, user, room); + } + + const files = (message.files || [message.file]).filter(Boolean); // Keep compatibility with old messages + + if (keepHistory) { + if (showDeletedStatus) { + // TODO is there a better way to tell TS "IUser[username]" is not undefined? + await Messages.cloneAndSaveAsHistoryById(message._id, user as Required>); + } else { + await Messages.setHiddenById(message._id, true); + } + + for (const file of files) { + file?._id && (await Uploads.updateOne({ _id: file._id }, { $set: { _hidden: true } })); + } + } else { + if (!showDeletedStatus) { + await Messages.removeById(message._id); + } + await ReadReceipts.removeByMessageId(message._id); + await ReadReceiptsArchive.removeByMessageId(message._id); + + for (const file of files) { + file?._id && (await FileUpload.getStore('Uploads').deleteById(file._id)); + } + } + if (showDeletedStatus) { + // TODO is there a better way to tell TS "IUser[username]" is not undefined? + await Messages.setAsDeletedByIdAndUser(message._id, user as Required>); + } else { + void api.broadcast('notify.deleteMessage', message.rid, { _id: message._id }); + } + + // update last message + if (settings.get('Store_Last_Message') && (!room?.lastMessage || room.lastMessage._id === message._id)) { + const lastMessageNotDeleted = await Messages.getLastVisibleUserMessageSentByRoomId(message.rid); + await Rooms.resetLastMessageById(message.rid, lastMessageNotDeleted, -1); + } else { + // decrease message count + await Rooms.decreaseMessageCountById(message.rid, 1); + } + + await callbacks.run('afterDeleteMessage', deletedMsg, { room, user }); + + void notifyOnRoomChangedById(message.rid); + + if (keepHistory || showDeletedStatus) { + void notifyOnMessageChange({ + id: message._id, + }); + } + + if (deletedMsg) { + void Apps.self?.triggerEvent(AppEvents.IPostMessageDeleted, deletedMsg, user); + } +} + +async function deleteThreadMessage(message: IThreadMessage, user: IUser, room: IRoom | null): Promise { + const updatedParentMessage = await Messages.decreaseReplyCountById(message.tmid, -1); + + if (room) { + const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(room._id, [message.tmid]); + if (modifiedCount > 0) { + // The replies array contains the ids of all the users that are following the thread (everyone that is involved + the ones who are following) + // Technically, user._id is already in the message.replies array, but since we don't have any strong + // guarantees of it, we are adding again to make sure it is there. + const userIdsThatAreWatchingTheThread = [...new Set([user._id, ...(message.replies || [])])]; + // So they can decrement the unread threads count + void notifyOnSubscriptionChangedByRoomIdAndUserIds(room._id, userIdsThatAreWatchingTheThread); + } + } + + if (updatedParentMessage?.tcount === 0) { + void notifyOnMessageChange({ + id: message.tmid, + }); + } +} diff --git a/apps/meteor/server/lib/messages/extractMentionsFromMessageAST.ts b/apps/meteor/server/lib/messages/extractMentionsFromMessageAST.ts new file mode 100644 index 0000000..441aa81 --- /dev/null +++ b/apps/meteor/server/lib/messages/extractMentionsFromMessageAST.ts @@ -0,0 +1,63 @@ +import type { Root, Paragraph, Blocks, Inlines, UserMention, ChannelMention, Task, ListItem, BigEmoji } from '@rocket.chat/message-parser'; + +type ExtractedMentions = { + mentions: string[]; + channels: string[]; +}; + +type MessageNode = Paragraph | Blocks | Inlines | Task | ListItem | BigEmoji; + +function isUserMention(node: MessageNode): node is UserMention { + return node.type === 'MENTION_USER'; +} + +function isChannelMention(node: MessageNode): node is ChannelMention { + return node.type === 'MENTION_CHANNEL'; +} + +function hasArrayValue(node: MessageNode): node is MessageNode & { value: MessageNode[] } { + return Array.isArray(node.value); +} + +function hasObjectValue(node: MessageNode): node is MessageNode & { value: Record } { + return typeof node.value === 'object' && node.value !== null && !Array.isArray(node.value); +} + +function traverse(node: MessageNode, mentions: Set, channels: Set): void { + if (isUserMention(node)) { + mentions.add(node.value.value); + return; + } + + if (isChannelMention(node)) { + channels.add(node.value.value); + return; + } + + if (hasArrayValue(node)) { + for (const child of node.value) { + traverse(child, mentions, channels); + } + return; + } + + if (hasObjectValue(node)) { + for (const key of Object.keys(node.value)) { + traverse(node.value[key], mentions, channels); + } + } +} + +export function extractMentionsFromMessageAST(ast: Root): ExtractedMentions { + const mentions = new Set(); + const channels = new Set(); + + for (const node of ast) { + traverse(node, mentions, channels); + } + + return { + mentions: Array.from(mentions), + channels: Array.from(channels), + }; +} diff --git a/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.spec.ts b/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.spec.ts new file mode 100644 index 0000000..19e9148 --- /dev/null +++ b/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { extractUrlsFromMessageAST } from './extractUrlsFromMessageAST'; + +describe('extractUrlsFromMessageAST', () => { + it('should extract URLs from LINK nodes', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://rocket.chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'rocket.chat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://rocket.chat']); + }); + + // TODO: this spec was never wired to a test runner; these two expectations predate the + // current implementation, which returns LINK src values without protocol normalization. + it.skip('should convert // prefix to https://', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: '//github.com/RocketChat/Rocket.Chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'github.com/RocketChat/Rocket.Chat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://github.com/RocketChat/Rocket.Chat']); + }); + + it.skip('should handle multiple links', () => { + const md = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: 'https://rocket.chat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'rocket.chat', + }, + ], + }, + }, + { + type: 'PLAIN_TEXT', + value: ' and ', + }, + { + type: 'LINK', + value: { + src: { + type: 'PLAIN_TEXT', + value: '//github.com/RocketChat', + }, + label: [ + { + type: 'PLAIN_TEXT', + value: 'github.com/RocketChat', + }, + ], + }, + }, + ], + }, + ]; + + const urls = extractUrlsFromMessageAST(md as any); + expect(urls).to.deep.equal(['https://rocket.chat', 'https://github.com/RocketChat']); + }); + + it('should return empty array for undefined or non-array input', () => { + expect(extractUrlsFromMessageAST(undefined)).to.deep.equal([]); + expect(extractUrlsFromMessageAST(null as any)).to.deep.equal([]); + expect(extractUrlsFromMessageAST({} as any)).to.deep.equal([]); + }); +}); diff --git a/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.ts b/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.ts new file mode 100644 index 0000000..222aa7c --- /dev/null +++ b/apps/meteor/server/lib/messages/extractUrlsFromMessageAST.ts @@ -0,0 +1,33 @@ +import type { Root } from '@rocket.chat/message-parser'; + +/** + * Extracts all URLs from parsed message AST (message-parser output) + * Looks for LINK nodes and extracts the src URL + */ +export const extractUrlsFromMessageAST = (md?: Root | Root[number] | Root[number]['value']): string[] => { + if (!md || !Array.isArray(md)) { + return []; + } + + const urls: string[] = []; + + const walk = (node: any): void => { + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (typeof node !== 'object' || node === null) { + return; + } + if (node.type === 'LINK' && node.value?.src?.value) { + urls.push(node.value.src.value); + } + if (node.value !== undefined) { + walk(node.value); + } + }; + + walk(md); + + return urls; +}; diff --git a/apps/meteor/server/lib/messages/insertMessage.ts b/apps/meteor/server/lib/messages/insertMessage.ts new file mode 100644 index 0000000..3c053ba --- /dev/null +++ b/apps/meteor/server/lib/messages/insertMessage.ts @@ -0,0 +1,48 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; + +import { parseUrlsInMessage } from './parseUrlsInMessage'; +import { validateMessage, prepareMessageObject } from './sendMessage'; + +// TODO: remove and move to Message.Service +export const insertMessage = async function ( + user: Pick, + message: IMessage, + rid: IRoom['_id'], + upsert = false, +): Promise { + if (!user || !message || !rid) { + return false; + } + + await validateMessage(message, { _id: rid }, user); + prepareMessageObject(message, rid, user); + message.urls = parseUrlsInMessage(message); + + if (message._id && upsert) { + const { _id, ...rest } = message; + const existingMessage = await Messages.findOneById(_id); + if (existingMessage) { + await Messages.updateOne( + { + _id, + 'u._id': message.u._id, + }, + { $set: rest }, + ); + } else { + await Messages.insertOne({ + _id, + ...rest, + }); + await Rooms.incMsgCountById(rid, 1); + } + message._id = _id; + } else { + const result = await Messages.insertOne(message); + message._id = result.insertedId; + await Rooms.incMsgCountById(rid, 1); + } + + return message; +}; diff --git a/apps/meteor/server/lib/messages/isTheLastMessage.ts b/apps/meteor/server/lib/messages/isTheLastMessage.ts new file mode 100644 index 0000000..d6772a2 --- /dev/null +++ b/apps/meteor/server/lib/messages/isTheLastMessage.ts @@ -0,0 +1,6 @@ +import type { IMessage, IRoom, AtLeast } from '@rocket.chat/core-typings'; + +import { settings } from '../../settings'; + +export const isTheLastMessage = (room: AtLeast, message: Pick) => + settings.get('Store_Last_Message') && (!room.lastMessage || room.lastMessage._id === message._id); diff --git a/apps/meteor/server/lib/messages/loadMessageHistory.ts b/apps/meteor/server/lib/messages/loadMessageHistory.ts new file mode 100644 index 0000000..a93b1db --- /dev/null +++ b/apps/meteor/server/lib/messages/loadMessageHistory.ts @@ -0,0 +1,94 @@ +import type { IMessage, IRoom, MessageTypesValues } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +import { settings } from '../../settings/cached'; +import { getHiddenSystemMessages } from '../messaging/getHiddenSystemMessages'; +import { normalizeMessagesForUser } from '../utils/lib/normalizeMessagesForUser'; + +export async function loadMessageHistory({ + userId, + rid, + end, + limit = 20, + ls, + showThreadMessages = true, + offset = 0, + room: providedRoom, +}: { + // userId is undefined if user is reading anonymously + userId?: string; + rid: string; + end: Date | undefined; + limit?: number; + ls?: string | Date; + showThreadMessages?: boolean; + offset?: number; + room?: IRoom; +}) { + const room = providedRoom ?? (await Rooms.findOneById(rid, { projection: { sysMes: 1 } })); + + if (!room) { + throw new Error('error-invalid-room'); + } + + const hiddenSystemMessages = settings.get('Hide_System_Messages'); + + const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages); + + const options: FindOptions = { + sort: { + ts: -1, + }, + limit, + skip: offset, + }; + + const records = end + ? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( + rid, + end, + hiddenMessageTypes, + options, + showThreadMessages, + ).toArray() + : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); + + const firstMessage = records[records.length - 1]; + const lastSeen = ls ? new Date(ls) : undefined; + const hasValidLastSeen = lastSeen !== undefined && !Number.isNaN(lastSeen.getTime()); + + const computeUnread = async (): Promise<{ firstUnread: IMessage | undefined; unreadNotLoaded: number }> => { + if (!hasValidLastSeen || !lastSeen || !firstMessage || !(new Date(firstMessage.ts) > lastSeen)) { + return { firstUnread: undefined, unreadNotLoaded: 0 }; + } + + const [firstUnreadRecords, unreadNotLoaded] = await Promise.all([ + Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + lastSeen, + firstMessage.ts, + hiddenMessageTypes, + { limit: 1, sort: { ts: 1 } }, + showThreadMessages, + ).toArray(), + Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + lastSeen, + firstMessage.ts, + hiddenMessageTypes, + showThreadMessages, + ), + ]); + + return { firstUnread: firstUnreadRecords[0], unreadNotLoaded }; + }; + + const [messages, unread] = await Promise.all([normalizeMessagesForUser(records, userId), computeUnread()]); + + return { + messages, + firstUnread: unread.firstUnread, + unreadNotLoaded: unread.unreadNotLoaded, + }; +} diff --git a/apps/meteor/server/lib/messages/loadRoomHistory.ts b/apps/meteor/server/lib/messages/loadRoomHistory.ts new file mode 100644 index 0000000..107a3b1 --- /dev/null +++ b/apps/meteor/server/lib/messages/loadRoomHistory.ts @@ -0,0 +1,248 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, MessageTypesValues } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +import { settings } from '../../settings/cached'; +import { getHiddenSystemMessages } from '../messaging/getHiddenSystemMessages'; +import { normalizeMessagesForUser } from '../utils/lib/normalizeMessagesForUser'; + +// The range query needs a closed upper bound; `new Date()` would drop client-stamped future `ts`. +const FAR_FUTURE = new Date(8640000000000000); + +export type RoomHistoryCursor = { + next: string | null; + previous: string | null; +}; + +export type RoomHistoryResult = { + messages: IMessage[]; + cursor: RoomHistoryCursor; + firstUnread?: IMessage; + unreadNotLoaded?: number; +}; + +export function encodeHistoryCursor(ts: Date): string { + return `${ts.getTime()}`; +} + +export function decodeHistoryCursor(cursor: string): Date { + if (!/^\d+$/.test(cursor)) { + throw new MeteorError('error-invalid-cursor', 'Invalid pagination cursor'); + } + + const date = new Date(parseInt(cursor, 10)); + + if (date.toString() === 'Invalid Date') { + throw new MeteorError('error-invalid-cursor', 'Invalid pagination cursor'); + } + + return date; +} + +/** + * Cursor-paginated room history, ordered newest-first regardless of paging direction. + * + * `lastSeen` positions the unread divider only. Using it as a pagination bound instead would truncate + * the page at the marker, which is why the per-type `*.history` endpoints cannot serve this. + */ +export async function loadRoomHistory({ + userId, + next, + previous, + around, + lastSeen, + count = 20, + showThreadMessages = true, + room, +}: { + userId?: string; + next?: string; + previous?: string; + around?: IMessage; + lastSeen?: Date; + count?: number; + showThreadMessages?: boolean; + room: IRoom; +}): Promise { + if ([next, previous, around].filter(Boolean).length > 1) { + throw new MeteorError('error-cursor-conflict', 'Only one of "next", "previous" and "aroundId" can be provided'); + } + + const rid = room._id; + + const hiddenMessageTypes = getHiddenSystemMessages(room, settings.get('Hide_System_Messages')); + + const { records, hasNewer, hasOlder } = around + ? await loadAround({ rid, around, count, hiddenMessageTypes, showThreadMessages }) + : await loadPage({ rid, next, previous, count, hiddenMessageTypes, showThreadMessages }); + + const newest = records[0]; + const oldest = records[records.length - 1]; + + const cursor: RoomHistoryCursor = { + next: newest && hasNewer ? encodeHistoryCursor(newest.ts) : null, + previous: oldest && hasOlder ? encodeHistoryCursor(oldest.ts) : null, + }; + + const [messages, unread] = await Promise.all([ + normalizeMessagesForUser(records, userId), + computeUnread({ rid, userId, lastSeen, oldest, hiddenMessageTypes, showThreadMessages }), + ]); + + return { messages, cursor, ...unread }; +} + +// In-memory `_id` tie-break for equal `ts`: the `{ rid, ts, _updatedAt }` index cannot back a +// `{ ts, _id }` sort, and without it MongoDB's order among equal timestamps is unspecified. +function sortByTs(records: IMessage[], direction: 1 | -1): IMessage[] { + return records.sort((a, b) => (a.ts.getTime() - b.ts.getTime() || (a._id < b._id ? -1 : 1)) * direction); +} + +type Window = { records: IMessage[]; hasNewer: boolean; hasOlder: boolean }; + +async function loadPage({ + rid, + next, + previous, + count, + hiddenMessageTypes, + showThreadMessages, +}: { + rid: IRoom['_id']; + next?: string; + previous?: string; + count: number; + hiddenMessageTypes: MessageTypesValues[]; + showThreadMessages: boolean; +}): Promise { + // One extra document reveals whether a further page exists. + const options: FindOptions = { sort: { ts: next ? 1 : -1 }, limit: count + 1 }; + + const records = next + ? await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + decodeHistoryCursor(next), + FAR_FUTURE, + hiddenMessageTypes, + options, + showThreadMessages, + ).toArray() + : await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( + rid, + previous ? decodeHistoryCursor(previous) : FAR_FUTURE, + hiddenMessageTypes, + options, + showThreadMessages, + ).toArray(); + + sortByTs(records, next ? 1 : -1); + + const hasMoreInPagingDirection = records.length > count; + + if (hasMoreInPagingDirection) { + records.pop(); + } + + if (next) { + records.reverse(); + } + + // Null means no more in that direction. The side we came from always has more — except the first + // page, which starts at the newest end. + return { + records, + hasNewer: next ? hasMoreInPagingDirection : Boolean(previous), + hasOlder: next ? true : hasMoreInPagingDirection, + }; +} + +async function loadAround({ + rid, + around, + count, + hiddenMessageTypes, + showThreadMessages, +}: { + rid: IRoom['_id']; + around: IMessage; + count: number; + hiddenMessageTypes: MessageTypesValues[]; + showThreadMessages: boolean; +}): Promise { + // A non-positive count would produce a Mongo `limit: 0`, which means "no limit". + const remaining = Math.max(count, 1) - 1; + const olderLimit = Math.ceil(remaining / 2); + const newerLimit = Math.floor(remaining / 2); + + const [older, newer] = await Promise.all([ + Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( + rid, + around.ts, + hiddenMessageTypes, + { sort: { ts: -1 }, limit: olderLimit + 1 }, + showThreadMessages, + ).toArray(), + Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + around.ts, + FAR_FUTURE, + hiddenMessageTypes, + { sort: { ts: 1 }, limit: newerLimit + 1 }, + showThreadMessages, + ).toArray(), + ]); + + sortByTs(older, -1); + sortByTs(newer, 1); + + const hasOlder = older.length > olderLimit; + const hasNewer = newer.length > newerLimit; + + if (hasOlder) { + older.pop(); + } + + if (hasNewer) { + newer.pop(); + } + + // The anchor is included whatever its type, so a jump always lands on its target. + return { records: [...newer.reverse(), around, ...older], hasNewer, hasOlder }; +} + +async function computeUnread({ + rid, + userId, + lastSeen, + oldest, + hiddenMessageTypes, + showThreadMessages, +}: { + rid: IRoom['_id']; + userId?: string; + lastSeen?: Date; + oldest?: IMessage; + hiddenMessageTypes: MessageTypesValues[]; + showThreadMessages: boolean; +}): Promise<{ firstUnread?: IMessage; unreadNotLoaded?: number }> { + if (!lastSeen || Number.isNaN(lastSeen.getTime()) || !oldest || !(new Date(oldest.ts) > lastSeen)) { + return { firstUnread: undefined, unreadNotLoaded: 0 }; + } + + const [firstUnreadRecords, unreadNotLoaded] = await Promise.all([ + Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + lastSeen, + oldest.ts, + hiddenMessageTypes, + { limit: 1, sort: { ts: 1 } }, + showThreadMessages, + ).toArray(), + Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(rid, lastSeen, oldest.ts, hiddenMessageTypes, showThreadMessages), + ]); + + const [firstUnread] = await normalizeMessagesForUser(firstUnreadRecords, userId); + + return { firstUnread, unreadNotLoaded }; +} diff --git a/apps/meteor/server/lib/messages/parseUrlsInMessage.ts b/apps/meteor/server/lib/messages/parseUrlsInMessage.ts new file mode 100644 index 0000000..1247f69 --- /dev/null +++ b/apps/meteor/server/lib/messages/parseUrlsInMessage.ts @@ -0,0 +1,40 @@ +import type { IMessage, AtLeast } from '@rocket.chat/core-typings'; + +import { extractUrlsFromMessageAST } from './extractUrlsFromMessageAST'; +import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; +import { settings } from '../../settings'; +import { Markdown } from '../messaging/markdown'; + +const prepareUrl = (url: string, previewUrls: string[] | undefined, siteUrl: string) => ({ + url, + meta: {}, + ...(previewUrls && !previewUrls.includes(url) && !url.includes(siteUrl) && { ignoreParse: true }), +}); + +const prepareUrls = (urls: string[], previewUrls?: string[]) => { + const siteUrl = settings.get('Site_Url'); + return [...new Set(urls)].map((url) => prepareUrl(url, previewUrls, siteUrl)); +}; + +export const parseUrlsInMessage = ( + message: AtLeast & { + parseUrls?: boolean; + }, + previewUrls?: string[], +) => { + // Also extract URLs from message blocks if they exist + if (message.md) { + const astUrls = extractUrlsFromMessageAST(message.md); + return prepareUrls(astUrls, previewUrls); + } + + // TODO: remove this after make the parser official + // Parse the message to extract URLs from links without schema + // The message parser converts links like "github.com" to proper links with "//" prefix + const result = Markdown.code({ + html: message.msg, + msg: message.msg, + }); + const htmlUrls = result.html?.match(getMessageUrlRegex()) || []; + return prepareUrls(htmlUrls, previewUrls); +}; diff --git a/apps/meteor/server/lib/messages/processWebhookMessage.ts b/apps/meteor/server/lib/messages/processWebhookMessage.ts new file mode 100644 index 0000000..38452d8 --- /dev/null +++ b/apps/meteor/server/lib/messages/processWebhookMessage.ts @@ -0,0 +1,204 @@ +import type { IMessage, IUser, RequiredField, MessageAttachment, IRoom } from '@rocket.chat/core-typings'; +import { removeEmpty } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { sendMessage, validateMessage } from './sendMessage'; +import { ensureArray } from '../../../lib/utils/arrayUtils'; +import { trim } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; +import { validateRoomMessagePermissionsAsync } from '../authorization/canSendMessage'; +import { SystemLogger } from '../logger/system'; +import { getRoomByNameOrIdWithOptionToJoin } from '../rooms/getRoomByNameOrIdWithOptionToJoin'; + +type Payload = { + channel?: string | string[]; + roomId?: string | string[]; + text?: IMessage['msg']; + msg?: IMessage['msg']; // overridden if text is present + username?: IMessage['alias']; + alias?: IMessage['alias']; // overridden if username is present + icon_emoji?: IMessage['emoji']; + emoji?: IMessage['emoji']; // overridden if icon_emoji is present + icon_url?: IMessage['avatar']; + avatar?: IMessage['avatar']; // overridden if icon_url is present + attachments?: IMessage['attachments']; + parseUrls?: boolean; + bot?: IMessage['bot']; + groupable?: IMessage['groupable']; + tmid?: IMessage['tmid']; + customFields?: IMessage['customFields']; +}; + +type DefaultValues = { + channel: string | string[]; + alias: string; + avatar: string; + emoji: string; +}; + +export type WebhookSuccessItem = { channel: string; error?: undefined; message: IMessage }; +export type WebhookFailureItem = { channel: string; error: string; message?: undefined }; +export type WebhookResponseItem = WebhookFailureItem | WebhookSuccessItem; + +export const validateWebhookMessage = async (message: Partial, room: IRoom | null, user: IUser) => { + if (message.msg) { + if (message.msg.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { + throw Error('error-message-size-exceeded'); + } + } + await validateMessage(message, room, user); +}; + +const getRoomWithOptionToJoin = async (channelType: string, channelValue: string, user: IUser) => { + switch (channelType) { + case '#': + return getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: channelValue, + joinChannel: true, + }); + case '@': + return getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: channelValue, + type: 'd', + }); + default: + const _channelValue = channelType + channelValue; + + // Try to find the room by id or name if they didn't include the prefix. + let room = await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: _channelValue, + joinChannel: true, + errorOnEmpty: false, + }); + if (room) { + return room; + } + + // We didn't get a room, let's try finding direct messages + room = await getRoomByNameOrIdWithOptionToJoin({ + user, + nameOrId: _channelValue, + tryDirectByUserIdOnly: true, + type: 'd', + }); + if (room) { + return room; + } + + // No room, so throw an error + throw new Meteor.Error('invalid-channel'); + } +}; + +const buildMessage = (messageObj: Payload, defaultValues: DefaultValues) => { + const message: Partial & { parseUrls?: boolean } = { + alias: messageObj.username || messageObj.alias || defaultValues.alias, + msg: trim(messageObj.text || messageObj.msg || ''), + attachments: messageObj.attachments || [], + parseUrls: messageObj.parseUrls !== undefined ? messageObj.parseUrls : !messageObj.attachments, + bot: messageObj.bot, + groupable: messageObj.groupable !== undefined ? messageObj.groupable : false, + tmid: messageObj.tmid, + customFields: messageObj.customFields, + }; + + if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) { + message.avatar = messageObj.icon_url || messageObj.avatar; + } else if (!_.isEmpty(messageObj.icon_emoji) || !_.isEmpty(messageObj.emoji)) { + message.emoji = messageObj.icon_emoji || messageObj.emoji; + } else if (!_.isEmpty(defaultValues.avatar)) { + message.avatar = defaultValues.avatar; + } else if (!_.isEmpty(defaultValues.emoji)) { + message.emoji = defaultValues.emoji; + } + + if (Array.isArray(message.attachments)) { + for (let i = 0; i < message.attachments.length; i++) { + const attachment = message.attachments[i] as MessageAttachment & { msg?: string }; + if (attachment.msg) { + attachment.text = trim(attachment.msg); + delete attachment.msg; + } + } + } + + return message; +}; + +export function processWebhookMessage( + messageObj: Payload & { separateResponse: true }, + user: RequiredField, + defaultValues?: DefaultValues, +): Promise; + +export function processWebhookMessage( + messageObj: Payload & { separateResponse?: false | undefined }, + user: RequiredField, + defaultValues?: DefaultValues, +): Promise; + +export async function processWebhookMessage( + messageObj: Payload & { + /** + * If true, the response will be sent separately for each channel. Messages will be sent to other channels even if one or more fails. If false or not provided, messages would not be sent to any channel if one or more fails. + */ + separateResponse?: boolean; + }, + user: RequiredField, + defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, +) { + const rooms: ({ channel: string } & ({ room: IRoom } | { room: IRoom | null; error?: any }))[] = []; + const sentData: WebhookResponseItem[] = []; + + const channels: Array = [...new Set(ensureArray(messageObj.channel || messageObj.roomId || defaultValues.channel))]; + + if (messageObj.attachments && !Array.isArray(messageObj.attachments)) { + SystemLogger.warn({ + msg: 'Attachments should be Array, ignoring value', + attachments: messageObj.attachments, + }); + messageObj.attachments = undefined; + } + + const message = buildMessage(messageObj, defaultValues); + + for (const channel of channels) { + const channelType = channel[0]; + const channelValue = channel.slice(1); + let room: IRoom | null = null; + try { + room = await getRoomWithOptionToJoin(channelType, channelValue, user); + if (!room) { + throw new Error('error-invalid-room'); + } + await validateRoomMessagePermissionsAsync(room, { uid: user._id, ...user }); + await validateWebhookMessage(message, room, user); + rooms.push({ room, channel }); + } catch (_error: any) { + if (messageObj.separateResponse) { + const { error, message } = _error || {}; + const errorMessage = error || message || 'unknown-error'; + rooms.push({ error: errorMessage, room, channel }); + continue; + } + throw _error; + } + } + + for (const roomData of rooms) { + if ('error' in roomData && roomData.error) { + if (messageObj.separateResponse) { + sentData.push({ channel: roomData.channel, error: roomData.error }); + continue; + } + } + const messageReturn = await sendMessage(user, removeEmpty(message), roomData.room); + sentData.push({ channel: roomData.channel, message: messageReturn }); + } + + return sentData; +} diff --git a/apps/meteor/server/lib/messages/sendMessage.ts b/apps/meteor/server/lib/messages/sendMessage.ts new file mode 100644 index 0000000..082c376 --- /dev/null +++ b/apps/meteor/server/lib/messages/sendMessage.ts @@ -0,0 +1,298 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { isAbsoluteURL } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; + +import { isRelativeURL } from '../../../lib/utils/isRelativeURL'; +import { afterSaveMessage } from '../../hooks/messages/afterSaveMessage'; +import { settings } from '../../settings'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { FileUpload } from '../media/file-upload'; +import { validateCustomMessageFields } from '../messaging/validateCustomMessageFields'; +import { notifyOnRoomChangedById } from '../notifyListener'; + +export type SendMessageOptions = { + upsert?: boolean; + previewUrls?: string[]; + skipNotifications?: boolean; +}; + +// TODO: most of the types here are wrong, but I don't want to change them now + +/** + * IMPORTANT + * + * This validator prevents malicious href values + * intending to run arbitrary js code in anchor tags. + * You should use it whenever the value you're checking + * is going to be rendered in the href attribute of a + * link. + */ +const validFullURLParam = Match.Where((value) => { + check(value, String); + + if (!isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) { + throw new Error('Invalid href value provided'); + } + + if (/^javascript:/i.test(value)) { + throw new Error('Invalid href value provided'); + } + + return true; +}); + +const validPartialURLParam = Match.Where((value) => { + check(value, String); + + if (!isRelativeURL(value) && !isAbsoluteURL(value) && !value.startsWith(FileUpload.getPath())) { + throw new Error('Invalid href value provided'); + } + + if (/^javascript:/i.test(value)) { + throw new Error('Invalid href value provided'); + } + + return true; +}); + +const objectMaybeIncluding = (types: any) => + Match.Where((value: any) => { + Object.keys(types).forEach((field) => { + if (value[field] != null) { + try { + check(value[field], types[field]); + } catch (error: any) { + error.path = field; + throw error; + } + } + }); + + return true; + }); + +const validateAttachmentsFields = (attachmentField: any) => { + check( + attachmentField, + objectMaybeIncluding({ + short: Boolean, + title: String, + value: Match.OneOf(String, Number, Boolean), + }), + ); + + if (!attachmentField.value || !attachmentField.title) { + throw new Error('Invalid attachment field, title and value is required'); + } +}; + +const validateAttachmentsActions = (attachmentActions: any) => { + check( + attachmentActions, + objectMaybeIncluding({ + type: String, + text: String, + url: validFullURLParam, + image_url: validFullURLParam, + is_webview: Boolean, + webview_height_ratio: String, + msg: String, + msg_in_chat_window: Boolean, + }), + ); +}; + +const validateAttachment = (attachment: any) => { + check( + attachment, + objectMaybeIncluding({ + color: String, + text: String, + ts: Match.OneOf(String, Number), + thumb_url: validFullURLParam, + button_alignment: String, + actions: [Match.Any], + message_link: validFullURLParam, + collapsed: Boolean, + author_name: String, + author_link: validFullURLParam, + author_icon: validFullURLParam, + title: String, + title_link: validFullURLParam, + title_link_download: Boolean, + image_dimensions: Object, + image_url: validFullURLParam, + image_preview: String, + image_type: String, + image_size: Number, + audio_url: validFullURLParam, + audio_type: String, + audio_size: Number, + video_url: validFullURLParam, + video_type: String, + video_size: Number, + fields: [Match.Any], + }), + ); + + if (attachment.fields?.length) { + attachment.fields.map(validateAttachmentsFields); + } + + if (attachment.actions?.length) { + attachment.actions.map(validateAttachmentsActions); + } +}; + +const validateBodyAttachments = (attachments: any[]) => attachments.map(validateAttachment); + +export const validateMessage = async (message: any, room: any, user: any) => { + check( + message, + objectMaybeIncluding({ + _id: String, + msg: String, + text: String, + alias: String, + emoji: String, + tmid: String, + tshow: Boolean, + avatar: validPartialURLParam, + attachments: [Match.Any], + blocks: [Match.Any], + }), + ); + + if (message.alias || message.avatar) { + const isLiveChatGuest = !message.avatar && user.token && user.token === room.v?.token; + + if (!isLiveChatGuest && !(await hasPermissionAsync(user._id, 'message-impersonate', room._id))) { + throw new Error('Not enough permission'); + } + } + + if (Array.isArray(message.attachments) && message.attachments.length) { + validateBodyAttachments(message.attachments); + } + + if (message.customFields) { + validateCustomMessageFields({ + customFields: message.customFields, + messageCustomFieldsEnabled: settings.get('Message_CustomFields_Enabled'), + messageCustomFields: settings.get('Message_CustomFields'), + }); + } +}; + +export function prepareMessageObject( + message: Partial, + rid: IRoom['_id'], + user: { _id: string; username?: string; name?: string }, +): asserts message is IMessage { + if (!message.ts) { + message.ts = new Date(); + } + + if (message.tshow !== true) { + delete message.tshow; + } + + const { _id, username, name } = user; + message.u = { + _id, + username: username as string, // FIXME: this is wrong but I don't want to change it now + name, + }; + message.rid = rid; + + if (!Match.test(message.msg, String)) { + message.msg = ''; + } + + if (message.ts == null) { + message.ts = new Date(); + } +} + +/** + * Validates and sends the message object. This function does not verify the Message_MaxAllowedSize settings. + * Caller of the function should verify the Message_MaxAllowedSize if needed. + * There might be same use cases which needs to override this setting. Example - sending error logs. + */ +export const sendMessage = async function (user: any, message: any, room: any, options: SendMessageOptions = {}) { + const { upsert = false, previewUrls } = options; + + if (!user || !message || !room._id) { + return false; + } + + await validateMessage(message, room, user); + prepareMessageObject(message, room._id, user); + + if (settings.get('Message_Read_Receipt_Enabled')) { + message.unread = true; + } + + // For the Rocket.Chat Apps :) + if (Apps.self?.isLoaded()) { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageSentPrevent, message); + + if (prevent) { + return; + } + + const result = await Apps.self?.triggerEvent( + AppEvents.IPreMessageSentModify, + await Apps.self?.triggerEvent(AppEvents.IPreMessageSentExtend, message), + ); + + if (typeof result === 'object') { + message = Object.assign(message, result); + + // Some app may have inserted malicious/invalid values in the message, let's check it again + await validateMessage(message, room, user); + } + } + + message = await Message.beforeSave({ message, room, user, previewUrls, parseUrls: message.parseUrls }); + + if (!message) { + return; + } + + if (message._id && upsert) { + const { _id } = message; + delete message._id; + await Messages.updateOne( + { + _id, + 'u._id': message.u._id, + }, + { $set: message }, + { upsert: true }, + ); + message._id = _id; + } else { + const messageAlreadyExists = message._id && (await Messages.findOneById(message._id, { projection: { _id: 1 } })); + if (messageAlreadyExists) { + return; + } + const { insertedId } = await Messages.insertOne(message); + message._id = insertedId; + } + + if (Apps.self?.isLoaded()) { + // If the message has a type (system message), we should notify the listener about it + const messageEvent = message.t ? AppEvents.IPostSystemMessageSent : AppEvents.IPostMessageSent; + void Apps.self?.triggerEvent(messageEvent, message); + } + + await afterSaveMessage(message, room, user, { options }); + + void notifyOnRoomChangedById(message.rid); + + return message; +}; diff --git a/apps/meteor/server/lib/messages/updateMessage.ts b/apps/meteor/server/lib/messages/updateMessage.ts new file mode 100644 index 0000000..3b5410d --- /dev/null +++ b/apps/meteor/server/lib/messages/updateMessage.ts @@ -0,0 +1,108 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { afterSaveMessage } from '../../hooks/messages/afterSaveMessage'; +import { settings } from '../../settings'; +import { validateCustomMessageFields } from '../messaging/validateCustomMessageFields'; +import { notifyOnRoomChangedById } from '../notifyListener'; + +export const updateMessage = async function ( + { + parseUrls, + ...message + }: (AtLeast | AtLeast) & { + parseUrls?: boolean; + }, + user: IUser, + originalMsg?: IMessage, + previewUrls?: string[], +): Promise { + const originalMessage = originalMsg || (await Messages.findOneById(message._id)); + if (!originalMessage) { + throw new Error('Invalid message ID.'); + } + + let messageData: IMessage = Object.assign({}, originalMessage, message); + + // For the Rocket.Chat Apps :) + if (message && Apps.self && Apps.isLoaded()) { + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedPrevent, messageData); + if (prevent) { + throw new Meteor.Error('error-app-prevented-updating', 'A Rocket.Chat App prevented the message updating.'); + } + + let result = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedExtend, messageData); + result = await Apps.self?.triggerEvent(AppEvents.IPreMessageUpdatedModify, result); + + if (typeof result === 'object') { + Object.assign(messageData, result); + } + } + + // If we keep history of edits, insert a new message to store history information + if (settings.get('Message_KeepHistory')) { + await Messages.cloneAndSaveAsHistoryById(messageData._id, user as Required>); + } + + Object.assign(messageData, { + editedAt: new Date(), + editedBy: { + _id: user._id, + username: user.username, + }, + }); + + const room = await Rooms.findOneById(messageData.rid); + if (!room) { + return; + } + + messageData = await Message.beforeSave({ message: messageData, room, user, previewUrls, parseUrls }); + + if (messageData.customFields) { + validateCustomMessageFields({ + customFields: messageData.customFields, + messageCustomFieldsEnabled: settings.get('Message_CustomFields_Enabled'), + messageCustomFields: settings.get('Message_CustomFields'), + }); + } + + const { _id, ...editedMessage } = messageData; + + if (!editedMessage.msg) { + delete editedMessage.md; + } + + // do not send $unset if not defined. Can cause exceptions in certain mongo versions. + await Messages.updateOne( + { _id }, + { + $set: { + ...editedMessage, + }, + ...(!editedMessage.md && { $unset: { md: 1 } }), + }, + ); + + if (Apps.self?.isLoaded()) { + // This returns a promise, but it won't mutate anything about the message + // so, we don't really care if it is successful or fails + void Apps.self?.triggerEvent(AppEvents.IPostMessageUpdated, messageData); + } + + setImmediate(async () => { + const msg = await Messages.findOneById(_id); + if (!msg) { + return; + } + + await afterSaveMessage(msg, room, user); + + if (room?.lastMessage?._id === msg._id) { + void notifyOnRoomChangedById(message.rid); + } + }); +}; diff --git a/apps/meteor/server/lib/messaging/Message.ts b/apps/meteor/server/lib/messaging/Message.ts new file mode 100644 index 0000000..5be58be --- /dev/null +++ b/apps/meteor/server/lib/messaging/Message.ts @@ -0,0 +1,27 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { escapeHTML } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; +import { i18n } from '../i18n'; + +export const Message = { + parse(msg: IMessage, language: string) { + const messageType = MessageTypes.getType(msg); + if (messageType) { + return messageType.text(i18n.getFixedT(language || Accounts.storageLocation.getItem('userLanguage') || 'en'), msg); + } + if (msg.u && msg.u.username === settings.get('Chatops_Username')) { + msg.html = msg.msg; + return msg.html; + } + msg.html = msg.msg; + if (trim(msg.html) !== '') { + msg.html = escapeHTML(msg.html); + } + msg.html = msg.html.replace(/\n/gm, '
'); + return msg.html; + }, +}; diff --git a/apps/meteor/server/lib/messaging/discussions/permissions.ts b/apps/meteor/server/lib/messaging/discussions/permissions.ts new file mode 100644 index 0000000..341e8ae --- /dev/null +++ b/apps/meteor/server/lib/messaging/discussions/permissions.ts @@ -0,0 +1,14 @@ +import { Permissions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +Meteor.startup(async () => { + // Add permissions for discussion + const permissions = [ + { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, + ]; + + for (const permission of permissions) { + await Permissions.create(permission._id, permission.roles); + } +}); diff --git a/apps/meteor/server/lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage.ts b/apps/meteor/server/lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage.ts new file mode 100644 index 0000000..02bb51f --- /dev/null +++ b/apps/meteor/server/lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage.ts @@ -0,0 +1,55 @@ +import type { IRoom, MessageTypesValues } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; + +import { settings } from '../../../settings/cached'; +import { notifyOnMessageChange } from '../../notifyListener'; + +type DiscussionRoom = Pick; + +/** + * Both the system messages hidden globally and the ones hidden on the room itself are + * filtered out from the room history, so the count has to consider both of them. + * Type `rm` is filtered out because it's already discounted when the message is deleted. + */ +const getHiddenTypesToDiscount = (room: DiscussionRoom): MessageTypesValues[] => { + const globalHiddenTypes = settings.get('Hide_System_Messages'); + const globallyHiddenTypes = Array.isArray(globalHiddenTypes) ? globalHiddenTypes : []; + const roomHiddenTypes = Array.isArray(room.sysMes) ? room.sysMes : []; + + return [...new Set([...globallyHiddenTypes, ...roomHiddenTypes])] + .flatMap((type) => + // `mute_unmute` is a single option covering two different message types + type === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [type], + ) + .filter((type) => type !== 'rm'); +}; + +const getDiscussionMessagesCount = async (room: DiscussionRoom): Promise => { + const hiddenMessageTypes = getHiddenTypesToDiscount(room); + + if (!hiddenMessageTypes.length) { + return room.msgs; + } + + const hiddenMessagesCount = await Messages.countVisibleByRoomIdContainingTypes(room._id, hiddenMessageTypes); + + return Math.max(room.msgs - hiddenMessagesCount, 0); +}; + +/** + * Copies the current metadata of a discussion (messages count and last message timestamp) to the + * message which links to it on the parent room, and notifies the change to the clients. + */ +export const updateAndNotifyParentRoomWithParentMessage = async (room: DiscussionRoom): Promise => { + room.msgs = await getDiscussionMessagesCount(room); + + const parentMessage = await Messages.refreshDiscussionMetadata(room); + if (!parentMessage) { + return; + } + + void notifyOnMessageChange({ + id: parentMessage._id, + data: parentMessage, + }); +}; diff --git a/apps/meteor/server/lib/messaging/emoji.ts b/apps/meteor/server/lib/messaging/emoji.ts new file mode 100644 index 0000000..5825474 --- /dev/null +++ b/apps/meteor/server/lib/messaging/emoji.ts @@ -0,0 +1,28 @@ +import type { EmojiPackages } from '../../../app/emoji/lib/rocketchat'; + +export const emoji: EmojiPackages = { + packages: { + base: { + emojiCategories: [{ key: 'recent', i18n: 'Frequently_Used' }], + categoryIndex: 0, + emojisByCategory: { + recent: [], + }, + toneList: {}, + render: (message: string) => message, + renderPicker(emojiToRender) { + if (!emoji.list[emojiToRender]) { + return; + } + const correctPackage = emoji.list[emojiToRender].emojiPackage; + if (!correctPackage) { + return; + } + + return emoji.packages[correctPackage]?.renderPicker(emojiToRender); + }, + }, + }, + /** @type {Record} */ + list: {}, +}; diff --git a/apps/meteor/server/lib/messaging/getHiddenSystemMessages.ts b/apps/meteor/server/lib/messaging/getHiddenSystemMessages.ts new file mode 100644 index 0000000..024197d --- /dev/null +++ b/apps/meteor/server/lib/messaging/getHiddenSystemMessages.ts @@ -0,0 +1,10 @@ +import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings'; + +export const getHiddenSystemMessages = (room: Pick, hiddenSystemMessages: MessageTypesValues[]): MessageTypesValues[] => { + const hiddenTypes = hiddenSystemMessages.reduce((array, value): MessageTypesValues[] => { + const newValue: MessageTypesValues[] = value === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [value]; + return [...array, ...newValue]; + }, [] as MessageTypesValues[]); + + return Array.isArray(room?.sysMes) ? room.sysMes : hiddenTypes; +}; diff --git a/apps/meteor/server/lib/messaging/markdown.ts b/apps/meteor/server/lib/messaging/markdown.ts new file mode 100644 index 0000000..981979a --- /dev/null +++ b/apps/meteor/server/lib/messaging/markdown.ts @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { createMarkdownMessageRenderer, createMarkdownNotificationRenderer } from '../../../app/markdown/lib/markdown'; +import { callbacks } from '../callbacks'; + +export { Markdown } from '../../../app/markdown/lib/markdown'; + +Meteor.startup(() => { + Tracker.autorun(() => { + const options = { + rootUrl: Meteor.absoluteUrl(), + }; + + const renderMessage = createMarkdownMessageRenderer(options); + callbacks.add('renderMessage', renderMessage, callbacks.priority.HIGH, 'markdown'); + }); + + const renderNotification = createMarkdownNotificationRenderer(); + callbacks.add('renderNotification', renderNotification, callbacks.priority.HIGH, 'filter-markdown'); +}); diff --git a/apps/meteor/server/lib/messaging/mentions/Mentions.ts b/apps/meteor/server/lib/messaging/mentions/Mentions.ts new file mode 100644 index 0000000..61ca579 --- /dev/null +++ b/apps/meteor/server/lib/messaging/mentions/Mentions.ts @@ -0,0 +1,128 @@ +/* + * Mentions is a named function that will process Mentions + * @param {Object} message - The message object + */ +import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; + +import { type MentionsParserArgs, MentionsParser } from '../../../../app/mentions/lib/MentionsParser'; +import { extractMentionsFromMessageAST } from '../../messages/extractMentionsFromMessageAST'; + +type MentionsServerArgs = MentionsParserArgs & { + messageMaxAll: () => number; + getChannels: (c: string[]) => Promise[]>; + getUsers: (u: string[]) => Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]>; + getUser: (u: string) => Promise; + getTotalChannelMembers: (rid: string) => Promise; + onMaxRoomMembersExceeded: ({ sender, rid }: { sender: IMessage['u']; rid: string }) => Promise; +}; + +export class MentionsServer extends MentionsParser { + messageMaxAll: MentionsServerArgs['messageMaxAll']; + + getChannels: MentionsServerArgs['getChannels']; + + getUsers: MentionsServerArgs['getUsers']; + + getUser: MentionsServerArgs['getUser']; + + getTotalChannelMembers: MentionsServerArgs['getTotalChannelMembers']; + + onMaxRoomMembersExceeded: MentionsServerArgs['onMaxRoomMembersExceeded']; + + constructor(args: MentionsServerArgs) { + super(args); + + this.messageMaxAll = args.messageMaxAll; + this.getChannels = args.getChannels; + this.getUsers = args.getUsers; + this.getUser = args.getUser; + this.getTotalChannelMembers = args.getTotalChannelMembers; + this.onMaxRoomMembersExceeded = + args.onMaxRoomMembersExceeded || + (() => { + /* do nothing */ + }); + } + + async getUsersByMentions(message: IMessage): Promise { + const { msg, rid, u: sender, e2eMentions }: Pick = message; + + const mentions = + isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0 + ? e2eMentions?.e2eUserMentions + : this.getUserMentions(msg); + + return this.convertMentionsToUsers(mentions, rid, sender); + } + + async convertMentionsToUsers(mentions: string[], rid: string, sender: IMessage['u']): Promise { + const mentionsAll: { _id: string; username: string }[] = []; + const userMentions = new Set(); + + // A message may mention both @all and @here; fetch the member count at + // most once per message instead of once per mention. + let totalChannelMembers: number | undefined; + + for (const m of mentions) { + let mention: string; + if (m.includes(':')) { + mention = m.trim(); + } else if (m.startsWith('@')) { + mention = m.substring(1); + } else { + mention = m; + } + if (mention !== 'all' && mention !== 'here') { + userMentions.add(mention); + continue; + } + if (this.messageMaxAll() > 0) { + totalChannelMembers ??= await this.getTotalChannelMembers(rid); + if (totalChannelMembers > this.messageMaxAll()) { + await this.onMaxRoomMembersExceeded({ sender, rid }); + continue; + } + } + mentionsAll.push({ + _id: mention, + username: mention, + }); + } + + return [...mentionsAll, ...(userMentions.size ? await this.getUsers(Array.from(userMentions)) : [])]; + } + + async getChannelbyMentions(message: IMessage) { + const { msg, e2eMentions }: Pick = message; + + const channels = + isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0 + ? e2eMentions?.e2eChannelMentions + : this.getChannelMentions(msg); + return this.convertMentionsToChannels(channels); + } + + async convertMentionsToChannels(channels: string[]): Promise[]> { + // Most messages don't mention any channel; skip the (empty) database + // query entirely in that case. An empty $in matches nothing anyway. + if (channels.length === 0) { + return []; + } + + return this.getChannels(channels.map((c) => (c.startsWith('#') ? c.substring(1) : c))); + } + + async execute(message: IMessage) { + if (message.md) { + const { mentions, channels } = extractMentionsFromMessageAST(message.md); + message.mentions = await this.convertMentionsToUsers(mentions, message.rid, message.u); + message.channels = await this.convertMentionsToChannels(channels); + return message; + } + + message.mentions = await this.getUsersByMentions(message); + message.channels = await this.getChannelbyMentions(message); + + return message; + } +} diff --git a/apps/meteor/server/lib/messaging/mentions/getMentionedTeamMembers.ts b/apps/meteor/server/lib/messaging/mentions/getMentionedTeamMembers.ts new file mode 100644 index 0000000..75645b1 --- /dev/null +++ b/apps/meteor/server/lib/messaging/mentions/getMentionedTeamMembers.ts @@ -0,0 +1,23 @@ +import { Team } from '@rocket.chat/core-services'; +import type { MessageMention } from '@rocket.chat/core-typings'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; + +const beforeGetMentions = async (mentionIds: string[], teamMentions: MessageMention[]): Promise => { + if (!teamMentions.length) { + return mentionIds; + } + + const teamsIds = teamMentions.map(({ _id }) => _id); + const members = await Team.getMembersByTeamIds(teamsIds, { projection: { userId: 1 } }); + return [...new Set([...mentionIds, ...members.map(({ userId }) => userId)])]; +}; + +settings.watch('Troubleshoot_Disable_Teams_Mention', (value) => { + if (value) { + callbacks.remove('beforeGetMentions', 'before-get-mentions-get-teams'); + } else { + callbacks.add('beforeGetMentions', beforeGetMentions, callbacks.priority.MEDIUM, 'before-get-mentions-get-teams'); + } +}); diff --git a/apps/meteor/server/lib/messaging/msgStream.ts b/apps/meteor/server/lib/messaging/msgStream.ts new file mode 100644 index 0000000..4e6a2a8 --- /dev/null +++ b/apps/meteor/server/lib/messaging/msgStream.ts @@ -0,0 +1,3 @@ +import notifications from '../notifications/core/lib/Notifications'; + +export const msgStream = notifications.streamRoomMessage; diff --git a/apps/meteor/server/lib/messaging/pins/pinMessage.ts b/apps/meteor/server/lib/messaging/pins/pinMessage.ts new file mode 100644 index 0000000..93d3f92 --- /dev/null +++ b/apps/meteor/server/lib/messaging/pins/pinMessage.ts @@ -0,0 +1,228 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings'; +import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { methodDeprecationLogger } from '../../deprecationWarningLogger'; +import { isTheLastMessage } from '../../messages/isTheLastMessage'; +import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener'; +import { getUserAvatarURL } from '../../utils/getUserAvatarURL'; + +const recursiveRemove = (msg: MessageAttachment, deep = 1) => { + if (!msg || !isQuoteAttachment(msg)) { + return; + } + + if (deep > (settings.get('Message_QuoteChainLimit') ?? 0)) { + delete msg.attachments; + return msg; + } + + msg.attachments = Array.isArray(msg.attachments) + ? msg.attachments.map((nestedMsg) => recursiveRemove(nestedMsg, deep + 1)).filter(isTruthy) + : undefined; + + return msg; +}; + +const shouldAdd = (attachments: MessageAttachment[], attachment: MessageQuoteAttachment) => + !attachments.some((_attachment) => isQuoteAttachment(_attachment) && _attachment.message_link === attachment.message_link); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + pinMessage(message: IMessage, pinnedAt?: Date): IMessage | null; + unpinMessage(message: IMessage): boolean; + } +} + +export async function pinMessage(message: IMessage, userId: string, pinnedAt?: Date) { + let originalMessage = await Messages.findOneById(message._id); + if (!originalMessage?.rid) { + throw new Meteor.Error('error-invalid-message', 'Message you are pinning was not found', { + method: 'pinMessage', + action: 'Message_pinning', + }); + } + + if (!settings.get('Message_AllowPinning')) { + throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', { + method: 'pinMessage', + action: 'Message_pinning', + }); + } + + if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } + + const room = await Rooms.findOneById(originalMessage.rid); + if (!room) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } + + if (!(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'pinMessage' }); + } + + if (originalMessage.pinned) { + return originalMessage; + } + + const me = await Users.findOneById(userId); + if (!me) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'pinMessage' }); + } + + originalMessage.pinned = true; + originalMessage.pinnedAt = pinnedAt || new Date(); + originalMessage.pinnedBy = { + _id: userId, + username: me.username, + }; + + originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); + + await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + if (isTheLastMessage(room, originalMessage)) { + await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); + } + + const attachments: MessageAttachment[] = []; + + if (Array.isArray(originalMessage.attachments)) { + originalMessage.attachments.forEach((attachment) => { + if (!isQuoteAttachment(attachment) || shouldAdd(attachments, attachment)) { + attachments.push(attachment); + } + }); + } + + // App IPostMessagePinned event hook + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned); + + const pinMessageType = originalMessage.t === 'e2e' ? 'message_pinned_e2e' : 'message_pinned'; + + return Message.saveSystemMessage(pinMessageType, originalMessage.rid, '', me, { + attachments: [ + { + text: originalMessage.msg, + author_name: originalMessage.u.username, + author_icon: getUserAvatarURL(originalMessage.u.username), + ...(originalMessage.content && { content: originalMessage.content }), + ts: originalMessage.ts, + attachments: attachments.map(recursiveRemove), + }, + ], + }); +} + +export const unpinMessage = async (userId: string, message: IMessage) => { + if (!settings.get('Message_AllowPinning')) { + throw new Meteor.Error('error-action-not-allowed', 'Message pinning not allowed', { + method: 'unpinMessage', + action: 'Message_pinning', + }); + } + + let originalMessage = await Messages.findOneById(message._id); + if (originalMessage?._id == null) { + throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', { + method: 'unpinMessage', + action: 'Message_pinning', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId, { projection: { _id: 1 } }); + if (!subscription) { + // If it's a valid message but on a room that the user is not subscribed to, report that the message was not found. + throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', { + method: 'unpinMessage', + action: 'Message_pinning', + }); + } + + if (!(await hasPermissionAsync(userId, 'pin-message', originalMessage.rid))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); + } + + const me = await Users.findOneById(userId); + if (!me) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unpinMessage' }); + } + + // If we keep history of edits, insert a new message to store history information + if (settings.get('Message_KeepHistory') && isRegisterUser(me)) { + await Messages.cloneAndSaveAsHistoryById(originalMessage._id, me); + } + + originalMessage.pinned = false; + originalMessage.pinnedBy = { + _id: userId, + username: me.username, + }; + + const room = await Rooms.findOneById(originalMessage.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } }); + if (!room) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); + } + + if (!(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'unpinMessage' }); + } + + originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me }); + + if (isTheLastMessage(room, message)) { + await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned); + void notifyOnRoomChangedById(room._id); + } + + // App IPostMessagePinned event hook + await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned); + + await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned); + void notifyOnMessageChange({ + id: message._id, + }); + + return true; +}; + +Meteor.methods({ + async pinMessage(message, pinnedAt) { + methodDeprecationLogger.method('pinMessage', '9.0.0', '/v1/chat.pinMessage'); + check(message._id, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'pinMessage', + }); + } + + return pinMessage(message, userId, pinnedAt); + }, + async unpinMessage(message) { + methodDeprecationLogger.method('unpinMessage', '9.0.0', '/v1/chat.unPinMessage'); + check(message._id, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unpinMessage', + }); + } + + return unpinMessage(userId, message); + }, +}); diff --git a/apps/meteor/server/lib/messaging/reactions/setReaction.ts b/apps/meteor/server/lib/messaging/reactions/setReaction.ts new file mode 100644 index 0000000..da67905 --- /dev/null +++ b/apps/meteor/server/lib/messaging/reactions/setReaction.ts @@ -0,0 +1,150 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../authorization'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { callbacks } from '../../callbacks'; +import { i18n } from '../../i18n'; +import { isTheLastMessage } from '../../messages/isTheLastMessage'; +import { notifyOnMessageChange } from '../../notifyListener'; +import { emoji } from '../emoji'; + +export const removeUserReaction = (message: IMessage, reaction: string, username: string) => { + if (!message.reactions) { + return message; + } + + const idx = message.reactions[reaction].usernames.indexOf(username); + + // user not found in reaction array + if (idx === -1) { + return message; + } + + message.reactions[reaction].usernames.splice(idx, 1); + if (!message.reactions[reaction].usernames.length) { + delete message.reactions[reaction]; + } + return message; +}; + +export async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, userAlreadyReacted?: boolean) { + await Message.beforeReacted(message, room); + + if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) { + throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { + rid: room._id, + }); + } + + if (room.ro === true && !room.reactWhenReadOnly && !(await hasPermissionAsync(user, 'post-readonly', room._id))) { + // Unless the user was manually unmuted + if (!(room.unmuted || []).includes(user.username as string)) { + throw new Error("You can't send messages because the room is readonly."); + } + } + + let isReacted; + if (userAlreadyReacted) { + const oldMessage = JSON.parse(JSON.stringify(message)); + removeUserReaction(message, reaction, user.username as string); + if (Object.keys(message.reactions || {}).length === 0) { + delete message.reactions; + await Messages.unsetReactions(message._id); + if (isTheLastMessage(room, message)) { + await Rooms.unsetReactionsInLastMessage(room._id); + } + } else { + await Messages.setReactions(message._id, message.reactions); + if (isTheLastMessage(room, message)) { + await Rooms.setReactionsInLastMessage(room._id, message.reactions); + } + } + void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage, room }); + + isReacted = false; + } else { + if (!message.reactions) { + message.reactions = {}; + } + if (!message.reactions[reaction]) { + message.reactions[reaction] = { + usernames: [], + }; + } + message.reactions[reaction].usernames.push(user.username as string); + await Messages.setReactions(message._id, message.reactions); + if (isTheLastMessage(room, message)) { + await Rooms.setReactionsInLastMessage(room._id, message.reactions); + } + + void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true, room }); + + isReacted = true; + } + + void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + + void notifyOnMessageChange({ + id: message._id, + }); +} + +export async function executeSetReaction( + userId: string, + reaction: string, + messageParam: IMessage['_id'] | IMessage, + shouldReact?: boolean, +) { + // Check if the emoji is valid before proceeding + const reactionWithoutColons = reaction.replace(/:/g, ''); + reaction = `:${reactionWithoutColons}:`; + + if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) { + throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { + method: 'setReaction', + }); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); + } + + const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam; + if (!message) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); + } + + const userAlreadyReacted = Boolean(message.reactions?.[reaction]?.usernames?.includes(user.username as string)); + + // When shouldReact was not informed, toggle the reaction. + if (shouldReact === undefined) { + shouldReact = !userAlreadyReacted; + } + + if (userAlreadyReacted === shouldReact) { + return; + } + + const room = await Rooms.findOneById(message.rid); + if (!room) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' }); + } + + return setReaction(room, user, message, reaction, userAlreadyReacted); +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setReaction(reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): boolean | undefined; + } +} diff --git a/apps/meteor/server/lib/messaging/stars/starMessage.ts b/apps/meteor/server/lib/messaging/stars/starMessage.ts new file mode 100644 index 0000000..5c5b1c9 --- /dev/null +++ b/apps/meteor/server/lib/messaging/stars/starMessage.ts @@ -0,0 +1,77 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization'; +import { methodDeprecationLogger } from '../../deprecationWarningLogger'; +import { isTheLastMessage } from '../../messages/isTheLastMessage'; +import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + starMessage(message: Pick & { starred: boolean }): boolean; + } +} + +export const starMessage = async (user: IUser, message: Pick & { starred: boolean }): Promise => { + if (!settings.get('Message_AllowStarring')) { + throw new Meteor.Error('error-action-not-allowed', 'Message starring not allowed', { + method: 'starMessage', + action: 'Message_starring', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(message.rid, user._id, { + projection: { _id: 1 }, + }); + if (!subscription) { + return false; + } + if (!(await Messages.findOneByRoomIdAndMessageId(message.rid, message._id))) { + return false; + } + + const room = await Rooms.findOneById(message.rid, { projection: { ...roomAccessAttributes, lastMessage: 1 } }); + + if (!room) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'starMessage' }); + } + + if (!(await canAccessRoomAsync(room, { _id: user._id }))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'starMessage' }); + } + + if (isTheLastMessage(room, message)) { + await Rooms.updateLastMessageStar(room._id, user._id, message.starred); + void notifyOnRoomChangedById(room._id); + } + + await Apps.self?.triggerEvent(AppEvents.IPostMessageStarred, message, user, message.starred); + + await Messages.updateUserStarById(message._id, user._id, message.starred); + + void notifyOnMessageChange({ + id: message._id, + }); + + return true; +}; + +Meteor.methods({ + async starMessage(message) { + methodDeprecationLogger.method('starMessage', '9.0.0', '/v1/chat.starMessage'); + const user = (await Meteor.userAsync()) as IUser; + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'starMessage', + }); + } + + return starMessage(user, message); + }, +}); diff --git a/apps/meteor/server/lib/messaging/threads/README.md b/apps/meteor/server/lib/messaging/threads/README.md new file mode 100644 index 0000000..513899a --- /dev/null +++ b/apps/meteor/server/lib/messaging/threads/README.md @@ -0,0 +1,17 @@ +# Threads + + +# TODO + +* Tests + * reply to message and check if thread is created + * reply to a msg reply and see if thead id updated + * remove the unique reply of a thread and see if the thread is removed + * remove some message from a thread and see if tlm is updated + * open threads list + * start a thread sending a reply, the thread should appear on the list + * remove a thread, the thread should be removed from the list + * open a thread + * send a new reply, the message should appear + * delete a reply, the message should be removed + * remove the thread, the panel should close diff --git a/apps/meteor/server/lib/messaging/threads/functions.ts b/apps/meteor/server/lib/messaging/threads/functions.ts new file mode 100644 index 0000000..5a1b892 --- /dev/null +++ b/apps/meteor/server/lib/messaging/threads/functions.ts @@ -0,0 +1,97 @@ +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models'; + +import { getMentions, getUserIdsFromHighlights } from '../../../hooks/messages/notifyUsersOnMessage'; +import { callbacks } from '../../callbacks'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener'; + +export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { + if (!tmid || isEditedMessage(message)) { + return false; + } + + const { rid, ts, u } = message; + + const { toAll, toHere, mentionIds } = await getMentions(message); + + const addToReplies = [ + ...new Set([ + ...followers, + ...mentionIds, + ...(Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id]), + ]), + ]; + + await Messages.updateRepliesByThreadId(tmid, addToReplies, ts); + + const [highlightsUids, threadFollowers] = await Promise.all([ + getUserIdsFromHighlights(rid, message), + Messages.getThreadFollowsByThreadId(tmid), + ]); + + const threadFollowersUids = threadFollowers?.filter((userId) => userId !== u._id && !mentionIds.includes(userId)) || []; + + // Notify everyone involved in the thread + const notifyOptions = toAll || toHere ? { groupMention: true } : {}; + + // Notify message mentioned users and highlights + const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])]; + + const promises = [Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions)]; + + if (mentionedUsers.length) { + promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true })); + } + + if (highlightsUids.length) { + promises.push( + Subscriptions.setAlertForRoomIdAndUserIds(rid, highlightsUids), + Subscriptions.setOpenForRoomIdAndUserIds(rid, highlightsUids), + ); + } + + await Promise.allSettled(promises); + + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, [...threadFollowersUids, ...mentionedUsers, ...highlightsUids]); +} + +export async function follow({ tmid, uid }: { tmid: string; uid: string }) { + if (!tmid || !uid) { + return false; + } + + await Messages.addThreadFollowerByThreadId(tmid, uid); +} + +export async function unfollow({ tmid, rid, uid }: { tmid: string; rid: string; uid: string }) { + if (!tmid || !uid) { + return false; + } + + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, uid); + } + + await Messages.removeThreadFollowerByThreadId(tmid, uid); +} + +export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }) => { + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { tunread: 1 } }); + if (!sub) { + return; + } + + // if the thread being marked as read is the last one unread also clear the unread subscription flag + const clearAlert = sub.tunread && sub.tunread?.length <= 1 && sub.tunread.includes(tmid); + + const removeUnreadThreadResponse = await Subscriptions.removeUnreadThreadByRoomIdAndUserId(room._id, user._id, tmid, clearAlert); + if (removeUnreadThreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, user._id); + } + + await NotificationQueue.clearQueueByUserId(user._id); + + callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); +}; diff --git a/apps/meteor/server/lib/messaging/unread/logger.ts b/apps/meteor/server/lib/messaging/unread/logger.ts new file mode 100644 index 0000000..23a21f5 --- /dev/null +++ b/apps/meteor/server/lib/messaging/unread/logger.ts @@ -0,0 +1,4 @@ +import { Logger } from '@rocket.chat/logger'; + +const logger = new Logger('MessageMarkAsUnread'); +export default logger; diff --git a/apps/meteor/server/lib/messaging/unread/unreadMessages.ts b/apps/meteor/server/lib/messaging/unread/unreadMessages.ts new file mode 100644 index 0000000..b118ea7 --- /dev/null +++ b/apps/meteor/server/lib/messaging/unread/unreadMessages.ts @@ -0,0 +1,95 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import logger from './logger'; +import { methodDeprecationLogger } from '../../deprecationWarningLogger'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + unreadMessages(firstUnreadMessage?: Pick, room?: IRoom['_id']): void; + } +} + +export const unreadMessages = async (userId: string, firstUnreadMessage?: Pick, room?: IRoom['_id']): Promise => { + if (room && typeof room === 'string') { + const lastMessage = ( + await Messages.findVisibleByRoomId(room, { + limit: 1, + sort: { ts: -1 }, + }).toArray() + )[0]; + + if (!lastMessage) { + throw new Meteor.Error('error-no-message-for-unread', 'There are no messages to mark unread', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(lastMessage.rid, userId, lastMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(lastMessage.rid, userId); + } + + return; + } + + if (typeof firstUnreadMessage?._id !== 'string') { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + + const originalMessage = await Messages.findOneById(firstUnreadMessage._id, { + projection: { + u: 1, + rid: 1, + ts: 1, + }, + }); + if (!originalMessage || userId === originalMessage.u._id) { + throw new Meteor.Error('error-action-not-allowed', 'Not allowed', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + const lastSeen = (await Subscriptions.findOneByRoomIdAndUserId(originalMessage.rid, userId))?.ls; + if (!lastSeen) { + throw new Meteor.Error('error-subscription-not-found', 'Subscription not found', { + method: 'unreadMessages', + action: 'Unread_messages', + }); + } + + if (originalMessage.ts >= lastSeen) { + return logger.debug('Provided message is already marked as unread'); + } + + logger.debug({ + msg: 'Updating unread message as the first unread', + timestamp: originalMessage.ts, + }); + const setAsUnreadResponse = await Subscriptions.setAsUnreadByRoomIdAndUserId(originalMessage.rid, userId, originalMessage.ts); + if (setAsUnreadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(originalMessage.rid, userId); + } +}; + +Meteor.methods({ + async unreadMessages(firstUnreadMessage, room) { + methodDeprecationLogger.method('unreadMessages', '9.0.0', '/v1/subscriptions.unread'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unreadMessages', + }); + } + + return unreadMessages(userId, firstUnreadMessage, room); + }, +}); diff --git a/apps/meteor/server/lib/messaging/validateCustomMessageFields.ts b/apps/meteor/server/lib/messaging/validateCustomMessageFields.ts new file mode 100644 index 0000000..62d6e51 --- /dev/null +++ b/apps/meteor/server/lib/messaging/validateCustomMessageFields.ts @@ -0,0 +1,42 @@ +import { ajv } from '@rocket.chat/rest-typings'; +import mem from 'mem'; + +const customFieldsValidate = mem( + (customFieldsSetting: string) => { + const schema = JSON.parse(customFieldsSetting); + + if (schema.type && schema.type !== 'object') { + throw new Error('Invalid custom fields config'); + } + + return ajv.compile({ + ...schema, + type: 'object', + additionalProperties: false, + }); + }, + { maxAge: 1000 * 60 }, +); + +export const validateCustomMessageFields = ({ + customFields, + messageCustomFieldsEnabled, + messageCustomFields, +}: { + customFields: Record; + messageCustomFieldsEnabled: boolean; + messageCustomFields: string; +}) => { + // get the json schema for the custom fields of the message and validate it using ajv + // if the validation fails, throw an error + // if there are no custom fields, the message object remains unchanged + + if (messageCustomFieldsEnabled !== true) { + throw new Error('Custom fields not enabled'); + } + + const validate = customFieldsValidate(messageCustomFields); + if (!validate(customFields)) { + throw new Error('Invalid custom fields'); + } +}; diff --git a/apps/meteor/server/lib/metrics/index.ts b/apps/meteor/server/lib/metrics/index.ts new file mode 100644 index 0000000..4379f45 --- /dev/null +++ b/apps/meteor/server/lib/metrics/index.ts @@ -0,0 +1,6 @@ +import { metrics } from './lib/metrics'; +import StatsTracker from './lib/statsTracker'; + +import './lib/collectMetrics'; + +export { metrics, StatsTracker }; diff --git a/apps/meteor/server/lib/metrics/lib/collectMetrics.ts b/apps/meteor/server/lib/metrics/lib/collectMetrics.ts new file mode 100644 index 0000000..37967a4 --- /dev/null +++ b/apps/meteor/server/lib/metrics/lib/collectMetrics.ts @@ -0,0 +1,213 @@ +import http from 'node:http'; + +import { Statistics } from '@rocket.chat/models'; +import { tracerSpan } from '@rocket.chat/tracing'; +import connect from 'connect'; +import { Facts } from 'meteor/facts-base'; +import { Meteor } from 'meteor/meteor'; +import client from 'prom-client'; +import gcStats from 'prometheus-gc-stats'; +import _ from 'underscore'; + +import { metrics } from './metrics'; +import { Info } from '../../../../app/utils/rocketchat.info'; +import { settings } from '../../../settings'; +import { SystemLogger } from '../../logger/system'; +import { getControl } from '../../migrations'; +import { getAppsStatistics } from '../../statistics/lib/getAppsStatistics'; + +Facts.incrementServerFact = function (pkg: 'pkg' | 'fact', fact: string | number, increment: number): void { + metrics.meteorFacts.inc({ pkg, fact }, increment); +}; + +const setPrometheusData = async (): Promise => { + metrics.info.set( + { + version: Info.version, + unique_id: settings.get('uniqueID'), + site_url: settings.get('Site_Url'), + }, + 1, + ); + + const sessions = Array.from<{ userId: string }>(Meteor.server.sessions.values()); + const authenticatedSessions = sessions.filter((s) => s.userId); + metrics.ddpSessions.set(Meteor.server.sessions.size); + metrics.ddpAuthenticatedSessions.set(authenticatedSessions.length); + metrics.ddpConnectedUsers.set(_.unique(authenticatedSessions.map((s) => s.userId)).length); + + // Apps metrics + const { totalInstalled, totalActive, totalFailed } = await getAppsStatistics(); + + metrics.totalAppsInstalled.set(totalInstalled || 0); + metrics.totalAppsEnabled.set(totalActive || 0); + metrics.totalAppsFailed.set(totalFailed || 0); + + const statistics = await Statistics.findLast(); + if (!statistics) { + return; + } + + metrics.version.set({ version: statistics.version }, 1); + metrics.migration.set((await getControl()).version); + metrics.instanceCount.set(statistics.instanceCount); + + // User statistics + metrics.totalUsers.set(statistics.totalUsers); + metrics.activeUsers.set(statistics.activeUsers); + metrics.nonActiveUsers.set(statistics.nonActiveUsers); + metrics.onlineUsers.set(statistics.onlineUsers); + metrics.awayUsers.set(statistics.awayUsers); + metrics.offlineUsers.set(statistics.offlineUsers); + + // Room statistics + metrics.totalRooms.set(statistics.totalRooms); + metrics.totalChannels.set(statistics.totalChannels); + metrics.totalPrivateGroups.set(statistics.totalPrivateGroups); + metrics.totalDirect.set(statistics.totalDirect); + metrics.totalLivechat.set(statistics.totalLivechat); + + // Message statistics + metrics.totalMessages.set(statistics.totalMessages); + metrics.totalChannelMessages.set(statistics.totalChannelMessages); + metrics.totalPrivateGroupMessages.set(statistics.totalPrivateGroupMessages); + metrics.totalDirectMessages.set(statistics.totalDirectMessages); + metrics.totalLivechatMessages.set(statistics.totalLivechatMessages); + + // Livechat stats + metrics.totalLivechatVisitors.set(statistics.totalLivechatVisitors); + metrics.totalLivechatAgents.set(statistics.totalLivechatAgents); + + metrics.pushQueue.set(statistics.pushQueue || 0); +}; + +const app = connect(); + +// const compression = require('compression'); +// app.use(compression()); + +app.use('/metrics', (_req, res) => { + res.setHeader('Content-Type', 'text/plain'); + client.register + .metrics() + .then((data) => { + metrics.metricsRequests.inc(); + metrics.metricsRequestsTotal.inc(); + metrics.metricsSize.set(data.length); + + res.end(data); + }) + .catch((err) => { + SystemLogger.error({ msg: 'Error while collecting metrics', err }); + res.end(); + }); +}); + +app.use('/', (_req, res) => { + const html = ` + + Rocket.Chat Prometheus Exporter + + +

Rocket.Chat Prometheus Exporter

+

Metrics

+ + `; + + res.write(html); + res.end(); +}); + +const server = http.createServer(app); + +let timer: NodeJS.Timeout; +let resetTimer: NodeJS.Timeout; +let defaultMetricsInitiated = false; +let gcStatsInitiated = false; +const was = { + enabled: false, + port: 9458, + resetInterval: 0, + collectGC: false, +}; +const updatePrometheusConfig = async (): Promise => { + const is = { + port: process.env.PROMETHEUS_PORT || settings.get('Prometheus_Port'), + enabled: settings.get('Prometheus_Enabled'), + resetInterval: settings.get('Prometheus_Reset_Interval'), + collectGC: settings.get('Prometheus_Garbage_Collector'), + }; + + if (Object.values(is).some((s) => s == null)) { + return; + } + + if (Object.entries(is).every(([k, v]) => v === was[k as keyof typeof was])) { + return; + } + + if (!is.enabled) { + if (was.enabled) { + SystemLogger.info('Disabling Prometheus'); + server.close(); + clearInterval(timer); + } + Object.assign(was, is); + return; + } + + SystemLogger.debug({ msg: 'Configuring Prometheus', is }); + + if (!was.enabled) { + server.listen({ + port: is.port, + host: process.env.BIND_IP || '0.0.0.0', + }); + + timer = setInterval(async () => { + void tracerSpan( + 'setPrometheusData', + { + attributes: { + port: is.port, + host: process.env.BIND_IP || '0.0.0.0', + }, + }, + () => { + void setPrometheusData(); + }, + ); + }, 5000); + } + + clearInterval(resetTimer); + if (is.resetInterval) { + resetTimer = setInterval(() => { + client.register.getMetricsAsArray().forEach((metric) => { + // @ts-expect-error Property 'hashMap' does not exist on type 'metric'. + metric.hashMap = {}; + }); + }, is.resetInterval); + } + + // Prevent exceptions on calling those methods twice since + // it's not possible to stop them to be able to restart + try { + if (defaultMetricsInitiated === false) { + defaultMetricsInitiated = true; + client.collectDefaultMetrics(); + } + if (is.collectGC && gcStatsInitiated === false) { + gcStatsInitiated = true; + gcStats(client.register)(); + } + } catch (error) { + SystemLogger.error({ err: error }); + } + + Object.assign(was, is); +}; + +Meteor.startup(async () => { + settings.watchByRegex(/^Prometheus_.+/, updatePrometheusConfig); +}); diff --git a/apps/meteor/server/lib/metrics/lib/metrics.ts b/apps/meteor/server/lib/metrics/lib/metrics.ts new file mode 100644 index 0000000..939b2ed --- /dev/null +++ b/apps/meteor/server/lib/metrics/lib/metrics.ts @@ -0,0 +1,340 @@ +import { performance } from 'node:perf_hooks'; + +import client from 'prom-client'; + +const percentiles = [0.01, 0.1, 0.5, 0.9, 0.95, 0.99, 1]; + +const latencyBuckets = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]; +const queueWaitBuckets = [1, 5, 10, 30, 60, 120, 300, 600, 900, 1800, 3600]; + +export const metrics = { + deprecations: new client.Counter({ + name: 'rocketchat_deprecations', + labelNames: ['type', 'kind', 'name', 'params'], + help: 'cumulated number of deprecations being used', + }), + deprecationsTotal: new client.Counter({ + name: 'rocketchat_deprecations_total', + labelNames: ['type', 'kind', 'name', 'params'], + help: 'cumulated number of deprecations being used', + }), + metricsRequests: new client.Counter({ + name: 'rocketchat_metrics_requests', + labelNames: ['notification_type'], + help: 'cumulated number of calls to the metrics endpoint', + }), + metricsRequestsTotal: new client.Counter({ + name: 'rocketchat_metrics_requests_total', + labelNames: ['notification_type'], + help: 'cumulated number of calls to the metrics endpoint', + }), + metricsSize: new client.Gauge({ + name: 'rocketchat_metrics_size', + help: 'size of the metrics response in chars', + }), + info: new client.Gauge({ + name: 'rocketchat_info', + labelNames: ['version', 'unique_id', 'site_url'], + help: 'Rocket.Chat info', + }), + meteorMethods: new client.Summary({ + name: 'rocketchat_meteor_methods', + help: 'summary of meteor methods count and time', + labelNames: ['method', 'has_connection', 'has_user'], + percentiles, + }), + meteorMethodsSeconds: new client.Histogram({ + name: 'rocketchat_meteor_methods_seconds', + help: 'histogram of meteor methods count and time in seconds', + labelNames: ['method', 'has_connection', 'has_user'], + buckets: latencyBuckets, + }), + rocketchatCallbacks: new client.Summary({ + name: 'rocketchat_callbacks', + help: 'summary of rocketchat callbacks count and time', + labelNames: ['hook', 'callback'], + percentiles, + }), + rocketchatCallbacksSeconds: new client.Histogram({ + name: 'rocketchat_callbacks_seconds', + help: 'histogram of rocketchat callbacks count and time in seconds', + labelNames: ['hook', 'callback'], + buckets: latencyBuckets, + }), + rocketchatHooks: new client.Summary({ + name: 'rocketchat_hooks', + help: 'summary of rocketchat hooks count and time', + labelNames: ['hook', 'callbacks_length'], + percentiles, + }), + rocketchatHooksSeconds: new client.Histogram({ + name: 'rocketchat_hooks_seconds', + help: 'histogram of rocketchat hooks count and time in seconds', + labelNames: ['hook', 'callbacks_length'], + buckets: latencyBuckets, + }), + rocketchatRestApi: new client.Summary({ + name: 'rocketchat_rest_api', + help: 'summary of rocketchat rest api count and time', + labelNames: ['method', 'entrypoint', 'user_agent', 'status', 'version'], + percentiles, + }), + rocketchatRestApiSeconds: new client.Histogram({ + name: 'rocketchat_rest_api_seconds', + help: 'histogram of rocketchat rest api count and time in seconds', + labelNames: ['method', 'entrypoint', 'status', 'version'], + buckets: latencyBuckets, + }), + rocketchatRestApiResponseSizeBytes: new client.Histogram({ + name: 'rocketchat_rest_api_response_size_bytes', + help: 'histogram of rocketchat rest api response sizes in bytes', + labelNames: ['method', 'entrypoint', 'status', 'version'], + buckets: [0, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000], + }), + rocketchatRestApiActiveRequests: new client.Gauge({ + name: 'rocketchat_rest_api_active_requests', + help: 'number of currently active rest api requests', + labelNames: ['method'], + }), + + meteorSubscriptions: new client.Summary({ + name: 'rocketchat_meteor_subscriptions', + help: 'summary of meteor subscriptions count and time', + labelNames: ['subscription'], + percentiles, + }), + meteorSubscriptionsSeconds: new client.Histogram({ + name: 'rocketchat_meteor_subscriptions_seconds', + help: 'histogram of meteor subscriptions count and time in seconds', + labelNames: ['subscription'], + buckets: latencyBuckets, + }), + + messagesSent: new client.Counter({ + name: 'rocketchat_message_sent', + help: 'cumulated number of messages sent', + }), + messagesSentTotal: new client.Counter({ + name: 'rocketchat_messages_sent_total', + help: 'cumulated number of messages sent', + }), + notificationsSent: new client.Counter({ + name: 'rocketchat_notification_sent', + labelNames: ['notification_type'], + help: 'cumulated number of notifications sent', + }), + notificationsSentTotal: new client.Counter({ + name: 'rocketchat_notifications_sent_total', + labelNames: ['notification_type'], + help: 'cumulated number of notifications sent', + }), + messageRoundtripTime: new client.Summary({ + name: 'rocketchat_messages_roundtrip_time_summary', + help: 'time spent by a message from save to receive back', + percentiles, + maxAgeSeconds: 60, + ageBuckets: 5, + // pruneAgedBuckets: true, // Type not added to prom-client on 14.2 https://github.com/siimon/prom-client/pull/558 + }), + messageRoundtripTimeSeconds: new client.Histogram({ + name: 'rocketchat_messages_roundtrip_time_seconds', + help: 'time in seconds spent by a message from save to receive back', + buckets: latencyBuckets, + }), + + ddpSessions: new client.Gauge({ + name: 'rocketchat_ddp_sessions_count', + help: 'number of open ddp sessions', + }), + ddpAuthenticatedSessions: new client.Gauge({ + name: 'rocketchat_ddp_sessions_auth', + help: 'number of authenticated open ddp sessions', + }), + ddpConnectedUsers: new client.Gauge({ + name: 'rocketchat_ddp_connected_users', + help: 'number of unique connected users', + }), + ddpRateLimitExceeded: new client.Counter({ + name: 'rocketchat_ddp_rate_limit_exceeded', + labelNames: ['limit_name', 'user_id', 'client_address', 'type', 'name', 'connection_id'], + help: 'number of times a ddp rate limiter was exceeded', + }), + ddpRateLimitExceededTotal: new client.Counter({ + name: 'rocketchat_ddp_rate_limit_exceeded_total', + labelNames: ['limit_name', 'user_id', 'client_address', 'type', 'name', 'connection_id'], + help: 'number of times a ddp rate limiter was exceeded', + }), + + version: new client.Gauge({ + name: 'rocketchat_version', + labelNames: ['version'], + help: 'Rocket.Chat version', + }), + migration: new client.Gauge({ name: 'rocketchat_migration', help: 'migration versoin' }), + instanceCount: new client.Gauge({ + name: 'rocketchat_instance_count', + help: 'instances running', + }), + pushQueue: new client.Gauge({ + name: 'rocketchat_push_queue', + labelNames: ['queue'], + help: 'push queue', + }), + + // User statistics + totalUsers: new client.Gauge({ name: 'rocketchat_users_total', help: 'total of users' }), + activeUsers: new client.Gauge({ + name: 'rocketchat_users_active', + help: 'total of active users', + }), + nonActiveUsers: new client.Gauge({ + name: 'rocketchat_users_non_active', + help: 'total of non active users', + }), + onlineUsers: new client.Gauge({ + name: 'rocketchat_users_online', + help: 'total of users online', + }), + awayUsers: new client.Gauge({ + name: 'rocketchat_users_away', + help: 'total of users away', + }), + offlineUsers: new client.Gauge({ + name: 'rocketchat_users_offline', + help: 'total of users offline', + }), + + // Room statistics + totalRooms: new client.Gauge({ name: 'rocketchat_rooms_total', help: 'total of rooms' }), + totalChannels: new client.Gauge({ + name: 'rocketchat_channels_total', + help: 'total of public rooms/channels', + }), + totalPrivateGroups: new client.Gauge({ + name: 'rocketchat_private_groups_total', + help: 'total of private rooms', + }), + totalDirect: new client.Gauge({ + name: 'rocketchat_direct_total', + help: 'total of direct rooms', + }), + totalLivechat: new client.Gauge({ + name: 'rocketchat_livechat_total', + help: 'total of livechat rooms', + }), + + // Message statistics + totalMessages: new client.Gauge({ + name: 'rocketchat_messages_total', + help: 'total of messages', + }), + totalChannelMessages: new client.Gauge({ + name: 'rocketchat_channel_messages_total', + help: 'total of messages in public rooms', + }), + totalPrivateGroupMessages: new client.Gauge({ + name: 'rocketchat_private_group_messages_total', + help: 'total of messages in private rooms', + }), + totalDirectMessages: new client.Gauge({ + name: 'rocketchat_direct_messages_total', + help: 'total of messages in direct rooms', + }), + totalLivechatMessages: new client.Gauge({ + name: 'rocketchat_livechat_messages_total', + help: 'total of messages in livechat rooms', + }), + + // Apps metrics + totalAppsInstalled: new client.Gauge({ + name: 'rocketchat_apps_installed', + help: 'total apps installed', + }), + totalAppsEnabled: new client.Gauge({ + name: 'rocketchat_apps_enabled', + help: 'total apps enabled', + }), + totalAppsFailed: new client.Gauge({ + name: 'rocketchat_apps_failed', + help: 'total apps that failed to load', + }), + appBridgeMethods: new client.Summary({ + name: 'rocketchat_apps_bridge_methods', + help: 'summary of app bridge method calls count and time', + labelNames: ['bridge', 'method', 'app_id'], + percentiles, + }), + appBridgeMethodsSeconds: new client.Histogram({ + name: 'rocketchat_apps_bridge_methods_seconds', + help: 'histogram of app bridge method calls count and time in seconds', + labelNames: ['bridge', 'method', 'app_id'], + buckets: latencyBuckets, + }), + + // Meteor Facts + meteorFacts: new client.Gauge({ + name: 'rocketchat_meteor_facts', + labelNames: ['pkg', 'fact'], + help: 'internal meteor facts', + }), + + // Livechat metrics + totalLivechatVisitors: new client.Gauge({ name: 'rocketchat_visitors_total', help: 'total of visitors' }), + totalLivechatAgents: new client.Gauge({ name: 'rocketchat_agents_total', help: 'total of agents' }), + totalLivechatWebhooksSuccess: new client.Counter({ + name: 'rocketchat_livechat_webhooks_success', + help: 'successful livechat webhooks', + }), + totalLivechatWebhooksSuccessTotal: new client.Counter({ + name: 'rocketchat_livechat_webhooks_success_total', + help: 'successful livechat webhooks', + }), + totalLivechatWebhooksFailures: new client.Counter({ + name: 'rocketchat_livechat_webhooks_failures', + help: 'failed livechat webhooks', + }), + totalLivechatWebhooksFailuresTotal: new client.Counter({ + name: 'rocketchat_livechat_webhooks_failures_total', + help: 'failed livechat webhooks', + }), + + totalItemsProcessedByQueue: new client.Counter({ + name: 'rocketchat_queue_items_processed_total', + labelNames: ['queue'], + help: 'Total number of items processed by Rocket.Chat Omni queues', + }), + totalItemsFailedByQueue: new client.Counter({ + name: 'rocketchat_queue_items_failed_total', + labelNames: ['queue'], + help: 'Total number of items failed by Rocket.Chat Omni queues', + }), + totalItemsProcessedByReconciliationQueue: new client.Counter({ + name: 'rocketchat_reconciliation_queue_items_processed_total', + labelNames: ['queue', 'action'], + help: 'Total number of items processed by the Omnichannel queue that were reconciled before processing', + }), + timeToQueueProcessingByQueue: new client.Summary({ + name: 'rocketchat_queue_wait_time_seconds', + labelNames: ['queue'], + help: 'Time taken in seconds for an item to be processed for the first time by Omni queues', + percentiles, + }), + timeToQueueProcessingByQueueHistogram: new client.Histogram({ + name: 'rocketchat_queue_wait_duration_seconds', + labelNames: ['queue'], + help: 'Histogram of time taken in seconds for an item to be processed for the first time by Omni queues', + buckets: queueWaitBuckets, + }), +}; + +// Metrics +let eluBase = performance.eventLoopUtilization(); + +new client.Gauge({ + name: 'nodejs_event_loop_utilization_ratio', + help: 'Event Loop Utilization (ELU) as reported by NodeJS', + collect() { + this.set(performance.eventLoopUtilization(eluBase).utilization); + eluBase = performance.eventLoopUtilization(); + }, +}); diff --git a/apps/meteor/server/lib/metrics/lib/statsTracker.js b/apps/meteor/server/lib/metrics/lib/statsTracker.js new file mode 100644 index 0000000..d46d759 --- /dev/null +++ b/apps/meteor/server/lib/metrics/lib/statsTracker.js @@ -0,0 +1,47 @@ +import { StatsD } from 'node-dogstatsd'; + +class StatsTracker { + constructor() { + this.StatsD = StatsD; + this.dogstatsd = new this.StatsD(); + } + + track(type, stats, ...args) { + this.dogstatsd[type](`RocketChat.${stats}`, ...args); + } + + now() { + const hrtime = process.hrtime(); + return hrtime[0] * 1000000 + hrtime[1] / 1000; + } + + timing(stats, time, tags) { + this.track('timing', stats, time, tags); + } + + increment(stats, time, tags) { + this.track('increment', stats, time, tags); + } + + decrement(stats, time, tags) { + this.track('decrement', stats, time, tags); + } + + histogram(stats, time, tags) { + this.track('histogram', stats, time, tags); + } + + gauge(stats, time, tags) { + this.track('gauge', stats, time, tags); + } + + unique(stats, time, tags) { + this.track('unique', stats, time, tags); + } + + set(stats, time, tags) { + this.track('set', stats, time, tags); + } +} + +export default new StatsTracker(); diff --git a/apps/meteor/server/lib/migrations.ts b/apps/meteor/server/lib/migrations.ts new file mode 100644 index 0000000..1812f04 --- /dev/null +++ b/apps/meteor/server/lib/migrations.ts @@ -0,0 +1,334 @@ +import type { IControl } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Migrations } from '@rocket.chat/models'; + +import { showErrorBox } from './logger/showBox'; +import { Info } from '../../app/utils/rocketchat.info'; +import { sleep } from '../../lib/utils/sleep'; + +type IMigration = { + name?: string; + version: number; + up: (migration: IMigration) => Promise | void; + down?: (migration: IMigration) => Promise | void; +}; + +const log = new Logger('Migrations'); + +const migrations = new Set(); + +// sets the control record +async function setControl(control: Pick): Promise> { + await Migrations.updateOne( + { + _id: 'control', + }, + { + $set: { + version: control.version, + locked: control.locked, + }, + }, + { + upsert: true, + }, + ); + + return control; +} + +// gets the current control record, optionally creating it if non-existant +export async function getControl(): Promise { + const control = (await Migrations.findOne({ + _id: 'control', + })) as IControl; + + return ( + control || + setControl({ + version: 0, + locked: false, + }) + ); +} + +// Returns true if lock was acquired. +async function lock(): Promise { + const date = new Date(); + const dateMinusInterval = new Date(); + dateMinusInterval.setMinutes(dateMinusInterval.getMinutes() - 5); + + const build = Info ? Info.build.date : date; + + // This is atomic. The selector ensures only one caller at a time will see + // the unlocked control, and locking occurs in the same update's modifier. + // All other simultaneous callers will get false back from the update. + return ( + ( + await Migrations.updateMany( + { + _id: 'control', + $or: [ + { + locked: false, + }, + { + lockedAt: { + $lt: dateMinusInterval, + }, + }, + { + buildAt: { + $ne: build, + }, + }, + ], + }, + { + $set: { + locked: true, + lockedAt: date, + buildAt: build, + }, + }, + ) + ).matchedCount === 1 + ); +} + +export function addMigration(migration: IMigration): void { + if (!migration?.version) { + throw new Error('Migration version is required'); + } + if (!migration?.up) { + throw new Error('Migration up() is required'); + } + migrations.add(migration); +} + +// Side effect: saves version. +async function unlock(version: number): Promise { + await setControl({ + locked: false, + version, + }); +} + +function getOrderedMigrations(): IMigration[] { + return Array.from(migrations).sort((a, b) => a.version - b.version); +} + +function showError(version: number, control: IControl, e: any): void { + showErrorBox( + 'ERROR! SERVER STOPPED', + [ + 'Your database migration failed:', + e.message, + '', + 'Please make sure you are running the latest version and try again.', + 'If the problem persists, please contact support.', + '', + `This Rocket.Chat version: ${Info.version}`, + `Database locked at version: ${control.version}`, + `Database target version: ${version}`, + '', + `Commit: ${Info.commit.hash}`, + `Date: ${Info.commit.date}`, + `Branch: ${Info.commit.branch}`, + `Tag: ${Info.commit.tag}`, + ].join('\n'), + ); +} + +// run the actual migration +async function migrate(direction: 'up' | 'down', migration: IMigration): Promise { + if (typeof migration[direction] !== 'function') { + throw new Error(`Cannot migrate ${direction} on version ${migration.version}`); + } + + log.startup({ + msg: 'Running migration', + direction, + version: migration.version, + name: migration.name, + }); + + await migration[direction]?.(migration); +} + +const maxAttempts = 30; +const retryInterval = 10; +let currentAttempt = 0; + +export async function migrateDatabase(targetVersion: 'latest' | number, subcommands?: string[]): Promise { + const control = await getControl(); + const currentVersion = control.version; + + const orderedMigrations = getOrderedMigrations(); + + if (orderedMigrations.length === 0) { + log.startup('No migrations to run'); + return true; + } + + // version 0 means it is a fresh database, just set the control to latest known version and skip + if (currentVersion === 0) { + await setControl({ + locked: false, + version: orderedMigrations[orderedMigrations.length - 1].version, + }); + return true; + } + + const version = targetVersion === 'latest' ? orderedMigrations[orderedMigrations.length - 1].version : targetVersion; + + // get latest version + // const { version } = orderedMigrations[orderedMigrations.length - 1]; + + if (!(await lock())) { + if (currentAttempt <= maxAttempts) { + log.warn({ + msg: 'Not migrating, control is locked. Will retry.', + retryIntervalSeconds: retryInterval, + attempt: currentAttempt, + maxAttempts, + }); + + await sleep(retryInterval * 1000); + + currentAttempt++; + return migrateDatabase(targetVersion, subcommands); + } + const control = await getControl(); // Side effect: upserts control document. + showErrorBox( + 'ERROR! SERVER STOPPED', + [ + 'Your database migration control is locked.', + 'Please make sure you are running the latest version and try again.', + 'If the problem persists, please contact support.', + '', + `This Rocket.Chat version: ${Info.version}`, + `Database locked at version: ${control.version}`, + `Database target version: ${version}`, + '', + `Commit: ${Info.commit.hash}`, + `Date: ${Info.commit.date}`, + `Branch: ${Info.commit.branch}`, + `Tag: ${Info.commit.tag}`, + ].join('\n'), + ); + process.exit(1); + } + + if (subcommands?.includes('rerun')) { + log.startup({ + msg: 'Rerunning migration', + targetVersion, + }); + const migration = orderedMigrations.find((migration) => migration.version === targetVersion); + + if (!migration) { + throw new Error(`Cannot rerun migration ${targetVersion}`); + } + + try { + await migrate('up', migration); + } catch (e) { + showError(version, control, e); + log.error({ err: e }); + process.exit(1); + } + log.startup('Finished migrating.'); + await unlock(currentVersion); + return true; + } + + if (currentVersion === version) { + log.startup({ + msg: 'Already at target migration version', + version, + }); + await unlock(currentVersion); + return true; + } + + const startIdx = orderedMigrations.findIndex((migration) => migration.version === currentVersion); + if (startIdx === -1) { + throw new Error(`Can't find migration version ${currentVersion}`); + } + + const endIdx = orderedMigrations.findIndex((migration) => migration.version === version); + if (endIdx === -1) { + throw new Error(`Can't find migration version ${version}`); + } + + log.startup({ + msg: 'Migrating between versions', + fromVersion: orderedMigrations[startIdx].version, + toVersion: orderedMigrations[endIdx].version, + }); + + try { + const migrations = []; + if (currentVersion < version) { + for (let i = startIdx; i < endIdx; i++) { + migrations.push(async () => { + await migrate('up', orderedMigrations[i + 1]); + await setControl({ + locked: true, + version: orderedMigrations[i + 1].version, + }); + }); + } + } else { + for (let i = startIdx; i > endIdx; i--) { + migrations.push(async () => { + await migrate('down', orderedMigrations[i]); + await setControl({ + locked: true, + version: orderedMigrations[i - 1].version, + }); + }); + } + } + for await (const migration of migrations) { + await migration(); + } + } catch (e) { + showError(version, control, e); + log.error({ err: e }); + process.exit(1); + } + + await unlock(orderedMigrations[endIdx].version); + log.startup('Finished migrating.'); + + // remember to run meteor with --once otherwise it will restart + if (subcommands?.includes('exit')) { + process.exit(0); + } + + return true; +} + +export async function onServerVersionChange(cb: () => Promise): Promise { + const result = await Migrations.findOneAndUpdate( + { + _id: 'upgrade', + }, + { + $set: { + hash: Info.commit.hash, + }, + }, + { + upsert: true, + }, + ); + + if (result?.hash === Info.commit.hash) { + return; + } + + await cb(); +} diff --git a/apps/meteor/server/lib/moderation/deleteReportedMessages.ts b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts new file mode 100644 index 0000000..af62260 --- /dev/null +++ b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts @@ -0,0 +1,75 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUser, IMessage } from '@rocket.chat/core-typings'; +import { Messages, Uploads, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models'; + +import { settings } from '../../settings'; +import { FileUpload } from '../media/file-upload'; + +// heavily inspired from message delete taking place in the user deletion process +// in this path we don't care about the apps engine events - it's a "raw" bulk action +export async function deleteReportedMessages(messages: IMessage[], user: IUser): Promise { + const keepHistory = settings.get('Message_KeepHistory'); + const showDeletedStatus = settings.get('Message_ShowDeletedStatus'); + const files: string[] = []; + const messageIds: string[] = []; + for (const message of messages) { + if (message.file) { + files.push(message.file._id); + } + if (message.files && message.files.length > 0) { + files.concat(message.files.map((file) => file._id)); + } + messageIds.push(message._id); + } + if (keepHistory) { + if (showDeletedStatus) { + const cursor = Messages.find({ _id: { $in: messageIds } }); + + for await (const doc of cursor) { + await Messages.cloneAndSaveAsHistoryByRecord( + doc, + user as Required> & { username: NonNullable }, + ); + } + } else { + await Messages.setHiddenByIds(messageIds, true); + } + + await Uploads.updateMany({ _id: { $in: files } }, { $set: { _hidden: true } }); + } else { + if (!showDeletedStatus) { + await Messages.deleteMany({ _id: { $in: messageIds } }); + } + await ReadReceipts.removeByMessageIds(messageIds); + await ReadReceiptsArchive.removeByMessageIds(messageIds); + + const store = FileUpload.getStore('Uploads'); + await Promise.all(files.map((file) => store.deleteById(file))); + } + if (showDeletedStatus) { + await Messages.setAsDeletedByIdsAndUser(messageIds, user as any); + } + + const transformed = messages.reduce( + (acc, { rid, _id }) => { + if (!acc[rid]) { + acc[rid] = []; + } + acc[rid].push(_id); + return acc; + }, + {} as Record, + ); + + Object.entries(transformed).forEach(([rid, messageIds]) => { + void api.broadcast('notify.deleteMessageBulk', rid, { + rid, + excludePinned: true, + ignoreDiscussion: true, + ts: { $gt: new Date() }, + users: [], + ids: messageIds, + showDeletedStatus, + }); + }); +} diff --git a/apps/meteor/server/lib/moderation/reportMessage.ts b/apps/meteor/server/lib/moderation/reportMessage.ts new file mode 100644 index 0000000..eea0367 --- /dev/null +++ b/apps/meteor/server/lib/moderation/reportMessage.ts @@ -0,0 +1,55 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Messages, ModerationReports, Rooms, Users } from '@rocket.chat/models'; + +import { canAccessRoomAsync } from '../authorization/canAccessRoom'; + +export const reportMessage = async (messageId: IMessage['_id'], description: string, uid: IUser['_id']) => { + if (!uid) { + throw new Error('error-invalid-user'); + } + + if (!description.trim()) { + throw new Error('error-invalid-description'); + } + + const message = await Messages.findOneById(messageId); + + if (!message) { + throw new Error('error-invalid-message_id'); + } + + const user = await Users.findOneById(uid); + + if (!user) { + throw new Error('error-invalid-user'); + } + + const { rid } = message; + // If the user can't access the room where the message is, report that the message id is invalid + const room = await Rooms.findOneById(rid); + if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) { + throw new Error('error-invalid-message_id'); + } + + const reportedBy = { + _id: user._id, + username: user.username, + name: user.name, + createdAt: user.createdAt, + }; + + const roomInfo = { + _id: rid, + name: room.name, + t: room.t, + federated: room.federated, + fname: room.fname, + }; + + await ModerationReports.createWithMessageDescriptionAndUserId(message, description, roomInfo, reportedBy); + + await Apps.self?.triggerEvent(AppEvents.IPostMessageReported, message, user, description); + + return true; +}; diff --git a/apps/meteor/server/lib/notifications/core/index.ts b/apps/meteor/server/lib/notifications/core/index.ts new file mode 100644 index 0000000..1cb2af1 --- /dev/null +++ b/apps/meteor/server/lib/notifications/core/index.ts @@ -0,0 +1,3 @@ +import Notifications from './lib/Notifications'; + +export { Notifications }; diff --git a/apps/meteor/server/lib/notifications/core/lib/Notifications.ts b/apps/meteor/server/lib/notifications/core/lib/Notifications.ts new file mode 100644 index 0000000..d5aca0e --- /dev/null +++ b/apps/meteor/server/lib/notifications/core/lib/Notifications.ts @@ -0,0 +1,40 @@ +import { api } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { DDPCommon } from 'meteor/ddp-common'; +import { Meteor } from 'meteor/meteor'; + +import { NotificationsModule } from '../../../../modules/notifications/notifications.module'; +import { Streamer } from '../../../../modules/streamer/streamer.module'; + +import './Presence'; + +class Stream extends Streamer<'local'> { + registerPublication(name: string, fn: (eventName: string, options: boolean | { useCollection?: boolean; args?: any }) => void): void { + Meteor.publish(name, function (eventName, options) { + return fn.call(this, eventName, options); + }); + } + + registerMethod(methods: Partial): void { + Meteor.methods(methods); + } + + changedPayload(collection: string, id: string, fields: Record): string | false { + return DDPCommon.stringifyDDP({ + msg: 'changed', + collection, + id, + fields, + }); + } +} + +const notifications = new NotificationsModule(Stream); + +notifications.configure(); + +notifications.streamLocal.on('broadcast', ({ eventName, args }) => { + void api.broadcastLocal(eventName, ...args); +}); + +export default notifications; diff --git a/apps/meteor/server/lib/notifications/core/lib/Presence.ts b/apps/meteor/server/lib/notifications/core/lib/Presence.ts new file mode 100644 index 0000000..0f24eab --- /dev/null +++ b/apps/meteor/server/lib/notifications/core/lib/Presence.ts @@ -0,0 +1,200 @@ +import { StatusVisibility } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { USER_STATUS_TO_PRESENCE_CODE, UserStatus } from '@rocket.chat/core-typings'; +import type { StreamerEvents } from '@rocket.chat/ddp-client'; +import { Emitter } from '@rocket.chat/emitter'; + +import { Streamer } from '../../../../modules/streamer/streamer.module'; +import type { IPublication, IStreamerConstructor, Connection, IStreamer } from '../../../../modules/streamer/types'; +import { statusVisibilityGate } from '../../../statusVisibility/StatusVisibilityGate'; + +type UserPresenceStreamProps = { + added: IUser['_id'][]; + removed: IUser['_id'][]; +}; + +type UserPresenceStreamArgs = { + uid: string; + args: StreamerEvents['user-presence'][number]['args']; +}; + +const e = new Emitter<{ + [key: string]: UserPresenceStreamArgs; +}>(); + +const clients = new WeakMap(); + +// mirror of `clients`, which cannot be enumerated +const liveClients = new Set(); + +class UserPresence { + private readonly streamer: IStreamer<'user-presence'>; + + private readonly publication: IPublication; + + private readonly listeners: Set; + + // map value as true marks a pending correction + private hiddenFrom = new Map(); + + private stale = true; + + constructor(publication: IPublication, streamer: IStreamer<'user-presence'>) { + this.listeners = new Set(); + this.publication = publication; + this.streamer = streamer; + } + + listen(uid: string): void { + if (this.listeners.has(uid)) { + return; + } + e.on(uid, this.run); + this.listeners.add(uid); + } + + off = (uid: string): void => { + e.off(uid, this.run); + this.listeners.delete(uid); + }; + + get isStale() { + return this.stale; + } + + async refreshHiddenFrom(): Promise { + if (!(await statusVisibilityGate.ensureEnabled())) { + if (this.hiddenFrom.size) { + this.hiddenFrom = new Map(); + } + this.stale = false; + return; + } + + const previous = this.hiddenFrom; + + try { + const hidden = await StatusVisibility.getHiddenFrom(this.publication._session?.userId); + + this.hiddenFrom = new Map(hidden.map((uid) => [uid, !previous.has(uid) && this.listeners.has(uid) ? true : undefined])); + this.stale = false; + } catch (error) { + this.stale = true; + throw error; + } + } + + run = (args: UserPresenceStreamArgs): void => { + const hidden = this.hiddenFrom.has(args.uid); + + if (hidden) { + if (!this.hiddenFrom.get(args.uid)) { + return; + } + + this.hiddenFrom.set(args.uid, undefined); + } + + const visiblePresence: UserPresenceStreamArgs = hidden + ? { uid: args.uid, args: [[args.args[0][0], USER_STATUS_TO_PRESENCE_CODE[UserStatus.OFFLINE]]] } + : args; + const payload = this.streamer.changedPayload(this.streamer.subscriptionName, args.uid, { ...visiblePresence, eventName: args.uid }); // there is no good explanation to keep eventName, I just want to save one 'DDPCommon.parseDDP' on the client side, so I'm trying to fit the Meteor Streamer's payload + if (!payload) { + return; + } + // after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong) + // we were just not counting on this, session is _session so we actually should not use it + // now after any await, the session can potentially be null, so we need to check for that + if (!Streamer.isPublicationActive(this.publication)) { + return; + } + + this.publication._session.socket.send(payload); + }; + + get viewerId(): IUser['_id'] | undefined { + return this.publication._session?.userId; + } + + stop(): void { + this.listeners.forEach(this.off); + clients.delete(this.publication.connection); + liveClients.delete(this); + } + + static getClient(publication: IPublication, streamer: IStreamer<'user-presence'>): [UserPresence, boolean] { + const { connection } = publication; + const stored = clients.get(connection); + + const client = stored || new UserPresence(publication, streamer); + + const main = Boolean(!stored); + + clients.set(connection, client); + liveClients.add(client); + + return [client, main]; + } +} + +export class StreamPresence { + // eslint-disable-next-line @typescript-eslint/naming-convention + static getInstance(StreamerClass: IStreamerConstructor, name = 'user-presence'): IStreamer<'user-presence'> { + return new ( + class StreamPresence extends StreamerClass<'user-presence'> { + override async _publish( + publication: IPublication, + _eventName: string, + options: boolean | { useCollection?: boolean; args?: any } = false, + ): Promise { + const { added, removed } = (typeof options !== 'boolean' ? options : {}) as unknown as UserPresenceStreamProps; + + const [client, main] = UserPresence.getClient(publication, this); + + if (client.isStale) { + try { + await client.refreshHiddenFrom(); + } catch (error) { + if (main) { + client.stop(); + throw error; + } + } + } + + if (!Streamer.isPublicationActive(publication)) { + if (main) { + client.stop(); + } + + return; + } + + added?.forEach((uid) => client.listen(uid)); + removed?.forEach((uid) => client.off(uid)); + + if (!main) { + publication.stop(); + return; + } + + publication.ready(); + + publication.onStop(() => client.stop()); + } + } as any + )(name); + } +} + +export const emit = (uid: string, args: UserPresenceStreamArgs['args']): void => { + e.emit(uid, { uid, args }); +}; + +// no viewers means the setting itself changed, so every client has to re-pull +export const refreshVisibility = async (viewers?: IUser['_id'][]): Promise => { + const affected = viewers && new Set(viewers); + const clients = affected ? Array.from(liveClients).filter(({ viewerId }) => viewerId && affected.has(viewerId)) : liveClients; + + await Promise.allSettled(Array.from(clients, (client) => client.refreshHiddenFrom())); +}; diff --git a/apps/meteor/server/lib/notifications/email/api.ts b/apps/meteor/server/lib/notifications/email/api.ts new file mode 100644 index 0000000..1cfaa01 --- /dev/null +++ b/apps/meteor/server/lib/notifications/email/api.ts @@ -0,0 +1,224 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { escapeHTML, validateEmail } from '@rocket.chat/tools'; +import juice from 'juice'; +import { Email } from 'meteor/email'; +import { Meteor } from 'meteor/meteor'; +import { stripHtml } from 'string-strip-html'; +import _ from 'underscore'; + +import { replaceVariables } from './replaceVariables'; +import { strLeft, strRightBack } from '../../../../lib/utils/stringUtils'; +import { settings } from '../../../settings'; +import { i18n } from '../../i18n'; +import { notifyOnSettingChanged } from '../../notifyListener'; + +let contentHeader: string | undefined; +let contentFooter: string | undefined; +let body: string | undefined; + +// define server language for email translations +// @TODO: change TAPi18n.__ function to use the server language by default +let lng = 'en'; +settings.watch('Language', (value) => { + lng = value || 'en'; +}); + +export const replacekey = (str: string, key: string, value = ''): string => + str.replace(new RegExp(`(\\[${key}\\]|__${key}__)`, 'igm'), value); + +export const translate = (str: string): string => replaceVariables(str, (_match, key) => i18n.t(key, { lng })); + +export const replace = (str: string, data: { [key: string]: unknown } = {}): string => { + if (!str) { + return ''; + } + + const options = { + Site_Name: settings.get('Site_Name'), + Site_URL: settings.get('Site_Url'), + Site_URL_Slash: settings.get('Site_Url')?.replace(/\/?$/, '/'), + ...(data.name + ? { + fname: strLeft(String(data.name), ' '), + lname: strRightBack(String(data.name), ' '), + } + : {}), + ...data, + }; + + return Object.entries(options).reduce((ret, [key, value]) => replacekey(ret, key, value), translate(str)); +}; + +const nonEscapeKeys = ['room_path']; + +export const replaceEscaped = (str: string, data: { [key: string]: unknown } = {}): string => { + const siteName = settings.get('Site_Name'); + const siteUrl = settings.get('Site_Url'); + + return replace(str, { + Site_Name: siteName ? escapeHTML(siteName) : undefined, + Site_Url: siteUrl ? escapeHTML(siteUrl) : undefined, + ...Object.entries(data).reduce<{ [key: string]: string }>((ret, [key, value]) => { + if (value !== undefined && value !== null) { + ret[key] = nonEscapeKeys.includes(key) ? String(value) : escapeHTML(String(value)); + } + return ret; + }, {}), + }); +}; + +export const wrap = (html: string, data: { [key: string]: unknown } = {}): string => { + if (settings.get('email_plain_text_only')) { + return replace(html, data); + } + + if (!body) { + throw new Error('error-email-body-not-initialized'); + } + + return replaceEscaped(body.replace('{{body}}', html), data); +}; + +export const inlinecss = (html: string): string => { + const css = settings.get('email_style'); + return css ? juice.inlineContent(html, css) : html; +}; + +export const getTemplate = (template: ISetting['_id'], fn: (html: string) => void, escape = true): void => { + let html = ''; + + settings.watch(template, (value) => { + html = value || ''; + fn(escape ? inlinecss(html) : html); + }); + + settings.watch('email_style', () => { + fn(escape ? inlinecss(html) : html); + }); +}; + +export const getTemplateWrapped = (template: ISetting['_id'], fn: (html: string) => void): void => { + let html = ''; + const wrapInlineCSS = _.debounce(() => fn(wrap(inlinecss(html))), 100); + + settings.watch('Email_Header', () => html && wrapInlineCSS()); + settings.watch('Email_Footer', () => html && wrapInlineCSS()); + settings.watch('email_style', () => html && wrapInlineCSS()); + settings.watch(template, (value) => { + html = value || ''; + return html && wrapInlineCSS(); + }); +}; + +settings.watchMultiple(['Email_Header', 'Email_Footer'], () => { + getTemplate( + 'Email_Header', + (value) => { + contentHeader = replace(value || ''); + body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`); + }, + false, + ); + + getTemplate( + 'Email_Footer', + (value) => { + contentFooter = replace(value || ''); + body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`); + }, + false, + ); + + body = inlinecss(`${contentHeader} {{body}} ${contentFooter}`); +}); + +export const checkAddressFormat = (adresses: string | string[]): boolean => + ([] as string[]).concat(adresses).every((address) => validateEmail(address)); + +export const sendNoWrap = async ({ + to, + from, + replyTo, + subject, + html, + text, + headers, +}: { + to: string | string[]; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; +}) => { + if (!checkAddressFormat(to)) { + throw new Meteor.Error('invalid email'); + } + + if (!text) { + text = html ? stripHtml(html).result : undefined; + } + + if (settings.get('email_plain_text_only')) { + html = undefined; + } + + const value = await Settings.incrementValueById('Triggered_Emails_Count', 1, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + + const email = { to, from, replyTo, subject, html, text, headers }; + + const eventResult = await Apps.self?.triggerEvent(AppEvents.IPreEmailSent, { email }); + + setImmediate(() => Email.sendAsync(eventResult || email).catch((e) => console.error(e))); +}; + +export const send = async ({ + to, + from, + replyTo, + subject, + html, + text, + data, + headers, +}: { + to: string | string[]; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; + data?: { [key: string]: unknown }; +}): Promise => + sendNoWrap({ + to, + from, + replyTo, + subject: replace(subject, data), + text: (text && replace(text, data)) || (html && stripHtml(replace(html, data)).result) || undefined, + html: html ? wrap(html, data) : undefined, + headers, + }); + +// Needed because of https://github.com/microsoft/TypeScript/issues/36931 +type Assert = (input: string, func: string) => asserts input; +export const checkAddressFormatAndThrow: Assert = (from: string, func: string): asserts from => { + if (checkAddressFormat(from)) { + return; + } + + throw new Meteor.Error('error-invalid-from-address', 'Invalid from address', { + function: func, + }); +}; + +export const getHeader = (): string | undefined => contentHeader; + +export const getFooter = (): string | undefined => contentFooter; diff --git a/apps/meteor/server/lib/notifications/email/replaceVariables.ts b/apps/meteor/server/lib/notifications/email/replaceVariables.ts new file mode 100644 index 0000000..fbd0e82 --- /dev/null +++ b/apps/meteor/server/lib/notifications/email/replaceVariables.ts @@ -0,0 +1,2 @@ +export const replaceVariables = (str: string, replacer: (substring: string, key: string) => string): string => + str.replace(/\{ *([^\{\} ]+)[^\{\}]*\}/gim, replacer); diff --git a/apps/meteor/server/lib/notifications/interceptDirectReplyEmails.js b/apps/meteor/server/lib/notifications/interceptDirectReplyEmails.js new file mode 100644 index 0000000..2100ea1 --- /dev/null +++ b/apps/meteor/server/lib/notifications/interceptDirectReplyEmails.js @@ -0,0 +1,151 @@ +import POP3Lib from '@rocket.chat/poplib'; +import { simpleParser } from 'mailparser'; + +import { processDirectEmail } from './processDirectEmail'; +import { IMAPInterceptor } from '../../email/IMAPInterceptor'; +import { settings } from '../../settings'; + +export class DirectReplyIMAPInterceptor extends IMAPInterceptor { + constructor(imapConfig, options = {}) { + imapConfig = { + user: settings.get('Direct_Reply_Username'), + password: settings.get('Direct_Reply_Password'), + host: settings.get('Direct_Reply_Host'), + port: settings.get('Direct_Reply_Port'), + debug: settings.get('Direct_Reply_Debug') ? console.log : false, + tls: !settings.get('Direct_Reply_IgnoreTLS'), + ...imapConfig, + }; + + options.deleteAfterRead = settings.get('Direct_Reply_Delete'); + + super(imapConfig, options); + + this.on('email', (email) => processDirectEmail(email)); + } +} + +class POP3Intercepter { + constructor() { + this.pop3 = new POP3Lib(settings.get('Direct_Reply_Port'), settings.get('Direct_Reply_Host'), { + enabletls: !settings.get('Direct_Reply_IgnoreTLS'), + // debug: settings.get('Direct_Reply_Debug') ? console.log : false, + debug: console.log, + }); + + this.totalMsgCount = 0; + this.currentMsgCount = 0; + + this.pop3.on('connect', () => { + console.log('Pop connect'); + this.pop3.login(settings.get('Direct_Reply_Username'), settings.get('Direct_Reply_Password')); + }); + + this.pop3.on('login', (status) => { + if (!status) { + return console.log('Unable to Log-in ....'); + } + console.log('Pop logged'); + // run on start + this.pop3.list(); + }); + + // on getting list of all emails + this.pop3.on('list', (status, msgcount) => { + if (!status) { + console.log('Cannot Get Emails ....'); + } + if (msgcount === 0) { + return this.pop3.quit(); + } + + this.totalMsgCount = msgcount; + this.currentMsgCount = 1; + // Retrieve email + this.pop3.retr(this.currentMsgCount); + }); + + // on retrieved email + this.pop3.on('retr', async (status, msgnumber, data) => { + if (!status) { + return console.log('Cannot Retrieve Message ....'); + } + + // parse raw email data to JSON object + simpleParser(data, (err, mail) => { + processDirectEmail(mail); + }); + + this.currentMsgCount += 1; + + // delete email + this.pop3.dele(msgnumber); + }); + + // on email deleted + this.pop3.on('dele', (status) => { + if (!status) { + return console.log('Cannot Delete Message....'); + } + + // get next email + if (this.currentMsgCount <= this.totalMsgCount) { + return this.pop3.retr(this.currentMsgCount); + } + + // parsed all messages.. so quitting + this.pop3.quit(); + }); + + // invalid server state + this.pop3.on('invalid-state', (cmd) => { + console.log(`Invalid state. You tried calling ${cmd}`); + }); + + this.pop3.on('error', (cmd) => { + console.log(`error state. You tried calling ${cmd}`); + }); + + // locked => command already running, not finished yet + this.pop3.on('locked', (cmd) => { + console.log(`Current command has not finished yet. You tried calling ${cmd}`); + }); + } +} + +export class POP3Helper { + constructor(frequency) { + this.frequency = frequency; + this.running = false; + + this.POP3 = new POP3Intercepter(); + } + + isActive() { + return this.running; + } + + start() { + this.log('POP3 started'); + this.running = setInterval( + () => { + // get new emails and process + this.POP3 = new POP3Intercepter(); + }, + Math.max(this.frequency * 60 * 1000, 2 * 60 * 1000), + ); + } + + log(...args) { + console.log(...args); + } + + stop(callback = new Function()) { + this.log('POP3 stop called'); + if (this.isActive()) { + clearInterval(this.running); + } + callback(); + this.log('POP3 stopped'); + } +} diff --git a/apps/meteor/server/lib/notifications/mail-messages/functions/sendMail.ts b/apps/meteor/server/lib/notifications/mail-messages/functions/sendMail.ts new file mode 100644 index 0000000..1749c53 --- /dev/null +++ b/apps/meteor/server/lib/notifications/mail-messages/functions/sendMail.ts @@ -0,0 +1,101 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import EJSON from 'ejson'; +import { Meteor } from 'meteor/meteor'; +import type { Filter } from 'mongodb'; + +import { generatePath } from '../../../../../lib/utils/generatePath'; +import { SystemLogger } from '../../../logger/system'; +import { placeholders } from '../../../utils/placeholders'; +import * as Mailer from '../../email/api'; + +export const sendMail = async function ({ + from, + subject, + body, + dryrun, + query, +}: { + from: string; + subject: string; + body: string; + dryrun?: boolean; + query?: string; +}): Promise { + Mailer.checkAddressFormatAndThrow(from, 'Mailer.sendMail'); + + if (body.indexOf('[unsubscribe]') === -1) { + throw new Meteor.Error('error-missing-unsubscribe-link', 'You must provide the [unsubscribe] link.', { + function: 'Mailer.sendMail', + }); + } + + let userQuery: Filter = { 'mailer.unsubscribed': { $exists: 0 } }; + if (query) { + userQuery = { $and: [userQuery, EJSON.parse(query)] }; + } + + if (dryrun) { + const user = await Users.findOneByEmailAddress(from); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'Mailer.sendMail', + }); + } + + const email = `${user.name} <${user.emails?.[0].address}>`; + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl( + generatePath('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt?.getTime().toString() || '', + }), + ), + name: user.name, + email, + }); + + SystemLogger.debug({ + msg: 'Sending email', + email, + }); + return Mailer.send({ + to: email, + from, + subject, + html, + }); + } + + const users = await Users.find(userQuery).toArray(); + + for await (const u of users) { + const user: Partial & Pick = u; + if (user?.emails && Array.isArray(user.emails) && user.emails.length) { + const email = `${user.name} <${user.emails[0].address}>`; + + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl( + generatePath('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt?.getTime().toString() || '', + }), + ), + name: escapeHTML(user.name || ''), + email: escapeHTML(email), + }); + SystemLogger.debug({ + msg: 'Sending email', + email, + }); + await Mailer.send({ + to: email, + from, + subject, + html, + }); + } + } +}; diff --git a/apps/meteor/server/lib/notifications/mail-messages/functions/unsubscribe.ts b/apps/meteor/server/lib/notifications/mail-messages/functions/unsubscribe.ts new file mode 100644 index 0000000..8d24df8 --- /dev/null +++ b/apps/meteor/server/lib/notifications/mail-messages/functions/unsubscribe.ts @@ -0,0 +1,19 @@ +import { Users } from '@rocket.chat/models'; + +import { SystemLogger } from '../../../logger/system'; + +export const unsubscribe = async function (_id: string, createdAt: string): Promise { + if (_id && createdAt) { + const affectedRows = (await Users.rocketMailUnsubscribe(_id, createdAt)) === 1; + + SystemLogger.debug({ + msg: '[Mailer:Unsubscribe]', + _id, + createdAt, + affectedRows, + }); + + return affectedRows; + } + return false; +}; diff --git a/apps/meteor/server/lib/notifications/mail-messages/lib/Mailer.ts b/apps/meteor/server/lib/notifications/mail-messages/lib/Mailer.ts new file mode 100644 index 0000000..ee55cbc --- /dev/null +++ b/apps/meteor/server/lib/notifications/mail-messages/lib/Mailer.ts @@ -0,0 +1,7 @@ +import { sendMail } from '../functions/sendMail'; +import { unsubscribe } from '../functions/unsubscribe'; + +export const Mailer = { + sendMail, + unsubscribe, +}; diff --git a/apps/meteor/server/lib/notifications/message/desktop.ts b/apps/meteor/server/lib/notifications/message/desktop.ts new file mode 100644 index 0000000..d55cefe --- /dev/null +++ b/apps/meteor/server/lib/notifications/message/desktop.ts @@ -0,0 +1,125 @@ +import { api } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; + +import { settings } from '../../../settings'; +import { metrics } from '../../metrics'; +import { roomCoordinator } from '../../rooms/roomCoordinator'; + +/** + * Send notification to user + * + * @param {string} userId The user to notify + * @param {object} user The sender + * @param {object} room The room send from + * @param {object} message The message object + * @param {number} duration Duration of notification + * @param {string} notificationMessage The message text to send on notification body + */ +export async function notifyDesktopUser({ + userId, + user, + message, + room, + duration, + notificationMessage, + audioNotificationValue, +}: { + userId: string; + user: AtLeast; + message: IMessage | Pick; + room: IRoom; + duration?: number; + notificationMessage: string; + audioNotificationValue?: string; +}): Promise { + const { title, text, name } = await roomCoordinator + .getRoomDirectives(room.t) + .getNotificationDetails(room, user, notificationMessage, userId); + + const payload = { + title: title || '', + text, + duration, + payload: { + _id: '', + rid: '', + tmid: '', + ...('_id' in message && { + // TODO: omnichannel is not sending _id, rid, tmid + _id: message._id, + rid: message.rid, + tmid: message.tmid, + }), + sender: message.u, + type: room.t, + message: { + msg: 'msg' in message ? message.msg : '', + ...('t' in message && { + t: message.t, + }), + ...('content' in message && { + content: message.content, + }), + }, + name, + audioNotificationValue, + }, + }; + + metrics.notificationsSent.inc({ notification_type: 'desktop' }); + metrics.notificationsSentTotal.inc({ notification_type: 'desktop' }); + + void api.broadcast('notify.desktop', userId, payload); +} + +export function shouldNotifyDesktop({ + disableAllMessageNotifications, + status, + statusConnection, + desktopNotifications, + hasMentionToAll, + hasMentionToHere, + isHighlighted, + hasMentionToUser, + hasReplyToThread, + roomType, + isThread, +}: { + disableAllMessageNotifications: boolean; + status: string; + statusConnection: string; + desktopNotifications: string | undefined; + hasMentionToAll: boolean; + hasMentionToHere: boolean; + isHighlighted: boolean; + hasMentionToUser: boolean; + hasReplyToThread: boolean; + roomType: string; + isThread: boolean; +}): boolean { + if (disableAllMessageNotifications && !desktopNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { + return false; + } + + if (statusConnection === 'offline' || status === 'busy' || desktopNotifications === 'nothing') { + return false; + } + + if (!desktopNotifications) { + if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'all' && (!isThread || hasReplyToThread)) { + return true; + } + if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'nothing') { + return false; + } + } + + return ( + (roomType === 'd' || + (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || + isHighlighted || + desktopNotifications === 'all' || + hasMentionToUser) && + (isHighlighted || !isThread || hasReplyToThread) + ); +} diff --git a/apps/meteor/server/lib/notifications/message/email.js b/apps/meteor/server/lib/notifications/message/email.js new file mode 100644 index 0000000..ba39629 --- /dev/null +++ b/apps/meteor/server/lib/notifications/message/email.js @@ -0,0 +1,242 @@ +import { escapeHTML } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { ltrim } from '../../../../lib/utils/stringUtils'; +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { i18n } from '../../i18n'; +import { metrics } from '../../metrics'; +import { roomCoordinator } from '../../rooms/roomCoordinator'; +import { getURL } from '../../utils/getURL'; +import * as Mailer from '../email/api'; + +let advice = ''; +let goToMessage = ''; +Meteor.startup(() => { + settings.watch('email_style', () => { + goToMessage = Mailer.inlinecss('

{Offline_Link_Message}

'); + }); + Mailer.getTemplate('Email_Footer_Direct_Reply', (value) => { + advice = value; + }); +}); + +export async function getEmailContent({ message, user, room }) { + const lng = (user && user.language) || settings.get('Language') || 'en'; + + const roomName = escapeHTML(`#${await roomCoordinator.getRoomName(room.t, room)}`); + const userName = escapeHTML(settings.get('UI_Use_Real_Name') ? message.u.name || message.u.username : message.u.username); + + const roomDirectives = roomCoordinator.getRoomDirectives(room.t); + + const files = (message.files || [message.file]).filter((file) => file && file.typeGroup !== 'thumb'); + const hasFiles = files.length > 0; + const hasText = typeof message.msg === 'string' && message.msg.trim() !== ''; + const isGroupChat = roomDirectives.isGroupChat(room); + + let header; + if (hasFiles && !hasText) { + // file-only message + const isMultipleFiles = files.length > 1; + let headerKey; + if (isGroupChat) { + headerKey = isMultipleFiles ? 'User_uploaded_files_on_channel' : 'User_uploaded_a_file_on_channel'; + } else { + headerKey = isMultipleFiles ? 'User_uploaded_files_to_you' : 'User_uploaded_a_file_to_you'; + } + header = i18n.t(headerKey, { username: userName, channel: roomName, count: files.length, lng }); + } else { + // text message (with or without files) + header = i18n.t(!isGroupChat ? 'User_sent_a_message_to_you' : 'User_sent_a_message_on_channel', { + username: userName, + channel: roomName, + lng, + }); + } + + if (message.t === 'e2e') { + return settings.get('Email_notification_show_message') ? i18n.t('Encrypted_message_preview_unavailable', { lng }) : header; + } + + if (!settings.get('Email_notification_show_message')) { + return header; + } + + const contentParts = []; + + if (hasText) { + let messageContent = escapeHTML(message.msg); + message = await callbacks.run('renderMessage', message); + if (message.tokens && message.tokens.length > 0) { + message.tokens.forEach((token) => { + token.text = token.text.replace(/([^\$])(\$[^\$])/gm, '$1$$$2'); + messageContent = messageContent.replace(token.token, token.text); + }); + } + contentParts.push(messageContent.replace(/\n/gm, '
')); + } + + if (hasFiles) { + const attachments = message.attachments || []; + const fileParts = files.map((file, index) => { + let part = escapeHTML(file.name); + if (attachments[index]?.description) { + part += `

${escapeHTML(attachments[index].description)}`; + } + return part; + }); + contentParts.push(fileParts.join('

')); + } + + if (contentParts.length > 0) { + return `${header}:

${contentParts.join('

')}`; + } + + if (Array.isArray(message.attachments) && message.attachments.length > 0) { + const [attachment] = message.attachments; + + let content = ''; + + if (attachment.title) { + content += `${escapeHTML(attachment.title)}
`; + } + if (attachment.text) { + content += `${escapeHTML(attachment.text)}
`; + } + + return `${header}:

${content}`; + } + + return header; +} + +const getButtonUrl = (room, subscription, message) => { + const basePath = roomCoordinator.getRouteLink(room.t, subscription).replace(Meteor.absoluteUrl(), ''); + + const path = `${ltrim(basePath, '/')}?msg=${message._id}`; + return getURL( + path, + { + full: true, + cloud: settings.get('Offline_Message_Use_DeepLink'), + cloud_route: 'room', + cloud_params: { + rid: room._id, + mid: message._id, + }, + }, + settings.get('DeepLink_Url'), + ); +}; + +function generateNameEmail(name, email) { + return `${String(name).replace(/@/g, '%40').replace(/[<>,]/g, '')} <${email}>`; +} + +export async function getEmailData({ message, receiver, sender, subscription, room, emailAddress, hasMentionToUser }) { + const username = settings.get('UI_Use_Real_Name') ? message.u.name || message.u.username : message.u.username; + let subjectKey = 'Offline_Mention_All_Email'; + + if (!roomCoordinator.getRoomDirectives(room.t).isGroupChat(room)) { + subjectKey = 'Offline_DM_Email'; + } else if (hasMentionToUser) { + subjectKey = 'Offline_Mention_Email'; + } + + const emailSubject = Mailer.replace(settings.get(subjectKey), { + user: username, + room: await roomCoordinator.getRoomName(room.t, room), + }); + const content = await getEmailContent({ + message, + user: receiver, + room, + }); + + const room_path = getButtonUrl(room, subscription, message); + + const receiverName = settings.get('UI_Use_Real_Name') ? receiver.name || receiver.username : receiver.username; + + const email = { + from: generateNameEmail(username, settings.get('From_Email')), + to: generateNameEmail(receiverName, emailAddress), + subject: emailSubject, + html: content + goToMessage + (settings.get('Direct_Reply_Enable') ? advice : ''), + data: { + room_path, + }, + headers: {}, + }; + + if (sender.emails?.length > 0 && settings.get('Add_Sender_To_ReplyTo')) { + const [senderEmail] = sender.emails; + email.headers['Reply-To'] = generateNameEmail(username, senderEmail.address); + } + + // If direct reply enabled, email content with headers + if (settings.get('Direct_Reply_Enable')) { + const replyto = settings.get('Direct_Reply_ReplyTo') || settings.get('Direct_Reply_Username'); + + // Reply-To header with format "username+messageId@domain" + email.headers['Reply-To'] = `${replyto.split('@')[0].split(settings.get('Direct_Reply_Separator'))[0]}${settings.get( + 'Direct_Reply_Separator', + )}${message.tmid || message._id}@${replyto.split('@')[1]}`; + } + + return email; +} + +export function sendEmailFromData(data) { + return Mailer.send(data).then(() => { + metrics.notificationsSent.inc({ notification_type: 'email' }); + metrics.notificationsSentTotal.inc({ notification_type: 'email' }); + }); +} + +export function shouldNotifyEmail({ + disableAllMessageNotifications, + statusConnection, + emailNotifications, + isHighlighted, + hasMentionToUser, + hasMentionToAll, + hasReplyToThread, + roomType, + isThread, +}) { + // email notifications are disabled globally + if (!settings.get('Accounts_AllowEmailNotifications')) { + return false; + } + + // user/room preference to nothing + if (emailNotifications === 'nothing') { + return false; + } + + // user connected (don't need to send him an email) + if (statusConnection === 'online') { + return false; + } + + // no user or room preference + if (emailNotifications == null) { + if (disableAllMessageNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { + return false; + } + + // default server preference is disabled + if (settings.get('Accounts_Default_User_Preferences_emailNotificationMode') === 'nothing') { + return false; + } + } + + return ( + (roomType === 'd' || + isHighlighted || + emailNotifications === 'all' || + hasMentionToUser || + (!disableAllMessageNotifications && hasMentionToAll)) && + (isHighlighted || !isThread || hasReplyToThread) + ); +} diff --git a/apps/meteor/server/lib/notifications/message/index.ts b/apps/meteor/server/lib/notifications/message/index.ts new file mode 100644 index 0000000..8002b6c --- /dev/null +++ b/apps/meteor/server/lib/notifications/message/index.ts @@ -0,0 +1,49 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { isFileAttachment, isFileImageAttachment } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { i18n } from '../../i18n'; + +/** + * This function returns a string ready to be shown in the notification + * + * @param {object} message the message to be parsed + */ +export async function parseMessageTextPerUser( + messageText: string, + message: Pick, + receiver: Pick, +): Promise { + const lng = receiver.language || settings.get('Language') || 'en'; + + const firstAttachment = message.attachments?.[0]; + if (!message.msg && firstAttachment && isFileAttachment(firstAttachment) && isFileImageAttachment(firstAttachment)) { + return firstAttachment.image_type ? i18n.t('User_uploaded_image', { lng }) : i18n.t('User_uploaded_file', { lng }); + } + + if (message.msg && message.t === 'e2e') { + return i18n.t('Encrypted_message', { lng }); + } + + // perform processing required before sending message as notification such as markdown filtering + return callbacks.run('renderNotification', messageText); +} + +/** + * Replaces @username with full name + * + * @param {string} message The message to replace + * @param {object[]} mentions Array of mentions used to make replacements + * + * @returns {string} + */ +export function replaceMentionedUsernamesWithFullNames(message: string, mentions: NonNullable): string { + mentions.forEach((mention) => { + if (mention.name) { + message = message.replace(new RegExp(escapeRegExp(`@${mention.username}`), 'g'), mention.name); + } + }); + return message; +} diff --git a/apps/meteor/server/lib/notifications/message/messageContainsHighlight.ts b/apps/meteor/server/lib/notifications/message/messageContainsHighlight.ts new file mode 100644 index 0000000..3c59792 --- /dev/null +++ b/apps/meteor/server/lib/notifications/message/messageContainsHighlight.ts @@ -0,0 +1,31 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; + +/** + * Checks if a message contains a user highlight + * + * @param {string} message + * @param {array|undefined} highlights + * + * @returns {boolean} + */ +export function messageContainsHighlight(message: Pick, highlights: string[] | undefined): boolean { + if (!highlights || highlights.length === 0) { + return false; + } + + const leftBoundary = '(?<=^|[^\\p{L}\\p{N}_])'; + const rightBoundary = '(?=$|[^\\p{L}\\p{N}_])'; + + return highlights.some((highlight: string) => { + // Due to unnecessary escaping in escapeRegExp, we need to remove the escape character for the following characters: - = ! : + // This is necessary because it was crashing the client due to Invalid regular expression error. + const hl = escapeRegExp(highlight).replace(/\\([-=!:])/g, '$1'); + const pattern = + `(? => 'date' in email && 'html' in email; + +export const processDirectEmail = async function (email: ParsedMail): Promise { + if (!isParsedEmail(email)) { + return; + } + + const to = Array.isArray(email.to) ? email.to.find((email) => email.text) : email.to; + const mid = to?.text.split('@')[0].split('+')[1]; + + if (!mid) { + return; + } + + const ts = new Date(email.date); + + const tsDiff = Math.abs(moment(ts).diff(new Date())); + + let msg = email.text.split('\n\n').join('\n'); + + if (msg && msg.length > (settings.get('Message_MaxAllowedSize') as number)) { + return; + } + const emailAdress = email.from.value[0].address; + if (!emailAdress) { + return; + } + + const user = await Users.findOneByEmailAddress(emailAdress, { + projection: { + username: 1, + name: 1, + }, + }); + + if (!user?.username) { + // user not found + return; + } + + const prevMessage = await Messages.findOneById(mid, { + projection: { + rid: 1, + u: 1, + }, + }); + + if (!prevMessage) { + // message doesn't exist anymore + return; + } + + const roomInfo = await Rooms.findOneById(prevMessage.rid); + + if (!roomInfo) { + // room doesn't exist anymore + return; + } + + const room = await canAccessRoomAsync(roomInfo, user); + if (!room) { + return; + } + + // check mention + if (msg.indexOf(`@${prevMessage.u.username}`) === -1 && roomInfo.t !== 'd') { + msg = `@${prevMessage.u.username} ${msg}`; + } + + // reply message link WE HA THREADS KNOW DONT NEED TO QUOTE THE previous message + // let prevMessageLink = `[ ](${ Meteor.absoluteUrl().replace(/\/$/, '') }`; + // if (roomInfo.t === 'c') { + // prevMessageLink += `/channel/${ roomInfo.name }?msg=${ mid }) `; + // } else if (roomInfo.t === 'd') { + // prevMessageLink += `/direct/${ prevMessage.u.username }?msg=${ mid }) `; + // } else if (roomInfo.t === 'p') { + // prevMessageLink += `/group/${ roomInfo.name }?msg=${ mid }) `; + // } + // // add reply message link + // msg = prevMessageLink + msg; + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(prevMessage.rid, user._id); + if (subscription && (subscription.blocked || subscription.blocker)) { + // room is blocked + return; + } + + if ((roomInfo.muted || []).includes(user.username || '')) { + // user is muted + return; + } + + // room is readonly + if (roomInfo.ro === true) { + if (!(await hasPermissionAsync(user._id, 'post-readonly', roomInfo._id))) { + // Check if the user was manually unmuted + if (!(roomInfo.unmuted || []).includes(user.username || '')) { + return; + } + } + } + + const message: Pick = { + ts: tsDiff < 10000 ? ts : new Date(), + msg, + sentByEmail: true, + groupable: false, + tmid: mid, + rid: prevMessage.rid, + }; + + const result = await sendMessage(user, message, roomInfo); + + metrics.messagesSent.inc(); + metrics.messagesSentTotal.inc(); + + return result; +}; diff --git a/apps/meteor/server/lib/notifications/push-config/index.ts b/apps/meteor/server/lib/notifications/push-config/index.ts new file mode 100644 index 0000000..c39a929 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push-config/index.ts @@ -0,0 +1,3 @@ +import PushNotification from './lib/PushNotification'; + +export { PushNotification }; diff --git a/apps/meteor/server/lib/notifications/push-config/lib/PushNotification.ts b/apps/meteor/server/lib/notifications/push-config/lib/PushNotification.ts new file mode 100644 index 0000000..0b138f0 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push-config/lib/PushNotification.ts @@ -0,0 +1,156 @@ +import type { IMessage, IPushNotificationConfig, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../../settings'; +import { callbacks } from '../../../callbacks'; +import { RocketChatAssets } from '../../../media/assets'; +import { metrics } from '../../../metrics'; +import { replaceMentionedUsernamesWithFullNames, parseMessageTextPerUser } from '../../message'; +import { getPushData } from '../../message/mobile'; +import { Push } from '../../push'; + +type PushNotificationData = { + rid: string; + uid: string; + mid: string; + roomName: string; + username: string; + message: string; + payload: Record; + badge: number; + category: string; +}; + +type GetNotificationConfigParam = PushNotificationData & { + idOnly: boolean; +}; + +type NotificationPayload = { + message: IMessage; + notification: IPushNotificationConfig; +}; + +function hash(str: string): number { + let hash = 0; + let i = str.length; + + while (i) { + hash = (hash << 5) - hash + str.charCodeAt(--i); + hash &= hash; // Convert to 32bit integer + } + return hash; +} + +class PushNotification { + getNotificationId(roomId: string): number { + const serverId = settings.get('uniqueID'); + return hash(`${serverId}|${roomId}`); // hash + } + + private getNotificationConfig({ + rid, + uid: userId, + mid: messageId, + roomName, + username, + message, + payload, + badge = 1, + category, + idOnly = false, + }: GetNotificationConfigParam): IPushNotificationConfig { + const title = idOnly ? '' : roomName || username; + + // message is being redacted already by 'getPushData' if idOnly is true + const text = !idOnly && roomName !== '' ? `${username}: ${message}` : message; + + return { + from: 'push', + badge, + sound: 'default', + priority: 10, + title, + text, + payload: { + host: Meteor.absoluteUrl(), + messageId, + notificationType: idOnly ? 'message-id-only' : 'message', + ...(!idOnly && { rid, ...payload }), + }, + userId, + notId: this.getNotificationId(rid), + gcm: { + style: 'inbox', + image: RocketChatAssets.getURL('Assets_favicon_192'), + }, + ...(category !== '' ? { apn: { category } } : {}), + }; + } + + async send({ rid, uid, mid, roomName, username, message, payload, badge = 1, category }: PushNotificationData): Promise { + const idOnly = settings.get('Push_request_content_from_server'); + const config = this.getNotificationConfig({ + rid, + uid, + mid, + roomName, + username, + message, + payload, + badge, + category, + idOnly, + }); + + await Push.send(config); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + metrics.notificationsSentTotal.inc({ notification_type: 'mobile' }); + } + + async getNotificationForMessageId({ + receiver, + message, + room, + }: { + receiver: IUser; + message: IMessage; + room: IRoom; + }): Promise { + const sender = await Users.findOneById(message.u._id, { projection: { username: 1, name: 1 } }); + if (!sender) { + throw new Error('Message sender not found'); + } + + let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg); + if (message.mentions && Object.keys(message.mentions).length > 0 && settings.get('UI_Use_Real_Name')) { + notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions); + } + notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver); + + const pushData = await getPushData({ + room, + message, + userId: receiver._id, + receiver, + senderUsername: sender.username, + senderName: sender.name, + notificationMessage, + shouldOmitMessage: false, + }); + + return { + message, + notification: this.getNotificationConfig({ + ...pushData, + rid: message.rid, + uid: message.u._id, + mid: message._id, + idOnly: false, + }), + }; + } +} + +export default new PushNotification(); diff --git a/apps/meteor/server/lib/notifications/push/apn.spec.ts b/apps/meteor/server/lib/notifications/push/apn.spec.ts new file mode 100644 index 0000000..f9b643e --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/apn.spec.ts @@ -0,0 +1,163 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const mocks = { + logger: { + debug: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }, + ApnProvider: sandbox.stub(), +}; + +const apnMock = { + 'Provider': mocks.ApnProvider, + 'Notification': sandbox.stub(), + '@noCallThru': true, +}; + +const { initAPN, shutdownAPN } = proxyquire.noCallThru().load('./apn', { + '@parse/node-apn': { + default: apnMock, + ...apnMock, + }, + './logger': { logger: mocks.logger }, +}); + +const baseOptions = { + apn: { + cert: 'cert-data', + key: 'key-data', + gateway: undefined as string | undefined, + }, + production: false, +}; + +const buildOptions = (overrides: Record = {}, apnOverrides: Record = {}) => ({ + ...baseOptions, + ...overrides, + apn: { + ...baseOptions.apn, + ...apnOverrides, + }, +}); + +describe('initAPN', () => { + beforeEach(() => { + sandbox.resetHistory(); + mocks.ApnProvider.reset(); + }); + + describe('APN provider initialization', () => { + it('should create apn.Provider with correct options', () => { + const options = buildOptions({ production: true }, { gateway: 'gateway.push.apple.com' }); + + initAPN({ + options, + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.calledWithNew()).to.be.true; + }); + + it('should pass production flag from options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', true); + }); + + it('should pass production false when options.production is false', () => { + initAPN({ + options: buildOptions({ production: false }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('production', false); + }); + + it('should pass cert and key to Provider', () => { + initAPN({ + options: buildOptions({}, { cert: 'my-cert', key: 'my-key' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.have.property('cert', 'my-cert'); + expect(providerArgs).to.have.property('key', 'my-key'); + }); + + it('should pass gateway to Provider when specified', () => { + initAPN({ + options: buildOptions({}, { gateway: 'gateway.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + expect(mocks.ApnProvider.firstCall.args[0]).to.have.property('gateway', 'gateway.push.apple.com'); + }); + + it('should spread all apn options to Provider', () => { + initAPN({ + options: buildOptions({ production: true }, { cert: 'c', key: 'k', gateway: 'gateway.sandbox.push.apple.com' }), + absoluteUrl: 'https://example.com', + }); + + const providerArgs = mocks.ApnProvider.firstCall.args[0]; + expect(providerArgs).to.deep.equal({ + cert: 'c', + key: 'k', + gateway: 'gateway.sandbox.push.apple.com', + production: true, + }); + }); + + it('should not throw when Provider constructor throws', () => { + mocks.ApnProvider.throws(new Error('APN init failed')); + + expect(() => + initAPN({ + options: buildOptions(), + absoluteUrl: 'https://example.com', + }), + ).to.not.throw(); + }); + }); +}); + +describe('shutdownAPN', () => { + beforeEach(() => { + sandbox.resetHistory(); + mocks.ApnProvider.reset(); + }); + + it('should shut down the active provider', async () => { + const shutdown = sandbox.stub().resolves(); + mocks.ApnProvider.returns({ shutdown }); + + initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' }); + await shutdownAPN(); + + expect(shutdown.calledOnce).to.be.true; + }); + + it('should resolve when no provider was initialized', async () => { + await shutdownAPN(); + await shutdownAPN(); + }); + + it('should not reject when provider shutdown fails', async () => { + const shutdown = sandbox.stub().rejects(new Error('shutdown failed')); + mocks.ApnProvider.returns({ shutdown }); + + initAPN({ options: buildOptions(), absoluteUrl: 'https://example.com' }); + await shutdownAPN(); + + expect(mocks.logger.error.called).to.be.true; + }); +}); diff --git a/apps/meteor/server/lib/notifications/push/apn.ts b/apps/meteor/server/lib/notifications/push/apn.ts new file mode 100644 index 0000000..b7a2aa0 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/apn.ts @@ -0,0 +1,172 @@ +import apn from '@parse/node-apn'; +import type { RequiredField } from '@rocket.chat/core-typings'; +import EJSON from 'ejson'; + +import type { PushOptions, PendingPushNotification } from './definition'; +import { logger } from './logger'; + +let apnConnection: apn.Provider | undefined; + +declare module '@parse/node-apn' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Notification { + setContentAvailable: (value: boolean | 1 | 0) => void; + set category(_value: string | undefined); + set body(_value: string); + set title(_value: string); + } +} + +export const sendAPN = ({ + userToken, + notification, + _removeToken, +}: { + userToken: string; + notification: PendingPushNotification & { topic: string }; + _removeToken: (token: string) => void; +}) => { + if (!apnConnection) { + throw new Error('Apn Connection not initialized.'); + } + + const priority = notification.priority || notification.priority === 0 ? notification.priority : 10; + + const note = new apn.Notification(); + + // Expires 1 hour from now, unless configured otherwise. + const expirationSeconds = notification.apn?.expirationSeconds ?? 3600; + + if (notification.useVoipToken) { + note.pushType = 'voip'; + } + + note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds; + + if (notification.badge !== undefined) { + note.badge = notification.badge; + } + + if (notification.sound !== undefined) { + note.sound = notification.sound; + } + + if (notification.contentAvailable != null) { + note.setContentAvailable(notification.contentAvailable); + } + + // adds category support for iOS8 custom actions as described here: + // https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/ + // RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36 + if (notification.apn?.category) { + note.category = notification.apn.category; + } + + if (notification.text) { + note.body = notification.text; + } + if (notification.title) { + note.title = notification.title; + } + + if (notification.notId != null) { + note.threadId = String(notification.notId); + } + + // Allow the user to set payload data + note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; + + if (notification.from) { + note.payload.messageFrom = notification.from; + } + note.priority = priority; + + note.topic = notification.topic; + note.mutableContent = true; + + void apnConnection.send(note, userToken).then((response) => { + response.failed.forEach((failure) => { + logger.debug({ + msg: 'Got error code for APN token', + status: failure.status, + token: userToken, + }); + + if (['400', '410'].includes(String(failure.status))) { + logger.debug({ + msg: 'Removing APN token', + token: userToken, + }); + _removeToken(userToken); + } + }); + }); +}; + +export const shutdownAPN = async () => { + const connection = apnConnection; + apnConnection = undefined; + + try { + await connection?.shutdown(); + } catch (err) { + logger.error({ msg: 'Error shutting down APN connection', err }); + } +}; + +export const initAPN = ({ options, absoluteUrl }: { options: RequiredField; absoluteUrl: string }) => { + logger.debug('APN configured'); + + if (options.apn.gateway) { + // We check the apn gateway i the options, we could risk shipping + // server into production while using the production configuration. + // On the other hand we could be in development but using the production + // configuration. And finally we could have configured an unknown apn + // gateway (this could change in the future - but a warning about typos + // can save hours of debugging) + // + // Warn about gateway configurations - it's more a guide + + if (options.apn.gateway === 'gateway.sandbox.push.apple.com') { + // Using the development sandbox + logger.warn('WARNING: Push APN is in development mode'); + } else if (options.apn.gateway === 'gateway.push.apple.com') { + // In production - but warn if we are running on localhost + if (/http:\/\/localhost/.test(absoluteUrl)) { + logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost'); + } + } else { + // Warn about gateways we dont know about + logger.warn({ + msg: 'WARNING: Push APN unknown gateway', + gateway: options.apn.gateway, + }); + } + } else if (options.production) { + if (/http:\/\/localhost/.test(absoluteUrl)) { + logger.warn('WARNING: Push APN is configured to production mode - but server is running from localhost'); + } + } else { + logger.warn('WARNING: Push APN is in development mode'); + } + + // Check certificate data + if (!options.apn.cert?.length) { + logger.error('ERROR: Push server could not find cert'); + } + + // Check key data + if (!options.apn.key?.length) { + logger.error('ERROR: Push server could not find key'); + } + + // Rig apn connection + try { + apnConnection = new apn.Provider({ + ...options.apn, + production: options.production, + }); + } catch (err) { + logger.error({ msg: 'Error trying to initialize APN', err }); + } +}; diff --git a/apps/meteor/server/lib/notifications/push/definition.ts b/apps/meteor/server/lib/notifications/push/definition.ts new file mode 100644 index 0000000..d194f66 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/definition.ts @@ -0,0 +1,47 @@ +export type PushOptions = { + sendTimeout?: number; + production?: boolean; + apn?: { + passphrase: string; + key: string; + cert: string; + + gateway?: string; + }; + gcm?: { + apiKey: string; + projectNumber: string; + }; + gateways?: string[]; + uniqueId: string; + getAuthorization?: () => Promise; +}; + +export type PendingPushNotification = { + from?: string; + title?: string; + text?: string; + badge?: number; + sound?: string; + notId?: number; + apn?: { + category?: string; + expirationSeconds?: number; + }; + gcm?: { + style?: string; + image?: string; + }; + payload?: Record; + createdAt: Date; + createdBy?: string; + + userId: string; + + sent?: boolean; + sending?: number; + priority?: number; + + contentAvailable?: 1 | 0; + useVoipToken?: boolean; +}; diff --git a/apps/meteor/server/lib/notifications/push/fcm.ts b/apps/meteor/server/lib/notifications/push/fcm.ts new file mode 100644 index 0000000..afe3657 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/fcm.ts @@ -0,0 +1,203 @@ +import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; +import EJSON from 'ejson'; +import type { Response } from 'node-fetch'; + +import type { PendingPushNotification } from './definition'; +import { logger } from './logger'; +import type { NativeNotificationParameters } from './push'; + +type FCMDataField = Record; + +type FCMNotificationField = { + title?: string; + body?: string; + image?: string; +}; + +type FCMMessage = { + notification?: FCMNotificationField; + data?: FCMDataField; + token?: string; + to?: string; + android?: { + collapseKey?: string; + priority?: 'HIGH' | 'NORMAL'; + ttl?: string; + restrictedPackageName?: string; + data?: FCMDataField; + notification?: FCMNotificationField; + fcm_options?: { + analytics_label?: string; + }; + direct_boot_ok?: boolean; + }; + webpush?: { + headers?: FCMDataField; + data?: FCMDataField; + notification?: FCMNotificationField; + fcm_options?: { + link?: string; + analytics_label?: string; + }; + }; + fcm_options?: { + analytics_label?: string; + }; +}; + +// https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode +type FCMError = { + error: { + code: number; + message: string; + status: string; + }; +}; + +// The 403 error code is used when the server is refusing to process a request to a specific token due to +// a SENDER_ID_MISMATCH error. This error is returned when the sender ID provided in the request does not match the sender ID +// associated with the registration token. +const SENDER_ID_MISMATCH_ERROR_CODE = 403; +const NOT_FOUND_ERROR_CODE = 404; + +/** + * Send a push notification using Firebase Cloud Messaging (FCM). + * implements the Firebase Cloud Messaging HTTP v1 API, and all of its retry logic, + * see: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + * + * Errors: + * - For 400, 401 errors: abort, and do not retry. + * - For 404 errors: remove the token from the database. + * - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds. + * - For 5xx errors: retry with exponential backoff. + */ +async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise { + const MAX_RETRIES = 5; + const response = await fetch(url, options); + + if (response.ok) { + return response; + } + + if (retries >= MAX_RETRIES) { + logger.error('sendFCM error: max retries reached'); + return response; + } + + const retryAfter = response.headers.get('retry-after'); + const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; + if ([SENDER_ID_MISMATCH_ERROR_CODE, NOT_FOUND_ERROR_CODE].includes(response.status)) { + _removeToken(); + return response; + } + + if (response.status === 429) { + await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000)); + return fetchWithRetry(url, _removeToken, options, retries + 1); + } + + if (response.status >= 500 && response.status < 600) { + const backoff = Math.pow(2, retries) * 10000; + await new Promise((resolve) => setTimeout(resolve, backoff)); + return fetchWithRetry(url, _removeToken, options, retries + 1); + } + + const error: FCMError = await response.json(); + logger.error({ msg: 'sendFCM error', err: error }); + + return response; +} + +function getFCMMessagesFromPushData(userTokens: string[], notification: PendingPushNotification): { message: FCMMessage }[] { + // first we will get the `data` field from the notification + const data: FCMDataField = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; + + // Set image + if (notification.gcm?.image) { + data.image = notification.gcm?.image; + } + + // Set extra details + if (notification.badge) { + data.msgcnt = notification.badge.toString(); + } + + if (notification.sound) { + data.soundname = notification.sound; + } + + if (notification.notId) { + data.notId = notification.notId.toString(); + } + + if (notification.gcm?.style) { + data.style = notification.gcm?.style; + } + + if (notification.contentAvailable) { + data['content-available'] = notification.contentAvailable.toString(); + } + + // then we will create the notification field + const notificationField: FCMNotificationField = { + ...(notification.title && { title: notification.title }), + ...(notification.text && { body: notification.text }), + }; + + // then we will create the message + const message: FCMMessage = { + ...(Object.keys(notificationField).length && { notification: notificationField }), + data, + android: { + priority: 'HIGH', + }, + }; + + // then we will create the message for each token + return userTokens.map((token) => ({ message: { ...message, token } })); +} + +export const sendFCM = function ({ userTokens, notification, _removeToken, options }: NativeNotificationParameters): void { + const tokens = typeof userTokens === 'string' ? [userTokens] : userTokens; + if (!tokens.length) { + logger.log('sendFCM no push tokens found'); + return; + } + + logger.debug({ msg: 'sendFCM', tokens, notification }); + + const messages = getFCMMessagesFromPushData(tokens, notification); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${options.gcm.apiKey}`, + 'access_token_auth': true, + } as Record; + + if (!options.gcm.projectNumber.trim()) { + logger.error('sendFCM error: GCM project number is missing'); + return; + } + + const url = `https://fcm.googleapis.com/v1/projects/${options.gcm.projectNumber}/messages:send`; + + for (const fcmRequest of messages) { + logger.debug({ msg: 'sendFCM message', request: fcmRequest }); + + const removeToken = () => { + const { token } = fcmRequest.message; + token && _removeToken(token); + }; + + const response = fetchWithRetry(url, removeToken, { + method: 'POST', + headers, + body: JSON.stringify(fcmRequest), + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + response.catch((err) => { + logger.error({ msg: 'sendFCM error', err }); + }); + } +}; diff --git a/apps/meteor/server/lib/notifications/push/index.ts b/apps/meteor/server/lib/notifications/push/index.ts new file mode 100644 index 0000000..189b11d --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/index.ts @@ -0,0 +1 @@ +export { Push } from './push'; diff --git a/apps/meteor/server/lib/notifications/push/logger.ts b/apps/meteor/server/lib/notifications/push/logger.ts new file mode 100644 index 0000000..5793949 --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('Push'); diff --git a/apps/meteor/server/lib/notifications/push/push.ts b/apps/meteor/server/lib/notifications/push/push.ts new file mode 100644 index 0000000..de643fb --- /dev/null +++ b/apps/meteor/server/lib/notifications/push/push.ts @@ -0,0 +1,532 @@ +import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { PushToken } from '@rocket.chat/models'; +import { ajv } from '@rocket.chat/rest-typings'; +import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { pick, truncateString } from '@rocket.chat/tools'; +import { JWT } from 'google-auth-library'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { initAPN, sendAPN, shutdownAPN } from './apn'; +import type { PushOptions, PendingPushNotification } from './definition'; +import { sendFCM } from './fcm'; +import { logger } from './logger'; +import { settings } from '../../../settings'; + +export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); + +const PUSH_TITLE_LIMIT = 65; +const PUSH_MESSAGE_BODY_LIMIT = 240; +const PUSH_GATEWAY_MAX_RETRIES = 5; + +type FCMCredentials = { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; + auth_uri: string; + token_uri: string; + auth_provider_x509_cert_url: string; + client_x509_cert_url: string; + universe_domain: string; +}; + +export const FCMCredentialsValidationSchema = { + type: 'object', + properties: { + type: { + type: 'string', + }, + project_id: { + type: 'string', + }, + private_key_id: { + type: 'string', + }, + private_key: { + type: 'string', + }, + client_email: { + type: 'string', + }, + client_id: { + type: 'string', + }, + auth_uri: { + type: 'string', + }, + token_uri: { + type: 'string', + }, + auth_provider_x509_cert_url: { + type: 'string', + }, + client_x509_cert_url: { + type: 'string', + }, + universe_domain: { + type: 'string', + }, + }, + required: ['client_email', 'project_id', 'private_key_id', 'private_key'], +}; + +export const isFCMCredentials = ajv.compile(FCMCredentialsValidationSchema); + +// This type must match the type defined in the push gateway +type GatewayNotification = { + uniqueId: string; + from?: string; + title?: string; + text?: string; + badge?: number; + sound?: string; + notId?: number; + contentAvailable?: 1 | 0; + forceStart?: number; + topic?: string; + apn?: { + from?: string; + title?: string; + text?: string; + badge?: number; + sound?: string; + notId?: number; + category?: string; + expirationSeconds?: number; + }; + gcm?: { + from?: string; + title?: string; + text?: string; + image?: string; + style?: string; + summaryText?: string; + picture?: string; + badge?: number; + sound?: string; + notId?: number; + actions?: any[]; + }; + query?: { + userId: any; + }; + token?: IPushToken['token']; + tokens?: IPushToken['token'][]; + payload?: Record; + delayUntil?: Date; + createdAt: Date; + createdBy?: string; +}; + +export type NativeNotificationParameters = { + userTokens: string | string[]; + notification: PendingPushNotification; + _removeToken: (token: string) => void; + options: RequiredField; +}; + +class PushClass { + options: PushOptions = { + uniqueId: '', + }; + + isConfigured = false; + + public configure(options: PushOptions): void { + this.options = { + sendTimeout: 60000, // Timeout period for notification send + ...options, + }; + // https://npmjs.org/package/apn + + // After requesting the certificate from Apple, export your private key as + // a .p12 file anddownload the .cer file from the iOS Provisioning Portal. + + // gateway.push.apple.com, port 2195 + // gateway.sandbox.push.apple.com, port 2195 + + // Now, in the directory containing cert.cer and key.p12 execute the + // following commands to generate your .pem files: + // $ openssl x509 -in cert.cer -inform DER -outform PEM -out cert.pem + // $ openssl pkcs12 -in key.p12 -out key.pem -nodes + + // Block multiple calls + if (this.isConfigured) { + throw new Error('Configure should not be called more than once!'); + } + + this.isConfigured = true; + + logger.debug({ msg: 'Configure', options: this.options }); + + if (this.options.apn) { + initAPN({ options: this.options as RequiredField, absoluteUrl: Meteor.absoluteUrl() }); + } + } + + public async unconfigure(): Promise { + logger.info('Push service disabled: shutting down existing connections'); + this.isConfigured = false; + return shutdownAPN(); + } + + private removeToken(token: string): void { + void PushToken.removeOrUnsetByTokenString(token).catch((err) => { + logger.error({ msg: 'Failed to remove push token', err }); + }); + } + + private shouldUseGateway(): boolean { + if (License.hasOfflineLicense()) { + return false; + } + + return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms')); + } + + private async sendNotificationNative( + app: IPushToken, + notification: PendingPushNotification, + countApn: string[], + countGcm: string[], + ): Promise { + logger.debug({ msg: 'send to token', token: app.token }); + + if ('apn' in app.token && app.token.apn) { + const userToken = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + + // Send to APN + if (this.options.apn && userToken) { + countApn.push(app._id); + sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken }); + } + } else if ('gcm' in app.token && app.token.gcm) { + countGcm.push(app._id); + + // override this.options.gcm.apiKey with the oauth2 token + const { projectId, token } = await this.getNativeNotificationAuthorizationCredentials(); + const sendGCMOptions = { + ...this.options, + gcm: { + ...this.options.gcm, + apiKey: token, + projectNumber: projectId, + }, + }; + + sendFCM({ + userTokens: app.token.gcm, + notification, + _removeToken: this.removeToken, + options: sendGCMOptions as RequiredField, + }); + } else { + throw new Error('send got a faulty query'); + } + } + + private async getNativeNotificationAuthorizationCredentials(): Promise<{ token: string; projectId: string }> { + const credentialsString = settings.get('Push_google_api_credentials'); + if (!credentialsString.trim()) { + throw new Error('Push_google_api_credentials is not set'); + } + + try { + const credentials = JSON.parse(credentialsString); + if (!isFCMCredentials(credentials)) { + throw new Error('Push_google_api_credentials is not in the correct format'); + } + + const client = new JWT({ + email: credentials.client_email, + key: credentials.private_key, + keyId: credentials.private_key_id, + scopes: 'https://www.googleapis.com/auth/firebase.messaging', + }); + + await client.authorize(); + + return { + token: client.credentials.access_token as string, + projectId: credentials.project_id, + }; + } catch (error) { + logger.error({ msg: 'Error getting FCM token', err: error }); + throw new Error('Error getting FCM token'); + } + } + + private async sendGatewayPush( + gateway: string, + service: 'apn' | 'gcm', + token: string, + notification: Optional, + retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES }, + ): Promise { + notification.uniqueId = this.options.uniqueId; + + const options = { + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + method: 'POST', + body: { + token, + options: notification, + }, + ...(token && this.options.getAuthorization && { headers: { Authorization: await this.options.getAuthorization() } }), + } as ExtendedFetchOptions; + + const result = await fetch(`${gateway}/push/${service}/send`, options); + const response = await result.text(); + + if (result.status === 406) { + logger.info({ msg: 'removing push token', token }); + this.removeToken(token); + return; + } + + if (result.status === 422) { + logger.info({ msg: 'gateway rejected push notification. not retrying.', response }); + return; + } + + if (result.status === 401) { + logger.warn({ msg: 'authorization failed when sending push to gateway. not retrying.', response }); + return; + } + + if (result.ok) { + return; + } + + const { tries, maxRetries } = retryOptions; + + logger.error({ msg: 'Error sending push to gateway', tries, err: response }); + + if (tries < maxRetries) { + // [1, 2, 4, 8, 16] minutes (total 31) + const ms = 60000 * Math.pow(2, tries); + + logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); + + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms); + } + } + + private getGatewayNotificationData(notification: PendingPushNotification): Omit { + // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken + // If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well. + const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification; + + return { + ...notifData, + }; + } + + private async sendNotificationGateway( + app: IPushToken, + notification: PendingPushNotification, + countApn: string[], + countGcm: string[], + ): Promise { + if (!this.options.gateways) { + return; + } + + const gatewayNotification = this.getGatewayNotificationData(notification); + const retryOptions = { + tries: 0, + maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES, + }; + + for (const gateway of this.options.gateways) { + logger.debug({ msg: 'send to token', token: app.token }); + + if ('apn' in app.token && app.token.apn) { + const token = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + + if (token) { + countApn.push(app._id); + return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions); + } + } + + if ('gcm' in app.token && app.token.gcm) { + countGcm.push(app._id); + return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions); + } + } + } + + private async sendNotification( + notification: PendingPushNotification, + options: { skipTokenId?: IPushToken['_id'] } = {}, + ): Promise<{ apn: string[]; gcm: string[] }> { + logger.debug({ msg: 'Sending notification', notification }); + + const countApn: string[] = []; + const countGcm: string[] = []; + + if (notification.from && notification.from !== String(notification.from)) { + throw new Error('Push.send: option "from" not a string'); + } + if (notification.title && notification.title !== String(notification.title)) { + throw new Error('Push.send: option "title" not a string'); + } + if (notification.text && notification.text !== String(notification.text)) { + throw new Error('Push.send: option "text" not a string'); + } + + logger.debug({ + msg: 'send message to userId', + title: notification.title, + userId: notification.userId, + }); + + const appTokens = options.skipTokenId + ? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId) + : PushToken.findAllTokensByUserId(notification.userId); + + for await (const app of appTokens) { + logger.debug({ msg: 'send to token', token: app.token }); + + if (this.shouldUseGateway()) { + await this.sendNotificationGateway(app, notification, countApn, countGcm); + continue; + } + + // The workspace is configured to send through a push gateway, but the offline + // license forbids contacting it — skip quietly instead of falling back to the + // (unconfigured) native providers. + if (this.options.gateways && License.hasOfflineLicense()) { + continue; + } + + await this.sendNotificationNative(app, notification, countApn, countGcm); + } + + if (settings.get('Log_Level') === '2') { + logger.debug({ + msg: 'Sent message to apps', + title: notification.title, + iosApps: countApn.length, + androidApps: countGcm.length, + }); + + // Add some verbosity about the send result, making sure the developer + // understands what just happened. + if (!countApn.length && !countGcm.length) { + if ((await PushToken.estimatedDocumentCount()) === 0) { + logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...'); + } + } else if (!countApn.length) { + if ((await PushToken.countApnTokens()) === 0) { + logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...'); + } + } else if (!countGcm.length) { + if ((await PushToken.countGcmTokens()) === 0) { + logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...'); + } + } + } + + return { + apn: countApn, + gcm: countGcm, + }; + } + + // This is a general function to validate that the data added to notifications + // is in the correct format. If not this function will throw errors + private _validateDocument(notification: PendingPushNotification): void { + // Check the general notification + check(notification, { + from: Match.Optional(String), + title: Match.Optional(String), + text: Match.Optional(String), + sent: Match.Optional(Boolean), + sending: Match.Optional(Match.Integer), + badge: Match.Optional(Match.Integer), + sound: Match.Optional(String), + notId: Match.Optional(Match.Integer), + contentAvailable: Match.Optional(Match.Integer), + apn: Match.Optional({ + category: Match.Optional(String), + expirationSeconds: Match.Optional(Match.Integer), + }), + gcm: Match.Optional({ + image: Match.Optional(String), + style: Match.Optional(String), + }), + userId: String, + payload: Match.Optional(Object), + createdAt: Date, + createdBy: Match.OneOf(String, null), + priority: Match.Optional(Match.Integer), + useVoipToken: Match.Optional(Boolean), + }); + + if (!notification.userId) { + throw new Error('No userId found'); + } + } + + private hasApnOptions(options: IPushNotificationConfig): options is RequiredField { + return Match.test(options.apn, Object); + } + + private hasGcmOptions(options: IPushNotificationConfig): options is RequiredField { + return Match.test(options.gcm, Object); + } + + public async send(options: IPushNotificationConfig) { + const notification: PendingPushNotification = { + createdAt: new Date(), + // createdBy is no longer used, but the gateway still expects it + createdBy: '', + sent: false, + sending: 0, + ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), + ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), + + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), + + ...(this.hasApnOptions(options) + ? { + apn: { + ...pick(options.apn, 'category', 'expirationSeconds'), + }, + } + : {}), + ...(this.hasGcmOptions(options) + ? { + gcm: { + ...pick(options.gcm, 'image', 'style'), + }, + } + : {}), + }; + + // Validate the notification + this._validateDocument(notification); + + try { + await this.sendNotification(notification, { skipTokenId: options.skipTokenId }); + } catch (error: any) { + logger.debug({ + msg: 'Could not send notification to user', + userId: notification.userId, + err: error, + }); + } + } +} + +export const Push = new PushClass(); diff --git a/apps/meteor/server/lib/notifications/queue/NotificationQueue.ts b/apps/meteor/server/lib/notifications/queue/NotificationQueue.ts new file mode 100644 index 0000000..692ef90 --- /dev/null +++ b/apps/meteor/server/lib/notifications/queue/NotificationQueue.ts @@ -0,0 +1,182 @@ +import type { INotification, INotificationItemPush, INotificationItemEmail, NotificationItem, IUser } from '@rocket.chat/core-typings'; +import { NotificationQueue, Users } from '@rocket.chat/models'; +import { tracerSpan } from '@rocket.chat/tracing'; +import { Meteor } from 'meteor/meteor'; + +import { SystemLogger } from '../../logger/system'; +import { sendEmailFromData } from '../message/email'; +import { PushNotification } from '../push-config'; + +const { + NOTIFICATIONS_WORKER_TIMEOUT = 2000, + NOTIFICATIONS_BATCH_SIZE = 100, + NOTIFICATIONS_SCHEDULE_DELAY_ONLINE = 120, + NOTIFICATIONS_SCHEDULE_DELAY_AWAY = 0, + NOTIFICATIONS_SCHEDULE_DELAY_OFFLINE = 0, +} = process.env; + +class NotificationClass { + private running = false; + + private cyclePause = Number(NOTIFICATIONS_WORKER_TIMEOUT); + + private maxBatchSize = Number(NOTIFICATIONS_BATCH_SIZE); + + private maxScheduleDelaySeconds: { [key: string]: number } = { + online: Number(NOTIFICATIONS_SCHEDULE_DELAY_ONLINE), + away: Number(NOTIFICATIONS_SCHEDULE_DELAY_AWAY), + offline: Number(NOTIFICATIONS_SCHEDULE_DELAY_OFFLINE), + }; + + initWorker(): void { + this.running = true; + this.executeWorkerLater(); + } + + stopWorker(): void { + this.running = false; + } + + executeWorkerLater(): void { + if (!this.running) { + return; + } + + setTimeout(async () => { + try { + const continueLater = await tracerSpan( + 'NotificationWorker', + { + attributes: { + workerTime: new Date().toISOString(), + }, + }, + () => this.worker(), + ); + + if (continueLater) { + this.executeWorkerLater(); + } + } catch (err) { + SystemLogger.error({ msg: 'Error sending notification', err }); + this.executeWorkerLater(); + } + }, this.cyclePause); + } + + async worker(counter = 0): Promise { + const notification = await this.getNextNotification(); + + if (!notification) { + return true; + } + + // Once we start notifying the user we anticipate all the schedules + const flush = await NotificationQueue.clearScheduleByUserId(notification.uid); + + // start worker again if queue flushed + if (flush.modifiedCount) { + await NotificationQueue.unsetSendingById(notification._id); + return this.worker(counter); + } + + try { + for (const item of notification.items) { + switch (item.type) { + case 'push': + await this.push(notification, item); + break; + case 'email': + await this.email(item); + break; + } + } + + await NotificationQueue.removeById(notification._id); + } catch (err) { + SystemLogger.error({ err }); + await NotificationQueue.setErrorById(notification._id, err instanceof Error ? err.message : String(err)); + } + + if (counter >= this.maxBatchSize) { + return true; + } + + return this.worker(++counter); + } + + getNextNotification(): Promise { + const expired = new Date(); + expired.setMinutes(expired.getMinutes() - 5); + + return NotificationQueue.findNextInQueueOrExpired(expired); + } + + async push({ uid, rid, mid }: INotification, item: INotificationItemPush): Promise { + await PushNotification.send({ + rid, + uid, + mid, + ...item.data, + }); + } + + async email(item: INotificationItemEmail): Promise { + return sendEmailFromData(item.data); + } + + async scheduleItem({ + uid, + rid, + mid, + items, + user, + }: { + uid: string; + rid: string; + mid: string; + items: NotificationItem[]; + user?: Partial; + }): Promise { + const receiver = + user || + (await Users.findOneById>(uid, { + projection: { + statusConnection: 1, + }, + })); + + if (!receiver) { + return; + } + + const { statusConnection = 'offline' } = receiver; + + let schedule: Date | undefined; + + const delay = this.maxScheduleDelaySeconds[statusConnection]; + + if (delay < 0) { + return; + } + if (delay > 0) { + schedule = new Date(); + schedule.setSeconds(schedule.getSeconds() + delay); + } + + await NotificationQueue.insertOne({ + uid, + rid, + mid, + ts: new Date(), + ...(schedule && { schedule }), + items, + }); + } +} + +export const Notification = new NotificationClass(); + +Meteor.startup(() => { + Notification.initWorker(); +}); diff --git a/apps/meteor/server/lib/notifyListener.ts b/apps/meteor/server/lib/notifyListener.ts new file mode 100644 index 0000000..ca81386 --- /dev/null +++ b/apps/meteor/server/lib/notifyListener.ts @@ -0,0 +1,633 @@ +import { api } from '@rocket.chat/core-services'; +import type { + IRocketChatRecord, + IRoom, + ILoginServiceConfiguration, + ISetting, + IRole, + IPermission, + IIntegration, + LoginServiceConfiguration as LoginServiceConfigurationData, + ILivechatInquiryRecord, + ILivechatPriority, + ILivechatDepartmentAgents, + IEmailInbox, + IIntegrationHistory, + AtLeast, + ISubscription, + ISettingColor, + IUser, + IMessage, + SettingValue, + MessageTypesValues, + ILivechatContact, +} from '@rocket.chat/core-typings'; +import { + Rooms, + LivechatRooms, + Permissions, + Settings, + Roles, + Integrations, + LoginServiceConfiguration, + IntegrationHistory, + Subscriptions, + LivechatInquiry, + LivechatDepartmentAgents, + Users, + Messages, +} from '@rocket.chat/models'; +import mem from 'mem'; + +import { shouldHideSystemMessage } from './systemMessage/hideSystemMessage'; +import { subscriptionFields } from '../../lib/publishFields'; + +type ClientAction = 'inserted' | 'updated' | 'removed'; + +export const notifyOnLivechatPriorityChanged = async ( + data: Pick, + clientAction: ClientAction = 'updated', +): Promise => { + const { _id, ...rest } = data; + void api.broadcast('watch.priorities', { clientAction, id: _id, diff: { ...rest } }); +}; + +export const notifyOnRoomChanged = async ( + data: T | T[], + clientAction: ClientAction = 'updated', +): Promise => { + const items = Array.isArray(data) ? data : [data]; + for (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } +}; + +export const notifyOnRoomChangedById = async ( + ids: T['_id'] | T['_id'][], + clientAction: ClientAction = 'updated', +): Promise => { + const eligibleIds = Array.isArray(ids) ? ids : [ids]; + const items = Rooms.findByIds(eligibleIds); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } +}; + +export const notifyOnRoomChangedByUsernamesOrUids = async ( + uids: T['u']['_id'][], + usernames: T['u']['username'][], + clientAction: ClientAction = 'updated', +): Promise => { + const items = Rooms.findByUsernamesOrUids(uids, usernames); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } +}; + +export const notifyOnRoomChangedByContactId = async ( + contactId: T['_id'], + clientAction: ClientAction = 'updated', +): Promise => { + const cursor = LivechatRooms.findOpenByContactId(contactId); + + void cursor.forEach((room) => { + void api.broadcast('watch.rooms', { clientAction, room }); + }); +}; + +export const notifyOnRoomChangedByUserDM = async ( + userId: T['u']['_id'], + clientAction: ClientAction = 'updated', +): Promise => { + const items = Rooms.findDMsByUids([userId]); + for await (const item of items) { + void api.broadcast('watch.rooms', { clientAction, room: item }); + } +}; + +export const notifyOnPermissionChanged = async (permission: IPermission, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('permission.changed', { clientAction, data: permission }); + + if (permission.level === 'settings' && permission.settingId) { + const setting = await Settings.findOneNotHiddenById(permission.settingId); + if (!setting) { + return; + } + void notifyOnSettingChanged(setting, 'updated'); + } +}; + +export const notifyOnPermissionChangedById = async (pid: IPermission['_id'], clientAction: ClientAction = 'updated'): Promise => { + const permission = await Permissions.findOneById(pid); + if (!permission) { + return; + } + + return notifyOnPermissionChanged(permission, clientAction); +}; + +export const notifyOnRoleChanged = async (role: T, clientAction: 'removed' | 'changed' = 'changed'): Promise => { + void api.broadcast('watch.roles', { clientAction, role }); +}; + +export const notifyOnRoleChangedById = async ( + id: T['_id'], + clientAction: 'removed' | 'changed' = 'changed', +): Promise => { + const role = await Roles.findOneById(id); + if (!role) { + return; + } + + void notifyOnRoleChanged(role, clientAction); +}; + +export const notifyOnLoginServiceConfigurationChanged = async ( + service: Partial & Pick, + clientAction: ClientAction = 'updated', +): Promise => { + void api.broadcast('watch.loginServiceConfiguration', { + clientAction, + id: service._id, + data: service, + }); +}; + +export const notifyOnLoginServiceConfigurationChangedByService = async ( + service: T['service'], + clientAction: ClientAction = 'updated', +): Promise => { + const item = await LoginServiceConfiguration.findOneByService>(service, { + projection: { secret: 0 }, + }); + if (!item) { + return; + } + + void notifyOnLoginServiceConfigurationChanged(item, clientAction); +}; + +export const notifyOnIntegrationChanged = async ( + data: T, + clientAction: ClientAction = 'updated', +): Promise => { + void api.broadcast('watch.integrations', { clientAction, id: data._id, data }); +}; + +export const notifyOnIntegrationChangedById = async ( + id: T['_id'], + clientAction: ClientAction = 'updated', +): Promise => { + const item = await Integrations.findOneById(id); + if (!item) { + return; + } + + void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); +}; + +export const notifyOnIntegrationChangedByUserId = async ( + id: T['userId'], + clientAction: ClientAction = 'updated', +): Promise => { + const items = Integrations.findByUserId(id); + + for await (const item of items) { + void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); + } +}; + +export const notifyOnIntegrationChangedByChannels = async ( + channels: T['channel'], + clientAction: ClientAction = 'updated', +): Promise => { + const items = Integrations.findByChannels(channels); + + for await (const item of items) { + void api.broadcast('watch.integrations', { clientAction, id: item._id, data: item }); + } +}; + +export const notifyOnEmailInboxChanged = async ( + data: Pick | T, // TODO: improve typing + clientAction: ClientAction = 'updated', +): Promise => { + void api.broadcast('watch.emailInbox', { clientAction, id: data._id, data }); +}; + +export const notifyOnLivechatInquiryChanged = async ( + data: ILivechatInquiryRecord | ILivechatInquiryRecord[], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, +): Promise => { + const items = Array.isArray(data) ? data : [data]; + + for (const item of items) { + void api.broadcast('watch.inquiries', { clientAction, inquiry: item, diff }); + } +}; + +export const notifyOnLivechatInquiryChangedById = async ( + ids: ILivechatInquiryRecord['_id'] | ILivechatInquiryRecord['_id'][], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, +): Promise => { + const eligibleIds = Array.isArray(ids) ? ids : [ids]; + + const items = + clientAction === 'removed' + ? LivechatInquiry.trashFind({ _id: { $in: eligibleIds } }) + : LivechatInquiry.find({ _id: { $in: eligibleIds } }); + + if (!items) { + return; + } + + for await (const inquiry of items) { + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + } +}; + +export const notifyOnLivechatInquiryChangedByVisitorIds = async ( + visitorIds: ILivechatInquiryRecord['v']['_id'][], + clientAction: Exclude = 'updated', + diff?: Partial & { queuedAt: Date; takenAt: Date }>, +): Promise => { + const cursor = LivechatInquiry.findByVisitorIds(visitorIds); + + void cursor.forEach((inquiry) => { + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + }); +}; + +export const notifyOnLivechatInquiryChangedByRoom = async ( + rids: ILivechatInquiryRecord['rid'] | ILivechatInquiryRecord['rid'][], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, +): Promise => { + const eligibleIds = Array.isArray(rids) ? rids : [rids]; + + const items = + clientAction === 'removed' + ? LivechatInquiry.trashFind({ rid: { $in: eligibleIds } }) + : LivechatInquiry.find({ rid: { $in: eligibleIds } }); + + if (!items) { + return; + } + + for await (const inquiry of items) { + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + } +}; + +export const notifyOnLivechatInquiryChangedByToken = async ( + token: ILivechatInquiryRecord['v']['token'], + clientAction: ClientAction = 'updated', + diff?: Partial & { queuedAt: unknown; takenAt: unknown }>, +): Promise => { + const inquiry = await LivechatInquiry.findOneByToken(token); + + if (!inquiry) { + return; + } + + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); +}; + +export const notifyOnIntegrationHistoryChanged = async ( + data: AtLeast, + clientAction: ClientAction = 'updated', + diff: Partial = {}, +): Promise => { + void api.broadcast('watch.integrationHistory', { clientAction, id: data._id, data, diff }); +}; + +export const notifyOnIntegrationHistoryChangedById = async ( + id: T['_id'], + clientAction: ClientAction = 'updated', + diff: Partial = {}, +): Promise => { + const item = await IntegrationHistory.findOneById(id); + + if (!item) { + return; + } + + void api.broadcast('watch.integrationHistory', { clientAction, id: item._id, data: item, diff }); +}; + +export const notifyOnLivechatDepartmentAgentChanged = async ( + data: Partial & Pick, + clientAction: ClientAction = 'updated', +): Promise => { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: data._id, data }); +}; + +export const notifyOnLivechatDepartmentAgentChangedByDepartmentId = async ( + departmentId: T['departmentId'], + clientAction: 'inserted' | 'updated' = 'updated', +): Promise => { + const items = LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { _id: 1, agentId: 1, departmentId: 1 } }); + + for await (const item of items) { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); + } +}; + +export const notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId = async ( + agentsIds: T['agentId'][], + departmentId: T['departmentId'], + clientAction: 'inserted' | 'updated' = 'updated', +): Promise => { + const items = LivechatDepartmentAgents.findByAgentsAndDepartmentId(agentsIds, departmentId, { + projection: { _id: 1, agentId: 1, departmentId: 1 }, + }); + + for await (const item of items) { + void api.broadcast('watch.livechatDepartmentAgents', { clientAction, id: item._id, data: item }); + } +}; + +export const notifyOnSettingChanged = async ( + setting: ISetting & { editor?: ISettingColor['editor'] }, + clientAction: ClientAction = 'updated', +): Promise => { + void api.broadcast('watch.settings', { clientAction, setting }); +}; + +export const notifyOnSettingChangedById = async (id: ISetting['_id'], clientAction: ClientAction = 'updated'): Promise => { + const item = clientAction === 'removed' ? await Settings.trashFindOneById(id) : await Settings.findOneById(id); + + if (!item) { + return; + } + + void api.broadcast('watch.settings', { clientAction, setting: item }); +}; + +type NotifyUserChange = { + id: IUser['_id']; + clientAction: 'inserted' | 'removed' | 'updated'; + data?: IUser; + diff?: Record; + unset?: Record; +}; + +export const notifyOnUserChange = async ({ clientAction, id, data, diff, unset }: NotifyUserChange) => { + if (clientAction === 'removed') { + void api.broadcast('watch.users', { clientAction, id }); + return; + } + + if (clientAction === 'inserted') { + void api.broadcast('watch.users', { clientAction, id, data: data! }); + return; + } + + void api.broadcast('watch.users', { clientAction, diff: diff!, unset: unset || {}, id }); +}; + +/** + * Calls the callback only if DB Watchers are disabled + */ +export const notifyOnUserChangeAsync = async (cb: () => Promise) => { + const result = await cb(); + if (!result) { + return; + } + + if (Array.isArray(result)) { + result.forEach((n) => notifyOnUserChange(n)); + return; + } + + return notifyOnUserChange(result); +}; + +// TODO this may be only useful on 'inserted' +export const notifyOnUserChangeById = async ({ + clientAction, + id, +}: { + id: IUser['_id']; + clientAction: 'inserted' | 'removed' | 'updated'; +}) => { + const user = await Users.findOneById(id); + if (!user) { + return; + } + + void notifyOnUserChange({ id, clientAction, data: user }); +}; + +const getUserNameCached = mem( + async (userId: string): Promise => { + const user = await Users.findOne>(userId, { projection: { name: 1 } }); + return user?.name; + }, + { maxAge: 10000 }, +); + +const getSettingCached = mem(async (setting: string): Promise => Settings.getValueById(setting), { maxAge: 10000 }); + +const getUsersByUsernamesCached = mem( + async (usernames: string[]): Promise> => { + const users = await Users.findByUsernames(usernames, { projection: { username: 1, name: 1 } }).toArray(); + return new Map(users.filter((u): u is IUser & { username: string } => !!u.username).map((u) => [u.username, u.name])); + }, + { maxAge: 10000, cacheKey: ([usernames]) => JSON.stringify([...usernames].sort()) }, +); + +export async function getMessageToBroadcast({ id, data }: { id: IMessage['_id']; data?: IMessage }): Promise { + const message = data ?? (await Messages.findOneById(id)); + if (!message) { + return; + } + + if (message.t) { + const hiddenSystemMessages = (await getSettingCached('Hide_System_Messages')) as MessageTypesValues[]; + const shouldHide = shouldHideSystemMessage(message.t, hiddenSystemMessages); + + if (shouldHide) { + return; + } + } + + if (message._hidden || message.imported != null) { + return; + } + + const useRealName = (await getSettingCached('UI_Use_Real_Name')) === true; + if (useRealName) { + if (message.u?._id) { + const name = await getUserNameCached(message.u._id); + if (name) { + message.u.name = name; + } + } + + if (message.mentions?.length) { + for await (const mention of message.mentions) { + const name = await getUserNameCached(mention._id); + if (name) { + mention.name = name; + } + } + } + + if (message.reactions) { + const allUsernames = [...new Set(Object.values(message.reactions).flatMap((r) => r.usernames))]; + if (allUsernames.length > 0) { + const nameByUsername = await getUsersByUsernamesCached(allUsernames); + for (const reaction of Object.values(message.reactions)) { + reaction.names = reaction.usernames.map((username) => nameByUsername.get(username) || username); + } + } + } + } + + return message; +} + +export const notifyOnMessageChange = async ({ id, data }: { id: IMessage['_id']; data?: IMessage }): Promise => { + const message = await getMessageToBroadcast({ id, data }); + if (!message) { + return; + } + void api.broadcast('watch.messages', { message }); +}; + +export const notifyOnSubscriptionChanged = async (subscription: ISubscription, clientAction: ClientAction = 'updated'): Promise => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); +}; + +export const notifyOnSubscriptionChangedByRoomIdAndUserId = async ( + rid: ISubscription['rid'], + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomIds(uid, [rid], { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionsChangedByRoomIdsAndUserId = async ( + roomIds: ISubscription['rid'][], + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomIds(uid, roomIds, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedById = async ( + id: ISubscription['_id'], + clientAction: Exclude = 'updated', +): Promise => { + const subscription = await Subscriptions.findOneById(id); + if (!subscription) { + return; + } + + void api.broadcast('watch.subscriptions', { clientAction, subscription }); +}; + +export const notifyOnSubscriptionChangedByUserPreferences = async ( + uid: ISubscription['u']['_id'], + notificationOriginField: keyof ISubscription, + originFieldNotEqualValue: 'user' | 'subscription', + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByUserPreferences(uid, notificationOriginField, originFieldNotEqualValue, { + projection: subscriptionFields, + }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByRoomId = async ( + rid: ISubscription['rid'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByRoomId(rid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByAutoTranslateAndUserId = async ( + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByAutoTranslateAndUserId(uid, true, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByUserIdAndRoomType = async ( + uid: ISubscription['u']['_id'], + t: ISubscription['t'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByUserIdAndRoomType(uid, t, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByVisitorIds = async ( + visitorIds: Exclude['_id'][], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findOpenByVisitorIds(visitorIds, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByNameAndRoomType = async ( + filter: Partial>, + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByNameAndRoomType(filter, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByUserId = async ( + uid: ISubscription['u']['_id'], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByUserId(uid, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; + +export const notifyOnSubscriptionChangedByRoomIdAndUserIds = async ( + rid: ISubscription['rid'], + uids: ISubscription['u']['_id'][], + clientAction: Exclude = 'updated', +): Promise => { + const cursor = Subscriptions.findByRoomIdAndUserIds(rid, uids, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); +}; diff --git a/apps/meteor/server/lib/oauth/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts new file mode 100644 index 0000000..1c5fb82 --- /dev/null +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -0,0 +1,267 @@ +/* eslint no-multi-spaces: 0 */ +/* eslint comma-spacing: 0 */ +import { capitalize } from '@rocket.chat/tools'; + +import { settingsRegistry } from '../../settings'; + +export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { + name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); + name = capitalize(name); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}`, values.enabled || false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Enable', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-url`, values.serverURL || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'URL', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-token_path`, values.tokenPath || '/oauth/token', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Token_Path', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-token_sent_via`, values.tokenSentVia || 'payload', { + type: 'select', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Token_Sent_Via', + persistent: true, + values: [ + { key: 'header', i18nLabel: 'Header' }, + { key: 'payload', i18nLabel: 'Payload' }, + ], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-identity_token_sent_via`, values.identityTokenSentVia || 'default', { + type: 'select', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Identity_Token_Sent_Via', + persistent: true, + values: [ + { key: 'default', i18nLabel: 'Same_As_Token_Sent_Via' }, + { key: 'header', i18nLabel: 'Header' }, + { key: 'payload', i18nLabel: 'Payload' }, + ], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-identity_path`, values.identityPath || '/me', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Identity_Path', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-authorize_path`, values.authorizePath || '/oauth/authorize', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Authorize_Path', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-scope`, values.scope || 'openid', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Scope', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-access_token_param`, values.accessTokenParam || 'access_token', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Access_Token_Param', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-id`, values.clientId || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_id', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-secret`, values.clientSecret || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Secret', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-login_style`, values.loginStyle || 'popup', { + type: 'select', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Login_Style', + persistent: true, + values: [ + { key: 'redirect', i18nLabel: 'Redirect' }, + { key: 'popup', i18nLabel: 'Popup' }, + { key: '', i18nLabel: 'Default' }, + ], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-button_label_text`, values.buttonLabelText || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-button_label_color`, values.buttonLabelColor || '#FFFFFF', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-button_color`, values.buttonColor || '#1d74f5', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-key_field`, values.keyField || 'username', { + type: 'select', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Key_Field', + persistent: true, + values: [ + { key: 'username', i18nLabel: 'Username' }, + { key: 'email', i18nLabel: 'Email' }, + ], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-username_field`, values.usernameField || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Username_Field', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-email_field`, values.emailField || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Email_Field', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-name_field`, values.nameField || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Name_Field', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-avatar_field`, values.avatarField || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Avatar_Field', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-roles_claim`, values.rolesClaim || 'roles', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Roles_Claim', + enterprise: true, + invalidValue: 'roles', + modules: ['oauth-enterprise'], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-groups_claim`, values.groupsClaim || 'groups', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Groups_Claim', + enterprise: true, + invalidValue: 'groups', + modules: ['oauth-enterprise'], + }); + + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-channels_admin`, values.channelsAdmin || 'rocket.cat', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Channel_Admin', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-map_channels`, values.mapChannels || false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Map_Channels', + enterprise: true, + invalidValue: false, + modules: ['oauth-enterprise'], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-merge_roles`, values.mergeRoles || false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Merge_Roles', + enterprise: true, + invalidValue: false, + modules: ['oauth-enterprise'], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-roles_to_sync`, values.rolesToSync || '', { + type: 'string', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Roles_To_Sync', + i18nDescription: 'Accounts_OAuth_Custom_Roles_To_Sync_Description', + enterprise: true, + enableQuery: { + _id: `Accounts_OAuth_Custom-${name}-merge_roles`, + value: true, + }, + invalidValue: '', + modules: ['oauth-enterprise'], + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-merge_users`, values.mergeUsers || false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Merge_Users', + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-merge_users_distinct_services`, values.mergeUsersDistinctServices || false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Merge_Users_Distinct_Services', + i18nDescription: 'Accounts_OAuth_Custom_Merge_Users_Distinct_Services_Description', + enableQuery: { + _id: `Accounts_OAuth_Custom-${name}-merge_users`, + value: true, + }, + persistent: true, + }); + await settingsRegistry.add(`Accounts_OAuth_Custom-${name}-show_button`, values.showButton || true, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Show_Button_On_Login_Page', + persistent: true, + }); + await settingsRegistry.add( + `Accounts_OAuth_Custom-${name}-groups_channel_map`, + values.channelsMap || '{\n\t"rocket-admin": "admin",\n\t"tech-support": "support"\n}', + { + type: 'code', + multiline: true, + code: 'application/json', + group: 'OAuth', + section: `Custom OAuth: ${name}`, + i18nLabel: 'Accounts_OAuth_Custom_Channel_Map', + persistent: true, + }, + ); +} diff --git a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts new file mode 100644 index 0000000..519cf41 --- /dev/null +++ b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts @@ -0,0 +1,62 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import passport from 'passport'; +import type { DoneCallback, Profile } from 'passport'; + +import { allowPassportOAuthMiddleware } from './allowPassportOAuthMiddleware'; +import { passportOAuthCallback } from './passportOAuthCallback'; +import { removeOAuthRoutes } from './removeOAuthRoutes'; +import { verifyFunction } from './verifyFunction'; +import { oAuthRouter } from '../../configuration/configurePassport'; +import { settings } from '../../settings'; +import { CustomOAuthStrategy } from '../auth-providers/custom-oauth/customOAuth'; + +export const addPassportCustomOAuth = ( + serviceName: string, + config: Partial, + isCustomOAuth: boolean = false, +) => { + passport.unuse(serviceName); + removeOAuthRoutes(serviceName); + + if (!config.clientId || !config.clientSecret || !config.serverURL) { + return; + } + + passport.use( + serviceName, + new CustomOAuthStrategy( + serviceName, + config as OAuthConfiguration & { clientSecret: string }, + (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => + verifyFunction(accessToken, refreshToken, profile, done, serviceName), + ), + ); + + const siteUrl = settings.get('Site_Url').replace(/\/$/, ''); + + oAuthRouter.get( + `/oauth/${serviceName}`, + allowPassportOAuthMiddleware(serviceName, isCustomOAuth), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + + oAuthRouter.get( + `/_oauth/${serviceName}`, + allowPassportOAuthMiddleware(serviceName, isCustomOAuth), + passport.authenticate(serviceName, { failureRedirect: '/login', failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl, config.loginStyle ? config.loginStyle : undefined), + ); +}; diff --git a/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts b/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts new file mode 100644 index 0000000..fca9290 --- /dev/null +++ b/apps/meteor/server/lib/oauth/allowPassportOAuthMiddleware.ts @@ -0,0 +1,20 @@ +import { capitalize } from '@rocket.chat/tools'; +import type { NextFunction, Request, Response } from 'express'; + +import { settings } from '../../settings'; + +export const allowPassportOAuthMiddleware = + (service: string, isCustomOAuth: boolean = false) => + (_req: Request, _res: Response, next: NextFunction) => { + const isPassportFlowEnabled = settings.get('Accounts_OAuth_Use_Modern_Flow'); + const settingPrefix = `${isCustomOAuth ? 'Accounts_OAuth_Custom-' : 'Accounts_OAuth_'}`; + const isOAuthServiceEnabled = settings.get( + `${settingPrefix}${service === 'github_enterprise' ? 'GitHub_Enterprise' : capitalize(service)}`, + ); + + if (!isPassportFlowEnabled || !isOAuthServiceEnabled) { + next('router'); + } else { + next(); + } + }; diff --git a/apps/meteor/server/lib/oauth/configureOAuthServices.ts b/apps/meteor/server/lib/oauth/configureOAuthServices.ts new file mode 100644 index 0000000..2310548 --- /dev/null +++ b/apps/meteor/server/lib/oauth/configureOAuthServices.ts @@ -0,0 +1,92 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import passport from 'passport'; +import type { Profile, DoneCallback } from 'passport'; + +import { allowPassportOAuthMiddleware } from './allowPassportOAuthMiddleware'; +import type { OAuthServiceConfig } from './createOAuthServiceConfig'; +import { passportOAuthCallback } from './passportOAuthCallback'; +import { removeOAuthRoutes } from './removeOAuthRoutes'; +import { oAuthRouter } from '../../configuration/configurePassport'; +import type { ICachedSettings } from '../../settings/CachedSettings'; + +export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], settings: ICachedSettings) => { + oauthServiceConfig.forEach((config) => { + const Strategy = config.strategy; + const siteUrl = settings.get('Site_Url').replace(/\/$/, ''); + + passport.unuse(config.provider); + removeOAuthRoutes(config.provider); + + passport.use( + config.provider, + new Strategy( + { + ...config, + clientID: config.clientId, + clientSecret: config.clientSecret, + consumerKey: config.clientId, + consumerSecret: config.clientSecret, + callbackURL: `${siteUrl}/_oauth/${config.provider}`, + state: true, + pkce: true, + profileFields: ['id', 'displayName', 'emails'], + }, + async (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + config.provider, + { + accessToken, + refreshToken, + name: profile.displayName, + ...restProfile, + ..._json, + email: profile?.emails?.[0]?.value, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); + }, + ), + ); + + oAuthRouter.get( + `/oauth/${config.provider}`, + allowPassportOAuthMiddleware(config.provider), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + //delete stale value from previous sessions if any + delete req.session.loginClient; + next(); + } + }, + passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), + ); + oAuthRouter.get( + `/_oauth/${config.provider}`, + allowPassportOAuthMiddleware(config.provider), + passport.authenticate(config.provider, { failureRedirect: '/login', failWithError: true, keepSessionInfo: true }), + passportOAuthCallback(siteUrl), + ); + }); +}; diff --git a/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts new file mode 100644 index 0000000..bfcc17c --- /dev/null +++ b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts @@ -0,0 +1,58 @@ +import { capitalize, isTruthy } from '@rocket.chat/tools'; +import type { Strategy } from 'passport'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../settings/CachedSettings'; + +export type OAuthServiceConfig = { + provider: string; + strategy: new (...args: any[]) => Strategy; + clientId: string; + clientSecret: string; + scope?: string[]; +}; + +export const createOAuthServiceConfig = (settings: ICachedSettings, services: string[]): OAuthServiceConfig[] => { + return services + .map((service) => { + if (!OAuthConfigs[service]) { + return; + } + + if (service === 'github_enterprise') { + const clientId = settings.get('Accounts_OAuth_GitHub_Enterprise_id'); + const clientSecret = settings.get('Accounts_OAuth_GitHub_Enterprise_secret'); + const serverUrl = settings.get('API_GitHub_Enterprise_URL'); + + if (!clientId || !clientSecret || !serverUrl) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + authorizationURL: `${serverUrl}/login/oauth/authorize`, + tokenURL: `${serverUrl}/login/oauth/access_token`, + userProfileURL: `${serverUrl}/api/v3/user`, + strategy: OAuthConfigs.github_enterprise.strategy, + scope: OAuthConfigs.github_enterprise.scope, + }; + } + + const clientId = settings.get(`Accounts_OAuth_${capitalize(service)}_id`); + const clientSecret = settings.get(`Accounts_OAuth_${capitalize(service)}_secret`); + + if (!clientId || !clientSecret) { + return; + } + + return { + provider: service, + clientId, + clientSecret, + ...OAuthConfigs[service], + }; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/getOAuthServices.ts b/apps/meteor/server/lib/oauth/getOAuthServices.ts new file mode 100644 index 0000000..743d60c --- /dev/null +++ b/apps/meteor/server/lib/oauth/getOAuthServices.ts @@ -0,0 +1,34 @@ +import { isTruthy } from '@rocket.chat/tools'; + +import { OAuthConfigs } from './oauthConfigs'; +import { type ICachedSettings } from '../../settings/CachedSettings'; + +export const getOAuthServices = (settings: ICachedSettings) => { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean' && value === true); + return filteredServices + .map(([key, value]) => { + if (!value) { + return; + } + + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + + if (/Accounts_OAuth_Custom-/.test(key)) { + return; + } + + const serviceKey = serviceName.toLowerCase(); + + const oauthConfig = OAuthConfigs[serviceKey]; + if (!oauthConfig) { + return; + } + + return serviceKey; + }) + .filter(isTruthy); +}; diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts new file mode 100644 index 0000000..3c909f6 --- /dev/null +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -0,0 +1,56 @@ +import { addOAuthService } from './addOAuthService'; + +export async function initCustomOAuthServices(): Promise { + // Add settings for custom OAuth providers to the settings so they get + // automatically added when they are defined in ENV variables + for await (const key of Object.keys(process.env)) { + if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { + // Most all shells actually prohibit the usage of - in environment variables + // So this will allow replacing - with _ and translate it back to the setting name + let name = key.replace('Accounts_OAuth_Custom_', ''); + + if (name.indexOf('_') > -1) { + name = name.replace(name.substr(name.indexOf('_')), ''); + } + + const serviceKey = `Accounts_OAuth_Custom_${name}`; + + if (key === serviceKey) { + const values = { + enabled: process.env[`${serviceKey}`] === 'true', + clientId: process.env[`${serviceKey}_id`], + clientSecret: process.env[`${serviceKey}_secret`], + serverURL: process.env[`${serviceKey}_url`], + tokenPath: process.env[`${serviceKey}_token_path`], + identityPath: process.env[`${serviceKey}_identity_path`], + authorizePath: process.env[`${serviceKey}_authorize_path`], + scope: process.env[`${serviceKey}_scope`], + accessTokenParam: process.env[`${serviceKey}_access_token_param`], + buttonLabelText: process.env[`${serviceKey}_button_label_text`], + buttonLabelColor: process.env[`${serviceKey}_button_label_color`], + loginStyle: process.env[`${serviceKey}_login_style`], + buttonColor: process.env[`${serviceKey}_button_color`], + tokenSentVia: process.env[`${serviceKey}_token_sent_via`], + identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], + keyField: process.env[`${serviceKey}_key_field`], + usernameField: process.env[`${serviceKey}_username_field`], + nameField: process.env[`${serviceKey}_name_field`], + emailField: process.env[`${serviceKey}_email_field`], + rolesClaim: process.env[`${serviceKey}_roles_claim`], + groupsClaim: process.env[`${serviceKey}_groups_claim`], + channelsMap: process.env[`${serviceKey}_groups_channel_map`], + channelsAdmin: process.env[`${serviceKey}_channels_admin`], + mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', + mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', + mapChannels: process.env[`${serviceKey}_map_channels`], + mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', + rolesToSync: process.env[`${serviceKey}_roles_to_sync`], + showButton: process.env[`${serviceKey}_show_button`] === 'true', + avatarField: process.env[`${serviceKey}_avatar_field`], + }; + + await addOAuthService(name, values); + } + } + } +} diff --git a/apps/meteor/server/lib/oauth/logger.ts b/apps/meteor/server/lib/oauth/logger.ts new file mode 100644 index 0000000..e1f0fc2 --- /dev/null +++ b/apps/meteor/server/lib/oauth/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('rocketchat:lib'); diff --git a/apps/meteor/server/lib/oauth/oauthConfigs.ts b/apps/meteor/server/lib/oauth/oauthConfigs.ts new file mode 100644 index 0000000..44fc2d1 --- /dev/null +++ b/apps/meteor/server/lib/oauth/oauthConfigs.ts @@ -0,0 +1,36 @@ +import { Strategy as XStrategy } from '@rocket.chat/passport-x'; +import type { Strategy } from 'passport'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; +import { Strategy as GitHubStrategy } from 'passport-github2'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; + +export type OAuthConfig = { + strategy: new (...args: any[]) => Strategy; + scope?: string[]; + includeEmail?: boolean; +}; + +export const OAuthConfigs: Record = { + github: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, + facebook: { + strategy: FacebookStrategy, + scope: ['email'], + }, + google: { + strategy: GoogleStrategy, + scope: ['email', 'profile'], + }, + twitter: { + strategy: XStrategy, + includeEmail: true, + }, + github_enterprise: { + strategy: GitHubStrategy, + scope: ['user:email'], + }, +} as const; + +export type Provider = keyof typeof OAuthConfigs; diff --git a/apps/meteor/server/lib/oauth/passportOAuthCallback.ts b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts new file mode 100644 index 0000000..dc3136f --- /dev/null +++ b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts @@ -0,0 +1,108 @@ +import crypto from 'crypto'; + +import type { IUser } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LoginCodes } from '@rocket.chat/models'; +import type { Request, Response } from 'express'; +import { Accounts } from 'meteor/accounts-base'; + +import { doesUserRequire2FA } from './twoFactorAuth'; + +const logger = new Logger('OAuth'); + +type LaunchStyle = 'popup' | 'redirect' | undefined; + +const createLoginToken = async (userId: string) => { + const stampedToken = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, stampedToken); + + return stampedToken.token; +}; + +const sendPopupMessage = (res: Response, payload: unknown) => { + const nonce = crypto.randomBytes(16).toString('base64'); + res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`); + + res.send(` + + `); +}; + +const handlePopupStyleOAuth = async (res: Response, oAuthUser: IUser, loginClient?: string) => { + const secondFactorMethod = doesUserRequire2FA(oAuthUser); + + if (secondFactorMethod) { + const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); + sendPopupMessage(res, { + oauthPopupCommand: 'redirect', + path: `/2fa/${secondFactorMethod.method}/${challengeId}${loginClient ? `?loginClient=${loginClient}` : ''}`, + }); + return; + } + + const token = await createLoginToken(oAuthUser._id); + + if (loginClient) { + sendPopupMessage(res, { + oauthPopupCommand: 'redirect', + path: `home?resumeToken=${token}&userId=${oAuthUser._id}&loginClient=${loginClient}`, + }); + return; + } + + sendPopupMessage(res, { oauthPopupCommand: 'oauth-login-with-token', token }); +}; + +export const passportOAuthCallback = + (siteUrl: string, launchStyle: LaunchStyle = 'redirect') => + async (req: Request, res: Response) => { + const oAuthUser = req.user as IUser; + + if (!oAuthUser) { + return res.redirect('/login'); + } + + const { loginClient } = req.session; + + if (launchStyle === 'popup') { + await handlePopupStyleOAuth(res, oAuthUser, loginClient); + return; + } + + const secondFactorMethod = doesUserRequire2FA(oAuthUser); + + if (secondFactorMethod) { + const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); + const twoFARedirectUrl = new URL(`/2fa/${secondFactorMethod.method}/${challengeId}`, siteUrl); + + if (loginClient) { + twoFARedirectUrl.searchParams.set('loginClient', loginClient); + } + + return res.redirect(twoFARedirectUrl.toString()); + } + + const loginCode = await LoginCodes.createCode(oAuthUser._id); + + const redirectUrl = new URL(`/home`, siteUrl); + + redirectUrl.searchParams.set('loginCode', loginCode); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + setImmediate(() => res.redirect(redirectUrl.toString())); + + req.session.destroy((err) => { + if (err) { + logger.error({ msg: 'Error destroying OAuth session', err }); + } + }); + }; diff --git a/apps/meteor/server/lib/oauth/removeOAuthRoutes.ts b/apps/meteor/server/lib/oauth/removeOAuthRoutes.ts new file mode 100644 index 0000000..009b9f4 --- /dev/null +++ b/apps/meteor/server/lib/oauth/removeOAuthRoutes.ts @@ -0,0 +1,19 @@ +import { oAuthRouter } from '../../configuration/configurePassport'; + +/** + * Removes every route previously registered for an OAuth service from the OAuth router. + * + * OAuth services are re-registered whenever their settings change, but express only ever + * *appends* to `router.stack` and dispatches on a first-match-wins basis. Without this, + * re-registering a service leaves the previous handlers in place and they keep serving the + * requests, using the configuration that was captured in their closures at registration time + * (`loginStyle`, `scope`, ...). Callers must invoke this before registering the routes again. + */ +export const removeOAuthRoutes = (serviceName: string): void => { + const paths = [`/oauth/${serviceName}`, `/_oauth/${serviceName}`]; + + const router = oAuthRouter; + + // Only route layers are dropped; middleware layers registered via `router.use` + router.stack = router.stack.filter((layer) => !layer.route || !paths.includes(layer.route.path)); +}; diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts new file mode 100644 index 0000000..383a5ac --- /dev/null +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -0,0 +1,9 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +export async function removeOAuthService(mainSettingId: string): Promise { + const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); + + await ServiceConfiguration.configurations.removeAsync({ + service: serviceName.toLowerCase(), + }); +} diff --git a/apps/meteor/server/lib/oauth/twoFactorAuth.ts b/apps/meteor/server/lib/oauth/twoFactorAuth.ts new file mode 100644 index 0000000..22302b7 --- /dev/null +++ b/apps/meteor/server/lib/oauth/twoFactorAuth.ts @@ -0,0 +1,37 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { getRememberDate } from '../2fa/code'; +import { EmailCheckForOAuth } from '../2fa/code/EmailCheckForOAuth'; +import { TOTPCheckForOAuth } from '../2fa/code/TOTPCheckForOAuth'; + +export const emailCheckForOAuth = new EmailCheckForOAuth(); +export const totpCheckForOAuth = new TOTPCheckForOAuth(); + +const twoFACheckMethodsForOAuth = { + [emailCheckForOAuth.method]: emailCheckForOAuth, + [totpCheckForOAuth.method]: totpCheckForOAuth, +}; + +export const getTwoFAMethodForOAuth = (method: 'email' | 'totp') => { + return twoFACheckMethodsForOAuth[method]; +}; + +const getSecondFactorMethod = (user: IUser) => { + return Array.from(Object.values(twoFACheckMethodsForOAuth)).find((method) => method.isEnabled(user)); +}; + +export const doesUserRequire2FA = (user: IUser) => { + const rememberAfterRegistration = getRememberDate(user.createdAt); + + if (rememberAfterRegistration && rememberAfterRegistration > new Date()) { + return false; + } + + const secondFactorMethod = getSecondFactorMethod(user); + + if (!secondFactorMethod) { + return false; + } + + return secondFactorMethod; +}; diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts new file mode 100644 index 0000000..6947e38 --- /dev/null +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -0,0 +1,135 @@ +import type { + FacebookOAuthConfiguration, + ILoginServiceConfiguration, + LinkedinOAuthConfiguration, + OAuthConfiguration, + TwitterOAuthConfiguration, +} from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; + +import { addPassportCustomOAuth } from './addPassportCustomOAuth'; +import { logger } from './logger'; +import { settings } from '../../settings/cached'; +import { CustomOAuth } from '../auth-providers/custom-oauth/custom_oauth_server'; +import { notifyOnLoginServiceConfigurationChanged, notifyOnLoginServiceConfigurationChangedByService } from '../notifyListener'; + +export async function updateOAuthServices(): Promise { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); + for await (const [key, value] of filteredServices) { + if (key === 'Accounts_OAuth_Use_Modern_Flow') { + continue; + } + + logger.debug({ oauth_updated: key }); + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + if (/Accounts_OAuth_Custom-/.test(key)) { + serviceName = key.replace('Accounts_OAuth_Custom-', ''); + } + + const serviceKey = serviceName.toLowerCase(); + + if (value === true) { + const data: Partial> = { + clientId: settings.get(`${key}_id`), + secret: settings.get(`${key}_secret`), + }; + + if (/Accounts_OAuth_Custom-/.test(key)) { + data.custom = true; + data.clientId = settings.get(`${key}-id`); + data.secret = settings.get(`${key}-secret`); + data.serverURL = settings.get(`${key}-url`); + data.tokenPath = settings.get(`${key}-token_path`); + data.identityPath = settings.get(`${key}-identity_path`); + data.authorizePath = settings.get(`${key}-authorize_path`); + data.scope = settings.get(`${key}-scope`); + data.accessTokenParam = settings.get(`${key}-access_token_param`); + data.buttonLabelText = settings.get(`${key}-button_label_text`); + data.buttonLabelColor = settings.get(`${key}-button_label_color`); + data.loginStyle = settings.get(`${key}-login_style`); + data.buttonColor = settings.get(`${key}-button_color`); + data.tokenSentVia = settings.get(`${key}-token_sent_via`); + data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); + data.keyField = settings.get(`${key}-key_field`); + data.usernameField = settings.get(`${key}-username_field`); + data.emailField = settings.get(`${key}-email_field`); + data.nameField = settings.get(`${key}-name_field`); + data.avatarField = settings.get(`${key}-avatar_field`); + data.rolesClaim = settings.get(`${key}-roles_claim`); + data.groupsClaim = settings.get(`${key}-groups_claim`); + data.channelsMap = settings.get(`${key}-groups_channel_map`); + data.channelsAdmin = settings.get(`${key}-channels_admin`); + data.mergeUsers = settings.get(`${key}-merge_users`); + data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); + data.mapChannels = settings.get(`${key}-map_channels`); + data.mergeRoles = settings.get(`${key}-merge_roles`); + data.rolesToSync = settings.get(`${key}-roles_to_sync`); + data.showButton = settings.get(`${key}-show_button`); + + const config = { + serverURL: data.serverURL, + tokenPath: data.tokenPath, + identityPath: data.identityPath, + authorizePath: data.authorizePath, + scope: data.scope, + loginStyle: data.loginStyle, + tokenSentVia: data.tokenSentVia, + identityTokenSentVia: data.identityTokenSentVia, + keyField: data.keyField, + usernameField: data.usernameField, + emailField: data.emailField, + nameField: data.nameField, + avatarField: data.avatarField, + rolesClaim: data.rolesClaim, + groupsClaim: data.groupsClaim, + mapChannels: data.mapChannels, + channelsMap: data.channelsMap, + channelsAdmin: data.channelsAdmin, + mergeUsers: data.mergeUsers, + mergeUsersDistinctServices: data.mergeUsersDistinctServices, + mergeRoles: data.mergeRoles, + rolesToSync: data.rolesToSync, + accessTokenParam: data.accessTokenParam, + showButton: data.showButton, + clientSecret: data.secret, + clientId: data.clientId, + }; + + new CustomOAuth(serviceKey, config); + addPassportCustomOAuth(serviceKey, config, true); + } + if (serviceName === 'Facebook') { + (data as FacebookOAuthConfiguration).appId = data.clientId as string; + delete data.clientId; + } + if (serviceName === 'Twitter') { + (data as TwitterOAuthConfiguration).consumerKey = data.clientId as string; + delete data.clientId; + } + + if (serviceName === 'Linkedin') { + (data as LinkedinOAuthConfiguration).clientConfig = { + requestPermissions: ['openid', 'email', 'profile'], + }; + } + + if (serviceName === 'Nextcloud') { + data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); + data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); + data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); + } + + await LoginServiceConfiguration.createOrUpdateService(serviceKey, data); + void notifyOnLoginServiceConfigurationChangedByService(serviceKey); + } else { + const service = await LoginServiceConfiguration.removeByService(serviceName); + if (service) { + void notifyOnLoginServiceConfigurationChanged({ _id: service._id }, 'removed'); + } + } + } +} diff --git a/apps/meteor/server/lib/oauth/verifyFunction.ts b/apps/meteor/server/lib/oauth/verifyFunction.ts new file mode 100644 index 0000000..c5f6fa8 --- /dev/null +++ b/apps/meteor/server/lib/oauth/verifyFunction.ts @@ -0,0 +1,40 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import type { DoneCallback, Profile } from 'passport'; + +export const verifyFunction = async ( + accessToken: string, + refreshToken: string, + profile: Profile, + done: DoneCallback, + serviceName: string, +) => { + const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; + const { _json, _raw, ...restProfile } = profileWithRaw; + + const user = await Accounts.updateOrCreateUserFromExternalService( + serviceName, + { + accessToken, + refreshToken, + name: profile.displayName, + ...profile, + ...restProfile, + ..._json, + email: profile?.emails?.[0]?.value, + }, + {}, + ); + + if (!user?.userId || typeof user?.userId !== 'string') { + return done(new Error('User not found')); + } + + const userFromDB = await Users.findOneById(user.userId); + + if (!userFromDB) { + return done(new Error('User not found')); + } + + return done(null, userFromDB); +}; diff --git a/apps/meteor/server/lib/omnichannel/AnalyticsTyped.ts b/apps/meteor/server/lib/omnichannel/AnalyticsTyped.ts new file mode 100644 index 0000000..c1c6b75 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/AnalyticsTyped.ts @@ -0,0 +1,17 @@ +import { OmnichannelAnalytics } from '@rocket.chat/core-services'; +import mem from 'mem'; + +export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000, + cacheKey: JSON.stringify, +}); +// Agent overview data on realtime is cached for 5 seconds +// while the data on the overview page is cached for 1 minute +export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { + maxAge: 60000, + cacheKey: JSON.stringify, +}); +export const getAnalyticsOverviewDataCachedForRealtime = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 5000, + cacheKey: JSON.stringify, +}); diff --git a/apps/meteor/server/lib/omnichannel/Assets.ts b/apps/meteor/server/lib/omnichannel/Assets.ts new file mode 100644 index 0000000..c812cd2 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/Assets.ts @@ -0,0 +1,4 @@ +export const addServerUrlToIndex = (file: string): string => { + const rootUrl = (global as any).__meteor_runtime_config__.ROOT_URL.replace(/\/$/, ''); + return file.replace('', ``); +}; diff --git a/apps/meteor/server/lib/omnichannel/Helper.ts b/apps/meteor/server/lib/omnichannel/Helper.ts new file mode 100644 index 0000000..a9d04f1 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/Helper.ts @@ -0,0 +1,964 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat'; +import { api, Message, Omnichannel } from '@rocket.chat/core-services'; +import type { + ILivechatVisitor, + IOmnichannelRoom, + SelectedAgent, + ISubscription, + ILivechatInquiryRecord, + IUser, + TransferData, + ILivechatDepartmentAgents, + TransferByData, + ILivechatAgent, + ILivechatDepartment, + IOmnichannelRoomInfo, + IOmnichannelInquiryExtraData, + IOmnichannelRoomExtraData, + ILivechatContact, +} from '@rocket.chat/core-typings'; +import { LivechatInquiryStatus, OmnichannelSourceType, DEFAULT_SLA_CONFIG, UserStatus } from '@rocket.chat/core-typings'; +import { LivechatPriorityWeight } from '@rocket.chat/core-typings/src/ILivechatPriority'; +import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { + LivechatDepartmentAgents, + LivechatInquiry, + LivechatRooms, + LivechatDepartment, + Subscriptions, + Users, + LivechatContacts, +} from '@rocket.chat/models'; +import { removeEmpty, validateEmail as validatorFunc } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; +import { ObjectId } from 'mongodb'; + +import { queueInquiry, saveQueueInquiry } from './QueueManager'; +import { RoutingManager } from './RoutingManager'; +import { isVerifiedChannelInSource } from './contacts/isVerifiedChannelInSource'; +import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact'; +import { afterRoomQueued, beforeNewRoom } from './hooks'; +import { checkOnlineAgents, getOnlineAgents } from './service-status'; +import { saveTransferHistory } from './transfer'; +import { sendNotification } from '../../hooks/messages/sendNotificationsOnMessage'; +import { settings } from '../../settings'; +import { hasRoleAsync } from '../authorization/hasRole'; +import { callbacks } from '../callbacks'; +import { i18n } from '../i18n'; +import { + notifyOnLivechatDepartmentAgentChanged, + notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId, + notifyOnSubscriptionChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnSubscriptionChanged, + notifyOnRoomChangedById, + notifyOnLivechatInquiryChangedByRoom, +} from '../notifyListener'; + +const logger = new Logger('LivechatHelper'); +export const allowAgentSkipQueue = (agent: SelectedAgent) => { + check( + agent, + Match.ObjectIncluding({ + agentId: String, + }), + ); + + return hasRoleAsync(agent.agentId, 'bot'); +}; +export const prepareLivechatRoom = async ( + rid: string, + guest: ILivechatVisitor, + roomInfo: IOmnichannelRoomInfo = { source: { type: OmnichannelSourceType.OTHER } }, + extraData?: IOmnichannelRoomExtraData, +): Promise> => { + check(rid, String); + check( + guest, + Match.ObjectIncluding({ + _id: String, + username: String, + status: Match.Maybe(String), + department: Match.Maybe(String), + }), + ); + + const extraRoomInfo = await beforeNewRoom(roomInfo, extraData); + const { _id, username, token, department: departmentId, status = 'online' } = guest; + const newRoomAt = new Date(); + const source = extraRoomInfo.source || roomInfo.source; + + if (settings.get('Livechat_Require_Contact_Verification') === 'always') { + await LivechatContacts.setChannelVerifiedStatus({ visitorId: _id, source }, false); + } + + const contactId = await migrateVisitorIfMissingContact(_id, source); + const contact = + contactId && + (await LivechatContacts.findOneEnabledById>(contactId, { + projection: { name: 1, channels: 1, activity: 1 }, + })); + if (!contact) { + throw new Error('error-invalid-contact'); + } + const verified = Boolean(contact.channels.some((channel) => isVerifiedChannelInSource(channel, _id, source))); + + const activity = guest.activity || contact.activity; + logger.debug({ + msg: 'Creating livechat room for visitor', + visitor: { _id, username, departmentId, status, activity }, + }); + + // TODO: Solve `u` missing issue + return { + _id: rid, + msgs: 0, + usersCount: 1, + lm: newRoomAt, + fname: contact.name, + t: 'l' as const, + ts: newRoomAt, + departmentId, + v: { + _id, + username, + token, + status, + ...(activity?.length && { activity }), + }, + contactId, + cl: false, + open: true, + waitingResponse: true, + verified, + // this should be overridden by extraRoomInfo when provided + // in case it's not provided, we'll use this "default" type + source: { + type: OmnichannelSourceType.OTHER, + alias: 'unknown', + }, + queuedAt: newRoomAt, + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, + ...extraRoomInfo, + // marker field for unique index - only new rooms have this field (see #39087) + // allows index creation to succeed even if old duplicates exist + _enforceSingleRoom: true, + } as InsertionModel; +}; + +export const createLivechatRoom = async (room: InsertionModel, session: ClientSession) => { + const result = await LivechatRooms.findOneAndUpdate( + removeEmpty(room), + { + $set: {}, + }, + { + upsert: true, + returnDocument: 'after', + session, + }, + ); + + if (!result) { + throw new Error('Room not created'); + } + + return result; +}; + +export const createLivechatInquiry = async ({ + rid, + name, + guest, + message, + initialStatus, + extraData, + session, +}: { + rid: string; + name?: string; + guest?: Pick; + message?: string; + initialStatus?: LivechatInquiryStatus; + extraData?: IOmnichannelInquiryExtraData; + session?: ClientSession; +}) => { + check(rid, String); + check(name, String); + check( + guest, + Match.ObjectIncluding({ + _id: String, + username: String, + status: Match.Maybe(String), + department: Match.Maybe(String), + activity: Match.Maybe([String]), + }), + ); + + const extraInquiryInfo = await callbacks.run('livechat.beforeInquiry', extraData); + + const { _id, username, token, department, status = UserStatus.ONLINE } = guest; + const inquirySource = extraData?.source || { type: OmnichannelSourceType.OTHER }; + const activity = + guest.activity || + (await LivechatContacts.findOneByVisitor({ visitorId: guest._id, source: inquirySource }, { projection: { activity: 1 } }))?.activity; + + const ts = new Date(); + + logger.debug({ + msg: 'Creating livechat inquiry for visitor', + visitor: { _id, username, department, status, activity }, + }); + + const result = await LivechatInquiry.findOneAndUpdate( + removeEmpty({ + rid, + name, + ts, + department, + message: message ?? '', + status: initialStatus || LivechatInquiryStatus.READY, + v: { + _id, + username, + token, + status, + ...(activity?.length && { activity }), + }, + t: 'l', + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, + + ...extraInquiryInfo, + }), + { + $set: { + _id: new ObjectId().toHexString(), + }, + }, + { + upsert: true, + returnDocument: 'after', + session, + }, + ); + logger.debug({ + msg: 'Inquiry created for visitor', + inquiryId: result, + visitorId: _id, + }); + + if (!result) { + throw new Error('Inquiry not created'); + } + + return result; +}; + +export const createLivechatSubscription = async ( + rid: string, + name: string, + guest: Pick, + agent: SelectedAgent, + department?: string, +) => { + check(rid, String); + check(name, String); + check( + guest, + Match.ObjectIncluding({ + _id: String, + username: String, + status: Match.Maybe(String), + }), + ); + check( + agent, + Match.ObjectIncluding({ + agentId: String, + username: String, + }), + ); + + const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(rid, agent.agentId); + if (existingSubscription?._id) { + return existingSubscription; + } + + const { _id, username, token, status = UserStatus.ONLINE } = guest; + + const subscriptionData: InsertionModel = { + rid, + name, + fname: name, + alert: true, + open: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + u: { + _id: agent.agentId, + username: agent.username, + }, + t: 'l', + desktopNotifications: 'all', + mobilePushNotifications: 'all', + emailNotifications: 'all', + v: { + _id, + username, + token, + status, + }, + ts: new Date(), + ...(department && { department }), + } as InsertionModel; + + const response = await Subscriptions.insertOne(subscriptionData); + + if (response?.insertedId) { + void notifyOnSubscriptionChangedById(response.insertedId, 'inserted'); + } + + return response; +}; + +export const removeAgentFromSubscription = async (rid: string, { _id, username }: Pick) => { + const room = await LivechatRooms.findOneById(rid); + const user = await Users.findOneById(_id); + + if (!room || !user) { + return; + } + + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, _id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + + await Message.saveSystemMessage('ul', rid, username || '', { _id: user._id, username: user.username, name: user.name }); + + setImmediate(() => { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentUnassigned, { room, user }); + }); +}; + +export const parseAgentCustomFields = (customFields?: Record) => { + if (!customFields) { + return; + } + + const externalCustomFields = () => { + const accountCustomFields = settings.get('Accounts_CustomFields'); + if (!accountCustomFields || accountCustomFields.trim() === '') { + return []; + } + + try { + const parseCustomFields = JSON.parse(accountCustomFields); + return Object.keys(parseCustomFields).filter((customFieldKey) => parseCustomFields[customFieldKey].sendToIntegrations === true); + } catch (error) { + logger.error({ msg: 'Error parsing agent custom fields', err: error }); + return []; + } + }; + + const externalCF = externalCustomFields(); + return Object.keys(customFields).reduce( + (newObj, key) => (externalCF.includes(key) ? { ...newObj, [key]: customFields[key] } : newObj), + {}, + ); +}; + +export const normalizeAgent = async (agentId?: string) => { + if (!agentId) { + return; + } + + if (!settings.get('Livechat_show_agent_info')) { + return { hiddenInfo: true }; + } + + const agent = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_email')); + if (!agent) { + return; + } + + const { customFields: agentCustomFields, ...extraData } = agent; + const customFields = parseAgentCustomFields(agentCustomFields); + + return Object.assign(extraData, { ...(customFields && { customFields }) }) as ILivechatAgent; +}; + +export const dispatchAgentDelegated = async (rid: string, agentId?: string) => { + const agent = await normalizeAgent(agentId); + + void api.broadcast('omnichannel.room', rid, { + type: 'agentData', + data: agent, + }); +}; + +/** + * @deprecated + */ + +export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, agent?: SelectedAgent | null) => { + if (!inquiry?._id) { + return; + } + logger.debug({ + msg: 'Notifying agents of queued inquiry', + inquiryId: inquiry._id, + agentId: agent?.agentId, + }); + + const { department, rid, v } = inquiry; + const room = await LivechatRooms.findOneById(rid); + if (!room) { + return; + } + + void afterRoomQueued(room); + + if (RoutingManager.getConfig()?.autoAssignAgent) { + return; + } + + if (agent && (await allowAgentSkipQueue(agent))) { + return; + } + + await saveQueueInquiry(inquiry); + + // Alert only the online agents of the queued request + const onlineAgents = await getOnlineAgents(department, agent); + if (!onlineAgents) { + logger.debug({ msg: 'Cannot notify agents of queued inquiry. No online agents found' }); + return; + } + + const notificationUserName = v && (v.name || v.username); + + for await (const agent of onlineAgents) { + const { _id, active, emails, language, status, statusConnection, username } = agent; + await sendNotification({ + // fake a subscription in order to make use of the function defined above + subscription: { + rid, + u: { + _id, + }, + receiver: [ + { + active, + emails, + language, + status, + statusConnection, + username, + }, + ], + name: '', + }, + sender: v, + hasMentionToAll: true, // consider all agents to be in the room + hasReplyToThread: false, + disableAllMessageNotifications: false, + hasMentionToHere: false, + message: { _id: '', u: v, msg: '' }, + // we should use server's language for this type of messages instead of user's + notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }), + room: Object.assign(room, { name: i18n.t('New_chat_in_queue', { lng: language }) }), + mentionIds: [], + }); + } +}; + +export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: TransferData) => { + if (!room?.open) { + return false; + } + + logger.debug({ + msg: 'Forwarding room to agent', + roomId: room._id, + userId: transferData.userId, + }); + + const { userId: agentId, clientAction } = transferData; + if (!agentId) { + throw new Error('error-invalid-agent'); + } + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + {}, + ); + if (!user) { + logger.debug({ + msg: 'Agent is offline. Cannot forward', + agentId, + }); + throw new Error('error-user-is-offline'); + } + + const { _id: rid, servedBy: oldServedBy } = room; + const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); + if (!inquiry) { + logger.debug({ + msg: 'No inquiries found for room. Cannot forward', + roomId: room._id, + }); + throw new Error('error-invalid-inquiry'); + } + + if (oldServedBy && agentId === oldServedBy._id) { + throw new Error('error-selected-agent-room-agent-are-same'); + } + + const { username } = user; + const agent = { agentId, username }; + // Remove department from inquiry to make sure the routing algorithm treat this as forwarding to agent and not as forwarding to department + delete inquiry.department; + // There are some Enterprise features that may interrupt the forwarding process + // Due to that we need to check whether the agent has been changed or not + logger.debug({ + msg: 'Forwarding inquiry to agent', + inquiryId: inquiry._id, + agentId: agent.agentId, + }); + const roomTaken = await RoutingManager.takeInquiry( + inquiry, + agent, + { + ...(clientAction && { clientAction }), + }, + room, + ); + if (!roomTaken) { + logger.debug({ + msg: 'Cannot forward inquiry', + inquiryId: inquiry._id, + }); + return false; + } + + await saveTransferHistory(room, transferData); + + const { servedBy } = roomTaken; + if (servedBy) { + if (oldServedBy && servedBy._id !== oldServedBy._id) { + await RoutingManager.removeAllRoomSubscriptions(room, servedBy); + } + + setImmediate(() => { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + type: LivechatTransferEventType.AGENT, + room: rid, + from: oldServedBy?._id, + to: servedBy._id, + }); + }); + } + + logger.debug({ + msg: 'Inquiry taken by agent after forwarding', + inquiryId: inquiry._id, + agentId: agent.agentId, + }); + await callbacks.run('livechat.afterForwardChatToAgent', { rid, servedBy, oldServedBy }); + return true; +}; + +export const updateChatDepartment = async ({ + rid, + newDepartmentId, + oldDepartmentId, +}: { + rid: string; + newDepartmentId: string; + oldDepartmentId?: string; +}) => { + const responses = await Promise.all([ + LivechatRooms.changeDepartmentIdByRoomId(rid, newDepartmentId), + LivechatInquiry.changeDepartmentIdByRoomId(rid, newDepartmentId), + Subscriptions.changeDepartmentByRoomId(rid, newDepartmentId), + ]); + + if (responses[0].modifiedCount) { + void notifyOnRoomChangedById(rid); + } + if (responses[1].modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid); + } + if (responses[2].modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + setImmediate(() => { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomTransferred, { + type: LivechatTransferEventType.DEPARTMENT, + room: rid, + from: oldDepartmentId, + to: newDepartmentId, + }); + }); + + return callbacks.run('livechat.afterForwardChatToDepartment', { + rid, + newDepartmentId, + oldDepartmentId, + }); +}; + +export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) => { + if (!room?.open) { + return false; + } + logger.debug({ + msg: 'Attempting to forward room to department', + roomId: room._id, + departmentId: transferData.departmentId, + }); + + await callbacks.run('livechat.beforeForwardRoomToDepartment', { room, transferData }); + const { _id: rid, servedBy: oldServedBy, departmentId: oldDepartmentId } = room; + let agent = null; + + const inquiry = await LivechatInquiry.findOneByRoomId(rid, {}); + if (!inquiry) { + logger.debug({ + msg: 'Cannot forward room. No inquiries found', + roomId: room._id, + }); + throw new Error('error-transferring-inquiry'); + } + + const { departmentId } = transferData; + if (!departmentId) { + logger.debug({ + msg: 'Cannot forward room. No departmentId provided', + roomId: room._id, + }); + throw new Error('error-transferring-inquiry-no-department'); + } + if (oldDepartmentId === departmentId) { + throw new Error('error-forwarding-chat-same-department'); + } + + const { userId: agentId, clientAction } = transferData; + if (agentId) { + logger.debug({ + msg: 'Forwarding room to department and user', + roomId: room._id, + departmentId, + agentId, + }); + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + {}, + ); + if (!user) { + throw new Error('error-user-is-offline'); + } + const isInDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(agentId, departmentId, { + projection: { _id: 1 }, + }); + if (!isInDepartment) { + throw new Error('error-user-not-belong-to-department'); + } + const { username } = user; + agent = { agentId, username }; + } + + const department = await LivechatDepartment.findOneById< + Pick + >(departmentId, { + projection: { + allowReceiveForwardOffline: 1, + fallbackForwardDepartment: 1, + name: 1, + }, + }); + + // Cases: + // 1. Routing is manual + // 2. Server is out of macs + // 3. Department allows to forward when offline and department is offline + // 4. Department is online && waiting queue is enabled + const onlineAgents = await checkOnlineAgents(departmentId); + const isWaitingQueueEnabled = settings.get('Livechat_waiting_queue'); + if ( + !RoutingManager.getConfig()?.autoAssignAgent || + !(await Omnichannel.isWithinMACLimit(room)) || + (department?.allowReceiveForwardOffline && !onlineAgents) || + (isWaitingQueueEnabled && onlineAgents) + ) { + logger.debug({ + msg: 'Room will be on department queue', + roomId: room._id, + departmentId, + departmentAllowOffline: department?.allowReceiveForwardOffline, + areAgentsOnline: onlineAgents, + isWaitingQueueEnabled, + }); + await saveTransferHistory(room, transferData); + return RoutingManager.unassignAgent(inquiry, departmentId, true, agent); + } + + // Fake the department to forward the inquiry - Case the forward process does not success + // the inquiry will stay in the same original department + inquiry.department = departmentId; + const roomTaken = await RoutingManager.delegateInquiry( + inquiry, + agent, + { + forwardingToDepartment: { oldDepartmentId }, + ...(clientAction && { clientAction }), + }, + room, + ); + if (!roomTaken) { + logger.debug({ + msg: 'Cannot forward room. Unable to delegate inquiry', + roomId: room._id, + }); + return false; + } + + const { servedBy, chatQueued } = roomTaken; + if (!chatQueued && oldServedBy && oldServedBy._id === servedBy?._id) { + if (!department?.fallbackForwardDepartment?.length) { + logger.debug({ + msg: 'Cannot forward room. Chat assigned to agent instead', + roomId: room._id, + agentId: servedBy._id, + previousAgentId: oldServedBy._id, + }); + throw new Error('error-no-agents-available-for-service-on-department'); + } + + if (!transferData.originalDepartmentName) { + transferData.originalDepartmentName = department.name; + } + // if a chat has a fallback department, attempt to redirect chat to there [EE] + const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData, department })); + // On CE theres no callback so it will return the room + if (typeof transferSuccess !== 'boolean' || !transferSuccess) { + logger.debug({ + msg: 'Cannot forward room. Unable to delegate inquiry', + roomId: room._id, + }); + return false; + } + + return true; + } + + // Send just 1 message to the room to inform the user that the chat was transferred + if (transferData.usingFallbackDep) { + const { _id, username } = transferData.transferredBy; + await Message.saveSystemMessage( + 'livechat_transfer_history_fallback', + room._id, + '', + { _id, username }, + { + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), + transferData: { + ...transferData, + prevDepartment: transferData.originalDepartmentName, + }, + }, + ); + } + + await saveTransferHistory(room, transferData); + if (oldServedBy) { + // if chat is queued then we don't ignore the new servedBy agent bcs at this + // point the chat is not assigned to him/her and it is still in the queue + await RoutingManager.removeAllRoomSubscriptions(room, !chatQueued ? servedBy : undefined); + } + + await updateChatDepartment({ rid, newDepartmentId: departmentId, oldDepartmentId }); + + if (chatQueued) { + logger.debug({ + msg: 'Forwarding successful. Marking inquiry as ready', + inquiryId: inquiry._id, + }); + await LivechatInquiry.readyInquiry(inquiry._id); + await LivechatRooms.removeAgentByRoomId(rid); + await dispatchAgentDelegated(rid); + const newInquiry = await LivechatInquiry.findOneById(inquiry._id); + if (!newInquiry) { + logger.debug({ + msg: 'Inquiry not found after forwarding', + inquiryId: inquiry._id, + }); + throw new Error('error-invalid-inquiry'); + } + + await queueInquiry(newInquiry); + logger.debug({ + msg: 'Inquiry queued successfully after forwarding', + inquiryId: inquiry._id, + }); + } + + return true; +}; + +type MakePropertyOptional = Omit & { [P in K]?: T[P] }; + +export const normalizeTransferredByData = ( + transferredBy: MakePropertyOptional, + room: IOmnichannelRoom, +): TransferByData => { + if (!transferredBy || !room) { + throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"'); + } + const { servedBy: { _id: agentId } = {} } = room; + const { _id, username, name, userType: transferType } = transferredBy; + const userType = transferType || (_id === agentId ? 'agent' : 'user'); + return { + _id, + username, + ...(name && { name }), + userType, + }; +}; + +const parseFromIntOrStr = (value: string | number) => { + if (typeof value === 'number') { + return value; + } + return parseInt(value); +}; + +export const updateDepartmentAgents = async ( + departmentId: string, + agents: { + upsert?: (Pick & { count?: number; order?: number })[]; + remove?: Pick[]; + }, + departmentEnabled: boolean, +) => { + check(departmentId, String); + check(agents, { + upsert: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + remove: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + }); + + const { upsert = [], remove = [] } = agents; + + const agentsUpdated = []; + const agentsRemoved = remove.map(({ agentId }: { agentId: string }) => agentId); + const agentsAdded = []; + + if (agentsRemoved.length > 0) { + const removedIds = await LivechatDepartmentAgents.findByAgentsAndDepartmentId(agentsRemoved, departmentId, { + projection: { agentId: 1 }, + }).toArray(); + + const { deletedCount } = await LivechatDepartmentAgents.removeByIds(removedIds.map(({ _id }) => _id)); + + if (deletedCount > 0) { + removedIds.forEach(({ _id, agentId }) => { + void notifyOnLivechatDepartmentAgentChanged( + { + _id, + agentId, + departmentId, + }, + 'removed', + ); + }); + } + + callbacks.runAsync('livechat.removeAgentDepartment', { departmentId, agentsId: agentsRemoved }); + } + + for await (const agent of upsert) { + const agentFromDb = await Users.findOneById(agent.agentId, { projection: { _id: 1, username: 1 } }); + if (!agentFromDb) { + continue; + } + + const livechatDepartmentAgent = await LivechatDepartmentAgents.saveAgent({ + agentId: agent.agentId, + departmentId, + username: agentFromDb.username || '', + count: agent.count ? parseFromIntOrStr(agent.count) : 0, + order: agent.order ? parseFromIntOrStr(agent.order) : 0, + departmentEnabled, + }); + + if (livechatDepartmentAgent.upsertedId) { + void notifyOnLivechatDepartmentAgentChanged( + { + _id: livechatDepartmentAgent.upsertedId as any, + agentId: agent.agentId, + departmentId, + }, + 'inserted', + ); + } else { + agentsUpdated.push(agent.agentId); + } + + agentsAdded.push(agent.agentId); + } + + if (agentsAdded.length > 0) { + callbacks.runAsync('livechat.saveAgentDepartment', { + departmentId, + agentsId: agentsAdded, + }); + } + + if (agentsUpdated.length > 0) { + void notifyOnLivechatDepartmentAgentChangedByAgentsAndDepartmentId(agentsUpdated, departmentId); + } + + if (agentsRemoved.length > 0 || agentsAdded.length > 0) { + const numAgents = await LivechatDepartmentAgents.countByDepartmentId(departmentId); + await LivechatDepartment.updateNumAgentsById(departmentId, numAgents); + } + + return true; +}; + +export const validateEmail = (email: string) => { + if (!validatorFunc(email)) { + throw new Meteor.Error('error-invalid-email', `Invalid email ${email}`, { + function: 'Livechat.validateEmail', + email, + }); + } + return true; +}; diff --git a/apps/meteor/server/lib/omnichannel/QueueManager.ts b/apps/meteor/server/lib/omnichannel/QueueManager.ts new file mode 100644 index 0000000..b73e213 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/QueueManager.ts @@ -0,0 +1,533 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; +import type { + ILivechatDepartment, + IOmnichannelRoomInfo, + IOmnichannelRoomExtraData, + AtLeast, + ILivechatInquiryRecord, + ILivechatVisitor, + IOmnichannelRoom, + SelectedAgent, +} from '@rocket.chat/core-typings'; +import { LivechatInquiryStatus } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { LivechatContacts, LivechatDepartment, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue, prepareLivechatRoom } from './Helper'; +import { RoutingManager } from './RoutingManager'; +import { isVerifiedChannelInSource } from './contacts/isVerifiedChannelInSource'; +import { checkOnlineForDepartment } from './departmentsLib'; +import { afterInquiryQueued, afterRoomQueued, beforeDelegateAgent, beforeRouteChat, onNewRoom } from './hooks'; +import { checkOnlineAgents, getOnlineAgents } from './service-status'; +import { getInquirySortMechanismSetting } from './settings'; +import { getOmniChatSortQuery } from '../../../app/livechat/lib/inquiries'; +import { i18n } from '../../../app/utils/lib/i18n'; +import { dispatchInquiryPosition } from '../../../ee/server/lib/omnichannel/Helper'; +import { client, shouldRetryTransaction } from '../../database/utils'; +import { sendNotification } from '../../hooks/messages/sendNotificationsOnMessage'; +import { settings } from '../../settings'; +import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged } from '../notifyListener'; + +const logger = new Logger('QueueManager'); + +export const saveQueueInquiry = async (inquiry: ILivechatInquiryRecord) => { + const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id); + if (!queuedInquiry) { + return; + } + + // After inquiry queued does not modify the inquiry, its safe to return the return of queueInquiry + await afterInquiryQueued(queuedInquiry); + + void notifyOnLivechatInquiryChanged(queuedInquiry, 'updated', { + status: LivechatInquiryStatus.QUEUED, + queuedAt: new Date(), + takenAt: undefined, + }); + + return queuedInquiry; +}; + +/** + * @deprecated + */ +export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent?: SelectedAgent) => { + // No projection: requeueInquiry forwards the room into the routing pipeline, which expects a full room. + const room = await LivechatRooms.findOneById(inquiry.rid); + + if (!room) { + await saveQueueInquiry(inquiry); + return; + } + + return QueueManager.requeueInquiry(inquiry, room, defaultAgent); +}; + +const getDepartment = async (department: string): Promise => { + if (!department) { + return; + } + + if (await checkOnlineForDepartment(department)) { + return department; + } + + const departmentDocument = await LivechatDepartment.findOneById>( + department, + { + projection: { fallbackForwardDepartment: 1 }, + }, + ); + + if (departmentDocument?.fallbackForwardDepartment) { + return getDepartment(departmentDocument.fallbackForwardDepartment); + } +}; + +export class QueueManager { + static async requeueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent) { + if (!(await Omnichannel.isWithinMACLimit(room))) { + logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry }); + // We'll queue these inquiries so when new license is applied, they just start rolling again + // Minimizing disruption + await saveQueueInquiry(inquiry); + return; + } + + const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry); + logger.debug({ + msg: 'Delegating inquiry', + inquiryId: inquiry._id, + defaultAgentUsername: defaultAgent?.username, + }); + const dbInquiry = await beforeRouteChat(inquiry, inquiryAgent); + + if (!dbInquiry) { + throw new Error('inquiry-not-found'); + } + + if (dbInquiry.status === 'ready') { + logger.debug({ + msg: 'Inquiry is ready. Delegating to agent', + inquiryId: inquiry._id, + agentUsername: inquiryAgent?.username, + }); + return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent, undefined, room); + } + } + + private static fnQueueInquiryStatus: (typeof QueueManager)['getInquiryStatus'] | undefined; + + public static patchInquiryStatus(fn: (typeof QueueManager)['getInquiryStatus']) { + this.fnQueueInquiryStatus = fn; + } + + static async getInquiryStatus({ room, agent }: { room: IOmnichannelRoom; agent?: SelectedAgent }): Promise { + if (this.fnQueueInquiryStatus) { + return this.fnQueueInquiryStatus({ room, agent }); + } + + const needVerification = ['once', 'always'].includes(settings.get('Livechat_Require_Contact_Verification')); + + if (needVerification && !(await this.isRoomContactVerified(room))) { + return LivechatInquiryStatus.VERIFYING; + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + return LivechatInquiryStatus.QUEUED; + } + + // bots should be able to skip the queue and the routing check + if (agent && (await allowAgentSkipQueue(agent))) { + return LivechatInquiryStatus.READY; + } + + if (settings.get('Livechat_waiting_queue')) { + return LivechatInquiryStatus.QUEUED; + } + + if (RoutingManager.getConfig()?.autoAssignAgent) { + return LivechatInquiryStatus.READY; + } + + if (settings.get('Livechat_Routing_Method') === 'Manual_Selection' && agent) { + return LivechatInquiryStatus.QUEUED; + } + + if (!agent) { + return LivechatInquiryStatus.QUEUED; + } + + return LivechatInquiryStatus.READY; + } + + static async processNewInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent | null) { + if (inquiry.status === LivechatInquiryStatus.VERIFYING) { + logger.debug({ + msg: 'Inquiry is waiting for contact verification. Ignoring it', + inquiryId: inquiry._id, + defaultAgentUsername: defaultAgent?.username, + status: inquiry.status, + }); + + if (defaultAgent) { + await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent); + } + return; + } + + if (inquiry.status === LivechatInquiryStatus.READY) { + logger.debug({ + msg: 'Inquiry is ready. Delegating', + inquiryId: inquiry._id, + defaultAgentUsername: defaultAgent?.username, + status: inquiry.status, + }); + return RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, room); + } + + if (inquiry.status === LivechatInquiryStatus.QUEUED) { + await Promise.all([afterInquiryQueued(inquiry), afterRoomQueued(room)]); + + if (defaultAgent) { + logger.debug({ msg: 'Setting default agent for inquiry', inquiryId: inquiry._id, agentUsername: defaultAgent.username }); + await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent); + } + + return this.dispatchInquiryQueued(inquiry, room, defaultAgent); + } + } + + static async verifyInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom) { + if (inquiry.status !== LivechatInquiryStatus.VERIFYING) { + return; + } + + const { defaultAgent: agent } = inquiry; + + const newStatus = await QueueManager.getInquiryStatus({ room, agent }); + + if (newStatus === inquiry.status) { + throw new Error('error-failed-to-verify-inquiry'); + } + + const newInquiry = await LivechatInquiry.setStatusById(inquiry._id, newStatus); + + await this.processNewInquiry(newInquiry, room, agent); + + const newRoom = await LivechatRooms.findOneById>(room._id, { + projection: { servedBy: 1, departmentId: 1 }, + }); + + if (!newRoom) { + logger.error({ msg: 'Room not found after inquiry verification', roomId: room._id }); + throw new Error('room-not-found'); + } + + await this.dispatchInquiryPosition(inquiry, newRoom); + } + + static async isRoomContactVerified(room: IOmnichannelRoom): Promise { + if (!room.contactId) { + return false; + } + + const contact = await LivechatContacts.findOneEnabledById(room.contactId, { projection: { channels: 1 } }); + if (!contact) { + return false; + } + + return Boolean(contact.channels.some((channel) => isVerifiedChannelInSource(channel, room.v._id, room.source))); + } + + static async startConversation( + rid: string, + insertionRoom: InsertionModel, + guest: ILivechatVisitor, + roomInfo: IOmnichannelRoomInfo, + defaultAgent?: SelectedAgent, + message?: string, + extraData?: IOmnichannelRoomExtraData, + attempts = 3, + ): Promise<{ room: IOmnichannelRoom; inquiry: ILivechatInquiryRecord }> { + const session = client.startSession(); + try { + session.startTransaction(); + const room = await createLivechatRoom(insertionRoom, session); + logger.debug({ msg: 'Room created for visitor', visitorId: guest._id, roomId: room._id }); + const inquiry = await createLivechatInquiry({ + rid, + name: room.fname, + initialStatus: await this.getInquiryStatus({ room, agent: defaultAgent }), + guest, + message, + extraData: { ...extraData, source: roomInfo.source }, + session, + }); + await session.commitTransaction(); + return { room, inquiry }; + } catch (e) { + await session.abortTransaction(); + if (shouldRetryTransaction(e)) { + if (attempts > 0) { + logger.debug({ msg: 'Retrying transaction because of transient error', attemptsLeft: attempts }); + return this.startConversation(rid, insertionRoom, guest, roomInfo, defaultAgent, message, extraData, attempts - 1); + } + throw new Error('error-failed-to-start-conversation'); + } + throw e; + } finally { + await session.endSession(); + } + } + + static async requestRoom({ + guest, + rid = Random.id(), + message, + roomInfo, + agent, + extraData: { customFields, ...extraData } = {}, + }: { + guest: ILivechatVisitor; + rid?: string; + message?: string; + roomInfo: IOmnichannelRoomInfo; + agent?: SelectedAgent; + extraData?: IOmnichannelRoomExtraData; + }) { + logger.debug({ msg: 'Requesting room for guest', guestId: guest._id }); + check( + guest, + Match.ObjectIncluding({ + _id: String, + username: String, + status: Match.Maybe(String), + department: Match.Maybe(String), + name: Match.Maybe(String), + activity: Match.Maybe([String]), + }), + ); + + const defaultAgent = + (await beforeDelegateAgent(agent, { + department: guest.department, + })) || undefined; + + const department = guest.department && (await getDepartment(guest.department)); + + /** + * we have 4 cases here + * 1. agent and no department + * 2. no agent and no department + * 3. no agent and department + * 4. agent and department informed + * + * in case 1, we check if the agent is online + * in case 2, we check if there is at least one online agent in the whole service + * in case 3, we check if there is at least one online agent in the department + * + * the case 4 is weird, but we are not throwing an error, just because the application works in some mysterious way + * we don't have explicitly defined what to do in this case so we just kept the old behavior + * it seems that agent has priority over department + * but some cases department is handled before agent + * + */ + + if (!settings.get('Livechat_accept_chats_with_no_agents')) { + if (agent && !defaultAgent) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + + if (!defaultAgent && guest.department && !department) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + + if (!agent && !guest.department && !(await checkOnlineAgents())) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + } + + const insertionRoom = await prepareLivechatRoom(rid, { ...guest, ...(department && { department }) }, roomInfo, { + ...extraData, + ...(Boolean(customFields) && { customFields }), + }); + + try { + await Apps.self?.triggerEvent(AppEvents.IPreLivechatRoomCreatePrevent, insertionRoom); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + // Transactional start of the conversation. This should prevent rooms from being created without inquiries and viceversa. + // All the actions that happened inside createLivechatRoom are now outside this transaction + const { room, inquiry } = await this.startConversation(rid, insertionRoom, guest, roomInfo, defaultAgent, message, extraData); + + await onNewRoom(room); + await Message.saveSystemMessageAndNotifyUser( + 'livechat-started', + rid, + '', + { _id: guest._id, username: guest.username }, + { groupable: false, token: guest.token }, + ); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room); + + await this.processNewInquiry(inquiry, room, defaultAgent); + const newRoom = await LivechatRooms.findOneById(rid); + + if (!newRoom) { + logger.error({ msg: 'Room not found', roomId: rid }); + throw new Error('room-not-found'); + } + + await this.dispatchInquiryPosition(inquiry, newRoom); + return newRoom; + } + + static async dispatchInquiryPosition( + inquiry: ILivechatInquiryRecord, + room: AtLeast, + ): Promise { + if ( + !room.servedBy && + inquiry.status !== LivechatInquiryStatus.VERIFYING && + settings.get('Livechat_waiting_queue') && + settings.get('Omnichannel_calculate_dispatch_service_queue_statistics') + ) { + const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({ + inquiryId: inquiry._id, + department: room.departmentId, + queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()), + }); + + if (inq) { + void dispatchInquiryPosition(inq); + } + } + } + + static async unarchiveRoom(archivedRoom: IOmnichannelRoom) { + if (!archivedRoom) { + throw new Error('no-room-to-unarchive'); + } + + const { _id: rid, open, closedAt, fname: name, servedBy, v, departmentId: department, lastMessage: message, source } = archivedRoom; + + if (!rid || !closedAt || !!open) { + return archivedRoom; + } + + logger.debug({ msg: 'Attempting to unarchive room', roomId: rid }); + + const oldInquiry = await LivechatInquiry.findOneByRoomId>(rid, { projection: { _id: 1 } }); + if (oldInquiry) { + logger.debug({ msg: 'Removing old inquiry before unarchiving room', inquiryId: oldInquiry._id, roomId: rid }); + await LivechatInquiry.removeByRoomId(rid); + void notifyOnLivechatInquiryChangedById(oldInquiry._id, 'removed'); + } + + const guest = { + ...v, + ...(department && { department }), + }; + + let defaultAgent: SelectedAgent | undefined; + const isAgentAvailable = (username: string) => + Users.findOneOnlineAgentByUserList( + username, + { projection: { _id: 1 } }, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + + if (servedBy?.username && (await isAgentAvailable(servedBy.username))) { + defaultAgent = { agentId: servedBy._id, username: servedBy.username }; + } + + // TODO: unarchive to return updated room + await LivechatRooms.unarchiveOneById(rid); + const room = await LivechatRooms.findOneById(rid); + if (!room) { + throw new Error('room-not-found'); + } + const inquiry = await createLivechatInquiry({ + rid, + name, + guest, + message: message?.msg, + extraData: { source }, + }); + if (!inquiry) { + throw new Error('inquiry-not-found'); + } + + await this.requeueInquiry(inquiry, room, defaultAgent); + logger.debug({ msg: 'Inquiry queued', inquiryId: inquiry._id }); + + return room; + } + + private static dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, agent?: SelectedAgent | null) => { + if (RoutingManager.getConfig()?.autoAssignAgent) { + return; + } + + logger.debug({ msg: 'Notifying agents of queued inquiry', inquiryId: inquiry._id }); + + const { department, rid, v } = inquiry; + // Alert only the online agents of the queued request + const onlineAgents = await getOnlineAgents(department, agent); + + if (!onlineAgents) { + logger.debug('Cannot notify agents of queued inquiry. No online agents found'); + return; + } + + const notificationUserName = v && (v.name || v.username); + + for await (const agent of onlineAgents) { + const { _id, active, emails, language, status, statusConnection, username } = agent; + await sendNotification({ + // fake a subscription in order to make use of the function defined above + subscription: { + rid, + u: { + _id, + }, + receiver: [ + { + active, + emails, + language, + status, + statusConnection, + username, + }, + ], + name: '', + }, + sender: v, + hasMentionToAll: true, // consider all agents to be in the room + hasReplyToThread: false, + disableAllMessageNotifications: false, + hasMentionToHere: false, + message: { _id: '', u: v, msg: '' }, + // we should use server's language for this type of messages instead of user's + notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName, lng: language }), + room: { ...room, name: i18n.t('New_chat_in_queue', { lng: language }) }, + mentionIds: [], + }); + } + }; +} diff --git a/apps/meteor/server/lib/omnichannel/RoutingManager.ts b/apps/meteor/server/lib/omnichannel/RoutingManager.ts new file mode 100644 index 0000000..22ac84c --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/RoutingManager.ts @@ -0,0 +1,381 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import type { + ILivechatInquiryRecord, + ILivechatVisitor, + IOmnichannelRoom, + IRoutingMethod, + IRoutingMethodConstructor, + RoutingMethodConfig, + SelectedAgent, + InquiryWithAgentInfo, + TransferData, + IUser, +} from '@rocket.chat/core-typings'; +import { LivechatInquiryStatus } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatInquiry, LivechatRooms, Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { + createLivechatSubscription, + dispatchAgentDelegated, + dispatchInquiryQueued, + forwardRoomToAgent, + forwardRoomToDepartment, + removeAgentFromSubscription, + updateChatDepartment, + allowAgentSkipQueue, +} from './Helper'; +import { conditionalLockAgent } from './conditionalLockAgent'; +import { afterTakeInquiry, beforeDelegateAgent } from './hooks'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { notifyOnLivechatInquiryChangedById, notifyOnLivechatInquiryChanged } from '../notifyListener'; + +const logger = new Logger('RoutingManager'); + +type Routing = { + methods: Record; + isMethodSet(): boolean; + registerMethod(name: string, Method: IRoutingMethodConstructor): void; + getMethod(): IRoutingMethod; + getConfig(): RoutingMethodConfig | undefined; + getNextAgent(department?: string, ignoreAgentId?: string): Promise; + delegateInquiry( + inquiry: InquiryWithAgentInfo, + agent?: SelectedAgent | null, + options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + room?: IOmnichannelRoom, + ): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | false>; + unassignAgent( + inquiry: ILivechatInquiryRecord, + departmentId?: string, + shouldQueue?: boolean, + agent?: SelectedAgent | null, + ): Promise; + takeInquiry( + inquiry: Omit< + ILivechatInquiryRecord, + 'estimatedInactivityCloseTimeAt' | 'message' | 't' | 'source' | 'estimatedWaitingTimeQueue' | 'priorityWeight' | '_updatedAt' + >, + agent: SelectedAgent | null, + options: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } }, + room: IOmnichannelRoom, + ): Promise; + transferRoom(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData): Promise; + delegateAgent(agent: SelectedAgent | undefined, inquiry: ILivechatInquiryRecord): Promise; + removeAllRoomSubscriptions(room: Pick, ignoreUser?: { _id: string }): Promise; + + assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise<{ inquiry: InquiryWithAgentInfo; user: IUser }>; +}; + +export const RoutingManager: Routing = { + methods: {}, + + isMethodSet() { + return settings.get('Livechat_Routing_Method') !== ''; + }, + + // eslint-disable-next-line @typescript-eslint/naming-convention + registerMethod(name, Method) { + this.methods[name] = new Method(); + }, + + getMethod() { + const setting = settings.get('Livechat_Routing_Method'); + if (!this.methods[setting]) { + throw new Meteor.Error('error-routing-method-not-available'); + } + return this.methods[setting]; + }, + + getConfig() { + return this.getMethod().config; + }, + + async getNextAgent(department, ignoreAgentId) { + logger.debug({ + msg: 'Getting next available agent with method', + routingMethod: settings.get('Livechat_Routing_Method'), + department, + ignoreAgentId, + }); + return this.getMethod().getNextAgent(department, ignoreAgentId); + }, + + async delegateInquiry(inquiry, agent, options = {}, room) { + const { department, rid } = inquiry; + logger.debug({ msg: 'Attempting to delegate inquiry', inquiryId: inquiry._id }); + if ( + !agent || + (agent.username && + !(await Users.findOneOnlineAgentByUserList( + agent.username, + {}, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + )) && + !(await allowAgentSkipQueue(agent))) + ) { + logger.debug({ msg: 'Agent offline or invalid. Using routing method to get next agent', inquiryId: inquiry._id }); + agent = await this.getNextAgent(department); + logger.debug({ msg: 'Routing method returned agent for inquiry', inquiryId: inquiry._id, agentId: agent?.agentId }); + } + + if (!agent) { + logger.debug({ msg: 'No agents available. Unable to delegate inquiry', inquiryId: inquiry._id }); + // When an inqury reaches here on CE, it will stay here as 'ready' since on CE there's no mechanism to re queue it. + // When reaching this point, managers have to manually transfer the inquiry to another room. This is expected. + return LivechatRooms.findOneById(rid); + } + + if (!room) { + throw new Meteor.Error('error-invalid-room'); + } + + logger.debug({ msg: 'Inquiry will be taken by agent', inquiryId: inquiry._id, agentId: agent.agentId }); + return this.takeInquiry(inquiry, agent, options, room); + }, + + async assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise<{ inquiry: InquiryWithAgentInfo; user: IUser }> { + check( + agent, + Match.ObjectIncluding({ + agentId: String, + username: String, + }), + ); + + logger.debug({ msg: 'Assigning agent to inquiry', agentId: agent.agentId, inquiryId: inquiry._id }); + + const { rid, name, v, department } = inquiry; + if (!(await createLivechatSubscription(rid, name, v, agent, department))) { + logger.debug({ msg: 'Cannot assign agent to inquiry. Cannot create subscription', inquiryId: inquiry._id, agentId: agent.agentId }); + throw new Meteor.Error('error-creating-subscription', 'Error creating subscription'); + } + + await LivechatRooms.changeAgentByRoomId(rid, agent); + await Rooms.incUsersCountById(rid, 1); + + const user = await Users.findOneById(agent.agentId); + if (!user) { + throw new Error('error-user-not-found'); + } + + await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]); + + await dispatchAgentDelegated(rid, agent.agentId); + + logger.debug({ msg: 'Agent assigned to inquiry. Instances notified', agentId: agent.agentId, inquiryId: inquiry._id }); + + return { inquiry, user }; + }, + + async unassignAgent(inquiry, departmentId, shouldQueue = false, defaultAgent?: SelectedAgent | null) { + const { rid, department } = inquiry; + const room = await LivechatRooms.findOneById(rid); + + logger.debug({ + msg: 'Removing assignations of inquiry', + inquiryId: inquiry._id, + departmentId, + room: { _id: room?._id, open: room?.open, servedBy: room?.servedBy }, + shouldQueue, + defaultAgent, + }); + + if (!room?.open) { + logger.debug({ msg: 'Cannot unassign agent from inquiry. Room already closed', inquiryId: inquiry._id }); + return false; + } + + if (departmentId && departmentId !== department) { + logger.debug({ + msg: 'Switching department for inquiry', + inquiryId: inquiry._id, + currentDepartment: department, + nextDepartment: departmentId, + }); + await updateChatDepartment({ + rid, + newDepartmentId: departmentId, + oldDepartmentId: department, + }); + // Fake the department to delegate the inquiry; + inquiry.department = departmentId; + } + + const { servedBy } = room; + + if (servedBy) { + await LivechatRooms.removeAgentByRoomId(rid); + await this.removeAllRoomSubscriptions(room); + await dispatchAgentDelegated(rid); + } + + if (shouldQueue) { + const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id, room.lastMessage, defaultAgent); + if (queuedInquiry) { + inquiry = queuedInquiry; + void notifyOnLivechatInquiryChanged(inquiry, 'updated', { + status: LivechatInquiryStatus.QUEUED, + queuedAt: new Date(), + takenAt: undefined, + }); + } + } + + await dispatchInquiryQueued(inquiry); + + return true; + }, + + async takeInquiry(inquiry, agent, options = { clientAction: false }, room) { + check( + agent, + Match.ObjectIncluding({ + agentId: String, + username: String, + }), + ); + + check( + inquiry, + Match.ObjectIncluding({ + _id: String, + rid: String, + status: String, + }), + ); + + logger.debug({ msg: 'Attempting to take Inquiry', inquiryId: inquiry._id, agentId: agent.agentId }); + + const { _id, rid } = inquiry; + if (!room?.open) { + logger.debug({ msg: 'Cannot take inquiry. Room is closed', inquiryId: inquiry._id }); + return room; + } + + if (room.servedBy && room.servedBy._id === agent.agentId) { + logger.debug({ msg: 'Cannot take inquiry. Already taken by agent', inquiryId: inquiry._id, agentId: room.servedBy._id }); + return room; + } + + const lock = await conditionalLockAgent(agent.agentId); + if (!lock.acquired && lock.required) { + logger.debug({ + msg: 'Cannot take inquiry because agent is currently locked by another process', + agentId: agent.agentId, + inquiryId: _id, + }); + if (options.clientAction && !options.forwardingToDepartment) { + throw new Error('error-agent-is-locked'); + } + agent = null; + } + + if (agent) { + try { + await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { + agent, + inquiry, + options, + }); + } catch (e) { + await lock.unlock(); + if (options.clientAction && !options.forwardingToDepartment) { + throw e; + } + agent = null; + } + } + + if (!agent) { + logger.debug({ msg: 'Cannot take inquiry. Precondition failed for agent', inquiryId: inquiry._id }); + const cbRoom = await callbacks.run<'livechat.onAgentAssignmentFailed'>('livechat.onAgentAssignmentFailed', room, { + inquiry, + options, + }); + return cbRoom; + } + + try { + const result = await LivechatInquiry.takeInquiry(_id, inquiry.lockedAt); + if (result.modifiedCount === 0) { + logger.error({ msg: 'Failed to take inquiry because lockedAt did not match', inquiryId: _id, lockedAt: inquiry.lockedAt }); + throw new Error('error-taking-inquiry-lockedAt-mismatch'); + } + + logger.info({ msg: 'Inquiry taken', inquiryId: _id, agentId: agent.agentId }); + + // assignAgent changes the room data to add the agent serving the conversation. afterTakeInquiry expects room object to be updated + const { inquiry: returnedInquiry, user } = await this.assignAgent(inquiry, agent); + const roomAfterUpdate = await LivechatRooms.findOneById(rid); + + if (!roomAfterUpdate) { + // This should never happen + throw new Error('error-room-not-found'); + } + + void Apps.self?.triggerEvent(AppEvents.IPostLivechatAgentAssigned, { room: roomAfterUpdate, user }); + void afterTakeInquiry({ inquiry: returnedInquiry, room: roomAfterUpdate, agent }); + + void notifyOnLivechatInquiryChangedById(inquiry._id, 'updated', { + status: LivechatInquiryStatus.TAKEN, + takenAt: new Date(), + defaultAgent: undefined, + estimatedInactivityCloseTimeAt: undefined, + queuedAt: undefined, + }); + + return roomAfterUpdate; + } finally { + await lock.unlock(); + } + }, + + async transferRoom(room, guest, transferData) { + logger.debug({ msg: 'Transferring room', roomId: room._id, transferredBy: transferData.transferredBy._id }); + if (transferData.departmentId) { + logger.debug({ msg: 'Transferring room to department', roomId: room._id, departmentId: transferData.departmentId }); + return forwardRoomToDepartment(room, guest, transferData); + } + + if (transferData.userId) { + logger.debug({ msg: 'Transferring room to user', roomId: room._id, userId: transferData.userId }); + return forwardRoomToAgent(room, transferData); + } + + logger.debug({ msg: 'Unable to transfer room. No target provided', roomId: room._id }); + return false; + }, + + async delegateAgent(agent, inquiry) { + const defaultAgent = await beforeDelegateAgent(agent, { + department: inquiry?.department, + }); + + if (defaultAgent) { + logger.debug({ msg: 'Delegating Inquiry to agent', inquiryId: inquiry._id, agentUsername: defaultAgent.username }); + await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent); + void notifyOnLivechatInquiryChanged(inquiry, 'updated', { defaultAgent }); + } + + logger.debug({ msg: 'Queueing inquiry', inquiryId: inquiry._id }); + await dispatchInquiryQueued(inquiry, defaultAgent); + return defaultAgent; + }, + + async removeAllRoomSubscriptions(room, ignoreUser) { + const { _id: roomId } = room; + + for await (const { u } of Subscriptions.findByRoomId(roomId, { projection: { u: 1 } })) { + if (ignoreUser?._id === u._id) { + continue; + } + await removeAgentFromSubscription(roomId, u); + } + }, +}; diff --git a/apps/meteor/server/lib/omnichannel/Visitors.ts b/apps/meteor/server/lib/omnichannel/Visitors.ts new file mode 100644 index 0000000..4bd8149 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/Visitors.ts @@ -0,0 +1,13 @@ +import type { ILivechatContactVisitorAssociation, IOmnichannelSource } from '@rocket.chat/core-typings'; + +export const Visitors = { + makeVisitorAssociation(visitorId: string, roomInfo: IOmnichannelSource): ILivechatContactVisitorAssociation { + return { + visitorId, + source: { + type: roomInfo.type, + id: roomInfo.id, + }, + }; + }, +}; diff --git a/apps/meteor/server/lib/omnichannel/analytics/agents.ts b/apps/meteor/server/lib/omnichannel/analytics/agents.ts new file mode 100644 index 0000000..082404b --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/analytics/agents.ts @@ -0,0 +1,72 @@ +import { LivechatRooms, LivechatAgentActivity } from '@rocket.chat/models'; + +type Params = { + start: Date; + end: Date; + options?: any; +}; + +export const findAllAverageServiceTimeAsync = async ({ start, end, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllAverageServiceTimeByAgents({ + start, + end, + onlyCount: true, + }).toArray(); + return { + agents: await LivechatRooms.findAllAverageServiceTimeByAgents({ + start, + end, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllServiceTimeAsync = async ({ start, end, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllServiceTimeByAgent({ + start, + end, + onlyCount: true, + }).toArray(); + return { + agents: await LivechatRooms.findAllServiceTimeByAgent({ start, end, options }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAvailableServiceTimeHistoryAsync = async ({ + start, + end, + fullReport, + options = {}, +}: { + start: string; + end: string; + fullReport: boolean; + options: { sort?: Record; offset?: number; count?: number }; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatAgentActivity.findAvailableServiceTimeHistory({ + start, + end, + fullReport, + onlyCount: true, + }).toArray(); + return { + agents: await LivechatAgentActivity.findAvailableServiceTimeHistory({ + start, + end, + fullReport, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; diff --git a/apps/meteor/server/lib/omnichannel/analytics/dashboards.ts b/apps/meteor/server/lib/omnichannel/analytics/dashboards.ts new file mode 100644 index 0000000..942f445 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/analytics/dashboards.ts @@ -0,0 +1,395 @@ +import { OmnichannelAnalytics } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { LivechatRooms, Users, LivechatVisitors, LivechatAgentActivity } from '@rocket.chat/models'; +import mem from 'mem'; +import moment from 'moment'; + +import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS'; +import { settings } from '../../../settings'; +import { getAnalyticsOverviewDataCachedForRealtime } from '../AnalyticsTyped'; +import { + findPercentageOfAbandonedRoomsAsync, + findAllAverageOfChatDurationTimeAsync, + findAllAverageWaitingTimeAsync, + findAllNumberOfAbandonedRoomsAsync, + findAllAverageServiceTimeAsync, +} from './departments'; + +const findAllChatsStatusAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + return { + open: await LivechatRooms.countAllOpenChatsBetweenDate({ start, end, departmentId }), + closed: await LivechatRooms.countAllClosedChatsBetweenDate({ start, end, departmentId }), + queued: await LivechatRooms.countAllQueuedChatsBetweenDate({ start, end, departmentId }), + onhold: await LivechatRooms.getOnHoldConversationsBetweenDate(start, end, departmentId), + }; +}; + +const getProductivityMetricsAsync = async ({ + start, + end, + departmentId = undefined, + user, +}: { + start: string; + end: string; + departmentId?: string; + user: Pick; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const totalizers = + (await OmnichannelAnalytics.getAnalyticsOverviewData({ + daterange: { + from: start, + to: end, + }, + analyticsOptions: { + name: 'Productivity', + }, + departmentId, + utcOffset: user?.utcOffset, + language: user?.language || settings.get('Language') || 'en', + executedBy: user._id, + })) || []; + const averageWaitingTime = await findAllAverageWaitingTimeAsync({ + start: new Date(start), + end: new Date(end), + departmentId, + }); + + const totalOfWaitingTime = averageWaitingTime.departments.length; + + const sumOfWaitingTime = averageWaitingTime.departments.reduce((acc: number, serviceTime: { averageWaitingTimeInSeconds: number }) => { + acc += serviceTime.averageWaitingTimeInSeconds; + return acc; + }, 0); + const totalOfAvarageWaitingTime = totalOfWaitingTime === 0 ? 0 : sumOfWaitingTime / totalOfWaitingTime; + + return { + totalizers: [...totalizers, { title: 'Avg_of_waiting_time', value: secondsToHHMMSS(totalOfAvarageWaitingTime) }], + }; +}; + +const getAgentsProductivityMetricsAsync = async ({ + start, + end, + departmentId = undefined, + user, +}: { + start: string; + end: string; + departmentId?: string; + user: Pick; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + // TODO: check type of date + const averageOfAvailableServiceTime = ( + await LivechatAgentActivity.findAllAverageAvailableServiceTime({ + date: parseInt(moment(start).format('YYYYMMDD')) as any, + departmentId, + }) + )[0]; + const averageOfServiceTime = await findAllAverageServiceTimeAsync({ + start: new Date(start), + end: new Date(end), + departmentId, + }); + const totalizers = + (await OmnichannelAnalytics.getAnalyticsOverviewData({ + daterange: { + from: start, + to: end, + }, + analyticsOptions: { + name: 'Conversations', + }, + departmentId, + utcOffset: user.utcOffset, + language: user.language || settings.get('Language') || 'en', + executedBy: user._id, + })) || []; + + const totalOfServiceTime = averageOfServiceTime.departments.length; + + const sumOfServiceTime = averageOfServiceTime.departments.reduce( + ( + acc: number, + serviceTime: { + averageServiceTimeInSeconds: number; + }, + ) => { + acc += serviceTime.averageServiceTimeInSeconds; + return acc; + }, + 0, + ); + const totalOfAverageAvailableServiceTime = averageOfAvailableServiceTime + ? averageOfAvailableServiceTime.averageAvailableServiceTimeInSeconds + : 0; + const totalOfAverageServiceTime = totalOfServiceTime === 0 ? 0 : sumOfServiceTime / totalOfServiceTime; + + return { + totalizers: [ + ...totalizers.filter((metric: { title: string }) => metric.title === 'Busiest_time'), + { + title: 'Avg_of_available_service_time', + value: secondsToHHMMSS(totalOfAverageAvailableServiceTime), + }, + { title: 'Avg_of_service_time', value: secondsToHHMMSS(totalOfAverageServiceTime) }, + ], + }; +}; + +const getChatsMetricsAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const abandonedRooms = await findAllNumberOfAbandonedRoomsAsync({ + start, + end, + departmentId, + }); + const averageOfAbandonedRooms = await findPercentageOfAbandonedRoomsAsync({ + start, + end, + departmentId, + }); + const averageOfChatDurationTime = await findAllAverageOfChatDurationTimeAsync({ + start, + end, + departmentId, + }); + + const totalOfAbandonedRooms = averageOfAbandonedRooms.departments.length; + const totalOfChatDurationTime = averageOfChatDurationTime.departments.length; + + const sumOfPercentageOfAbandonedRooms = averageOfAbandonedRooms.departments.reduce( + ( + acc: number, + abandonedRoom: { + percentageOfAbandonedChats: number; + }, + ) => { + acc += abandonedRoom.percentageOfAbandonedChats; + return acc; + }, + 0, + ); + const sumOfChatDurationTime = averageOfChatDurationTime.departments.reduce( + ( + acc: number, + chatDurationTime: { + averageChatDurationTimeInSeconds: number; + }, + ) => { + acc += chatDurationTime.averageChatDurationTimeInSeconds; + return acc; + }, + 0, + ); + const totalAbandonedRooms = abandonedRooms.departments.reduce( + ( + acc: number, + item: { + abandonedRooms: number; + }, + ) => { + acc += item.abandonedRooms; + return acc; + }, + 0, + ); + + const totalOfAverageAbandonedRooms = totalOfAbandonedRooms === 0 ? 0 : sumOfPercentageOfAbandonedRooms / totalOfAbandonedRooms; + const totalOfAverageChatDurationTime = totalOfChatDurationTime === 0 ? 0 : sumOfChatDurationTime / totalOfChatDurationTime; + + return { + totalizers: [ + { title: 'Total_abandoned_chats', value: totalAbandonedRooms }, + { title: 'Avg_of_abandoned_chats', value: `${totalOfAverageAbandonedRooms}%` }, + { + title: 'Avg_of_chat_duration_time', + value: secondsToHHMMSS(totalOfAverageChatDurationTime), + }, + ], + }; +}; + +const getConversationsMetricsAsync = async ({ + start, + end, + departmentId, + user, +}: { + start: string; + end: string; + departmentId?: string; + user: Pick; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const totalizers = + (await getAnalyticsOverviewDataCachedForRealtime({ + daterange: { + from: start, + to: end, + }, + analyticsOptions: { + name: 'Conversations', + }, + ...(departmentId && departmentId !== 'undefined' && { departmentId }), + utcOffset: user.utcOffset, + language: user.language || settings.get('Language') || 'en', + executedBy: user._id, + })) || []; + const metrics = ['Total_conversations', 'Open_conversations', 'On_Hold_conversations', 'Total_messages']; + const visitorsCount = await LivechatVisitors.countVisitorsBetweenDate({ + start: new Date(start), + end: new Date(end), + department: departmentId, + }); + return { + totalizers: [ + ...totalizers.filter((metric: { title: string }) => metrics.includes(metric.title)), + { title: 'Total_visitors', value: visitorsCount }, + ], + }; +}; + +const findAllChatMetricsByAgentAsync = async ({ + start, + end, + departmentId = undefined, +}: { + start: Date; + end: Date; + departmentId?: string; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const open = await LivechatRooms.countAllOpenChatsByAgentBetweenDate({ + start, + end, + departmentId, + }); + const closed = await LivechatRooms.countAllClosedChatsByAgentBetweenDate({ + start, + end, + departmentId, + }); + const onhold = await LivechatRooms.countAllOnHoldChatsByAgentBetweenDate({ + start, + end, + departmentId, + }); + + const result: Record = {}; + (open || []).forEach((agent: { chats: number; _id: string }) => { + result[agent._id] = { open: agent.chats, closed: 0, onhold: 0 }; + }); + (closed || []).forEach((agent: { _id: string; chats: number }) => { + result[agent._id] = { + open: result[agent._id] ? result[agent._id].open : 0, + closed: agent.chats, + }; + }); + + (onhold || []).forEach((agent: { _id: string; chats: number }) => { + result[agent._id] = { + ...result[agent._id], + onhold: agent.chats, + }; + }); + return result; +}; + +const findAllAgentsStatusAsync = async ({ departmentId = undefined }: { departmentId?: string }) => + (await Users.countAllAgentsStatus({ departmentId }))[0]; + +const findAllChatMetricsByDepartmentAsync = async ({ + start, + end, + departmentId = undefined, +}: { + start: Date; + end: Date; + departmentId?: string; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const open = await LivechatRooms.countAllOpenChatsByDepartmentBetweenDate({ + start, + end, + departmentId, + }); + const closed = await LivechatRooms.countAllClosedChatsByDepartmentBetweenDate({ + start, + end, + departmentId, + }); + const result: Record = {}; + (open || []).forEach((department: { name: string; chats: number }) => { + result[department.name] = { open: department.chats, closed: 0 }; + }); + (closed || []).forEach((department: { name: string; chats: number }) => { + result[department.name] = { + open: result[department.name] ? result[department.name].open : 0, + closed: department.chats, + }; + }); + return result; +}; + +const findAllResponseTimeMetricsAsync = async ({ + start, + end, + departmentId = undefined, +}: { + start: Date; + end: Date; + departmentId?: string; +}) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const responseTimes = (await LivechatRooms.calculateResponseTimingsBetweenDates({ start, end, departmentId }))[0]; + const reactionTimes = (await LivechatRooms.calculateReactionTimingsBetweenDates({ start, end, departmentId }))[0]; + const durationTimings = (await LivechatRooms.calculateDurationTimingsBetweenDates({ start, end, departmentId }))[0]; + + return { + response: { + avg: responseTimes ? responseTimes.avg : 0, + longest: responseTimes ? responseTimes.longest : 0, + }, + reaction: { + avg: reactionTimes ? reactionTimes.avg : 0, + longest: reactionTimes ? reactionTimes.longest : 0, + }, + chatDuration: { + avg: durationTimings ? durationTimings.avg : 0, + longest: durationTimings ? durationTimings.longest : 0, + }, + }; +}; + +export const getConversationsMetricsAsyncCached = mem(getConversationsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getAgentsProductivityMetricsAsyncCached = mem(getAgentsProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getChatsMetricsAsyncCached = mem(getChatsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getProductivityMetricsAsyncCached = mem(getProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatsStatusAsyncCached = mem(findAllChatsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatMetricsByAgentAsyncCached = mem(findAllChatMetricsByAgentAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllAgentsStatusAsyncCached = mem(findAllAgentsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatMetricsByDepartmentAsyncCached = mem(findAllChatMetricsByDepartmentAsync, { + maxAge: 5000, + cacheKey: JSON.stringify, +}); +export const findAllResponseTimeMetricsAsyncCached = mem(findAllResponseTimeMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); diff --git a/apps/meteor/server/lib/omnichannel/analytics/departments.ts b/apps/meteor/server/lib/omnichannel/analytics/departments.ts new file mode 100644 index 0000000..da670ae --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/analytics/departments.ts @@ -0,0 +1,192 @@ +import { LivechatRooms, Messages } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; + +type Params = { + start: Date; + end: Date; + options?: any; + departmentId?: string; +}; + +export const findAllRoomsAsync = async ({ start, end, answered, departmentId, options = {} }: Params & { answered?: boolean }) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllRooms({ + start, + answered, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await LivechatRooms.findAllRooms({ + start, + answered, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllAverageOfChatDurationTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllAverageOfChatDurationTime({ + start, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await LivechatRooms.findAllAverageOfChatDurationTime({ + start, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllAverageServiceTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllAverageOfServiceTime({ + start, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await LivechatRooms.findAllAverageOfServiceTime({ + start, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllServiceTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllServiceTime({ + start, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await LivechatRooms.findAllServiceTime({ + start, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllAverageWaitingTimeAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await LivechatRooms.findAllAverageWaitingTime({ + start, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await LivechatRooms.findAllAverageWaitingTime({ + start, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllNumberOfTransferredRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await Messages.findAllNumberOfTransferredRooms({ + start, + end, + departmentId, + onlyCount: true, + }).toArray(); + return { + departments: await Messages.findAllNumberOfTransferredRooms({ + start, + end, + departmentId, + options, + }).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findAllNumberOfAbandonedRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await ( + await LivechatRooms.findAllNumberOfAbandonedRooms({ + start, + end, + departmentId, + inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'), + onlyCount: true, + }) + ).toArray(); + return { + departments: await ( + await LivechatRooms.findAllNumberOfAbandonedRooms({ + start, + end, + departmentId, + inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'), + options, + }) + ).toArray(), + total: total.length ? total[0].total : 0, + }; +}; + +export const findPercentageOfAbandonedRoomsAsync = async ({ start, end, departmentId, options = {} }: Params) => { + if (!start || !end) { + throw new Error('"start" and "end" must be provided'); + } + const total = await ( + await LivechatRooms.findPercentageOfAbandonedRooms({ + start, + end, + departmentId, + inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'), + onlyCount: true, + }) + ).toArray(); + return { + departments: await ( + await LivechatRooms.findPercentageOfAbandonedRooms({ + start, + end, + departmentId, + inactivityTimeout: settings.get('Livechat_visitor_inactivity_timeout'), + options, + }) + ).toArray(), + total: total.length ? total[0].total : 0, + }; +}; diff --git a/apps/meteor/server/lib/omnichannel/business-hour/AbstractBusinessHour.ts b/apps/meteor/server/lib/omnichannel/business-hour/AbstractBusinessHour.ts new file mode 100644 index 0000000..10b4463 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/AbstractBusinessHour.ts @@ -0,0 +1,144 @@ +import type { AtLeast, ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings'; +import { LivechatBusinessHours, Users } from '@rocket.chat/models'; +import type { IWorkHoursCronJobsWrapper } from '@rocket.chat/models'; +import moment from 'moment-timezone'; +import type { UpdateFilter } from 'mongodb'; + +import { notifyOnUserChange } from '../../notifyListener'; + +export interface IBusinessHourBehavior { + findHoursToCreateJobs(): Promise; + openBusinessHoursByDayAndHour(day: string, hour: string): Promise; + closeBusinessHoursByDayAndHour(day: string, hour: string): Promise; + onDisableBusinessHours(): Promise; + onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise; + onRemoveAgentFromDepartment(options?: Record): Promise; + onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise; + onDepartmentDisabled(department?: AtLeast): Promise; + onDepartmentArchived(department: Pick): Promise; + onStartBusinessHours(): Promise; + afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise; + allowAgentChangeServiceStatus(agentId: string): Promise; + changeAgentActiveStatus(agentId: string, status: string): Promise; + // If a new agent is created, this callback will be called + onNewAgentCreated(agentId: string): Promise; +} + +export interface IBusinessHourType { + name: string; + getBusinessHour(id?: string): Promise; + saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise; + removeBusinessHourById(id: string): Promise; +} + +export abstract class AbstractBusinessHourBehavior { + protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours; + + protected UsersRepository: IUsersModel = Users; + + async findHoursToCreateJobs(): Promise { + return this.BusinessHourRepository.findHoursToScheduleJobs(); + } + + async onDisableBusinessHours(): Promise { + await this.UsersRepository.removeBusinessHoursFromAllUsers(); + } + + async allowAgentChangeServiceStatus(agentId: string): Promise { + return this.UsersRepository.isAgentWithinBusinessHours(agentId); + } + + async changeAgentActiveStatus(agentId: string, status: ILivechatAgentStatus): Promise { + const result = await this.UsersRepository.setLivechatStatusIf( + agentId, + status, + // Why this works: statusDefault is the property set when a user manually changes their status + // So if it's set to offline, we can be sure the user will be offline after login and we can skip the update + { livechatStatusSystemModified: true, statusDefault: { $ne: UserStatus.OFFLINE } }, + { livechatStatusSystemModified: true }, + ); + + if (result.modifiedCount > 0) { + void notifyOnUserChange({ + clientAction: 'updated', + id: agentId, + diff: { statusLivechat: 'available', livechatStatusSystemModified: true }, + }); + } + + return result; + } +} + +export abstract class AbstractBusinessHourType { + protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours; + + protected UsersRepository: IUsersModel = Users; + + protected async baseSaveBusinessHour(businessHourData: ILivechatBusinessHour): Promise { + businessHourData.active = Boolean(businessHourData.active); + businessHourData = this.convertWorkHours(businessHourData); + if (businessHourData._id) { + await this.BusinessHourRepository.updateOne({ _id: businessHourData._id }, { + $set: businessHourData, + } as UpdateFilter); // TODO: Remove this cast when TypeScript is updated + return businessHourData._id; + } + const { insertedId } = await this.BusinessHourRepository.insertOne(businessHourData); + return insertedId; + } + + private convertWorkHours(businessHourData: ILivechatBusinessHour): ILivechatBusinessHour { + businessHourData.workHours.forEach((hour: any) => { + const startUtc = moment.tz(`${hour.day}:${hour.start}`, 'dddd:HH:mm', businessHourData.timezone.name).utc(); + const finishUtc = moment.tz(`${hour.day}:${hour.finish}`, 'dddd:HH:mm', businessHourData.timezone.name).utc(); + + if (hour.open && finishUtc.isBefore(startUtc)) { + throw new Error('error-business-hour-finish-time-before-start-time'); + } + + if (hour.open && startUtc.isSame(finishUtc)) { + throw new Error('error-business-hour-finish-time-equals-start-time'); + } + + hour.start = { + time: hour.start, + utc: { + dayOfWeek: startUtc.clone().format('dddd'), + time: startUtc.clone().format('HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'HH:mm'), + }, + }; + hour.finish = { + time: hour.finish, + utc: { + dayOfWeek: finishUtc.clone().format('dddd'), + time: finishUtc.clone().format('HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'HH:mm'), + }, + }; + }); + return businessHourData; + } + + protected getUTCFromTimezone(timezone?: string): string { + if (!timezone) { + return String(moment().utcOffset() / 60); + } + return moment.tz(timezone).format('Z'); + } + + private formatDayOfTheWeekFromServerTimezoneAndUtcHour(utc: any, format: string): string { + return moment(utc.format('dddd:HH:mm'), 'dddd:HH:mm') + .add(moment().utcOffset() / 60, 'hours') + .format(format); + } +} diff --git a/apps/meteor/server/lib/omnichannel/business-hour/BusinessHourManager.ts b/apps/meteor/server/lib/omnichannel/business-hour/BusinessHourManager.ts new file mode 100644 index 0000000..1fe462c --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/BusinessHourManager.ts @@ -0,0 +1,306 @@ +import type { ILivechatBusinessHour, IBusinessHourTimezone } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import type { AgendaCronJobs } from '@rocket.chat/cron'; +import { LivechatBusinessHours, LivechatDepartment, Users } from '@rocket.chat/models'; +import moment from 'moment-timezone'; + +import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; +import { closeBusinessHour } from './closeBusinessHour'; +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { notifyOnUserChange } from '../../notifyListener'; +import { businessHourLogger } from '../logger'; + +const CRON_EVERY_MIDNIGHT_EXPRESSION = '0 0 * * *'; +const CRON_DAYLIGHT_JOB_NAME = 'livechat-business-hour-daylight-saving-time-verifier'; + +export class BusinessHourManager { + private types: Map = new Map(); + + private behavior: IBusinessHourBehavior; + + private cronJobs: AgendaCronJobs; + + private cronJobsCache: string[] = []; + + constructor(cronJobs: AgendaCronJobs) { + this.cronJobs = cronJobs; + this.openWorkHoursCallback = this.openWorkHoursCallback.bind(this); + this.closeWorkHoursCallback = this.closeWorkHoursCallback.bind(this); + } + + async startManager(): Promise { + await this.createCronJobsForWorkHours(); + this.setupCallbacks(); + await this.cleanupDisabledDepartmentReferences(); + await this.behavior.onStartBusinessHours(); + void this.startDaylightSavingTimeVerifier(); + void this.registerDaylightSavingTimeCronJob(); + } + + async stopManager(): Promise { + await this.removeCronJobs(); + this.clearCronJobsCache(); + this.removeCallbacks(); + await this.behavior.onDisableBusinessHours(); + await this.cronJobs.remove(CRON_DAYLIGHT_JOB_NAME); + } + + async restartManager(): Promise { + await this.stopManager(); + await this.startManager(); + } + + async cleanupDisabledDepartmentReferences(): Promise { + // Get business hours with departments enabled and disabled + const bhWithDepartments = await LivechatDepartment.getBusinessHoursWithDepartmentStatuses(); + + if (!bhWithDepartments.length) { + // If there are no bh, skip + return; + } + + for await (const { _id: businessHourId, validDepartments, invalidDepartments } of bhWithDepartments) { + if (!invalidDepartments.length) { + continue; + } + + // If there are no enabled departments, close the business hour + const allDepsAreDisabled = validDepartments.length === 0 && invalidDepartments.length > 0; + if (allDepsAreDisabled) { + const businessHour = await this.getBusinessHour(businessHourId, LivechatBusinessHourTypes.CUSTOM); + if (!businessHour) { + continue; + } + await closeBusinessHour(businessHour); + } + + // Remove business hour from disabled departments + await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(invalidDepartments, businessHourId); + } + } + + async allowAgentChangeServiceStatus(agentId: string): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return true; + } + return this.behavior.allowAgentChangeServiceStatus(agentId); + } + + registerBusinessHourType(businessHourType: IBusinessHourType): void { + this.types.set(businessHourType.name, businessHourType); + } + + registerBusinessHourBehavior(behavior: IBusinessHourBehavior): void { + this.behavior = behavior; + } + + async getBusinessHour(id?: string, type?: string): Promise { + const businessHourType = this.getBusinessHourType((type as string) || LivechatBusinessHourTypes.DEFAULT); + if (!businessHourType) { + return null; + } + return businessHourType.getBusinessHour(id); + } + + async saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise { + const type = this.getBusinessHourType((businessHourData.type as string) || LivechatBusinessHourTypes.DEFAULT) as IBusinessHourType; + const saved = await type.saveBusinessHour(businessHourData); + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + await this.behavior.afterSaveBusinessHours(saved); + await this.createCronJobsForWorkHours(); + } + + async removeBusinessHourByIdAndType(id: string, type: string): Promise { + const businessHourType = this.getBusinessHourType(type) as IBusinessHourType; + await businessHourType.removeBusinessHourById(id); + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + await this.createCronJobsForWorkHours(); + } + + async onLogin(agentId: string): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return this.behavior.changeAgentActiveStatus(agentId, 'available'); + } + + const result = await Users.setLivechatStatusActiveBasedOnBusinessHours(agentId); + if (result.updatedCount > 0) { + void notifyOnUserChange({ clientAction: 'updated', id: agentId, diff: { statusLivechat: 'available ' } }); + } + + return result; + } + + async restartCronJobsIfNecessary(): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + + await this.createCronJobsForWorkHours(); + } + + private setupCallbacks(): void { + callbacks.add( + 'livechat.removeAgentDepartment', + this.behavior.onRemoveAgentFromDepartment.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-remove-agent-department', + ); + callbacks.add( + 'livechat.afterRemoveDepartment', + this.behavior.onRemoveDepartment.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-after-remove-department', + ); + callbacks.add( + 'livechat.saveAgentDepartment', + this.behavior.onAddAgentToDepartment.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-save-agent-department', + ); + callbacks.add( + 'livechat.afterDepartmentDisabled', + this.behavior.onDepartmentDisabled.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-department-disabled', + ); + callbacks.add( + 'livechat.afterDepartmentArchived', + this.behavior.onDepartmentArchived.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-department-archived', + ); + callbacks.add( + 'livechat.onNewAgentCreated', + this.behavior.onNewAgentCreated.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-agent-created', + ); + } + + private removeCallbacks(): void { + callbacks.remove('livechat.removeAgentDepartment', 'business-hour-livechat-on-remove-agent-department'); + callbacks.remove('livechat.afterRemoveDepartment', 'business-hour-livechat-after-remove-department'); + callbacks.remove('livechat.saveAgentDepartment', 'business-hour-livechat-on-save-agent-department'); + callbacks.remove('livechat.afterDepartmentDisabled', 'business-hour-livechat-on-department-disabled'); + callbacks.remove('livechat.afterDepartmentArchived', 'business-hour-livechat-on-department-archived'); + callbacks.remove('livechat.onNewAgentCreated', 'business-hour-livechat-on-agent-created'); + } + + private async createCronJobsForWorkHours(): Promise { + await this.removeCronJobs(); + this.clearCronJobsCache(); + const [workHours] = await this.behavior.findHoursToCreateJobs(); + if (!workHours) { + return; + } + + const { start, finish } = workHours; + + await Promise.all(start.map(({ day, times }) => this.scheduleCronJob(times, day, 'open', this.openWorkHoursCallback))); + await Promise.all(finish.map(({ day, times }) => this.scheduleCronJob(times, day, 'close', this.closeWorkHoursCallback))); + } + + private async scheduleCronJob( + items: string[], + day: string, + type: 'open' | 'close', + job: (day: string, hour: string) => void, + ): Promise { + await Promise.all( + items.map((hour) => { + const time = moment(hour, 'HH:mm').day(day); + const jobName = `${time.format('dddd')}/${time.format('HH:mm')}/${type}`; + const scheduleAt = `${time.minutes()} ${time.hours()} * * ${time.day()}`; + this.addToCache(jobName); + return this.cronJobs.add(jobName, scheduleAt, () => job(day, hour)); + }), + ); + } + + private async openWorkHoursCallback(day: string, hour: string): Promise { + try { + return await this.behavior.openBusinessHoursByDayAndHour(day, hour); + } catch (err) { + businessHourLogger.error({ msg: 'Error while opening business hours', err }); + throw err; + } + } + + private async closeWorkHoursCallback(day: string, hour: string): Promise { + try { + return await this.behavior.closeBusinessHoursByDayAndHour(day, hour); + } catch (err) { + businessHourLogger.error({ msg: 'Error while closing business hours', err }); + throw err; + } + } + + private getBusinessHourType(type: string): IBusinessHourType | undefined { + return this.types.get(type); + } + + private async removeCronJobs(): Promise { + await Promise.all(this.cronJobsCache.map((jobName) => this.cronJobs.remove(jobName))); + } + + private addToCache(jobName: string): void { + this.cronJobsCache.push(jobName); + } + + private clearCronJobsCache(): void { + this.cronJobsCache = []; + } + + hasDaylightSavingTimeChanged(timezone: IBusinessHourTimezone): boolean { + const now = moment().utc().tz(timezone.name); + const currentUTC = now.format('Z'); + const existingTimezoneUTC = moment(timezone.utc, 'Z').utc().tz(timezone.name); + const DSTHasChanged = !moment(currentUTC, 'Z').utc().tz(timezone.name).isSame(existingTimezoneUTC); + + return currentUTC !== timezone.utc && DSTHasChanged; + } + + async registerDaylightSavingTimeCronJob(): Promise { + await this.cronJobs.add(CRON_DAYLIGHT_JOB_NAME, CRON_EVERY_MIDNIGHT_EXPRESSION, this.startDaylightSavingTimeVerifier.bind(this)); + } + + async startDaylightSavingTimeVerifier(): Promise { + const activeBusinessHours = await LivechatBusinessHours.findActiveBusinessHours(); + const timezonesNeedingAdjustment = activeBusinessHours.filter( + ({ timezone }) => timezone && this.hasDaylightSavingTimeChanged(timezone), + ); + if (timezonesNeedingAdjustment.length === 0) { + return; + } + const result = await Promise.allSettled( + timezonesNeedingAdjustment.map((businessHour) => { + const businessHourType = this.getBusinessHourType(businessHour.type); + if (!businessHourType) { + return; + } + + return businessHourType.saveBusinessHour({ + ...businessHour, + timezoneName: businessHour.timezone.name, + workHours: businessHour.workHours.map((hour) => ({ ...hour, start: hour.start.time, finish: hour.finish.time })) as Record< + string, + any + >[], + } as ILivechatBusinessHour & { timezoneName: string }); + }), + ); + const failed = result.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + failed.forEach((error: any) => { + businessHourLogger.error({ msg: 'Failed to update business hours with new timezone', err: error }); + }); + } + + await this.createCronJobsForWorkHours(); + } +} diff --git a/apps/meteor/server/lib/omnichannel/business-hour/Default.ts b/apps/meteor/server/lib/omnichannel/business-hour/Default.ts new file mode 100644 index 0000000..e14567d --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/Default.ts @@ -0,0 +1,36 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import moment from 'moment-timezone'; + +import type { IBusinessHourType } from './AbstractBusinessHour'; +import { AbstractBusinessHourType } from './AbstractBusinessHour'; + +interface IExtraProperties { + timezoneName?: string; +} + +export class DefaultBusinessHour extends AbstractBusinessHourType implements IBusinessHourType { + name = LivechatBusinessHourTypes.DEFAULT; + + getBusinessHour(): Promise { + return this.BusinessHourRepository.findOneDefaultBusinessHour(); + } + + async saveBusinessHour(businessHour: ILivechatBusinessHour & IExtraProperties): Promise { + const { timezoneName, ...businessHourData } = businessHour; + + if (!businessHourData._id) { + return businessHourData; + } + businessHourData.timezone = { + name: timezoneName || moment.tz.guess(), + utc: this.getUTCFromTimezone(timezoneName), + }; + await this.baseSaveBusinessHour(businessHourData); + return businessHourData; + } + + removeBusinessHourById(): Promise { + return Promise.resolve(); + } +} diff --git a/apps/meteor/server/lib/omnichannel/business-hour/Helper.ts b/apps/meteor/server/lib/omnichannel/business-hour/Helper.ts new file mode 100644 index 0000000..237988f --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/Helper.ts @@ -0,0 +1,106 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import { LivechatBusinessHours, Users } from '@rocket.chat/models'; +import moment from 'moment'; + +import { createDefaultBusinessHourRow } from './LivechatBusinessHours'; +import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened'; +import { notifyOnUserChangeAsync } from '../../notifyListener'; +import { businessHourLogger } from '../logger'; + +export { filterBusinessHoursThatMustBeOpened }; + +export const filterBusinessHoursThatMustBeOpenedByDay = async ( + businessHours: Pick[], + day: string, // Format: moment.format('dddd') +): Promise[]> => { + return filterBusinessHoursThatMustBeOpened( + businessHours.filter((businessHour) => + businessHour.workHours.some((workHour) => workHour.start.utc.dayOfWeek === day || workHour.finish.utc.dayOfWeek === day), + ), + ); +}; + +export const openBusinessHourDefault = async (): Promise => { + try { + await Users.removeBusinessHoursFromAllUsers(); + const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm'); + const day = currentTime.format('dddd'); + const activeBusinessHours = await LivechatBusinessHours.findDefaultActiveAndOpenBusinessHoursByDay(day, { + projection: { + workHours: 1, + timezone: 1, + type: 1, + active: 1, + }, + }); + + const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id); + businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds }); + await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds); + if (businessHoursToOpenIds.length) { + await makeOnlineAgentsAvailable(); + } + await makeAgentsUnavailableBasedOnBusinessHour(); + } catch (err) { + businessHourLogger.error({ msg: 'Error while opening default business hours', err }); + } +}; + +export const createDefaultBusinessHourIfNotExists = async (): Promise => { + if ((await LivechatBusinessHours.countDocuments({ type: LivechatBusinessHourTypes.DEFAULT })) === 0) { + await LivechatBusinessHours.insertOne(createDefaultBusinessHourRow()); + } +}; + +export async function makeAgentsUnavailableBasedOnBusinessHour(agentIds?: string[]) { + const results = await Users.findAgentsAvailableWithoutBusinessHours(agentIds).toArray(); + + const update = await Users.updateLivechatStatusByAgentIds( + results.map(({ _id }) => _id), + ILivechatAgentStatus.NOT_AVAILABLE, + ); + + if (update.modifiedCount === 0) { + return; + } + + void notifyOnUserChangeAsync(async () => + results.map(({ _id, openBusinessHours }) => { + return { + id: _id, + clientAction: 'updated', + diff: { + statusLivechat: 'not-available', + openBusinessHours, + }, + }; + }), + ); +} + +export async function makeOnlineAgentsAvailable(agentIds?: string[]) { + const results = await Users.findOnlineButNotAvailableAgents(agentIds).toArray(); + + const update = await Users.updateLivechatStatusByAgentIds( + results.map(({ _id }) => _id), + ILivechatAgentStatus.AVAILABLE, + ); + + if (update.modifiedCount === 0) { + return; + } + + void notifyOnUserChangeAsync(async () => + results.map(({ _id, openBusinessHours }) => { + return { + id: _id, + clientAction: 'updated', + diff: { + statusLivechat: 'available', + openBusinessHours, + }, + }; + }), + ); +} diff --git a/apps/meteor/server/lib/omnichannel/business-hour/LivechatBusinessHours.ts b/apps/meteor/server/lib/omnichannel/business-hour/LivechatBusinessHours.ts new file mode 100644 index 0000000..188b427 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/LivechatBusinessHours.ts @@ -0,0 +1,47 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import moment from 'moment-timezone'; +import { ObjectId } from 'mongodb'; + +export const createDefaultBusinessHourRow = (): Omit => { + const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const closedDays = ['Saturday', 'Sunday']; + return { + _id: new ObjectId().toHexString(), + name: '', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + ts: new Date(), + workHours: days.map((day, index) => ({ + day, + start: { + time: '08:00', + utc: { + dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('dddd'), + time: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('HH:mm'), + }, + cron: { + dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').format('dddd'), + time: moment(`${day}:08:00`, 'dddd:HH:mm').format('HH:mm'), + }, + }, + finish: { + time: '20:00', + utc: { + dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('dddd'), + time: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('HH:mm'), + }, + cron: { + dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').format('dddd'), + time: moment(`${day}:20:00`, 'dddd:HH:mm').format('HH:mm'), + }, + }, + code: index + 1, + open: !closedDays.includes(day), + })), + timezone: { + name: moment.tz.guess(), + utc: String(moment().utcOffset() / 60), + }, + }; +}; diff --git a/apps/meteor/server/lib/omnichannel/business-hour/Single.ts b/apps/meteor/server/lib/omnichannel/business-hour/Single.ts new file mode 100644 index 0000000..64987c8 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/Single.ts @@ -0,0 +1,99 @@ +import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import { LivechatBusinessHours, Users } from '@rocket.chat/models'; + +import type { IBusinessHourBehavior } from './AbstractBusinessHour'; +import { AbstractBusinessHourBehavior } from './AbstractBusinessHour'; +import { filterBusinessHoursThatMustBeOpened, makeAgentsUnavailableBasedOnBusinessHour, openBusinessHourDefault } from './Helper'; +import { notifyOnUserChange } from '../../notifyListener'; +import { businessHourLogger } from '../logger'; + +export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior { + async openBusinessHoursByDayAndHour(): Promise { + await openBusinessHourDefault(); + } + + async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise { + const businessHoursIds = ( + await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, LivechatBusinessHourTypes.DEFAULT, { + projection: { _id: 1 }, + }) + ).map((businessHour) => businessHour._id); + await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds(businessHoursIds); + + await makeAgentsUnavailableBasedOnBusinessHour(); + } + + async onStartBusinessHours(): Promise { + await openBusinessHourDefault(); + } + + async onNewAgentCreated(agentId: string): Promise { + const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour(); + if (!defaultBusinessHour) { + businessHourLogger.debug({ + msg: 'No default business hour found for agentId', + agentId, + }); + return; + } + + const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]); + if (!businessHourToOpen.length) { + businessHourLogger.debug({ + msg: 'No business hours found. Moving agent to NOT_AVAILABLE status', + agentId, + newStatus: ILivechatAgentStatus.NOT_AVAILABLE, + }); + + const { modifiedCount } = await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE); + if (modifiedCount > 0) { + void notifyOnUserChange({ + id: agentId, + clientAction: 'updated', + diff: { + statusLivechat: ILivechatAgentStatus.NOT_AVAILABLE, + livechatStatusSystemModified: false, + }, + }); + } + + return; + } + + await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id); + + businessHourLogger.debug({ + msg: 'Business hours found. Moving agent to AVAILABLE status', + agentId, + newStatus: ILivechatAgentStatus.AVAILABLE, + }); + } + + afterSaveBusinessHours(): Promise { + return openBusinessHourDefault(); + } + + removeBusinessHourById(): Promise { + return Promise.resolve(); + } + + onAddAgentToDepartment(): Promise { + return Promise.resolve(); + } + + onRemoveAgentFromDepartment(): Promise { + return Promise.resolve(); + } + + onRemoveDepartment(): Promise { + return Promise.resolve(); + } + + onDepartmentDisabled(): Promise { + return Promise.resolve(); + } + + onDepartmentArchived(): Promise { + return Promise.resolve(); + } +} diff --git a/apps/meteor/server/lib/omnichannel/business-hour/closeBusinessHour.ts b/apps/meteor/server/lib/omnichannel/business-hour/closeBusinessHour.ts new file mode 100644 index 0000000..885e237 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/closeBusinessHour.ts @@ -0,0 +1,27 @@ +import type { ILivechatBusinessHour, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +import { makeAgentsUnavailableBasedOnBusinessHour } from './Helper'; +import { getAgentIdsForBusinessHour } from './getAgentIdsForBusinessHour'; +import { businessHourLogger } from '../logger'; + +export const closeBusinessHourByAgentIds = async ( + businessHourId: ILivechatBusinessHour['_id'], + agentIds: IUser['_id'][], +): Promise => { + businessHourLogger.debug({ + msg: 'Closing business hour', + businessHour: businessHourId, + totalAgents: agentIds.length, + top10AgentIds: agentIds.slice(0, 10), + }); + await Users.removeBusinessHourByAgentIds(agentIds, businessHourId); + + await makeAgentsUnavailableBasedOnBusinessHour(); +}; + +export const closeBusinessHour = makeFunction(async (businessHour: Pick): Promise => { + const agentIds = await getAgentIdsForBusinessHour(); + return closeBusinessHourByAgentIds(businessHour._id, agentIds); +}); diff --git a/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts b/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts new file mode 100644 index 0000000..0ca9924 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts @@ -0,0 +1,541 @@ +import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; + +import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened'; + +describe('different timezones between server and business hours saturday ', () => { + beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2024-04-20T20:10:11Z'))); + afterEach(() => jest.useRealTimers()); + it('should return a bh when the finish time resolves to a different day on server', async () => { + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '65c40fa9052d6750ae25df83', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Saturday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Saturday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Sunday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Sunday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + ], + }, + ]); + + expect(bh.length).toEqual(1); + }); +}); + +describe('different timezones between server and business hours sunday ', () => { + beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-07-27T11:02:11Z'))); + afterEach(() => jest.useRealTimers()); + it('should return a bh when the finish time resolves to a different day on server', async () => { + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '68516f256ebb4bdceda2757e', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Saturday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Saturday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Sunday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Sunday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Monday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Sunday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Sunday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Monday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Monday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Tuesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Monday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Monday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Tuesday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Wednesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Tuesday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Wednesday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Thursday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Wednesday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Thursday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Thursday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Friday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Thursday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Thursday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Friday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Friday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + { + day: 'Saturday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Friday', + time: '18:30', + }, + cron: { + dayOfWeek: 'Friday', + time: '15:30', + }, + }, + finish: { + time: '23:59', + utc: { + dayOfWeek: 'Saturday', + time: '18:29', + }, + cron: { + dayOfWeek: 'Saturday', + time: '15:29', + }, + }, + open: true, + code: '', + }, + ], + }, + ]); + + expect(bh.length).toEqual(1); + }); +}); + +describe('regular business hours', () => { + beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-08-07T22:02:11Z'))); + afterEach(() => jest.useRealTimers()); + it('should return a bh when the finish time resolves to a different day on server', async () => { + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '68516f256ebb4bdceda2757e', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Sunday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Sunday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Sunday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Sunday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Monday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Monday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Monday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Monday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Monday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Tuesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Tuesday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Tuesday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Tuesday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Wednesday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Wednesday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Wednesday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Wednesday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Thursday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Thursday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Thursday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Thursday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Thursday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Friday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Friday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Friday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Friday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Friday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + { + day: 'Saturday', + start: { + time: '00:00', + utc: { + dayOfWeek: 'Saturday', + time: '03:00', + }, + cron: { + dayOfWeek: 'Saturday', + time: '00:00', + }, + }, + finish: { + time: '00:01', + utc: { + dayOfWeek: 'Saturday', + time: '03:01', + }, + cron: { + dayOfWeek: 'Saturday', + time: '00:01', + }, + }, + open: true, + code: '', + }, + ], + }, + ]); + + expect(bh.length).toEqual(0); + }); +}); + +describe('finish time boundary', () => { + afterEach(() => jest.useRealTimers()); + + // 00:00-23:59 in Asia/Kolkata, which on a UTC server runs from Saturday 18:30 to Sunday 18:29 + const alwaysOpenSunday = [ + { + _id: '68516f256ebb4bdceda2757e', + name: '', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + ts: new Date(), + workHours: [ + { + day: 'Sunday', + start: { + time: '00:00', + utc: { dayOfWeek: 'Saturday', time: '18:30' }, + cron: { dayOfWeek: 'Saturday', time: '18:30' }, + }, + finish: { + time: '23:59', + utc: { dayOfWeek: 'Sunday', time: '18:29' }, + cron: { dayOfWeek: 'Sunday', time: '18:29' }, + }, + open: true, + code: '', + }, + ], + timezone: { + name: 'Asia/Kolkata', + utc: '+05:30', + }, + }, + ]; + + it('should keep the business hour open during its finish minute', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:29:30Z')); + + expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(1); + }); + + it('should close the business hour once the finish minute has elapsed', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:30:30Z')); + + expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(0); + }); + + it('should keep a same-day business hour open during its finish minute', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-07-28T20:00:30Z')); + + const bh = await filterBusinessHoursThatMustBeOpened([ + { + _id: '68516f256ebb4bdceda2757f', + active: true, + type: LivechatBusinessHourTypes.DEFAULT, + workHours: [ + { + day: 'Monday', + start: { + time: '08:00', + utc: { dayOfWeek: 'Monday', time: '08:00' }, + cron: { dayOfWeek: 'Monday', time: '08:00' }, + }, + finish: { + time: '20:00', + utc: { dayOfWeek: 'Monday', time: '20:00' }, + cron: { dayOfWeek: 'Monday', time: '20:00' }, + }, + open: true, + code: '', + }, + ], + }, + ]); + + expect(bh).toHaveLength(1); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.ts b/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.ts new file mode 100644 index 0000000..b6dafe4 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/filterBusinessHoursThatMustBeOpened.ts @@ -0,0 +1,58 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import moment from 'moment'; + +export const filterBusinessHoursThatMustBeOpened = async ( + businessHours: Pick[], +): Promise[]> => { + const currentTime = moment(moment().format('dddd:HH:mm:ss'), 'dddd:HH:mm:ss'); + + return businessHours + .filter( + (businessHour) => + businessHour.active && + businessHour.workHours + .filter((hour) => hour.open) + .some((hour) => { + const localTimeStart = moment(`${hour.start.cron.dayOfWeek}:${hour.start.cron.time}:00`, 'dddd:HH:mm:ss'); + const localTimeFinish = moment(`${hour.finish.cron.dayOfWeek}:${hour.finish.cron.time}:00`, 'dddd:HH:mm:ss'); + + /** because we use `dayOfWeek` moment decides if saturday/sunday belongs to the current week or the next one, this is a bit + * confusing and for that reason we need this workaround + */ + + const currentDay = currentTime.format('dddd'); + const localTimeStartDay = localTimeStart.format('dddd'); + + // This only works for sundays (where we can test if sunday is before saturday = something is wrong) + + if (localTimeStart.isAfter(localTimeFinish)) { + localTimeStart.subtract(1, 'week'); + } + if (localTimeFinish.isBefore(localTimeStart)) { + localTimeFinish.add(1, 'week'); + } + + // During Saturday, if current weekday is the same but the start time is after the current time, we need to subtract a week + if (currentDay === localTimeStartDay && localTimeStart.diff(currentTime, 'days') > 0) { + localTimeStart.subtract(1, 'week'); + } + + // During Saturday, if current weekday is the same but the finish time is before the current time, we need to add a week + if (currentDay === localTimeStartDay && localTimeFinish.diff(currentTime, 'days') < 0) { + localTimeFinish.add(1, 'week'); + } + + // Work hours are configured at minute granularity, so a window stays open until the end of its + // `finish` minute. Treating `finish` as exclusive leaves consecutive daily windows disjoint: + // a 00:00-23:59 business hour (the only way the UI can express "always open") would close + // for the whole 23:59 minute, every day. + const afterFinishMinute = localTimeFinish.clone().add(1, 'minute'); + + return currentTime.isSameOrAfter(localTimeStart) && currentTime.isBefore(afterFinishMinute); + }), + ) + .map((businessHour) => ({ + _id: businessHour._id, + type: businessHour.type, + })); +}; diff --git a/apps/meteor/server/lib/omnichannel/business-hour/getAgentIdsForBusinessHour.ts b/apps/meteor/server/lib/omnichannel/business-hour/getAgentIdsForBusinessHour.ts new file mode 100644 index 0000000..849975f --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/getAgentIdsForBusinessHour.ts @@ -0,0 +1,45 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models'; + +const getAllAgentIdsWithoutDepartment = async (): Promise => { + // Fetch departments with agents excluding archived ones (disabled ones still can be tied to business hours) + // Then find the agents that are not in any of those departments + + const departmentIds = (await LivechatDepartment.findNotArchived({ projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); + + const agentIdsWithDepartment = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(departmentIds); + + const agentIdsWithoutDepartment = ( + await Users.findUsersInRolesWithQuery( + 'livechat-agent', + { + _id: { $nin: agentIdsWithDepartment }, + }, + { projection: { _id: 1 } }, + ).toArray() + ).map((user) => user._id); + + return agentIdsWithoutDepartment; +}; + +const getAllAgentIdsWithDepartmentNotConnectedToBusinessHour = async (): Promise => { + const activeDepartmentsWithoutBusinessHour = ( + await LivechatDepartment.findActiveDepartmentsWithoutBusinessHour({ + projection: { _id: 1 }, + }).toArray() + ).map((dept) => dept._id); + + const agentIdsWithDepartmentNotConnectedToBusinessHour = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments( + activeDepartmentsWithoutBusinessHour, + ); + return agentIdsWithDepartmentNotConnectedToBusinessHour; +}; + +export const getAgentIdsForBusinessHour = async (): Promise => { + const [withoutDepartment, withDepartmentNotConnectedToBusinessHour] = await Promise.all([ + getAllAgentIdsWithoutDepartment(), + getAllAgentIdsWithDepartmentNotConnectedToBusinessHour(), + ]); + + return [...new Set([...withoutDepartment, ...withDepartmentNotConnectedToBusinessHour])]; +}; diff --git a/apps/meteor/server/lib/omnichannel/business-hour/index.ts b/apps/meteor/server/lib/omnichannel/business-hour/index.ts new file mode 100644 index 0000000..13fc618 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/business-hour/index.ts @@ -0,0 +1,23 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { BusinessHourManager } from './BusinessHourManager'; +import { DefaultBusinessHour } from './Default'; +import { SingleBusinessHourBehavior } from './Single'; +import { callbacks } from '../../callbacks'; + +export const businessHourManager = new BusinessHourManager(cronJobs); + +Meteor.startup(async () => { + const { BusinessHourBehaviorClass } = await callbacks.run('on-business-hour-start', { + BusinessHourBehaviorClass: SingleBusinessHourBehavior, + }); + businessHourManager.registerBusinessHourBehavior(new BusinessHourBehaviorClass()); + businessHourManager.registerBusinessHourType(new DefaultBusinessHour()); + + Accounts.onLogin(({ user }: { user: IUser }) => { + void (user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && businessHourManager.onLogin(user._id)); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/closeLivechatRoom.ts b/apps/meteor/server/lib/omnichannel/closeLivechatRoom.ts new file mode 100644 index 0000000..b79dad4 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/closeLivechatRoom.ts @@ -0,0 +1,87 @@ +import type { IUser, IRoom, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; + +import { closeRoom } from './closeRoom'; +import type { CloseRoomParams } from './localTypes'; +import { hasPermissionAsync } from '../authorization/hasPermission'; + +export const closeLivechatRoom = async ( + user: IUser, + roomId: IRoom['_id'], + { + comment, + tags, + generateTranscriptPdf, + transcriptEmail, + forceClose = false, + }: { + comment?: string; + tags?: string[]; + generateTranscriptPdf?: boolean; + transcriptEmail?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: Pick, 'email' | 'subject'>; + }; + forceClose?: boolean; + }, +): Promise => { + const room = await LivechatRooms.findOneById(roomId); + if (!room) { + throw new Error('error-invalid-room'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { projection: { _id: 1 } }); + if (!subscription && !(await hasPermissionAsync(user, 'close-others-livechat-room'))) { + throw new Error('error-not-authorized'); + } + + const options: CloseRoomParams['options'] = { + clientAction: true, + tags, + ...(generateTranscriptPdf && { pdfTranscript: { requestedBy: user._id } }), + ...(transcriptEmail && { + ...(transcriptEmail.sendToVisitor + ? { + emailTranscript: { + sendToVisitor: true, + requestData: { + email: transcriptEmail.requestData.email, + subject: transcriptEmail.requestData.subject, + requestedAt: new Date(), + requestedBy: user, + }, + }, + } + : { + emailTranscript: { + sendToVisitor: false, + }, + }), + }), + }; + + if (forceClose) { + return closeRoom({ + room, + user, + options, + comment, + forceClose, + }); + } + + if (!room.open) { + throw new Error('error-room-already-closed'); + } + + return closeRoom({ + room, + user, + options, + comment, + }); +}; diff --git a/apps/meteor/server/lib/omnichannel/closeOmnichannelConversations.ts b/apps/meteor/server/lib/omnichannel/closeOmnichannelConversations.ts new file mode 100644 index 0000000..93dd2a8 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/closeOmnichannelConversations.ts @@ -0,0 +1,34 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { closeRoom } from './closeRoom'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { i18n } from '../i18n'; + +type SubscribedRooms = { + rid: string; + t: string; +}; + +export const closeOmnichannelConversations = async ( + user: IUser, + subscribedRooms: SubscribedRooms[], + executedBy?: string, +): Promise => { + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy }); + const roomsInfo = LivechatRooms.findByIds( + subscribedRooms.map(({ rid }) => rid), + {}, + extraQuery, + ); + const language = settings.get('Language') || 'en'; + const comment = i18n.t('Agent_deactivated', { lng: language }); + + const promises: Promise[] = []; + await roomsInfo.forEach((room) => { + promises.push(closeRoom({ user, room, comment })); + }); + + await Promise.all(promises); +}; diff --git a/apps/meteor/server/lib/omnichannel/closeRoom.ts b/apps/meteor/server/lib/omnichannel/closeRoom.ts new file mode 100644 index 0000000..2b8a845 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/closeRoom.ts @@ -0,0 +1,297 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { Message } from '@rocket.chat/core-services'; +import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; +import type { ClientSession } from 'mongodb'; + +import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; +import { livechatLogger as logger } from './logger'; +import { parseTranscriptRequest } from './parseTranscriptRequest'; +import { client, shouldRetryTransaction } from '../../database/utils'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { + notifyOnLivechatInquiryChanged, + notifyOnRoomChanged, + notifyOnRoomChangedById, + notifyOnSubscriptionChanged, +} from '../notifyListener'; + +type ChatCloser = { _id: string; username: string | undefined }; + +const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser => + (params as CloseRoomParamsByUser).user !== undefined; +const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor => + (params as CloseRoomParamsByVisitor).visitor !== undefined; + +export async function closeRoom(params: CloseRoomParams, attempts = 2): Promise { + let newRoom: IOmnichannelRoom; + let chatCloser: ChatCloser; + let removedInquiryObj: ILivechatInquiryRecord | null; + + const session = client.startSession(); + try { + session.startTransaction(); + const { room, closedBy, removedInquiry } = await doCloseRoom(params, session); + await session.commitTransaction(); + + newRoom = room; + chatCloser = closedBy; + removedInquiryObj = removedInquiry; + } catch (e) { + logger.error({ err: e, msg: 'Failed to close room', afterAttempts: attempts }); + if (session.inTransaction()) { + await session.abortTransaction(); + } + // Dont propagate transaction errors + if (shouldRetryTransaction(e)) { + if (attempts > 0) { + logger.debug({ msg: 'Retrying close room because of transient error', attemptsLeft: attempts }); + return closeRoom(params, attempts - 1); + } + + throw new Error('error-room-cannot-be-closed-try-again'); + } + throw e; + } finally { + await session.endSession(); + } + + // Note: when reaching this point, the room has been closed + // Transaction is commited and so these messages can be sent here. + return afterRoomClosed(newRoom, chatCloser, removedInquiryObj, params); +} + +async function afterRoomClosed( + newRoom: IOmnichannelRoom, + chatCloser: ChatCloser, + inquiry: ILivechatInquiryRecord | null, + params: CloseRoomParams, +): Promise { + if (!chatCloser) { + // this should never happen + return; + } + // Note: we are okay with these messages being sent outside of the transaction. The process of sending a message + // is huge and involves multiple db calls. Making it transactionable this way would be really hard. + // And passing just _some_ actions to the transaction creates some deadlocks since messages are updated in the afterSaveMessages callbacks. + const transcriptRequested = + !!params.room.transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); + logger.debug({ msg: 'Sending closing message to room', roomId: newRoom._id }); + await Message.saveSystemMessageAndNotifyUser('livechat-close', newRoom._id, params.comment ?? '', chatCloser, { + groupable: false, + transcriptRequested, + ...(isRoomClosedByVisitorParams(params) && { token: params.visitor.token }), + }); + + if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { + await Message.saveSystemMessage('command', newRoom._id, 'promptTranscript', chatCloser); + } + + logger.debug({ msg: 'Running callbacks for room', roomId: newRoom._id }); + + process.nextTick(() => { + /** + * @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed + * in the next major version of the Apps-Engine + */ + void Apps.self?.triggerEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomClosed, newRoom); + }); + + const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; + const opts = await parseTranscriptRequest(params.room, params.options, visitor); + if (process.env.TEST_MODE) { + await callbacks.run('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } else { + callbacks.runAsync('livechat.closeRoom', { + room: newRoom, + options: opts, + }); + } + + void notifyOnRoomChangedById(newRoom._id); + if (inquiry) { + void notifyOnLivechatInquiryChanged(inquiry, 'removed'); + } + + logger.debug({ msg: 'Room was closed', roomId: newRoom._id }); +} + +async function doCloseRoom( + params: CloseRoomParams, + session: ClientSession, +): Promise<{ room: IOmnichannelRoom; closedBy: ChatCloser; removedInquiry: ILivechatInquiryRecord | null }> { + const { comment } = params; + const { room, forceClose } = params; + + logger.debug({ msg: 'Attempting to close room', roomId: room._id, forceClose }); + if (!room || !isOmnichannelRoom(room) || (!forceClose && !room.open)) { + logger.debug({ msg: 'Room is not open', roomId: room._id }); + throw new Error('error-room-closed'); + } + + const commentRequired = settings.get('Livechat_request_comment_when_closing_conversation'); + if (commentRequired && !comment?.trim()) { + throw new Error('error-comment-is-required'); + } + + const { updatedOptions: options } = await resolveChatTags(room, params.options); + logger.debug({ msg: 'Resolved chat tags for room', roomId: room._id }); + + const now = new Date(); + const { _id: rid, servedBy } = room; + const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000; + + const closeData: IOmnichannelRoomClosingInfo = { + closedAt: now, + chatDuration: (now.getTime() - new Date(room.ts).getTime()) / 1000, + ...(serviceTimeDuration && { serviceTimeDuration }), + ...options, + }; + logger.debug({ msg: 'Room was closed', roomId: room._id, closedAt: closeData.closedAt, chatDuration: closeData.chatDuration }); + + if (isRoomClosedByUserParams(params)) { + const { user } = params; + logger.debug({ msg: 'Closing by user', userId: user?._id }); + closeData.closer = 'user'; + closeData.closedBy = { + _id: user?._id || '', + username: user?.username, + }; + } else if (isRoomClosedByVisitorParams(params)) { + const { visitor } = params; + logger.debug({ msg: 'Closing by visitor', visitorId: params.visitor._id }); + closeData.closer = 'visitor'; + closeData.closedBy = { + _id: visitor._id, + username: visitor.username, + }; + } else { + throw new Error('Error: Please provide details of the user or visitor who closed the room'); + } + + logger.debug({ msg: 'Updating DB for room with close data', roomId: room._id }); + + const inquiry = await LivechatInquiry.findOneByRoomId(rid, { session }); + const removedInquiry = await LivechatInquiry.removeByRoomId(rid, { session }); + if (!params.forceClose && removedInquiry && removedInquiry.deletedCount !== 1) { + throw new Error('Error removing inquiry'); + } + if (removedInquiry.deletedCount) { + void notifyOnLivechatInquiryChanged(inquiry!, 'removed'); + } + + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData, { session }); + if (!params.forceClose && updatedRoom?.modifiedCount !== 1) { + throw new Error('Error closing room'); + } + + const subs = await Subscriptions.countByRoomId(rid, { session }); + if (subs) { + const removedSubs = await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + session, + }); + + if (!params.forceClose && removedSubs.deletedCount !== subs) { + throw new Error('Error removing subscriptions'); + } + } + + logger.debug({ msg: 'DB updated for room', roomId: room._id }); + + // Retrieve the closed room + const newRoom = await LivechatRooms.findOneById(rid, { session }); + if (!newRoom) { + throw new Error('Error: Room not found'); + } + + void notifyOnRoomChanged(newRoom, 'updated'); + return { room: newRoom, closedBy: closeData.closedBy, removedInquiry: inquiry }; +} + +async function resolveChatTags( + room: IOmnichannelRoom, + options: CloseRoomParams['options'] = {}, +): Promise<{ updatedOptions: CloseRoomParams['options'] }> { + logger.debug({ msg: 'Resolving chat tags for room', roomId: room._id }); + + const concatUnique = (...arrays: (string[] | undefined)[]): string[] => [ + ...new Set(([] as string[]).concat(...arrays.filter((a): a is string[] => !!a))), + ]; + + const { departmentId, tags: optionsTags } = room; + const { clientAction, tags: oldRoomTags } = options; + const roomTags = concatUnique(oldRoomTags, optionsTags); + + if (!departmentId) { + return { + updatedOptions: { + ...options, + ...(roomTags.length && { tags: roomTags }), + }, + }; + } + + const department = await LivechatDepartment.findOneById>( + departmentId, + { + projection: { requestTagBeforeClosingChat: 1, chatClosingTags: 1 }, + }, + ); + if (!department) { + return { + updatedOptions: { + ...options, + ...(roomTags.length && { tags: roomTags }), + }, + }; + } + + const { requestTagBeforeClosingChat, chatClosingTags } = department; + const extraRoomTags = concatUnique(roomTags, chatClosingTags); + + if (!requestTagBeforeClosingChat) { + return { + updatedOptions: { + ...options, + ...(extraRoomTags.length && { tags: extraRoomTags }), + }, + }; + } + + const checkRoomTags = !clientAction || (roomTags && roomTags.length > 0); + const checkDepartmentTags = chatClosingTags && chatClosingTags.length > 0; + if (!checkRoomTags || !checkDepartmentTags) { + throw new Error('error-tags-must-be-assigned-before-closing-chat'); + } + + return { + updatedOptions: { + ...options, + ...(extraRoomTags.length && { tags: extraRoomTags }), + }, + }; +} + +export async function closeOpenChats(userId: string, comment?: string) { + logger.debug({ msg: 'Closing open chats for user', userId }); + const user = await Users.findOneById(userId); + + const extraQuery = await applyDepartmentRestrictions({}, userId); + const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); + const promises: Promise[] = []; + await openChats.forEach((room) => { + promises.push(closeRoom({ user, room, comment })); + }); + + await Promise.all(promises); +} diff --git a/apps/meteor/server/lib/omnichannel/conditionalLockAgent.ts b/apps/meteor/server/lib/omnichannel/conditionalLockAgent.ts new file mode 100644 index 0000000..bcceb02 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/conditionalLockAgent.ts @@ -0,0 +1,36 @@ +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../settings'; +import { hasRoleAsync } from '../authorization/hasRole'; + +type LockResult = { + acquired: boolean; + required: boolean; + unlock: () => Promise; +}; + +export async function conditionalLockAgent(agentId: string): Promise { + // Lock and chats limits enforcement are only required when waiting_queue is enabled and the agent is not a bot + const shouldLock = settings.get('Livechat_waiting_queue'); + + if (!shouldLock || (await hasRoleAsync(agentId, 'bot'))) { + return { + acquired: false, + required: false, + unlock: async () => { + // no-op + }, + }; + } + + const lockTime = new Date(); + const lockAcquired = await Users.acquireAgentLock(agentId, lockTime); + + return { + acquired: !!lockAcquired, + required: true, + unlock: async () => { + await Users.releaseAgentLock(agentId, lockTime); + }, + }; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts b/apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts new file mode 100644 index 0000000..d533071 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts @@ -0,0 +1,336 @@ +import type { + ILivechatContact, + ILivechatVisitor, + ILivechatContactChannel, + ILivechatContactConflictingField, + IUser, + DeepWritable, + IOmnichannelSource, +} from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import type { ClientSession, UpdateFilter } from 'mongodb'; + +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; +import { isSameChannel } from '../../../../app/livechat/lib/isSameChannel'; + +type ManagerValue = { id: string } | { username: string }; +type ContactFields = { + email: string; + phone: string; + name: string; + username: string; + manager: ManagerValue; + channel: ILivechatContactChannel; + activity: string[]; +}; + +type CustomFieldAndValue = { type: `customFields.${string}`; value: string }; + +export type FieldAndValue = + | { type: keyof Omit; value: string } + | { type: 'manager'; value: ManagerValue } + | { type: 'channel'; value: ILivechatContactChannel } + | { type: 'activity'; value: string[] } + | CustomFieldAndValue; + +type ConflictHandlingMode = 'conflict' | 'overwrite' | 'ignore'; + +type MergeFieldsIntoContactParams = { + fields: FieldAndValue[]; + contact: ILivechatContact; + conflictHandlingMode?: ConflictHandlingMode; + session?: ClientSession; +}; + +export class ContactMerger { + private managerList = new Map['username'], IUser['_id'] | undefined>(); + + private getManagerId(manager: ManagerValue): IUser['_id'] | undefined { + if ('id' in manager) { + return manager.id; + } + + return this.managerList.get(manager.username); + } + + private isSameManager(manager1: ManagerValue, manager2: ManagerValue): boolean { + if ('id' in manager1 && 'id' in manager2) { + return manager1.id === manager2.id; + } + if ('username' in manager1 && 'username' in manager2) { + return manager1.username === manager2.username; + } + + const id1 = this.getManagerId(manager1); + const id2 = this.getManagerId(manager2); + + if (!id1 || !id2) { + return false; + } + + return id1 === id2; + } + + private isSameField(field1: FieldAndValue, field2: FieldAndValue): boolean { + if (field1.type === 'manager' && field2.type === 'manager') { + return this.isSameManager(field1.value, field2.value); + } + + if (field1.type === 'channel' && field2.type === 'channel') { + return isSameChannel(field1.value.visitor, field2.value.visitor); + } + + if (field1.type !== field2.type) { + return false; + } + + if (field1.value === field2.value) { + return true; + } + + return false; + } + + private async loadDataForFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise { + for (const fieldList of fieldLists) { + for (const field of fieldList) { + if (field.type !== 'manager' || 'id' in field.value) { + continue; + } + + if (!field.value.username) { + continue; + } + + if (this.managerList.has(field.value.username)) { + continue; + } + + const id = await getContactManagerIdByUsername(field.value.username, session); + this.managerList.set(field.value.username, id); + } + } + } + + static async createWithFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise { + const merger = new ContactMerger(); + await merger.loadDataForFields(session, ...fieldLists); + + return merger; + } + + static getAllFieldsFromContact(contact: ILivechatContact): FieldAndValue[] { + const { customFields = {}, name, contactManager, activity } = contact; + + const fields = new Set(); + + contact.emails?.forEach(({ address: value }) => fields.add({ type: 'email', value })); + contact.phones?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value })); + contact.channels.forEach((value) => fields.add({ type: 'channel', value })); + + if (name) { + fields.add({ type: 'name', value: name }); + } + + if (contactManager) { + fields.add({ type: 'manager', value: { id: contactManager } }); + } + + if (activity) { + fields.add({ type: 'activity', value: activity }); + } + + Object.keys(customFields).forEach((key) => + fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue), + ); + + // If the contact already has conflicts, load their values as well + if (contact.conflictingFields) { + for (const conflict of contact.conflictingFields) { + fields.add({ type: conflict.field, value: conflict.value } as FieldAndValue); + } + } + + return [...fields]; + } + + static async getAllFieldsFromVisitor(visitor: ILivechatVisitor, source?: IOmnichannelSource): Promise { + const { livechatData: customFields = {}, contactManager, name, username } = visitor; + + const fields = new Set(); + + visitor.visitorEmails?.forEach(({ address: value }) => fields.add({ type: 'email', value })); + visitor.phone?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value })); + if (name) { + fields.add({ type: 'name', value: name }); + } + if (username) { + fields.add({ type: 'username', value: username }); + } + if (contactManager?.username) { + fields.add({ type: 'manager', value: { username: contactManager?.username } }); + } + Object.keys(customFields).forEach((key) => + fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue), + ); + + if (source) { + fields.add({ + type: 'channel', + value: { + name: source.label || source.type.toString(), + visitor: { + visitorId: visitor._id, + source: { + type: source.type, + id: source.id, + }, + }, + blocked: false, + verified: false, + details: source, + }, + }); + } + + return [...fields]; + } + + static getFieldValuesByType(fields: FieldAndValue[], type: T): ContactFields[T][] { + return fields.filter((field) => field.type === type).map(({ value }) => value) as ContactFields[T][]; + } + + static async mergeFieldsIntoContact({ + fields, + contact, + conflictHandlingMode = 'conflict', + session, + }: MergeFieldsIntoContactParams): Promise { + const existingFields = ContactMerger.getAllFieldsFromContact(contact); + const overwriteData = conflictHandlingMode === 'overwrite'; + + const merger = await ContactMerger.createWithFields(session, fields, existingFields); + + const newFields = fields.filter((field) => { + // If the field already exists with the same value, ignore it + if (existingFields.some((existingField) => merger.isSameField(existingField, field))) { + return false; + } + + // If the field is an username and the contact already has a name, ignore it as well + if (field.type === 'username' && existingFields.some(({ type }) => type === 'name')) { + return false; + } + + return true; + }); + + const newPhones = ContactMerger.getFieldValuesByType(newFields, 'phone'); + const newEmails = ContactMerger.getFieldValuesByType(newFields, 'email'); + const newChannels = ContactMerger.getFieldValuesByType(newFields, 'channel'); + const newActivities = ContactMerger.getFieldValuesByType(newFields, 'activity'); + const newNamesOnly = ContactMerger.getFieldValuesByType(newFields, 'name'); + const newCustomFields = newFields.filter(({ type }) => type.startsWith('customFields.')) as CustomFieldAndValue[]; + // Usernames are ignored unless the contact has no other name + const newUsernames = !contact.name && !newNamesOnly.length ? ContactMerger.getFieldValuesByType(newFields, 'username') : []; + + const dataToSet: DeepWritable['$set']> = {}; + + // Names, Managers and Custom Fields need are set as conflicting fields if the contact already has them + const newNames = [...newNamesOnly, ...newUsernames]; + const newManagers = ContactMerger.getFieldValuesByType(newFields, 'manager') + .map((manager) => { + if ('id' in manager) { + return manager.id; + } + return merger.getManagerId(manager); + }) + .filter((id) => Boolean(id)); + + if (newNames.length && (!contact.name || overwriteData)) { + const firstName = newNames.shift(); + if (firstName) { + dataToSet.name = firstName; + } + } + + if (newManagers.length && (!contact.contactManager || overwriteData)) { + const firstManager = newManagers.shift(); + if (firstManager) { + dataToSet.contactManager = firstManager; + } + } + + if (newActivities.length) { + const newActivity = newActivities.shift(); + if (newActivity) { + const distinctActivities = new Set([...newActivity, ...(contact.activity || [])]); + const latestActivities = Array.from(distinctActivities).sort().slice(-12); + dataToSet.activity = latestActivities; + } + } + + const customFieldsPerName = new Map(); + for (const customField of newCustomFields) { + if (!customFieldsPerName.has(customField.type)) { + customFieldsPerName.set(customField.type, []); + } + customFieldsPerName.get(customField.type)?.push(customField); + } + + for (const [key, customFields] of customFieldsPerName) { + const fieldName = key.replace('customFields.', ''); + + // If the contact does not have this custom field yet, save the first value directly to the contact instead of as a conflict + if (!contact.customFields?.[fieldName] || overwriteData) { + const first = customFields.shift(); + if (first) { + dataToSet[key] = first.value; + } + } + } + + const allConflicts: ILivechatContactConflictingField[] = + conflictHandlingMode !== 'conflict' + ? [] + : [ + ...newNames.map((name): ILivechatContactConflictingField => ({ field: 'name', value: name })), + ...newManagers.map((manager): ILivechatContactConflictingField => ({ field: 'manager', value: manager as string })), + ]; + + // Phones, Emails and Channels are simply added to the contact's existing list + const dataToAdd: UpdateFilter['$addToSet'] = { + ...(newPhones.length ? { phones: { $each: newPhones.map((phoneNumber) => ({ phoneNumber })) } } : {}), + ...(newEmails.length ? { emails: { $each: newEmails.map((address) => ({ address })) } } : {}), + ...(newChannels.length ? { channels: { $each: newChannels } } : {}), + ...(allConflicts.length ? { conflictingFields: { $each: allConflicts } } : {}), + }; + + if (newChannels.length) { + dataToSet.preRegistration = false; + } + + const updateData: UpdateFilter = { + ...(Object.keys(dataToSet).length ? { $set: dataToSet } : {}), + ...(Object.keys(dataToAdd).length ? { $addToSet: dataToAdd } : {}), + }; + + if (Object.keys(updateData).length) { + await LivechatContacts.updateById(contact._id, updateData, { session }); + } + } + + public static async mergeVisitorIntoContact( + visitor: ILivechatVisitor, + contact: ILivechatContact, + source?: IOmnichannelSource, + ): Promise { + const fields = await ContactMerger.getAllFieldsFromVisitor(visitor, source); + + await ContactMerger.mergeFieldsIntoContact({ + fields, + contact, + conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', + }); + } +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/addContactEmail.ts b/apps/meteor/server/lib/omnichannel/contacts/addContactEmail.ts new file mode 100644 index 0000000..e41267f --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/addContactEmail.ts @@ -0,0 +1,20 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +/** + * Adds a new email into the contact's email list, if the email is already in the list it does not add anything + * and simply return the data, since the email was aready registered :P + * + * @param contactId the id of the contact that will be updated + * @param email the email that will be added to the contact + * @returns the updated contact + */ +export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { + const contact = await LivechatContacts.addEmail(contactId, email); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + return contact; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/createContact.ts b/apps/meteor/server/lib/omnichannel/contacts/createContact.ts new file mode 100644 index 0000000..44efef5 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/createContact.ts @@ -0,0 +1,52 @@ +import type { ILivechatContactChannel, IVisitorLastChat } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; + +export type CreateContactParams = { + name: string; + emails?: string[]; + phones?: string[]; + unknown: boolean; + customFields?: Record; + lastChat?: IVisitorLastChat; + contactManager?: string; + channels?: ILivechatContactChannel[]; + importIds?: string[]; + shouldValidateCustomFields?: boolean; +}; + +export async function createContact({ + name, + emails, + phones, + customFields: receivedCustomFields = {}, + lastChat, + contactManager, + channels = [], + unknown, + importIds, + shouldValidateCustomFields = true, +}: CreateContactParams): Promise { + if (contactManager) { + await validateContactManager(contactManager); + } + + const customFields = shouldValidateCustomFields + ? validateCustomFields(await getAllowedCustomFields(), receivedCustomFields) + : receivedCustomFields; + + return LivechatContacts.insertContact({ + name, + ...(emails && { emails: emails?.map((address) => ({ address })) }), + ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), + ...(contactManager && { contactManager }), + ...(channels && { channels }), + ...(customFields && { customFields }), + ...(lastChat && { lastChat }), + unknown, + ...(importIds?.length && { importIds }), + }); +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/createContactFromVisitor.ts b/apps/meteor/server/lib/omnichannel/contacts/createContactFromVisitor.ts new file mode 100644 index 0000000..c541399 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/createContactFromVisitor.ts @@ -0,0 +1,24 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { createContact } from './createContact'; +import { mapVisitorToContact } from './mapVisitorToContact'; + +export async function createContactFromVisitor(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { + const contactData = await mapVisitorToContact(visitor, source); + + const contactId = await createContact(contactData); + + await LivechatRooms.setContactByVisitorAssociation( + { + visitorId: visitor._id, + source: { type: source.type, ...(source.id ? { id: source.id } : {}) }, + }, + { + _id: contactId, + name: contactData.name, + }, + ); + + return contactId; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/disableContact.ts b/apps/meteor/server/lib/omnichannel/contacts/disableContact.ts new file mode 100644 index 0000000..d6441a1 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/disableContact.ts @@ -0,0 +1,23 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; +import { removeGuest } from '../guests'; + +export async function disableContactById(contactId: string): Promise { + const contact = await LivechatContacts.findOneEnabledById>(contactId); + if (!contact) { + throw new Error('error-contact-not-found'); + } + + // Checking if the contact has any open channel/room before removing its data. + const contactOpenRooms = await LivechatRooms.checkContactOpenRooms(contactId); + if (contactOpenRooms && !settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + throw new Error('error-contact-has-open-rooms'); + } + + // Cleaning contact/visitor data; + await Promise.all(contact.channels.map((channel) => removeGuest({ _id: channel.visitor.visitorId }))); + + await LivechatContacts.disableByContactId(contactId); +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/getAllowedCustomFields.ts b/apps/meteor/server/lib/omnichannel/contacts/getAllowedCustomFields.ts new file mode 100644 index 0000000..d71f902 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getAllowedCustomFields.ts @@ -0,0 +1,12 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { LivechatCustomField } from '@rocket.chat/models'; + +export async function getAllowedCustomFields(): Promise[]> { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/getContactChannelsGrouped.ts b/apps/meteor/server/lib/omnichannel/contacts/getContactChannelsGrouped.ts new file mode 100644 index 0000000..20e3f59 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getContactChannelsGrouped.ts @@ -0,0 +1,26 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export async function getContactChannelsGrouped(contactId: string): Promise { + const contact = await LivechatContacts.findOneEnabledById>(contactId, { projection: { channels: 1 } }); + + if (!contact?.channels) { + return []; + } + + const groupedChannels = new Map(); + + contact.channels.forEach((channel: ILivechatContactChannel) => { + const existingChannel = groupedChannels.get(channel.name); + + if (!existingChannel) { + return groupedChannels.set(channel.name, channel); + } + + if ((channel.lastChat?.ts?.valueOf() || 0) > (existingChannel?.lastChat?.ts?.valueOf() || 0)) { + groupedChannels.set(channel.name, channel); + } + }); + + return [...groupedChannels.values()]; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/getContactHistory.ts b/apps/meteor/server/lib/omnichannel/contacts/getContactHistory.ts new file mode 100644 index 0000000..5eee31d --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getContactHistory.ts @@ -0,0 +1,76 @@ +import type { ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { FindPaginated } from '@rocket.chat/model-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; +import type { FindOptions, Sort, FindCursor } from 'mongodb'; + +export type GetContactHistoryParams = { + contactId: string; + source?: string; + count: number; + offset: number; + sort: Sort; +}; + +export const fetchContactHistory = makeFunction( + async ({ + contactId, + options, + }: { + contactId: string; + options?: FindOptions; + extraParams?: Record; + }): Promise>> => + LivechatRooms.findClosedRoomsByContactPaginated({ + contactId, + options, + }), +); + +export const getContactHistory = makeFunction( + async (params: GetContactHistoryParams): Promise> => { + const { contactId, count, offset, sort } = params; + + const contact = await LivechatContacts.findOneEnabledById>(contactId, { projection: { _id: 1 } }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + const options: FindOptions = { + sort: sort || { closedAt: -1 }, + skip: offset, + limit: count, + projection: { + fname: 1, + ts: 1, + v: 1, + msgs: 1, + servedBy: 1, + closedAt: 1, + closedBy: 1, + closer: 1, + tags: 1, + source: 1, + lastMessage: 1, + verified: 1, + }, + }; + + const { totalCount, cursor } = await fetchContactHistory({ + contactId: contact._id, + options, + extraParams: params, + }); + + const [total, history] = await Promise.all([totalCount, cursor.toArray()]); + + return { + history, + count: history.length, + offset, + total, + }; + }, +); diff --git a/apps/meteor/server/lib/omnichannel/contacts/getContactIdByVisitor.ts b/apps/meteor/server/lib/omnichannel/contacts/getContactIdByVisitor.ts new file mode 100644 index 0000000..922b05a --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getContactIdByVisitor.ts @@ -0,0 +1,8 @@ +import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export async function getContactIdByVisitor(visitor: ILivechatContactVisitorAssociation): Promise { + const contact = await LivechatContacts.findOneByVisitor>(visitor, { projection: { _id: 1 } }); + + return contact?._id; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/getContactManagerIdByUsername.ts b/apps/meteor/server/lib/omnichannel/contacts/getContactManagerIdByUsername.ts new file mode 100644 index 0000000..c43776a --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getContactManagerIdByUsername.ts @@ -0,0 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +export async function getContactManagerIdByUsername( + username: Required['username'], + session?: ClientSession, +): Promise { + const user = await Users.findOneByUsername>(username, { projection: { _id: 1 }, session }); + + return user?._id; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/getContacts.ts b/apps/meteor/server/lib/omnichannel/contacts/getContacts.ts new file mode 100644 index 0000000..ad9a4cb --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/getContacts.ts @@ -0,0 +1,51 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isNotUndefined } from '@rocket.chat/core-typings'; +import { LivechatContacts, Users } from '@rocket.chat/models'; +import type { PaginatedResult, ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; +import type { FindCursor, Sort } from 'mongodb'; + +export type GetContactsParams = { + searchText?: string; + count: number; + offset: number; + sort: Sort; + unknown?: boolean; +}; + +export async function getContacts(params: GetContactsParams): Promise> { + const { searchText, count, offset, sort, unknown } = params; + + const { cursor, totalCount } = LivechatContacts.findPaginatedContacts( + { searchText, unknown }, + { + limit: count, + skip: offset, + sort: sort ?? { name: 1 }, + }, + ); + + const [rawContacts, total] = await Promise.all([cursor.toArray(), totalCount]); + + const managerIds = [...new Set(rawContacts.map(({ contactManager }) => contactManager))].filter(isNotUndefined); + const managersCursor: FindCursor<[string, Pick]> = Users.findByIds(managerIds, { + projection: { name: 1, username: 1 }, + }).map((manager) => [manager._id, manager]); + const managersData = await managersCursor.toArray(); + const mappedManagers = Object.fromEntries(managersData); + + const contacts: ILivechatContactWithManagerData[] = rawContacts.map((contact) => { + const { contactManager, ...data } = contact; + + return { + ...data, + ...(contactManager ? { contactManager: mappedManagers[contactManager] } : {}), + }; + }); + + return { + contacts, + count, + offset, + total, + }; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/isAgentAvailableToTakeContactInquiry.ts b/apps/meteor/server/lib/omnichannel/contacts/isAgentAvailableToTakeContactInquiry.ts new file mode 100644 index 0000000..a0daa07 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/isAgentAvailableToTakeContactInquiry.ts @@ -0,0 +1,12 @@ +import type { ILivechatContact, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const isAgentAvailableToTakeContactInquiry = makeFunction( + async ( + _visitorId: ILivechatVisitor['_id'], + _source: IOmnichannelSource, + _contactId: ILivechatContact['_id'], + ): Promise<{ error: string; value: false } | { value: true }> => ({ + value: true, + }), +); diff --git a/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.spec.ts new file mode 100644 index 0000000..a49d94a --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.spec.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +const { isVerifiedChannelInSource } = proxyquire.noCallThru().load('./isVerifiedChannelInSource', {}); + +describe('isVerifiedChannelInSource', () => { + it('should return false if channel is not verified', () => { + const channel = { + verified: false, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false; + }); + + it('should return false if channel visitorId is different from visitorId', () => { + const channel = { + verified: true, + visitor: { + visitorId: 'visitor2', + }, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false; + }); + + it('should return false if channel visitor source type is different from source type', () => { + const channel = { + verified: true, + visitor: { + visitorId: 'visitor1', + source: { + type: 'web', + }, + }, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false; + }); + + it('should return false if channel visitor source id is different from source id', () => { + const channel = { + verified: true, + visitor: { + visitorId: 'visitor1', + source: { + type: 'widget', + id: 'source1', + }, + }, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + id: 'source2', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false; + }); + + it('should return false if source id is not defined and channel visitor source id is defined', () => { + const channel = { + verified: true, + visitor: { + visitorId: 'visitor1', + source: { + type: 'widget', + id: 'source1', + }, + }, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false; + }); + + it('should return true if all conditions are met', () => { + const channel = { + verified: true, + visitor: { + visitorId: 'visitor1', + source: { + type: 'widget', + id: 'source1', + }, + }, + }; + const visitorId = 'visitor1'; + const source = { + type: 'widget', + id: 'source1', + }; + + expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.true; + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.ts b/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.ts new file mode 100644 index 0000000..14074e8 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/isVerifiedChannelInSource.ts @@ -0,0 +1,25 @@ +import type { ILivechatContactChannel, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; + +export const isVerifiedChannelInSource = ( + channel: ILivechatContactChannel, + visitorId: ILivechatVisitor['_id'], + source: IOmnichannelSource, +) => { + if (!channel.verified) { + return false; + } + + if (channel.visitor.visitorId !== visitorId) { + return false; + } + + if (channel.visitor.source.type !== source.type) { + return false; + } + + if ((source.id || channel.visitor.source.id) && channel.visitor.source.id !== source.id) { + return false; + } + + return true; +}; diff --git a/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.spec.ts new file mode 100644 index 0000000..07a10f6 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.spec.ts @@ -0,0 +1,214 @@ +import { OmnichannelSourceType, type ILivechatVisitor, type IOmnichannelSource } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { CreateContactParams } from './createContact'; + +const getContactManagerIdByUsername = sinon.stub(); +const getAllowedCustomFields = sinon.stub(); + +const { mapVisitorToContact } = proxyquire.noCallThru().load('./mapVisitorToContact', { + './getContactManagerIdByUsername': { + getContactManagerIdByUsername, + }, + './getAllowedCustomFields': { getAllowedCustomFields }, +}); + +const testDate = new Date(); +const dataMap: [Partial, IOmnichannelSource, CreateContactParams][] = [ + [ + { + _id: 'visitor1', + username: 'Username', + name: 'Name', + visitorEmails: [{ address: 'email1@domain.com' }, { address: 'email2@domain.com' }], + phone: [{ phoneNumber: '10' }, { phoneNumber: '20' }], + contactManager: { + username: 'user1', + }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + { + type: OmnichannelSourceType.WIDGET, + }, + { + name: 'Name', + emails: ['email1@domain.com', 'email2@domain.com'], + phones: ['10', '20'], + unknown: true, + channels: [ + { + name: 'widget', + visitor: { + visitorId: 'visitor1', + source: { + type: OmnichannelSourceType.WIDGET, + }, + }, + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.WIDGET, + }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + ], + lastChat: { _id: 'afdsfdasf', ts: testDate }, + customFields: undefined, + shouldValidateCustomFields: false, + contactManager: 'manager1', + }, + ], + + [ + { + _id: 'visitor1', + username: 'Username', + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + { + type: OmnichannelSourceType.SMS, + }, + { + name: 'Username', + emails: undefined, + phones: undefined, + unknown: true, + channels: [ + { + name: 'sms', + visitor: { + visitorId: 'visitor1', + source: { + type: OmnichannelSourceType.SMS, + }, + }, + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.SMS, + }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + ], + customFields: undefined, + shouldValidateCustomFields: false, + contactManager: undefined, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + ], + + [ + { + _id: 'visitor1', + username: 'Username', + activity: ['2024-11'], + lastChat: { + _id: 'last-chat-id', + ts: testDate, + }, + }, + { + type: OmnichannelSourceType.WIDGET, + }, + { + name: 'Username', + emails: undefined, + phones: undefined, + unknown: false, + channels: [ + { + name: 'widget', + visitor: { + visitorId: 'visitor1', + source: { + type: OmnichannelSourceType.WIDGET, + }, + }, + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.WIDGET, + }, + lastChat: { + _id: 'last-chat-id', + ts: testDate, + }, + }, + ], + customFields: undefined, + shouldValidateCustomFields: false, + lastChat: { + _id: 'last-chat-id', + ts: testDate, + }, + contactManager: undefined, + }, + ], + + [ + { + _id: 'visitor1', + username: 'Username', + livechatData: { + customFieldId: 'customFieldValue', + invalidCustomFieldId: 'invalidCustomFieldValue', + }, + activity: [], + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + { + type: OmnichannelSourceType.WIDGET, + }, + { + name: 'Username', + emails: undefined, + phones: undefined, + unknown: true, + channels: [ + { + name: 'widget', + visitor: { + visitorId: 'visitor1', + source: { + type: OmnichannelSourceType.WIDGET, + }, + }, + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.WIDGET, + }, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + ], + customFields: { + customFieldId: 'customFieldValue', + }, + shouldValidateCustomFields: false, + contactManager: undefined, + lastChat: { _id: 'afdsfdasf', ts: testDate }, + }, + ], +]; + +describe('mapVisitorToContact', () => { + beforeEach(() => { + getContactManagerIdByUsername.reset(); + getContactManagerIdByUsername.callsFake((username) => { + if (username === 'user1') { + return 'manager1'; + } + + return undefined; + }); + getAllowedCustomFields.resolves([{ _id: 'customFieldId', label: 'custom-field-label' }]); + }); + + dataMap.forEach(([visitor, source, contact], index) => { + it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => { + expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact); + }); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.ts b/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.ts new file mode 100644 index 0000000..6478fd1 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/mapVisitorToContact.ts @@ -0,0 +1,40 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; + +import type { CreateContactParams } from './createContact'; +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; +import { validateCustomFields } from './validateCustomFields'; + +export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { + return { + name: visitor.name || visitor.username, + emails: visitor.visitorEmails?.map(({ address }) => address), + phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), + unknown: !visitor.activity || visitor.activity.length === 0, + channels: [ + { + name: source.label || source.type.toString(), + visitor: { + visitorId: visitor._id, + source: { + type: source.type, + ...(source.id ? { id: source.id } : {}), + }, + }, + blocked: false, + verified: false, + details: source, + lastChat: visitor.lastChat, + }, + ], + customFields: + visitor.livechatData && + validateCustomFields(await getAllowedCustomFields(), visitor.livechatData, { + ignoreAdditionalFields: true, + ignoreValidationErrors: true, + }), + shouldValidateCustomFields: false, + lastChat: visitor.lastChat, + contactManager: visitor.contactManager?.username && (await getContactManagerIdByUsername(visitor.contactManager.username)), + }; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/mergeContacts.ts b/apps/meteor/server/lib/omnichannel/contacts/mergeContacts.ts new file mode 100644 index 0000000..123f3f7 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/mergeContacts.ts @@ -0,0 +1,8 @@ +import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import type { ClientSession } from 'mongodb'; + +export const mergeContacts = makeFunction( + async (_contactId: string, _visitor: ILivechatContactVisitorAssociation, _session?: ClientSession): Promise => + null, +); diff --git a/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorIfMissingContact.ts b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorIfMissingContact.ts new file mode 100644 index 0000000..ba68515 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorIfMissingContact.ts @@ -0,0 +1,25 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +import { livechatContactsLogger as logger } from '../logger'; +import { getContactIdByVisitor } from './getContactIdByVisitor'; +import { migrateVisitorToContactId } from './migrateVisitorToContactId'; + +export async function migrateVisitorIfMissingContact( + visitorId: ILivechatVisitor['_id'], + source: IOmnichannelSource, +): Promise { + logger.debug({ msg: 'Detecting visitor contact ID' }); + // Check if there is any contact already linking to this visitorId and source + const contactId = await getContactIdByVisitor({ visitorId, source }); + if (contactId) { + return contactId; + } + + const visitor = await LivechatVisitors.findOneById(visitorId); + if (!visitor) { + throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); + } + + return migrateVisitorToContactId({ visitor, source, requireRoom: false }); +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.spec.ts new file mode 100644 index 0000000..47dd3bd --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.spec.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findContactMatchingVisitor: sinon.stub(), + }, + LivechatRooms: { + setContactByVisitorAssociation: sinon.stub(), + findNewestByContactVisitorAssociation: sinon.stub(), + }, +}; + +const createContactFromVisitor = sinon.stub(); +const mergeVisitorIntoContact = sinon.stub(); + +const { migrateVisitorToContactId } = proxyquire.noCallThru().load('./migrateVisitorToContactId', { + './createContactFromVisitor': { + createContactFromVisitor, + }, + './ContactMerger': { + ContactMerger: { + mergeVisitorIntoContact, + }, + }, + '@rocket.chat/models': modelsMock, + '../logger': { + livechatContactsLogger: { + debug: sinon.stub(), + }, + }, +}); + +describe('migrateVisitorToContactId', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findContactMatchingVisitor.reset(); + modelsMock.LivechatRooms.setContactByVisitorAssociation.reset(); + modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.reset(); + createContactFromVisitor.reset(); + mergeVisitorIntoContact.reset(); + }); + + it('should not create a contact if there is no source for the visitor', async () => { + expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' } })).to.be.null; + }); + + it('should attempt to create a new contact if there is no free existing contact matching the visitor data', async () => { + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined); + const visitor = { _id: 'visitor1' }; + const source = { type: 'other' }; + modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source }); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' }, source })).to.be.equal('contactCreated'); + }); + + it('should not attempt to create a new contact if one is found for the visitor', async () => { + const visitor = { _id: 'visitor1' }; + const contact = { _id: 'contact1' }; + const source = { type: 'sms' }; + modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source }); + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(contact); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId({ visitor, source })).to.be.equal('contact1'); + expect(mergeVisitorIntoContact.calledOnceWith(visitor, contact, source)).to.be.true; + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.ts b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.ts new file mode 100644 index 0000000..42be592 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/migrateVisitorToContactId.ts @@ -0,0 +1,55 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; + +import { livechatContactsLogger as logger } from '../logger'; +import { ContactMerger } from './ContactMerger'; +import { createContactFromVisitor } from './createContactFromVisitor'; + +/** + This function assumes you already ensured that the visitor is not yet linked to any contact +**/ +export async function migrateVisitorToContactId({ + visitor, + source, + requireRoom = true, +}: { + visitor: ILivechatVisitor; + source: IOmnichannelSource; + requireRoom?: boolean; +}): Promise { + if (requireRoom) { + // Do not migrate the visitor with this source if they have no rooms matching it + const anyRoom = await LivechatRooms.findNewestByContactVisitorAssociation>( + { visitorId: visitor._id, source }, + { + projection: { _id: 1 }, + }, + ); + + if (!anyRoom) { + return null; + } + } + + // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. + const existingContact = await LivechatContacts.findContactMatchingVisitor(visitor); + if (!existingContact) { + logger.debug({ msg: 'Creating a new contact for existing visitor', visitorId: visitor._id }); + return createContactFromVisitor(visitor, source); + } + + // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it + logger.debug({ msg: 'Adding channel to existing contact', contactId: existingContact._id }); + await ContactMerger.mergeVisitorIntoContact(visitor, existingContact, source); + + // Update all existing rooms matching the visitor id and source to set the contactId to them + await LivechatRooms.setContactByVisitorAssociation( + { + visitorId: visitor._id, + source, + }, + existingContact, + ); + + return existingContact._id; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/patchContact.ts b/apps/meteor/server/lib/omnichannel/contacts/patchContact.ts new file mode 100644 index 0000000..75c94b9 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/patchContact.ts @@ -0,0 +1,14 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export const patchContact = async ( + contactId: ILivechatContact['_id'], + data: { set?: Partial; unset?: Partial> }, +): Promise => { + const { set = {}, unset = {} } = data; + + if (Object.keys(set).length === 0 && Object.keys(unset).length === 0) { + return LivechatContacts.findOneEnabledById(contactId); + } + return LivechatContacts.patchContact(contactId, data); +}; diff --git a/apps/meteor/server/lib/omnichannel/contacts/registerContact.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/registerContact.spec.ts new file mode 100644 index 0000000..fe83e38 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/registerContact.spec.ts @@ -0,0 +1,129 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + 'Users': { + findOneAgentById: sinon.stub(), + findOneByUsername: sinon.stub(), + }, + 'LivechatContacts': { + findOneById: sinon.stub(), + insertOne: sinon.stub(), + upsertContact: sinon.stub(), + updateContact: sinon.stub(), + findContactMatchingVisitor: sinon.stub(), + }, + 'LivechatRooms': { + findNewestByVisitorIdOrToken: sinon.stub(), + setContactIdByVisitorIdOrToken: sinon.stub(), + findByVisitorId: sinon.stub(), + }, + 'LivechatVisitors': { + findOneById: sinon.stub(), + updateById: sinon.stub(), + updateOne: sinon.stub(), + getVisitorByToken: sinon.stub(), + findOneGuestByEmailAddress: sinon.stub(), + }, + 'LivechatCustomField': { + findByScope: sinon.stub(), + }, + '@global': true, +}; + +const { registerContact } = proxyquire.noCallThru().load('./registerContact', { + 'meteor/meteor': sinon.stub(), + '@rocket.chat/models': modelsMock, + '@rocket.chat/tools': { wrapExceptions: sinon.stub() }, +}); + +describe('registerContact', () => { + beforeEach(() => { + modelsMock.Users.findOneByUsername.reset(); + modelsMock.LivechatVisitors.getVisitorByToken.reset(); + modelsMock.LivechatVisitors.updateOne.reset(); + modelsMock.LivechatVisitors.findOneGuestByEmailAddress.reset(); + modelsMock.LivechatCustomField.findByScope.reset(); + modelsMock.LivechatRooms.findByVisitorId.reset(); + }); + + it(`should throw an error if there's no token`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if the token is not a string`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 15, + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if there's an invalid manager username`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-contact-manager-not-found'); + }); + + it(`should throw an error if the manager username does not belong to a livechat agent`, async () => { + modelsMock.Users.findOneByUsername.returns({ roles: ['user'] }); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'username', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-manager'); + }); + + it('should register a contact when passing valid data', async () => { + modelsMock.LivechatVisitors.getVisitorByToken.returns({ _id: 'visitor1' }); + modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); + modelsMock.LivechatRooms.findByVisitorId.returns({ toArray: () => [] }); + modelsMock.LivechatVisitors.updateOne.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + }), + ).to.eventually.be.equal('visitor1'); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/registerContact.ts b/apps/meteor/server/lib/omnichannel/contacts/registerContact.ts new file mode 100644 index 0000000..04fa196 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/registerContact.ts @@ -0,0 +1,120 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users, LivechatRooms, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateCustomFields } from './validateCustomFields'; +import { callbacks } from '../../callbacks'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId, notifyOnLivechatInquiryChangedByRoom } from '../../notifyListener'; + +type RegisterContactProps = { + _id?: string; + token: string; + name: string; + username?: string; + email?: string; + phone?: string; + customFields?: Record; + contactManager?: { + username: string; + }; +}; + +export async function registerContact( + { token, name, email = '', phone, username, customFields = {}, contactManager }: RegisterContactProps, + userId: string, +): Promise { + if (!token || typeof token !== 'string') { + throw new MeteorError('error-invalid-contact-data', 'Invalid visitor token'); + } + + const visitorEmail = email.trim().toLowerCase(); + + if (contactManager?.username) { + // verify if the user exists with this username and has a livechat-agent role + const manager = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } }); + if (!manager) { + throw new MeteorError('error-contact-manager-not-found', `No user found with username ${contactManager.username}`); + } + if (!manager.roles || !Array.isArray(manager.roles) || !manager.roles.includes('livechat-agent')) { + throw new MeteorError('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"'); + } + } + + const existingUserByToken = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + let visitorId = existingUserByToken?._id; + + if (!existingUserByToken) { + if (!username) { + username = await LivechatVisitors.getNextVisitorUsername(); + } + + const existingUserByEmail = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail); + visitorId = existingUserByEmail?._id; + + if (!existingUserByEmail) { + const userData = { + username, + ts: new Date(), + token, + }; + + visitorId = (await LivechatVisitors.insertOne(userData)).insertedId; + } + } + + const allowedCF = await getAllowedCustomFields(); + const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); + + const fieldsToRemove = { + // if field is explicitely set to empty string, remove + ...(phone === '' && { phone: 1 }), + ...(visitorEmail === '' && { visitorEmails: 1 }), + ...(!contactManager?.username && { contactManager: 1 }), + }; + + const updateUser: { $set: MatchKeysAndValues; $unset?: OnlyFieldsOfType } = { + $set: { + token, + name, + livechatData, + // if phone has some value, set + ...(phone && { phone: [{ phoneNumber: phone }] }), + ...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }), + ...(contactManager?.username && { contactManager: { username: contactManager.username } }), + }, + ...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }), + }; + + await LivechatVisitors.updateOne({ _id: visitorId }, updateUser); + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId }); + const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray(); + + if (rooms?.length) { + for (const room of rooms) { + const { _id: rid } = room; + + const responses = await Promise.all([ + Rooms.setFnameById(rid, name), + LivechatInquiry.setNameByRoomId(rid, name), + Subscriptions.updateDisplayNameByRoomId(rid, name), + ]); + + if (responses[0]?.modifiedCount) { + void notifyOnRoomChangedById(rid); + } + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + } + } + + return visitorId as string; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.spec.ts new file mode 100644 index 0000000..0974465 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.spec.ts @@ -0,0 +1,278 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneEnabledById: sinon.stub(), + patchContact: sinon.stub(), + }, + Settings: { + incrementValueById: sinon.stub(), + }, +}; + +const validateContactManagerMock = sinon.stub(); + +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + +const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', { + '@rocket.chat/models': modelsMock, + './validateContactManager': { + validateContactManager: validateContactManagerMock, + }, + './patchContact': { + patchContact, + }, +}); + +describe('resolveContactConflicts', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneEnabledById.reset(); + modelsMock.Settings.incrementValueById.reset(); + modelsMock.LivechatContacts.patchContact.reset(); + validateContactManagerMock.reset(); + }); + + it('should update the contact with the resolved custom field', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], + }); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + customFields: { customField: 'newestValue' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', customFields: { customField: 'newestValue' } }); + + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + customFields: { customField: 'newestValue' }, + conflictingFields: [], + }, + }); + }); + + it('should update the contact with the resolved name', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Old Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'name', value: 'Old Name' }], + }); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); + + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + name: 'New Name', + conflictingFields: [], + }, + }); + }); + + it('should update the contact with the resolved contact manager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'contactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + validateContactManagerMock.resolves(); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'newContactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: 'newContactManagerId' }); + + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('newContactManagerId'); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + contactManager: 'newContactManagerId', + conflictingFields: [], + }, + }); + }); + + it('should wipe all conflicts if wipeConflicts = true', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.Settings.incrementValueById.resolves({ _id: 'Resolved_Conflicts_Count', value: 2 }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], + } as Partial); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); + + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + + expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Resolved_Conflicts_Count'); + expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + name: 'New Name', + conflictingFields: [], + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], + }); + }); + + it('should only resolve specified conflicts when wipeConflicts = false', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + } as Partial); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); + + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + + // When wipeConflicts is false, incrementValueById should NOT be called + expect(modelsMock.Settings.incrementValueById.called).to.be.false; + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + name: 'New Name', + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + }); + }); + + it('should throw an error if the contact does not exist', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( + 'error-contact-not-found', + ); + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; + }); + + it('should throw an error if the contact has no conflicting fields', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'contactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [], + }); + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( + 'error-contact-has-no-conflicts', + ); + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; + }); + + it('should unset contactManager when explicitly set to empty string', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'oldManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: '' }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: { contactManager: '' }, + }); + expect(validateContactManagerMock.called).to.be.false; + }); + + it('should unset contactManager when explicitly set to undefined', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'oldManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: undefined }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: { contactManager: '' }, + }); + expect(validateContactManagerMock.called).to.be.false; + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.ts b/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.ts new file mode 100644 index 0000000..b82bd6f --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/resolveContactConflicts.ts @@ -0,0 +1,77 @@ +import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings'; +import { LivechatContacts, Settings } from '@rocket.chat/models'; + +import { patchContact } from './patchContact'; +import { validateContactManager } from './validateContactManager'; +import { notifyOnSettingChanged } from '../../notifyListener'; + +export type ResolveContactConflictsParams = { + contactId: string; + name?: string; + customFields?: Record; + contactManager?: string; + wipeConflicts?: boolean; +}; + +export async function resolveContactConflicts(params: ResolveContactConflictsParams): Promise { + const { contactId, name, customFields, contactManager, wipeConflicts } = params; + + const contact = await LivechatContacts.findOneEnabledById>( + contactId, + { + projection: { _id: 1, customFields: 1, conflictingFields: 1 }, + }, + ); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + if (!contact.conflictingFields?.length) { + throw new Error('error-contact-has-no-conflicts'); + } + + if (contactManager) { + await validateContactManager(contactManager); + } + + let updatedConflictingFieldsArr: ILivechatContactConflictingField[] = []; + if (wipeConflicts) { + const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, { + returnDocument: 'after', + }); + if (value) { + void notifyOnSettingChanged(value); + } + } else { + const fieldsToRemove = new Set( + [ + name && 'name', + 'contactManager' in params && 'manager', + ...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []), + ].filter((field): field is string => !!field), + ); + + updatedConflictingFieldsArr = contact.conflictingFields.filter( + (conflictingField: ILivechatContactConflictingField) => !fieldsToRemove.has(conflictingField.field), + ) as ILivechatContactConflictingField[]; + } + + const set = { + ...(name && { name }), + ...(contactManager && { contactManager }), + ...(customFields && { customFields: { ...contact.customFields, ...customFields } }), + conflictingFields: updatedConflictingFieldsArr, + }; + + const unset: Partial> = + 'contactManager' in params && !contactManager ? { contactManager: '' } : {}; + + const updatedContact = await patchContact(contactId, { set, unset }); + + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } + + return updatedContact; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/updateContact.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/updateContact.spec.ts new file mode 100644 index 0000000..3a8fb24 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/updateContact.spec.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneEnabledById: sinon.stub(), + patchContact: sinon.stub(), + }, + LivechatRooms: { + updateContactDataByContactId: sinon.stub(), + }, +}; + +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + +const { updateContact } = proxyquire.noCallThru().load('./updateContact', { + './getAllowedCustomFields': { + getAllowedCustomFields: sinon.stub().resolves([]), + }, + './validateContactManager': { + validateContactManager: sinon.stub(), + }, + './validateCustomFields': { + validateCustomFields: sinon.stub(), + }, + + '@rocket.chat/models': modelsMock, + + './patchContact': { + patchContact, + }, +}); + +describe('updateContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneEnabledById.reset(); + modelsMock.LivechatContacts.patchContact.reset(); + modelsMock.LivechatRooms.updateContactDataByContactId.reset(); + }); + + it('should throw an error if the contact does not exist', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); + await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0)).to.be.null; + }); + + it('should update the contact with correct params', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ set: { name: 'John Doe' } }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); + }); + + it('should be able to clear the contact manager when passing an empty string for contactManager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: '' }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); + + it('should be able to clear the contact manager when passing undefined for contactManager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: undefined }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/updateContact.ts b/apps/meteor/server/lib/omnichannel/contacts/updateContact.ts new file mode 100644 index 0000000..3fb0a9e --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/updateContact.ts @@ -0,0 +1,109 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { patchContact } from './patchContact'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; +import { + notifyOnSubscriptionChangedByVisitorIds, + notifyOnRoomChangedByContactId, + notifyOnLivechatInquiryChangedByVisitorIds, + notifyOnSettingChanged, +} from '../../notifyListener'; + +export type UpdateContactParams = { + contactId: string; + name?: string; + emails?: string[]; + phones?: string[]; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; + wipeConflicts?: boolean; +}; + +export async function updateContact(params: UpdateContactParams): Promise { + const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; + + const contact = await LivechatContacts.findOneEnabledById>( + contactId, + { + projection: { _id: 1, name: 1, customFields: 1, conflictingFields: 1 }, + }, + ); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + if (contactManager) { + await validateContactManager(contactManager); + } + + if (wipeConflicts && contact.conflictingFields?.length) { + const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, { + returnDocument: 'after', + }); + if (value) { + void notifyOnSettingChanged(value); + } + } + + const workspaceAllowedCustomFields = await getAllowedCustomFields(); + const workspaceAllowedCustomFieldsIds = workspaceAllowedCustomFields.map((customField) => customField._id); + const currentCustomFieldsIds = Object.keys(contact.customFields || {}); + const notRegisteredCustomFields = currentCustomFieldsIds + .filter((customFieldId) => !workspaceAllowedCustomFieldsIds.includes(customFieldId)) + .map((customFieldId) => ({ _id: customFieldId })); + + const customFieldsToUpdate = + receivedCustomFields && + validateCustomFields(workspaceAllowedCustomFields, receivedCustomFields, { + ignoreAdditionalFields: !!notRegisteredCustomFields.length, + }); + + if (receivedCustomFields && customFieldsToUpdate && notRegisteredCustomFields.length) { + const allowedCustomFields = [...workspaceAllowedCustomFields, ...notRegisteredCustomFields]; + validateCustomFields(allowedCustomFields, receivedCustomFields); + + notRegisteredCustomFields.forEach((notRegisteredCustomField) => { + customFieldsToUpdate[notRegisteredCustomField._id] = contact.customFields?.[notRegisteredCustomField._id] as string; + }); + } + + const set = { + ...(name !== undefined && { name }), + ...(emails && { emails: emails?.map((address) => ({ address })) }), + ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), + ...(contactManager && { contactManager }), + ...(channels && { channels }), + ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), + ...(wipeConflicts && { conflictingFields: [] }), + }; + const unset: Partial> = + 'contactManager' in params && !contactManager ? { contactManager: '' } : {}; + + const updatedContact = await patchContact(contactId, { set, unset }); + + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } + + // If the contact name changed, update the name of its existing rooms and subscriptions + if (name !== undefined && name !== contact.name) { + await LivechatRooms.updateContactDataByContactId(contactId, { name }); + void notifyOnRoomChangedByContactId(contactId); + + const visitorIds = updatedContact.channels?.map((channel) => channel.visitor.visitorId); + if (visitorIds?.length) { + await Subscriptions.updateNameAndFnameByVisitorIds(visitorIds, name); + void notifyOnSubscriptionChangedByVisitorIds(visitorIds); + + await LivechatInquiry.updateNameByVisitorIds(visitorIds, name); + void notifyOnLivechatInquiryChangedByVisitorIds(visitorIds, 'updated', { name }); + } + } + + return updatedContact; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.spec.ts new file mode 100644 index 0000000..b3268ff --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.spec.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + Users: { + findOneAgentById: sinon.stub(), + }, +}; + +const { validateContactManager } = proxyquire.noCallThru().load('./validateContactManager', { + '@rocket.chat/models': modelsMock, +}); + +describe('validateContactManager', () => { + beforeEach(() => { + modelsMock.Users.findOneAgentById.reset(); + }); + + it('should throw an error if the user does not exist', async () => { + modelsMock.Users.findOneAgentById.resolves(undefined); + await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found'); + }); + + it('should not throw an error if the user has the "livechat-agent" role', async () => { + const user = { _id: 'userId' }; + modelsMock.Users.findOneAgentById.resolves(user); + + await expect(validateContactManager('userId')).to.not.be.rejected; + expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId'); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.ts b/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.ts new file mode 100644 index 0000000..cea2c0f --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/validateContactManager.ts @@ -0,0 +1,9 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function validateContactManager(contactManagerUserId: string) { + const contactManagerUser = await Users.findOneAgentById>(contactManagerUserId, { projection: { _id: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.spec.ts b/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.spec.ts new file mode 100644 index 0000000..4bb0d48 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +const { validateCustomFields } = proxyquire.noCallThru().load('./validateCustomFields', {}); + +describe('validateCustomFields', () => { + const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; + + it('should validate custom fields correctly', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); + }); + + it('should throw an error if a required custom field is missing', () => { + expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); + }); + + it('should not throw an error if a required custom field is missing, but the ignoreValidationErrors option is provided', () => { + expect(() => validateCustomFields(mockCustomFields, {}, { ignoreValidationErrors: true })) + .not.to.throw() + .and.to.equal({}); + }); + + it('should NOT throw an error when a non-required custom field is missing', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a custom field value does not match the regexp', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); + }); + + it('should not throw an error if a custom field value does not match the regexp, but the ignoreValidationErrors option is provided', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' }, { ignoreValidationErrors: true })) + .not.to.throw() + .and.to.equal({}); + }); + + it('should handle an empty customFields input without throwing an error', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a extra custom field is passed', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = { field2: 'value' }; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw(); + }); + + it('should not throw an error if a extra custom field is passed, but the ignoreValidationErrors option is provided', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = { field2: 'value' }; + + expect(() => validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true })) + .not.to.throw() + .and.to.equal({}); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.ts b/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.ts new file mode 100644 index 0000000..0dd3241 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/validateCustomFields.ts @@ -0,0 +1,56 @@ +import type { AtLeast, ILivechatCustomField } from '@rocket.chat/core-typings'; + +import { i18n } from '../../../../app/utils/lib/i18n'; +import { trim } from '../../../../lib/utils/stringUtils'; + +export function validateCustomFields( + allowedCustomFields: AtLeast[], + customFields: Record, + { + ignoreAdditionalFields = false, + ignoreValidationErrors = false, + }: { ignoreAdditionalFields?: boolean; ignoreValidationErrors?: boolean } = {}, +): Record { + const validValues: Record = {}; + + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required && !ignoreValidationErrors) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required && !ignoreValidationErrors) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + if (ignoreValidationErrors) { + continue; + } + + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id })); + } + } + + validValues[cf._id] = cfValue; + } + + if (!ignoreAdditionalFields) { + const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); + for (const key in customFields) { + if (!allowedCustomFieldIds.has(key)) { + throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + } + } + } + + return validValues; +} diff --git a/apps/meteor/server/lib/omnichannel/contacts/verifyContactChannel.ts b/apps/meteor/server/lib/omnichannel/contacts/verifyContactChannel.ts new file mode 100644 index 0000000..77bc1e4 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/contacts/verifyContactChannel.ts @@ -0,0 +1,12 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export type VerifyContactChannelParams = { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; +}; + +export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); diff --git a/apps/meteor/server/lib/omnichannel/custom-fields.ts b/apps/meteor/server/lib/omnichannel/custom-fields.ts new file mode 100644 index 0000000..df9f763 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/custom-fields.ts @@ -0,0 +1,231 @@ +import type { ILivechatContact, ILivechatCustomField, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatCustomField, LivechatRooms, LivechatVisitors } from '@rocket.chat/models'; + +import { livechatLogger } from './logger'; +import { i18n } from '../../../app/utils/lib/i18n'; + +export const validateRequiredCustomFields = ( + customFields: string[], + livechatCustomFields: Pick[], +) => { + const errors: string[] = []; + const requiredCustomFields = livechatCustomFields.filter((field) => field.required); + + requiredCustomFields.forEach((field) => { + if (!customFields.find((f) => f === field._id)) { + errors.push(field._id); + } + }); + + if (errors.length > 0) { + throw new Error(`Missing required custom fields: ${errors.join(', ')}`); + } +}; + +export async function updateContactsCustomFields( + contact: ILivechatContact, + validCustomFields: { + key: string; + value: string; + overwrite: boolean; + }[], +): Promise { + const contactCustomFieldsToUpdate = validCustomFields.reduce( + (prev, curr) => { + if (curr.overwrite || !contact?.customFields?.[curr.key]) { + prev[`customFields.${curr.key}`] = curr.value; + return prev; + } + prev.conflictingFields ??= contact.conflictingFields || []; + prev.conflictingFields.push({ field: `customFields.${curr.key}`, value: curr.value }); + return prev; + }, + {} as Record, + ); + + if (!Object.keys(contactCustomFieldsToUpdate).length) { + return; + } + + livechatLogger.debug({ msg: 'Updating custom fields for contact', contactId: contact._id, contactCustomFieldsToUpdate }); + await LivechatContacts.updateById(contact._id, { $set: contactCustomFieldsToUpdate }); +} + +export async function setCustomFields({ + token, + key, + value, + overwrite, +}: { + key: string; + value: string; + overwrite: boolean; + token: string; +}): Promise { + livechatLogger.debug({ msg: 'Setting custom fields data for visitor with token', token }); + + const customField = await LivechatCustomField.findOneById(key); + if (!customField) { + throw new Error('invalid-custom-field'); + } + + if (customField.regexp !== undefined && customField.regexp !== '') { + const regexp = new RegExp(customField.regexp); + if (!regexp.test(value)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: key })); + } + } + + let result; + if (customField.scope === 'room') { + result = await LivechatRooms.updateDataByToken(token, key, value, overwrite); + } else { + result = await LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite); + + const visitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + if (visitor) { + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + if (contacts.length > 0) { + await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, [{ key, value, overwrite }]))); + } + } + } + + if (typeof result === 'boolean') { + // Note: this only happens when !overwrite is passed, in this case we don't do any db update + return 0; + } + + return result.modifiedCount; +} + +export async function setMultipleVisitorCustomFields( + visitor: ILivechatVisitor, + customFields: { + key: string; + value: string; + overwrite: boolean; + }[], + livechatCustomFields?: Pick[], +) { + const keys = customFields.map((field) => field.key); + + livechatCustomFields ??= await LivechatCustomField.findByScope('visitor', { projection: { _id: 1, required: 1 } }, false).toArray(); + validateRequiredCustomFields(keys, livechatCustomFields); + + const matchingCustomFields = livechatCustomFields.filter((field) => keys.includes(field._id)); + const validCustomFields = customFields.filter((cf) => matchingCustomFields.find((mcf) => cf.key === mcf._id)); + if (!validCustomFields.length) { + return false; + } + + const visitorCustomFieldsToUpdate = validCustomFields.reduce( + (prev, curr) => { + if (curr.overwrite) { + prev[`livechatData.${curr.key}`] = curr.value; + return prev; + } + + if (!visitor?.livechatData?.[curr.key]) { + prev[`livechatData.${curr.key}`] = curr.value; + } + + return prev; + }, + {} as Record, + ); + + if (Object.keys(visitorCustomFieldsToUpdate).length) { + await LivechatVisitors.updateAllLivechatDataByToken(visitor.token, visitorCustomFieldsToUpdate); + } + + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + if (contacts.length) { + await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, validCustomFields))); + } + + if (validCustomFields.length !== keys.length) { + livechatLogger.warn({ + msg: 'Some custom fields were not processed', + visitorId: visitor._id, + missingKeys: keys.filter((key) => !validCustomFields.map((v) => v.key).includes(key)), + }); + } + + return true; +} + +export async function setMultipleCustomFields({ + visitor, + customFields, +}: { + visitor: ILivechatVisitor; + customFields: { + key: string; + value: string; + overwrite: boolean; + }[]; +}): Promise< + { + key: string; + value: string; + overwrite: boolean; + }[] +> { + livechatLogger.debug({ msg: 'Setting custom fields data for visitor with token', token: visitor.token }); + + const keys = customFields.map((customField) => customField.key); + const dbFields = await LivechatCustomField.find( + { _id: { $in: keys } }, + { projection: { _id: 1, required: 1, regexp: 1, scope: 1 } }, + ).toArray(); + if (!dbFields.length) { + throw new Error('invalid-custom-field'); + } + + const { visitorFields, roomFields } = customFields.reduce( + (prev, customField) => { + const dbField = dbFields.find((customF) => customF._id === customField.key); + if (!dbField) { + throw new Error('invalid-custom-field'); + } + + if (dbField.regexp !== undefined && dbField.regexp !== '') { + const regexp = new RegExp(dbField.regexp); + if (!regexp.test(customField.value)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: dbField._id })); + } + } + + if (dbField.scope === 'visitor') { + prev.visitorFields ??= []; + prev.visitorFields.push(customField); + return prev; + } + + prev.roomFields ??= []; + prev.roomFields.push(customField); + return prev; + }, + {} as { + visitorFields: { key: string; value: string; overwrite: boolean }[]; + roomFields: { key: string; value: string; overwrite: boolean }[]; + }, + ); + + if (roomFields?.length) { + await Promise.all(roomFields.map(({ key, value, overwrite }) => LivechatRooms.updateDataByToken(visitor.token, key, value, overwrite))); + livechatLogger.debug({ msg: 'Custom fields for room updated', visitor, roomFields }); + } + + if (visitorFields?.length) { + await setMultipleVisitorCustomFields( + visitor, + visitorFields, + dbFields.filter((field) => field.scope === 'visitor'), + ); + livechatLogger.debug({ msg: 'Custom fields for visitor updated', visitor, visitorFields }); + } + + return [...(roomFields || []), ...(visitorFields || [])]; +} diff --git a/apps/meteor/server/lib/omnichannel/departmentsLib.ts b/apps/meteor/server/lib/omnichannel/departmentsLib.ts new file mode 100644 index 0000000..a7e1d58 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/departmentsLib.ts @@ -0,0 +1,310 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { LivechatDepartmentDTO, ILivechatDepartment, ILivechatDepartmentAgents, ILivechatAgent } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents, LivechatVisitors, LivechatRooms, Users } from '@rocket.chat/models'; +import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { updateDepartmentAgents } from './Helper'; +import { afterDepartmentArchived, afterDepartmentUnarchived } from './hooks'; +import { livechatLogger } from './logger'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnLivechatDepartmentAgentChanged } from '../notifyListener'; +/** + * @param {string|null} _id - The department id + * @param {Partial} departmentData + * @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents + * @param {{_id?: string}} [departmentUnit] - The department's unit id + */ +export async function saveDepartment( + userId: string, + _id: string | null, + departmentData: LivechatDepartmentDTO, + departmentAgents?: { + upsert?: { agentId: string; count?: number; order?: number }[]; + remove?: { agentId: string; count?: number; order?: number }[]; + }, + departmentUnit?: { _id?: string }, +) { + if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') { + throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', { + method: 'livechat:saveDepartment', + }); + } + + const department = _id + ? await LivechatDepartment.findOneById>(_id, { + projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 }, + }) + : null; + + if (departmentUnit && !departmentUnit._id && department && department.parentId) { + const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1; + if (isLastDepartmentInUnit) { + throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", { + method: 'livechat:saveDepartment', + }); + } + } + + if (!department && !(await isDepartmentCreationAvailable())) { + throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', { + method: 'livechat:saveDepartment', + }); + } + + if (department?.archived && departmentData.enabled) { + throw new Meteor.Error('error-archived-department-cant-be-enabled', 'Archived departments cant be enabled', { + method: 'livechat:saveDepartment', + }); + } + + // TODO: Use AJV or Zod for validation (or the lib we are using rn) + const defaultValidations: Record | BooleanConstructor | StringConstructor> = { + enabled: Boolean, + name: String, + description: Match.Optional(String), + showOnRegistration: Boolean, + email: String, + showOnOfflineForm: Boolean, + requestTagBeforeClosingChat: Match.Optional(Boolean), + chatClosingTags: Match.Optional([String]), + fallbackForwardDepartment: Match.Optional(String), + departmentsAllowedToForward: Match.Optional([String]), + allowReceiveForwardOffline: Match.Optional(Boolean), + }; + + // The Livechat Form department support addition/custom fields, so those fields need to be added before validating + Object.keys(departmentData).forEach((field) => { + if (!defaultValidations.hasOwnProperty(field)) { + defaultValidations[field] = Match.OneOf(String, Match.Integer, Boolean); + } + }); + + check(departmentData, defaultValidations); + check( + departmentAgents, + Match.Maybe({ + upsert: Match.Maybe(Array), + remove: Match.Maybe(Array), + }), + ); + + const { requestTagBeforeClosingChat, chatClosingTags, fallbackForwardDepartment } = departmentData; + if (requestTagBeforeClosingChat && (!chatClosingTags || chatClosingTags.length === 0)) { + throw new Meteor.Error( + 'error-validating-department-chat-closing-tags', + 'At least one closing tag is required when the department requires tag(s) on closing conversations.', + { method: 'livechat:saveDepartment' }, + ); + } + + if (_id && !department) { + throw new Meteor.Error('error-department-not-found', 'Department not found', { + method: 'livechat:saveDepartment', + }); + } + + if (fallbackForwardDepartment === _id) { + throw new Meteor.Error( + 'error-fallback-department-circular', + 'Cannot save department. Circular reference between fallback department and department', + ); + } + + if (fallbackForwardDepartment) { + const fallbackDep = await LivechatDepartment.findOneById>(fallbackForwardDepartment, { + projection: { _id: 1 }, + }); + if (!fallbackDep) { + throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', { + method: 'livechat:saveDepartment', + }); + } + } + + const departmentDB = await LivechatDepartment.createOrUpdateDepartment(_id, departmentData); + if (departmentDB && departmentAgents) { + await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled); + } + + if (department?.enabled !== departmentData.enabled) { + void notifyOnLivechatDepartmentAgentChangedByDepartmentId(departmentDB._id, department ? 'updated' : 'inserted'); + } + + // Disable event + if (department?.enabled && !departmentDB?.enabled) { + await callbacks.run('livechat.afterDepartmentDisabled', departmentDB); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentDisabled, { department: departmentDB }); + } + + if (departmentUnit) { + await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id }); + } + + return departmentDB; +} + +export async function archiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById>(_id, { + projection: { _id: 1, businessHourId: 1 }, + }); + + if (!department) { + throw new Error('department-not-found'); + } + + const status = await LivechatDepartment.archiveDepartment(department._id); + if (status.modifiedCount) { + await afterDepartmentArchived(department); + } +} + +export async function unarchiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById>(_id, { projection: { _id: 1 } }); + + if (!department) { + throw new Meteor.Error('department-not-found'); + } + + const status = await LivechatDepartment.unarchiveDepartment(department._id); + if (status.modifiedCount) { + await afterDepartmentUnarchived(department); + } +} + +export async function saveDepartmentAgents( + _id: string, + departmentAgents: { + upsert?: (Pick & { + count?: number; + order?: number; + })[]; + remove?: Pick[]; + }, +) { + const department = await LivechatDepartment.findOneById>(_id, { projection: { enabled: 1 } }); + if (!department) { + throw new Meteor.Error('error-department-not-found', 'Department not found'); + } + + return updateDepartmentAgents(_id, departmentAgents, department.enabled); +} + +export async function setDepartmentForGuest({ visitorId, department }: { visitorId: string; department: string }) { + livechatLogger.debug({ + msg: 'Switching departments for visitor', + visitorId, + department, + }); + + const dep = await LivechatDepartment.findOneById>(department, { projection: { _id: 1 } }); + if (!dep) { + throw new Meteor.Error('invalid-department', 'Provided department does not exists'); + } + + // Visitor is already validated at this point + return LivechatVisitors.updateDepartmentById(visitorId, department); +} + +export async function removeDepartment(departmentId: string) { + livechatLogger.debug({ msg: 'Removing department', departmentId }); + + const department = await LivechatDepartment.findOneById>(departmentId, { + projection: { _id: 1, businessHourId: 1, parentId: 1 }, + }); + if (!department) { + throw new Error('error-department-not-found'); + } + + const { _id } = department; + + const ret = await LivechatDepartment.removeById(_id); + if (ret.acknowledged !== true) { + throw new Error('error-failed-to-delete-department'); + } + + const removedAgents = await LivechatDepartmentAgents.findByDepartmentId(department._id, { projection: { agentId: 1 } }).toArray(); + + const actions = ['Removing department agents', 'Unsetting fallback department', 'Removing department from rooms']; + livechatLogger.debug({ + msg: 'Post department removal actions', + departmentId: _id, + actions, + }); + + const promiseResponses = await Promise.allSettled([ + LivechatDepartmentAgents.removeByDepartmentId(_id), + LivechatDepartment.unsetFallbackDepartmentByDepartmentId(_id), + LivechatRooms.bulkRemoveDepartmentAndUnitsFromRooms(_id), + ]); + + promiseResponses.forEach((response, index) => { + if (response.status === 'rejected') { + livechatLogger.error({ + msg: 'Post removal action failed', + actionId: actions[index], + error: response.reason, + }); + } + }); + + const { deletedCount } = promiseResponses[0].status === 'fulfilled' ? promiseResponses[0].value : { deletedCount: 0 }; + + if (deletedCount > 0) { + removedAgents.forEach(({ _id: docId, agentId }) => { + void notifyOnLivechatDepartmentAgentChanged( + { + _id: docId, + agentId, + departmentId: _id, + }, + 'removed', + ); + }); + } + + await callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds: removedAgents.map(({ agentId }) => agentId) }); + void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentRemoved, { department }); + + return ret; +} + +export async function getRequiredDepartment(onlineRequired = true) { + if (!onlineRequired) { + return LivechatDepartment.findOneEnabledWithAgentsAndRegistration(); + } + + const departments = LivechatDepartment.findEnabledWithAgentsAndRegistration(); + + for await (const dept of departments) { + const onlineAgents = await checkOnlineForDepartment(dept._id); + if (onlineAgents) { + return dept; + } + } +} + +export async function checkOnlineForDepartment(departmentId: string) { + const depUsers = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray(); + const onlineForDep = await Users.findOneOnlineAgentByUserList( + depUsers.map((agent) => agent.username), + { projection: { _id: 1 } }, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + + return !!onlineForDep; +} + +export async function getOnlineForDepartment(departmentId: string) { + const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray(); + const usernames = agents.map(({ username }) => username); + + return Users.findOnlineUserFromList( + [...new Set(usernames)], + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); +} diff --git a/apps/meteor/server/lib/omnichannel/getRoomMessages.ts b/apps/meteor/server/lib/omnichannel/getRoomMessages.ts new file mode 100644 index 0000000..8a9af72 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/getRoomMessages.ts @@ -0,0 +1,23 @@ +import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +export async function getRoomMessages({ rid }: { rid: string }) { + const room = await Rooms.findOneById>(rid, { projection: { t: 1 } }); + if (room?.t !== 'l') { + throw new Meteor.Error('invalid-room'); + } + + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + ]; + + return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { + sort: { ts: 1 }, + }); +} diff --git a/apps/meteor/server/lib/omnichannel/guests.ts b/apps/meteor/server/lib/omnichannel/guests.ts new file mode 100644 index 0000000..25a9130 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/guests.ts @@ -0,0 +1,205 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { ILivechatVisitor, IOmnichannelRoom, IUser, UserStatus } from '@rocket.chat/core-typings'; +import { + LivechatVisitors, + LivechatCustomField, + LivechatInquiry, + LivechatRooms, + Messages, + ReadReceipts, + ReadReceiptsArchive, + Subscriptions, + LivechatContacts, + Users, +} from '@rocket.chat/models'; +import UAParser from 'ua-parser-js'; + +import { parseAgentCustomFields } from './Helper'; +import type { ICRMData } from './localTypes'; +import { livechatLogger } from './logger'; +import { trim } from '../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { i18n } from '../i18n'; +import { FileUpload } from '../media/file-upload'; +import { notifyOnSubscriptionChanged, notifyOnLivechatInquiryChanged, notifyOnLivechatInquiryChangedByToken } from '../notifyListener'; + +export async function saveGuest( + guestData: Pick & { email?: string; phone?: string }, + userId: string, +) { + const { _id, name, email, phone, livechatData = {} } = guestData; + + const visitor = await LivechatVisitors.findOneById(_id, { projection: { _id: 1 } }); + if (!visitor) { + throw new Error('error-invalid-visitor'); + } + + livechatLogger.debug({ msg: 'Saving guest', guestData }); + const updateData = { + ...(name && { name }), + ...(email && { email }), + ...(phone && { phone }), + livechatData: {}, + }; + + const customFields: Record = {}; + + if ((!userId || (await hasPermissionAsync(userId, 'edit-livechat-room-customfields'))) && Object.keys(livechatData).length) { + livechatLogger.debug({ msg: 'Saving custom fields for visitor', visitorId: _id, livechatData }); + for await (const field of LivechatCustomField.findByScope('visitor')) { + if (!livechatData.hasOwnProperty(field._id)) { + continue; + } + const value = trim(livechatData[field._id]); + if (value !== '' && field.regexp !== undefined && field.regexp !== '') { + const regexp = new RegExp(field.regexp); + if (!regexp.test(value)) { + throw new Error(i18n.t('error-invalid-custom-field-value')); + } + } + customFields[field._id] = value; + } + updateData.livechatData = customFields; + livechatLogger.debug({ + msg: 'About to update custom fields for visitor', + visitorId: _id, + customFieldCount: Object.keys(customFields).length, + }); + } + const ret = await LivechatVisitors.saveGuestById(_id, updateData); + + setImmediate(() => { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatGuestSaved, _id); + }); + + return ret; +} + +export async function removeGuest({ _id }: { _id: string }) { + await cleanGuestHistory(_id); + return LivechatVisitors.disableById(_id); +} + +export async function removeContactsByVisitorId({ _id }: { _id: string }) { + // A visitor shouldn't have many contacts associated, so we can remove them like this + const contacts = await LivechatContacts.findAllByVisitorId(_id).toArray(); + if (!contacts.length) { + livechatLogger.debug({ msg: 'No contacts found for visitor', visitorId: _id }); + await removeGuest({ _id }); + } + + // And a contact shouldn't have many channels associated, so we can do this + livechatLogger.debug({ msg: 'Removing channels for contacts', visitorId: _id, contacts: contacts.map(({ _id }) => _id) }); + for await (const contact of contacts) { + for await (const { visitor } of contact.channels) { + await removeGuest({ _id: visitor.visitorId }); + } + + await LivechatContacts.disableByVisitorId(_id); + } +} + +async function cleanGuestHistory(_id: string) { + // This shouldn't be possible, but just in case + if (!_id) { + throw new Error('error-invalid-guest'); + } + + // TODO: optimize function => instead of removing one by one, fetch the _ids of the rooms and then remove them in bulk + const cursor = LivechatRooms.findByVisitorId(_id, { projection: { _id: 1 } }); + for await (const room of cursor) { + await Promise.all([ + Subscriptions.removeByRoomId(room._id, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ReadReceiptsArchive.removeByRoomId(room._id), + ]); + } + + await LivechatRooms.removeByVisitorId(_id); + + const livechatInquiries = await LivechatInquiry.findByVisitorIds([_id]).toArray(); + await LivechatInquiry.removeByIds(livechatInquiries.map(({ _id }) => _id)); + void notifyOnLivechatInquiryChanged(livechatInquiries, 'removed'); +} + +export async function getLivechatRoomGuestInfo(room: IOmnichannelRoom) { + const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); + if (!visitor) { + throw new Error('error-invalid-visitor'); + } + + const agent = room.servedBy?._id + ? await Users.findOneById>(room.servedBy?._id, { + projection: { _id: 1, customFields: 1, name: 1, username: 1, emails: 1 }, + }) + : null; + + const ua = new UAParser(); + ua.setUA(visitor.userAgent || ''); + + const postData: ICRMData = { + _id: room._id, + label: room.fname || room.label, // using same field for compatibility + topic: room.topic, + createdAt: room.ts, + lastMessageAt: room.lm, + tags: room.tags, + customFields: room.livechatData, + visitor: { + _id: visitor._id, + token: visitor.token, + name: visitor.name, + username: visitor.username, + department: visitor.department, + ip: visitor.ip, + os: ua.getOS().name && `${ua.getOS().name} ${ua.getOS().version}`, + browser: ua.getBrowser().name && `${ua.getBrowser().name} ${ua.getBrowser().version}`, + customFields: visitor.livechatData, + }, + }; + + if (agent) { + const customFields = parseAgentCustomFields(agent.customFields); + + postData.agent = { + _id: agent._id, + username: agent.username, + name: agent.name, + ...(customFields && { customFields }), + }; + + if (agent.emails && agent.emails.length > 0) { + postData.agent.email = agent.emails[0].address; + } + } + + if (room.crmData) { + postData.crmData = room.crmData; + } + + if (visitor.visitorEmails && visitor.visitorEmails.length > 0) { + postData.visitor.email = visitor.visitorEmails; + } + if (visitor.phone && visitor.phone.length > 0) { + postData.visitor.phone = visitor.phone; + } + + return postData; +} + +export async function notifyGuestStatusChanged(token: string, status: UserStatus) { + const [, inquiryVisitorStatus] = await Promise.all([ + LivechatRooms.updateVisitorStatus(token, status), + LivechatInquiry.updateVisitorStatus(token, status), + ]); + + if (inquiryVisitorStatus.modifiedCount) { + void notifyOnLivechatInquiryChangedByToken(token, 'updated', { v: { status } }); + } +} diff --git a/apps/meteor/server/lib/omnichannel/hooks.ts b/apps/meteor/server/lib/omnichannel/hooks.ts new file mode 100644 index 0000000..ee25d14 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/hooks.ts @@ -0,0 +1,140 @@ +import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; +import type { + AtLeast, + ILivechatDepartment, + IOmnichannelRoom, + IOmnichannelRoomExtraData, + IOmnichannelRoomInfo, + IOmnichannelSource, + IUser, + SelectedAgent, + InquiryWithAgentInfo, + ILivechatInquiryRecord, +} from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatDepartmentAgents, LivechatVisitors, Users } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +import { setUserStatusLivechat } from './utils'; +import { sendToCRM } from '../../hooks/omnichannel/sendToCRM'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { notifyOnLivechatDepartmentAgentChangedByDepartmentId } from '../notifyListener'; + +export async function afterAgentUserActivated(user: IUser) { + if (!user.roles.includes('livechat-agent')) { + throw new Error('invalid-user-role'); + } + callbacks.runAsync('livechat.onNewAgentCreated', user._id); +} + +export async function afterAgentAdded>(user: T): Promise { + await setUserStatusLivechat(user._id, user.status !== 'offline' ? ILivechatAgentStatus.AVAILABLE : ILivechatAgentStatus.NOT_AVAILABLE); + callbacks.runAsync('livechat.onNewAgentCreated', user._id); + + return user; +} + +export async function afterRemoveAgent(user: AtLeast) { + await callbacks.run('livechat.afterAgentRemoved', { agent: user }); + return true; +} + +export async function afterDepartmentArchived(department: AtLeast) { + await LivechatDepartmentAgents.disableAgentsByDepartmentId(department._id); + + void notifyOnLivechatDepartmentAgentChangedByDepartmentId(department._id); + + await callbacks.run('livechat.afterDepartmentArchived', department); +} + +export async function afterDepartmentUnarchived(department: AtLeast) { + await LivechatDepartmentAgents.enableAgentsByDepartmentId(department._id); + + void notifyOnLivechatDepartmentAgentChangedByDepartmentId(department._id); +} + +export const checkDefaultAgentOnNewRoom = makeFunction( + async (defaultAgent?: SelectedAgent, _params?: { visitorId?: string; source?: IOmnichannelSource }) => defaultAgent, +); + +export const beforeDelegateAgent = async ( + agent: SelectedAgent | undefined, + { department }: { department?: string } = {}, +): Promise => { + if (agent) { + return agent; + } + + if (!settings.get('Livechat_assign_new_conversation_to_bot')) { + return null; + } + + if (department) { + return LivechatDepartmentAgents.getNextBotForDepartment(department); + } + + return Users.getNextBotAgent(); +}; + +export const beforeNewRoom = makeFunction( + async (roomInfo: IOmnichannelRoomInfo, _extraData?: IOmnichannelRoomExtraData): Promise> => roomInfo, +); + +export const onNewRoom = makeFunction(async (room: IOmnichannelRoom) => { + const { + _id, + v: { _id: guestId }, + source, + contactId, + } = room; + + const lastChat = { + _id, + ts: new Date(), + }; + + await Promise.all([ + LivechatVisitors.setLastChatById(guestId, lastChat), + contactId + ? LivechatContacts.updateLastChatById( + contactId, + { + visitorId: guestId, + source, + }, + lastChat, + ) + : undefined, + ]); + + if (settings.get('Livechat_webhook_on_start')) { + await sendToCRM('LivechatSessionStart', room); + } +}); + +export const afterTakeInquiry = makeFunction( + async ({ room }: { inquiry: InquiryWithAgentInfo; room: IOmnichannelRoom; agent: { agentId: string; username: string } }) => { + if (!settings.get('Livechat_webhook_on_chat_taken')) { + return; + } + await sendToCRM('LivechatSessionTaken', room); + }, +); + +export const afterInquiryQueued = makeFunction(async (_inquiry: ILivechatInquiryRecord) => { + return void 0; +}); + +export const afterRoomQueued = makeFunction((room: IOmnichannelRoom) => { + if (!settings.get('Livechat_webhook_on_chat_queued')) { + return; + } + + return sendToCRM('LivechatSessionQueued', room); +}); + +export const beforeRouteChat = makeFunction( + async (inquiry: ILivechatInquiryRecord, _agent?: SelectedAgent | null): Promise => { + return inquiry; + }, +); diff --git a/apps/meteor/server/lib/omnichannel/index.ts b/apps/meteor/server/lib/omnichannel/index.ts new file mode 100644 index 0000000..f3d9258 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/index.ts @@ -0,0 +1,24 @@ +import './livechat'; +import './startup'; +import '../../hooks/omnichannel/leadCapture'; +import '../../hooks/omnichannel/markRoomResponded'; +import '../../hooks/omnichannel/offlineMessage'; +import '../../hooks/omnichannel/offlineMessageToChannel'; +import '../../hooks/omnichannel/saveAnalyticsData'; +import '../../hooks/omnichannel/sendToCRM'; +import '../../hooks/omnichannel/processRoomAbandonment'; +import '../../hooks/omnichannel/saveLastVisitorMessageTs'; +import '../../hooks/omnichannel/markRoomNotResponded'; +import '../../hooks/omnichannel/sendEmailTranscriptOnClose'; +import '../../hooks/omnichannel/saveLastMessageToInquiry'; +import '../../hooks/omnichannel/afterUserActions'; +import '../../hooks/omnichannel/afterAgentRemoved'; +import '../../hooks/omnichannel/afterSaveOmnichannelMessage'; +import './QueueManager'; +import './RoutingManager'; +import './routing/External'; +import './routing/ManualSelection'; +import './routing/AutoSelection'; +import './stream/agentStatus'; +import './sendMessageBySMS'; +import '../../api/v1/omnichannel'; diff --git a/apps/meteor/server/lib/omnichannel/isMessageFromBot.ts b/apps/meteor/server/lib/omnichannel/isMessageFromBot.ts new file mode 100644 index 0000000..e394366 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/isMessageFromBot.ts @@ -0,0 +1,6 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function isMessageFromBot(message: IMessage): Promise | null> { + return Users.isUserInRole(message.u._id, 'bot'); +} diff --git a/apps/meteor/server/lib/omnichannel/livechat.ts b/apps/meteor/server/lib/omnichannel/livechat.ts new file mode 100644 index 0000000..c90e84c --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/livechat.ts @@ -0,0 +1,73 @@ +import url from 'node:url'; + +import jsdom from 'jsdom'; +import mem from 'mem'; +import { WebApp } from 'meteor/webapp'; + +import { addServerUrlToIndex } from './Assets'; +import { settings } from '../../settings'; + +const indexHtmlWithServerURL = await Assets.getTextAsync('livechat/index.html'); + +function parseExtraAttributes(widgetData: string): string { + const liveChatAdditionalScripts = settings.get('Livechat_AdditionalWidgetScripts'); + const additionalClass = settings.get('Livechat_WidgetLayoutClasses'); + + if (liveChatAdditionalScripts == null || additionalClass == null) { + return widgetData; + } + + const domParser = new jsdom.JSDOM(widgetData); + const doc = domParser.window.document; + const head = doc.querySelector('head'); + const body = doc.querySelector('body'); + + liveChatAdditionalScripts.split(',').forEach((script) => { + const scriptElement = doc.createElement('script'); + scriptElement.src = script; + body?.appendChild(scriptElement); + }); + + additionalClass.split(',').forEach((css) => { + const linkElement = doc.createElement('link'); + linkElement.rel = 'stylesheet'; + linkElement.href = css; + head?.appendChild(linkElement); + }); + + return doc.documentElement.innerHTML; +} + +const memoizedParseExtraAttributes = mem(parseExtraAttributes, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000, +}); + +WebApp.connectHandlers.use('/livechat', (req, res, next) => { + if (!req.url) { + return next(); + } + + const reqUrl = url.parse(req.url); + if (reqUrl.pathname !== '/') { + return next(); + } + + res.setHeader('content-type', 'text/html; charset=utf-8'); + const domainWhiteListSetting = settings.get('Livechat_AllowedDomainsList'); + let domainWhiteList = []; + if (req.headers.referer && domainWhiteListSetting.trim()) { + domainWhiteList = domainWhiteListSetting.split(',').map((domain) => domain.trim()); + const referer = url.parse(req.headers.referer); + if (referer.host && !domainWhiteList.includes(referer.host)) { + res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + return next(); + } + res.setHeader('Content-Security-Policy', `frame-ancestors ${referer.protocol}//${referer.host}`); + } else { + // TODO need to remove inline scripts from this route to be able to enable CSP here as well + res.removeHeader('Content-Security-Policy'); + } + + res.write(memoizedParseExtraAttributes(addServerUrlToIndex(indexHtmlWithServerURL || ''))); + res.end(); +}); diff --git a/apps/meteor/server/lib/omnichannel/localTypes.ts b/apps/meteor/server/lib/omnichannel/localTypes.ts new file mode 100644 index 0000000..2676443 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/localTypes.ts @@ -0,0 +1,88 @@ +import type { + IOmnichannelRoom, + IUser, + ILivechatVisitor, + IMessage, + MessageAttachment, + IMessageInbox, + IOmnichannelAgent, +} from '@rocket.chat/core-typings'; + +type GenericCloseRoomParams = { + room: IOmnichannelRoom; + comment?: string; + forceClose?: boolean; + options?: { + clientAction?: boolean; + tags?: string[]; + emailTranscript?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: NonNullable; + }; + pdfTranscript?: { + requestedBy: string; + }; + }; +}; + +export type CloseRoomParamsByUser = { + user: IUser | null; +} & GenericCloseRoomParams; + +export type CloseRoomParamsByVisitor = { + visitor: ILivechatVisitor; +} & GenericCloseRoomParams; + +export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; + +type UploadedFile = { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; +}; + +export interface ILivechatMessage { + token: string; + _id: string; + rid: string; + msg: string; + file?: UploadedFile; + files?: UploadedFile[]; + attachments?: MessageAttachment[]; + alias?: string; + groupable?: boolean; + blocks?: IMessage['blocks']; + email?: IMessageInbox['email']; +} + +export type ICRMData = { + _id: string; + label?: string; + topic?: string; + createdAt: Date; + lastMessageAt?: Date; + tags?: string[]; + customFields?: IOmnichannelRoom['livechatData']; + visitor: Pick & { + email?: ILivechatVisitor['visitorEmails']; + os?: string; + browser?: string; + customFields: ILivechatVisitor['livechatData']; + }; + agent?: Pick & { + email?: NonNullable[number]['address']; + }; + crmData?: IOmnichannelRoom['crmData']; +}; + +export type AKeyOf = { + [K in keyof T]?: T[K]; +}; diff --git a/apps/meteor/server/lib/omnichannel/logger.ts b/apps/meteor/server/lib/omnichannel/logger.ts new file mode 100644 index 0000000..7c79c56 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/logger.ts @@ -0,0 +1,7 @@ +import { Logger } from '@rocket.chat/logger'; + +export const callbackLogger = new Logger('[Omnichannel] Callback'); +export const businessHourLogger = new Logger('Business Hour'); +export const livechatLogger = new Logger('Livechat'); +export const livechatContactsLogger = new Logger('Livechat Contacts'); +export const webhooksLogger = new Logger('Webhooks'); diff --git a/apps/meteor/server/lib/omnichannel/messages.ts b/apps/meteor/server/lib/omnichannel/messages.ts new file mode 100644 index 0000000..7225067 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/messages.ts @@ -0,0 +1,153 @@ +import dns from 'node:dns'; +import * as util from 'node:util'; + +import type { ILivechatVisitor, AtLeast, IMessage, IUser, IOmnichannelRoomInfo, SelectedAgent } from '@rocket.chat/core-typings'; +import { LivechatDepartment, Messages } from '@rocket.chat/models'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import type { ILivechatMessage } from './localTypes'; +import { getRoom } from './rooms'; +import { showConnecting } from './utils'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { deleteMessage as deleteMessageFunc } from '../messages/deleteMessage'; +import { sendMessage as sendMessageFunc } from '../messages/sendMessage'; +import { updateMessage as updateMessageFunc } from '../messages/updateMessage'; +import * as Mailer from '../notifications/email/api'; + +const dnsResolveMx = util.promisify(dns.resolveMx); + +type OfflineMessageData = { + message: string; + name: string; + email: string; + department?: string; + host?: string; +}; + +export async function sendOfflineMessage(data: OfflineMessageData) { + if (!settings.get('Livechat_display_offline_form')) { + throw new Error('error-offline-form-disabled'); + } + + const { message, name, email, department, host } = data; + + if (!email) { + throw new Error('error-invalid-email'); + } + + const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + let html = '

New livechat message

'; + if (host && host !== '') { + html = html.concat(`

Sent from: ${host}

`); + } + html = html.concat(` +

Visitor name: ${name}

+

Visitor email: ${email}

+

Message:
${emailMessage}

`); + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + + let from: string; + if (fromEmail) { + from = fromEmail[0]; + } else { + from = settings.get('From_Email'); + } + + if (settings.get('Livechat_validate_offline_email')) { + const emailDomain = email.substr(email.lastIndexOf('@') + 1); + + try { + await dnsResolveMx(emailDomain); + } catch (e) { + throw new Meteor.Error('error-invalid-email-address'); + } + } + + // TODO Block offline form if Livechat_offline_email is undefined + // (it does not make sense to have an offline form that does nothing) + // `this.sendEmail` will throw an error if the email is invalid + // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client + let emailTo = settings.get('Livechat_offline_email'); + if (department && department !== '') { + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { email: 1 } }); + if (dep) { + emailTo = dep.email || emailTo; + } + } + + const fromText = `${name} - ${email} <${from}>`; + const replyTo = `${name} <${email}>`; + const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`; + await Mailer.send({ + to: emailTo, + from: fromText, + replyTo, + subject, + html, + }); + + setImmediate(() => { + void callbacks.run('livechat.offlineMessage', data); + }); +} + +export async function updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { + // TODO: Remove check + check(message, Match.ObjectIncluding({ _id: String })); + + const originalMessage = await Messages.findOneById>(message._id, { projection: { u: 1 } }); + if (!originalMessage?._id) { + return; + } + + // TODO: shouldn't this happen inside updateMessageFunc? + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === guest._id; + + if (!editAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: Apps sends an `any` object and apparently we just check for _id being present + // while updateMessage expects AtLeast + await updateMessageFunc(message, guest as unknown as IUser); + + return true; +} + +export async function deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = message.u && message.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: we shouldn't do this :( + await deleteMessageFunc(message, guest as unknown as IUser); + + return true; +} + +export async function sendMessage({ + guest, + message, + roomInfo, + agent, +}: { + guest: ILivechatVisitor; + message: ILivechatMessage; + roomInfo: IOmnichannelRoomInfo; + agent?: SelectedAgent; +}) { + const { room, newRoom } = await getRoom(guest, message, roomInfo, agent); + return { + ...(await sendMessageFunc(guest, { ...message, token: guest.token, ...(guest.name && { alias: guest.name }) }, room)), + newRoom, + showConnecting: showConnecting(), + }; +} diff --git a/apps/meteor/server/lib/omnichannel/omni-users.ts b/apps/meteor/server/lib/omnichannel/omni-users.ts new file mode 100644 index 0000000..baff92e --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/omni-users.ts @@ -0,0 +1,116 @@ +import { api } from '@rocket.chat/core-services'; +import type { UserStatus, IUser } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents, LivechatRooms, Users } from '@rocket.chat/models'; +import { removeEmpty } from '@rocket.chat/tools'; + +import { updateDepartmentAgents } from './Helper'; +import { afterAgentAdded, afterRemoveAgent } from './hooks'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { addUserRolesAsync } from '../roles/addUserRoles'; +import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles'; + +export async function notifyAgentStatusChanged(userId: string, status?: UserStatus) { + if (!status) { + return; + } + + void callbacks.runAsync('livechat.agentStatusChanged', { userId, status }); + if (!settings.get('Livechat_show_agent_info')) { + return; + } + + for await (const room of LivechatRooms.findOpenByAgent(userId, { projection: { _id: 1 } })) { + void api.broadcast('omnichannel.room', room._id, { + type: 'agentStatus', + status, + }); + } +} + +export async function addManager(username: string) { + const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } }); + + if (!user) { + throw new Error('error-invalid-user'); + } + + if (await addUserRolesAsync(user._id, ['livechat-manager'])) { + return user; + } + + return false; +} + +export async function addAgent(username: string) { + // `status` is deliberately not projected: this endpoint has always turned the new agent available + // regardless of the user's presence, and `afterAgentAdded` relies on the field being absent to do so. + const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } }); + + if (!user) { + throw new Error('error-invalid-user'); + } + + if (await addUserRolesAsync(user._id, ['livechat-agent'])) { + return afterAgentAdded(user); + } + + return false; +} + +export async function removeAgent(id: IUser['_id']) { + const user = await Users.findOneById>(id, { projection: { _id: 1, username: 1 } }); + + if (!user) { + throw new Error('error-invalid-user'); + } + const { _id } = user; + + if (await removeUserFromRolesAsync(_id, ['livechat-agent'])) { + return afterRemoveAgent(user); + } + + return false; +} + +export async function removeManager(id: IUser['_id']) { + const user = await Users.findOneById>(id, { projection: { _id: 1 } }); + + if (!user) { + throw new Error('error-invalid-user'); + } + + return removeUserFromRolesAsync(user._id, ['livechat-manager']); +} + +export async function saveAgentInfo(_id: string, agentData: any, agentDepartments: string[]) { + await Users.setLivechatData(_id, removeEmpty(agentData)); + + const currentDepartmentsForAgent = await LivechatDepartmentAgents.findByAgentId(_id).toArray(); + + const toRemoveIds = currentDepartmentsForAgent + .filter((dept) => !agentDepartments.includes(dept.departmentId)) + .map((dept) => dept.departmentId); + const toAddIds = agentDepartments.filter((d) => !currentDepartmentsForAgent.some((c) => c.departmentId === d)); + + await Promise.all( + await LivechatDepartment.findInIds([...toRemoveIds, ...toAddIds], { + projection: { + _id: 1, + enabled: 1, + }, + }) + .map((dep) => { + return updateDepartmentAgents( + dep._id, + { + ...(toRemoveIds.includes(dep._id) ? { remove: [{ agentId: _id }] } : { upsert: [{ agentId: _id, count: 0, order: 0 }] }), + }, + dep.enabled, + ); + }) + .toArray(), + ); + + return true; +} diff --git a/apps/meteor/server/lib/omnichannel/outboundcommunication.ts b/apps/meteor/server/lib/omnichannel/outboundcommunication.ts new file mode 100644 index 0000000..238bdfd --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/outboundcommunication.ts @@ -0,0 +1,6 @@ +import type { IOutboundMessageProviderService } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const getOutboundService = makeFunction((): IOutboundMessageProviderService => { + throw new Error('error-no-license'); +}); diff --git a/apps/meteor/server/lib/omnichannel/parseTranscriptRequest.ts b/apps/meteor/server/lib/omnichannel/parseTranscriptRequest.ts new file mode 100644 index 0000000..338b31e --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/parseTranscriptRequest.ts @@ -0,0 +1,63 @@ +import type { ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users } from '@rocket.chat/models'; + +import type { CloseRoomParams } from './localTypes'; +import { settings } from '../../settings'; + +export const parseTranscriptRequest = async ( + room: IOmnichannelRoom, + options: CloseRoomParams['options'], + visitor?: ILivechatVisitor, + user?: IUser, +): Promise => { + const visitorDecideTranscript = settings.get('Livechat_enable_transcript'); + // visitor decides, no changes + if (visitorDecideTranscript) { + return options; + } + + // send always is disabled, no changes + const sendAlways = settings.get('Livechat_transcript_send_always'); + if (!sendAlways) { + return options; + } + + const visitorData = + visitor || + (await LivechatVisitors.findOneById>(room.v._id, { projection: { visitorEmails: 1 } })); + // no visitor, no changes + if (!visitorData) { + return options; + } + const visitorEmail = visitorData?.visitorEmails?.[0]?.address; + // visitor doesnt have email, no changes + if (!visitorEmail) { + return options; + } + + // `as const` keeps the projection statically readable so the return type narrows; + // `utcOffset` is part of the `requestedBy` contract. + const defOptions = { projection: { _id: 1, username: 1, name: 1, utcOffset: 1 } } as const; + const requestedBy = + user || + (room.servedBy && (await Users.findOneById(room.servedBy._id, defOptions))) || + (await Users.findOneById('rocket.cat', defOptions)); + + // no user available for backing request, no changes + if (!requestedBy) { + return options; + } + + return { + ...options, + emailTranscript: { + sendToVisitor: true, + requestData: { + email: visitorEmail, + requestedAt: new Date(), + subject: '', + requestedBy, + }, + }, + }; +}; diff --git a/apps/meteor/server/lib/omnichannel/resolveVisitor.ts b/apps/meteor/server/lib/omnichannel/resolveVisitor.ts new file mode 100644 index 0000000..7275f7b --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/resolveVisitor.ts @@ -0,0 +1,23 @@ +import type { IVisitorExternalIdentifier, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +type ResolveVisitorContactData = { phone: string } | { email: string }; + +type ResolveVisitorParams = { + appId: string; + externalId: Omit; + contactData?: ResolveVisitorContactData; +}; + +export async function resolveVisitor({ appId, externalId, contactData }: ResolveVisitorParams): Promise { + const visitorByExternalId = await LivechatVisitors.findOneByExternalId(externalId.entityId); + if (visitorByExternalId) { + return visitorByExternalId; + } + + if (contactData && (('phone' in contactData && contactData.phone) || ('email' in contactData && contactData.email))) { + return LivechatVisitors.findOneVisitorByPhoneOrEmailAndAddExternalId(contactData, appId, externalId); + } + + return null; +} diff --git a/apps/meteor/server/lib/omnichannel/roomAccessValidator.compatibility.ts b/apps/meteor/server/lib/omnichannel/roomAccessValidator.compatibility.ts new file mode 100644 index 0000000..f7dce39 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/roomAccessValidator.compatibility.ts @@ -0,0 +1,97 @@ +import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatDepartmentAgents, LivechatInquiry, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; + +import { RoutingManager } from './RoutingManager'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { hasRoleAsync } from '../authorization/hasRole'; + +type OmnichannelRoomAccessValidator = ( + room: IOmnichannelRoom, + user?: Pick, + extraData?: Record, +) => boolean | Promise; + +export const validators: OmnichannelRoomAccessValidator[] = [ + async function (_room, user) { + if (!user?._id) { + return false; + } + return hasPermissionAsync(user._id, 'view-livechat-rooms'); + }, + async function (room, user) { + if (!user?._id) { + return false; + } + + const { _id: userId } = user; + const { servedBy: { _id: agentId } = {} } = room; + return userId === agentId || (!room.open && hasPermissionAsync(user._id, 'view-livechat-room-closed-by-another-agent')); + }, + async function (room, _user, extraData) { + if (extraData?.rid) { + const dbRoom = await LivechatRooms.findOneById(extraData.rid); + if (dbRoom) { + room = dbRoom; + } + } + + return extraData?.visitorToken && room.v && room.v.token === extraData.visitorToken && room.open === true; + }, + async function (room, user) { + if (!user?._id) { + return false; + } + if (!RoutingManager.getConfig()?.previewRoom) { + return; + } + + let departmentIds; + if (!(await hasRoleAsync(user._id, 'livechat-manager'))) { + const departmentAgents = (await LivechatDepartmentAgents.findByAgentId(user._id, { projection: { departmentId: 1 } }).toArray()).map( + (d) => d.departmentId, + ); + departmentIds = (await LivechatDepartment.findEnabledInIds(departmentAgents, { projection: { _id: 1 } }).toArray()).map((d) => d._id); + } + + const filter = { + rid: room._id, + $or: [ + { + $and: [{ defaultAgent: { $exists: true } }, { 'defaultAgent.agentId': user._id }], + }, + { + ...(departmentIds && departmentIds.length > 0 && { department: { $in: departmentIds } }), + }, + { + department: { $exists: false }, // No department == public queue + }, + ], + }; + + // TODO: findone filtering if the inquiry is queued instead of checking here + const inquiry = await LivechatInquiry.findOne(filter, { projection: { status: 1 } }); + return inquiry && inquiry.status === 'queued'; + }, + async function (room, user) { + if (!room.departmentId || room.open || !user?._id) { + return; + } + const agentOfDepartment = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(user._id, room.departmentId, { + projection: { _id: 1 }, + }); + if (!agentOfDepartment) { + return; + } + return hasPermissionAsync(user._id, 'view-livechat-room-closed-same-department'); + }, + function (_room, user) { + // Check if user is rocket.cat + if (!user?._id) { + return false; + } + + // This opens the ability for rocketcat to upload files to a livechat room without being included in it :) + // Worst case, someone manages to log in as rocketcat lol + return user._id === 'rocket.cat'; + }, +]; diff --git a/apps/meteor/server/lib/omnichannel/roomAccessValidator.internalService.ts b/apps/meteor/server/lib/omnichannel/roomAccessValidator.internalService.ts new file mode 100644 index 0000000..957d783 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/roomAccessValidator.internalService.ts @@ -0,0 +1,21 @@ +import type { IAuthorizationLivechat } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings'; + +import { validators } from './roomAccessValidator.compatibility'; + +export class AuthorizationLivechat extends ServiceClassInternal implements IAuthorizationLivechat { + protected name = 'authorization-livechat'; + + protected override internal = true; + + async canAccessRoom(room: IOmnichannelRoom, user?: Pick, extraData?: object): Promise { + for (const validator of validators) { + if (await validator(room, user, extraData)) { + return true; + } + } + + return false; + } +} diff --git a/apps/meteor/server/lib/omnichannel/rooms.ts b/apps/meteor/server/lib/omnichannel/rooms.ts new file mode 100644 index 0000000..cecc36b --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/rooms.ts @@ -0,0 +1,314 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { Omnichannel } from '@rocket.chat/core-services'; +import type { + ILivechatVisitor, + IMessage, + IOmnichannelRoomInfo, + SelectedAgent, + IOmnichannelRoomExtraData, + IOmnichannelRoom, + TransferData, +} from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { + LivechatRooms, + LivechatContacts, + Messages, + LivechatCustomField, + LivechatInquiry, + Rooms, + Subscriptions, + Users, + ReadReceipts, + ReadReceiptsArchive, +} from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { normalizeTransferredByData } from './Helper'; +import { QueueManager } from './QueueManager'; +import { RoutingManager } from './RoutingManager'; +import { Visitors } from './Visitors'; +import { getRequiredDepartment } from './departmentsLib'; +import { checkDefaultAgentOnNewRoom } from './hooks'; +import { livechatLogger } from './logger'; +import { saveTransferHistory } from './transfer'; +import { i18n } from '../../../app/utils/lib/i18n'; +import { trim } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { callbacks } from '../callbacks'; +import { + notifyOnLivechatInquiryChangedByRoom, + notifyOnSubscriptionChangedByRoomId, + notifyOnRoomChangedById, + notifyOnLivechatInquiryChanged, + notifyOnSubscriptionChanged, + notifyOnRoomChanged, +} from '../notifyListener'; + +export async function getRoom( + guest: ILivechatVisitor, + message: Pick, + roomInfo: IOmnichannelRoomInfo, + agent?: SelectedAgent, + extraData?: IOmnichannelRoomExtraData, +) { + if (!settings.get('Livechat_enabled')) { + throw new Meteor.Error('error-omnichannel-is-disabled'); + } + livechatLogger.debug({ msg: 'Attempting to find or create a room for visitor', visitorId: guest._id }); + const room = await LivechatRooms.findOneById(message.rid); + + if (room?.v._id && (await LivechatContacts.isChannelBlocked(Visitors.makeVisitorAssociation(room.v._id, room.source)))) { + throw new Error('error-contact-channel-blocked'); + } + + if (!room?.open) { + livechatLogger.debug({ msg: 'Last room for visitor closed. Creating new one', visitorId: guest._id }); + } + + if (!room?.open) { + return { + room: await createRoom({ visitor: guest, message: message.msg, roomInfo, agent, extraData }), + newRoom: true, + }; + } + + if (room.v.token !== guest.token) { + livechatLogger.debug({ msg: 'Visitor trying to access another visitor room', visitorId: guest._id }); + throw new Meteor.Error('cannot-access-room'); + } + + return { room, newRoom: false }; +} + +export async function createRoom({ + visitor, + message, + rid, + roomInfo, + agent, + extraData, +}: { + visitor: ILivechatVisitor; + message?: string; + rid?: string; + roomInfo: IOmnichannelRoomInfo; + agent?: SelectedAgent; + extraData?: IOmnichannelRoomExtraData; +}) { + if (!settings.get('Livechat_enabled')) { + throw new Meteor.Error('error-omnichannel-is-disabled'); + } + + if (await LivechatContacts.isChannelBlocked(Visitors.makeVisitorAssociation(visitor._id, roomInfo.source))) { + throw new Error('error-contact-channel-blocked'); + } + + const defaultAgent = await checkDefaultAgentOnNewRoom(agent, { + visitorId: visitor._id, + source: roomInfo.source, + }); + + // if no department selected verify if there is at least one active and pick the first + if (!defaultAgent && !visitor.department) { + const department = await getRequiredDepartment(); + livechatLogger.debug({ msg: 'No department or default agent selected for visitor', visitorId: visitor._id }); + + if (department) { + livechatLogger.debug({ msg: 'Assigning visitor to department', visitorId: visitor._id, departmentId: department._id }); + visitor.department = department._id; + } + } + + // delegate room creation to QueueManager + livechatLogger.debug({ msg: 'Calling QueueManager to request a room for visitor', visitorId: visitor._id }); + + const room = await QueueManager.requestRoom({ + guest: visitor, + message, + rid, + roomInfo, + agent: defaultAgent, + extraData, + }); + + livechatLogger.debug({ msg: 'Room obtained for visitor', visitorId: visitor._id, roomId: room._id }); + + await Messages.setRoomIdByToken(visitor.token, room._id); + + return room; +} + +export async function saveRoomInfo( + roomData: { + _id: string; + topic?: string; + tags?: string[]; + livechatData?: { [k: string]: string }; + // For priority and SLA, if the value is blank (ie ""), then system will remove the priority or SLA from the room + priorityId?: string; + slaId?: string; + }, + guestData?: { + _id: string; + name?: string; + email?: string; + phone?: string; + livechatData?: { [k: string]: string }; + }, + userId?: string, +) { + livechatLogger.debug({ msg: 'Saving room information', roomId: roomData._id }); + const { livechatData = {} } = roomData; + const customFields: Record = {}; + + if ((!userId || (await hasPermissionAsync(userId, 'edit-livechat-room-customfields'))) && Object.keys(livechatData).length) { + const fields = LivechatCustomField.findByScope('room'); + for await (const field of fields) { + if (!livechatData.hasOwnProperty(field._id)) { + continue; + } + const value = trim(livechatData[field._id]); + if (value !== '' && field.regexp !== undefined && field.regexp !== '') { + const regexp = new RegExp(field.regexp); + if (!regexp.test(value)) { + throw new Meteor.Error(i18n.t('error-invalid-custom-field-value', { field: field.label })); + } + } + customFields[field._id] = value; + } + roomData.livechatData = customFields; + livechatLogger.debug({ + msg: 'About to update custom fields on room', + roomId: roomData._id, + customFieldCount: Object.keys(customFields).length, + }); + } + + await LivechatRooms.saveRoomById(roomData); + + setImmediate(() => { + void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomSaved, roomData._id); + }); + + if (guestData?.name?.trim().length) { + const { _id: rid } = roomData; + const { name } = guestData; + + const responses = await Promise.all([ + Rooms.setFnameById(rid, name), + LivechatInquiry.setNameByRoomId(rid, name), + Subscriptions.updateDisplayNameByRoomId(rid, name), + ]); + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + await notifyOnSubscriptionChangedByRoomId(rid); + } + } + + void notifyOnRoomChangedById(roomData._id); + + return true; +} + +export async function returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: string, overrideTransferData: Partial = {}) { + livechatLogger.debug({ msg: 'Transferring room to queue', scope: departmentId ? 'department' : undefined, room }); + if (!room.open) { + throw new Meteor.Error('room-closed', 'Room closed'); + } + + if (room.onHold) { + throw new Meteor.Error('error-room-onHold'); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached'); + } + + if (!room.servedBy) { + return false; + } + + const user = await Users.findOneById(room.servedBy._id); + if (!user?._id) { + throw new Meteor.Error('error-invalid-user'); + } + + const inquiry = await LivechatInquiry.findOne({ rid: room._id }); + if (!inquiry) { + return false; + } + + // update inquiry's last message with room's last message to correctly display in the queue + // because we stop updating the inquiry when it's been taken + if (room.lastMessage) { + await LivechatInquiry.setLastMessageById(inquiry._id, room.lastMessage); + } + + const transferredBy = normalizeTransferredByData(user, room); + livechatLogger.debug({ msg: 'Transferring room by user', roomId: room._id, transferredBy: transferredBy._id }); + const transferData = { scope: 'queue' as const, departmentId, transferredBy, ...overrideTransferData }; + try { + await saveTransferHistory(room, transferData); + await RoutingManager.unassignAgent(inquiry, departmentId); + } catch (err) { + livechatLogger.error({ err }); + throw new Meteor.Error('error-returning-inquiry'); + } + + callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); + + return true; +} + +export async function removeOmnichannelRoom(rid: string) { + livechatLogger.debug({ msg: 'Deleting room', roomId: rid }); + check(rid, String); + const room = await LivechatRooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + if (!isOmnichannelRoom(room)) { + throw new Meteor.Error('error-this-is-not-a-livechat-room'); + } + + if (room.open) { + throw new Meteor.Error('error-room-is-not-closed'); + } + + const inquiry = await LivechatInquiry.findOneByRoomId(rid); + + const result = await Promise.allSettled([ + Messages.removeByRoomId(rid), + ReadReceipts.removeByRoomId(rid), + Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), + LivechatInquiry.removeByRoomId(rid), + LivechatRooms.removeById(rid), + ReadReceiptsArchive.removeByRoomId(rid), + ]); + + if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) { + void notifyOnLivechatInquiryChanged(inquiry, 'removed'); + } + if (result[4]?.status === 'fulfilled' && result[4].value?.deletedCount) { + void notifyOnRoomChanged(room, 'removed'); + } + + for (const r of result) { + if (r.status === 'rejected') { + livechatLogger.error({ msg: 'Error removing room', roomId: rid, err: r.reason }); + throw new Meteor.Error('error-removing-room', 'Error removing room'); + } + } +} diff --git a/apps/meteor/server/lib/omnichannel/routing/AutoSelection.ts b/apps/meteor/server/lib/omnichannel/routing/AutoSelection.ts new file mode 100644 index 0000000..64cb3f8 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/routing/AutoSelection.ts @@ -0,0 +1,51 @@ +import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings'; +import { LivechatDepartmentAgents, Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { RoutingManager } from '../RoutingManager'; + +/* Auto Selection Queuing method: + * + * default method where the agent with the least number + * of open chats is paired with the incoming livechat + */ +class AutoSelection implements IRoutingMethod { + config: RoutingMethodConfig; + + constructor() { + this.config = { + previewRoom: false, + showConnecting: false, + showQueue: false, + showQueueLink: true, + returnQueue: false, + enableTriggerAction: true, + autoAssignAgent: true, + }; + } + + async getNextAgent(department?: string, ignoreAgentId?: string): Promise { + const extraQuery = await callbacks.run('livechat.applySimultaneousChatRestrictions', undefined, { + ...(department ? { departmentId: department } : {}), + }); + if (department) { + return LivechatDepartmentAgents.getNextAgentForDepartment( + department, + settings.get('Livechat_enabled_when_agent_idle'), + ignoreAgentId, + extraQuery, + settings.get('Livechat_accept_chats_with_no_agents'), + ); + } + + return Users.getNextAgent( + ignoreAgentId, + extraQuery, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + } +} + +RoutingManager.registerMethod('Auto_Selection', AutoSelection); diff --git a/apps/meteor/server/lib/omnichannel/routing/External.ts b/apps/meteor/server/lib/omnichannel/routing/External.ts new file mode 100644 index 0000000..1d3ee5a --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/routing/External.ts @@ -0,0 +1,81 @@ +import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { SystemLogger } from '../../logger/system'; +import { RoutingManager } from '../RoutingManager'; + +class ExternalQueue implements IRoutingMethod { + config: RoutingMethodConfig; + + constructor() { + this.config = { + previewRoom: false, + showConnecting: false, + showQueue: false, + showQueueLink: false, + returnQueue: false, + enableTriggerAction: true, + autoAssignAgent: true, + }; + } + + async getNextAgent(department?: string, ignoreAgentId?: string): Promise { + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(this.getAgentFromExternalQueue(department, ignoreAgentId)); + } + try { + const results = (await Promise.all(promises)).filter(Boolean); + if (!results.length) { + throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); + } + return results[0]; + } catch (err) { + SystemLogger.error({ msg: 'Error requesting agent from external queue.', err }); + throw err; + } + } + + private async getAgentFromExternalQueue(department?: string, ignoreAgentId?: string): Promise { + try { + const request = await fetch(`${settings.get('Livechat_External_Queue_URL')}`, { + headers: { + 'User-Agent': 'RocketChat Server', + 'Accept': 'application/json', + 'X-RocketChat-Secret-Token': settings.get('Livechat_External_Queue_Token'), + }, + params: { + ...(department && { departmentId: department }), + ...(ignoreAgentId && { ignoreAgentId }), + }, + // // SECURITY: The URL is a value that is only configurable by admins/users with the right permissions. It's ok to disable it here. + ignoreSsrfValidation: true, + }); + const result = (await request.json()) as { username?: string }; + + if (result?.username) { + const agent = await Users.findOneOnlineAgentByUserList( + result.username, + {}, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + + if (!agent?.username) { + return; + } + return { + agentId: agent._id, + username: agent.username, + }; + } + } catch (err) { + SystemLogger.error({ msg: 'Error requesting agent from external queue.', err }); + } + } +} + +RoutingManager.registerMethod('External', ExternalQueue); diff --git a/apps/meteor/server/lib/omnichannel/routing/ManualSelection.ts b/apps/meteor/server/lib/omnichannel/routing/ManualSelection.ts new file mode 100644 index 0000000..99be927 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/routing/ManualSelection.ts @@ -0,0 +1,34 @@ +import type { IRoutingMethod, RoutingMethodConfig, SelectedAgent } from '@rocket.chat/core-typings'; + +import { RoutingManager } from '../RoutingManager'; + +/* Manual Selection Queuing Method: + * + * An incoming livechat is created as an Inquiry + * which is picked up from an agent. + * An Inquiry is visible to all agents + * + * A room is still created with the initial message, but it is occupied by + * only the client until paired with an agent + */ +class ManualSelection implements IRoutingMethod { + config: RoutingMethodConfig; + + constructor() { + this.config = { + previewRoom: true, + showConnecting: true, + showQueue: true, + showQueueLink: false, + returnQueue: true, + enableTriggerAction: false, + autoAssignAgent: false, + }; + } + + getNextAgent(_department?: string, _excludeAgent?: string): Promise { + return Promise.resolve(undefined); + } +} + +RoutingManager.registerMethod('Manual_Selection', ManualSelection); diff --git a/apps/meteor/server/lib/omnichannel/sendMessageBySMS.ts b/apps/meteor/server/lib/omnichannel/sendMessageBySMS.ts new file mode 100644 index 0000000..28e5a4d --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/sendMessageBySMS.ts @@ -0,0 +1,79 @@ +import { OmnichannelIntegration } from '@rocket.chat/core-services'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +import { callbackLogger } from './logger'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { normalizeMessageFileUpload } from '../utils/functions/normalizeMessageFileUpload'; + +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room }) => { + // skips this callback if the message was edited + if (isEditedMessage(message)) { + return message; + } + + if (!settings.get('SMS_Enabled')) { + return message; + } + + // only send the sms by SMS if it is a livechat room with SMS set to true + if (!(room.sms && room.v && room.v.token)) { + return message; + } + + // if the message has a token, it was sent from the visitor, so ignore it + if (message.token) { + return message; + } + + // if the message has a type means it is a special message (like the closing comment), so skips + if (message.t) { + return message; + } + + const { rid, u: { _id: userId } = {} } = message; + let extraData = { rid, userId }; + if (message.file) { + message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; + const { fileUpload } = message; + extraData = Object.assign({}, extraData, { fileUpload }); + } + + if (message.location) { + const { location } = message; + extraData = Object.assign({}, extraData, { location }); + } + const service = settings.get('SMS_Service'); + + const SMSService = await OmnichannelIntegration.getSmsService(service); + + if (!SMSService) { + callbackLogger.debug('SMS Service is not configured, skipping SMS send'); + return message; + } + + const visitor = await LivechatVisitors.getVisitorByToken(room.v.token, { projection: { phone: 1, source: 1 } }); + + if (!visitor?.phone || visitor.phone.length === 0) { + return message; + } + + try { + await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); + callbackLogger.debug({ + msg: 'SMS message sent', + phoneNumber: visitor.phone[0].phoneNumber, + service, + }); + } catch (err) { + callbackLogger.error({ msg: 'Error sending SMS message', err }); + } + + return message; + }, + callbacks.priority.LOW, + 'sendMessageBySms', +); diff --git a/apps/meteor/server/lib/omnichannel/sendTranscript.ts b/apps/meteor/server/lib/omnichannel/sendTranscript.ts new file mode 100644 index 0000000..56c7221 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/sendTranscript.ts @@ -0,0 +1,262 @@ +import { Message, Omnichannel } from '@rocket.chat/core-services'; +import { + type IUser, + type MessageTypesValues, + type IOmnichannelSystemMessage, + type ILivechatVisitor, + type IOmnichannelRoom, + isFileAttachment, + isFileImageAttachment, + type AtLeast, +} from '@rocket.chat/core-typings'; +import colors from '@rocket.chat/fuselage-tokens/dist/colors.json'; +import { Logger } from '@rocket.chat/logger'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models'; +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { Meteor } from 'meteor/meteor'; +import moment from 'moment-timezone'; + +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { i18n } from '../i18n'; +import { FileUpload } from '../media/file-upload'; +import * as Mailer from '../notifications/email/api'; +import { getTimezone } from '../utils/lib/getTimezone'; + +const logger = new Logger('Livechat-SendTranscript'); + +const DOMPurify = createDOMPurify(new JSDOM('').window); + +export async function sendTranscript({ + token, + rid, + email, + subject, + user, +}: { + token: string; + rid: string; + email: string; + subject?: string; + user?: Pick | null; +}): Promise { + logger.debug({ msg: 'Sending conversation transcript', rid, token }); + + const room = await LivechatRooms.findOneById>(rid, { projection: { _id: 1, v: 1 } }); + if (!room) { + throw new Error('error-invalid-room'); + } + + const visitor = room?.v as ILivechatVisitor; + if (token !== visitor?.token) { + throw new Error('error-invalid-visitor'); + } + + const userLanguage = settings.get('Language') || 'en'; + const timezone = getTimezone(user); + logger.debug({ msg: 'Transcript will be sent using timezone', timezone }); + + const showAgentInfo = settings.get('Livechat_show_agent_info'); + const showSystemMessages = settings.get('Livechat_transcript_show_system_messages'); + const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + 'omnichannel_priority_change_history', + ]; + const messages = Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( + rid, + ignoredMessageTypes, + closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), + showSystemMessages, + { + sort: { ts: 1 }, + }, + ); + let html = '

'; + const InvalidFileMessage = `
${i18n.t( + 'This_attachment_is_not_supported', + { lng: userLanguage }, + )}
`; + + function escapeHtml(str: string): string { + if (typeof str !== 'string') return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + } + + for await (const message of messages) { + let author; + if (message.u._id === visitor._id) { + author = i18n.t('You', { lng: userLanguage }); + } else { + author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); + } + + const messageType = MessageTypes.getType(message); + + let messageContent = messageType?.system + ? DOMPurify.sanitize(` + ${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}`) + : escapeHtml(message.msg); + + let filesHTML = ''; + + if (message.attachments && message.attachments?.length > 0) { + messageContent = message.attachments[0].description || ''; + escapeHtml(messageContent); + + for await (const attachment of message.attachments) { + if (!isFileAttachment(attachment)) { + continue; + } + + if (!isFileImageAttachment(attachment)) { + filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; + continue; + } + + const file = message.files?.find((file) => file.name === attachment.title); + + if (!file) { + filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; + continue; + } + + const uploadedFile = await Uploads.findOneById(file._id); + + if (!uploadedFile) { + filesHTML += `
${escapeHtml(file.name)}${InvalidFileMessage}
`; + continue; + } + + const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); + filesHTML += `
+

${escapeHtml(file.name)}

+ +
`; + } + } + + const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); + const singleMessage = ` +

${escapeHtml(author)} ${escapeHtml(datetime)}

+

${messageContent}

+

${filesHTML}

+ `; + html += singleMessage; + } + + html = `${html}
`; + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + let emailFromRegexp = ''; + if (fromEmail) { + emailFromRegexp = fromEmail[0]; + } else { + emailFromRegexp = settings.get('From_Email'); + } + + // Some endpoints allow the caller to pass a different `subject` via parameter. + // IF subject is passed, we'll use that one and treat it as an override + // IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject` + // IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value + // As subject and setting value are user input, we don't translate them + const mailSubject = + subject || + settings.get('Livechat_transcript_email_subject') || + i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); + + await Mailer.send({ + to: email, + from: emailFromRegexp, + replyTo: emailFromRegexp, + subject: mailSubject, + html, + }); + + setImmediate(() => { + void callbacks.run('livechat.sendTranscript', messages, email); + }); + + const requestData: IOmnichannelSystemMessage['requestData'] = { + type: 'user', + visitor, + user, + }; + + if (!user?.username) { + const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); + if (cat) { + requestData.user = cat; + requestData.type = 'visitor'; + } + } + + if (!requestData.user) { + logger.error('rocket.cat user not found'); + throw new Error('No user provided and rocket.cat not found'); + } + + await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { + requestData, + }); + + return true; +} + +export async function requestTranscript({ + rid, + email, + subject, + user, +}: { + rid: string; + email: string; + subject: string; + user: AtLeast; +}) { + // `v` is required by the MAC-limit check below + const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 1, v: 1 } }); + + if (!room?.open) { + throw new Meteor.Error('error-invalid-room', 'Invalid room'); + } + + if (room.transcriptRequest) { + throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested'); + } + + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + const { _id, username, name, utcOffset } = user; + const transcriptRequest = { + requestedAt: new Date(), + requestedBy: { + _id, + username, + name, + utcOffset, + }, + email, + subject, + }; + + await LivechatRooms.setEmailTranscriptRequestedByRoomId(rid, transcriptRequest); + return true; +} diff --git a/apps/meteor/server/lib/omnichannel/service-status.ts b/apps/meteor/server/lib/omnichannel/service-status.ts new file mode 100644 index 0000000..18fd662 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/service-status.ts @@ -0,0 +1,90 @@ +import type { ILivechatAgent, ILivechatDepartment, SelectedAgent } from '@rocket.chat/core-typings'; +import { Users, LivechatDepartmentAgents, LivechatDepartment } from '@rocket.chat/models'; +import type { FindCursor } from 'mongodb'; + +import { checkOnlineForDepartment, getOnlineForDepartment } from './departmentsLib'; +import { livechatLogger } from './logger'; +import { settings } from '../../settings'; + +export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { + if (agent?.agentId) { + return Users.findOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + } + + if (department) { + return getOnlineForDepartment(department); + } + return Users.findOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); +} + +export async function online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { + livechatLogger.debug({ msg: 'Checking online agents', department }); + if (!skipNoAgentSetting && settings.get('Livechat_accept_chats_with_no_agents')) { + livechatLogger.debug('Can accept without online agents: true'); + return true; + } + + if (settings.get('Livechat_assign_new_conversation_to_bot')) { + livechatLogger.debug({ msg: 'Fetching online bot agents for department', department }); + // get & count where doing the same, but get was getting data, while count was only counting. We only need the count here + const botAgents = await countBotAgents(department); + if (botAgents) { + livechatLogger.debug({ msg: 'Found online bot agents', botAgents }); + if (botAgents > 0) { + return true; + } + } + } + + const agentsOnline = await checkOnlineAgents(department, undefined, skipFallbackCheck); + livechatLogger.debug({ msg: 'Online agents status', department, agentsOnline }); + return agentsOnline; +} + +export async function checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise { + if (agent?.agentId) { + return Users.checkOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); + } + + if (department) { + const onlineForDep = await checkOnlineForDepartment(department); + if (onlineForDep || skipFallbackCheck) { + return !!onlineForDep; + } + + const dep = await LivechatDepartment.findOneById>(department, { + projection: { fallbackForwardDepartment: 1 }, + }); + if (!dep?.fallbackForwardDepartment) { + return !!onlineForDep; + } + + return checkOnlineAgents(dep?.fallbackForwardDepartment); + } + + return Users.checkOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); +} + +async function countBotAgents(department?: string) { + if (department) { + return LivechatDepartmentAgents.countBotsForDepartment(department); + } + + return Users.countBotAgents(); +} diff --git a/apps/meteor/server/lib/omnichannel/settings.ts b/apps/meteor/server/lib/omnichannel/settings.ts new file mode 100644 index 0000000..32fd2e7 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/settings.ts @@ -0,0 +1,60 @@ +import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings'; + +import { showConnecting } from './utils'; +import { settings } from '../../settings'; + +export const getInquirySortMechanismSetting = (): OmnichannelSortingMechanismSettingType => + settings.get('Omnichannel_sorting_mechanism') || OmnichannelSortingMechanismSettingType.Timestamp; + +export async function getInitSettings() { + const validSettings = [ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Message_MaxAllowedSize', + 'Livechat_enabled', + 'Livechat_registration_form', + 'Livechat_allow_switching_departments', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_form_unavailable', + 'Livechat_display_offline_form', + 'Omnichannel_call_provider', + 'Language', + 'Livechat_enable_transcript', + 'Livechat_transcript_message', + 'Livechat_fileupload_enabled', + 'FileUpload_Enabled', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_force_accept_data_processing_consent', + 'Livechat_data_processing_consent_text', + 'Livechat_show_agent_info', + 'Livechat_clear_local_storage_when_chat_ended', + 'Livechat_history_monitor_type', + 'Livechat_hide_system_messages', + 'Livechat_widget_position', + 'Livechat_background', + 'Assets_livechat_widget_logo', + 'Livechat_hide_watermark', + 'Omnichannel_allow_visitors_to_close_conversation', + 'Livechat_hide_expand_chat', + ] as const; + + type SettingTypes = (typeof validSettings)[number] | 'Livechat_Show_Connecting'; + + const rcSettings = validSettings.reduce>((acc, setting) => { + acc[setting] = settings.get(setting); + return acc; + }, {} as any); + + rcSettings.Livechat_Show_Connecting = showConnecting(); + + return rcSettings; +} diff --git a/apps/meteor/server/lib/omnichannel/startup.ts b/apps/meteor/server/lib/omnichannel/startup.ts new file mode 100644 index 0000000..05a184b --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/startup.ts @@ -0,0 +1,148 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { validateEmail, wrapExceptions } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { businessHourManager } from './business-hour'; +import { createDefaultBusinessHourIfNotExists } from './business-hour/Helper'; +import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor'; +import { setUserStatusLivechatIf } from './utils'; +import { maybeMigrateLivechatRoom } from '../../api/lib/maybeMigrateLivechatRoom'; +import { settings } from '../../settings'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { callbacks } from '../callbacks'; +import { beforeLeaveRoomCallback } from '../callbacks/beforeLeaveRoomCallback'; +import { i18n } from '../i18n'; +import { notifyOnUserChange } from '../notifyListener'; +import { roomCoordinator } from '../rooms/roomCoordinator'; +import './roomAccessValidator.internalService'; +import { ContactMerger, type FieldAndValue } from './contacts/ContactMerger'; + +const logger = new Logger('LivechatStartup'); + +// TODO this patch is temporary because `ContactMerger` still a lot of dependencies, so it is not suitable to be moved to omni-core package +// TODO add tests covering the ContactMerger usage +registerGuest.patch(async (originalFn, newData, options) => { + const visitor = await originalFn(newData, options); + if (!visitor) { + return null; + } + + const { name, phone, email, username } = newData; + + const validatedEmail = + email && + wrapExceptions(() => { + const trimmedEmail = email.trim().toLowerCase(); + validateEmail(trimmedEmail); + return trimmedEmail; + }).suppress(); + + const fields = [ + { type: 'name', value: name }, + { type: 'phone', value: phone?.number }, + { type: 'email', value: validatedEmail }, + { type: 'username', value: username || visitor.username }, + ].filter((field) => Boolean(field.value)) as FieldAndValue[]; + + if (!fields.length) { + return null; + } + + // If a visitor was updated who already had contacts, load up the contacts and update that information as well + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + for await (const contact of contacts) { + await ContactMerger.mergeFieldsIntoContact({ + fields, + contact, + conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', + }); + } + + return visitor; +}); + +Meteor.startup(async () => { + roomCoordinator.setRoomFind('l', async (id) => maybeMigrateLivechatRoom(await LivechatRooms.findOneById(id))); + + beforeLeaveRoomCallback.add( + (user, room) => { + if (!isOmnichannelRoom(room)) { + return; + } + throw new Meteor.Error( + i18n.t('You_cant_leave_a_livechat_room_Please_use_the_close_button', { + lng: user.language || settings.get('Language') || 'en', + }), + ); + }, + callbacks.priority.LOW, + 'cant-leave-omnichannel-room', + ); + + callbacks.add( + 'beforeJoinRoom', + async (user, room) => { + if (isOmnichannelRoom(room) && !(await hasPermissionAsync(user, 'view-l-room'))) { + throw new Meteor.Error('error-user-is-not-agent', 'User is not an Omnichannel Agent', { + method: 'beforeJoinRoom', + }); + } + + return user; + }, + callbacks.priority.LOW, + 'cant-join-omnichannel-room', + ); + + const monitor = new LivechatAgentActivityMonitor(); + + settings.watch('Troubleshoot_Disable_Livechat_Activity_Monitor', async (value) => { + if (value) { + return monitor.stop(); + } + + await monitor.start(); + }); + await createDefaultBusinessHourIfNotExists(); + + settings.watch( + 'Livechat_enable_business_hours', + async (value) => { + logger.debug({ msg: 'Starting business hour manager', enabled: value }); + if (value) { + await businessHourManager.startManager(); + return; + } + await businessHourManager.stopManager(); + }, + process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' + ? { + debounce: 10, + } + : undefined, + ); + + // Remove when accounts.onLogout is async + Accounts.onLogout(({ user }: { user?: IUser }) => { + if (!user?.roles?.includes('livechat-agent') || user?.roles?.includes('bot')) { + return; + } + + void setUserStatusLivechatIf(user._id, ILivechatAgentStatus.NOT_AVAILABLE, {}, { livechatStatusSystemModified: true }).catch(); + + // TODO: Shouldn't this notifier be the same as the one inside setUserStatusLivechatIf? + void notifyOnUserChange({ + id: user._id, + clientAction: 'updated', + diff: { + statusLivechat: ILivechatAgentStatus.NOT_AVAILABLE, + livechatStatusSystemModified: true, + }, + }); + }); +}); diff --git a/apps/meteor/server/lib/omnichannel/statistics/LivechatAgentActivityMonitor.ts b/apps/meteor/server/lib/omnichannel/statistics/LivechatAgentActivityMonitor.ts new file mode 100644 index 0000000..fd5f273 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/statistics/LivechatAgentActivityMonitor.ts @@ -0,0 +1,166 @@ +import type { ILivechatAgent, ISocketConnection } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { LivechatAgentActivity, Sessions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import moment from 'moment'; + +import { callbacks } from '../../callbacks'; + +const formatDate = (dateTime = new Date()): { date: number } => ({ + date: parseInt(moment(dateTime).format('YYYYMMDD')), +}); + +export class LivechatAgentActivityMonitor { + private _started: boolean; + + private _name: string; + + private scheduler = cronJobs; + + constructor() { + this._started = false; + this._handleAgentStatusChanged = this._handleAgentStatusChanged.bind(this); + this._handleUserStatusLivechatChanged = this._handleUserStatusLivechatChanged.bind(this); + this._updateActiveSessions = this._updateActiveSessions.bind(this); + this._name = 'Livechat Agent Activity Monitor'; + } + + async start(): Promise { + await this._setupListeners(); + } + + async stop(): Promise { + if (!this.isRunning()) { + return; + } + + await this.scheduler.remove(this._name); + + this._started = false; + } + + isRunning(): boolean { + return this._started; + } + + async _setupListeners(): Promise { + if (this.isRunning()) { + return; + } + await this._startMonitoring(); + + // TODO use service event socket.connected instead + Meteor.onConnection((connection: unknown) => this._handleMeteorConnection(connection as ISocketConnection)); + callbacks.add('livechat.agentStatusChanged', this._handleAgentStatusChanged); + callbacks.add('livechat.setUserStatusLivechat', (...args) => { + return this._handleUserStatusLivechatChanged(...args); + }); + this._started = true; + } + + async _startMonitoring(): Promise { + await this.scheduler.add(this._name, '0 0 * * *', () => this._updateActiveSessions()); + } + + async _updateActiveSessions(): Promise { + const openLivechatAgentSessions = LivechatAgentActivity.findOpenSessions(); + + const today = moment(new Date()); + const startedAt = new Date(today.year(), today.month(), today.date()); + for await (const session of openLivechatAgentSessions) { + const startDate = moment(session.lastStartedAt); + const stoppedAt = new Date(startDate.year(), startDate.month(), startDate.date(), 23, 59, 59); + const data = { ...formatDate(startDate.toDate()), agentId: session.agentId }; + const availableTime = moment(stoppedAt).diff(moment(new Date(session.lastStartedAt)), 'seconds'); + + await Promise.all([ + LivechatAgentActivity.updateLastStoppedAt({ ...data, availableTime, lastStoppedAt: stoppedAt }), + LivechatAgentActivity.updateServiceHistory({ ...data, serviceHistory: { startedAt: session.lastStartedAt, stoppedAt } }), + ]); + await this._createOrUpdateSession(session.agentId, startedAt); + } + } + + async _handleMeteorConnection(connection: ISocketConnection): Promise { + if (!this.isRunning()) { + return; + } + + const session = await Sessions.findOneBySessionId(connection.id); + if (!session) { + return; + } + const user = await Users.findOneById>(session.userId, { + projection: { _id: 1, status: 1, statusLivechat: 1 }, + }); + if (user && user.status !== 'offline' && user.statusLivechat === 'available') { + await this._createOrUpdateSession(user._id); + } + connection.onClose(() => { + if (session) { + void this._updateSessionWhenAgentStop(session.userId); + } + }); + } + + async _handleAgentStatusChanged({ userId, status }: { userId: string; status: string }) { + if (!this.isRunning()) { + return; + } + + const user = await Users.findOneById>(userId, { projection: { statusLivechat: 1 } }); + if (!user || user.statusLivechat !== 'available') { + return; + } + + if (status !== 'offline') { + await this._createOrUpdateSession(userId); + } else { + await this._updateSessionWhenAgentStop(userId); + } + } + + async _handleUserStatusLivechatChanged({ userId, status }: { userId: string; status: string }): Promise { + if (!this.isRunning()) { + return; + } + + const user = await Users.findOneById>(userId, { projection: { status: 1 } }); + if (user && user.status === 'offline') { + return; + } + + if (status === 'available') { + await this._createOrUpdateSession(userId); + } + if (status === 'not-available') { + await this._updateSessionWhenAgentStop(userId); + } + } + + async _createOrUpdateSession(userId: string, lastStartedAt?: Date): Promise { + const data = { ...formatDate(lastStartedAt), agentId: userId, lastStartedAt }; + await LivechatAgentActivity.createOrUpdate(data); + } + + async _updateSessionWhenAgentStop(agentId: string): Promise { + const { date } = formatDate(); + + const livechatSession = await LivechatAgentActivity.findOneByAgendIdAndDate(agentId, date); + if (!livechatSession) { + return; + } + + const stoppedAt = new Date(); + const availableTime = moment(stoppedAt).diff(moment(new Date(livechatSession.lastStartedAt)), 'seconds'); + + await Promise.all([ + LivechatAgentActivity.updateLastStoppedAt({ agentId, date, availableTime, lastStoppedAt: stoppedAt }), + LivechatAgentActivity.updateServiceHistory({ + agentId, + date, + serviceHistory: { startedAt: livechatSession.lastStartedAt, stoppedAt }, + }), + ]); + } +} diff --git a/apps/meteor/server/lib/omnichannel/stream/agentStatus.ts b/apps/meteor/server/lib/omnichannel/stream/agentStatus.ts new file mode 100644 index 0000000..7fb615b --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/stream/agentStatus.ts @@ -0,0 +1,86 @@ +import { Logger } from '@rocket.chat/logger'; + +import { settings } from '../../../settings'; +import { closeOpenChats } from '../closeRoom'; +import { forwardOpenChats } from '../transfer'; + +const logger = new Logger('AgentStatusWatcher'); + +export let monitorAgents = false; +let actionTimeout = 60000; +let action = 'none'; +let comment = ''; + +settings.watch('Livechat_agent_leave_action_timeout', (value) => { + if (typeof value !== 'number') { + return; + } + actionTimeout = value * 1000; +}); + +settings.watch('Livechat_agent_leave_action', (value) => { + monitorAgents = value !== 'none'; + action = value as string; +}); + +settings.watch('Livechat_agent_leave_comment', (value) => { + if (typeof value !== 'string') { + return; + } + comment = value; +}); + +export const onlineAgents = { + users: new Set(), + queue: new Map(), + + add(userId: string): void { + if (this.exists(userId)) { + return; + } + + if (this.queue.has(userId)) { + clearTimeout(this.queue.get(userId)); + this.queue.delete(userId); + } + this.users.add(userId); + }, + + remove(userId: string): void { + if (!this.exists(userId)) { + return; + } + this.users.delete(userId); + + if (this.queue.has(userId)) { + clearTimeout(this.queue.get(userId)); + } + + this.queue.set(userId, setTimeout(this.runAgentLeaveAction, actionTimeout, userId)); + }, + + exists(userId: string): boolean { + return this.users.has(userId); + }, + + runAgentLeaveAction: async (userId: string) => { + onlineAgents.users.delete(userId); + onlineAgents.queue.delete(userId); + + try { + if (action === 'close') { + return await closeOpenChats(userId, comment); + } + + if (action === 'forward') { + return await forwardOpenChats(userId); + } + } catch (e) { + logger.error({ + msg: 'Cannot perform action', + action, + err: e, + }); + } + }, +}; diff --git a/apps/meteor/server/lib/omnichannel/takeInquiry.ts b/apps/meteor/server/lib/omnichannel/takeInquiry.ts new file mode 100644 index 0000000..047d25a --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/takeInquiry.ts @@ -0,0 +1,65 @@ +import { Omnichannel } from '@rocket.chat/core-services'; +import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoutingManager } from './RoutingManager'; +import { isAgentAvailableToTakeContactInquiry } from './contacts/isAgentAvailableToTakeContactInquiry'; +import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact'; +import { settings } from '../../settings'; + +export const takeInquiry = async ( + userId: string, + inquiryId: string, + options?: { clientAction: boolean; forwardingToDepartment?: { oldDepartmentId: string; transferData: any } }, +): Promise => { + const inquiry = await LivechatInquiry.findOneById(inquiryId); + + if (!inquiry) { + throw new Meteor.Error('error-not-found', 'Inquiry not found', { + method: 'livechat:takeInquiry', + }); + } + + if (inquiry.status === 'taken') { + throw new Meteor.Error('error-inquiry-taken', 'Inquiry already taken', { + method: 'livechat:takeInquiry', + }); + } + + const user = await Users.findOneOnlineAgentById( + userId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + {}, + ); + if (!user) { + throw new Meteor.Error('error-agent-status-service-offline', 'Agent status is offline or Omnichannel service is not active', { + method: 'livechat:takeInquiry', + ...(process.env.TEST_MODE && { + Livechat_enabled_when_agent_idle: settings.get('Livechat_enabled_when_agent_idle'), + Livechat_accept_chats_with_no_agents: settings.get('Livechat_accept_chats_with_no_agents'), + user: await Users.findOneById(userId), + }), + }); + } + + const room = await LivechatRooms.findOneById(inquiry.rid); + if (!room || !(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached'); + } + + const contactId = room.contactId ?? (await migrateVisitorIfMissingContact(room.v._id, room.source)); + if (contactId) { + const isAgentAvailableToTakeContactInquiryResult = await isAgentAvailableToTakeContactInquiry(inquiry.v._id, room.source, contactId); + if (!isAgentAvailableToTakeContactInquiryResult.value) { + throw new Meteor.Error(isAgentAvailableToTakeContactInquiryResult.error); + } + } + + const agent = { + agentId: user._id, + username: user.username, + }; + + await RoutingManager.takeInquiry(inquiry, agent, options ?? {}, room); +}; diff --git a/apps/meteor/server/lib/omnichannel/tracking.ts b/apps/meteor/server/lib/omnichannel/tracking.ts new file mode 100644 index 0000000..a9a0f28 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/tracking.ts @@ -0,0 +1,54 @@ +import { Message } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; + +import { livechatLogger } from './logger'; +import { settings } from '../../settings'; + +type PageInfo = { title: string; location: { href: string }; change: string }; + +export async function savePageHistory(token: string, roomId: string | undefined, pageInfo: PageInfo) { + livechatLogger.debug({ + msg: `Saving page movement history for visitor with token ${token}`, + pageInfo, + roomId, + }); + + if (pageInfo.change !== settings.get('Livechat_history_monitor_type')) { + return; + } + const user = await Users.findOneById('rocket.cat'); + + if (!user) { + throw new Error('error-invalid-user'); + } + + const pageTitle = pageInfo.title; + const pageUrl = pageInfo.location.href; + const extraData: { + navigation: { + page: PageInfo; + token: string; + }; + expireAt?: number; + _hidden?: boolean; + } = { + navigation: { + page: pageInfo, + token, + }, + }; + + if (!roomId) { + livechatLogger.warn({ msg: 'Saving page history without room id for visitor', token }); + // keep history of unregistered visitors for 1 month + const keepHistoryMiliseconds = 2592000000; + extraData.expireAt = new Date().getTime() + keepHistoryMiliseconds; + } + + if (!settings.get('Livechat_Visitor_navigation_as_a_message')) { + extraData._hidden = true; + } + + // @ts-expect-error: Investigating on which case we won't receive a roomId and where that history is supposed to be stored + return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData); +} diff --git a/apps/meteor/server/lib/omnichannel/transfer.ts b/apps/meteor/server/lib/omnichannel/transfer.ts new file mode 100644 index 0000000..a447fbc --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/transfer.ts @@ -0,0 +1,85 @@ +import { Message } from '@rocket.chat/core-services'; +import type { ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom, TransferData } from '@rocket.chat/core-typings'; +import { Users, LivechatRooms, LivechatVisitors, LivechatDepartment } from '@rocket.chat/models'; +import { check, Match } from 'meteor/check'; + +import { normalizeTransferredByData } from './Helper'; +import { RoutingManager } from './RoutingManager'; +import { livechatLogger } from './logger'; + +export async function saveTransferHistory(room: IOmnichannelRoom, transferData: TransferData) { + const { departmentId: previousDepartment } = room; + const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; + + check( + transferredBy, + Match.ObjectIncluding({ + _id: String, + username: String, + name: Match.Maybe(String), + userType: String, + }), + ); + + const { _id, username } = transferredBy; + const scopeData = scope || (nextDepartment ? 'department' : 'agent'); + livechatLogger.info({ msg: 'Storing new chat transfer', roomId: room._id, transferredBy: _id, scope: scopeData }); + + const transferMessage = { + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), + transferData: { + transferredBy, + ts: new Date(), + scope: scopeData, + comment, + ...(previousDepartment && { previousDepartment }), + ...(nextDepartment && { nextDepartment }), + ...(transferredTo && { transferredTo }), + }, + }; + + await Message.saveSystemMessageAndNotifyUser('livechat_transfer_history', room._id, '', { _id, username }, transferMessage); +} + +export async function forwardOpenChats(userId: string) { + livechatLogger.debug({ msg: 'Transferring open chats for user', userId }); + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('error-invalid-user'); + } + + const { _id, username, name } = user; + for await (const room of LivechatRooms.findOpenByAgent(userId)) { + const guest = await LivechatVisitors.findOneEnabledById(room.v._id); + if (!guest) { + continue; + } + + const transferredBy = normalizeTransferredByData({ _id, username, name }, room); + await transfer(room, guest, { + transferredBy, + departmentId: guest.department, + }); + } +} + +export async function transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) { + livechatLogger.debug({ msg: 'Transferring room', roomId: room._id, transferredBy: transferData?.transferredBy?._id }); + if (room.onHold) { + throw new Error('error-room-onHold'); + } + + if (transferData.departmentId) { + const department = await LivechatDepartment.findOneById>(transferData.departmentId, { + projection: { name: 1 }, + }); + if (!department) { + throw new Error('error-invalid-department'); + } + + transferData.department = department; + livechatLogger.debug({ msg: 'Transferring room to department', roomId: room._id, departmentId: transferData.department?._id }); + } + + return RoutingManager.transferRoom(room, guest, transferData); +} diff --git a/apps/meteor/server/lib/omnichannel/utils.ts b/apps/meteor/server/lib/omnichannel/utils.ts new file mode 100644 index 0000000..6516a63 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/utils.ts @@ -0,0 +1,62 @@ +import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import type { Filter } from 'mongodb'; + +import { RoutingManager } from './RoutingManager'; +import { businessHourManager } from './business-hour'; +import type { AKeyOf } from './localTypes'; +import { callbacks } from '../callbacks'; +import { notifyOnUserChange } from '../notifyListener'; + +export function showConnecting() { + return RoutingManager.getConfig()?.showConnecting || false; +} + +export async function setUserStatusLivechat(userId: string, status: ILivechatAgentStatus) { + const user = await Users.setLivechatStatus(userId, status); + // TODO: shouldnt this callback run if the modified count is > 0 too? + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + + if (user.modifiedCount > 0) { + void notifyOnUserChange({ + id: userId, + clientAction: 'updated', + diff: { + statusLivechat: status, + livechatStatusSystemModified: false, + }, + }); + } + + return user; +} + +export async function setUserStatusLivechatIf( + userId: string, + status: ILivechatAgentStatus, + condition?: Filter, + fields?: AKeyOf, +) { + const result = await Users.setLivechatStatusIf(userId, status, condition, fields); + + if (result.modifiedCount > 0) { + void notifyOnUserChange({ + id: userId, + clientAction: 'updated', + diff: { ...fields, statusLivechat: status }, + }); + } + + // TODO: shouldnt this callback run if the modified count is > 0 too? + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + return result; +} + +export async function allowAgentChangeServiceStatus(statusLivechat: ILivechatAgentStatus, agentId: string) { + if (statusLivechat !== ILivechatAgentStatus.AVAILABLE) { + return true; + } + + return businessHourManager.allowAgentChangeServiceStatus(agentId); +} diff --git a/apps/meteor/server/lib/omnichannel/webhooks.ts b/apps/meteor/server/lib/omnichannel/webhooks.ts new file mode 100644 index 0000000..85f9dc0 --- /dev/null +++ b/apps/meteor/server/lib/omnichannel/webhooks.ts @@ -0,0 +1,77 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type { Response } from '@rocket.chat/server-fetch'; + +import { webhooksLogger } from './logger'; +import { settings } from '../../settings'; +import { metrics } from '../metrics'; + +const isRetryable = (status: number): boolean => status >= 500 || status === 429; + +export async function sendRequest( + postData: { + type: string; + [key: string]: any; + }, + attempts = 5, + cb?: (response: Response) => Promise, +) { + const timeout = settings.get('Livechat_http_timeout'); + const secretToken = settings.get('Livechat_secret_token'); + const webhookUrl = settings.get('Livechat_webhookUrl'); + try { + webhooksLogger.debug({ msg: 'Sending webhook request', postData }); + const result = await fetch(webhookUrl, { + method: 'POST', + headers: { + ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), + }, + body: postData, + timeout, + // SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + size: 10 * 1024 * 1024, + }); + + if (result.status === 200) { + metrics.totalLivechatWebhooksSuccess.inc(); + metrics.totalLivechatWebhooksSuccessTotal.inc(); + await cb?.(result); + return result; + } + + if (!isRetryable(result.status)) { + webhooksLogger.error({ + msg: 'Non-retryable error response from webhook', + webhookUrl, + status: result.status, + response: await result.text(), + }); + metrics.totalLivechatWebhooksFailures.inc(); + metrics.totalLivechatWebhooksFailuresTotal.inc(); + return; + } + + metrics.totalLivechatWebhooksFailures.inc(); + metrics.totalLivechatWebhooksFailuresTotal.inc(); + throw new Error(await result.text()); + } catch (err) { + const retryAfter = timeout * 4; + webhooksLogger.debug({ + msg: 'Error response on retry', + attempt: 6 - attempts, + err, + newAttemptAfterSeconds: retryAfter / 1000, + webhookUrl, + }); + const remainingAttempts = attempts - 1; + // try 5 times after 20 seconds each + if (!remainingAttempts) { + webhooksLogger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); + return; + } + + setTimeout(async () => { + await sendRequest(postData, remainingAttempts, cb); + }, retryAfter); + } +} diff --git a/apps/meteor/server/lib/openRoom.ts b/apps/meteor/server/lib/openRoom.ts new file mode 100644 index 0000000..6469279 --- /dev/null +++ b/apps/meteor/server/lib/openRoom.ts @@ -0,0 +1,13 @@ +import { Subscriptions } from '@rocket.chat/models'; + +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from './notifyListener'; + +export async function openRoom(userId: string, roomId: string) { + const openByRoomResponse = await Subscriptions.openByRoomIdAndUserId(roomId, userId); + + if (openByRoomResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(roomId, userId); + } + + return openByRoomResponse.modifiedCount; +} diff --git a/apps/meteor/server/lib/parseMessageSearchQuery.ts b/apps/meteor/server/lib/parseMessageSearchQuery.ts new file mode 100644 index 0000000..7446b7d --- /dev/null +++ b/apps/meteor/server/lib/parseMessageSearchQuery.ts @@ -0,0 +1,341 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { Filter, FindOptions } from 'mongodb'; + +class MessageSearchQueryParser { + private query: Exclude, Partial> = {}; + + private options: FindOptions = { + projection: {}, + sort: { + ts: -1, + }, + skip: 0, + limit: 20, + }; + + private user: IUser | undefined; + + private forceRegex = false; + + constructor({ + user, + offset = 0, + limit = 20, + forceRegex = false, + }: { + user?: IUser; + offset?: number; + limit?: number; + forceRegex?: boolean; + }) { + this.user = user; + this.options.skip = offset; + this.options.limit = limit; + this.forceRegex = forceRegex; + } + + private consumeFrom(text: string) { + const from: string[] = []; + + return text.replace(/from:([a-z0-9.\-_]+)/gi, (_, username) => { + if (username === 'me' && this.user?.username && !from.includes(this.user.username)) { + username = this.user.username; + } + from.push(username); + + this.query['u.username'] = { + $regex: from.join('|'), + $options: 'i', + }; + + return ''; + }); + } + + private consumeMention(text: string) { + const mentions: string[] = []; + + return text.replace(/mention:([a-z0-9.\-_]+)/gi, (_: string, username: string) => { + mentions.push(username); + + this.query['mentions.username'] = { + $regex: mentions.join('|'), + $options: 'i', + }; + + return ''; + }); + } + + /** + * Filter on messages that are starred by the current user. + */ + private consumeHasStar(text: string) { + return text.replace(/has:star/g, () => { + if (this.user?._id) { + this.query['starred._id'] = this.user._id; + } + return ''; + }); + } + + /** + * Filter on messages that have an url. + */ + private consumeHasUrl(text: string) { + return text.replace(/has:url|has:link/g, () => { + this.query['urls.0'] = { + $exists: true, + }; + return ''; + }); + } + + /** + * Filter on pinned messages. + */ + private consumeIsPinned(text: string) { + return text.replace(/is:pinned|has:pin/g, () => { + this.query.pinned = true; + return ''; + }); + } + + /** + * Filter on messages which have a location attached. + */ + private consumeHasLocation(text: string) { + return text.replace(/has:location|has:map/g, () => { + this.query.location = { + $exists: true, + }; + return ''; + }); + } + + /** + * Filter image tags + */ + private consumeLabel(text: string) { + return text.replace(/label:*"([^"]+)"|label:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { + const tag = (quoted ?? unquoted)?.trim(); + if (!tag || typeof tag !== 'string') return ''; + + this.query['attachments.0.labels'] = { + $regex: escapeRegExp(tag.trim()), + $options: 'i', + }; + + return ''; + }); + } + + /** + * Filter on description of messages. + */ + private consumeFileDescription(text: string) { + return text.replace(/file-desc:"([^"]+)"|file-desc:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { + const tag = (quoted ?? unquoted)?.trim(); + if (!tag || typeof tag !== 'string') return ''; + + this.query['attachments.description'] = { + $regex: escapeRegExp(tag.trim()), + $options: 'i', + }; + + return ''; + }); + } + + /** + * Filter on title of messages. + */ + private consumeFileTitle(text: string) { + return text.replace(/file-title:"([^"]+)"|file-title:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => { + const tag = (quoted ?? unquoted)?.trim(); + if (!tag || typeof tag !== 'string') return ''; + + this.query['attachments.title'] = { + $regex: escapeRegExp(tag.trim()), + $options: 'i', + }; + + return ''; + }); + } + + /** + * Filter on messages that have been sent before a date. + */ + private consumeBefore(text: string) { + return text.replace(/before:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { + const beforeDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)); + beforeDate.setUTCHours(beforeDate.getUTCHours() + beforeDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + + this.query.ts = { + ...this.query.ts, + $lte: beforeDate, + }; + + return ''; + }); + } + + /** + * Filter on messages that have been sent after a date. + */ + private consumeAfter(text: string) { + return text.replace(/after:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { + const afterDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10) + 1); + afterDate.setUTCHours(afterDate.getUTCHours() + afterDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + + this.query.ts = { + ...this.query.ts, + $gte: afterDate, + }; + + return ''; + }); + } + + /** + * Filter on messages that have been sent on a date. + */ + private consumeOn(text: string) { + return text.replace(/on:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => { + const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)); + date.setUTCHours(date.getUTCHours() + date.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0)); + const dayAfter = new Date(date); + dayAfter.setDate(dayAfter.getDate() + 1); + + this.query.ts = { + $gte: date, + $lt: dayAfter, + }; + + return ''; + }); + } + + /** + * Sort by timestamp. + */ + consumeOrder(text: string) { + return text.replace(/(?:order|sort):(asc|ascend|ascending|desc|descend|descending)/g, (_: string, direction: string) => { + if (direction.startsWith('asc')) { + this.options.sort = { + ...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}), + ts: 1, + }; + } else if (direction.startsWith('desc')) { + this.options.sort = { + ...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}), + ts: -1, + }; + } + return ''; + }); + } + + /** + * Query in message text + */ + private consumeMessageText(text: string) { + text = text.trim().replace(/\s\s/g, ' '); + + if (text === '') { + return text; + } + + if (/^\/.+\/[imxs]*$/.test(text)) { + const r = text.split('/'); + + // We remove the 'x' flag that JS does not support but Mongo does + new RegExp(r[1], r[2].replace(/x/g, '')); + + this.query.msg = { + $regex: r[1], + $options: r[2], + }; + } else if (this.forceRegex) { + new RegExp(text, 'i'); + + this.query.msg = { + $regex: text, + $options: 'i', + }; + } else { + this.query.$text = { + $search: text, + }; + this.options.projection = { + score: { + $meta: 'textScore', + }, + }; + } + + return text; + } + + parse(text: string) { + [ + (input: string) => this.consumeFrom(input), + (input: string) => this.consumeMention(input), + (input: string) => this.consumeHasStar(input), + (input: string) => this.consumeHasUrl(input), + (input: string) => this.consumeIsPinned(input), + (input: string) => this.consumeHasLocation(input), + (input: string) => this.consumeLabel(input), + (input: string) => this.consumeFileDescription(input), + (input: string) => this.consumeFileTitle(input), + (input: string) => this.consumeBefore(input), + (input: string) => this.consumeAfter(input), + (input: string) => this.consumeOn(input), + (input: string) => this.consumeOrder(input), + (input: string) => this.consumeMessageText(input), + ].reduce((text, fn) => fn(text), text); + + return { + query: this.query, + options: this.options, + }; + } +} + +/** + * Parses a message search query and returns a MongoDB query and options + * @param text The query text + * @param options The options + * @param options.user The user object + * @param options.offset The offset + * @param options.limit The limit + * @param options.forceRegex Whether to force the use of regex + * @returns The MongoDB query and options + * @private + * @example + * const { query, options } = parseMessageSearchQuery('from:rocket.cat', { + * user: await Meteor.userAsync(), + * offset: 0, + * limit: 20, + * forceRegex: false, + * }); + */ +export function parseMessageSearchQuery( + text: string, + { + user, + offset = 0, + limit = 20, + forceRegex = false, + }: { + user?: IUser; + offset?: number; + limit?: number; + forceRegex?: boolean; + }, +) { + const parser = new MessageSearchQueryParser({ user, offset, limit, forceRegex }); + return parser.parse(text); +} diff --git a/apps/meteor/server/lib/premiumAuthDeprecation.ts b/apps/meteor/server/lib/premiumAuthDeprecation.ts new file mode 100644 index 0000000..265fd87 --- /dev/null +++ b/apps/meteor/server/lib/premiumAuthDeprecation.ts @@ -0,0 +1,21 @@ +import type { LicenseModule } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; + +import { SystemLogger } from './logger/system'; + +const warned = new Set(); + +// Checked on login instead of on startup so the license is already loaded and +// workspaces that do have the module never see the warning. +export function warnUnlicensedAuthService(service: 'LDAP' | 'SAML', module: LicenseModule): void { + if (warned.has(module) || License.hasModule(module)) { + return; + } + + warned.add(module); + + SystemLogger.warn({ + msg: `${service} authentication will require a Premium plan starting on version 9.0.0. This workspace has no license covering it, so ${service} logins will stop working after the upgrade.`, + module, + }); +} diff --git a/apps/meteor/server/lib/pushConfig.ts b/apps/meteor/server/lib/pushConfig.ts new file mode 100644 index 0000000..8034bb7 --- /dev/null +++ b/apps/meteor/server/lib/pushConfig.ts @@ -0,0 +1,74 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { PushToken } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from './RateLimiter'; +import { hasPermissionAsync } from './authorization/hasPermission'; +import { methodDeprecationLogger } from './deprecationWarningLogger'; +import { i18n } from './i18n'; +import { settings } from '../settings'; +import { Push } from './notifications/push'; + +export const executePushTest = async (userId: IUser['_id'], username: IUser['username']): Promise => { + const tokens = await PushToken.countTokensByUserId(userId); + + if (tokens === 0) { + throw new Meteor.Error('error-no-tokens-for-this-user', 'There are no tokens for this user', { + method: 'push_test', + }); + } + + await Push.send({ + from: 'push', + title: `@${username}`, + text: i18n.t('This_is_a_push_test_messsage'), + sound: 'default', + userId, + }); + + return tokens; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + push_test(): { message: string; params: number[] }; + } +} + +Meteor.methods({ + async push_test() { + methodDeprecationLogger.method('push_test', '9.0.0', '/v1/push.test'); + + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'push_test', + }); + } + + if (!(await hasPermissionAsync(user, 'test-push-notifications'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'push_test', + }); + } + + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(user._id, user.username); + return { + message: 'Your_push_was_sent_to_s_devices', + params: [tokensCount], + }; + }, +}); + +RateLimiter.limitMethod('push_test', 1, 1000, { + userId: () => true, +}); diff --git a/apps/meteor/server/lib/readMessages.ts b/apps/meteor/server/lib/readMessages.ts new file mode 100644 index 0000000..774a902 --- /dev/null +++ b/apps/meteor/server/lib/readMessages.ts @@ -0,0 +1,28 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { NotificationQueue, Subscriptions } from '@rocket.chat/models'; + +import { callbacks } from './callbacks'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from './notifyListener'; + +export async function readMessages(room: IRoom, uid: IUser['_id'], readThreads: boolean): Promise { + await callbacks.run('beforeReadMessages', room._id, uid); + + const projection = { ls: 1, tunread: 1, alert: 1, ts: 1 }; + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { projection }); + if (!sub) { + throw new Error('error-invalid-subscription'); + } + + // do not mark room as read if there are still unread threads + const alert = !!(sub.alert && !readThreads && sub.tunread && sub.tunread.length > 0); + + const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(room._id, uid, readThreads, alert); + if (setAsReadResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, uid); + } + + await NotificationQueue.clearQueueByUserId(uid); + + const lastSeen = sub.ls || sub.ts; + callbacks.runAsync('afterReadMessages', room, { uid, lastSeen }); +} diff --git a/apps/meteor/server/lib/refreshLoginServices.ts b/apps/meteor/server/lib/refreshLoginServices.ts new file mode 100644 index 0000000..2b4ffa6 --- /dev/null +++ b/apps/meteor/server/lib/refreshLoginServices.ts @@ -0,0 +1,11 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { updateCasServices } from './cas/updateCasService'; +import { updateOAuthServices } from './oauth/updateOAuthServices'; +import { loadSamlServiceProviders } from './saml/lib/settings'; + +export async function refreshLoginServices(): Promise { + await ServiceConfiguration.configurations.removeAsync({}); + + await Promise.allSettled([updateOAuthServices(), loadSamlServiceProviders(), updateCasServices()]); +} diff --git a/apps/meteor/server/lib/resetUserE2EKey.ts b/apps/meteor/server/lib/resetUserE2EKey.ts new file mode 100644 index 0000000..6f50e9d --- /dev/null +++ b/apps/meteor/server/lib/resetUserE2EKey.ts @@ -0,0 +1,84 @@ +import { api } from '@rocket.chat/core-services'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from './i18n'; +import { isUserIdFederated } from './isUserIdFederated'; +import * as Mailer from './notifications/email/api'; +import { notifyOnUserChange, notifyOnSubscriptionChangedByUserId } from './notifyListener'; +import { settings } from '../settings'; + +const sendResetNotification = async function (uid: string): Promise { + const user = await Users.findOneById(uid, {}); + if (!user) { + throw new Meteor.Error('invalid-user'); + } + + const language = user.language || settings.get('Language') || 'en'; + const addresses = user.emails?.filter(({ verified }) => verified).map((e) => e.address); + if (!addresses?.length) { + return; + } + + const t = i18n.getFixedT(language); + const text = ` + ${t('Your_e2e_key_has_been_reset')} + + ${t('E2E_Reset_Email_Content')} + `; + const html = ` +

${t('Your_e2e_key_has_been_reset')}

+

${t('E2E_Reset_Email_Content')}

+ `; + + const from = settings.get('From_Email'); + const subject = t('E2E_key_reset_email'); + + for await (const address of addresses) { + try { + await Mailer.send({ + to: address, + from, + subject, + text, + html, + } as any); + } catch (error) { + throw new Meteor.Error( + 'error-email-send-failed', + `Error trying to send email: ${error instanceof Error ? error.message : String(error)}`, + { + function: 'resetUserE2EEncriptionKey', + message: error instanceof Error ? error.message : String(error), + }, + ); + } + } +}; + +export async function resetUserE2EEncriptionKey(uid: string, notifyUser: boolean): Promise { + if (notifyUser) { + await sendResetNotification(uid); + } + + const isUserFederated = await isUserIdFederated(uid); + if (isUserFederated) { + throw new Meteor.Error('error-not-allowed', 'Federated Users cant have TOTP', { function: 'resetTOTP' }); + } + + // force logout the live sessions + await api.broadcast('user.forceLogout', uid); + + const responses = await Promise.all([Users.resetE2EKey(uid), Subscriptions.resetUserE2EKey(uid), Rooms.removeUserFromE2EEQueue(uid)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(uid); + } + + // Force the user to logout, so that the keys can be generated again + await Users.unsetLoginTokens(uid); + + void notifyOnUserChange({ clientAction: 'updated', id: uid, diff: { 'services.resume.loginTokens': [] } }); + + return true; +} diff --git a/apps/meteor/server/lib/roles/addUserRoles.ts b/apps/meteor/server/lib/roles/addUserRoles.ts new file mode 100644 index 0000000..f5d0d6b --- /dev/null +++ b/apps/meteor/server/lib/roles/addUserRoles.ts @@ -0,0 +1,47 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Roles, Subscriptions, Users } from '@rocket.chat/models'; + +import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority'; +import { validateRoleList } from './validateRoleList'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../notifyListener'; + +export const addUserRolesAsync = async (userId: IUser['_id'], roles: IRole['_id'][], scope?: IRoom['_id']): Promise => { + if (!userId || !roles?.length) { + return false; + } + + if (!(await validateRoleList(roles))) { + throw new MeteorError('error-invalid-role', 'Invalid role'); + } + + if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) { + throw new Error('Roles.addUserRoles method received a role scope instead of a scope value.'); + } + + if (!Array.isArray(roles)) { + roles = [roles]; + process.env.NODE_ENV === 'development' && console.warn('[WARN] RolesRaw.addUserRoles: roles should be an array'); + } + + for await (const roleId of roles) { + const role = await Roles.findOneById>(roleId, { projection: { scope: 1 } }); + + if (!role) { + process.env.NODE_ENV === 'development' && console.warn(`[WARN] RolesRaw.addUserRoles: role: ${roleId} not found`); + continue; + } + + if (role.scope === 'Subscriptions' && scope) { + const addRolesResponse = await Subscriptions.addRolesByUserId(userId, [role._id], scope); + await syncRoomRolePriorityForUserAndRoom(userId, scope); + if (addRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId); + } + } else { + await Users.addRolesByUserId(userId, [role._id]); + } + } + + return true; +}; diff --git a/apps/meteor/server/lib/roles/createOrUpdateProtectedRole.ts b/apps/meteor/server/lib/roles/createOrUpdateProtectedRole.ts new file mode 100644 index 0000000..ea59a29 --- /dev/null +++ b/apps/meteor/server/lib/roles/createOrUpdateProtectedRole.ts @@ -0,0 +1,32 @@ +import type { IRole, AtLeast } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +export const createOrUpdateProtectedRoleAsync = async ( + roleId: string, + roleData: AtLeast, 'name'>, +): Promise => { + const role = await Roles.findOneById>(roleId, { + projection: { name: 1, scope: 1, description: 1, mandatory2fa: 1 }, + }); + + if (role) { + await Roles.updateById( + roleId, + roleData.name || role.name, + roleData.scope || role.scope, + roleData.description || role.description, + roleData.mandatory2fa || role.mandatory2fa, + ); + + return; + } + + await Roles.insertOne({ + _id: roleId, + scope: 'Users', + description: '', + mandatory2fa: false, + ...roleData, + protected: true, + }); +}; diff --git a/apps/meteor/server/lib/roles/getRoomRoles.ts b/apps/meteor/server/lib/roles/getRoomRoles.ts new file mode 100644 index 0000000..7fa2f13 --- /dev/null +++ b/apps/meteor/server/lib/roles/getRoomRoles.ts @@ -0,0 +1,43 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Roles, Subscriptions, Users } from '@rocket.chat/models'; +import _ from 'underscore'; + +import { settings } from '../../settings'; + +export type RoomRoles = { + rid: IRoom['_id']; + u: { + _id: string; + username: string; + name?: string; + }; + roles: string[]; +}; + +export async function getRoomRoles(rid: IRoom['_id']): Promise { + const useRealName = settings.get('UI_Use_Real_Name') === true; + + const roles = await Roles.find({ scope: 'Subscriptions', description: { $exists: true, $ne: '' } }).toArray(); + const subscriptions = await Subscriptions.findByRoomIdAndRoles(rid, _.pluck(roles, '_id'), { + sort: { + 'u.username': 1 as const, + }, + projection: { + rid: 1, + u: 1, + roles: 1, + }, + }).toArray(); + + if (!useRealName) { + return subscriptions; + } + + return Promise.all( + subscriptions.map(async (subscription) => { + const user = await Users.findOneById(subscription.u._id); + subscription.u.name = user?.name; + return subscription; + }), + ); +} diff --git a/apps/meteor/server/lib/roles/removeUserFromRoles.ts b/apps/meteor/server/lib/roles/removeUserFromRoles.ts new file mode 100644 index 0000000..c48787c --- /dev/null +++ b/apps/meteor/server/lib/roles/removeUserFromRoles.ts @@ -0,0 +1,45 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Users, Subscriptions, Roles } from '@rocket.chat/models'; + +import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority'; +import { validateRoleList } from './validateRoleList'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../notifyListener'; + +export const removeUserFromRolesAsync = async (userId: IUser['_id'], roles: IRole['_id'][], scope?: IRoom['_id']): Promise => { + if (!userId || !roles) { + return false; + } + + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user'); + } + + if (!(await validateRoleList(roles))) { + throw new MeteorError('error-invalid-role', 'Invalid role'); + } + + if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) { + throw new Error('Roles.removeUserRoles method received a role scope instead of a scope value.'); + } + + for await (const roleId of roles) { + const role = await Roles.findOneById>(roleId, { projection: { scope: 1 } }); + if (!role) { + continue; + } + + if (role.scope === 'Subscriptions' && scope) { + const removeRolesResponse = await Subscriptions.removeRolesByUserId(userId, [roleId], scope); + await syncRoomRolePriorityForUserAndRoom(userId, scope); + if (removeRolesResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId); + } + } else { + await Users.removeRolesByUserId(userId, [roleId]); + } + } + + return true; +}; diff --git a/apps/meteor/server/lib/roles/syncRoomRolePriority.ts b/apps/meteor/server/lib/roles/syncRoomRolePriority.ts new file mode 100644 index 0000000..677ee73 --- /dev/null +++ b/apps/meteor/server/lib/roles/syncRoomRolePriority.ts @@ -0,0 +1,36 @@ +import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; + +import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles'; + +/** + * Updates the room role priority for a user in a given room. + * If roles are provided, it uses them directly; otherwise, it fetches the subscription for the user and room. + * @param userId The user's ID. + * @param rid The room's ID. + * @param roles Optional roles array. If not provided, the roles will be fetched. + */ +export const syncRoomRolePriorityForUserAndRoom = async ( + userId: IUser['_id'], + rid: IRoom['_id'], + roles?: IRole['_id'][], +): Promise => { + const updateRolePriority = async (userId: IUser['_id'], rid: IRoom['_id'], roles: IRole['_id'][]): Promise => { + const rolePriority = calculateRoomRolePriorityFromRoles(roles); + await Users.addRoomRolePriorityByUserId(userId, rid, rolePriority); + }; + + if (roles) { + return updateRolePriority(userId, rid, roles); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { + projection: { roles: 1, u: 1, rid: 1 }, + }); + + if (!subscription?.roles) { + return; + } + + return updateRolePriority(subscription.u._id, subscription.rid, subscription.roles); +}; diff --git a/apps/meteor/server/lib/roles/validateRoleList.ts b/apps/meteor/server/lib/roles/validateRoleList.ts new file mode 100644 index 0000000..8333bef --- /dev/null +++ b/apps/meteor/server/lib/roles/validateRoleList.ts @@ -0,0 +1,13 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { Roles } from '@rocket.chat/models'; + +export const validateRoleList = async (roleIds: IRole['_id'][]): Promise => { + const options = { + projection: { + _id: 1, + }, + }; + + const existingRoleIds = (await Roles.findInIds>(roleIds, options).toArray()).map(({ _id }) => _id); + return !roleIds.find((_id) => !existingRoleIds.includes(_id)); +}; diff --git a/apps/meteor/server/lib/rooms/acceptRoomInvite.ts b/apps/meteor/server/lib/rooms/acceptRoomInvite.ts new file mode 100644 index 0000000..730042b --- /dev/null +++ b/apps/meteor/server/lib/rooms/acceptRoomInvite.ts @@ -0,0 +1,61 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { Message } from '@rocket.chat/core-services'; +import type { IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../callbacks'; +import { notifyOnSubscriptionChangedById } from '../notifyListener'; + +/** + * Accepts a room invite when triggered by internal events such as federation + * or third-party callbacks. Performs the necessary database updates and triggers + * safe callbacks, ensuring no propagation loops are created during external event + * processing. + */ + +// TODO this funcion is pretty much the same as the one in addUserToRoom.ts, we should probably +// unify them at some point +export const performAcceptRoomInvite = async ( + room: IRoom, + subscription: ISubscription, + user: IUser & { username: string }, +): Promise => { + if (subscription.status !== 'INVITED' || !subscription.inviter) { + throw new Meteor.Error('error-not-invited', `User was not invited to this room ${subscription.status}`); + } + const inviter = await Users.findOneById(subscription.inviter._id); + + await callbacks.run('beforeJoinRoom', user, room); + + await callbacks.run('beforeAddedToRoom', { user, inviter }, room); + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, user, inviter); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + await Subscriptions.acceptInvitationById(subscription._id); + + void notifyOnSubscriptionChangedById(subscription._id, 'updated'); + + await Message.saveSystemMessage('uj', room._id, user.username, user); + + if (room.t === 'c' || room.t === 'p') { + process.nextTick(async () => { + // Add a new event, with an optional inviter + await callbacks.run('afterAddedToRoom', { user, inviter }, room); + + // Keep the current event + await callbacks.run('afterJoinRoom', user, room); + + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter); + }); + } +}; diff --git a/apps/meteor/server/lib/rooms/addUserToDefaultChannels.ts b/apps/meteor/server/lib/rooms/addUserToDefaultChannels.ts new file mode 100644 index 0000000..2a46aff --- /dev/null +++ b/apps/meteor/server/lib/rooms/addUserToDefaultChannels.ts @@ -0,0 +1,47 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; + +import { getDefaultChannels } from './getDefaultChannels'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { getSubscriptionAutotranslateDefaultConfig } from '../getSubscriptionAutotranslateDefaultConfig'; +import { notifyOnSubscriptionChangedById } from '../notifyListener'; +import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref'; + +export const addUserToDefaultChannels = async function (user: IUser, silenced?: boolean): Promise { + await callbacks.run('beforeJoinDefaultChannels', user); + const defaultRooms = await getDefaultChannels(); + + for (const room of defaultRooms) { + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + continue; + } + + if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); + + // Add a subscription to this user + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { + ts: new Date(), + open: true, + alert: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + ...(room.favorite && { f: true }), + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(user), + }); + + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + + // Insert user joined message + if (!silenced) { + await Message.saveSystemMessage('uj', room._id, user.username || '', user); + } + } + } +}; diff --git a/apps/meteor/server/lib/rooms/addUserToRoom.ts b/apps/meteor/server/lib/rooms/addUserToRoom.ts new file mode 100644 index 0000000..1ac5cae --- /dev/null +++ b/apps/meteor/server/lib/rooms/addUserToRoom.ts @@ -0,0 +1,145 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { Message, Team, Room } from '@rocket.chat/core-services'; +import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { callbacks } from '../callbacks'; +import { roomCoordinator } from './roomCoordinator'; +import { beforeAddUserToRoom as beforeAddUserToRoomPatch } from '../../hooks/rooms/beforeAddUserToRoom'; +import { settings } from '../../settings'; +import { beforeAddUserToRoom } from '../callbacks/beforeAddUserToRoom'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener'; + +/** + * This function adds user to the given room. + * Caution - It does not validates if the user has permission to join room + */ + +export const addUserToRoom = async ( + rid: string, + user: Pick, + inviter?: Pick, + { + skipSystemMessage, + skipAlertSound, + createAsHidden = false, + }: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + createAsHidden?: boolean; + } = {}, +): Promise => { + const now = new Date(); + const room = await Rooms.findOneById(rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addUserToRoom', + }); + } + + const userToBeAdded = await Users.findOneById(user._id); + const roomDirectives = roomCoordinator.getRoomDirectives(room.t); + + if (!userToBeAdded) { + throw new Meteor.Error('user-not-found'); + } + + if ( + !(await roomDirectives.allowMemberAction(room, RoomMemberActions.JOIN, userToBeAdded._id)) && + !(await roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, userToBeAdded._id)) + ) { + return; + } + + try { + const inviterUser = inviter && ((await Users.findOneById(inviter._id)) || undefined); + // Not "duplicated": we're moving away from callbacks so this is a patch function. We should migrate the next one to be a patch or use this same patch, instead of calling both + await beforeAddUserToRoomPatch([userToBeAdded.username!], room, inviterUser); + await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: inviterUser }, room); + } catch (error) { + throw new Meteor.Error((error as any)?.message); + } + + // TODO: are we calling this twice? + + await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }); + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + // for federation rooms we stop here since everything else will be handled by the federation invite flow + if (isRoomNativeFederated(room)) { + return; + } + + // TODO: are we calling this twice? + if (room.t === 'c' || room.t === 'p' || room.t === 'l') { + // Add a new event, with an optional inviter + await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }, room); + + // Keep the current event + await callbacks.run('beforeJoinRoom', userToBeAdded, room); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); + if (subscription) { + if (!isBannedSubscription(subscription)) { + return true; + } + + const deletedSub = await Subscriptions.removeByRoomIdAndUserId(rid, userToBeAdded._id); + if (!deletedSub) { + return true; + } + void notifyOnSubscriptionChanged(deletedSub, 'removed'); + + if (!skipSystemMessage && inviter) { + await Message.saveSystemMessage('user-unbanned', rid, userToBeAdded.username!, inviter, { ts: now }); + } + } + + await Room.createUserSubscription({ + room, + ts: now, + inviter, + userToBeAdded, + createAsHidden, + skipAlertSound, + skipSystemMessage, + }); + + if (room.t === 'c' || room.t === 'p') { + process.nextTick(async () => { + // Add a new event, with an optional inviter + await callbacks.run('afterAddedToRoom', { user: userToBeAdded, inviter }, room); + + // Keep the current event + await callbacks.run('afterJoinRoom', userToBeAdded, room); + + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); + }); + } + + if (room.teamMain && room.teamId) { + // if user is joining to main team channel, create a membership + await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); + } + + if (room.encrypted && settings.get('E2E_Enable') && userToBeAdded.e2e?.public_key) { + await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); + } + + void notifyOnRoomChangedById(rid); + return true; +}; diff --git a/apps/meteor/server/lib/rooms/archiveRoom.ts b/apps/meteor/server/lib/rooms/archiveRoom.ts new file mode 100644 index 0000000..f6781d9 --- /dev/null +++ b/apps/meteor/server/lib/rooms/archiveRoom.ts @@ -0,0 +1,25 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; + +import { callbacks } from '../callbacks'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedByRoomId } from '../notifyListener'; + +export const archiveRoom = async function (rid: string, user: IMessage['u']): Promise { + await Rooms.archiveById(rid); + + const archiveResponse = await Subscriptions.archiveByRoomId(rid); + if (archiveResponse.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + await Message.saveSystemMessage('room-archived', rid, '', user); + + const room = await Rooms.findOneById(rid); + + await callbacks.run('afterRoomArchived', room, user); + + if (room) { + void notifyOnRoomChanged(room); + } +}; diff --git a/apps/meteor/server/lib/rooms/banUserFromRoom.ts b/apps/meteor/server/lib/rooms/banUserFromRoom.ts new file mode 100644 index 0000000..b68b983 --- /dev/null +++ b/apps/meteor/server/lib/rooms/banUserFromRoom.ts @@ -0,0 +1,76 @@ +import { Message, Team } from '@rocket.chat/core-services'; +import { isBannedSubscription } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { afterBanFromRoomCallback } from '../callbacks/afterBanFromRoomCallback'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener'; +import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles'; + +/** + * Bans a user from a room when triggered by federation or other external events. + * Executes only the necessary database operations, with no callbacks, to prevent + * propagation loops during external event processing. + * `byUser` must be the Rocket.Chat user who initiated the ban (local record). + */ +export const performUserBan = async function (room: IRoom, user: IUser, byUser: IUser): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + if (!subscription) { + return; + } + + if (!user.username) { + throw new Error('User must have a username to be banned from the room'); + } + + // Already banned — nothing to do + if (isBannedSubscription(subscription)) { + return; + } + + // Set subscription status to BANNED (keeps the record, unlike kick which deletes it) + await Subscriptions.banByRoomIdAndUserId(room._id, user._id); + + // Remove the room from the user's __rooms array so they don't appear in member listings + await Users.removeRoomByUserId(user._id, room._id); + + // Decrement the room's user count + await Rooms.incUsersCountById(room._id, -1); + + // Remove room-scoped roles (moderator, owner, leader) + if (['c', 'p'].includes(room.t)) { + await removeUserFromRolesAsync(user._id, ['moderator', 'owner', 'leader'], room._id); + } + + // Remove from team when banning from main team room so roster stays in sync with subscription state + if (room.teamId && room.teamMain) { + await Team.removeMember(room.teamId, user._id); + } + + // Save system message (who banned is always recorded) + await Message.saveSystemMessage('user-banned', room._id, user.username, user, { + u: byUser, + }); + + // Send 'removed' so the client drops the room stream/socket subscription. + // The record still exists in DB with status BANNED for access-control purposes. + void notifyOnSubscriptionChanged(subscription, 'removed'); + void notifyOnRoomChangedById(room._id); +}; + +/** + * Bans a user from the given room by updating the subscription status to BANNED, + * removing them from member listings, and triggering all standard callbacks. + * Used for local actions (UI or API) that should propagate normally to federation + * and other subscribers. + */ +export const banUserFromRoom = async function (rid: string, user: IUser, byUser: IUser): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('error-invalid-room'); + } + + await performUserBan(room, user, byUser); + + void afterBanFromRoomCallback.run({ bannedUser: user, userWhoBanned: byUser }, room); +}; diff --git a/apps/meteor/server/lib/rooms/cleanRoomHistory.ts b/apps/meteor/server/lib/rooms/cleanRoomHistory.ts new file mode 100644 index 0000000..db4d439 --- /dev/null +++ b/apps/meteor/server/lib/rooms/cleanRoomHistory.ts @@ -0,0 +1,169 @@ +import { api } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models'; + +import { deleteRoom } from './deleteRoom'; +import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants'; +import { i18n } from '../i18n'; +import { FileUpload } from '../media/file-upload'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../notifyListener'; + +const FILE_CLEANUP_BATCH_SIZE = 1000; + +export async function cleanRoomHistory({ + rid = '', + latest = new Date(), + oldest = new Date('0001-01-01T00:00:00Z'), + inclusive = true, + limit = 0, + excludePinned = true, + ignoreDiscussion = true, + filesOnly = false, + fromUsers = [], + ignoreThreads = true, +}: { + rid?: IRoom['_id']; + latest?: Date; + oldest?: Date; + inclusive?: boolean; + limit?: number; + excludePinned?: boolean; + ignoreDiscussion?: boolean; + filesOnly?: boolean; + fromUsers?: string[]; + ignoreThreads?: boolean; +}): Promise { + const gt = inclusive ? '$gte' : '$gt'; + const lt = inclusive ? '$lte' : '$lt'; + + const ts = { [gt]: oldest, [lt]: latest }; + + const text = `_${i18n.t('File_removed_by_prune')}_`; + + let fileCount = 0; + + const cursor = Messages.findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreDiscussion, ts, fromUsers, ignoreThreads, { + projection: { pinned: 1, files: 1 }, + limit, + }); + + const targetMessageIdsForAttachmentRemoval = new Set(); + // Since we remove every file from the messages, we don't need to specify which fileId has been removed. + const pruneMessageAttachment = { type: 'removed-file', color: NOTIFICATION_ATTACHMENT_COLOR, text }; + + async function performFileAttachmentCleanupBatch() { + if (targetMessageIdsForAttachmentRemoval.size === 0) return; + + const ids = [...targetMessageIdsForAttachmentRemoval]; + await Messages.removeFileAttachmentsByMessageIds(ids, pruneMessageAttachment); + await Messages.clearFilesByMessageIds(ids); + void api.broadcast('notify.deleteMessageBulk', rid, { + rid, + excludePinned, + ignoreDiscussion, + ts, + users: fromUsers, + ids, + filesOnly: true, + replaceFileAttachmentsWith: pruneMessageAttachment, + }); + targetMessageIdsForAttachmentRemoval.clear(); + } + + for await (const document of cursor) { + const uploadsStore = FileUpload.getStore('Uploads'); + + document.files && (await Promise.all(document.files.map((file) => uploadsStore.deleteById(file._id)))); + + fileCount++; + if (filesOnly) { + targetMessageIdsForAttachmentRemoval.add(document._id); + } + + if (targetMessageIdsForAttachmentRemoval.size >= FILE_CLEANUP_BATCH_SIZE) { + await performFileAttachmentCleanupBatch(); + } + } + + if (targetMessageIdsForAttachmentRemoval.size > 0) { + await performFileAttachmentCleanupBatch(); + } + + if (filesOnly) { + return fileCount; + } + + if (!ignoreDiscussion) { + const discussionsCursor = Messages.findDiscussionByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers, { + projection: { drid: 1 }, + ...(limit && { limit }), + }); + + for await (const { drid } of discussionsCursor) { + if (!drid) { + continue; + } + await deleteRoom(drid); + } + } + + if (!ignoreThreads) { + const threads = new Set(); + + await Messages.findThreadsByRoomIdPinnedTimestampAndUsers( + { rid, pinned: excludePinned, ignoreDiscussion, ts, users: fromUsers }, + { projection: { _id: 1 } }, + ).forEach(({ _id }) => { + threads.add(_id); + }); + + if (threads.size > 0) { + const subscriptionIds: string[] = ( + await Subscriptions.findUnreadThreadsByRoomId(rid, [...threads], { projection: { _id: 1 } }).toArray() + ).map(({ _id }) => _id); + + const { modifiedCount } = await Subscriptions.removeUnreadThreadsByRoomId(rid, [...threads]); + if (modifiedCount) { + subscriptionIds.forEach((id) => notifyOnSubscriptionChangedById(id)); + } + } + } + + const selectedMessageIds = limit + ? await Messages.findByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, limit, fromUsers, ignoreThreads) + : undefined; + const count = await Messages.removeByIdPinnedTimestampLimitAndUsers( + rid, + excludePinned, + ignoreDiscussion, + ts, + limit, + fromUsers, + ignoreThreads, + selectedMessageIds, + ); + + if (limit && selectedMessageIds) { + await ReadReceipts.removeByMessageIds(selectedMessageIds); + await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds); + } + + if (count) { + const lastMessage = await Messages.getLastVisibleUserMessageSentByRoomId(rid); + + await Rooms.resetLastMessageById(rid, lastMessage, -count); + + void notifyOnRoomChangedById(rid); + + void api.broadcast('notify.deleteMessageBulk', rid, { + rid, + excludePinned, + ignoreDiscussion, + ts, + users: fromUsers, + ids: selectedMessageIds, + }); + } + + return count; +} diff --git a/apps/meteor/server/lib/rooms/createDirectRoom.ts b/apps/meteor/server/lib/rooms/createDirectRoom.ts new file mode 100644 index 0000000..0b4f21c --- /dev/null +++ b/apps/meteor/server/lib/rooms/createDirectRoom.ts @@ -0,0 +1,223 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import type { ISubscriptionExtraData } from '@rocket.chat/core-services'; +import type { ICreatedRoom, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import type { MatchKeysAndValues } from 'mongodb'; + +import { getNameForDMs } from '../../services/room/getNameForDMs'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomIdAndUserId } from '../notifyListener'; +import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref'; + +const generateSubscription = ( + fname: string, + name: string, + user: IUser, + extra: MatchKeysAndValues, +): MatchKeysAndValues => ({ + _id: Random.id(), + ts: new Date(), + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + ...(user.customFields && { customFields: user.customFields }), + ...getDefaultSubscriptionPref(user), + ...extra, + t: 'd', + fname, + name, + u: { + _id: user._id, + username: user.username, + }, +}); + +export async function createDirectRoom( + members: IUser[] | string[], + roomExtraData: Partial = {}, + options: { + forceNew?: boolean; + creator?: IUser['_id']; + subscriptionExtra?: ISubscriptionExtraData; + }, +): Promise { + const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; + if (members.length > maxUsers) { + throw new Meteor.Error( + 'error-direct-message-max-user-exceeded', + `You cannot add more than ${maxUsers} users, including yourself to a direct message`, + { + method: 'createDirectRoom', + }, + ); + } + + const membersUsernames: string[] = members + .map((member) => { + if (typeof member === 'string') { + return member; + } + return member.username; + }) + .filter(isTruthy); + + await callbacks.run('beforeCreateDirectRoom', membersUsernames, roomExtraData); + + const roomMembers = await Users.findUsersByUsernames(membersUsernames).toArray(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sortedMembers = roomMembers.sort((u1, u2) => (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!)); + + const usernames: string[] = sortedMembers.map(({ username }) => username as string).filter(Boolean); + const uids = roomMembers.map(({ _id }) => _id).sort(); + + // Deprecated: using users' _id to compose the room _id is deprecated + // No projection: the full room is spread into the ICreatedRoom returned below. + const room: IRoom | null = options?.forceNew ? null : await Rooms.findOneDirectRoomContainingAllUserIDs(uids); + + const isNewRoom = !room; + + // Mirror setUserActiveStatus: a 1-on-1 DM containing a deactivated user is read-only + const isReadOnlyForDeactivatedMember = uids.length === 2 && roomMembers.some((member) => member.active === false); + + const roomInfo = { + t: 'd', + usernames, + usersCount: members.length, + msgs: 0, + ts: new Date(), + uids, + ...roomExtraData, + ...(isReadOnlyForDeactivatedMember && { ro: true, reactWhenReadOnly: false }), + }; + + if (isNewRoom) { + const tmpRoom: { _USERNAMES?: (string | undefined)[] } & typeof roomInfo = { + ...roomInfo, + _USERNAMES: usernames, + }; + + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmpRoom).catch((error) => { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + }); + + if (prevent) { + throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); + } + + const result = await Apps.self?.triggerEvent( + AppEvents.IPreRoomCreateModify, + await Apps.self?.triggerEvent(AppEvents.IPreRoomCreateExtend, tmpRoom), + ); + + if (typeof result === 'object') { + Object.assign(roomInfo, result); + } + + delete tmpRoom._USERNAMES; + } + + // @ts-expect-error - TODO: room expects `u` to be passed, but it's not part of the original object in here + const rid = room?._id || (await Rooms.insertOne(roomInfo)).insertedId; + + void notifyOnRoomChangedById(rid, isNewRoom ? 'inserted' : 'updated'); + + if (roomMembers.length === 1) { + // dm to yourself + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( + { rid, 'u._id': roomMembers[0]._id }, + { + $set: { open: true }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + $setOnInsert: generateSubscription(roomMembers[0].name! || roomMembers[0].username!, roomMembers[0].username!, roomMembers[0], { + ...options?.subscriptionExtra, + }), + }, + { upsert: true }, + ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, roomMembers[0]._id, modifiedCount ? 'updated' : 'inserted'); + } + } else { + const memberIds = roomMembers.map((member) => member._id); + const membersWithPreferences: IUser[] = await Users.find( + { _id: { $in: memberIds } }, + { projection: { 'username': 1, 'settings.preferences': 1 } }, + ).toArray(); + + const creatorUser = roomMembers.find((member) => member._id === options?.creator); + if (roomExtraData.federated && !creatorUser) { + throw new Meteor.Error('error-creator-not-in-room', 'The creator user must be part of the direct room'); + } + + const roomNames = getNameForDMs(roomMembers); + + for (const member of membersWithPreferences) { + const subscriptionStatus: Partial = + roomExtraData.federated && options.creator !== member._id && creatorUser + ? { + status: 'INVITED', + inviter: { + _id: creatorUser._id, + username: creatorUser.username!, + name: creatorUser.name, + }, + open: true, + unread: 1, + userMentions: 1, + } + : {}; + + const { fname, name } = roomNames[member._id]; + + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( + { rid, 'u._id': member._id }, + { + ...(options?.creator === member._id && { $set: { open: true } }), + $setOnInsert: generateSubscription(fname, name, member, { + ...options?.subscriptionExtra, + ...(options?.creator !== member._id && { open: members.length > 2 }), + ...subscriptionStatus, + ...(roomExtraData.federated && member._id === options?.creator && { roles: ['owner'] }), + }), + }, + { upsert: true }, + ); + if (modifiedCount || upsertedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, member._id, modifiedCount ? 'updated' : 'inserted'); + } + } + } + + // If the room is new, run a callback + if (isNewRoom) { + const insertedRoom = await Rooms.findOneById(rid); + + await callbacks.run('afterCreateDirectRoom', insertedRoom, { + members: roomMembers, + creatorId: options?.creator, + }); + + void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); + } + + return { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...room!, + _id: String(rid), + usernames, + t: 'd', + rid, + inserted: isNewRoom, + }; +} diff --git a/apps/meteor/server/lib/rooms/createRoom.ts b/apps/meteor/server/lib/rooms/createRoom.ts new file mode 100644 index 0000000..90a11dd --- /dev/null +++ b/apps/meteor/server/lib/rooms/createRoom.ts @@ -0,0 +1,327 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services'; +import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; +import { type ICreatedRoom, type IUser, type IRoom, type RoomType, isUserNativeFederated } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { createDirectRoom } from './createDirectRoom'; +import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles'; +import { callbacks } from '../callbacks'; +import { beforeAddUserToRoom } from '../callbacks/beforeAddUserToRoom'; +import { beforeCreateRoomCallback, prepareCreateRoomCallback } from '../callbacks/beforeCreateRoomCallback'; +import { getSubscriptionAutotranslateDefaultConfig } from '../getSubscriptionAutotranslateDefaultConfig'; +import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../roles/syncRoomRolePriority'; +import { getDefaultSubscriptionPref } from '../utils/lib/getDefaultSubscriptionPref'; +import { getValidRoomName } from '../utils/lib/getValidRoomName'; + +const isValidName = (name: unknown): name is string => { + return typeof name === 'string' && name.trim().length > 0; +}; + +const onlyUsernames = (members: unknown): members is string[] => + Array.isArray(members) && members.every((member) => typeof member === 'string'); + +async function createUsersSubscriptions({ + room, + shouldBeHandledByFederation, + members, + now, + owner, + options, +}: { + room: IRoom; + shouldBeHandledByFederation: boolean; + members: string[]; + now: Date; + owner: IUser; + options?: ICreateRoomParams['options']; +}) { + if (shouldBeHandledByFederation) { + const extra: Partial = { + ...options?.subscriptionExtra, + open: true, + ls: now, + roles: ['owner'], + ...(room.prid && { prid: room.prid }), + ...getDefaultSubscriptionPref(owner), + }; + + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra); + await syncRoomRolePriorityForUserAndRoom(owner._id, room._id, ['owner']); + + if (insertedId) { + await notifyOnSubscriptionChangedById(insertedId, 'inserted'); + await notifyOnRoomChanged(room, 'inserted'); + } + + // Invite federated members to the room SYNCRONOUSLY, + // since we do not use to invite lots of users at once, this is acceptable. + const membersToInvite = members.filter((m) => m !== owner.username); + + await FederationMatrix.ensureFederatedUsersExistLocally(membersToInvite); + + for (const memberUsername of membersToInvite) { + const member = await Users.findOneByUsername(memberUsername); + if (!member) { + throw new Error('Federated user not found locally'); + } + + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: member, + inviter: owner, + status: 'INVITED', + }); + } + + return; + } + + const subs = []; + + const memberIds = []; + + const memberIdAndRolePriorityMap: Record = {}; + + const membersCursor = Users.findUsersByUsernames(members); + + // TODO: Check re new federation-service - should we add them here or keep on createRoom inside of homeserver?! + for await (const member of membersCursor) { + try { + await beforeAddUserToRoom.run({ user: member, inviter: owner }, room); + await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner }); + } catch (error) { + continue; + } + + memberIds.push(member._id); + + const extra: Partial = { open: true, ...options?.subscriptionExtra }; + + if (room.prid) { + extra.prid = room.prid; + } + + if (member.username === owner.username) { + extra.ls = now; + extra.roles = ['owner']; + } + + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(member); + + subs.push({ + user: member, + extraData: { + ...extra, + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(member), + }, + }); + + if (extra.roles) { + memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles); + } + } + + if (!['d', 'l'].includes(room.t)) { + await Users.addRoomByUserIds(memberIds, room._id); + } + + const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs); + await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id); + + Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted')); + + await Rooms.incUsersCountById(room._id, subs.length); +} + +export const createRoom = async ( + type: T, + name: T extends 'd' ? undefined : string, + owner: T extends 'd' ? IUser | undefined : IUser, + members: T extends 'd' ? IUser[] : string[] = [], + excludeSelf?: boolean, + readOnly?: boolean, + roomExtraData?: Partial, + options?: ICreateRoomParams['options'], +): Promise< + ICreatedRoom & { + rid: string; + } +> => { + const { teamId, ...extraData } = roomExtraData || ({} as IRoom); + + // TODO: use a shared helper to check whether a user is federated + const hasFederatedMembers = members.some((member) => { + if (typeof member === 'string') { + return member.includes(':') && member.includes('@'); + } + return member.username?.includes(':') && member.username?.includes('@'); + }); + + // Prevent adding federated users to rooms that are not marked as federated explicitly + if (hasFederatedMembers && extraData.federated !== true) { + throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', { + method: 'createRoom', + }); + } + + await prepareCreateRoomCallback.run({ + type, + // name, + // owner: ownerUsername, + // members, + // readOnly, + extraData, + // options, + }); + + const shouldBeHandledByFederation = extraData.federated === true; + + if (shouldBeHandledByFederation && owner && !isUserNativeFederated(owner) && !(await FederationMatrix.canUserAccessFederation(owner))) { + throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', { + method: 'createRoom', + }); + } + + if (type === 'd') { + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); + } + + const memberList = [...members]; + + if (!onlyUsernames(memberList)) { + throw new Meteor.Error( + 'error-invalid-members', + 'members should be an array of usernames if provided for rooms other than direct messages', + ); + } + + if (!isValidName(name)) { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { + function: 'RocketChat.createRoom', + }); + } + + if (!owner) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.createRoom', + }); + } + + if (!owner?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.createRoom', + }); + } + + if (!excludeSelf && owner.username && !memberList.includes(owner.username)) { + memberList.push(owner.username); + } + + if (extraData.broadcast) { + readOnly = true; + delete extraData.reactWhenReadOnly; + } + + // this might not be the best way to check if the room is a discussion, we may need a specific field for that + const isDiscussion = 'prid' in extraData && extraData.prid !== ''; + + const now = new Date(); + + const roomProps: Omit = { + fname: name, + _updatedAt: now, + ...extraData, + name: isDiscussion ? name : await getValidRoomName(name.trim(), undefined), + t: type, + msgs: 0, + usersCount: 0, + u: { + _id: owner._id, + username: owner.username, + name: owner.name, + }, + ts: now, + ro: readOnly === true, + }; + + if (teamId) { + const team = await Team.getOneById(teamId, { projection: { _id: 1 } }); + if (team) { + roomProps.teamId = team._id; + } + } + + const tmp = { + ...roomProps, + _USERNAMES: memberList, + }; + + const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomCreatePrevent, tmp).catch((error) => { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + }); + + if (prevent) { + throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); + } + + const eventResult = await Apps.self?.triggerEvent( + AppEvents.IPreRoomCreateModify, + await Apps.triggerEvent(AppEvents.IPreRoomCreateExtend, tmp), + ); + + if (eventResult && typeof eventResult === 'object' && delete eventResult._USERNAMES) { + Object.assign(roomProps, eventResult); + } + + await beforeCreateRoomCallback.run({ + owner, + room: roomProps, + }); + + if (type === 'c') { + await callbacks.run('beforeCreateChannel', owner, roomProps); + } + + const room = await Rooms.createWithFullRoomData(roomProps); + + void notifyOnRoomChanged(room, 'inserted'); + + // If federated, we must create Matrix room BEFORE subscriptions so invites can be sent. + if (shouldBeHandledByFederation) { + // Reusing unused callback to create Matrix room. + // We should discuss the opportunity to rename it to something with "before" prefix. + await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: memberList, options }); + } + + await createUsersSubscriptions({ room, members: memberList, now, owner, options, shouldBeHandledByFederation }); + + if (type === 'c') { + if (room.teamId) { + const team = await Team.getOneById(room.teamId); + if (team) { + await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', owner); + } + } + callbacks.runAsync('afterCreateChannel', owner, room); + } else if (type === 'p') { + callbacks.runAsync('afterCreatePrivateGroup', owner, room); + } + callbacks.runAsync('afterCreateRoom', owner, room); + + void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, room); + return { + rid: room._id, // backwards compatible + inserted: true, + ...room, + }; +}; diff --git a/apps/meteor/server/lib/rooms/deleteRoom.ts b/apps/meteor/server/lib/rooms/deleteRoom.ts new file mode 100644 index 0000000..f7ff9ac --- /dev/null +++ b/apps/meteor/server/lib/rooms/deleteRoom.ts @@ -0,0 +1,28 @@ +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; + +import { callbacks } from '../callbacks'; +import { FileUpload } from '../media/file-upload'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener'; + +export const deleteRoom = async function (rid: string): Promise { + await FileUpload.removeFilesByRoomId(rid); + + await Messages.removeByRoomId(rid); + + await callbacks.run('beforeDeleteRoom', rid); + + await Subscriptions.removeByRoomId(rid, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }); + + await FileUpload.getStore('Avatars').deleteByRoomId(rid); + + await callbacks.run('afterDeleteRoom', rid); + + const { deletedCount } = await Rooms.removeById(rid); + if (deletedCount) { + void notifyOnRoomChangedById(rid, 'removed'); + } +}; diff --git a/apps/meteor/server/lib/rooms/executeUnbanUserFromRoom.ts b/apps/meteor/server/lib/rooms/executeUnbanUserFromRoom.ts new file mode 100644 index 0000000..e6625f6 --- /dev/null +++ b/apps/meteor/server/lib/rooms/executeUnbanUserFromRoom.ts @@ -0,0 +1,54 @@ +import { Message } from '@rocket.chat/core-services'; +import { isBannedSubscription, isInviteSubscription, type IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { afterUnbanFromRoomCallback } from '../callbacks/afterUnbanFromRoomCallback'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener'; + +export const executeUnbanUserFromRoom = async function (rid: string, user: IUser, byUser: IUser): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('error-invalid-room'); + } + + if (!user.username) { + throw new Error('error-invalid-user'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + if (!subscription) { + throw new Error('error-invalid-subscription'); + } + + // if the subscription is an invite it means we were unbanned and then invited again, then + // the invite was accepted and we receive a leave event (meaning the user was unbanned), so + // at this point we just need send the message to say the user was unbanned. + if (isInviteSubscription(subscription)) { + await Message.saveSystemMessage('user-unbanned', rid, user.username, user, { + u: { _id: byUser._id, username: byUser.username }, + }); + + return; + } + + // if the subscription exists and is not an invite and not banned + if (!isBannedSubscription(subscription)) { + throw new Error('error-user-not-banned'); + } + + // Remove the subscription entirely — the user is no longer banned but also not a member. + // Room count and __rooms were already adjusted during ban, so we only delete the document. + await Subscriptions.removeById(subscription._id); + + await Message.saveSystemMessage('user-unbanned', rid, user.username, user, { + u: { _id: byUser._id, username: byUser.username }, + }); + + void notifyOnSubscriptionChanged(subscription, 'removed'); + void notifyOnRoomChangedById(rid); + + const inviterUser = await Users.findOneById(byUser._id); + if (inviterUser) { + await afterUnbanFromRoomCallback.run({ unbannedUser: user, userWhoUnbanned: inviterUser }, room); + } +}; diff --git a/apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts b/apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts new file mode 100644 index 0000000..baa32a5 --- /dev/null +++ b/apps/meteor/server/lib/rooms/findDirectRoomByIdentifier.ts @@ -0,0 +1,30 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; + +// direct rooms carry no `name`; the member set is what identifies them +export const findDirectRoomByIdentifier = async (identifier: string, uid: IUser['_id']): Promise => { + const targets = identifier.split(',').map((username) => username.trim()); + + // only a single target can be a room id, since ids never contain commas. + if (targets.length === 1) { + const byId = await Rooms.findByTypeAndNameOrId('d', targets[0]); + if (byId) { + return byId; + } + } + + const me = await Users.findOneById>(uid, { projection: { username: 1 } }); + if (!me?.username) { + return null; + } + + const usernames = [...new Set([me.username, ...targets])]; + const members = await Users.findUsersByUsernames>(usernames, { projection: { _id: 1 } }).toArray(); + if (members.length !== usernames.length) { + return null; + } + + const uids = members.map(({ _id }) => _id).sort(); + + return Rooms.findOneDirectRoomContainingAllUserIDs(uids); +}; diff --git a/apps/meteor/server/lib/rooms/getDefaultChannels.ts b/apps/meteor/server/lib/rooms/getDefaultChannels.ts new file mode 100644 index 0000000..e47518d --- /dev/null +++ b/apps/meteor/server/lib/rooms/getDefaultChannels.ts @@ -0,0 +1,25 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +export async function getDefaultChannels(): Promise { + const defaultRooms = await Rooms.findByDefaultAndTypes(true, ['c', 'p'], { + projection: { usernames: 0 }, + }).toArray(); + const roomsThatAreGoingToBeJoined = new Set(defaultRooms.map((room) => room._id)); + + // If any of those are teams, we need to get all the channels that have the auto-join flag as well + const teamRooms = defaultRooms.filter((room) => room.teamMain && room.teamId); + if (teamRooms.length > 0) { + for await (const teamRoom of teamRooms) { + const defaultTeamRooms = await Rooms.findDefaultRoomsForTeam(teamRoom.teamId).toArray(); + + const defaultTeamRoomsThatWereNotAlreadyAdded = defaultTeamRooms.filter((channel) => !roomsThatAreGoingToBeJoined.has(channel._id)); + + defaultTeamRoomsThatWereNotAlreadyAdded.forEach((channel) => roomsThatAreGoingToBeJoined.add(channel._id)); + // Add the channels to the defaultRooms list + defaultRooms.push(...defaultTeamRoomsThatWereNotAlreadyAdded); + } + } + + return defaultRooms; +} diff --git a/apps/meteor/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.ts b/apps/meteor/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.ts new file mode 100644 index 0000000..e1acf1a --- /dev/null +++ b/apps/meteor/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.ts @@ -0,0 +1,98 @@ +import { Room } from '@rocket.chat/core-services'; +import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { isObject } from '../../../lib/utils/isObject'; +import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage'; + +export const getRoomByNameOrIdWithOptionToJoin = async ({ + user, + nameOrId = '', + type, + tryDirectByUserIdOnly = false, + joinChannel = true, + errorOnEmpty = true, +}: { + user: Pick; + nameOrId: string; + type?: RoomType; + tryDirectByUserIdOnly?: boolean; + joinChannel?: boolean; + errorOnEmpty?: boolean; +}): Promise => { + let room: IRoom | null; + + // If the nameOrId starts with #, then let's try to find a channel or group + if (nameOrId.startsWith('#')) { + nameOrId = nameOrId.substring(1); + room = await Rooms.findOneByIdOrName(nameOrId); + } else if (nameOrId.startsWith('@') || type === 'd') { + // If the nameOrId starts with @ OR type is 'd', then let's try just a direct message + nameOrId = nameOrId.replace('@', ''); + + let roomUser; + if (tryDirectByUserIdOnly) { + roomUser = await Users.findOneById(nameOrId); + } else { + roomUser = await Users.findOne({ + $or: [{ _id: nameOrId }, { username: nameOrId }], + }); + } + + room = isObject(roomUser) + ? await Rooms.findOneDirectRoomContainingAllUserIDs([...new Set([user._id, roomUser._id])]) + : await Rooms.findOneById(nameOrId); + + // If the room hasn't been found yet, let's try some more + if (!isObject(room)) { + // If the roomUser wasn't found, then there's no destination to point towards + // so return out based upon errorOnEmpty + if (!isObject(roomUser)) { + if (errorOnEmpty) { + throw new Meteor.Error('invalid-channel'); + } else { + return null; + } + } + + const { rid } = await createDirectMessage([roomUser.username], user._id); + + return Rooms.findOneById(rid); + } + } else { + // Otherwise, we'll treat this as a channel or group. + room = await Rooms.findOneByIdOrName(nameOrId); + } + + // If no room was found, handle the room return based upon errorOnEmpty + if (!room && errorOnEmpty) { + throw new Meteor.Error('invalid-channel'); + } + + if (room === null) { + return null; + } + + // If a room was found and they provided a type to search, then check + // and if the type found isn't what we're looking for then handle + // the return based upon errorOnEmpty + if (type && room.t !== type) { + if (errorOnEmpty) { + throw new Meteor.Error('invalid-channel'); + } else { + return null; + } + } + + // If the room type is channel and joinChannel has been passed, try to join them + // if they can't join the room, this will error out! + if (room.t === 'c' && joinChannel) { + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }); + if (!sub) { + await Room.join({ room, user }); + } + } + + return room; +}; diff --git a/apps/meteor/server/lib/rooms/getRoomsWithSingleOwner.ts b/apps/meteor/server/lib/rooms/getRoomsWithSingleOwner.ts new file mode 100644 index 0000000..1fa596c --- /dev/null +++ b/apps/meteor/server/lib/rooms/getRoomsWithSingleOwner.ts @@ -0,0 +1,81 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; + +import { subscriptionHasRole } from '../authorization'; + +export type SubscribedRoomsForUserWithDetails = { + rid: string; + t: string; + shouldBeRemoved: boolean; + shouldChangeOwner: boolean; + userIsLastOwner: boolean; + newOwner: IUser['_id'] | null; +}; + +export function shouldRemoveOrChangeOwner(subscribedRooms: SubscribedRoomsForUserWithDetails[]): boolean { + return subscribedRooms.some(({ shouldBeRemoved, shouldChangeOwner }) => shouldBeRemoved || shouldChangeOwner); +} + +export async function getSubscribedRoomsForUserWithDetails( + userId: string, + assignNewOwner = true, + roomIds: string[] = [], +): Promise { + const subscribedRooms: SubscribedRoomsForUserWithDetails[] = []; + + const cursor = + roomIds.length > 0 ? Subscriptions.findByUserIdAndRoomIds(userId, roomIds) : Subscriptions.findByUserIdExceptType(userId, 'd'); + + // Iterate through all the rooms the user is subscribed to, to check if he is the last owner of any of them. + for await (const subscription of cursor) { + const roomData: SubscribedRoomsForUserWithDetails = { + rid: subscription.rid, + t: subscription.t, + shouldBeRemoved: false, + shouldChangeOwner: false, + userIsLastOwner: false, + newOwner: null, + }; + + if (subscriptionHasRole(subscription, 'owner')) { + // Fetch the number of owners + const numOwners = await Subscriptions.countByRoomIdAndRoles(subscription.rid, ['owner']); + // If it's only one, then this user is the only owner. + roomData.userIsLastOwner = numOwners === 1; + if (numOwners === 1 && assignNewOwner) { + // Let's check how many subscribers the room has. + const options = { projection: { 'u._id': 1 }, sort: { ts: 1 as const } }; + const subscribersCursor = Subscriptions.findByRoomId(subscription.rid, options); + + for await (const { + u: { _id: uid }, + } of subscribersCursor) { + // If we already changed the owner or this subscription is for the user we are removing, then don't try to give it ownership + if (uid === userId) { + continue; + } + const newOwner = await Users.findOneActiveById(uid, { projection: { _id: 1 } }); + if (!newOwner) { + continue; + } + + roomData.newOwner = uid; + roomData.shouldChangeOwner = true; + break; + } + + // If there's no subscriber available to be the new owner and it's not a public room, we can remove it. + if (!roomData.shouldChangeOwner && roomData.t !== 'c') { + roomData.shouldBeRemoved = true; + } + } + } else if (roomData.t !== 'c') { + // If the user is not an owner, remove the room if the user is the only subscriber + roomData.shouldBeRemoved = (await Subscriptions.countByRoomId(roomData.rid)) === 1; + } + + subscribedRooms.push(roomData); + } + + return subscribedRooms; +} diff --git a/apps/meteor/server/lib/rooms/invites/findOrCreateInvite.ts b/apps/meteor/server/lib/rooms/invites/findOrCreateInvite.ts new file mode 100644 index 0000000..1c7f543 --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/findOrCreateInvite.ts @@ -0,0 +1,126 @@ +import { api } from '@rocket.chat/core-services'; +import type { IInvite } from '@rocket.chat/core-typings'; +import { Invites, Subscriptions, Rooms } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { settings } from '../../../settings'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { getURL } from '../../utils/getURL'; +import { roomCoordinator } from '../roomCoordinator'; + +function getInviteUrl(invite: Omit) { + const { _id } = invite; + + const useDirectLink = settings.get('Accounts_Registration_InviteUrlType') === 'direct'; + + return getURL( + `invite/${_id}`, + { + full: useDirectLink, + cloud: !useDirectLink, + cloud_route: 'invite', + }, + settings.get('DeepLink_Url'), + ); +} + +const possibleDays = [0, 1, 7, 15, 30]; +const possibleUses = [0, 1, 5, 10, 25, 50, 100]; + +export const findOrCreateInvite = async (userId: string, invite: Pick) => { + if (!userId || !invite) { + return false; + } + + if (!invite.rid) { + throw new Meteor.Error('error-the-field-is-required', 'The field rid is required', { + method: 'findOrCreateInvite', + field: 'rid', + }); + } + + if (!(await hasPermissionAsync(userId, 'create-invite-links', invite.rid))) { + throw new Meteor.Error('not_authorized'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(invite.rid, userId, { + projection: { _id: 1 }, + }); + if (!subscription) { + throw new Meteor.Error('error-invalid-room', 'The rid field is invalid', { + method: 'findOrCreateInvite', + field: 'rid', + }); + } + + const room = await Rooms.findOneById(invite.rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'The rid field is invalid', { + method: 'findOrCreateInvite', + field: 'rid', + }); + } + + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', { + method: 'findOrCreateInvite', + field: 'rid', + }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, userId))) { + throw new Meteor.Error('error-room-type-not-allowed', 'Cannot create invite links for this room type', { + method: 'findOrCreateInvite', + }); + } + + const { days = 1, maxUses = 0 } = invite; + + if (!possibleDays.includes(days)) { + throw new Meteor.Error('invalid-number-of-days', 'Invite should expire in 1, 7, 15 or 30 days, or send 0 to never expire.'); + } + + if (!possibleUses.includes(maxUses)) { + throw new Meteor.Error('invalid-number-of-uses', 'Invite should be valid for 1, 5, 10, 25, 50, 100 or infinite (0) uses.'); + } + + // Before anything, let's check if there's an existing invite with the same settings for the same channel and user and that has not yet expired. + const existing = await Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days); + + // If an existing invite was found, return it's _id instead of creating a new one. + if (existing) { + existing.url = getInviteUrl(existing); + return existing; + } + + const _id = Random.id(6); + + // insert invite + const createdAt = new Date(); + let expires = null; + if (days > 0) { + expires = new Date(createdAt); + expires.setDate(expires.getDate() + days); + } + + const createInvite: Omit = { + _id, + days, + maxUses, + rid: invite.rid, + userId, + createdAt, + expires, + url: '', + uses: 0, + }; + + await Invites.insertOne(createInvite); + + void api.broadcast('notify.updateInvites', userId, { invite: createInvite }); + + createInvite.url = getInviteUrl(createInvite); + return createInvite; +}; diff --git a/apps/meteor/server/lib/rooms/invites/listInvites.ts b/apps/meteor/server/lib/rooms/invites/listInvites.ts new file mode 100644 index 0000000..cc43976 --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/listInvites.ts @@ -0,0 +1,16 @@ +import { Invites } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +export const listInvites = async (userId: string) => { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'listInvites' }); + } + + if (!(await hasPermissionAsync(userId, 'create-invite-links'))) { + throw new Meteor.Error('not_authorized'); + } + + return Invites.find({}).toArray(); +}; diff --git a/apps/meteor/server/lib/rooms/invites/removeInvite.ts b/apps/meteor/server/lib/rooms/invites/removeInvite.ts new file mode 100644 index 0000000..6be4157 --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/removeInvite.ts @@ -0,0 +1,32 @@ +import type { IInvite } from '@rocket.chat/core-typings'; +import { Invites } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +export const removeInvite = async (userId: string, invite: Pick) => { + if (!userId || !invite) { + return false; + } + + if (!(await hasPermissionAsync(userId, 'create-invite-links'))) { + throw new Meteor.Error('not_authorized'); + } + + if (!invite._id) { + throw new Meteor.Error('error-the-field-is-required', 'The field _id is required', { + method: 'removeInvite', + field: '_id', + }); + } + + const { deletedCount } = await Invites.removeById(invite._id); + + if (!deletedCount) { + throw new Meteor.Error('invalid-invitation-id', 'Invalid Invitation _id', { + method: 'removeInvite', + }); + } + + return true; +}; diff --git a/apps/meteor/server/lib/rooms/invites/sendInvitationEmail.ts b/apps/meteor/server/lib/rooms/invites/sendInvitationEmail.ts new file mode 100644 index 0000000..dc4f25f --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/sendInvitationEmail.ts @@ -0,0 +1,70 @@ +import { Settings } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import * as Mailer from '../../notifications/email/api'; +import { notifyOnSettingChanged } from '../../notifyListener'; + +let html = ''; +Meteor.startup(() => { + Mailer.getTemplate('Invitation_Email', (value) => { + html = value; + }); +}); + +export const sendInvitationEmail = async (userId: string, emails: string[]) => { + check(emails, [String]); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendInvitationEmail', + }); + } + if (!(await hasPermissionAsync(userId, 'bulk-register-user'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'sendInvitationEmail', + }); + } + const validEmails = emails.filter(Mailer.checkAddressFormat); + + if (!validEmails || validEmails.length === 0) { + throw new Meteor.Error('error-email-send-failed', 'No valid email addresses', { + method: 'sendInvitationEmail', + }); + } + + const subject = settings.get('Invitation_Subject'); + + if (!subject) { + throw new Meteor.Error('error-email-send-failed', 'No subject', { + method: 'sendInvitationEmail', + }); + } + + for (const email of validEmails) { + try { + await Mailer.send({ + to: email, + from: settings.get('From_Email'), + subject, + html, + data: { + email, + }, + }); + + const value = await Settings.incrementValueById('Invitation_Email_Count', 1, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + + continue; + } catch ({ message }: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, { + method: 'sendInvitationEmail', + message, + }); + } + } +}; diff --git a/apps/meteor/server/lib/rooms/invites/useInviteToken.ts b/apps/meteor/server/lib/rooms/invites/useInviteToken.ts new file mode 100644 index 0000000..837700a --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/useInviteToken.ts @@ -0,0 +1,69 @@ +import { isBannedSubscription } from '@rocket.chat/core-typings'; +import { Invites, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { validateInviteToken } from './validateInviteToken'; +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { addUserToRoom } from '../addUserToRoom'; +import { roomCoordinator } from '../roomCoordinator'; + +export const useInviteToken = async (userId: string, token: string) => { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'The user is invalid', { + method: 'useInviteToken', + field: 'userId', + }); + } + + if (!token) { + throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { + method: 'useInviteToken', + field: 'token', + }); + } + + const { inviteData, room } = await validateInviteToken(token); + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, userId))) { + throw new Meteor.Error('error-room-type-not-allowed', "Can't join room of this type via invite", { + method: 'useInviteToken', + field: 'token', + }); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The user is invalid', { + method: 'useInviteToken', + field: 'userId', + }); + } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id); + if (subscription && isBannedSubscription(subscription)) { + throw new Meteor.Error('error-user-is-banned', 'User is banned from this room', { + method: 'useInviteToken', + }); + } + + await Users.updateInviteToken(user._id, token); + + if (!subscription) { + await Invites.increaseUsageById(inviteData._id, 1); + } + + // If the user already has an username, then join the invite room, + // If no username is set yet, then the the join will happen on the setUsername method + if (user.username) { + await addUserToRoom(room._id, user); + } + + return { + room: { + rid: inviteData.rid, + prid: room.prid, + fname: room.fname, + name: room.name, + t: room.t, + }, + }; +}; diff --git a/apps/meteor/server/lib/rooms/invites/validateInviteToken.ts b/apps/meteor/server/lib/rooms/invites/validateInviteToken.ts new file mode 100644 index 0000000..9b071ea --- /dev/null +++ b/apps/meteor/server/lib/rooms/invites/validateInviteToken.ts @@ -0,0 +1,55 @@ +import { Invites, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; + +export const validateInviteToken = async (token: string) => { + if (!token || typeof token !== 'string') { + throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { + method: 'validateInviteToken', + field: 'token', + }); + } + + const inviteData = await Invites.findOneById(token); + if (!inviteData) { + throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { + method: 'validateInviteToken', + field: 'token', + }); + } + + const room = await Rooms.findOneById(inviteData.rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'The invite token is invalid.', { + method: 'validateInviteToken', + field: 'rid', + }); + } + + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', { + method: 'validateInviteToken', + field: 'rid', + }); + } + + if (inviteData.expires && new Date(inviteData.expires).getTime() <= Date.now()) { + throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', { + method: 'validateInviteToken', + field: 'expires', + }); + } + + if (inviteData.maxUses > 0 && inviteData.uses >= inviteData.maxUses) { + throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', { + method: 'validateInviteToken', + field: 'maxUses', + }); + } + + return { + inviteData, + room, + }; +}; diff --git a/apps/meteor/server/lib/rooms/joinDefaultChannels.ts b/apps/meteor/server/lib/rooms/joinDefaultChannels.ts new file mode 100644 index 0000000..b0029cd --- /dev/null +++ b/apps/meteor/server/lib/rooms/joinDefaultChannels.ts @@ -0,0 +1,13 @@ +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { addUserToDefaultChannels } from './addUserToDefaultChannels'; + +export const joinDefaultChannels = async (userId: string, silenced?: boolean): Promise => { + const user = await Users.findOneById(userId); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + return addUserToDefaultChannels(user, Boolean(silenced)); +}; diff --git a/apps/meteor/server/lib/rooms/relinquishRoomOwnerships.ts b/apps/meteor/server/lib/rooms/relinquishRoomOwnerships.ts new file mode 100644 index 0000000..cdfd1bb --- /dev/null +++ b/apps/meteor/server/lib/rooms/relinquishRoomOwnerships.ts @@ -0,0 +1,85 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive, Team } from '@rocket.chat/models'; + +import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner'; +import { eraseRoomLooseValidation, eraseTeamOnRelinquishRoomOwnerships } from '../../api/lib/eraseTeam'; +import { FileUpload } from '../media/file-upload'; +import { notifyOnSubscriptionChanged } from '../notifyListener'; +import { addUserRolesAsync } from '../roles/addUserRoles'; + +const bulkTeamCleanup = async (rids: IRoom['_id'][]) => { + const rooms = (await Rooms.findByIds(rids, { projection: { teamId: 1, teamMain: 1 } }).toArray()) as Pick< + IRoom, + '_id' | 'teamId' | 'teamMain' + >[]; + + const teamsToRemove = rooms.filter((room) => room.teamMain); + const teamIds = teamsToRemove.map((room) => room.teamId).filter((teamId) => teamId !== undefined); + const uniqueTeamIds = [...new Set(teamIds)]; + + const deletedRoomIds: string[] = []; + await Promise.all( + uniqueTeamIds.map(async (teamId) => { + const team = await Team.findOneById(teamId); + if (!team) { + return; + } + + const ids = await eraseTeamOnRelinquishRoomOwnerships(team, []); + ids.forEach((id) => deletedRoomIds.push(id)); + }), + ); + return deletedRoomIds; +}; + +const bulkRoomCleanUp = async (rids: string[]) => { + // no bulk deletion for files + await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid))); + + const [, , , , deletedRoomIds] = await Promise.all([ + Subscriptions.removeByRoomIds(rids, { + async onTrash(doc) { + void notifyOnSubscriptionChanged(doc, 'removed'); + }, + }), + Messages.removeByRoomIds(rids), + ReadReceipts.removeByRoomIds(rids), + ReadReceiptsArchive.removeByRoomIds(rids), + bulkTeamCleanup(rids), + ]); + + const restRidsToRemove = rids.filter((rid) => !deletedRoomIds.includes(rid)); + await Promise.all( + restRidsToRemove.map(async (rid) => { + const isDeleted = await eraseRoomLooseValidation(rid); + if (isDeleted) { + deletedRoomIds.push(rid); + } + }), + ); + + return deletedRoomIds; +}; + +export const relinquishRoomOwnerships = async function ( + userId: string, + subscribedRooms: SubscribedRoomsForUserWithDetails[], + removeDirectMessages = true, +) { + // change owners + const changeOwner = subscribedRooms.filter(({ shouldChangeOwner }) => shouldChangeOwner); + + for (const { newOwner, rid } of changeOwner) { + newOwner && (await addUserRolesAsync(newOwner, ['owner'], rid)); + } + + const roomIdsToRemove: string[] = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }) => rid); + + if (removeDirectMessages) { + (await Rooms.find1On1ByUserId(userId, { projection: { _id: 1 } }).toArray()).map(({ _id }: { _id: string }) => + roomIdsToRemove.push(_id), + ); + } + + return bulkRoomCleanUp(roomIdsToRemove); +}; diff --git a/apps/meteor/server/lib/rooms/removeUserFromRoom.ts b/apps/meteor/server/lib/rooms/removeUserFromRoom.ts new file mode 100644 index 0000000..58da007 --- /dev/null +++ b/apps/meteor/server/lib/rooms/removeUserFromRoom.ts @@ -0,0 +1,120 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { Message, Team, Room } from '@rocket.chat/core-services'; +import type { IRoom, IUser, MessageTypesValues } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; +import { afterLeaveRoomCallback } from '../callbacks/afterLeaveRoomCallback'; +import { beforeLeaveRoomCallback } from '../callbacks/beforeLeaveRoomCallback'; +import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../notifyListener'; + +/** + * Removes a user from a room when triggered by federation or other external events. + * Executes only the necessary database operations, with no callbacks, to prevent + * propagation loops during external event processing. + */ +export const performUserRemoval = async function ( + room: IRoom, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, +): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { + projection: { _id: 1, status: 1 }, + }); + if (!subscription) { + return; + } + + // make TS happy, this should never happen + if (!user.username) { + throw new Error('User must have a username to be removed from the room'); + } + + // TODO: move before callbacks to service + await beforeLeaveRoomCallback.run(user, room); + + if (subscription) { + if (options?.customSystemMessage) { + await Message.saveSystemMessage(options?.customSystemMessage, room._id, user.username || '', user); + } else if (options?.byUser) { + const extraData = { + u: options.byUser, + }; + + if (room.teamMain) { + await Message.saveSystemMessage('removed-user-from-team', room._id, user.username, user, extraData); + } else { + await Message.saveSystemMessage('ru', room._id, user.username, user, extraData); + } + } else if (subscription.status === 'INVITED') { + await Message.saveSystemMessage('uir', room._id, user.username, user); + } else if (room.teamMain) { + await Message.saveSystemMessage('ult', room._id, user.username, user); + } else { + await Message.saveSystemMessage('ul', room._id, user.username, user); + } + } + + if (room.t === 'l') { + await Message.saveSystemMessage('command', room._id, 'survey', user); + } + + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(room._id, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + + if (room.teamId && room.teamMain) { + await Team.removeMember(room.teamId, user._id); + } + + if (room.encrypted && settings.get('E2E_Enable')) { + await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); + } + + // remove references to the user in direct message rooms + if (room.t === 'd') { + await Rooms.removeUserReferenceFromDMsById(room._id, user.username, user._id); + } + + void notifyOnRoomChangedById(room._id); +}; + +/** + * Removes a user from the given room by performing the required database updates + * and triggering all standard callbacks. Used for local actions (UI or API) + * that should propagate normally to federation and other subscribers. + */ +export const removeUserFromRoom = async function ( + rid: string, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, +): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + return; + } + + // Rationale: for an abac room, we don't want apps to be able to prevent a user from leaving + if (!options?.skipAppPreEvents) { + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + } + + await Room.beforeLeave(room); + + await performUserRemoval(room, user, options); + + await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); + + await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); +}; diff --git a/apps/meteor/server/lib/rooms/retention/cronPruneMessages.ts b/apps/meteor/server/lib/rooms/retention/cronPruneMessages.ts new file mode 100644 index 0000000..dedec14 --- /dev/null +++ b/apps/meteor/server/lib/rooms/retention/cronPruneMessages.ts @@ -0,0 +1,133 @@ +import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { Rooms } from '@rocket.chat/models'; + +import { getCronAdvancedTimerFromPrecisionSetting } from '../../../../lib/getCronAdvancedTimerFromPrecisionSetting'; +import { settings } from '../../../settings'; +import { cleanRoomHistory } from '../cleanRoomHistory'; + +type RetentionRoomTypes = 'c' | 'p' | 'd'; + +const getMaxAgeSettingIdByRoomType = (type: RetentionRoomTypes) => { + switch (type) { + case 'c': + return settings.get('RetentionPolicy_TTL_Channels'); + case 'p': + return settings.get('RetentionPolicy_TTL_Groups'); + case 'd': + return settings.get('RetentionPolicy_TTL_DMs'); + } +}; + +let types: RetentionRoomTypes[] = []; + +const oldest = new Date('0001-01-01T00:00:00Z'); + +const toDays = (d: number): number => d * 1000 * 60 * 60 * 24; + +async function job(): Promise { + const now = new Date(); + const filesOnly = settings.get('RetentionPolicy_FilesOnly'); + const excludePinned = settings.get('RetentionPolicy_DoNotPrunePinned'); + const ignoreDiscussion = settings.get('RetentionPolicy_DoNotPruneDiscussion'); + const ignoreThreads = settings.get('RetentionPolicy_DoNotPruneThreads'); + + const ignoreDiscussionQuery = ignoreDiscussion ? { prid: { $exists: false } } : {}; + + // get all rooms with default values + for (const type of types) { + const maxAge = getMaxAgeSettingIdByRoomType(type) || 0; + const latest = new Date(now.getTime() - maxAge); + + const rooms = await Rooms.find( + { + 't': type, + '$or': [{ 'retention.enabled': { $eq: true } }, { 'retention.enabled': { $exists: false } }], + 'retention.overrideGlobal': { $ne: true }, + ...ignoreDiscussionQuery, + }, + { projection: { _id: 1 } }, + ).toArray(); + + for await (const { _id: rid } of rooms) { + await cleanRoomHistory({ + rid, + latest, + oldest, + filesOnly, + excludePinned, + ignoreDiscussion, + ignoreThreads, + }); + } + } + + const rooms = await Rooms.find( + { + 'retention.enabled': { $eq: true }, + 'retention.overrideGlobal': { $eq: true }, + 'retention.maxAge': { $gte: 0 }, + ...ignoreDiscussionQuery, + }, + { projection: { _id: 1, retention: 1 } }, + ).toArray(); + + for await (const { _id: rid, retention } of rooms) { + const { maxAge = 30, filesOnly, excludePinned, ignoreThreads } = retention; + const latest = new Date(now.getTime() - toDays(maxAge)); + await cleanRoomHistory({ + rid, + latest, + oldest, + filesOnly, + excludePinned, + ignoreDiscussion, + ignoreThreads, + }); + } +} + +const pruneCronName = 'Prune old messages by retention policy'; + +async function deployCron(precision: string): Promise { + if (await cronJobs.has(pruneCronName)) { + await cronJobs.remove(pruneCronName); + } + await cronJobs.add(pruneCronName, precision, async () => job()); +} + +settings.watchMultiple( + [ + 'RetentionPolicy_Enabled', + 'RetentionPolicy_AppliesToChannels', + 'RetentionPolicy_AppliesToGroups', + 'RetentionPolicy_AppliesToDMs', + 'RetentionPolicy_Advanced_Precision', + 'RetentionPolicy_Advanced_Precision_Cron', + 'RetentionPolicy_Precision', + ], + async function reloadPolicy() { + types = []; + + if (!settings.get('RetentionPolicy_Enabled')) { + return cronJobs.remove(pruneCronName); + } + if (settings.get('RetentionPolicy_AppliesToChannels')) { + types.push('c'); + } + + if (settings.get('RetentionPolicy_AppliesToGroups')) { + types.push('p'); + } + + if (settings.get('RetentionPolicy_AppliesToDMs')) { + types.push('d'); + } + + const precision = + (settings.get('RetentionPolicy_Advanced_Precision') && settings.get('RetentionPolicy_Advanced_Precision_Cron')) || + getCronAdvancedTimerFromPrecisionSetting(settings.get('RetentionPolicy_Precision')); + + return deployCron(precision); + }, +); diff --git a/apps/meteor/server/lib/rooms/retention/index.ts b/apps/meteor/server/lib/rooms/retention/index.ts new file mode 100644 index 0000000..87bfac0 --- /dev/null +++ b/apps/meteor/server/lib/rooms/retention/index.ts @@ -0,0 +1 @@ +import './cronPruneMessages'; diff --git a/apps/meteor/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts new file mode 100644 index 0000000..dbec195 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomCoordinator.ts @@ -0,0 +1,109 @@ +import { getUserDisplayName } from '@rocket.chat/core-typings'; +import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, IUpload } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import type { IRoomTypeConfig, IRoomTypeServerDirectives, RoomSettingsEnum, RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { RoomCoordinator } from '../../../lib/rooms/coordinator'; +import { settings } from '../../settings'; + +class RoomCoordinatorServer extends RoomCoordinator { + add(roomConfig: IRoomTypeConfig, directives: Partial): void { + this.addRoomType(roomConfig, { + allowRoomSettingChange(_room: IRoom, _setting: ValueOf) { + return true; + }, + async allowMemberAction(_room: IRoom, _action: ValueOf, _userId?: IUser['_id']): Promise { + return false; + }, + async roomName(_room: IRoom, _userId?: string): Promise { + return ''; + }, + isGroupChat(_room: IRoom): boolean { + return false; + }, + async canBeDeleted(hasPermission: (permissionId: string, rid?: string) => Promise | boolean, room: IRoom): Promise { + if (!hasPermission && typeof hasPermission !== 'function') { + throw new Error('You MUST provide the "hasPermission" to canBeDeleted function'); + } + return hasPermission(`delete-${room.t}`, room._id); + }, + preventRenaming(): boolean { + return false; + }, + async getDiscussionType(): Promise { + return 'p'; + }, + async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }, _file?: IUpload): Promise { + return false; + }, + async getNotificationDetails( + room: IRoom, + sender: AtLeast, + notificationMessage: string, + userId: string, + ): Promise<{ title: string | undefined; text: string; name: string | undefined }> { + const title = `#${await this.roomName(room, userId)}`; + const useRealName = settings.get('UI_Use_Real_Name'); + const senderName = getUserDisplayName(sender.name, sender.username, useRealName); + + const text = `${senderName}: ${notificationMessage}`; + + return { title, text, name: room.name }; + }, + getMsgSender(message: IMessage): Promise { + return Users.findOneById(message.u._id); + }, + includeInRoomSearch(): boolean { + return false; + }, + includeInDashboard(): boolean { + return false; + }, + + ...directives, + config: roomConfig, + }); + } + + getRoomDirectives(roomType: string): IRoomTypeServerDirectives { + const directives = this.roomTypes[roomType]?.directives; + + if (!directives) { + throw new Error(`Room type ${roomType} not found`); + } + return directives as IRoomTypeServerDirectives; + } + + getTypesToShowOnDashboard(): Array { + return Object.keys(this.roomTypes).filter((key) => (this.roomTypes[key].directives as IRoomTypeServerDirectives).includeInDashboard()); + } + + async getRoomName(roomType: string, roomData: IRoom, userId?: string): Promise { + return (await this.getRoomDirectives(roomType).roomName(roomData, userId)) ?? ''; + } + + setRoomFind(roomType: string, roomFind: Required>['roomFind']): void { + const directives = this.getRoomDirectives(roomType); + if (!directives) { + return; + } + + if (directives.roomFind) { + throw new Error('Room find for the given type already exists'); + } + + directives.roomFind = roomFind; + } + + getRoomFind(roomType: string): Required>['roomFind'] | undefined { + return this.getRoomDirectives(roomType).roomFind; + } + + searchableRoomTypes(): Array { + return Object.entries(this.roomTypes) + .filter(([_identifier, { directives }]) => (directives as IRoomTypeServerDirectives).includeInRoomSearch()) + .map(([identifier]) => identifier); + } +} + +export const roomCoordinator = new RoomCoordinatorServer(); diff --git a/apps/meteor/server/lib/rooms/roomTypes/conversation.ts b/apps/meteor/server/lib/rooms/roomTypes/conversation.ts new file mode 100644 index 0000000..d600eb5 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/conversation.ts @@ -0,0 +1,6 @@ +import { getConversationRoomType } from '../../../../lib/rooms/roomTypes/conversation'; +import { roomCoordinator } from '../roomCoordinator'; + +const ConversationRoomType = getConversationRoomType(roomCoordinator); + +roomCoordinator.add(ConversationRoomType, {}); diff --git a/apps/meteor/server/lib/rooms/roomTypes/direct.ts b/apps/meteor/server/lib/rooms/roomTypes/direct.ts new file mode 100644 index 0000000..b2b90ec --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/direct.ts @@ -0,0 +1,118 @@ +import type { AtLeast } from '@rocket.chat/core-typings'; +import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; + +import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig'; +import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { getDirectMessageRoomType } from '../../../../lib/rooms/roomTypes/direct'; +import { isFederationEnabled } from '../../../services/federation/utils'; +import { settings } from '../../../settings'; +import { findDirectRoomByIdentifier } from '../findDirectRoomByIdentifier'; +import { roomCoordinator } from '../roomCoordinator'; + +const DirectMessageRoomType = getDirectMessageRoomType(roomCoordinator); + +roomCoordinator.add(DirectMessageRoomType, { + allowRoomSettingChange(_room, setting) { + if (isRoomFederated(_room)) { + if (isRoomNativeFederated(_room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (setting) { + case RoomSettingsEnum.TYPE: + case RoomSettingsEnum.NAME: + case RoomSettingsEnum.SYSTEM_MESSAGES: + case RoomSettingsEnum.DESCRIPTION: + case RoomSettingsEnum.READ_ONLY: + case RoomSettingsEnum.REACT_WHEN_READ_ONLY: + case RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE: + case RoomSettingsEnum.JOIN_CODE: + return false; + case RoomSettingsEnum.E2E: + return settings.get('E2E_Enable') === true; + default: + return true; + } + }, + + async allowMemberAction(room, action, _userId) { + if (isRoomFederated(room)) { + if (isRoomNativeFederated(room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (action) { + case RoomMemberActions.BLOCK: + return !this.isGroupChat(room); + default: + return false; + } + }, + + async roomName(room, uid) { + const subscription = await (async (): Promise<{ fname?: string; name?: string } | null> => { + if (room.fname || room.name) { + return { + fname: room.fname, + name: room.name, + }; + } + + if (!room._id) { + return null; + } + + if (uid) { + return Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { projection: { name: 1, fname: 1 } }); + } + + // If we don't know what user is requesting the roomName, then any subscription will do + return Subscriptions.findOne({ rid: room._id }, { projection: { name: 1, fname: 1 } }); + })(); + + if (!subscription) { + return; + } + + if (settings.get('UI_Use_Real_Name') && room.fname) { + return subscription.fname; + } + + return subscription.name; + }, + + isGroupChat(room) { + return (room?.uids?.length || 0) > 2; + }, + + roomFind(identifier, uid) { + return uid ? findDirectRoomByIdentifier(identifier, uid) : undefined; + }, + + async getNotificationDetails(room, sender, notificationMessage, userId) { + const useRealName = settings.get('UI_Use_Real_Name'); + + const displayRoomName = await this.roomName(room, userId); + + if (this.isGroupChat(room)) { + return { + title: displayRoomName, + text: `${(useRealName && sender.name) || sender.username}: ${notificationMessage}`, + name: room.name || displayRoomName, + }; + } + + return { + title: (useRealName && sender.name) || sender.username, + text: notificationMessage, + name: room.name || displayRoomName, + }; + }, + + includeInDashboard() { + return true; + }, +} satisfies AtLeast); diff --git a/apps/meteor/server/lib/rooms/roomTypes/favorite.ts b/apps/meteor/server/lib/rooms/roomTypes/favorite.ts new file mode 100644 index 0000000..c06c3ae --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/favorite.ts @@ -0,0 +1,6 @@ +import { getFavoriteRoomType } from '../../../../lib/rooms/roomTypes/favorite'; +import { roomCoordinator } from '../roomCoordinator'; + +const FavoriteRoomType = getFavoriteRoomType(roomCoordinator); + +roomCoordinator.add(FavoriteRoomType, {}); diff --git a/apps/meteor/server/lib/rooms/roomTypes/index.ts b/apps/meteor/server/lib/rooms/roomTypes/index.ts new file mode 100644 index 0000000..bdb2d43 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/index.ts @@ -0,0 +1,7 @@ +import './conversation'; +import './direct'; +import './favorite'; +import './livechat'; +import './private'; +import './public'; +import './unread'; diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts new file mode 100644 index 0000000..7c1bc3c --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -0,0 +1,53 @@ +import type { AtLeast, ValueOf } from '@rocket.chat/core-typings'; +import { isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models'; + +import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig'; +import { getLivechatRoomType } from '../../../../lib/rooms/roomTypes/livechat'; +import { roomCoordinator } from '../roomCoordinator'; + +const LivechatRoomType = getLivechatRoomType(roomCoordinator); + +roomCoordinator.add(LivechatRoomType, { + allowRoomSettingChange(_room, setting) { + switch (setting) { + case RoomSettingsEnum.JOIN_CODE: + return false; + default: + return true; + } + }, + + async allowMemberAction(_room, action) { + return ([RoomMemberActions.INVITE, RoomMemberActions.JOIN] as Array>).includes(action); + }, + + async roomName(room, _userId?) { + return (room.name || room.fname || (room as any).label) as string; + }, + + async canAccessUploadedFile({ rc_token: token, rc_rid: rid }, file) { + if (!token || !rid) { + return false; + } + if (file?.rid && file.rid !== rid) { + return false; + } + return !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token)); + }, + + async getNotificationDetails(room, _sender, notificationMessage, userId) { + const roomName = await this.roomName(room, userId); + const title = `[Omnichannel] ${roomName}`; + const text = notificationMessage; + + return { title, text, name: roomName }; + }, + + async getMsgSender(message) { + if (isMessageFromVisitor(message)) { + return LivechatVisitors.findOneEnabledById(message.u._id); + } + }, +} as AtLeast); diff --git a/apps/meteor/server/lib/rooms/roomTypes/private.ts b/apps/meteor/server/lib/rooms/roomTypes/private.ts new file mode 100644 index 0000000..926d1e3 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/private.ts @@ -0,0 +1,70 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings'; + +import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { getPrivateRoomType } from '../../../../lib/rooms/roomTypes/private'; +import { isFederationEnabled } from '../../../services/federation/utils'; +import { settings } from '../../../settings'; +import { roomCoordinator } from '../roomCoordinator'; + +const PrivateRoomType = getPrivateRoomType(roomCoordinator); + +roomCoordinator.add(PrivateRoomType, { + allowRoomSettingChange(room, setting) { + if (isRoomFederated(room)) { + if (isRoomNativeFederated(room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (setting) { + case RoomSettingsEnum.JOIN_CODE: + return false; + case RoomSettingsEnum.BROADCAST: + return Boolean(room.broadcast); + case RoomSettingsEnum.READ_ONLY: + return !room.broadcast; + case RoomSettingsEnum.REACT_WHEN_READ_ONLY: + return Boolean(!room.broadcast && room.ro); + case RoomSettingsEnum.E2E: + return settings.get('E2E_Enable') === true; + case RoomSettingsEnum.SYSTEM_MESSAGES: + default: + return true; + } + }, + + async allowMemberAction(_room, action, _userId) { + if (isRoomFederated(_room as IRoom)) { + if (isRoomNativeFederated(_room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (action) { + case RoomMemberActions.BLOCK: + return false; + default: + return true; + } + }, + + async roomName(room, _userId?) { + if (room.prid || isRoomFederated(room)) { + return room.fname; + } + if (settings.get('UI_Allow_room_names_with_special_chars')) { + return room.fname || room.name; + } + + return room.name; + }, + + isGroupChat(_room) { + return true; + }, + + includeInDashboard() { + return true; + }, +}); diff --git a/apps/meteor/server/lib/rooms/roomTypes/public.ts b/apps/meteor/server/lib/rooms/roomTypes/public.ts new file mode 100644 index 0000000..ad5eaf2 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/public.ts @@ -0,0 +1,83 @@ +import { Team } from '@rocket.chat/core-services'; +import type { AtLeast, IRoom } from '@rocket.chat/core-typings'; +import { isRoomFederated, isRoomNativeFederated, TeamType } from '@rocket.chat/core-typings'; + +import type { IRoomTypeServerDirectives } from '../../../../definition/IRoomTypeConfig'; +import { RoomSettingsEnum, RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { getPublicRoomType } from '../../../../lib/rooms/roomTypes/public'; +import { isFederationEnabled } from '../../../services/federation/utils'; +import { settings } from '../../../settings'; +import { roomCoordinator } from '../roomCoordinator'; + +const PublicRoomType = getPublicRoomType(roomCoordinator); + +roomCoordinator.add(PublicRoomType, { + allowRoomSettingChange(room, setting) { + if (isRoomFederated(room)) { + if (isRoomNativeFederated(room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (setting) { + case RoomSettingsEnum.BROADCAST: + return Boolean(room.broadcast); + case RoomSettingsEnum.READ_ONLY: + return Boolean(!room.broadcast); + case RoomSettingsEnum.REACT_WHEN_READ_ONLY: + return Boolean(!room.broadcast && room.ro); + case RoomSettingsEnum.E2E: + return false; + case RoomSettingsEnum.SYSTEM_MESSAGES: + default: + return true; + } + }, + + async allowMemberAction(_room, action, _userId) { + if (isRoomFederated(_room as IRoom)) { + if (isRoomNativeFederated(_room) && isFederationEnabled()) { + return true; + } + return false; + } + switch (action) { + case RoomMemberActions.BLOCK: + return false; + default: + return true; + } + }, + + async roomName(room, _userId?) { + if (room.prid || isRoomFederated(room)) { + return room.fname; + } + if (settings.get('UI_Allow_room_names_with_special_chars')) { + return room.fname || room.name; + } + return room.name; + }, + + isGroupChat(_room) { + return true; + }, + + includeInDashboard() { + return true; + }, + + async getDiscussionType(room) { + if (room?.teamId) { + const team = await Team.getOneById(room.teamId, { projection: { type: 1 } }); + if (team?.type === TeamType.PRIVATE) { + return 'p'; + } + } + return 'c'; + }, + + includeInRoomSearch() { + return true; + }, +} as AtLeast); diff --git a/apps/meteor/server/lib/rooms/roomTypes/unread.ts b/apps/meteor/server/lib/rooms/roomTypes/unread.ts new file mode 100644 index 0000000..351fea2 --- /dev/null +++ b/apps/meteor/server/lib/rooms/roomTypes/unread.ts @@ -0,0 +1,6 @@ +import { getUnreadRoomType } from '../../../../lib/rooms/roomTypes/unread'; +import { roomCoordinator } from '../roomCoordinator'; + +const UnreadRoomType = getUnreadRoomType(roomCoordinator); + +roomCoordinator.add(UnreadRoomType, {}); diff --git a/apps/meteor/server/lib/rooms/setRoomAvatar.ts b/apps/meteor/server/lib/rooms/setRoomAvatar.ts new file mode 100644 index 0000000..7b19f6a --- /dev/null +++ b/apps/meteor/server/lib/rooms/setRoomAvatar.ts @@ -0,0 +1,51 @@ +import { api, Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { isRegisterUser } from '@rocket.chat/core-typings'; +import { Avatars, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RocketChatFile } from '../media/file'; +import { FileUpload } from '../media/file-upload'; + +export const setRoomAvatar = async function (rid: string, dataURI: string, user: IUser): Promise { + if (!isRegisterUser(user)) { + throw new Meteor.Error('invalid-user', 'Invalid user', { + function: 'RocketChat.setRoomAvatar', + }); + } + + const fileStore = FileUpload.getStore('Avatars'); + + const current = await Avatars.findOneByRoomId(rid); + + if (!dataURI) { + await fileStore.deleteByRoomId(rid); + await Message.saveSystemMessage('room_changed_avatar', rid, '', user); + void api.broadcast('room.avatarUpdate', { _id: rid }); + await Rooms.unsetAvatarData(rid); + return; + } + + const fileData = RocketChatFile.dataURIParse(dataURI); + + const buffer = Buffer.from(fileData.image, 'base64'); + + const file = { + rid, + type: fileData.contentType, + size: buffer.length, + uid: user._id, + }; + + if (current) { + await fileStore.deleteById(current._id); + } + + const result = await fileStore.insert(file, buffer); + + setTimeout(async () => { + result.etag && (await Rooms.setAvatarData(rid, 'upload', result.etag)); + await Message.saveSystemMessage('room_changed_avatar', rid, '', user); + void api.broadcast('room.avatarUpdate', { _id: rid, avatarETag: result.etag }); + }, 500); +}; diff --git a/apps/meteor/server/lib/rooms/settings/index.ts b/apps/meteor/server/lib/rooms/settings/index.ts new file mode 100644 index 0000000..9e963fd --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/index.ts @@ -0,0 +1,2 @@ +export { saveRoomTopic } from './saveRoomTopic'; +export { saveRoomName } from './saveRoomName'; diff --git a/apps/meteor/server/lib/rooms/settings/saveReactWhenReadOnly.ts b/apps/meteor/server/lib/rooms/settings/saveReactWhenReadOnly.ts new file mode 100644 index 0000000..f9edd75 --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveReactWhenReadOnly.ts @@ -0,0 +1,28 @@ +import { Message } from '@rocket.chat/core-services'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +export const saveReactWhenReadOnly = async function ( + rid: string, + allowReact: boolean, + user: { + _id: string; + username: string; + }, + sendMessage = true, +) { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveReactWhenReadOnly', + }); + } + + const result = await Rooms.setAllowReactingWhenReadOnlyById(rid, allowReact); + + if (result && sendMessage) { + const type = allowReact ? 'room-allowed-reacting' : 'room-disallowed-reacting'; + + await Message.saveSystemMessage(type, rid, '', user); + } +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomAnnouncement.ts b/apps/meteor/server/lib/rooms/settings/saveRoomAnnouncement.ts new file mode 100644 index 0000000..dae66fc --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomAnnouncement.ts @@ -0,0 +1,34 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateResult } from 'mongodb'; + +export const saveRoomAnnouncement = async function ( + rid: string, + roomAnnouncement: string, + user: IUser, + sendMessage = true, +): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomAnnouncement', + }); + } + + let message; + let announcementDetails; + if (typeof roomAnnouncement === 'string') { + message = roomAnnouncement; + } else { + ({ message, ...announcementDetails } = roomAnnouncement); + } + + const updated = await Rooms.setAnnouncementById(rid, message, announcementDetails); + if (updated && sendMessage) { + await Message.saveSystemMessage('room_changed_announcement', rid, message, user); + } + + return updated; +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomCustomFields.ts b/apps/meteor/server/lib/rooms/settings/saveRoomCustomFields.ts new file mode 100644 index 0000000..b39129f --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomCustomFields.ts @@ -0,0 +1,30 @@ +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateResult } from 'mongodb'; + +import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener'; + +export const saveRoomCustomFields = async function (rid: string, roomCustomFields: Record): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomCustomFields', + }); + } + + if (!Match.test(roomCustomFields, Object)) { + throw new Meteor.Error('invalid-roomCustomFields-type', 'Invalid roomCustomFields type', { + function: 'RocketChat.saveRoomCustomFields', + }); + } + + const ret = await Rooms.setCustomFieldsById(rid, roomCustomFields); + + // Update customFields of any user's Subscription related with this rid + const { modifiedCount } = await Subscriptions.updateCustomFieldsByRoomId(rid, roomCustomFields); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return ret; +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomDescription.ts b/apps/meteor/server/lib/rooms/settings/saveRoomDescription.ts new file mode 100644 index 0000000..2d30ecb --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomDescription.ts @@ -0,0 +1,18 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateResult } from 'mongodb'; + +export const saveRoomDescription = async function (rid: string, roomDescription: string, user: IUser): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomDescription', + }); + } + + const update = await Rooms.setDescriptionById(rid, roomDescription); + await Message.saveSystemMessage('room_changed_description', rid, roomDescription, user); + return update; +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomEncrypted.ts b/apps/meteor/server/lib/rooms/settings/saveRoomEncrypted.ts new file mode 100644 index 0000000..c025bcd --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomEncrypted.ts @@ -0,0 +1,38 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { isRegisterUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateResult } from 'mongodb'; + +import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener'; + +export const saveRoomEncrypted = async function (rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomEncrypted', + }); + } + + if (!isRegisterUser(user)) { + throw new Meteor.Error('invalid-user', 'Invalid user', { + function: 'RocketChat.saveRoomEncrypted', + }); + } + + const update = await Rooms.saveEncryptedById(rid, encrypted); + if (update && sendMessage) { + const type = encrypted ? 'room_e2e_enabled' : 'room_e2e_disabled'; + + await Message.saveSystemMessage(type, rid, user.username, user); + } + + if (encrypted) { + const { modifiedCount } = await Subscriptions.disableAutoTranslateByRoomId(rid); + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + } + return update; +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomName.ts b/apps/meteor/server/lib/rooms/settings/saveRoomName.ts new file mode 100644 index 0000000..4853e4f --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomName.ts @@ -0,0 +1,105 @@ +import { Message, Room } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { isRoomNativeFederated } from '@rocket.chat/core-typings'; +import { Integrations, Rooms, Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { Document, UpdateResult } from 'mongodb'; + +import { callbacks } from '../../callbacks'; +import { notifyOnIntegrationChangedByChannels, notifyOnSubscriptionChangedByRoomId } from '../../notifyListener'; +import { checkUsernameAvailability } from '../../users/checkUsernameAvailability'; +import { getValidRoomName } from '../../utils/lib/getValidRoomName'; +import { roomCoordinator } from '../roomCoordinator'; + +const updateFName = async (rid: string, displayName: string): Promise<(UpdateResult | Document)[]> => { + const responses = await Promise.all([Rooms.setFnameById(rid, displayName), Subscriptions.updateFnameByRoomId(rid, displayName)]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; +}; + +const updateRoomName = async (rid: string, displayName: string, slugifiedRoomName: string) => { + // Check if the name is available + if (!(await checkUsernameAvailability(slugifiedRoomName, 'room'))) { + throw new Meteor.Error('error-duplicate-handle', `A room, team or user with name '${slugifiedRoomName}' already exists`, { + function: 'RocketChat.updateRoomName', + handle: slugifiedRoomName, + }); + } + + const responses = await Promise.all([ + Rooms.setNameById(rid, slugifiedRoomName, displayName), + Subscriptions.updateNameAndAlertByRoomId(rid, slugifiedRoomName, displayName), + ]); + + if (responses[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + return responses; +}; + +export async function saveRoomName( + rid: string, + displayName: string | undefined, + user: IUser, + sendMessage = true, +): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomdisplayName', + }); + } + + if (roomCoordinator.getRoomDirectives(room.t).preventRenaming()) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + function: 'RocketChat.saveRoomdisplayName', + }); + } + + await Room.beforeNameChange(room); + + if (isRoomNativeFederated(room)) { + displayName = `${displayName}:${room.federation.mrid.split(':').pop()}`; + } + + if (displayName === room.name) { + return; + } + + if (!displayName?.trim()) { + return; + } + + const isDiscussion = Boolean(room?.prid); + + const slugifiedRoomName = isDiscussion || isRoomNativeFederated(room) ? displayName : await getValidRoomName(displayName, rid); + + let update; + + if (isDiscussion || isRoomNativeFederated(room)) { + update = await updateFName(rid, displayName); + } else { + update = await updateRoomName(rid, displayName, slugifiedRoomName); + } + + if (!update) { + return; + } + + if (room.name && !isDiscussion) { + await Integrations.updateRoomName(room.name, slugifiedRoomName); + void notifyOnIntegrationChangedByChannels([slugifiedRoomName]); + } + + if (sendMessage) { + await Message.saveSystemMessage('r', rid, displayName, user); + } + + await callbacks.run('afterRoomNameChange', { room, name: displayName, oldName: room.name, user }); + return displayName; +} diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomReadOnly.ts b/apps/meteor/server/lib/rooms/settings/saveRoomReadOnly.ts new file mode 100644 index 0000000..9de6031 --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomReadOnly.ts @@ -0,0 +1,29 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +export async function saveRoomReadOnly( + rid: string, + readOnly: boolean, + user: Required>, + sendMessage = true, +) { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomReadOnly', + }); + } + + const result = await Rooms.setReadOnlyById(rid, readOnly); + + if (result && sendMessage) { + if (readOnly) { + await Message.saveSystemMessage('room-set-read-only', rid, '', user); + } else { + await Message.saveSystemMessage('room-removed-read-only', rid, '', user); + } + } + return result; +} diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomSystemMessages.ts b/apps/meteor/server/lib/rooms/settings/saveRoomSystemMessages.ts new file mode 100644 index 0000000..946d563 --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomSystemMessages.ts @@ -0,0 +1,23 @@ +import type { MessageTypesValues } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { MessageTypesValues as messageTypesValues } from '../../../../app/lib/lib/MessageTypes'; + +export const saveRoomSystemMessages = async function (rid: string, systemMessages: MessageTypesValues[]) { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomSystemMessages', + }); + } + if ( + systemMessages && + (!Match.test(systemMessages, [String]) || systemMessages.some((value) => !messageTypesValues.map(({ key }) => key).includes(value))) + ) { + throw new Meteor.Error('invalid-room', 'Invalid option', { + function: 'RocketChat.saveRoomSystemMessages', + }); + } + return Rooms.setSystemMessagesById(rid, systemMessages); +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomTopic.ts b/apps/meteor/server/lib/rooms/settings/saveRoomTopic.ts new file mode 100644 index 0000000..fa4c326 --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomTopic.ts @@ -0,0 +1,31 @@ +import { Message, Room } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../callbacks'; + +export const saveRoomTopic = async ( + rid: string, + roomTopic: string | undefined, + user: Pick, + sendMessage = true, +) => { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomTopic', + }); + } + + const room = await Rooms.findOneById(rid); + + await Room.beforeTopicChange(room!); + + const update = await Rooms.setTopicById(rid, roomTopic); + if (update && sendMessage) { + await Message.saveSystemMessage('room_changed_topic', rid, roomTopic || '', user); + } + await callbacks.run('afterRoomTopicChange', undefined, { room, topic: roomTopic, user }); + return update; +}; diff --git a/apps/meteor/server/lib/rooms/settings/saveRoomType.ts b/apps/meteor/server/lib/rooms/settings/saveRoomType.ts new file mode 100644 index 0000000..3db3aaf --- /dev/null +++ b/apps/meteor/server/lib/rooms/settings/saveRoomType.ts @@ -0,0 +1,74 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateResult, Document } from 'mongodb'; + +import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; +import { settings } from '../../../settings'; +import { i18n } from '../../i18n'; +import { notifyOnSubscriptionChangedByRoomId } from '../../notifyListener'; +import { roomCoordinator } from '../roomCoordinator'; + +export const saveRoomType = async function ( + rid: string, + roomType: IRoom['t'], + user: IUser, + sendMessage = true, +): Promise { + if (!Match.test(rid, String)) { + throw new Meteor.Error('invalid-room', 'Invalid room', { + function: 'RocketChat.saveRoomType', + }); + } + if (roomType !== 'c' && roomType !== 'p') { + throw new Meteor.Error('error-invalid-room-type', 'error-invalid-room-type', { + function: 'RocketChat.saveRoomType', + type: roomType, + }); + } + const room = await Rooms.findOneById(rid); + if (room == null) { + throw new Meteor.Error('error-invalid-room', 'error-invalid-room', { + function: 'RocketChat.saveRoomType', + _id: rid, + }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t)?.allowRoomSettingChange(room, RoomSettingsEnum.TYPE))) { + throw new Meteor.Error('error-direct-room', "Can't change type of direct rooms", { + function: 'RocketChat.saveRoomType', + }); + } + + const result = await Promise.all([Rooms.setTypeById(rid, roomType), Subscriptions.updateTypeByRoomId(rid, roomType)]); + + if (!result) { + return result; + } + + if (roomType === 'c' && room.abacAttributes?.length) { + await Rooms.unsetAbacAttributesById(rid); + } + + if (result[1]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + + if (sendMessage) { + let message; + if (roomType === 'c') { + message = i18n.t('public', { + lng: user?.language || settings.get('Language') || 'en', + }); + } else { + message = i18n.t('private', { + lng: user?.language || settings.get('Language') || 'en', + }); + } + await Message.saveSystemMessage('room_changed_privacy', rid, message, user); + } + + return result; +}; diff --git a/apps/meteor/server/lib/rooms/syncRolePrioritiesForRoomIfRequired.ts b/apps/meteor/server/lib/rooms/syncRolePrioritiesForRoomIfRequired.ts new file mode 100644 index 0000000..67748b9 --- /dev/null +++ b/apps/meteor/server/lib/rooms/syncRolePrioritiesForRoomIfRequired.ts @@ -0,0 +1,70 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; + +import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles'; + +const READ_BATCH_SIZE = 1000; + +const SYNC_VERSION = 2; + +async function assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap: Map) { + const bulk = Users.col.initializeUnorderedBulkOp(); + + userIdAndRoomRolePrioritiesMap.forEach((roomRolePriorities, userId) => { + userIdAndRoomRolePrioritiesMap.delete(userId); + + if (roomRolePriorities) { + const updateFields = Object.entries(roomRolePriorities).reduce( + (operations, rolePriorityData) => { + const [rid, rolePriority] = rolePriorityData; + operations[`roomRolePriorities.${rid}`] = rolePriority; + return operations; + }, + {} as Record, + ); + + bulk.find({ _id: userId }).updateOne({ + $set: updateFields, + }); + } + }); + + if (bulk.length > 0) { + await bulk.execute(); + } +} + +export const syncRolePrioritiesForRoomIfRequired = async (rid: IRoom['_id']) => { + const userIdAndRoomRolePrioritiesMap = new Map(); + + if (await Rooms.hasCreatedRolePrioritiesForRoom(rid, SYNC_VERSION)) { + return; + } + + const cursor = Subscriptions.find( + { rid, roles: { $exists: true } }, + { + projection: { 'rid': 1, 'roles': 1, 'u._id': 1 }, + sort: { _id: 1 }, + }, + ).batchSize(READ_BATCH_SIZE); + + for await (const sub of cursor) { + if (!sub.roles?.length) { + continue; + } + + const userId = sub.u._id; + const roomId = sub.rid; + const priority = calculateRoomRolePriorityFromRoles(sub.roles); + + const existingPriorities = userIdAndRoomRolePrioritiesMap.get(userId) || {}; + existingPriorities[roomId] = priority; + userIdAndRoomRolePrioritiesMap.set(userId, existingPriorities); + } + + // Flush any remaining priorities in the map + await assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap); + + await Rooms.markRolePrioritesCreatedForRoom(rid, SYNC_VERSION); +}; diff --git a/apps/meteor/server/lib/rooms/unarchiveRoom.ts b/apps/meteor/server/lib/rooms/unarchiveRoom.ts new file mode 100644 index 0000000..233ad38 --- /dev/null +++ b/apps/meteor/server/lib/rooms/unarchiveRoom.ts @@ -0,0 +1,44 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId } from '../notifyListener'; + +const BATCH_SIZE = 100_000; + +async function getActiveUserIds(userIds: string[]): Promise> { + const activeUserIds = new Set(); + + for (let i = 0; i < userIds.length; i += BATCH_SIZE) { + const batch = await Users.findActiveByIds(userIds.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray(); + for (const u of batch) { + activeUserIds.add(u._id); + } + } + + return activeUserIds; +} + +async function unarchiveSubscriptionsByIds(ids: string[]): Promise { + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + await Subscriptions.unarchiveByIds(ids.slice(i, i + BATCH_SIZE)); + } +} + +export const unarchiveRoom = async function (rid: string, user: IMessage['u']): Promise { + await Rooms.unarchiveById(rid); + + const archivedSubs = await Subscriptions.findArchivedByRoomId(rid, { projection: { 'u._id': 1 } }).toArray(); + + if (archivedSubs.length > 0) { + const activeUserIds = await getActiveUserIds(archivedSubs.map((s) => s.u._id)); + const idsToUnarchive = archivedSubs.filter((s) => activeUserIds.has(s.u._id)).map((s) => s._id); + + await unarchiveSubscriptionsByIds(idsToUnarchive); + void notifyOnSubscriptionChangedByRoomId(rid); + } + + await Message.saveSystemMessage('room-unarchived', rid, '', user); + + void notifyOnRoomChangedById(rid); +}; diff --git a/apps/meteor/server/lib/rooms/updateGroupDMsName.ts b/apps/meteor/server/lib/rooms/updateGroupDMsName.ts new file mode 100644 index 0000000..da3c8c5 --- /dev/null +++ b/apps/meteor/server/lib/rooms/updateGroupDMsName.ts @@ -0,0 +1,84 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isNotUndefined } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +import { notifyOnSubscriptionChangedByRoomId } from '../notifyListener'; + +type GroupDMMember = Pick; + +const getFname = (members: GroupDMMember[]): string => members.map(({ name, username }) => name || username).join(', '); +const getName = (members: GroupDMMember[]): string => members.map(({ username }) => username).join(','); + +async function getUsersWhoAreInTheSameGroupDMsAs(user: GroupDMMember) { + // add all users to single array so we can fetch details from them all at once + if ((await Rooms.countGroupDMsByUids([user._id])) === 0) { + return; + } + + const userIds = new Set(); + const users = new Map(); + + const rooms = Rooms.findGroupDMsByUids([user._id], { projection: { uids: 1 } }); + await rooms.forEach((room) => { + if (!room.uids) { + return; + } + + room.uids.forEach((uid) => uid !== user._id && userIds.add(uid)); + }); + + (await Users.findByIds([...userIds], { projection: { username: 1, name: 1 } }).toArray()).forEach((user) => users.set(user._id, user)); + + return users; +} + +function sortUsersAlphabetically(u1: GroupDMMember, u2: GroupDMMember): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!); +} + +export const updateGroupDMsName = async ( + userThatChangedName: GroupDMMember, + options?: { + session?: ClientSession; + }, +): Promise => { + if (!userThatChangedName.username) { + return; + } + + const users = await getUsersWhoAreInTheSameGroupDMsAs(userThatChangedName); + if (!users) { + return; + } + + users.set(userThatChangedName._id, userThatChangedName); + + const { session } = options || {}; + + const rooms = Rooms.findGroupDMsByUids([userThatChangedName._id], { projection: { uids: 1 }, session }); + + const getMembers = (uids: string[]) => uids.map((uid) => users.get(uid)).filter(isNotUndefined); + + // loop rooms to update the subscriptions from them all + for await (const room of rooms) { + if (!room.uids) { + return; + } + + const members = getMembers(room.uids); + const sortedMembers = members.sort(sortUsersAlphabetically); + + const subs = Subscriptions.findByRoomId(room._id, { projection: { '_id': 1, 'u._id': 1 }, session }); + for await (const sub of subs) { + const otherMembers = sortedMembers.filter(({ _id }) => _id !== sub.u._id); + const updateNameRespose = await Subscriptions.updateNameAndFnameById(sub._id, getName(otherMembers), getFname(otherMembers), { + session, + }); + if (updateNameRespose.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(room._id); + } + } + } +}; diff --git a/apps/meteor/server/lib/saml/CHANGELOG.md b/apps/meteor/server/lib/saml/CHANGELOG.md new file mode 100644 index 0000000..c9c2caa --- /dev/null +++ b/apps/meteor/server/lib/saml/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +This is a changelog of changes made to the saml package for RocketChat. +The package is originally based on https://github.com/steffow/meteor-accounts-saml . + +## 9-Nov-2016 danb@catalyst-au.net + +* Only do `logoutWithSaml` if we are doing single logout (ie + `idpSLORedirectURL` is set). We get an error if we do + `logoutWithSaml` and `idpSLORedirectURL` is not set. This was + discovered when testing with simplesamlphp. In this latter case, + `logoutWithSaml` will try to use the standard saml endpoint to + request the logout resulting in an error. +* **TODO:** doing the standard `Meteor.logout` doesn't seem to be + enough when logging out. If the user logs back into RocketChat from + the same window, they will be automatically logged back in to + RocketChat, no credentials required. If you enable single logout + (ie set `idpSLORedirectURL`), then `logoutWithSaml` will be called + and the client should be properly logged out. +* Switch to using `Cookies` for the client when recording the saml + provider and whether single logout is enabled. If the client has + multiple rocketchat tabs or windows, this will ensure they all + log out properly with the idp - but only when single logout + (`idpSLORedirectURL`) is enabled. + +## 31-Oct-2016 danb@catalyst-au.net + +* Attempt at SP-initiated logout (**requires more work**). + * Added configuration setting for SLO (single logout) redirect url + to the IDP. + * A `Session` key is set called `saml_provider`; it is unset at + logout. Is there a better way? + * `Meteor.logout` on the client is overridden so that + `Meteor.logoutWithSaml` is called if `saml_provider` is set. This + isn't great - is there a better way? + * Tested using simplesamlphp as the idp + * login in using saml + * then logout using the normal RocketChat logout + * after some time, you should see the RocketChat login screen + * if you try to login again, you will have to login to the idp again + repeating the previous login flow + * **TODO:** If a user has 2 independent browser login sessions using + saml, both will log out, but only the one the user logged out from + appears to be properly logged out of the idp. + * this might be because we shouldn't be logging out the second session + but RocketChat / meteor is doing this. + +## 17-Oct-2016 + +* Fixed signing for redirect to idp. + * At login time, if the idp is configured to verify the redirect to its login screen, it will + expect `RelayState` as part of the signature. + Tested with **simplesamlphp** with `'redirect.validate' => TRUE` in the `sp-remote` metadata file + * Added `privateKey` - `SAML_Custom_Private_Key` + * Added `privateCert` - `SAML_Custom_Public_Cert` + * These input fields should point to locations on the the server eg `/path/to/private/certs/`. + * `privateKey` is used by RocketChat to sign saml requests. diff --git a/apps/meteor/server/lib/saml/README.md b/apps/meteor/server/lib/saml/README.md new file mode 100644 index 0000000..abd441d --- /dev/null +++ b/apps/meteor/server/lib/saml/README.md @@ -0,0 +1,127 @@ +Meteor-accounts-saml +================== + +SAML v2 login support for existing password based accounts + +----- + +## Demo + +For OpenIDP, see the example app `example-openidp` and http://accounts-saml-example.meteor.com/ for a demo. + +For OpenAM, see the example app `openam-example`. + +## Important Notes + +* **This package is working but may have issues with various saml providers** - it has only been tested and verified with [OpenIDP](https://openidp.feide.no/) and [OpenAM](https://www.forgerock.org/openam). +* Most SAML IDPs don't allow SPs with a _localhost (127.0.0.1)_ address. Unless you run your own IDP (eg via your own OpenAM instance) you might exprience issues. +* The accounts-ui loggin buttons will not include saml providers, this may be implemented as a future enhancement, see below for how to build a custom login button. + +## Usage + +Put SAML settings in eg `server/lib/settings.js` like so: + +``` +settings = {"saml":[{ + "provider":"openam", + "entryPoint":"https://openam.idp.io/openam/SSORedirect/metaAlias/zimt/idp", + "issuer": "https://sp.zimt.io/", //replace with url of your app + "cert":"MIICizCCAfQCCQCY8tKaMc0 LOTS OF FUNNY CHARS ==", + "idpSLORedirectURL": "http://openam.idp.io/openam/IDPSloRedirect/metaAlias/zimt/idp", + "privateKeyFile": "certs/mykey.pem", // path is relative to $METEOR-PROJECT/private + "publicCertFile": "certs/mycert.pem", // eg $METEOR-PROJECT/private/certs/mycert.pem + "dynamicProfile": true // set to true if we want to create a user in Meteor.users dynamically if SAML assertion is valid + "identifierFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", // Defaults to urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + "localProfileMatchAttribute": "telephoneNumber" // CAUTION: this will be mapped to profile. attribute in Mongo if identifierFormat (see above) differs from urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress, + "attributesSAML": [telephoneNumber, sn, givenName, mail], // attrs from SAML attr statement, which will be used for local Meteor profile creation. Currently no real attribute mapping. If required use mapping on IdP side. + + }]} + +Meteor.settings = settings; +``` + +in some template + +``` + +``` + +in helper function + +``` + 'click .saml-login' (event) { + event.preventDefault(); + var provider = event.target.getAttribute('data-provider'); + Meteor.loginWithSaml({ + provider + }, function(error, result) { + //handle errors and result + }); + } +``` + +and if SingleLogout is needed + +``` +'click .saml-login': function(event, template){ + event.preventDefault(); + var provider = $(event.target).data('provider'); + Meteor.logoutWithSaml({ + provider:provider + }, function(error, result){ + //handle errors and result + }); + } +``` + +## Setup SAML SP (Consumer) + +1. Create a Meteor project by `meteor create sp` and cd into it. +2. Add `steffo:meteor-accounts-saml` +3. Create `server/lib/settings.js` as described above. Since Meteor loads things in `server/lib` first, this ensures that your settings are respected even on Galaxy where you cannot use `meteor --settings`. +4. Put your private key and your cert (not the IDP's one) into the "private" directory. Eg if your meteor project is at `/Users/steffo/sp` then place them in `/Users/steffo/sp/private` +5. Check if you can receive SP metadata eg via `curl http://localhost:3000/_saml/metadata/openam`. Output should look like: + +``` + + + + + + + MKA.... lot of funny chars ... gkqhkiG9w0BAQUFADATMREwDwYDVQQDEwgxMC4w + + + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + ``` + +##OpenAM Setup + +1. I prefer using OpenAM realms. Set up a realm using a name that matches the one in the entry point URL of the `settings.json` file: `https://openam.idp.io/openam/SSORedirect/metaAlias//idp`; we used `zimt` above. +2. Save the SP metadata (obtained in Step 5 above) in a file `sp-metadata.xml`. +3. Logon OpenSSO console as `amadmin` and select _Common Tasks > Register Remote Service Provider_ +4. Select the corresponding real and upload the metadata (alternatively, point OpenAM to the SP's metadata URL eg `http://sp.meteor.com/_saml/metadata/openam`). If all goes well the new SP shows up under _Federation > Entity Providers_ + + + +## OpenIDP setup +- EntryID = http://accounts-saml-example.meteor.com +- Name of Service = meteor-accounts-saml-example +- AssertionConsumerService endpoint = http://accounts-saml-example.meteor.com/_saml/validate/openidp/ + +## Roadmap +* Introduction of IDP types (eg openam, auth0 etc) to support implementaion specific workarounds. +* Support for different SAML Bindings +* Check for better/alternative SAML profile. I have the impression that the SAML Web Browser SSO profile somewhat conflicts with Meteor's DDP/websocket approach. Eg when the browser issues an HTTP request, Meteor apps don't necessarily know from which user/session this request comes from (ja, we could use cookies but that's not the the Meteor-way). + +## Credits +Based Nat Strauser's Meteor/SAML package _natestrauser:meteor-accounts-saml_ which is +heavily derived from https://github.com/bergie/passport-saml. diff --git a/apps/meteor/server/lib/saml/definition/IAttributeMapping.ts b/apps/meteor/server/lib/saml/definition/IAttributeMapping.ts new file mode 100644 index 0000000..573ca7a --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/IAttributeMapping.ts @@ -0,0 +1,16 @@ +export interface IAttributeMapping { + fieldName: string | Array; + regex?: string; + template?: string; +} + +export interface IUserDataMap { + attributeList: Set; + identifier: { + type: string; + attribute?: string; + }; + email: IAttributeMapping; + username: IAttributeMapping; + name: IAttributeMapping; +} diff --git a/apps/meteor/server/lib/saml/definition/IAuthorizeRequestVariables.ts b/apps/meteor/server/lib/saml/definition/IAuthorizeRequestVariables.ts new file mode 100644 index 0000000..d751a3b --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/IAuthorizeRequestVariables.ts @@ -0,0 +1,10 @@ +export interface IAuthorizeRequestVariables extends Record { + newId: string; + instant: string; + callbackUrl: string; + entryPoint: string; + issuer: string; + identifierFormat: string; + authnContextComparison: string; + authnContext: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ILogoutRequestVariables.ts b/apps/meteor/server/lib/saml/definition/ILogoutRequestVariables.ts new file mode 100644 index 0000000..f85f3d7 --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ILogoutRequestVariables.ts @@ -0,0 +1,9 @@ +export interface ILogoutRequestVariables extends Record { + newId: string; + instant: string; + idpSLORedirectURL: string; + issuer: string; + identifierFormat: string; + nameID: string; + sessionIndex: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ILogoutResponse.ts b/apps/meteor/server/lib/saml/definition/ILogoutResponse.ts new file mode 100644 index 0000000..84de67a --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ILogoutResponse.ts @@ -0,0 +1,5 @@ +export interface ILogoutResponse { + id: string; + response: string; + inResponseToId: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ILogoutResponseVariables.ts b/apps/meteor/server/lib/saml/definition/ILogoutResponseVariables.ts new file mode 100644 index 0000000..fa8a0a3 --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ILogoutResponseVariables.ts @@ -0,0 +1,10 @@ +export interface ILogoutResponseVariables extends Record { + newId: string; + instant: string; + idpSLORedirectURL: string; + issuer: string; + identifierFormat: string; + nameID: string; + sessionIndex: string; + inResponseToId: string; +} diff --git a/apps/meteor/server/lib/saml/definition/IMetadataVariables.ts b/apps/meteor/server/lib/saml/definition/IMetadataVariables.ts new file mode 100644 index 0000000..b26b2b0 --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/IMetadataVariables.ts @@ -0,0 +1,7 @@ +export interface IMetadataVariables extends Record { + issuer: string; + certificate: string; + identifierFormat: string; + callbackUrl: string; + sloLocation: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ISAMLAction.ts b/apps/meteor/server/lib/saml/definition/ISAMLAction.ts new file mode 100644 index 0000000..1c8f52e --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ISAMLAction.ts @@ -0,0 +1,5 @@ +export interface ISAMLAction { + actionName: string; + serviceName: string; + credentialToken: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ISAMLAssertion.ts b/apps/meteor/server/lib/saml/definition/ISAMLAssertion.ts new file mode 100644 index 0000000..7480d34 --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ISAMLAssertion.ts @@ -0,0 +1,4 @@ +export interface ISAMLAssertion { + assertion: Element; + xml: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ISAMLGlobalSettings.ts b/apps/meteor/server/lib/saml/definition/ISAMLGlobalSettings.ts new file mode 100644 index 0000000..a7fe88d --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ISAMLGlobalSettings.ts @@ -0,0 +1,11 @@ +export interface ISAMLGlobalSettings { + generateUsername: boolean; + nameOverwrite: boolean; + mailOverwrite: boolean; + immutableProperty: string; + defaultUserRole: string; + userDataFieldMap: string; + usernameNormalize: string; + channelsAttributeUpdate: boolean; + includePrivateChannelsInUpdate: boolean; +} diff --git a/apps/meteor/server/lib/saml/definition/ISAMLRequest.ts b/apps/meteor/server/lib/saml/definition/ISAMLRequest.ts new file mode 100644 index 0000000..05eb26e --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ISAMLRequest.ts @@ -0,0 +1,4 @@ +export interface ISAMLRequest { + id: string; + request: string; +} diff --git a/apps/meteor/server/lib/saml/definition/ISAMLUser.ts b/apps/meteor/server/lib/saml/definition/ISAMLUser.ts new file mode 100644 index 0000000..0dccac7 --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/ISAMLUser.ts @@ -0,0 +1,22 @@ +export interface ISAMLUser { + emailList: Array; + fullName: string | null; + roles?: Array; + eppn: string | null; + + username?: string; + language?: string; + channels?: Array; + samlLogin: { + provider: string | null; + idp: string; + idpSession: string; + nameID: string; + }; + + attributeList: Map; + identifier: { + type: string; + attribute?: string; + }; +} diff --git a/apps/meteor/server/lib/saml/definition/IServiceProviderOptions.ts b/apps/meteor/server/lib/saml/definition/IServiceProviderOptions.ts new file mode 100644 index 0000000..673486d --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/IServiceProviderOptions.ts @@ -0,0 +1,28 @@ +import type { SAMLSignatureAlgorithm } from '@rocket.chat/core-typings'; + +export interface IServiceProviderOptions { + provider: string; + entryPoint: string; + idpSLORedirectURL: string; + issuer: string; + cert: string; + privateCert: string; + privateKey: string; + signatureAlgorithm: SAMLSignatureAlgorithm; + customAuthnContext: string; + authnContextComparison: string; + defaultUserRole: string; + allowedClockDrift: number; + signatureValidationType: string; + validateLogoutRequestSignature: boolean; + validateLogoutResponseSignature: boolean; + identifierFormat: string; + nameIDPolicyTemplate: string; + authnContextTemplate: string; + authRequestTemplate: string; + logoutResponseTemplate: string; + logoutRequestTemplate: string; + metadataCertificateTemplate: string; + metadataTemplate: string; + callbackUrl: string; +} diff --git a/apps/meteor/server/lib/saml/definition/SAMLEnvelope.ts b/apps/meteor/server/lib/saml/definition/SAMLEnvelope.ts new file mode 100644 index 0000000..35378aa --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/SAMLEnvelope.ts @@ -0,0 +1,43 @@ +// Other binding values are not supported: HTTP-Artifact, URI, SOAP, PAOS +export type SAMLBinding = 'HTTP-Redirect' | 'HTTP-POST'; + +export type SAMLDocumentType = 'SAMLRequest' | 'SAMLResponse'; + +export type SAMLBaseEnvelope = { + binding: SAMLBinding; + /** + * encodedDocument is the document in the exact format we received it + * */ + encodedDocument: string; + /** + * decodedDocument is the raw XML file; processed according to the binding specification + * */ + decodedDocument: string; + type: T; + relayState?: string; +}; + +/** + * HTTP-Redirect envelope + * For this binding, the document must be a Deflated XML file that is then base64 encoded + **/ +export type SAMLRedirectEnvelope = SAMLBaseEnvelope & { + binding: 'HTTP-Redirect'; + sigAlg?: string; + signature?: string; + + /** + * signedContent is the complete string value that must be matched by the signature + * */ + signedContent?: string; +}; + +/** + * HTTP-POST envelope + * For this binding, the document must be a regular XML file that is base64 encoded + **/ +export type SAMLPOSTEnvelope = SAMLBaseEnvelope & { + binding: 'HTTP-POST'; +}; + +export type SAMLEnvelope = SAMLRedirectEnvelope | SAMLPOSTEnvelope; diff --git a/apps/meteor/server/lib/saml/definition/callbacks.ts b/apps/meteor/server/lib/saml/definition/callbacks.ts new file mode 100644 index 0000000..8e6d84e --- /dev/null +++ b/apps/meteor/server/lib/saml/definition/callbacks.ts @@ -0,0 +1,11 @@ +export interface ILogoutRequestValidateCallback { + (err: string | object | null, data?: Record | null): Promise; +} + +export interface ILogoutResponseValidateCallback { + (err: string | object | null, inResponseTo?: string | null): Promise; +} + +export interface IResponseValidateCallback { + (err: string | object | null, profile?: Record | null, loggedOut?: boolean): void; +} diff --git a/apps/meteor/server/lib/saml/lib/SAML.ts b/apps/meteor/server/lib/saml/lib/SAML.ts new file mode 100644 index 0000000..406e0a4 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/SAML.ts @@ -0,0 +1,615 @@ +import type { ServerResponse } from 'node:http'; + +import type { IUser, IIncomingMessage, IPersonalAccessToken, IRole } from '@rocket.chat/core-typings'; +import { CredentialTokens, Rooms, Users, Roles, SamlUsedAssertions } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { escapeRegExp, escapeHTML } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { SAMLServiceProvider } from './ServiceProvider'; +import { SAMLUtils } from './Utils'; +import { getSAMLEnvelope } from './getSAMLEnvelope'; +import { i18n } from '../../../../app/utils/lib/i18n'; +import { ensureArray } from '../../../../lib/utils/arrayUtils'; +import { settings } from '../../../settings'; +import { SystemLogger } from '../../logger/system'; +import { addUserToRoom } from '../../rooms/addUserToRoom'; +import { createRoom } from '../../rooms/createRoom'; +import { generateUsernameSuggestion } from '../../users/getUsernameSuggestion'; +import { saveUserIdentity } from '../../users/saveUserIdentity'; +import type { ISAMLAction } from '../definition/ISAMLAction'; +import type { ISAMLUser } from '../definition/ISAMLUser'; +import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; + +const showErrorMessage = function (res: ServerResponse, err: string): void { + res.writeHead(200, { + 'Content-Type': 'text/html', + }); + const content = `

Sorry, an annoying error occured

${escapeHTML(err)}
`; + res.end(content, 'utf-8'); +}; + +const convertRoleNamesToIds = async (roleNamesOrIds: string[]): Promise => { + const normalizedRoleNamesOrIds = roleNamesOrIds.map((role) => role.trim()).filter((role) => role.length > 0); + if (!normalizedRoleNamesOrIds.length) { + throw new Error(`No valid role names or ids provided for conversion: ${roleNamesOrIds.join(', ')}`); + } + + const roles = (await Roles.findInIdsOrNames(normalizedRoleNamesOrIds).toArray()).map((role) => role._id); + + if (roles.length !== normalizedRoleNamesOrIds.length) { + SystemLogger.warn({ + msg: 'Failed to convert some role names to ids', + roles: normalizedRoleNamesOrIds, + }); + } + + if (!roles.length) { + throw new Error(`We should have at least one existing role to create the user: ${normalizedRoleNamesOrIds.join(', ')}`); + } + + return roles; +}; + +export class SAML { + public static async processRequest( + req: IIncomingMessage, + res: ServerResponse, + service: IServiceProviderOptions, + samlObject: ISAMLAction, + ): Promise { + // Skip everything if there's no service set by the saml middleware + if (!service) { + if (samlObject.actionName === 'metadata') { + showErrorMessage(res, `Unexpected SAML service ${samlObject.serviceName}`); + return; + } + + throw new Error(`Unexpected SAML service ${samlObject.serviceName}`); + } + + switch (samlObject.actionName) { + case 'metadata': + return this.processMetadataAction(res, service); + case 'logout': + return this.processLogoutAction(req, res, service); + case 'sloRedirect': + return this.processSLORedirectAction(req, res, service); + case 'authorize': + return this.processAuthorizeAction(req, res, service, samlObject); + case 'validate': + return this.processValidateAction(req, res, service, samlObject); + default: + throw new Error(`Unexpected SAML action ${samlObject.actionName}`); + } + } + + public static async hasCredential(credentialToken: string): Promise { + return (await CredentialTokens.findOneNotExpiredById(credentialToken)) != null; + } + + public static async retrieveCredential(credentialToken: string): Promise | undefined> { + // The credentialToken in all these functions corresponds to SAMLs inResponseTo field and is mandatory to check. + const data = await CredentialTokens.findOneNotExpiredById(credentialToken); + if (data) { + return data.userInfo; + } + } + + public static async storeCredential(credentialToken: string, loginResult: { profile: Record }): Promise { + await CredentialTokens.create(credentialToken, loginResult); + } + + public static async insertOrUpdateSAMLUser(userObject: ISAMLUser): Promise<{ userId: string; token: string }> { + const { generateUsername, immutableProperty, nameOverwrite, mailOverwrite, channelsAttributeUpdate, defaultUserRole } = + SAMLUtils.globalSettings; + + let customIdentifierMatch = false; + let customIdentifierAttributeName: string | null = null; + let user = null; + + // First, try searching by custom identifier + if ( + userObject.identifier.type === 'custom' && + userObject.identifier.attribute && + userObject.attributeList.has(userObject.identifier.attribute) + ) { + customIdentifierAttributeName = userObject.identifier.attribute; + + const query: Record = {}; + query[`services.saml.${customIdentifierAttributeName}`] = userObject.attributeList.get(customIdentifierAttributeName); + user = await Users.findOne(query); + + if (user) { + customIdentifierMatch = true; + } + } + + // Second, try searching by username or email (according to the immutableProperty setting) + if (!user) { + const expression = userObject.emailList.map((email) => `^${escapeRegExp(email)}$`).join('|'); + const emailRegex = new RegExp(expression, 'i'); + + user = await SAML.findUser(userObject.username, emailRegex); + } + + const emails = userObject.emailList.map((email) => ({ + address: email, + verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), + })); + + let { username } = userObject; + const { fullName } = userObject; + + const active = !settings.get('Accounts_ManuallyApproveNewUsers'); + + if (!user) { + let roleNamesOrIds: string[] = []; + if (userObject.roles && userObject.roles.length > 0) { + roleNamesOrIds = userObject.roles; + } else if (defaultUserRole) { + roleNamesOrIds = ensureArray(defaultUserRole.split(',')); + } + + const roles = roleNamesOrIds.length > 0 ? await convertRoleNamesToIds(roleNamesOrIds) : []; + + const newUser: Record = { + name: fullName, + active, + globalRoles: roles, + emails, + services: { + saml: { + provider: userObject.samlLogin.provider, + idp: userObject.samlLogin.idp, + }, + }, + }; + + if (customIdentifierAttributeName) { + newUser.services.saml[customIdentifierAttributeName] = userObject.attributeList.get(customIdentifierAttributeName); + } + + if (generateUsername === true) { + username = await generateUsernameSuggestion(newUser); + } + + if (username) { + newUser.username = username; + newUser.name = newUser.name || SAML.guessNameFromUsername(username); + } + + if (userObject.language) { + if (i18n.languages?.includes(userObject.language)) { + newUser.language = userObject.language; + } + } + + // only set skipAuthServiceDefaultRoles if SAML is providing its own roles + // otherwise, leave it as false to fallback to generic auth service default roles + // from Accounts_Registration_AuthenticationServices_Default_Roles + const skipAuthServiceDefaultRoles = roleNamesOrIds.length > 0; + const userId = await Accounts.insertUserDoc({ skipAuthServiceDefaultRoles, skipNewUserRolesSetting: true }, newUser); + user = await Users.findOneById(userId); + + if (user && userObject.channels && channelsAttributeUpdate !== true) { + await SAML.subscribeToSAMLChannels(userObject.channels, user); + } + } + + if (!user) { + throw new Error('Failed to create user'); + } + // creating the token and adding to the user + const stampedToken = Accounts._generateStampedLoginToken(); + await Users.addPersonalAccessTokenToUser({ + userId: user._id, + loginTokenObject: stampedToken as unknown as IPersonalAccessToken, + }); + + const updateData: Record = { + 'services.saml.provider': userObject.samlLogin.provider, + 'services.saml.idp': userObject.samlLogin.idp, + 'services.saml.idpSession': userObject.samlLogin.idpSession, + 'services.saml.nameID': userObject.samlLogin.nameID, + }; + + // If the user was not found through the customIdentifier property, then update it's value + if (customIdentifierMatch === false && customIdentifierAttributeName) { + updateData[`services.saml.${customIdentifierAttributeName}`] = userObject.attributeList.get(customIdentifierAttributeName); + } + + // Overwrite mail if needed + if (mailOverwrite === true && (customIdentifierMatch === true || immutableProperty !== 'EMail')) { + updateData.emails = emails; + } + + // When updating an user, we only update the roles if we received them from the mapping + if (userObject.roles?.length) { + const roles = await convertRoleNamesToIds(userObject.roles); + updateData.roles = roles; + } + + if (userObject.channels && channelsAttributeUpdate === true) { + await SAML.subscribeToSAMLChannels(userObject.channels, user); + } + + await Users.updateOne( + { + _id: user._id, + }, + { + $set: updateData, + }, + ); + + if ((username && username !== user.username) || (nameOverwrite && fullName && fullName !== user.name)) { + await saveUserIdentity({ _id: user._id, name: nameOverwrite ? fullName || undefined : user.name, username }); + } + // sending token along with the userId + return { + userId: user._id, + token: stampedToken.token, + }; + } + + private static processMetadataAction(res: ServerResponse, service: IServiceProviderOptions): void { + try { + const serviceProvider = new SAMLServiceProvider(service); + + res.writeHead(200); + res.write(serviceProvider.generateServiceProviderMetadata()); + res.end(); + } catch (err: any) { + showErrorMessage(res, err); + } + } + + private static async processLogoutAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise { + // This is where we receive SAML LogoutResponse + if (req.query.SAMLRequest) { + return this.processLogoutRequest(req, res, service); + } + + return this.processLogoutResponse(req, res, service); + } + + private static async _logoutRemoveTokens(userId: string): Promise { + SAMLUtils.log({ msg: 'Found user', userId }); + + await Users.unsetLoginTokens(userId); + await Users.removeSamlServiceSession(userId); + } + + private static async processLogoutRequest(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise { + const errorHandler = (err: unknown) => { + SystemLogger.error({ err }); + throw new Meteor.Error('Unable to Validate Logout Request'); + }; + + const envelope = await getSAMLEnvelope(req, 'SAMLRequest', 'HTTP-Redirect').catch(errorHandler); + if (!envelope) { + errorHandler('No envelope found for SAML Logout Request'); + return; + } + + const serviceProvider = new SAMLServiceProvider(service); + await serviceProvider.validateLogoutRequest(envelope, async (err, result) => { + if (err) { + errorHandler(err); + } + + if (!result?.nameID || !result?.idpSession) { + throw new Meteor.Error('Unable to process Logout Request: missing request data.'); + } + + let timeoutHandler: NodeJS.Timeout | undefined = undefined; + const redirect = (url?: string): void => { + if (!timeoutHandler) { + // If the handler is null, then we already ended the response; + return; + } + + clearTimeout(timeoutHandler); + timeoutHandler = undefined; + + res.writeHead(302, { + Location: url || Meteor.absoluteUrl(), + }); + res.end(); + }; + + // Add a timeout to end the server response + timeoutHandler = setTimeout(() => { + // If we couldn't get a valid IdP url, let's redirect the user to our home so the browser doesn't hang on them. + redirect(); + }, 5000); + + try { + // limit 2: only need to know whether the match is missing, unique, or ambiguous + const loggedOutUsers = await Users.findBySAMLNameIdOrIdpSession(result.nameID, result.idpSession, { + projection: { _id: 1 }, + limit: 2, + }).toArray(); + if (loggedOutUsers.length > 1) { + throw new Meteor.Error('Found multiple users matching SAML session'); + } + + if (loggedOutUsers.length === 0) { + throw new Meteor.Error('Invalid logout request: no user associated with session.'); + } + + await this._logoutRemoveTokens(loggedOutUsers[0]._id); + + const { response } = serviceProvider.generateLogoutResponse({ + nameID: result.nameID || '', + sessionIndex: result.idpSession || '', + inResponseToId: result.id || '', + }); + + serviceProvider.logoutResponseToUrl(response, envelope.relayState, (err, url) => { + if (err) { + SystemLogger.error({ err }); + return redirect(); + } + + redirect(url); + }); + } catch (err: any) { + SystemLogger.error({ err }); + redirect(); + } + }); + } + + private static async processLogoutResponse(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): Promise { + const envelope = await getSAMLEnvelope(req, 'SAMLResponse', 'HTTP-Redirect'); + if (!envelope) { + SAMLUtils.error({ msg: 'Invalid LogoutResponse received: missing SAMLResponse parameter.', query: req.query }); + throw new Error('Invalid LogoutResponse received.'); + } + + const serviceProvider = new SAMLServiceProvider(service); + await serviceProvider.validateLogoutResponse(envelope, async (err, inResponseTo) => { + if (err) { + throw new Error('Logout Response Validation failed'); + } + + if (!inResponseTo) { + throw new Meteor.Error('Invalid logout request: no inResponseTo value.'); + } + + const logOutUser = async (inResponseTo: string): Promise => { + SAMLUtils.log({ msg: 'Processing logout for inResponseTo', inResponseTo }); + + // limit 2: only need to know whether the match is missing, unique, or ambiguous + const loggedOutUsers = await Users.findBySAMLInResponseTo(inResponseTo, { projection: { _id: 1 }, limit: 2 }).toArray(); + if (loggedOutUsers.length > 1) { + throw new Meteor.Error('Found multiple users matching SAML inResponseTo fields'); + } + + if (loggedOutUsers.length === 0) { + throw new Meteor.Error('Invalid logout request: no user associated with inResponseTo.'); + } + + await this._logoutRemoveTokens(loggedOutUsers[0]._id); + }; + + try { + await logOutUser(inResponseTo); + } finally { + res.writeHead(302, { + Location: Meteor.absoluteUrl(), + }); + res.end(); + } + }); + } + + private static processSLORedirectAction(req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions): void { + const { idpSLORedirectURL } = service; + const userRedirect = req.query.redirect as string; + + if (!idpSLORedirectURL) { + res.writeHead(500); + res.end('SLO redirect not configured'); + return; + } + + if (!userRedirect || typeof userRedirect !== 'string') { + res.writeHead(400); + res.end('Missing redirect parameter'); + return; + } + + let configuredURL: URL; + let requestURL: URL; + + try { + configuredURL = new URL(idpSLORedirectURL); + requestURL = new URL(userRedirect); + } catch { + res.writeHead(400); + res.end('Invalid URL format'); + return; + } + + if (configuredURL.origin !== requestURL.origin) { + res.writeHead(403); + res.end('Unauthorized redirect origin'); + return; + } + + const normalizePath = (p: string): string => p.replace(/\/+$/, '') || '/'; + if (normalizePath(configuredURL.pathname) !== normalizePath(requestURL.pathname)) { + res.writeHead(403); + res.end('Unauthorized redirect path'); + return; + } + + res.writeHead(302, { + Location: requestURL.toString(), + }); + + res.end(); + } + + private static async processAuthorizeAction( + req: IIncomingMessage, + res: ServerResponse, + service: IServiceProviderOptions, + samlObject: ISAMLAction, + ): Promise { + const serviceProvider = new SAMLServiceProvider(service); + let url: string | undefined; + const requestedLoginClient = settings.get('Accounts_OAuth_Use_Modern_Flow') ? req.query.loginClient : undefined; + const loginClient = SAMLUtils.isSupportedLoginClient(requestedLoginClient) ? requestedLoginClient : undefined; + + try { + url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken, loginClient); + } catch (err: any) { + SAMLUtils.error({ err, msg: 'Unable to generate authorize url' }); + url = Meteor.absoluteUrl(); + } + + res.writeHead(302, { + Location: url, + }); + res.end(); + } + + private static async processValidateAction( + req: IIncomingMessage, + res: ServerResponse, + service: IServiceProviderOptions, + _samlObject: ISAMLAction, + ): Promise { + const redirect = (url?: string) => { + res.writeHead(302, { + Location: url ?? Meteor.absoluteUrl(), + }); + res.end(); + }; + + const envelope = await getSAMLEnvelope(req, 'SAMLResponse', 'HTTP-POST').catch((err) => SAMLUtils.error(err)); + + if (!envelope) { + SAMLUtils.error({ msg: 'No envelope found for SAML Response' }); + return redirect(); + } + + const serviceProvider = new SAMLServiceProvider(service); + const { provider, loginClient } = SAMLUtils.decodeAuthorizeRelayState(envelope.relayState); + // Keep exposing the provider as the relay state for downstream profile mapping. + SAMLUtils.relayState = provider ?? null; + serviceProvider.validateResponse(envelope, async (err, profile /* , loggedOut*/) => { + try { + if (err) { + SAMLUtils.error(err); + throw new Error('Unable to validate response url'); + } + + if (!profile) { + throw new Error('No user data collected from IdP response.'); + } + + const baseExpireAt = profile.expireAt instanceof Date ? profile.expireAt : new Date(Date.now() + 300000); + + const safeExpireAt = new Date(baseExpireAt.getTime() + (service.allowedClockDrift || 0)); + + if (!profile.assertionId || !profile.issuer) { + SAMLUtils.error({ msg: 'Invalid SAML response: missing Assertion ID or Issuer', profile }); + throw new Error('Invalid SAML response: missing Assertion ID or Issuer.'); + } + + if (!(await SamlUsedAssertions.markUsed(profile.assertionId, profile.issuer, safeExpireAt))) { + SAMLUtils.warn({ msg: 'SAML assertion replay detected', issuer: profile.issuer, assertionId: profile.assertionId }); + throw new Error('An assertion with the same ID cannot be used more than once.'); + } + + // create a random token to store the login result + // to test an IdP initiated login on localhost, use the following URL (assuming SimpleSAMLPHP on localhost:8080): + // http://localhost:8080/simplesaml/saml2/idp/SSOService.php?spentityid=http://localhost:3000/_saml/metadata/test-sp + const credentialToken = Random.id(); + + const loginResult = { + profile, + }; + + await this.storeCredential(credentialToken, loginResult); + + let redirectPath = SAMLUtils.getValidationActionRedirectPath(credentialToken); + if (loginClient) { + redirectPath += `&loginClient=${loginClient}`; + } + + const url = Meteor.absoluteUrl(redirectPath); + redirect(url); + } catch (err) { + SAMLUtils.error({ err }); + redirect(); + } + }); + } + + private static async findUser(username: string | undefined, emailRegex: RegExp): Promise { + const { globalSettings } = SAMLUtils; + + if (globalSettings.immutableProperty === 'Username') { + if (username) { + return Users.findOne({ + username, + }); + } + + return; + } + + return Users.findOne({ + 'emails.address': emailRegex, + }); + } + + private static guessNameFromUsername(username: string): string { + return username + .replace(/\W/g, ' ') + .replace(/\s(.)/g, (u) => u.toUpperCase()) + .replace(/^(.)/, (u) => u.toLowerCase()) + .replace(/^\w/, (u) => u.toUpperCase()); + } + + private static async subscribeToSAMLChannels(channels: Array, user: IUser): Promise { + const { includePrivateChannelsInUpdate } = SAMLUtils.globalSettings; + try { + for (let roomName of channels) { + roomName = roomName.trim(); + if (!roomName) { + continue; + } + + const privRoom = await Rooms.findOneByNameAndType(roomName, 'p', {}); + + if (privRoom && includePrivateChannelsInUpdate === true) { + await addUserToRoom(privRoom._id, user); + continue; + } + + const room = await Rooms.findOneByNameAndType(roomName, 'c', {}); + if (room) { + await addUserToRoom(room._id, user); + continue; + } + + if (!room && !privRoom) { + // If the user doesn't have an username yet, we can't create new rooms for them + if (user.username) { + await createRoom('c', roomName, user); + } + } + } + } catch (err: any) { + SystemLogger.error({ err }); + } + } +} diff --git a/apps/meteor/server/lib/saml/lib/ServiceProvider.ts b/apps/meteor/server/lib/saml/lib/ServiceProvider.ts new file mode 100644 index 0000000..346a36b --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/ServiceProvider.ts @@ -0,0 +1,199 @@ +import crypto from 'node:crypto'; +import querystring from 'node:querystring'; +import util from 'node:util'; +import zlib from 'node:zlib'; + +import { Meteor } from 'meteor/meteor'; + +import { SAMLUtils } from './Utils'; +import type { ILogoutResponse } from '../definition/ILogoutResponse'; +import type { ISAMLRequest } from '../definition/ISAMLRequest'; +import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; +import type { ILogoutRequestValidateCallback, ILogoutResponseValidateCallback, IResponseValidateCallback } from '../definition/callbacks'; +import { AuthorizeRequest } from './generators/AuthorizeRequest'; +import { LogoutRequest } from './generators/LogoutRequest'; +import { LogoutResponse } from './generators/LogoutResponse'; +import { ServiceProviderMetadata } from './generators/ServiceProviderMetadata'; +import { LogoutRequestParser } from './parsers/LogoutRequest'; +import { LogoutResponseParser } from './parsers/LogoutResponse'; +import { ResponseParser } from './parsers/Response'; +import type { SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../definition/SAMLEnvelope'; +import { getSigAlgKeyIfSupported, type SigAlgKey, signatureAlgorithms } from './signature/signatureAlgorithms'; + +export class SAMLServiceProvider { + serviceProviderOptions: IServiceProviderOptions; + + constructor(serviceProviderOptions: IServiceProviderOptions) { + if (!serviceProviderOptions) { + throw new Error('SAMLServiceProvider instantiated without an options object'); + } + + this.serviceProviderOptions = serviceProviderOptions; + } + + private getSignatureAlgorithm(): SigAlgKey { + const algorithm = `RSA-${this.serviceProviderOptions.signatureAlgorithm}`; + return getSigAlgKeyIfSupported(algorithm) || 'RSA-SHA256'; + } + + private maybeSignRequest(samlObject: Record): Record { + if (!this.serviceProviderOptions.privateKey) { + return samlObject; + } + + const algorithm = this.getSignatureAlgorithm(); + + const alg = signatureAlgorithms[algorithm]; + const xml = querystring.stringify({ ...samlObject, SigAlg: alg }); + + const signer = crypto.createSign(algorithm); + signer.update(xml); + const signature = signer.sign(this.serviceProviderOptions.privateKey, 'base64'); + + return { + ...samlObject, + SigAlg: alg, + Signature: signature, + }; + } + + public generateAuthorizeRequest(credentialToken: string): string { + const identifiedRequest = AuthorizeRequest.generate(this.serviceProviderOptions, credentialToken); + return identifiedRequest.request; + } + + public generateLogoutResponse({ + nameID, + sessionIndex, + inResponseToId, + }: { + nameID: string; + sessionIndex: string; + inResponseToId: string; + }): ILogoutResponse { + return LogoutResponse.generate(this.serviceProviderOptions, nameID, sessionIndex, inResponseToId); + } + + public generateLogoutRequest({ nameID, sessionIndex }: { nameID: string; sessionIndex: string }): ISAMLRequest { + return LogoutRequest.generate(this.serviceProviderOptions, nameID, sessionIndex); + } + + /* + This method will generate the response URL with all the query string params and pass it to the callback + */ + public logoutResponseToUrl( + response: string, + relayState: string | undefined, + callback: (err: string | object | null, url?: string) => void, + ): void { + zlib.deflateRaw(response, (err, buffer) => { + if (err) { + return callback(err); + } + + try { + const base64 = buffer.toString('base64'); + let target = this.serviceProviderOptions.idpSLORedirectURL; + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // The SAML spec requires the response RelayState to exactly match the value received on the request. + const samlResponse = this.maybeSignRequest({ + SAMLResponse: base64, + ...(relayState !== undefined && { RelayState: relayState }), + }); + + target += querystring.stringify(samlResponse); + + return callback(null, target); + } catch (error) { + return callback(error instanceof Error ? error : String(error)); + } + }); + } + + /* + This method will generate the request URL with all the query string params and pass it to the callback + */ + public async requestToUrl(request: string, operation: string, loginClient?: string): Promise { + const buffer = await util.promisify(zlib.deflateRaw)(request); + try { + const base64 = buffer.toString('base64'); + let target = this.serviceProviderOptions.entryPoint; + + if (operation === 'logout') { + if (this.serviceProviderOptions.idpSLORedirectURL) { + target = this.serviceProviderOptions.idpSLORedirectURL; + } + } + + if (target.indexOf('?') > 0) { + target += '&'; + } else { + target += '?'; + } + + // TBD. We should really include a proper RelayState here + let relayState; + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + relayState = Meteor.absoluteUrl(); + } else { + relayState = SAMLUtils.encodeAuthorizeRelayState(this.serviceProviderOptions.provider, loginClient); + } + + const samlRequest = this.maybeSignRequest({ + SAMLRequest: base64, + RelayState: relayState, + }); + + target += querystring.stringify(samlRequest); + + SAMLUtils.log(`requestToUrl: ${target}`); + + if (operation === 'logout') { + // in case of logout we want to be redirected back to the Meteor app. + return target; + } + return target; + } catch (error) { + throw error instanceof Error ? error : String(error); + } + } + + public async getAuthorizeUrl(credentialToken: string, loginClient?: string): Promise { + const request = this.generateAuthorizeRequest(credentialToken); + SAMLUtils.log({ request, msg: 'getAuthorizeUrl' }); + + return this.requestToUrl(request, 'authorize', loginClient); + } + + public async validateLogoutRequest( + envelope: SAMLRedirectEnvelope<'SAMLRequest'>, + callback: ILogoutRequestValidateCallback, + ): Promise { + const parser = new LogoutRequestParser(this.serviceProviderOptions); + return parser.validate(envelope, callback); + } + + public async validateLogoutResponse( + envelope: SAMLRedirectEnvelope<'SAMLResponse'>, + callback: ILogoutResponseValidateCallback, + ): Promise { + const parser = new LogoutResponseParser(this.serviceProviderOptions); + return parser.validate(envelope, callback); + } + + public validateResponse(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void { + const parser = new ResponseParser(this.serviceProviderOptions); + return parser.validate(envelope, callback); + } + + public generateServiceProviderMetadata(): string { + return ServiceProviderMetadata.generate(this.serviceProviderOptions); + } +} diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts new file mode 100644 index 0000000..efaf349 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -0,0 +1,519 @@ +import crypto from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import zlib from 'node:zlib'; + +import type { Logger } from '@rocket.chat/logger'; + +import { StatusCode } from './constants'; +import { ensureArray } from '../../../../lib/utils/arrayUtils'; +import type { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping'; +import type { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings'; +import type { ISAMLUser } from '../definition/ISAMLUser'; +import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; + +let providerList: Array = []; +let debug = false; +let relayState: string | null = null; +let logger: Logger | undefined; + +const globalSettings: ISAMLGlobalSettings = { + generateUsername: false, + nameOverwrite: false, + mailOverwrite: false, + immutableProperty: 'EMail', + defaultUserRole: 'user', + userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}', + usernameNormalize: 'None', + channelsAttributeUpdate: false, + includePrivateChannelsInUpdate: false, +}; + +export class SAMLUtils { + public static events: EventEmitter; + + public static get isDebugging(): boolean { + return debug; + } + + public static get globalSettings(): ISAMLGlobalSettings { + return globalSettings; + } + + public static get serviceProviders(): Array { + return providerList; + } + + public static get relayState(): string | null { + return relayState; + } + + public static set relayState(value: string | null) { + relayState = value; + } + + public static get logger(): Logger | undefined { + return logger; + } + + public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined { + this.log({ providerName, providerList }); + + return providerList.find((providerOptions) => providerOptions.provider === providerName); + } + + public static setServiceProvidersList(list: Array): void { + providerList = list; + } + + public static setLoggerInstance(instance: Logger): void { + logger = instance; + } + + // TODO: Some of those should probably not be global + public static updateGlobalSettings(samlConfigs: Record): void { + debug = Boolean(samlConfigs.debug); + + globalSettings.generateUsername = Boolean(samlConfigs.generateUsername); + globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite); + globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite); + globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate); + globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate); + globalSettings.defaultUserRole = samlConfigs.defaultUserRole; + + if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') { + globalSettings.immutableProperty = samlConfigs.immutableProperty; + } + + if (samlConfigs.usernameNormalize && typeof samlConfigs.usernameNormalize === 'string') { + globalSettings.usernameNormalize = samlConfigs.usernameNormalize; + } + + if (samlConfigs.userDataFieldMap && typeof samlConfigs.userDataFieldMap === 'string') { + globalSettings.userDataFieldMap = samlConfigs.userDataFieldMap; + } + } + + public static generateUniqueID(): string { + const chars = 'abcdef0123456789'; + let uniqueID = 'id-'; + for (let i = 0; i < 20; i++) { + uniqueID += chars.substr(Math.floor(Math.random() * 15), 1); + } + return uniqueID; + } + + public static generateInstant(): string { + return new Date().toISOString(); + } + + public static certToPEM(cert: string): string { + const lines = cert.match(/.{1,64}/g); + if (!lines) { + throw new Error('Invalid Certificate'); + } + + lines.splice(0, 0, '-----BEGIN CERTIFICATE-----'); + lines.push('-----END CERTIFICATE-----'); + + return lines.join('\n'); + } + + public static isParsableCertificate(cert: string): boolean { + try { + void new crypto.X509Certificate(this.certToPEM(cert)); + return true; + } catch { + return false; + } + } + + public static fillTemplateData(template: string, data: Record): string { + let newTemplate = template; + + for (const variable in data) { + if (variable in data) { + const key = `__${variable}__`; + while (newTemplate.includes(key)) { + newTemplate = newTemplate.replace(key, data[variable]); + } + } + } + + return newTemplate; + } + + public static getValidationActionRedirectPath(credentialToken: string): string { + // the saml_idp_credentialToken param is needed by the mobile app + return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; + } + + public static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' { + return value === 'desktop' || value === 'mobile'; + } + + public static encodeAuthorizeRelayState(provider: string, loginClient?: string): string { + if (!this.isSupportedLoginClient(loginClient)) { + return provider; + } + + return new URLSearchParams({ provider, loginClient }).toString(); + } + + public static decodeAuthorizeRelayState(relayState?: string | null): { provider?: string; loginClient?: 'desktop' | 'mobile' } { + if (!relayState) { + return {}; + } + + if (relayState.startsWith('provider=') && relayState.includes('&loginClient=')) { + const params = new URLSearchParams(relayState); + const provider = params.get('provider') ?? undefined; + const loginClient = params.get('loginClient'); + + return { + provider, + loginClient: this.isSupportedLoginClient(loginClient) ? loginClient : undefined, + }; + } + + return { provider: relayState }; + } + + public static log(obj: object | string): void { + if (debug && logger) { + logger.debug(obj); + } + } + + public static error(obj: object | string): void { + if (logger) { + logger.error(obj); + } + } + + public static warn(obj: object | string): void { + if (logger) { + logger.warn(obj); + } + } + + public static async inflateXml(deflatedXml: Buffer): Promise> { + return new Promise((resolve, reject) => { + zlib.inflateRaw(deflatedXml, (err, inflatedXml) => { + if (err) { + this.log({ msg: 'Error while inflating.', err }); + return reject(err); + } + + if (!inflatedXml) { + return reject(new Error('Failed to extract request data')); + } + + resolve(Buffer.from(inflatedXml)); + }); + }); + } + + public static validateStatus(doc: Document): { + success: boolean; + message: string; + statusCode: string; + } { + let successStatus = false; + let status = null; + let messageText = ''; + + const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode'); + + if (statusNodes.length) { + const statusNode = statusNodes[0]; + const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0]; + + if (statusMessage?.firstChild?.textContent) { + messageText = statusMessage.firstChild.textContent; + } + + status = statusNode.getAttribute('Value'); + + if (status === StatusCode.success) { + successStatus = true; + } + } + return { + success: successStatus, + message: messageText, + statusCode: status || '', + }; + } + + public static normalizeCert(cert: string): string { + if (!cert) { + return cert; + } + + return cert + .replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '') + .replace(/-+END CERTIFICATE-+\r?\n?/, '') + .replace(/\r\n/g, '\n') + .trim(); + } + + public static getUserDataMapping(): IUserDataMap { + const { userDataFieldMap, immutableProperty } = globalSettings; + + let map: Record; + + try { + map = JSON.parse(userDataFieldMap); + } catch (err) { + SAMLUtils.log({ userDataFieldMap, err }); + throw new Error('Failed to parse custom user field map'); + } + + const parsedMap: IUserDataMap = { + attributeList: new Set(), + email: { + fieldName: 'email', + }, + username: { + fieldName: 'username', + }, + name: { + fieldName: 'cn', + }, + identifier: { + type: '', + }, + }; + + let identifier = immutableProperty.toLowerCase(); + + for (const spFieldName in map) { + if (!map.hasOwnProperty(spFieldName)) { + continue; + } + + const attribute = map[spFieldName]; + if (typeof attribute !== 'string' && typeof attribute !== 'object') { + throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`); + } + + if (spFieldName === '__identifier__') { + if (typeof attribute !== 'string') { + throw new Error('SAML User Map: Invalid identifier.'); + } + + identifier = attribute; + continue; + } + + let attributeMap: IAttributeMapping | null = null; + + // If it's a complex type, let's check what's in it + if (typeof attribute === 'object') { + // A fieldName is mandatory for complex fields. If it's missing, let's skip this one. + if (!attribute.hasOwnProperty('fieldName') && !attribute.hasOwnProperty('fieldNames')) { + continue; + } + + const fieldName = attribute.fieldName || attribute.fieldNames; + const { regex, template } = attribute; + + if (Array.isArray(fieldName)) { + if (!fieldName.length) { + throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`); + } + + for (const idpFieldName of fieldName) { + parsedMap.attributeList.add(idpFieldName); + } + } else { + parsedMap.attributeList.add(fieldName); + } + + if (regex && typeof regex !== 'string') { + throw new Error('SAML User Map: Invalid RegEx'); + } + + if (template && typeof template !== 'string') { + throw new Error('SAML User Map: Invalid Template'); + } + + attributeMap = { + fieldName, + ...(regex && { regex }), + ...(template && { template }), + }; + } else if (typeof attribute === 'string') { + attributeMap = { + fieldName: attribute, + }; + parsedMap.attributeList.add(attribute); + } + + if (attributeMap) { + if (spFieldName === 'email' || spFieldName === 'username' || spFieldName === 'name') { + parsedMap[spFieldName] = attributeMap; + } + } + } + + if (identifier) { + const defaultTypes = ['email', 'username']; + + if (defaultTypes.includes(identifier)) { + parsedMap.identifier.type = identifier; + } else { + parsedMap.identifier.type = 'custom'; + parsedMap.identifier.attribute = identifier; + parsedMap.attributeList.add(identifier); + } + } + return parsedMap; + } + + public static getProfileValue(profile: Record, mapping: IAttributeMapping, forceString = false): any { + const values: Record = { + regex: '', + }; + const fieldNames = ensureArray(mapping.fieldName); + + let mainValue; + for (const fieldName of fieldNames) { + let profileValue = profile[fieldName]; + + if (Array.isArray(profileValue)) { + for (let i = 0; i < profile[fieldName].length; i++) { + // Add every index to the list of possible values to be used, both first to last and from last to first + values[`${fieldName}[${i}]`] = profileValue[i]; + values[`${fieldName}[-${Math.abs(0 - profileValue.length + i)}]`] = profileValue[i]; + } + values[`${fieldName}[]`] = profileValue.join(' '); + if (forceString) { + profileValue = profileValue.join(' '); + } + } else { + values[fieldName] = profileValue; + } + + values[fieldName] = profileValue; + + if (!mainValue) { + mainValue = profileValue; + } + } + + let shouldRunTemplate = false; + if (typeof mapping.template === 'string') { + // unless the regex result is used on the template, we process the template first + if (mapping.template.includes('__regex__')) { + shouldRunTemplate = true; + } else { + mainValue = this.fillTemplateData(mapping.template, values); + } + } + + if (mapping.regex && mainValue?.match) { + let regexValue; + const match = mainValue.match(new RegExp(mapping.regex)); + if (match?.length) { + if (match.length >= 2) { + regexValue = match[1]; + } else { + regexValue = match[0]; + } + } + + if (regexValue) { + values.regex = regexValue; + if (!shouldRunTemplate) { + mainValue = regexValue; + } + } + } + + if (shouldRunTemplate && typeof mapping.template === 'string') { + mainValue = this.fillTemplateData(mapping.template, values); + } + + return mainValue; + } + + public static normalizeUsername(name: string): string { + const { globalSettings } = this; + + switch (globalSettings.usernameNormalize) { + case 'Lowercase': + name = name.toLowerCase(); + break; + } + + return name; + } + + public static mapProfileToUserObject(profile: Record): ISAMLUser { + const userDataMap = this.getUserDataMapping(); + SAMLUtils.log({ msg: 'Mapping SAML Profile to User Object', userDataMap }); + + if (userDataMap.identifier.type === 'custom') { + if (!userDataMap.identifier.attribute) { + throw new Error('SAML User Data Map: invalid Identifier configuration received.'); + } + if (!profile[userDataMap.identifier.attribute]) { + throw new Error(`SAML Profile did not have the expected identifier (${userDataMap.identifier.attribute}).`); + } + } + + const attributeList = new Map(); + for (const attributeName of userDataMap.attributeList) { + if (profile[attributeName] === undefined) { + this.log({ msg: 'SAML user profile is missing the attribute.', attribute: attributeName }); + continue; + } + attributeList.set(attributeName, profile[attributeName]); + } + const email = this.getProfileValue(profile, userDataMap.email); + const profileUsername = this.getProfileValue(profile, userDataMap.username, true); + const name = this.getProfileValue(profile, userDataMap.name, true); + + // Even if we're not using the email to identify the user, it is still mandatory because it's a mandatory information on Rocket.Chat + if (!email) { + throw new Error('SAML Profile did not contain an email address'); + } + + const userObject: ISAMLUser = { + samlLogin: { + provider: this.relayState, + idp: profile.issuer, + idpSession: profile.sessionIndex, + nameID: profile.nameID, + }, + emailList: ensureArray(email), + fullName: name || profile.displayName || profile.username, + eppn: profile.eppn, + attributeList, + identifier: userDataMap.identifier, + }; + + if (profileUsername) { + userObject.username = this.normalizeUsername(profileUsername); + } + + if (profile.language) { + userObject.language = profile.language; + } + + if (profile.channels) { + if (Array.isArray(profile.channels)) { + userObject.channels = profile.channels; + } else { + userObject.channels = profile.channels.split(','); + } + } + + this.events.emit('mapUser', { profile, userObject }); + + return userObject; + } +} + +SAMLUtils.events = new EventEmitter(); diff --git a/apps/meteor/server/lib/saml/lib/constants.ts b/apps/meteor/server/lib/saml/lib/constants.ts new file mode 100644 index 0000000..a6066f0 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/constants.ts @@ -0,0 +1,53 @@ +export const defaultAuthnContextTemplate = ` + + __authnContext__ + +`; + +export const defaultAuthRequestTemplate = ` + __issuer__ + __identifierFormatTag__ + __authnContextTag__ +`; + +export const defaultLogoutResponseTemplate = ` + __issuer__ + +`; + +export const defaultLogoutRequestTemplate = ` + __issuer__ + __nameID__ + __sessionIndex__ +`; + +export const defaultMetadataCertificateTemplate = ` + + + + __certificate__ + + + + + + `; + +export const defaultMetadataTemplate = ` + + __certificateTag__ + + __identifierFormat__ + + +`; + +export const defaultNameIDTemplate = + ''; +export const defaultIdentifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; +export const defaultAuthnContext = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; + +export const StatusCode = { + success: 'urn:oasis:names:tc:SAML:2.0:status:Success', + responder: 'urn:oasis:names:tc:SAML:2.0:status:Responder', +}; diff --git a/apps/meteor/server/lib/saml/lib/generators/AuthorizeRequest.ts b/apps/meteor/server/lib/saml/lib/generators/AuthorizeRequest.ts new file mode 100644 index 0000000..27d0100 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/generators/AuthorizeRequest.ts @@ -0,0 +1,81 @@ +import type { IAuthorizeRequestVariables } from '../../definition/IAuthorizeRequestVariables'; +import type { ISAMLRequest } from '../../definition/ISAMLRequest'; +import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import { SAMLUtils } from '../Utils'; +import { + defaultIdentifierFormat, + defaultAuthnContext, + defaultAuthRequestTemplate, + defaultNameIDTemplate, + defaultAuthnContextTemplate, +} from '../constants'; + +function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions): string | undefined; +function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue: string): string; +function resolveCustomAuthnContext(serviceProviderOptions: IServiceProviderOptions, defaultValue?: string): string | undefined { + const value = serviceProviderOptions.customAuthnContext.trim(); + return value.length > 0 ? value : defaultValue; +} + +/* + An Authorize Request is used to show the Identity Provider login form when the user clicks on the Rocket.Chat SAML login button +*/ +export class AuthorizeRequest { + public static generate(serviceProviderOptions: IServiceProviderOptions, credentialToken: string): ISAMLRequest { + const data = this.getDataForNewRequest(serviceProviderOptions, credentialToken); + const request = SAMLUtils.fillTemplateData(this.authorizeRequestTemplate(serviceProviderOptions), data); + + return { + request, + id: data.newId, + }; + } + + // The AuthorizeRequest template is split into three parts + // This way, users don't need to change the template when all they want to do is remove the NameID Policy or the AuthnContext. + // This also ensures compatibility with providers that were configured before the templates existed. + private static authorizeRequestTemplate(serviceProviderOptions: IServiceProviderOptions): string { + const data = { + identifierFormatTag: this.identifierFormatTagTemplate(serviceProviderOptions), + authnContextTag: this.authnContextTagTemplate(serviceProviderOptions), + }; + + const template = serviceProviderOptions.authRequestTemplate || defaultAuthRequestTemplate; + return SAMLUtils.fillTemplateData(template, data); + } + + private static identifierFormatTagTemplate(serviceProviderOptions: IServiceProviderOptions): string { + if (!serviceProviderOptions.identifierFormat) { + return ''; + } + + return serviceProviderOptions.nameIDPolicyTemplate || defaultNameIDTemplate; + } + + private static authnContextTagTemplate(serviceProviderOptions: IServiceProviderOptions): string { + if (!resolveCustomAuthnContext(serviceProviderOptions)) { + return ''; + } + + return serviceProviderOptions.authnContextTemplate || defaultAuthnContextTemplate; + } + + private static getDataForNewRequest( + serviceProviderOptions: IServiceProviderOptions, + credentialToken?: string, + ): IAuthorizeRequestVariables { + const id = credentialToken || `_${SAMLUtils.generateUniqueID()}`; + const instant = SAMLUtils.generateInstant(); + + return { + newId: id, + instant, + callbackUrl: serviceProviderOptions.callbackUrl, + entryPoint: serviceProviderOptions.entryPoint, + issuer: serviceProviderOptions.issuer, + identifierFormat: serviceProviderOptions.identifierFormat || defaultIdentifierFormat, + authnContextComparison: serviceProviderOptions.authnContextComparison || 'exact', + authnContext: resolveCustomAuthnContext(serviceProviderOptions, defaultAuthnContext), + }; + } +} diff --git a/apps/meteor/server/lib/saml/lib/generators/LogoutRequest.ts b/apps/meteor/server/lib/saml/lib/generators/LogoutRequest.ts new file mode 100644 index 0000000..733ffd4 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/generators/LogoutRequest.ts @@ -0,0 +1,45 @@ +import type { ILogoutRequestVariables } from '../../definition/ILogoutRequestVariables'; +import type { ISAMLRequest } from '../../definition/ISAMLRequest'; +import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import { SAMLUtils } from '../Utils'; +import { defaultIdentifierFormat, defaultLogoutRequestTemplate } from '../constants'; + +/* + A Logout Request is used when the user is logged out of Rocket.Chat and the Service Provider is configured to also logout from the Identity Provider. +*/ +export class LogoutRequest { + static generate(serviceProviderOptions: IServiceProviderOptions, nameID: string, sessionIndex: string): ISAMLRequest { + const data = this.getDataForNewRequest(serviceProviderOptions, nameID, sessionIndex); + const request = SAMLUtils.fillTemplateData(serviceProviderOptions.logoutRequestTemplate || defaultLogoutRequestTemplate, data); + + SAMLUtils.log({ request, msg: '------- SAML Logout request -----------' }); + + return { + request, + id: data.newId, + }; + } + + static getDataForNewRequest( + serviceProviderOptions: IServiceProviderOptions, + nameID: string, + sessionIndex: string, + ): ILogoutRequestVariables { + // nameId: + // sessionIndex: sessionIndex + // --- NO SAMLsettings: , attributeName: string): string | null { + if (!(attributeName in params)) { + return null; + } + + if (typeof params[attributeName] !== 'string') { + return null; + } + + return params[attributeName]; +} + +async function performBindingSpecificDecoding(binding: SAMLBinding, buffer: Buffer): Promise> { + switch (binding) { + case 'HTTP-Redirect': + return SAMLUtils.inflateXml(buffer); + default: + return buffer; + } +} + +async function decodeDocument(base64Data: string, binding: SAMLBinding): Promise { + const buffer = await performBindingSpecificDecoding(binding, Buffer.from(base64Data, 'base64')); + + return buffer.toString('utf8'); +} + +function getSignedContent(req: IIncomingMessage, documentType: SAMLDocumentType): string | null { + // We load the signed content directly from the query string, because: + // 1. We need the raw value, without running decodeURIComponent or any other processing/normalization + // 2. We need to respect the order the params came in (although the order should be fixed) + const rawUrl = req.url; + if (!rawUrl) { + return null; + } + + const queryString = rawUrl.split('?')[1]; + if (!queryString) { + return null; + } + const params = queryString.split('&'); + + const signedContent = params + .filter((p) => p.startsWith(`${documentType}=`) || p.startsWith(`RelayState=`) || p.startsWith('SigAlg=')) + .join('&'); + + return signedContent || null; +} + +export async function getSAMLEnvelope( + req: IIncomingMessage, + type: T, + binding: 'HTTP-Redirect', +): Promise | null>; +export async function getSAMLEnvelope( + req: IIncomingMessage, + type: T, + binding: 'HTTP-POST', +): Promise | null>; +export async function getSAMLEnvelope( + req: IIncomingMessage, + type: T, + binding: B, +): Promise | null> { + const params = binding === 'HTTP-Redirect' ? req.query : req.body; + + if (!params) { + return null; + } + + const encodedDocument = getStringAttribute(params, type); + + if (!encodedDocument) { + return null; + } + + const decodedDocument = await decodeDocument(encodedDocument, binding); + + const relayState = getStringAttribute(params, 'RelayState'); + + const envelope: SAMLBaseEnvelope = { + binding, + type, + encodedDocument, + decodedDocument, + ...(typeof relayState === 'string' && { relayState }), + }; + + if (envelope.binding !== 'HTTP-Redirect') { + return envelope; + } + + const signedContent = getSignedContent(req, type); + + const sigAlg = getStringAttribute(params, 'SigAlg'); + const signature = getStringAttribute(params, 'Signature'); + + if (sigAlg && signature && signedContent) { + return { + ...envelope, + sigAlg, + signature, + signedContent, + }; + } + + return envelope; +} diff --git a/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts b/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts new file mode 100644 index 0000000..72433f7 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts @@ -0,0 +1,141 @@ +import xmldom from '@xmldom/xmldom'; + +import { SAMLUtils } from '../Utils'; + +const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata'; +const DS_NS = 'http://www.w3.org/2000/09/xmldsig#'; +const REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'; +const SAML2_PROTOCOL = 'urn:oasis:names:tc:SAML:2.0:protocol'; + +type IdpMetadataResult = { + entryPoint?: string; + idpSLORedirectURL?: string; + cert?: string; + identifierFormat?: string; + warnings: string[]; +}; + +type ExtractedValue = { + value?: string; + warning?: string; +}; + +export class InvalidIdpMetadataError extends Error {} + +const isHttpUrl = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const childrenOf = (el: Element | Document, tag: string): Element[] => + Array.from(el.getElementsByTagNameNS(MD_NS, tag)).filter((child) => child.parentNode === el); + +const parseIdpDescriptor = (xml: string): Element => { + const doc = new xmldom.DOMParser({ + errorHandler: () => { + throw new InvalidIdpMetadataError('invalid-xml'); + }, + }).parseFromString(xml, 'text/xml'); + + if (!doc) { + throw new InvalidIdpMetadataError('invalid-xml'); + } + + const entityDescriptor = childrenOf(doc, 'EntityDescriptor')[0]; + if (!entityDescriptor) { + throw new InvalidIdpMetadataError('root-is-not-entity-descriptor'); + } + + const idpDescriptors = childrenOf(entityDescriptor, 'IDPSSODescriptor'); + if (!idpDescriptors.length) { + throw new InvalidIdpMetadataError('no-idp-sso-descriptor'); + } + + const idpDescriptor = idpDescriptors.find((descriptor) => + (descriptor.getAttribute('protocolSupportEnumeration') ?? '').split(/\s+/).includes(SAML2_PROTOCOL), + ); + if (!idpDescriptor) { + throw new InvalidIdpMetadataError('no-saml2-idp-sso-descriptor'); + } + + return idpDescriptor; +}; + +const extractSigningCert = (idp: Element): ExtractedValue => { + const certs = childrenOf(idp, 'KeyDescriptor') + .filter((kd) => { + const use = kd.getAttribute('use'); + return !use || use === 'signing'; + }) + .map((kd) => kd.getElementsByTagNameNS(DS_NS, 'X509Certificate')[0]?.textContent?.trim()) + .map((raw) => (raw ? SAMLUtils.normalizeCert(raw) : undefined)) + .filter((cert): cert is string => !!cert && SAMLUtils.isParsableCertificate(cert)); + + if (!certs.length) { + return { warning: 'SAML_Metadata_warning_no_valid_cert' }; + } + + const warning = certs.length > 1 ? 'SAML_Metadata_warning_multiple_certs' : undefined; + + return { value: certs[0], warning }; +}; + +const findRedirectLocation = (services: Element[]): string | undefined => + services + .filter((s) => s.getAttribute('Binding') === REDIRECT_BINDING) + .map((s) => s.getAttribute('Location')) + .find((location): location is string => !!location && isHttpUrl(location)); + +const extractEntryPoint = (idp: Element): ExtractedValue => { + const value = findRedirectLocation(childrenOf(idp, 'SingleSignOnService')); + const warning = value ? undefined : 'SAML_Metadata_warning_no_redirect_binding'; + + return { value, warning }; +}; + +const extractSloUrl = (idp: Element): ExtractedValue => { + const services = childrenOf(idp, 'SingleLogoutService'); + if (!services.length) { + return {}; + } + + const value = findRedirectLocation(services); + const warning = value ? undefined : 'SAML_Metadata_warning_no_slo_redirect_binding'; + + return { value, warning }; +}; + +const extractIdentifierFormat = (idp: Element): ExtractedValue => { + const formats = childrenOf(idp, 'NameIDFormat') + .map((n) => n.textContent?.trim()) + .filter((v): v is string => !!v); + + const warning = formats.length > 1 ? 'SAML_Metadata_warning_multiple_nameid_formats' : undefined; + + return { + value: formats[0], + warning, + }; +}; + +export function parseIdpMetadata(xml: string): IdpMetadataResult { + const idp = parseIdpDescriptor(xml); + + const cert = extractSigningCert(idp); + const entryPoint = extractEntryPoint(idp); + const sloUrl = extractSloUrl(idp); + const identifierFormat = extractIdentifierFormat(idp); + const warnings = [cert, entryPoint, sloUrl, identifierFormat].flatMap(({ warning }) => warning ?? []); + + return { + cert: cert.value, + entryPoint: entryPoint.value, + idpSLORedirectURL: sloUrl.value, + identifierFormat: identifierFormat.value, + warnings, + }; +} diff --git a/apps/meteor/server/lib/saml/lib/parsers/LogoutRequest.ts b/apps/meteor/server/lib/saml/lib/parsers/LogoutRequest.ts new file mode 100644 index 0000000..917d29a --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/parsers/LogoutRequest.ts @@ -0,0 +1,66 @@ +import xmldom from '@xmldom/xmldom'; + +import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope'; +import type { ILogoutRequestValidateCallback } from '../../definition/callbacks'; +import { SAMLUtils } from '../Utils'; +import { validateRedirectSignature } from '../signature/validateRedirectSignature'; + +export class LogoutRequestParser { + serviceProviderOptions: IServiceProviderOptions; + + constructor(serviceProviderOptions: IServiceProviderOptions) { + this.serviceProviderOptions = serviceProviderOptions; + } + + public async validate(envelope: SAMLRedirectEnvelope<'SAMLRequest'>, callback: ILogoutRequestValidateCallback): Promise { + const { decodedDocument: xmlString } = envelope; + + SAMLUtils.log({ msg: 'Validating SAML Logout Request', xmlString }); + + if (!this.verifySignature(envelope)) { + SAMLUtils.log({ msg: 'Failed to verify signature from Logout Request' }); + return callback('Signature validation failed'); + } + + const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + const request = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutRequest')[0]; + if (!request) { + return callback('No Request Found'); + } + + try { + const sessionNode = request.getElementsByTagNameNS('*', 'SessionIndex')[0]; + const nameIdNode = request.getElementsByTagNameNS('*', 'NameID')[0]; + + if (!nameIdNode) { + throw new Error('SAML Logout Request: No NameID node found'); + } + + const idpSession = sessionNode.childNodes[0].nodeValue; + const nameID = nameIdNode.childNodes[0].nodeValue; + const id = request.getAttribute('ID'); + + return callback(null, { idpSession, nameID, id }); + } catch (err) { + SAMLUtils.error({ err }); + + const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); + SAMLUtils.log({ msg: `Unexpected msg from IDP. Does your session still exist at IDP?`, statusMessage }); + + return callback(err instanceof Error ? err : String(err), null); + } + } + + private verifySignature(envelope: SAMLRedirectEnvelope<'SAMLRequest'>): boolean { + if (!this.serviceProviderOptions.validateLogoutRequestSignature) { + return true; + } + + return validateRedirectSignature(envelope, this.serviceProviderOptions.cert); + } +} diff --git a/apps/meteor/server/lib/saml/lib/parsers/LogoutResponse.ts b/apps/meteor/server/lib/saml/lib/parsers/LogoutResponse.ts new file mode 100644 index 0000000..fa62aa4 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/parsers/LogoutResponse.ts @@ -0,0 +1,66 @@ +import xmldom from '@xmldom/xmldom'; + +import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope'; +import type { ILogoutResponseValidateCallback } from '../../definition/callbacks'; +import { SAMLUtils } from '../Utils'; +import { validateRedirectSignature } from '../signature/validateRedirectSignature'; + +export class LogoutResponseParser { + serviceProviderOptions: IServiceProviderOptions; + + constructor(serviceProviderOptions: IServiceProviderOptions) { + this.serviceProviderOptions = serviceProviderOptions; + } + + public async validate(envelope: SAMLRedirectEnvelope<'SAMLResponse'>, callback: ILogoutResponseValidateCallback): Promise { + const { decodedDocument: xmlString } = envelope; + + SAMLUtils.log({ msg: 'Validating SAML Logout Response', xmlString }); + + if (!this.verifySignature(envelope)) { + SAMLUtils.log({ msg: 'Failed to verify signature from Logout Response' }); + return callback('Signature validation failed'); + } + + const doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse')[0]; + if (!response) { + return callback('No Response Found', null); + } + + // TBD. Check if this msg corresponds to one we sent + let inResponseTo; + try { + inResponseTo = response.getAttribute('InResponseTo'); + SAMLUtils.log({ msg: `Found InResponseTo`, inResponseTo }); + } catch (err) { + SAMLUtils.log({ err }); + const msg = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage'); + SAMLUtils.log(`Unexpected msg from IDP. Does your session still exist at IDP? Idp returned: \n ${msg}`); + } + + if (!inResponseTo) { + return callback('Unexpected Response from IDP', null); + } + + const statusValidateObj = SAMLUtils.validateStatus(doc); + if (!statusValidateObj.success) { + return callback('Error. Logout not confirmed by IDP', null); + } + + return callback(null, inResponseTo); + } + + private verifySignature(envelope: SAMLRedirectEnvelope<'SAMLResponse'>): boolean { + if (!this.serviceProviderOptions.validateLogoutResponseSignature) { + return true; + } + + return validateRedirectSignature(envelope, this.serviceProviderOptions.cert); + } +} diff --git a/apps/meteor/server/lib/saml/lib/parsers/Response.ts b/apps/meteor/server/lib/saml/lib/parsers/Response.ts new file mode 100644 index 0000000..a7d755a --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/parsers/Response.ts @@ -0,0 +1,531 @@ +import xmldom from '@xmldom/xmldom'; +import { SignedXml } from 'xml-crypto'; +import type { Reference } from 'xml-crypto'; +import xmlenc from 'xml-encryption'; + +import type { ISAMLAssertion } from '../../definition/ISAMLAssertion'; +import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions'; +import type { SAMLPOSTEnvelope } from '../../definition/SAMLEnvelope'; +import type { IResponseValidateCallback } from '../../definition/callbacks'; +import { SAMLUtils } from '../Utils'; +import { StatusCode } from '../constants'; + +type XmlParent = Element | Document; + +export class ResponseParser { + serviceProviderOptions: IServiceProviderOptions; + + constructor(serviceProviderOptions: IServiceProviderOptions) { + this.serviceProviderOptions = serviceProviderOptions; + } + + public validate(envelope: SAMLPOSTEnvelope<'SAMLResponse'>, callback: IResponseValidateCallback): void { + const { decodedDocument: xml } = envelope; + // We currently use RelayState to save SAML provider + SAMLUtils.log({ msg: 'Validating SAML Response', xml }); + + let error: Error | null = null; + + const doc = new xmldom.DOMParser({ + errorHandler: { + fatalError: (e: any) => { + if (e instanceof Error) { + error = e; + return; + } + + if (typeof e === 'string') { + error = new Error(e); + return; + } + + error = new Error(); + }, + error: (e: Error) => { + if (e instanceof Error) { + error = e; + return; + } + + if (typeof e === 'string') { + error = new Error(e); + return; + } + + error = new Error(); + }, + }, + }).parseFromString(xml, 'text/xml'); + if (!doc) { + return callback('No Doc Found'); + } + + if (error) { + return callback(error, null, false); + } + + const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response'); + if (allResponses.length === 0) { + return this._checkLogoutResponse(doc, callback); + } + + if (allResponses.length !== 1) { + return callback(new Error('Too many SAML responses'), null, false); + } + const response = allResponses[0]; + + if (response !== doc.documentElement) { + return callback(new Error('SAML Response must be the document root'), null, false); + } + + SAMLUtils.log('Got response'); + + SAMLUtils.log('Verify status'); + const statusValidateObj = SAMLUtils.validateStatus(doc); + if (!statusValidateObj.success) { + if (!statusValidateObj.statusCode) { + return callback(new Error('Missing StatusCode'), null, false); + } + + if (statusValidateObj.statusCode === StatusCode.responder && statusValidateObj.message) { + return callback(new Error(statusValidateObj.message), null, false); + } + + return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false); + } + SAMLUtils.log('Status ok'); + + let assertion: Element; + let assertionData: ISAMLAssertion; + let issuer; + + try { + assertionData = this.getAssertion(response, xml); + assertion = assertionData.assertion; + + this.verifySignatures(response, assertionData, xml); + } catch (e) { + return callback(e instanceof Error ? e : String(e), null, false); + } + + const profile: Record = {}; + + if (response.hasAttribute('InResponseTo')) { + profile.inResponseToId = response.getAttribute('InResponseTo'); + } + + try { + issuer = this.getIssuer(assertion); + } catch (e) { + return callback(e instanceof Error ? e : String(e), null, false); + } + + if (issuer) { + profile.issuer = issuer.textContent; + } + + if (assertion.hasAttribute('ID')) { + profile.assertionId = assertion.getAttribute('ID'); + } + + const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; + if (conditions?.hasAttribute('NotOnOrAfter')) { + const notOnOrAfter = conditions.getAttribute('NotOnOrAfter'); + if (notOnOrAfter) { + profile.expireAt = new Date(notOnOrAfter); + } + } + + const subject = this.getSubject(assertion); + if (subject) { + const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; + if (nameID) { + profile.nameID = nameID.textContent; + + if (nameID.hasAttribute('Format')) { + profile.nameIDFormat = nameID.getAttribute('Format'); + } + } + + try { + this.validateSubjectConditions(subject); + } catch (e) { + return callback(e instanceof Error ? e : String(e), null, false); + } + } + + try { + this.validateAssertionConditions(assertion); + } catch (e) { + return callback(e instanceof Error ? e : String(e), null, false); + } + + const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; + + if (authnStatement) { + if (authnStatement.hasAttribute('SessionIndex')) { + profile.sessionIndex = authnStatement.getAttribute('SessionIndex'); + SAMLUtils.log({ msg: 'Session Index Found', sessionIndex: profile.sessionIndex }); + } else { + SAMLUtils.log('No Session Index Found'); + } + } else { + SAMLUtils.log('No AuthN Statement found'); + } + + const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0]; + if (attributeStatement) { + this.mapAttributes(attributeStatement, profile); + } else { + SAMLUtils.log('No Attribute Statement found in SAML response.'); + } + + if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) { + profile.email = profile.nameID; + } + + const profileKeys = Object.keys(profile); + for (let i = 0; i < profileKeys.length; i++) { + const key = profileKeys[i]; + + if (key.match(/\./)) { + profile[key.replace(/\./g, '-')] = profile[key]; + delete profile[key]; + } + } + + SAMLUtils.log({ msg: 'NameID', profile }); + return callback(null, profile, false); + } + + private _checkLogoutResponse(doc: Document, callback: IResponseValidateCallback): void { + const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); + if (!logoutResponse.length) { + return callback(new Error('Unknown SAML response message'), null, false); + } + + SAMLUtils.log('Verify status'); + const statusValidateObj = SAMLUtils.validateStatus(doc); + if (!statusValidateObj.success) { + return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false); + } + SAMLUtils.log('Status ok'); + + // @ToDo: Check if this situation is still used + return callback(null, null, true); + } + + private getAssertion(response: Element, xml: string): ISAMLAssertion { + const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); + const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion'); + + if (allAssertions.length + allEncrypedAssertions.length > 1) { + throw new Error('Too many SAML assertions'); + } + + let assertion: Element = allAssertions[0]; + const encAssertion = allEncrypedAssertions[0]; + let newXml = null; + + if (typeof encAssertion !== 'undefined') { + // disallowDecryptionWithInsecureAlgorithm defaults to true in xml-encryption v4, but AES-CBC/3DES + // are still widely used by SAML IdPs in practice, so we keep the pre-v4 behaviour here. + const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false }; + const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0]; + xmlenc.decrypt(encData, options, (err, result) => { + if (err) { + SAMLUtils.error(err); + } + + const document = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + if (!document) { + throw new Error('Failed to decrypt SAML assertion'); + } + + const decryptedAssertions = document.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); + if (decryptedAssertions.length) { + assertion = decryptedAssertions[0]; + } + + newXml = result; + }); + } + + if (!assertion) { + throw new Error('Missing SAML assertion'); + } + + return { + assertion, + xml: newXml || xml, + }; + } + + private verifySignatures(response: Element, assertionData: ISAMLAssertion, xml: string): void { + const signatureType = this.serviceProviderOptions.signatureValidationType; + + const checkEither = signatureType === 'Either'; + const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither; + const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither; + let anyValidSignature = false; + + if (!this.serviceProviderOptions.cert) { + if (checkResponse || checkAssertion) { + SAMLUtils.log('Missing Signature validation params'); + throw new Error('Unable to validate signature'); + } + return; + } + + if (checkResponse) { + SAMLUtils.log('Verify Document Signature'); + if (!this.validateResponseSignature(xml, this.serviceProviderOptions.cert, response)) { + if (!checkEither) { + SAMLUtils.log('Document Signature WRONG'); + throw new Error('Invalid Signature'); + } + } else { + anyValidSignature = true; + } + SAMLUtils.log('Document Signature OK'); + } + + if (checkAssertion) { + SAMLUtils.log('Verify Assertion Signature'); + if (!this.validateAssertionSignature(assertionData.xml, this.serviceProviderOptions.cert, assertionData.assertion)) { + if (!checkEither) { + SAMLUtils.log('Assertion Signature WRONG'); + throw new Error('Invalid Assertion signature'); + } + } else { + anyValidSignature = true; + } + SAMLUtils.log('Assertion Signature OK'); + } + + if (checkEither && !anyValidSignature) { + SAMLUtils.log('No Valid Signature'); + throw new Error('No valid SAML Signature found'); + } + } + + private validateResponseSignature(xml: string, cert: string, response: Element): boolean { + return this.validateSignatureChildren(xml, cert, response); + } + + private validateAssertionSignature(xml: string, cert: string, assertion: Element): boolean { + return this.validateSignatureChildren(xml, cert, assertion); + } + + private validateSignatureChildren(xml: string, cert: string, parent: Element): boolean { + const signatures = parent.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature'); + let signature = null; + + for (let i = 0; i < signatures.length; i++) { + const sign = signatures[i]; + if (sign.parentNode !== parent) { + continue; + } + + // Too many signatures + if (signature) { + SAMLUtils.log('Failed to validate SAML signature: Too Many Signatures'); + return false; + } + + signature = sign; + } + + if (!signature) { + SAMLUtils.log('Failed to validate SAML signature: Signature not found'); + return false; + } + + return this.validateSignature(xml, cert, signature, this.getElementId(parent)); + } + + private getElementId(element: Element): string | null { + return element.getAttribute('ID') || element.getAttribute('Id') || element.getAttribute('id'); + } + + private signatureCoversElement(sig: SignedXml, expectedId: string | null): boolean { + if (!expectedId) { + return false; + } + + const references: Reference[] = sig.getReferences() || []; + if (references.length === 0) { + return false; + } + + return references.every((reference: Reference) => { + // `signedReference` is only populated for references that were part of the successfully + // validated signature, so its presence proves this reference was actually covered. + if (reference.signedReference == null) { + return false; + } + + const uri = reference.uri || ''; + const referencedId = uri.charAt(0) === '#' ? uri.substring(1) : uri; + return referencedId === expectedId; + }); + } + + private validateSignature(xml: string, cert: string, signature: Element, expectedId: string | null): boolean { + const sig = new SignedXml({ publicCert: Buffer.from(SAMLUtils.certToPEM(cert)) }); + + sig.loadSignature(signature); + + try { + if (!sig.checkSignature(xml)) { + SAMLUtils.log(sig.getReferences().map((reference) => reference.validationError?.message)); + return false; + } + } catch (e) { + SAMLUtils.log(e instanceof Error ? e.message : String(e)); + return false; + } + + if (!this.signatureCoversElement(sig, expectedId)) { + SAMLUtils.log('Failed to validate SAML signature: signed reference does not match the processed element'); + return false; + } + + return true; + } + + private getIssuer(assertion: Element): any { + const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer'); + if (issuers.length > 1) { + throw new Error('Too many Issuers'); + } + + return issuers[0]; + } + + private getSubject(assertion: Element): XmlParent { + let subject: XmlParent = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; + const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; + + if (typeof encSubject !== 'undefined') { + const options = { key: this.serviceProviderOptions.privateKey, disallowDecryptionWithInsecureAlgorithm: false }; + xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => { + if (err) { + SAMLUtils.error({ err }); + } + subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + return subject; + } + + private validateSubjectConditions(subject: XmlParent): void { + const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0]; + if (subjectConfirmation) { + const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'SubjectConfirmationData', + )[0]; + if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } + } + } + + private validateNotBeforeNotOnOrAfterAssertions(element: Element): boolean { + const sysnow = new Date(); + const allowedclockdrift = this.serviceProviderOptions.allowedClockDrift || 0; + + const now = new Date(sysnow.getTime() + allowedclockdrift); + + if (element.hasAttribute('NotBefore')) { + const notBefore: string | null = element.getAttribute('NotBefore'); + + if (!notBefore) { + return false; + } + + const date = new Date(notBefore); + if (now < date) { + return false; + } + } + + if (element.hasAttribute('NotOnOrAfter')) { + const notOnOrAfter: string | null = element.getAttribute('NotOnOrAfter'); + if (!notOnOrAfter) { + return false; + } + + const date = new Date(notOnOrAfter); + + if (now >= date) { + return false; + } + } + + return true; + } + + private validateAssertionConditions(assertion: Element): void { + const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; + if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } + } + + private mapAttributes(attributeStatement: Element, profile: Record): void { + SAMLUtils.log({ msg: 'Attribute Statement found, mapping attributes to profile.', attributeStatement }); + const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute'); + SAMLUtils.log({ msg: 'Attributes will be processed', count: attributes.length }); + + if (attributes) { + for (let i = 0; i < attributes.length; i++) { + const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue'); + let value; + if (values.length === 1) { + value = values[0].textContent; + } else { + value = []; + for (let j = 0; j < values.length; j++) { + value.push(values[j].textContent); + } + } + + const key = attributes[i].getAttribute('Name'); + if (key) { + SAMLUtils.log({ msg: 'Mapping attribute to profile', attribute: key, value }); + profile[key] = value; + } + } + } else { + SAMLUtils.log('No Attributes found in SAML attribute statement.'); + } + + if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) { + // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs + profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3']; + } + + if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) { + profile.email = profile['urn:oid:1.2.840.113549.1.9.1']; + } + + if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) { + profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241']; + } + + if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) { + profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']; + } + + if (!profile.email && profile.mail) { + profile.email = profile.mail; + } + + if (!profile.cn && profile['urn:oid:2.5.4.3']) { + profile.cn = profile['urn:oid:2.5.4.3']; + } + } +} diff --git a/apps/meteor/server/lib/saml/lib/settings.ts b/apps/meteor/server/lib/saml/lib/settings.ts new file mode 100644 index 0000000..47dec51 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/settings.ts @@ -0,0 +1,362 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { SAMLUtils } from './Utils'; +import { + defaultAuthnContextTemplate, + defaultAuthRequestTemplate, + defaultLogoutResponseTemplate, + defaultLogoutRequestTemplate, + defaultNameIDTemplate, + defaultIdentifierFormat, + defaultAuthnContext, + defaultMetadataTemplate, + defaultMetadataCertificateTemplate, +} from './constants'; +import { settings, settingsRegistry } from '../../../settings'; +import { SystemLogger } from '../../logger/system'; +import { notifyOnLoginServiceConfigurationChanged, notifyOnLoginServiceConfigurationChangedByService } from '../../notifyListener'; +import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions'; + +const getSamlConfigs = function (service: string): SAMLConfiguration { + const configs: SAMLConfiguration = { + buttonLabelText: settings.get(`${service}_button_label_text`), + buttonLabelColor: settings.get(`${service}_button_label_color`), + buttonColor: settings.get(`${service}_button_color`), + clientConfig: { + provider: settings.get(`${service}_provider`), + }, + entryPoint: settings.get(`${service}_entry_point`), + idpSLORedirectURL: settings.get(`${service}_idp_slo_redirect_url`), + usernameNormalize: settings.get(`${service}_username_normalize`), + immutableProperty: settings.get(`${service}_immutable_property`), + generateUsername: settings.get(`${service}_generate_username`), + debug: settings.get(`${service}_debug`), + nameOverwrite: settings.get(`${service}_name_overwrite`), + mailOverwrite: settings.get(`${service}_mail_overwrite`), + issuer: settings.get(`${service}_issuer`), + logoutBehaviour: settings.get(`${service}_logout_behaviour`), + defaultUserRole: settings.get(`${service}_default_user_role`), + secret: { + privateKey: settings.get(`${service}_private_key`), + publicCert: settings.get(`${service}_public_cert`), + // People often overlook the instruction to remove the header and footer of the certificate on this specific setting, so let's do it for them. + cert: SAMLUtils.normalizeCert((settings.get(`${service}_cert`) as string) || ''), + algorithm: settings.get(`${service}_signature_algorithm`) || 'SHA1', + }, + signatureValidationType: settings.get(`${service}_signature_validation_type`), + validateLogoutRequestSignature: settings.get(`${service}_validate_logout_request_signature`), + validateLogoutResponseSignature: settings.get(`${service}_validate_logout_response_signature`), + userDataFieldMap: settings.get(`${service}_user_data_fieldmap`), + allowedClockDrift: settings.get(`${service}_allowed_clock_drift`), + customAuthnContext: defaultAuthnContext, + authnContextComparison: 'exact', + identifierFormat: defaultIdentifierFormat, + nameIDPolicyTemplate: defaultNameIDTemplate, + authnContextTemplate: defaultAuthnContextTemplate, + authRequestTemplate: defaultAuthRequestTemplate, + logoutResponseTemplate: defaultLogoutResponseTemplate, + logoutRequestTemplate: defaultLogoutRequestTemplate, + metadataCertificateTemplate: defaultMetadataCertificateTemplate, + metadataTemplate: defaultMetadataTemplate, + channelsAttributeUpdate: settings.get(`${service}_channels_update`), + includePrivateChannelsInUpdate: settings.get(`${service}_include_private_channels_update`), + }; + + SAMLUtils.events.emit('loadConfigs', service, configs); + return configs; +}; + +const isValidConfiguration = function (key: string, samlConfigs: SAMLConfiguration): boolean { + const needsCert = samlConfigs.signatureValidationType !== 'None'; + + if (!samlConfigs.secret.cert && needsCert) { + SAMLUtils.log({ msg: 'SAML Configuration missing Custom Certificate setting', key }); + return false; + } + + return true; +}; + +const configureSamlService = function (samlConfigs: Record): IServiceProviderOptions { + let privateCert = null; + let privateKey = null; + const rawCustomAuthnContext = samlConfigs.customAuthnContext; + const normalizedCustomAuthnContext = typeof rawCustomAuthnContext === 'string' ? rawCustomAuthnContext.trim() : rawCustomAuthnContext; + const customAuthnContext = typeof normalizedCustomAuthnContext === 'string' ? normalizedCustomAuthnContext : defaultAuthnContext; + + if (samlConfigs.secret.privateKey && samlConfigs.secret.publicCert) { + privateKey = samlConfigs.secret.privateKey; + privateCert = samlConfigs.secret.publicCert; + } else if (samlConfigs.secret.privateKey || samlConfigs.secret.publicCert) { + SAMLUtils.error('SAML Service: You must specify both cert and key files.'); + } + + SAMLUtils.updateGlobalSettings(samlConfigs); + + return { + provider: samlConfigs.clientConfig.provider, + entryPoint: samlConfigs.entryPoint, + idpSLORedirectURL: samlConfigs.idpSLORedirectURL, + issuer: samlConfigs.issuer, + cert: samlConfigs.secret.cert, + privateCert, + privateKey, + signatureAlgorithm: samlConfigs.secret.algorithm, + customAuthnContext, + authnContextComparison: samlConfigs.authnContextComparison, + defaultUserRole: samlConfigs.defaultUserRole, + allowedClockDrift: parseInt(samlConfigs.allowedClockDrift) || 0, + signatureValidationType: samlConfigs.signatureValidationType, + validateLogoutRequestSignature: samlConfigs.validateLogoutRequestSignature, + validateLogoutResponseSignature: samlConfigs.validateLogoutResponseSignature, + identifierFormat: samlConfigs.identifierFormat, + nameIDPolicyTemplate: samlConfigs.nameIDPolicyTemplate, + authnContextTemplate: samlConfigs.authnContextTemplate, + authRequestTemplate: samlConfigs.authRequestTemplate, + logoutResponseTemplate: samlConfigs.logoutResponseTemplate, + logoutRequestTemplate: samlConfigs.logoutRequestTemplate, + metadataCertificateTemplate: samlConfigs.metadataCertificateTemplate, + metadataTemplate: samlConfigs.metadataTemplate, + callbackUrl: Meteor.absoluteUrl(`_saml/validate/${samlConfigs.clientConfig.provider}`), + }; +}; + +export const loadSamlServiceProviders = async function (): Promise { + const serviceName = 'saml'; + const services = settings.getByRegexp(/^(SAML_Custom_)[a-z]+$/i); + + if (!services) { + return SAMLUtils.setServiceProvidersList([]); + } + + const providers = ( + await Promise.all( + services.map(async ([key, value]) => { + if (value === true) { + const samlConfigs = getSamlConfigs(key); + + if (isValidConfiguration(key, samlConfigs)) { + SAMLUtils.log({ msg: 'Loading SAML Provider', key }); + await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); + void notifyOnLoginServiceConfigurationChangedByService(serviceName); + return configureSamlService(samlConfigs); + } + + SAMLUtils.logger?.warn({ msg: 'SAML Provider not loaded due to invalid configuration', key }); + } + + const service = await LoginServiceConfiguration.removeByService(serviceName); + if (!service) { + return false; + } + + void notifyOnLoginServiceConfigurationChanged({ _id: service._id }, 'removed'); + + return false; + }), + ) + ).filter((e) => e) as IServiceProviderOptions[]; + + SAMLUtils.setServiceProvidersList(providers); +}; + +export const addSamlService = function (name: string): void { + SystemLogger.warn({ + msg: 'Adding SAML service is deprecated', + serviceName: name, + }); +}; + +export const addSettings = async function (name: string): Promise { + await settingsRegistry.addGroup('SAML', async function () { + await this.with( + { + tab: 'SAML_Connection', + }, + async function () { + await this.add(`SAML_Custom_${name}`, false, { + type: 'boolean', + i18nLabel: 'Accounts_OAuth_Custom_Enable', + public: true, + alert: 'Premium_required_from_9_0_0_alert', + }); + await this.add(`SAML_Custom_${name}_provider`, 'provider-name', { + type: 'string', + i18nLabel: 'SAML_Custom_Provider', + public: true, + }); + await this.add(`SAML_Custom_${name}_entry_point`, 'https://example.com/simplesaml/saml2/idp/SSOService.php', { + type: 'string', + i18nLabel: 'SAML_Custom_Entry_point', + }); + await this.add(`SAML_Custom_${name}_idp_slo_redirect_url`, 'https://example.com/simplesaml/saml2/idp/SingleLogoutService.php', { + type: 'string', + i18nLabel: 'SAML_Custom_IDP_SLO_Redirect_URL', + public: true, + }); + await this.add(`SAML_Custom_${name}_issuer`, 'https://your-rocket-chat/_saml/metadata/provider-name', { + type: 'string', + i18nLabel: 'SAML_Custom_Issuer', + }); + await this.add(`SAML_Custom_${name}_debug`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_Debug', + }); + + await this.section('SAML_Section_2_Certificate', async function () { + await this.add(`SAML_Custom_${name}_cert`, '', { + type: 'string', + i18nLabel: 'SAML_Custom_Cert', + multiline: true, + secret: true, + }); + await this.add(`SAML_Custom_${name}_public_cert`, '', { + type: 'string', + multiline: true, + i18nLabel: 'SAML_Custom_Public_Cert', + }); + await this.add(`SAML_Custom_${name}_signature_validation_type`, 'All', { + type: 'select', + values: [ + { key: 'None', i18nLabel: 'SAML_Custom_signature_validation_none' }, + { key: 'Response', i18nLabel: 'SAML_Custom_signature_validation_response' }, + { key: 'Assertion', i18nLabel: 'SAML_Custom_signature_validation_assertion' }, + { key: 'Either', i18nLabel: 'SAML_Custom_signature_validation_either' }, + { key: 'All', i18nLabel: 'SAML_Custom_signature_validation_all' }, + ], + i18nLabel: 'SAML_Custom_signature_validation_type', + i18nDescription: 'SAML_Custom_signature_validation_type_description', + }); + await this.add(`SAML_Custom_${name}_validate_logout_request_signature`, true, { + type: 'boolean', + i18nLabel: 'SAML_Custom_validate_logout_request_signature', + }); + await this.add(`SAML_Custom_${name}_validate_logout_response_signature`, true, { + type: 'boolean', + i18nLabel: 'SAML_Custom_validate_logout_response_signature', + }); + await this.add(`SAML_Custom_${name}_private_key`, '', { + type: 'string', + multiline: true, + i18nLabel: 'SAML_Custom_Private_Key', + secret: true, + }); + await this.add(`SAML_Custom_${name}_signature_algorithm`, 'SHA1', { + type: 'select', + values: [ + { key: 'SHA1', i18nLabel: 'SHA1' }, + { key: 'SHA256', i18nLabel: 'SHA256' }, + { key: 'SHA384', i18nLabel: 'SHA384' }, + { key: 'SHA512', i18nLabel: 'SHA512' }, + ], + i18nLabel: 'SAML_Custom_Signature_Algorithm', + i18nDescription: 'SAML_Custom_Signature_Algorithm_description', + secret: true, + }); + }); + }, + ); + + await this.with( + { + tab: 'SAML_General', + }, + async function () { + await this.section('SAML_Section_1_User_Interface', async function () { + await this.add(`SAML_Custom_${name}_button_label_text`, 'SAML', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + }); + await this.add(`SAML_Custom_${name}_button_label_color`, '#FFFFFF', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + alert: 'OAuth_button_colors_alert', + }); + await this.add(`SAML_Custom_${name}_button_color`, '#1d74f5', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + alert: 'OAuth_button_colors_alert', + }); + }); + + await this.section('SAML_Section_3_Behavior', async function () { + // Settings to customize behavior + await this.add(`SAML_Custom_${name}_generate_username`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_Generate_Username', + }); + await this.add(`SAML_Custom_${name}_username_normalize`, 'None', { + type: 'select', + values: [ + { key: 'None', i18nLabel: 'SAML_Custom_Username_Normalize_None' }, + { key: 'Lowercase', i18nLabel: 'SAML_Custom_Username_Normalize_Lowercase' }, + ], + i18nLabel: 'SAML_Custom_Username_Normalize', + }); + await this.add(`SAML_Custom_${name}_immutable_property`, 'EMail', { + type: 'select', + values: [ + { key: 'Username', i18nLabel: 'SAML_Custom_Immutable_Property_Username' }, + { key: 'EMail', i18nLabel: 'SAML_Custom_Immutable_Property_EMail' }, + ], + i18nLabel: 'SAML_Custom_Immutable_Property', + }); + await this.add(`SAML_Custom_${name}_name_overwrite`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_name_overwrite', + }); + await this.add(`SAML_Custom_${name}_mail_overwrite`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_mail_overwrite', + }); + await this.add(`SAML_Custom_${name}_logout_behaviour`, 'SAML', { + type: 'select', + values: [ + { key: 'SAML', i18nLabel: 'SAML_Custom_Logout_Behaviour_Terminate_SAML_Session' }, + { key: 'Local', i18nLabel: 'SAML_Custom_Logout_Behaviour_End_Only_RocketChat' }, + ], + i18nLabel: 'SAML_Custom_Logout_Behaviour', + public: true, + }); + await this.add(`SAML_Custom_${name}_channels_update`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_channels_update', + i18nDescription: 'SAML_Custom_channels_update_description', + }); + await this.add(`SAML_Custom_${name}_include_private_channels_update`, false, { + type: 'boolean', + i18nLabel: 'SAML_Custom_include_private_channels_update', + i18nDescription: 'SAML_Custom_include_private_channels_update_description', + }); + + await this.add(`SAML_Custom_${name}_default_user_role`, 'user', { + type: 'string', + i18nLabel: 'SAML_Default_User_Role', + i18nDescription: 'SAML_Default_User_Role_Description', + }); + + await this.add(`SAML_Custom_${name}_allowed_clock_drift`, 0, { + type: 'int', + invalidValue: 0, + i18nLabel: 'SAML_Allowed_Clock_Drift', + i18nDescription: 'SAML_Allowed_Clock_Drift_Description', + }); + }); + + await this.section('SAML_Section_5_Mapping', async function () { + // Data Mapping Settings + await this.add(`SAML_Custom_${name}_user_data_fieldmap`, '{"username":"username", "email":"email", "name": "cn"}', { + type: 'string', + i18nLabel: 'SAML_Custom_user_data_fieldmap', + i18nDescription: 'SAML_Custom_user_data_fieldmap_description', + multiline: true, + }); + }); + }, + ); + + SAMLUtils.events.emit('addSettings', name); + }); +}; diff --git a/apps/meteor/server/lib/saml/lib/signature/signatureAlgorithms.ts b/apps/meteor/server/lib/saml/lib/signature/signatureAlgorithms.ts new file mode 100644 index 0000000..90e0a60 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/signature/signatureAlgorithms.ts @@ -0,0 +1,38 @@ +export const signatureAlgorithms = { + 'RSA-SHA1': 'http://www.w3.org/2000/09/xmldsig#rsa-sha1', + 'RSA-SHA256': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'RSA-SHA384': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384', + 'RSA-SHA512': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512', +} as const; + +export type SigAlgKey = keyof typeof signatureAlgorithms; + +export type SigAlgURI = (typeof signatureAlgorithms)[SigAlgKey]; + +export function getSigAlgKeyIfSupported(key: SigAlgKey): SigAlgKey; +export function getSigAlgKeyIfSupported(key: string): SigAlgKey | null; +export function getSigAlgKeyIfSupported(key: string): SigAlgKey | null { + if (key in signatureAlgorithms) { + return key as keyof typeof signatureAlgorithms; + } + + return null; +} + +export function getSigAlgKeyByURI(algURI: SigAlgURI): SigAlgKey; +export function getSigAlgKeyByURI(algURI: string): SigAlgKey | null; +export function getSigAlgKeyByURI(algURI: string): SigAlgKey | null { + for (const [key, uri] of Object.entries(signatureAlgorithms)) { + if (uri === algURI) { + return key as SigAlgKey; + } + } + + return null; +} + +export function getSigAlgURIByKey(key: SigAlgKey): SigAlgURI; +export function getSigAlgURIByKey(key: string): SigAlgURI | null; +export function getSigAlgURIByKey(key: string): SigAlgURI | null { + return signatureAlgorithms[key as SigAlgKey] ?? null; +} diff --git a/apps/meteor/server/lib/saml/lib/signature/validateRedirectSignature.ts b/apps/meteor/server/lib/saml/lib/signature/validateRedirectSignature.ts new file mode 100644 index 0000000..31c57ce --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/signature/validateRedirectSignature.ts @@ -0,0 +1,44 @@ +import crypto from 'node:crypto'; + +import type { SAMLRedirectEnvelope } from '../../definition/SAMLEnvelope'; +import { SAMLUtils } from '../Utils'; +import { getSigAlgKeyByURI } from './signatureAlgorithms'; + +export function validateRedirectSignature(envelope: SAMLRedirectEnvelope, certificate: string): boolean { + try { + if (!envelope.sigAlg || !envelope.signature) { + SAMLUtils.log({ msg: 'Could not validate SAML Document Signature: Missing Signature params', type: envelope.type }); + return false; + } + + if (!envelope.signedContent) { + SAMLUtils.log({ msg: 'Could not validate SAML Document signature: signedContent is empty', type: envelope.type }); + return false; + } + + const algorithm = getSigAlgKeyByURI(envelope.sigAlg); + if (!algorithm) { + SAMLUtils.log({ msg: 'Could not validate SAML Document signature: invalid algorithm', type: envelope.type }); + return false; + } + + const cert = SAMLUtils.certToPEM(certificate); + + SAMLUtils.log({ + msg: 'Verifying Signatures for SAML Document', + algorithm, + cert, + type: envelope.type, + }); + const verifier = crypto.createVerify(algorithm); + + verifier.update(envelope.signedContent, 'utf8'); + verifier.end(); + + return verifier.verify(cert, envelope.signature, 'base64'); + } catch (err) { + SAMLUtils.error({ msg: 'Failed to validate SAML Document Signature', err }); + } + + return false; +} diff --git a/apps/meteor/server/lib/saml/listener.ts b/apps/meteor/server/lib/saml/listener.ts new file mode 100644 index 0000000..23e7fd5 --- /dev/null +++ b/apps/meteor/server/lib/saml/listener.ts @@ -0,0 +1,90 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import bodyParser from 'body-parser'; +import express from 'express'; +import { Meteor } from 'meteor/meteor'; +import { RoutePolicy } from 'meteor/routepolicy'; +import { WebApp } from 'meteor/webapp'; + +import type { ISAMLAction } from './definition/ISAMLAction'; +import { SAML } from './lib/SAML'; +import { SAMLUtils } from './lib/Utils'; +import { SystemLogger } from '../logger/system'; + +RoutePolicy.declare('/_saml/', 'network'); + +const samlUrlToObject = function (url: string | undefined): ISAMLAction | null { + // req.url will be '/_saml///' + if (!url) { + return null; + } + + const splitUrl = url.split('?'); + const splitPath = splitUrl[0].split('/'); + + // Any non-saml request will continue down the default + // middlewares. + if (splitPath[1] !== '_saml') { + return null; + } + + const result = { + actionName: splitPath[2], + serviceName: splitPath[3], + credentialToken: splitPath[4], + }; + + SAMLUtils.log(result); + return result; +}; + +const middleware = async function (req: express.Request, res: ServerResponse, next: (err?: any) => void): Promise { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + const samlObject = samlUrlToObject(req.originalUrl); + if (!samlObject?.serviceName) { + next(); + return; + } + + if (!samlObject.actionName) { + throw new Error('Missing SAML action'); + } + + const service = SAMLUtils.getServiceProviderOptions(samlObject.serviceName); + if (!service) { + SystemLogger.error({ + msg: 'SAML service provider not found', + serviceName: samlObject.serviceName, + }); + throw new Error('SAML Service Provider not found.'); + } + + await SAML.processRequest(req, res, service, samlObject); + } catch (err) { + // @ToDo: Ideally we should send some error message to the client, but there's no way to do it on a redirect right now. + SystemLogger.error({ err }); + + if (!res.headersSent) { + const url = Meteor.absoluteUrl('home'); + res.writeHead(302, { + Location: url, + }); + res.end(); + } + } +}; + +// Listen to incoming SAML http requests +// In Meteor 3 connectHandlers is an express app (multi-middleware use), but +// @types/meteor still types it as connect.Server (1-2 args). +(WebApp.connectHandlers as unknown as express.Application).use( + /^\/_saml/, + bodyParser.json(), + express.urlencoded({ + extended: true, + limit: '50mb', + }), + async (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) => middleware(req as express.Request, res, next), +); diff --git a/apps/meteor/server/lib/saml/loginHandler.ts b/apps/meteor/server/lib/saml/loginHandler.ts new file mode 100644 index 0000000..63b9593 --- /dev/null +++ b/apps/meteor/server/lib/saml/loginHandler.ts @@ -0,0 +1,68 @@ +import { CredentialTokens } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from '../i18n'; +import { SAML } from './lib/SAML'; +import { SAMLUtils } from './lib/Utils'; +import { SystemLogger } from '../logger/system'; +import { warnUnlicensedAuthService } from '../premiumAuthDeprecation'; + +const makeError = (message: string): Record => ({ + type: 'saml', + error: new Meteor.Error(Accounts.LoginCancelledError.numericError, message), +}); + +Accounts.registerLoginHandler('saml', async (loginRequest) => { + if ( + !loginRequest.saml || + !loginRequest.credentialToken || + typeof loginRequest.credentialToken !== 'string' || + SAMLUtils.serviceProviders.length === 0 + ) { + return undefined; + } + + warnUnlicensedAuthService('SAML', 'saml-enterprise'); + + const loginResult = await SAML.retrieveCredential(loginRequest.credentialToken); + + await CredentialTokens.removeById(loginRequest.credentialToken); + SAMLUtils.log({ msg: 'RESULT', loginResult }); + + if (!loginResult) { + return makeError('No matching login attempt found'); + } + + if (!loginResult.profile) { + return makeError('No profile information found'); + } + + try { + const userObject = SAMLUtils.mapProfileToUserObject(loginResult.profile); + const updatedUser = await SAML.insertOrUpdateSAMLUser(userObject); + SAMLUtils.events.emit('updateCustomFields', loginResult, updatedUser); + + return updatedUser; + } catch (err: any) { + SystemLogger.error({ err }); + + let message = err.toString(); + let errorCode = ''; + + if (err instanceof Meteor.Error) { + errorCode = (err.error || err.message) as string; + } else if (err instanceof Error) { + errorCode = err.message; + } + + if (errorCode) { + const localizedMessage = i18n.t(errorCode); + if (localizedMessage && localizedMessage !== errorCode) { + message = localizedMessage; + } + } + + return makeError(message); + } +}); diff --git a/apps/meteor/server/lib/saml/startup.ts b/apps/meteor/server/lib/saml/startup.ts new file mode 100644 index 0000000..ff09d46 --- /dev/null +++ b/apps/meteor/server/lib/saml/startup.ts @@ -0,0 +1,16 @@ +import { Logger } from '@rocket.chat/logger'; +import debounce from 'lodash.debounce'; +import { Meteor } from 'meteor/meteor'; + +import { SAMLUtils } from './lib/Utils'; +import { loadSamlServiceProviders, addSettings } from './lib/settings'; +import { settings } from '../../settings'; + +const logger = new Logger('steffo:meteor-accounts-saml'); +SAMLUtils.setLoggerInstance(logger); + +Meteor.startup(async () => { + await addSettings('Default'); +}); + +settings.watchByRegex(/^SAML_.+/, debounce(loadSamlServiceProviders, 2000)); diff --git a/apps/meteor/server/lib/search/events/EventService.ts b/apps/meteor/server/lib/search/events/EventService.ts new file mode 100644 index 0000000..14bee9b --- /dev/null +++ b/apps/meteor/server/lib/search/events/EventService.ts @@ -0,0 +1,19 @@ +import { SearchLogger } from '../logger/logger'; +import { searchProviderService } from '../service'; + +export class EventService { + _pushError(name: string, value: string, _payload?: unknown) { + // TODO implement a (performant) cache + SearchLogger.debug({ + msg: 'Error on event', + eventName: name, + eventId: value, + }); + } + + promoteEvent(name: string, value: string, payload?: unknown) { + if (!searchProviderService.activeProvider?.on(name, value)) { + this._pushError(name, value, payload); + } + } +} diff --git a/apps/meteor/server/lib/search/events/index.ts b/apps/meteor/server/lib/search/events/index.ts new file mode 100644 index 0000000..1fddb95 --- /dev/null +++ b/apps/meteor/server/lib/search/events/index.ts @@ -0,0 +1,32 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +import { EventService } from './EventService'; +import { settings } from '../../../settings'; +import { callbacks } from '../../callbacks'; +import { searchProviderService } from '../service'; + +export const searchEventService = new EventService(); + +/** + * Listen to message changes via Hooks + */ +function afterSaveMessage(m: IMessage) { + searchEventService.promoteEvent('message.save', m._id, m); + return m; +} + +function afterDeleteMessage(m: IMessage) { + searchEventService.promoteEvent('message.delete', m._id); + return m; +} + +settings.watch('Search.Provider', () => { + if (searchProviderService.activeProvider?.on) { + callbacks.add('afterSaveMessage', afterSaveMessage, callbacks.priority.MEDIUM, 'search-events'); + callbacks.add('afterDeleteMessage', afterDeleteMessage, callbacks.priority.MEDIUM, 'search-events-delete'); + return; + } + + callbacks.remove('afterSaveMessage', 'search-events'); + callbacks.remove('afterDeleteMessage', 'search-events-delete'); +}); diff --git a/apps/meteor/server/lib/search/logger/logger.ts b/apps/meteor/server/lib/search/logger/logger.ts new file mode 100644 index 0000000..ac103d5 --- /dev/null +++ b/apps/meteor/server/lib/search/logger/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const SearchLogger = new Logger('Search Logger'); diff --git a/apps/meteor/server/lib/search/model/ISearchResult.ts b/apps/meteor/server/lib/search/model/ISearchResult.ts new file mode 100644 index 0000000..cc80531 --- /dev/null +++ b/apps/meteor/server/lib/search/model/ISearchResult.ts @@ -0,0 +1,29 @@ +import type { IMessage, IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; + +export type IRawSearchResult = { + message?: { docs: IMessage[] }; + room?: { docs: IRoom[] }; +}; + +export type ISearchResult = { + message?: { + docs: (IMessage & { + user: IUser['_id']; + rid: IRoom['_id']; + r?: { + name: string | undefined; + t: RoomType; + }; + username?: string; + valid?: boolean; + })[]; + }; + room?: { + docs: { + _id: IRoom['_id']; + valid?: boolean; + t?: RoomType; + name?: string; + }[]; + }; +}; diff --git a/apps/meteor/server/lib/search/model/SearchProvider.ts b/apps/meteor/server/lib/search/model/SearchProvider.ts new file mode 100644 index 0000000..c4b1ac6 --- /dev/null +++ b/apps/meteor/server/lib/search/model/SearchProvider.ts @@ -0,0 +1,117 @@ +import type { IMessageSearchSuggestion, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +import type { IRawSearchResult } from './ISearchResult'; +import { Settings } from './Settings'; +import { SearchLogger } from '../logger/logger'; + +export abstract class SearchProvider { + private _key: string; + + protected _settings: Settings; + + /** + * Create search provider, key must match /^[A-Za-z0-9]+$/ + */ + constructor(key: string) { + if (!key.match(/^[A-Za-z0-9]+$/)) { + throw new Error(`cannot instantiate provider: ${key} does not match key-pattern`); + } + + SearchLogger.info({ + msg: 'create search provider', + providerKey: key, + }); + + this._key = key; + this._settings = new Settings(key); + } + + /* --- basic params ---*/ + get key() { + return this._key; + } + + abstract get i18nLabel(): TranslationKey; + + abstract get i18nDescription(): TranslationKey; + + get iconName() { + return 'magnifier'; + } + + get settings() { + return this._settings.list(); + } + + get settingsAsMap() { + return this._settings.map(); + } + + /* --- templates ---*/ + get resultTemplate() { + return 'DefaultSearchResultTemplate'; + } + + get suggestionItemTemplate() { + return 'DefaultSuggestionItemTemplate'; + } + + /* --- search functions ---*/ + /** + * Search using the current search provider and check if results are valid for the user. The search result has + * the format {messages:{start:0,numFound:1,docs:[{...}]},users:{...},rooms:{...}} + * @param text the search text + * @param context the context (uid, rid) + * @param payload custom payload (e.g. for paging) + * @param callback is used to return result an can be called with (error,result) + */ + public abstract search( + userId: string, + text: string, + context: { uid?: IUser['_id']; rid: IRoom['_id'] }, + payload?: TPayload, + callback?: (error: Error | null, result: IRawSearchResult) => void, + ): Promise; + + /** + * Returns an ordered list of suggestions. The result should have at least the form [{text:string}] + * @param _text the search text + * @param _context the context (uid, rid) + * @param _payload custom payload (e.g. for paging) + * @param callback is used to return result an can be called with (error,result) + */ + suggest( + _text: string, + _context: { uid?: IUser['_id']; rid: IRoom['_id'] }, + _payload: TPayload, + callback?: (error: Error | null, result: IMessageSearchSuggestion[]) => void, + ) { + callback?.(null, []); + } + + get supportsSuggestions() { + return false; + } + + /* --- triggers ---*/ + on(_name: string, _value: unknown) { + return true; + } + + /* --- livecycle ---*/ + run(reason: any) { + return new Promise((resolve, reject) => { + this._settings.load(); + this.start(reason, resolve, reject); + }); + } + + start(_reason: any, resolve: (value: unknown) => void, _reject: (reason?: any) => void) { + resolve(undefined); + } + + stop(resolve: () => void) { + resolve(); + } +} diff --git a/apps/meteor/server/lib/search/model/Setting.ts b/apps/meteor/server/lib/search/model/Setting.ts new file mode 100644 index 0000000..ec71a63 --- /dev/null +++ b/apps/meteor/server/lib/search/model/Setting.ts @@ -0,0 +1,48 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; + +import { settings } from '../../../settings'; + +/** + * Setting Object in order to manage settings loading for providers and admin ui display + */ +export class Setting { + private readonly _basekey: string; + + public readonly key: string; + + public readonly type: string; + + public readonly defaultValue: SettingValue; + + public readonly options: Record; + + private _value: SettingValue; + + constructor(basekey: string, key: string, type: string, defaultValue: SettingValue, options = {}) { + this._basekey = basekey; + this.key = key; + this.type = type; + this.defaultValue = defaultValue; + this.options = options; + this._value = undefined; + } + + get value() { + return this._value; + } + + /** + * Id is generated based on baseKey and key + */ + get id() { + return `Search.${this._basekey}.${this.key}`; + } + + load() { + this._value = settings.get(this.id); + + if (this._value === undefined) { + this._value = this.defaultValue; + } + } +} diff --git a/apps/meteor/server/lib/search/model/Settings.ts b/apps/meteor/server/lib/search/model/Settings.ts new file mode 100644 index 0000000..f1b5aeb --- /dev/null +++ b/apps/meteor/server/lib/search/model/Settings.ts @@ -0,0 +1,45 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; + +import { Setting } from './Setting'; + +/** + * Settings Object allows to manage Setting Objects + */ +export class Settings { + private settings: Record; + + constructor(public basekey: string) { + this.settings = {}; + } + + add(key: string, type: string, defaultValue: SettingValue, options: Record) { + this.settings[key] = new Setting(this.basekey, key, type, defaultValue, options); + } + + list() { + return Object.keys(this.settings).map((key) => this.settings[key]); + } + + map() { + return this.settings; + } + + /** + * return the value for key + */ + get(key: string) { + if (!this.settings[key]) { + throw new Error('Setting is not set'); + } + return this.settings[key].value as TValue; + } + + /** + * load currently stored values of all settings + */ + load() { + Object.keys(this.settings).forEach((key) => { + this.settings[key].load(); + }); + } +} diff --git a/apps/meteor/server/lib/search/provider/DefaultProvider.ts b/apps/meteor/server/lib/search/provider/DefaultProvider.ts new file mode 100644 index 0000000..2bbef9a --- /dev/null +++ b/apps/meteor/server/lib/search/provider/DefaultProvider.ts @@ -0,0 +1,52 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { messageSearch } from '../../../meteor-methods/messages/messageSearch'; +import type { IRawSearchResult } from '../model/ISearchResult'; +import { SearchProvider } from '../model/SearchProvider'; + +/** + * Implements the default provider (based on mongo db search) + */ +export class DefaultProvider extends SearchProvider<{ searchAll?: boolean; limit?: number }> { + /** + * Enable settings: GlobalSearchEnabled, PageSize + */ + constructor() { + super('defaultProvider'); + this._settings.add('GlobalSearchEnabled', 'boolean', false, { + i18nLabel: 'Global_Search', + alert: 'This feature is currently in beta and could decrease the application performance', + }); + this._settings.add('PageSize', 'int', 10, { + i18nLabel: 'Search_Page_Size', + }); + } + + get i18nLabel() { + return 'Default_provider' as const; + } + + get i18nDescription() { + return 'You_can_search_using_RegExp_eg' as const; + } + + /** + * Uses Meteor function 'messageSearch' + */ + async search( + userId: string, + text: string, + context: { uid?: IUser['_id']; rid: IRoom['_id'] }, + payload: { searchAll?: boolean; limit?: number } = {}, + callback?: (error: Error | null, result: IRawSearchResult) => void, + ): Promise { + const _rid = payload.searchAll ? undefined : context.rid; + + const _limit = payload.limit || this._settings.get('PageSize'); + + const result = await messageSearch(userId, text, _rid, _limit); + if (callback && result !== false) { + return callback(null, result); + } + } +} diff --git a/apps/meteor/server/lib/search/register.ts b/apps/meteor/server/lib/search/register.ts new file mode 100644 index 0000000..809dce9 --- /dev/null +++ b/apps/meteor/server/lib/search/register.ts @@ -0,0 +1,5 @@ +import { DefaultProvider } from './provider/DefaultProvider'; +import { searchProviderService } from './service'; + +// register provider +searchProviderService.register(new DefaultProvider()); diff --git a/apps/meteor/server/lib/search/search.internalService.ts b/apps/meteor/server/lib/search/search.internalService.ts new file mode 100644 index 0000000..3e28e6c --- /dev/null +++ b/apps/meteor/server/lib/search/search.internalService.ts @@ -0,0 +1,45 @@ +import { api, ServiceClassInternal } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; + +import { searchEventService } from './events'; +import { searchProviderService } from './service'; +import { settings } from '../../settings'; + +class Search extends ServiceClassInternal { + protected name = 'search'; + + protected override internal = true; + + constructor() { + super(); + + this.onEvent('watch.users', async ({ clientAction, id, ...rest }) => { + if (clientAction === 'removed') { + searchEventService.promoteEvent('user.delete', id, undefined); + return; + } + + const user = ('data' in rest && rest.data) || (await Users.findOneById(id)); + searchEventService.promoteEvent('user.save', id, user); + }); + + this.onEvent('watch.rooms', async ({ clientAction, room }) => { + if (clientAction === 'removed') { + searchEventService.promoteEvent('room.delete', room._id, undefined); + return; + } + + searchEventService.promoteEvent('room.save', room._id, room); + }); + } +} + +const service = new Search(); + +settings.watch('Search.Provider', async () => { + if (searchProviderService.activeProvider?.on) { + api.registerService(service); + } else { + await api.destroyService(service); + } +}); diff --git a/apps/meteor/server/lib/search/service/SearchProviderService.ts b/apps/meteor/server/lib/search/service/SearchProviderService.ts new file mode 100644 index 0000000..8ce02c7 --- /dev/null +++ b/apps/meteor/server/lib/search/service/SearchProviderService.ts @@ -0,0 +1,122 @@ +import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; +import { settings, settingsRegistry } from '../../../settings'; +import { SearchLogger } from '../logger/logger'; +import type { SearchProvider } from '../model/SearchProvider'; + +export class SearchProviderService { + public providers: Record = {}; + + public activeProvider?: SearchProvider; + + /** + * Stop current provider (if there is one) and start the new + * @param id the id of the provider which should be started + */ + async use(id: SearchProvider['key']) { + if (!this.providers[id]) { + throw new Error(`provider ${id} cannot be found`); + } + + let reason: 'startup' | 'update' | 'switch'; + + if (!this.activeProvider) { + reason = 'startup'; + } else if (this.activeProvider.key === this.providers[id].key) { + reason = 'update'; + } else { + reason = 'switch'; + } + + if (this.activeProvider) { + const provider = this.activeProvider; + SearchLogger.debug({ + msg: 'Stopping provider', + providerKey: provider.key, + }); + + await new Promise((resolve) => provider.stop(resolve)); + } + + this.activeProvider = undefined; + + SearchLogger.debug({ + msg: 'Start provider', + providerKey: id, + }); + + await this.providers[id].run(reason); + this.activeProvider = this.providers[id]; + } + + /** + * Registers a search provider on system startup + */ + register(provider: SearchProvider) { + this.providers[provider.key] = provider; + } + + /** + * Starts the service (loads provider settings for admin ui, add lister not setting changes, enable current provider + */ + async start() { + SearchLogger.debug('Load data for all providers'); + + const { providers } = this; + + // add settings for admininistration + await settingsRegistry.addGroup('Search', async function () { + await this.add('Search.Provider', 'defaultProvider', { + type: 'select', + values: Object.values(providers).map((provider) => ({ + key: provider.key, + i18nLabel: provider.i18nLabel, + })), + public: true, + i18nLabel: 'Search_Provider', + }); + + await Promise.all( + Object.keys(providers) + .filter((key) => providers[key].settings && providers[key].settings.length > 0) + .map(async (key) => { + await this.section(providers[key].i18nLabel, async function () { + await Promise.all( + providers[key].settings.map(async (setting) => { + const _options: Record = { + type: setting.type, + ...setting.options, + }; + + _options.enableQuery = _options.enableQuery || []; + + if (!_options.enableQuery) { + _options.enableQuery = []; + } + + if (Array.isArray(_options.enableQuery)) { + _options.enableQuery.push({ + _id: 'Search.Provider', + value: key, + }); + } + + await this.add(setting.id, setting.defaultValue, _options); + }), + ); + }); + }), + ); + }); + + // add listener to react on setting changes + const configProvider = withDebouncing({ wait: 1000 })(() => { + const providerId = settings.get('Search.Provider'); + + if (providerId) { + void this.use(providerId); // TODO do something with success and errors + } + }); + + settings.watchByRegex(/^Search\./, configProvider); + } +} diff --git a/apps/meteor/server/lib/search/service/SearchResultValidationService.ts b/apps/meteor/server/lib/search/service/SearchResultValidationService.ts new file mode 100644 index 0000000..d46ac0a --- /dev/null +++ b/apps/meteor/server/lib/search/service/SearchResultValidationService.ts @@ -0,0 +1,112 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Users, Rooms } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import mem from 'mem'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../authorization'; +import { SearchLogger } from '../logger/logger'; +import type { IRawSearchResult, ISearchResult } from '../model/ISearchResult'; + +export class SearchResultValidationService { + private getSubscription = mem(async (rid: IRoom['_id'], uid?: IUser['_id']) => { + if (!rid) { + return; + } + + const room = await Rooms.findOneById(rid); + if (!room) { + return; + } + + if (!uid || !(await canAccessRoomAsync(room, { _id: uid }))) { + return; + } + + return room; + }); + + private getUser = mem(async (uid: IUser['_id']) => { + if (!uid) { + return; + } + + return Users.findOneById(uid, { projection: { username: 1 } }); + }); + + async validateSearchResult(result: IRawSearchResult): Promise { + const uid = Meteor.userId() ?? undefined; + + const validatedResult: ISearchResult = {}; + + // get subscription for message + if (result.message) { + validatedResult.message = { + docs: ( + await Promise.all( + result.message.docs.map(async (msg) => { + const user = await this.getUser(msg.u._id); + const subscription = await this.getSubscription(msg.rid, uid); + + if (subscription) { + SearchLogger.debug({ + msg: 'User can access message', + userId: uid, + roomId: msg.rid, + }); + return { + ...msg, + user: msg.u._id, + r: { name: subscription.name, t: subscription.t }, + username: user?.username, + valid: true, + }; + } + + SearchLogger.debug({ + msg: 'User cannot access message', + userId: uid, + roomId: msg.rid, + }); + return undefined; + }), + ) + ).filter(isTruthy), + }; + } + + if (result.room) { + result.room.docs = ( + await Promise.all( + result.room.docs.map(async (room) => { + const subscription = await this.getSubscription(room._id, uid); + + if (!subscription) { + SearchLogger.debug({ + msg: 'User cannot access room', + userId: uid, + roomId: room._id, + }); + return undefined; + } + + SearchLogger.debug({ + msg: 'User can access room', + userId: uid, + roomId: room._id, + }); + + return { + ...room, + valid: true, + t: subscription.t, + name: subscription.name, + }; + }), + ) + ).filter(isTruthy); + } + + return validatedResult; + } +} diff --git a/apps/meteor/server/lib/search/service/index.ts b/apps/meteor/server/lib/search/service/index.ts new file mode 100644 index 0000000..9bf4ca1 --- /dev/null +++ b/apps/meteor/server/lib/search/service/index.ts @@ -0,0 +1,5 @@ +import { SearchProviderService } from './SearchProviderService'; +import { SearchResultValidationService } from './SearchResultValidationService'; + +export const searchProviderService = new SearchProviderService(); +export const validationService = new SearchResultValidationService(); diff --git a/apps/meteor/server/lib/search/startup.ts b/apps/meteor/server/lib/search/startup.ts new file mode 100644 index 0000000..8ca6c74 --- /dev/null +++ b/apps/meteor/server/lib/search/startup.ts @@ -0,0 +1,7 @@ +import { Meteor } from 'meteor/meteor'; + +import { searchProviderService } from './service'; + +Meteor.startup(async () => { + await searchProviderService.start(); +}); diff --git a/apps/meteor/server/lib/sendDirectMessageToUsers.ts b/apps/meteor/server/lib/sendDirectMessageToUsers.ts new file mode 100644 index 0000000..d3c7638 --- /dev/null +++ b/apps/meteor/server/lib/sendDirectMessageToUsers.ts @@ -0,0 +1,34 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { SystemLogger } from './logger/system'; +import { createDirectMessage } from '../meteor-methods/messages/createDirectMessage'; +import { executeSendMessage } from '../meteor-methods/messages/sendMessage'; + +export async function sendDirectMessageToUsers( + fromId = 'rocket.cat', + toIds: string[], + messageFn: (user: Pick) => string, +): Promise { + const fromUser = await Users.findOneById(fromId, { projection: { _id: 1, username: 1 } }); + if (!fromUser) { + throw new Error(`User not found: ${fromId}`); + } + + const users = Users.findByIds(toIds, { projection: { _id: 1, username: 1, language: 1 } }); + const success: string[] = []; + + for await (const user of users) { + try { + const { rid } = await createDirectMessage([user.username], fromId); + const msg = typeof messageFn === 'function' ? messageFn(user) : messageFn; + + await executeSendMessage(fromId, { rid, msg }); + success.push(user._id); + } catch (err) { + SystemLogger.error({ err }); + } + } + + return success; +} diff --git a/apps/meteor/server/lib/sendMessagesToAdmins.ts b/apps/meteor/server/lib/sendMessagesToAdmins.ts new file mode 100644 index 0000000..0cff402 --- /dev/null +++ b/apps/meteor/server/lib/sendMessagesToAdmins.ts @@ -0,0 +1,84 @@ +import type { IUser, IMessage } from '@rocket.chat/core-typings'; +import { Roles, Users } from '@rocket.chat/models'; + +import { SystemLogger } from './logger/system'; +import { notifyOnUserChangeAsync } from './notifyListener'; +import { createDirectMessage } from '../meteor-methods/messages/createDirectMessage'; +import { executeSendMessage } from '../meteor-methods/messages/sendMessage'; + +type Banner = { + id: string; + priority: number; + title: string; + text: string; + textArguments?: string[]; + modifiers: string[]; + link: string; +}; + +const getData = async (param: T[] | ((params: { adminUser: IUser }) => Promise), adminUser: IUser): Promise => { + const result = typeof param === 'function' ? await param({ adminUser }) : param; + + if (!Array.isArray(result)) { + return [result]; + } + + return result; +}; + +export async function sendMessagesToAdmins({ + fromId = 'rocket.cat', + checkFrom = true, + msgs = [], + banners = [], +}: { + fromId?: string; + checkFrom?: boolean; + msgs?: Partial[] | ((params: { adminUser: IUser }) => Promise[] | Partial>); + banners?: Banner[] | ((params: { adminUser: IUser }) => Promise); +}): Promise { + const fromUser = checkFrom ? await Users.findOneById(fromId, { projection: { _id: 1 } }) : true; + + const users = await (await Roles.findUsersInRole('admin')).toArray(); + + const notifyAdmins: string[] = []; + + for await (const adminUser of users) { + if (fromUser) { + try { + const { rid } = await createDirectMessage([adminUser.username], fromId); + + await Promise.all( + (await getData>(msgs, adminUser)).map((msg) => executeSendMessage(fromId, Object.assign({ rid }, msg))), + ); + } catch (err) { + SystemLogger.error({ err }); + } + } + + const updates = await Promise.all( + (await getData(banners, adminUser)).map((banner) => Users.addBannerById(adminUser._id, banner)), + ); + + const hasUpdated = updates.some(({ modifiedCount }) => modifiedCount > 0); + if (hasUpdated) { + notifyAdmins.push(adminUser._id); + } + } + + if (notifyAdmins.length === 0) { + return; + } + + void notifyOnUserChangeAsync(async () => { + const results = await Users.findByIds>(notifyAdmins, { projection: { banners: 1 } }).toArray(); + + return results.map(({ _id, banners }) => ({ + id: _id, + clientAction: 'updated', + diff: { + banners, + }, + })); + }); +} diff --git a/apps/meteor/server/lib/settingValidationRules.ts b/apps/meteor/server/lib/settingValidationRules.ts new file mode 100644 index 0000000..7acbce3 --- /dev/null +++ b/apps/meteor/server/lib/settingValidationRules.ts @@ -0,0 +1,156 @@ +import type { ISetting, SettingValidationRule } from '@rocket.chat/core-typings'; +import { isSettingCode } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; +import { isRecord } from '@rocket.chat/tools'; + +import { settings } from '../settings'; +import { getSettingSchemaValidator } from '../settings/functions/settingSchemas'; + +const logger = new Logger('SettingValidation'); + +export class SettingValidationError extends Error {} + +const isAppliesWhenCondition = (value: unknown): value is { _id: ISetting['_id']; value: unknown } => + isRecord(value) && typeof value._id === 'string' && 'value' in value; + +const isValidationRule = (rule: unknown): rule is SettingValidationRule => + isRecord(rule) && + isRecord(rule.query) && + (rule.appliesWhen === undefined || + isAppliesWhenCondition(rule.appliesWhen) || + (Array.isArray(rule.appliesWhen) && rule.appliesWhen.every(isAppliesWhenCondition))); + +const isValidationRuleArray = (value: unknown): value is SettingValidationRule[] => Array.isArray(value) && value.every(isValidationRule); + +const parseValidationRules = (settingId: ISetting['_id'], validation: NonNullable): SettingValidationRule[] => { + if (typeof validation !== 'string') { + return validation; + } + + let parsed: unknown; + try { + parsed = JSON.parse(validation); + } catch (err) { + logger.error({ msg: 'Failed to parse setting validation rules', settingId, err }); + return []; + } + + if (!isValidationRuleArray(parsed)) { + logger.error({ msg: 'Setting validation rules are not well-formed', settingId }); + return []; + } + + return parsed; +}; + +const isSettingReference = (value: unknown): value is { $setting: ISetting['_id'] } => + isRecord(value) && typeof value.$setting === 'string'; + +// an empty value means the setting is unconfigured and always passes +const validatesSchema = (setting: ISetting, value: unknown): boolean => { + const validate = getSettingSchemaValidator(setting._id); + if (!validate || !isSettingCode(setting) || setting.code !== 'application/json' || value === '') { + return true; + } + + if (typeof value !== 'string') { + return false; + } + + try { + return !!validate(JSON.parse(value)); + } catch { + return false; + } +}; + +/** + * Evaluates a setting's validation rule against the value being saved. Returns `false` only when the rule's filter + * rejects the candidate. A rule that references a setting which does not exist is logged and treated as passing, so a + * broken declaration never blocks a save. + */ +const evaluateSettingValidationRule = ( + settingId: ISetting['_id'], + rule: SettingValidationRule, + get: (id: ISetting['_id']) => unknown, +): boolean => { + const references: Record = {}; + + // true when `filter` matches the current value of the setting `id` + const matches = (filter: Record, id: ISetting['_id']): boolean => + createPredicateFromFilter<{ _id: ISetting['_id']; value: unknown }>(filter)({ _id: id, value: get(id) }); + + // replaces every `{ $setting: '' }` in the filter with that setting's value, + // recording each in `references` + const resolveObject = (filter: Record): Record => + Object.fromEntries(Object.entries(filter).map(([key, value]) => [key, resolveNode(value)])); + + const resolveNode = (node: unknown): unknown => { + if (isSettingReference(node)) { + references[node.$setting] = get(node.$setting); + return references[node.$setting]; + } + if (Array.isArray(node)) { + return node.map(resolveNode); + } + if (isRecord(node)) { + return resolveObject(node); + } + return node; + }; + + const conditions = [rule.appliesWhen ?? []].flat(); + const query = resolveObject(rule.query); + for (const condition of conditions) { + references[condition._id] = get(condition._id); + } + + const unknownReferences = Object.keys(references).filter((id) => references[id] === undefined); + if (unknownReferences.length) { + logger.error({ msg: 'Setting validation rule references unknown settings', settingId, references: unknownReferences }); + return true; + } + + // the rule only applies while all of its `appliesWhen` conditions hold, + // otherwise it is not relevant and passes + const conditionHolds = (condition: { _id: ISetting['_id']; value: unknown }): boolean => + matches({ value: condition.value }, condition._id); + if (!conditions.every(conditionHolds)) { + return true; + } + + return matches(query, settingId); +}; + +export const validateSettingRules = (changes: { _id: ISetting['_id']; value: ISetting['value'] }[]): void => { + // a value being saved in this batch wins over the stored one, + // so rules never compare against stale values + const getValueOf = (id: ISetting['_id']): unknown => { + const beingSaved = changes.find(({ _id }) => _id === id); + return beingSaved ? beingSaved.value : settings.get(id); + }; + + for (const { _id, value } of changes) { + const setting = settings.getSetting(_id); + if (!setting) { + continue; + } + + if (!validatesSchema(setting, value)) { + throw new SettingValidationError(`${setting._id}_Invalid`); + } + + if (!setting.validation) { + continue; + } + + for (const rule of parseValidationRules(setting._id, setting.validation)) { + if (evaluateSettingValidationRule(setting._id, rule, getValueOf)) { + continue; + } + + throw new SettingValidationError(`${setting._id}_Invalid`); + } + } +}; diff --git a/apps/meteor/server/lib/settingsRegenerator.ts b/apps/meteor/server/lib/settingsRegenerator.ts new file mode 100644 index 0000000..afc430c --- /dev/null +++ b/apps/meteor/server/lib/settingsRegenerator.ts @@ -0,0 +1,35 @@ +// Validates settings on DB are correct on structure +// And deletes invalid ones +import { Logger } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; + +// Validates settings on DB are correct on structure by matching the ones missing all the required fields +const logger = new Logger('SettingsRegenerator'); +export async function settingsRegenerator() { + const invalidSettings = await Settings.find( + { + // Putting the $and explicit to ensure it's "intentional" + $and: [ + { value: { $exists: false } }, + { type: { $exists: false } }, + { public: { $exists: false } }, + { packageValue: { $exists: false } }, + { blocked: { $exists: false } }, + { sorter: { $exists: false } }, + { i18nLabel: { $exists: false } }, + ], + }, + { projection: { _id: 1 } }, + ).toArray(); + + if (invalidSettings.length > 0) { + logger.warn({ + msg: 'Invalid settings found on DB. Deleting them.', + settings: invalidSettings.map(({ _id }) => _id), + }); + await Settings.deleteMany({ _id: { $in: invalidSettings.map(({ _id }) => _id) } }); + // No need to notify listener + } else { + logger.info('No invalid settings found on DB.'); + } +} diff --git a/apps/meteor/server/lib/shared/disableCustomScripts.ts b/apps/meteor/server/lib/shared/disableCustomScripts.ts new file mode 100644 index 0000000..0d1f1a2 --- /dev/null +++ b/apps/meteor/server/lib/shared/disableCustomScripts.ts @@ -0,0 +1,14 @@ +import { License } from '@rocket.chat/license'; + +export const disableCustomScripts = () => { + const license = License.getLicense(); + + if (!license) { + return false; + } + + const isCustomScriptDisabled = process.env.DISABLE_CUSTOM_SCRIPTS === 'true'; + const isTrialLicense = license?.information.trial; + + return isCustomScriptDisabled && isTrialLicense; +}; diff --git a/apps/meteor/server/lib/shared/getModifiedHttpHeaders.ts b/apps/meteor/server/lib/shared/getModifiedHttpHeaders.ts new file mode 100644 index 0000000..2a14900 --- /dev/null +++ b/apps/meteor/server/lib/shared/getModifiedHttpHeaders.ts @@ -0,0 +1,28 @@ +export const normalizeHeaders = (httpHeaders?: Headers | Record) => { + if (httpHeaders instanceof Headers) { + return { ...Object.fromEntries(httpHeaders.entries()) }; + } + + return { ...httpHeaders }; +}; + +export const getModifiedHttpHeaders = (httpHeaders: Headers | Record) => { + const modifiedHttpHeaders = normalizeHeaders(httpHeaders); + + if ('x-auth-token' in modifiedHttpHeaders) { + modifiedHttpHeaders['x-auth-token'] = '[redacted]'; + } + + if (modifiedHttpHeaders.cookie) { + const cookies = modifiedHttpHeaders.cookie.split('; '); + const modifiedCookies = cookies.map((cookie: string) => { + if (cookie.startsWith('rc_token=')) { + return 'rc_token=[redacted]'; + } + return cookie; + }); + modifiedHttpHeaders.cookie = modifiedCookies.join('; '); + } + + return modifiedHttpHeaders; +}; diff --git a/apps/meteor/server/lib/shared/validateName.ts b/apps/meteor/server/lib/shared/validateName.ts new file mode 100644 index 0000000..c9adeaf --- /dev/null +++ b/apps/meteor/server/lib/shared/validateName.ts @@ -0,0 +1,14 @@ +import { settings } from '../../settings'; + +export const validateName = function (name: string): boolean { + const blockedNames = settings.get('Accounts_SystemBlockedUsernameList'); + if (!blockedNames || typeof blockedNames !== 'string') { + return true; + } + + if (blockedNames.split(',').includes(name.toLowerCase())) { + return false; + } + + return true; +}; diff --git a/apps/meteor/server/lib/shared/validateNameChars.ts b/apps/meteor/server/lib/shared/validateNameChars.ts new file mode 100644 index 0000000..07330c6 --- /dev/null +++ b/apps/meteor/server/lib/shared/validateNameChars.ts @@ -0,0 +1,21 @@ +export const validateNameChars = (name: string | undefined): boolean => { + if (typeof name !== 'string') { + return false; + } + + const invalidChars = /[<>\\/]/; + if (invalidChars.test(name)) { + return false; + } + + try { + const decodedName = decodeURI(name); + if (invalidChars.test(decodedName)) { + return false; + } + } catch (err) { + return false; + } + + return true; +}; diff --git a/apps/meteor/server/lib/shouldBreakInVersion.ts b/apps/meteor/server/lib/shouldBreakInVersion.ts new file mode 100644 index 0000000..c839e12 --- /dev/null +++ b/apps/meteor/server/lib/shouldBreakInVersion.ts @@ -0,0 +1,6 @@ +import semver from 'semver'; + +import type { DeprecationLoggerNextPlannedVersion } from './deprecationWarningLogger'; +import { Info } from '../../app/utils/rocketchat.info'; + +export const shouldBreakInVersion = (version: DeprecationLoggerNextPlannedVersion) => semver.gte(Info.version, version); diff --git a/apps/meteor/server/lib/spotlight.js b/apps/meteor/server/lib/spotlight.js new file mode 100644 index 0000000..6f6edce --- /dev/null +++ b/apps/meteor/server/lib/spotlight.js @@ -0,0 +1,287 @@ +import { Team } from '@rocket.chat/core-services'; +import { Users, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; + +import { canAccessRoomAsync, roomAccessAttributes } from './authorization'; +import { settings } from '../settings'; +import { hasPermissionAsync, hasAllPermissionAsync } from './authorization/hasPermission'; +import { roomCoordinator } from './rooms/roomCoordinator'; +import { trim } from '../../lib/utils/stringUtils'; +import { readSecondaryPreferred } from '../database/readSecondaryPreferred'; + +export class Spotlight { + async fetchRooms(userId, rooms) { + if (!settings.get('Store_Last_Message') || (userId && (await hasPermissionAsync(userId, 'preview-c-room')))) { + return rooms; + } + + return rooms.map((room) => { + delete room.lastMessage; + return room; + }); + } + + async searchRooms({ userId, text, includeFederatedRooms = false }) { + const regex = new RegExp(trim(escapeRegExp(text)), 'i'); + + const roomOptions = { + limit: 5, + projection: { + t: 1, + name: 1, + fname: 1, + teamMain: 1, + joinCodeRequired: 1, + lastMessage: 1, + federated: true, + prid: 1, + }, + sort: { + name: 1, + }, + readPreference: readSecondaryPreferred(Rooms.col.s.db), + }; + + if (userId == null) { + if (!settings.get('Accounts_AllowAnonymousRead')) { + return []; + } + + return this.fetchRooms(userId, await Rooms.findByNameAndTypeNotDefault(regex, 'c', roomOptions, includeFederatedRooms).toArray()); + } + + if (!(await hasAllPermissionAsync(userId, ['view-outside-room', 'view-c-room']))) { + return []; + } + + const searchableRoomTypeIds = roomCoordinator.searchableRoomTypes(); + + const [subscriptions, exactRoom] = await Promise.all([ + SubscriptionsRaw.findByUserIdAndTypes(userId, searchableRoomTypeIds, { + projection: { rid: 1 }, + readPreference: roomOptions.readPreference, + }).toArray(), + Rooms.findOneByNameAndType(text, searchableRoomTypeIds, roomOptions, includeFederatedRooms), + ]); + + const roomIds = subscriptions.map((s) => s.rid); + if (exactRoom) { + roomIds.push(exactRoom.rid); + } + + return this.fetchRooms( + userId, + await Rooms.findByNameOrFNameAndTypesNotInIds(regex, searchableRoomTypeIds, roomIds, roomOptions, includeFederatedRooms).toArray(), + ); + } + + mapOutsiders(u) { + u.outside = true; + return u; + } + + processLimitAndUsernames(options, usernames, users) { + // Reduce the results from the limit for the next query + options.limit -= users.length; + + // If the limit was reached, return + if (options.limit <= 0) { + return users; + } + + // Prevent the next query to get the same users + usernames.push(...users.map((u) => u.username).filter((u) => !usernames.includes(u))); + } + + async _searchInsiderUsers({ rid, text, usernames, options, users, insiderExtraQuery, match = { startsWith: false, endsWith: false } }) { + // Get insiders first + if (rid) { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + + users.push(...(await Users.findByActiveUsersExcept(text, usernames, options, searchFields, insiderExtraQuery, match).toArray())); + + // If the limit was reached, return + if (this.processLimitAndUsernames(options, usernames, users)) { + return users; + } + } + } + + async _searchConnectedUsers(userId, { text, usernames, options, users, match = { startsWith: false, endsWith: false } }, roomType) { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + + users.push( + ...( + await SubscriptionsRaw.findConnectedUsersExcept(userId, text, usernames, searchFields, {}, options.limit || 5, roomType, match, { + readPreference: options.readPreference, + }) + ).map(this.mapOutsiders), + ); + + // If the limit was reached, return + if (this.processLimitAndUsernames(options, usernames, users)) { + return users; + } + } + + async _searchOutsiderUsers({ text, usernames, options, users, canListOutsiders, match = { startsWith: false, endsWith: false } }) { + // Then get the outsiders if allowed + if (canListOutsiders) { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + users.push( + ...(await Users.findByActiveUsersExcept(text, usernames, options, searchFields, undefined, match).toArray()).map(this.mapOutsiders), + ); + + // If the limit was reached, return + if (this.processLimitAndUsernames(options, usernames, users)) { + return users; + } + } + } + + mapTeams(teams) { + return teams.map((t) => { + t.isTeam = true; + t.username = t.name; + t.status = 'online'; + return t; + }); + } + + async _searchTeams(userId, { text, options, users, mentions }) { + if (!mentions || settings.get('Troubleshoot_Disable_Teams_Mention')) { + return users; + } + + options.limit -= users.length; + + if (options.limit <= 0) { + return users; + } + + const teamOptions = { ...options, projection: { name: 1, type: 1 } }; + const teams = await Team.search(userId, text, teamOptions); + users.push(...this.mapTeams(teams)); + + return users; + } + + async searchUsers({ userId, rid, text, usernames, mentions }) { + const users = []; + + const options = { + limit: settings.get('Number_of_users_autocomplete_suggestions'), + projection: { + username: 1, + nickname: 1, + name: 1, + status: 1, + statusText: 1, + avatarETag: 1, + }, + sort: { + [settings.get('UI_Use_Real_Name') ? 'name' : 'username']: 1, + }, + readPreference: readSecondaryPreferred(Users.col.s.db), + }; + + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, _id: 1, t: 1, uids: 1 } }); + + if (rid && !room) { + return users; + } + + const canListOutsiders = !!userId && (await hasAllPermissionAsync(userId, ['view-outside-room', 'view-d-room'])); + const canListInsiders = canListOutsiders || (rid && (await canAccessRoomAsync(room, { _id: userId }))); + + const insiderExtraQuery = []; + + if (rid) { + switch (room.t) { + case 'd': + insiderExtraQuery.push({ + _id: { $in: room.uids.filter((id) => id !== userId) }, + }); + break; + case 'l': + insiderExtraQuery.push({ + _id: { + $in: (await SubscriptionsRaw.findByRoomId(room._id).toArray()).map((s) => s.u?._id).filter((id) => id && id !== userId), + }, + }); + break; + default: + insiderExtraQuery.push({ + __rooms: rid, + }); + break; + } + } + + const searchParams = { + rid, + text, + usernames, + options, + users, + canListOutsiders, + insiderExtraQuery, + mentions, + }; + + // Exact match for username only + // TODO: these exact-match branches push the user without filtering against `usernames` + // (the exclusion list), so an exact username query bypasses the exclusion that the + // findByActiveUsersExcept paths below honor. Evaluate filtering exactMatch against + // `usernames` here so the exclusion applies uniformly. + if (rid && canListInsiders) { + const exactMatch = await Users.findOneByUsernameAndRoomIgnoringCase(text, rid, { + projection: options.projection, + readPreference: options.readPreference, + }); + if (exactMatch) { + users.push(exactMatch); + this.processLimitAndUsernames(options, usernames, users); + } + } + + if (users.length === 0 && canListOutsiders && text) { + const exactMatch = await Users.findOneByUsernameIgnoringCase(text, { + projection: options.projection, + readPreference: options.readPreference, + }); + if (exactMatch) { + users.push(this.mapOutsiders(exactMatch)); + this.processLimitAndUsernames(options, usernames, users); + } + } + + if (canListInsiders && rid) { + // Search for insiders + if (await this._searchInsiderUsers(searchParams)) { + return users; + } + + // Search for users that the requester has DMs with + if (await this._searchConnectedUsers(userId, searchParams, 'd')) { + return users; + } + } + + // If the user can search outsiders, search for any user in the server + // Otherwise, search for users that are subscribed to the same rooms as the requester + if (canListOutsiders) { + if (await this._searchOutsiderUsers(searchParams)) { + return users; + } + } else if (await this._searchConnectedUsers(userId, searchParams, 'd')) { + return users; + } + + if (await this._searchTeams(userId, searchParams)) { + return users; + } + + return users; + } +} diff --git a/apps/meteor/server/lib/statistics/functions/getLastStatistics.ts b/apps/meteor/server/lib/statistics/functions/getLastStatistics.ts new file mode 100644 index 0000000..6b80716 --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/getLastStatistics.ts @@ -0,0 +1,16 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Statistics } from '@rocket.chat/models'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { statistics } from '../lib/statistics'; + +export async function getLastStatistics({ userId, refresh }: { userId: IUser['_id']; refresh?: boolean }) { + if (!(await hasPermissionAsync(userId, 'view-statistics'))) { + throw new Error('error-not-allowed'); + } + + if (refresh) { + return statistics.save(); + } + return Statistics.findLast(); +} diff --git a/apps/meteor/server/lib/statistics/functions/getStatistics.ts b/apps/meteor/server/lib/statistics/functions/getStatistics.ts new file mode 100644 index 0000000..64829b3 --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/getStatistics.ts @@ -0,0 +1,44 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { Statistics } from '@rocket.chat/models'; +import type { FindOptions, SchemaMember } from 'mongodb'; + +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +type GetStatisticsParams = { + userId: string; + query?: Record; + pagination: { + offset: number; + count?: number; + sort?: FindOptions['sort']; + fields?: SchemaMember; + }; +}; + +type GetStatisticsReturn = { statistics: IStats[]; count: number; offset: number; total: number }; + +export async function getStatistics({ + userId, + query = {}, + pagination: { offset, count, sort, fields }, +}: GetStatisticsParams): Promise { + if (!(await hasPermissionAsync(userId, 'view-statistics'))) { + throw new Error('error-not-allowed'); + } + + const { cursor, totalCount } = Statistics.findPaginated(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [statistics, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + statistics, + count: statistics.length, + offset, + total, + }; +} diff --git a/apps/meteor/server/lib/statistics/functions/sendUsageReport.spec.ts b/apps/meteor/server/lib/statistics/functions/sendUsageReport.spec.ts new file mode 100644 index 0000000..9adeaac --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/sendUsageReport.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const mocks = { + Statistics: { + findLast: sandbox.stub(), + updateOne: sandbox.stub(), + }, + statistics: { + save: sandbox.stub(), + }, + serverFetch: sandbox.stub(), + getWorkspaceAccessToken: sandbox.stub().resolves('workspace-token'), + Meteor: { + absoluteUrl: sandbox.stub().returns('http://localhost:3000/'), + }, + logger: { + error: sandbox.stub(), + }, + Info: { + version: '3.0.1', + }, +}; + +const { sendUsageReport } = proxyquire.noCallThru().load('./sendUsageReport', { + '@rocket.chat/models': { Statistics: mocks.Statistics }, + '@rocket.chat/server-fetch': { serverFetch: mocks.serverFetch }, + '../index': { statistics: mocks.statistics }, + '../../cloud': { getWorkspaceAccessToken: mocks.getWorkspaceAccessToken }, + 'meteor/meteor': { Meteor: mocks.Meteor }, + '../../../../app/utils/rocketchat.info': { Info: mocks.Info }, +}); + +describe('sendUsageReport', () => { + beforeEach(() => { + sandbox.resetHistory(); + }); + + afterEach(() => { + delete process.env.RC_DISABLE_STATISTICS_REPORTING; + }); + + it('should save statistics locally and not send to collector when RC_DISABLE_STATISTICS_REPORTING is true', async () => { + process.env.RC_DISABLE_STATISTICS_REPORTING = 'true'; + + const result = await sendUsageReport(mocks.logger); + + expect(mocks.statistics.save.called).to.be.true; + expect(mocks.serverFetch.called).to.be.false; + expect(result).to.be.undefined; + }); + + it('should save statistics locally and send to collector when RC_DISABLE_STATISTICS_REPORTING is false', async () => { + process.env.RC_DISABLE_STATISTICS_REPORTING = 'false'; + + const result = await sendUsageReport(mocks.logger); + + expect(mocks.statistics.save.called).to.be.true; + expect(mocks.serverFetch.calledOnce).to.be.true; + expect(mocks.serverFetch.calledWith('https://collector.rocket.chat/', sinon.match({ method: 'POST' }))).to.be.true; + expect(result).to.be.undefined; + }); + + it('should generate new statistics when version changes', async () => { + mocks.Statistics.findLast.resolves({ + _id: 'stats-id', + version: '2.9.0', + createdAt: new Date(), + }); + + mocks.statistics.save.resolves({ _id: 'new-stats-id' }); + + const result = await sendUsageReport(mocks.logger); + + expect(mocks.statistics.save.calledOnce).to.be.true; + expect(result).to.be.undefined; + }); + + it('should NOT generate new statistics if last version equals current version', async () => { + mocks.Statistics.findLast.resolves({ + _id: 'stats-id', + version: '3.0.1', + createdAt: new Date(), + statsToken: 'token', + }); + + const result = await sendUsageReport(mocks.logger); + + expect(mocks.statistics.save.called).to.be.false; + expect(result).to.equal('token'); + }); + + it('should generate new statistics when no previous stats exist', async () => { + mocks.Statistics.findLast.resolves(undefined); + mocks.statistics.save.resolves({ _id: 'new-stats-id' }); + + await sendUsageReport(mocks.logger); + + expect(mocks.statistics.save.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts new file mode 100644 index 0000000..374a560 --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts @@ -0,0 +1,79 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import type { Logger } from '@rocket.chat/logger'; +import { Statistics } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { tracerSpan } from '@rocket.chat/tracing'; +import { Meteor } from 'meteor/meteor'; + +import { Info } from '../../../../app/utils/rocketchat.info'; +import { shouldReportStatistics } from '../../../cron/usageReport'; +import { getWorkspaceAccessToken } from '../../cloud'; +import { statistics } from '../index'; + +async function sendStats(logger: Logger, cronStatistics: IStats): Promise { + try { + const token = await getWorkspaceAccessToken(); + const headers = { ...(token && { Authorization: `Bearer ${token}` }) }; + + const response = await fetch('https://collector.rocket.chat/', { + method: 'POST', + body: { + ...cronStatistics, + host: Meteor.absoluteUrl(), + }, + headers, + // SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check. + ignoreSsrfValidation: true, + }); + + const { statsToken } = await response.json(); + + if (statsToken != null) { + await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } }); + return statsToken; + } + } catch (err) { + logger.error({ msg: 'Failed to send usage report', err }); + } +} + +export async function sendUsageReport(logger: Logger): Promise { + // Even when disabled, we still generate statistics locally to avoid breaking + // internal processes, such as restriction checks for air-gapped workspaces. + // Evaluated at send time (not entry) so the verdict cannot go stale while the + // statistics are being generated — e.g. an offline license applied during startup. + const shouldSendToCollector = () => shouldReportStatistics() && !License.hasOfflineLicense(); + + return tracerSpan('generateStatistics', {}, async () => { + const last = await Statistics.findLast(); + + const currentVersion = Info.version; + + if (last && last.version === currentVersion) { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + // if the last data we have has less than 24h and was not sent to yet, send it + if (last.createdAt > yesterday) { + // but if it has the confirmation token, we can skip + if (last.statsToken) { + return last.statsToken; + } + + // if it doesn't it means the request failed, so we try sending again with the same data + if (shouldSendToCollector()) { + return sendStats(logger, last); + } + + return; + } + } + + // if our latest stats has more than 24h OR the statistics.version differs from the actual version, it is time to generate a new one and send it + const cronStatistics = await statistics.save(); + if (shouldSendToCollector()) { + return sendStats(logger, cronStatistics); + } + }); +} diff --git a/apps/meteor/server/lib/statistics/functions/slashCommandsStats.ts b/apps/meteor/server/lib/statistics/functions/slashCommandsStats.ts new file mode 100644 index 0000000..5cb9e26 --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/slashCommandsStats.ts @@ -0,0 +1,12 @@ +import { updateCounter } from './updateStatsCounter'; +import telemetryEvent from '../lib/telemetryEvents'; + +type slashCommandsDataType = { command: string }; + +export function slashCommandsStats(data: slashCommandsDataType): void { + if (data.command === 'jitsi') { + updateCounter({ settingsId: 'Jitsi_Start_SlashCommands_Count' }); + } +} + +telemetryEvent.register('slashCommandsStats', slashCommandsStats); diff --git a/apps/meteor/server/lib/statistics/functions/updateStatsCounter.ts b/apps/meteor/server/lib/statistics/functions/updateStatsCounter.ts new file mode 100644 index 0000000..7999d71 --- /dev/null +++ b/apps/meteor/server/lib/statistics/functions/updateStatsCounter.ts @@ -0,0 +1,17 @@ +import { Settings } from '@rocket.chat/models'; + +import { notifyOnSettingChanged } from '../../notifyListener'; +import telemetryEvent from '../lib/telemetryEvents'; + +type updateCounterDataType = { settingsId: string }; + +export function updateCounter(data: updateCounterDataType): void { + void (async () => { + const value = await Settings.incrementValueById(data.settingsId, 1, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + })(); +} + +telemetryEvent.register('updateCounter', updateCounter); diff --git a/apps/meteor/server/lib/statistics/getSettingsStatistics.ts b/apps/meteor/server/lib/statistics/getSettingsStatistics.ts new file mode 100644 index 0000000..fdec8c9 --- /dev/null +++ b/apps/meteor/server/lib/statistics/getSettingsStatistics.ts @@ -0,0 +1,146 @@ +import type { ISettingStatistics, ISettingStatisticsObject } from '@rocket.chat/core-typings'; + +import { settings } from '../../settings'; + +const setSettingsStatistics = async (settings: ISettingStatistics): Promise => { + const { + account2fa, + cannedResponsesEnabled, + e2e, + e2eDefaultDirectRoom, + e2eDefaultPrivateRoom, + smtpHost, + smtpPort, + fromEmail, + fileUploadEnable, + frameworkDevMode, + frameworkEnable, + surveyEnabled, + updateChecker, + liveStream, + broadcasting, + allowEditing, + allowDeleting, + allowUnrecognizedSlashCommand, + allowBadWordsFilter, + readReceiptEnabled, + readReceiptStoreUsers, + globalSearchEnabled, + pushEnable, + threadsEnabled, + webRTCEnableChannel, + webRTCEnablePrivate, + webRTCEnableDirect, + } = settings; + + // If Canned Response does not exist add blank object to the statistic + const cannedRes = cannedResponsesEnabled !== undefined ? { cannedResponses: { cannedResponsesEnabled } } : {}; + + const statisticObject = { + accounts: { + account2fa, + }, + ...cannedRes, + e2ee: { + e2e, + e2eDefaultDirectRoom, + e2eDefaultPrivateRoom, + }, + email: { + smtp: { + smtpHost, + smtpPort, + fromEmail, + }, + }, + fileUpload: { + fileUploadEnable, + }, + general: { + apps: { + frameworkDevMode, + frameworkEnable, + }, + nps: { + surveyEnabled, + }, + update: { + updateChecker, + }, + }, + liveStreamAndBroadcasting: { + liveStream, + broadcasting, + }, + message: { + allowEditing, + allowDeleting, + allowUnrecognizedSlashCommand, + allowBadWordsFilter, + readReceiptEnabled, + readReceiptStoreUsers, + }, + push: { + pushEnable, + }, + search: { + defaultProvider: { + globalSearchEnabled, + }, + }, + threads: { + threadsEnabled, + }, + webRTC: { + webRTCEnableChannel, + webRTCEnablePrivate, + webRTCEnableDirect, + }, + }; + return statisticObject; +}; +export const getSettingsStatistics = async (): Promise => { + try { + const settingsBase = [ + { key: 'Accounts_TwoFactorAuthentication_Enabled', alias: 'account2fa' }, + { key: 'E2E_Enable', alias: 'e2e' }, + { key: 'E2E_Enabled_Default_DirectRooms', alias: 'e2eDefaultDirectRoom' }, + { key: 'E2E_Enabled_Default_PrivateRooms', alias: 'e2eDefaultPrivateRoom' }, + { key: 'SMTP_Host', alias: 'smtpHost' }, + { key: 'SMTP_Port', alias: 'smtpPort' }, + { key: 'From_Email', alias: 'fromEmail' }, + { key: 'FileUpload_Enabled', alias: 'fileUploadEnable' }, + { key: 'NPS_survey_enabled', alias: 'surveyEnabled' }, + { key: 'Update_EnableChecker', alias: 'updateChecker' }, + { key: 'Livestream_enabled', alias: 'liveStream' }, + { key: 'Broadcasting_enabled', alias: 'broadcasting' }, + { key: 'Message_AllowEditing', alias: 'allowEditing' }, + { key: 'Message_AllowDeleting', alias: 'allowDeleting' }, + { key: 'Message_AllowUnrecognizedSlashCommand', alias: 'allowUnrecognizedSlashCommand' }, + { key: 'Message_AllowBadWordsFilter', alias: 'allowBadWordsFilter' }, + { key: 'Message_Read_Receipt_Enabled', alias: 'readReceiptEnabled' }, + { key: 'Message_Read_Receipt_Store_Users', alias: 'readReceiptStoreUsers' }, + { key: 'Search.defaultProvider.GlobalSearchEnabled', alias: 'globalSearchEnabled' }, + { key: 'Push_enable', alias: 'pushEnable' }, + { key: 'Threads_enabled', alias: 'threadsEnabled' }, + { key: 'WebRTC_Enable_Channel', alias: 'webRTCEnableChannel' }, + { key: 'WebRTC_Enable_Private', alias: 'webRTCEnablePrivate' }, + { key: 'WebRTC_Enable_Direct', alias: 'webRTCEnableDirect' }, + { key: 'Canned_Responses_Enable', alias: 'cannedResponsesEnabled' }, + ] as const; + + const settingsStatistics = settingsBase.reduce((acc, { key, alias }) => { + if (!settings.has(key)) return acc; + + acc[alias] = settings.get(key); + + return acc; + }, {}); + + const staticticObject = await setSettingsStatistics(settingsStatistics); + + return staticticObject; + } catch (error: any) { + return error; + } +}; diff --git a/apps/meteor/server/lib/statistics/index.ts b/apps/meteor/server/lib/statistics/index.ts new file mode 100644 index 0000000..a6ca3c2 --- /dev/null +++ b/apps/meteor/server/lib/statistics/index.ts @@ -0,0 +1,7 @@ +import './startup/monitor'; +import './functions/slashCommandsStats'; + +export { statistics } from './lib/statistics'; +export { getLastStatistics } from './functions/getLastStatistics'; +export { getStatistics } from './functions/getStatistics'; +export { updateCounter } from './functions/updateStatsCounter'; diff --git a/apps/meteor/server/lib/statistics/lib/SAUMonitor.ts b/apps/meteor/server/lib/statistics/lib/SAUMonitor.ts new file mode 100644 index 0000000..e78b4a4 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/SAUMonitor.ts @@ -0,0 +1,342 @@ +import type { ISession, ISessionDevice, IUser } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { Logger } from '@rocket.chat/logger'; +import { Sessions, Users, aggregates } from '@rocket.chat/models'; +import mem from 'mem'; +import { Meteor } from 'meteor/meteor'; +import UAParser from 'ua-parser-js'; + +import { UAParserMobile, UAParserDesktop } from './UAParserCustom'; +import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole'; +import { sauEvents } from '../../../services/sauMonitor/events'; + +type DateObj = { day: number; month: number; year: number }; + +const getDateObj = (dateTime = new Date()): DateObj => ({ + day: dateTime.getDate(), + month: dateTime.getMonth() + 1, + year: dateTime.getFullYear(), +}); + +const logger = new Logger('SAUMonitor'); + +const getUserRoles = mem( + async (userId: string): Promise => { + const user = await Users.findOneById>(userId, { projection: { roles: 1 } }); + + return user?.roles || []; + }, + { maxAge: 5000 }, +); + +const isProdEnv = process.env.NODE_ENV === 'production'; + +type HandleSessionArgs = { + userId: string; + instanceId: string; + userAgent: string; + loginToken?: string; + connectionId: string; + clientAddress: string; + host: string; +}; + +/** + * Server Session Monitor for SAU(Simultaneously Active Users) based on Meteor server sessions + */ +export class SAUMonitorClass { + private _started: boolean; + + private _dailyComputeJobName: string; + + private _dailyFinishSessionsJobName: string; + + private scheduler = cronJobs; + + constructor() { + this._started = false; + this._dailyComputeJobName = 'aggregate-sessions'; + this._dailyFinishSessionsJobName = 'finish-sessions'; + } + + async start(): Promise { + if (this.isRunning()) { + return; + } + + await this._startMonitoring(); + + this._started = true; + logger.debug({ msg: '[start]' }); + } + + async stop(): Promise { + if (!this.isRunning()) { + return; + } + + this._started = false; + + if (await this.scheduler.has(this._dailyComputeJobName)) { + await this.scheduler.remove(this._dailyComputeJobName); + } + if (await this.scheduler.has(this._dailyFinishSessionsJobName)) { + await this.scheduler.remove(this._dailyFinishSessionsJobName); + } + + logger.debug({ msg: '[stop]' }); + } + + isRunning(): boolean { + return this._started === true; + } + + async _startMonitoring(): Promise { + try { + this._handleAccountEvents(); + this._handleOnConnection(); + await this._startCronjobs(); + } catch (err: any) { + throw new Meteor.Error(err); + } + } + + private _handleOnConnection(): void { + if (this.isRunning()) { + return; + } + + sauEvents.on('sau.socket.disconnected', async ({ connectionId, instanceId }) => { + if (!this.isRunning()) { + return; + } + + await Sessions.closeByInstanceIdAndSessionId(instanceId, connectionId); + }); + } + + private _handleAccountEvents(): void { + if (this.isRunning()) { + return; + } + + sauEvents.on('sau.accounts.login', async ({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }) => { + if (!this.isRunning()) { + return; + } + + const roles = await getUserRoles(userId); + + const mostImportantRole = getMostImportantRole(roles); + + const loginAt = new Date(); + const params = { roles, mostImportantRole, loginAt, ...getDateObj() }; + await this._handleSession({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }, params); + }); + + sauEvents.on('sau.accounts.logout', async ({ userId, sessionId }) => { + if (!this.isRunning()) { + return; + } + + if (!userId) { + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'userId'" }); + return; + } + + if (!sessionId) { + logger.warn({ msg: "Received 'sau.accounts.logout' event without 'sessionId'" }); + return; + } + + const session = await Sessions.getLoggedInByUserIdAndSessionId>(userId, sessionId, { + projection: { loginToken: 1 }, + }); + if (!session?.loginToken) { + if (!isProdEnv) { + throw new Error('Session not found during logout'); + } + logger.error({ msg: 'Session not found during logout', userId, sessionId }); + return; + } + + await Sessions.logoutBySessionIdAndUserId({ loginToken: session.loginToken, userId }); + }); + } + + private async _handleSession( + { userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: HandleSessionArgs, + params: Pick, + ): Promise { + const data: Omit = { + userId, + ...(loginToken && { loginToken }), + ip: clientAddress, + host, + sessionId: connectionId, + instanceId, + type: 'session', + ...(loginToken && this._getUserAgentInfo(userAgent)), + ...params, + }; + + const searchTerm = this._getSearchTerm(data); + + await Sessions.createOrUpdate({ ...data, searchTerm }); + } + + private async _finishSessionsFromDate(yesterday: Date, today: Date): Promise { + if (!this.isRunning()) { + return; + } + + const { day, month, year } = getDateObj(yesterday); + const beforeDateTime = new Date(year, month - 1, day, 23, 59, 59, 999); + + const currentDate = getDateObj(today); + const nextDateTime = new Date(currentDate.year, currentDate.month - 1, currentDate.day); + + const cursor = Sessions.findSessionsNotClosedByDateWithoutLastActivity({ year, month, day }); + + const batch = []; + + for await (const session of cursor) { + // create a new session for the current day + batch.push({ + ...session, + ...currentDate, + createdAt: nextDateTime, + }); + + if (batch.length === 500) { + await Sessions.createBatch(batch); + batch.length = 0; + } + } + + if (batch.length > 0) { + await Sessions.createBatch(batch); + } + + // close all sessions from current 'date' + await Sessions.updateActiveSessionsByDate( + { year, month, day }, + { + lastActivityAt: beforeDateTime, + }, + ); + + // TODO missing an action to perform on dangling sessions (for example remove sessions not closed one month ago) + } + + private _getSearchTerm(session: Omit): string { + return [session.device?.name, session.device?.type, session.device?.os.name, session.sessionId, session.userId] + .filter(Boolean) + .join(''); + } + + private _getUserAgentInfo(uaString: string): { device: ISessionDevice } | undefined { + // TODO define a type for "result" below + // | UAParser.IResult + // | { device: { type: string; model?: string }; browser: undefined; os: undefined; app: { name: string; version: string } } + // | { + // device: { type: string; model?: string }; + // browser: undefined; + // os: string; + // app: { name: string; version: string }; + // } + + const result = ((): any => { + if (UAParserMobile.isMobileApp(uaString)) { + return UAParserMobile.uaObject(uaString); + } + + if (UAParserDesktop.isDesktopApp(uaString)) { + return UAParserDesktop.uaObject(uaString); + } + + const ua = new UAParser(uaString); + return ua.getResult(); + })(); + + const info: ISessionDevice = { + type: 'other', + name: '', + longVersion: '', + os: { + name: '', + version: '', + }, + version: '', + }; + + const removeEmptyProps = (obj: any): any => { + Object.keys(obj).forEach((p) => (!obj[p] || obj[p] === undefined) && delete obj[p]); + return obj; + }; + + if (result.browser?.name) { + info.type = 'browser'; + info.name = result.browser.name; + info.longVersion = result.browser.version || ''; + } + + if (typeof result.os !== 'string' && result.os?.name) { + info.os = removeEmptyProps(result.os) || ''; + } + + if (result.device && (result.device.type || result.device.model)) { + info.type = result.device.type || ''; + + if (result.hasOwnProperty('app') && result.app?.name) { + info.name = result.app.name; + info.longVersion = result.app.version; + if (result.app.bundle) { + info.longVersion += ` ${result.app.bundle}`; + } + } + } + + if (typeof info.longVersion === 'string') { + info.version = info.longVersion.match(/(\d+\.){0,2}\d+/)?.[0] || ''; + } + + return { + device: info, + }; + } + + private async _startCronjobs(): Promise { + logger.info({ msg: '[aggregate] - Start Cron.' }); + const dailyComputeProcessTime = '0 2 * * *'; + const dailyFinishSessionProcessTime = '5 1 * * *'; + await this.scheduler.add(this._dailyComputeJobName, dailyComputeProcessTime, async () => this._aggregate()); + await this.scheduler.add(this._dailyFinishSessionsJobName, dailyFinishSessionProcessTime, async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await this._finishSessionsFromDate(yesterday, new Date()); + }); + } + + private async _aggregate(): Promise { + if (!this.isRunning()) { + return; + } + + const today = new Date(); + + // get sessions from 3 days ago to make sure even if a few cron jobs were skipped, we still have the data + const threeDaysAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3, 0, 0, 0, 0); + + const period = { start: getDateObj(threeDaysAgo), end: getDateObj(today) }; + + logger.info({ msg: '[aggregate] - Aggregating data.', period }); + + for await (const record of aggregates.dailySessions(Sessions.col, period)) { + await Sessions.updateDailySessionById(`${record.userId}-${record.year}-${record.month}-${record.day}`, record); + } + + await Sessions.updateAllSessionsByDateToComputed(period); + } +} diff --git a/apps/meteor/server/lib/statistics/lib/UAParserCustom.js b/apps/meteor/server/lib/statistics/lib/UAParserCustom.js new file mode 100644 index 0000000..679c039 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/UAParserCustom.js @@ -0,0 +1,154 @@ +import UAParser from 'ua-parser-js'; + +const mergeDeep = (target, source) => { + if (!(typeof target === 'object' && typeof source === 'object')) { + return target; + } + + for (const key in source) { + if (source[key] === null && (target[key] === undefined || target[key] === null)) { + target[key] = null; + } else if (source[key] instanceof Array) { + if (!target[key]) { + target[key] = []; + } + target[key] = target[key].concat(source[key]); + } else if (typeof source[key] === 'object') { + if (!target[key]) { + target[key] = {}; + } + mergeDeep(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + + return target; +}; + +export const UAParserMobile = { + appName: 'RC Mobile', + device: 'mobile-app', + uaSeparator: ';', + props: { + os: { + list: ['name', 'version'], + }, + app: { + list: ['version', 'bundle'], + get: (prop, value) => { + if (prop === 'bundle') { + return value.replace(/([()])/g, ''); + } + + if (prop === 'version') { + return value.replace(/^v/g, ''); + } + + return value; + }, + }, + }, + + isMobileApp(uaString) { + if (!uaString || typeof uaString !== 'string') { + return false; + } + + const splitUA = uaString.split(this.uaSeparator); + return splitUA && splitUA[0] && splitUA[0].trim() === this.appName; + }, + + /** + * + * @param {string} uaString + * @returns { device: { type: '' }, app: { name: '', version: '' } } + */ + uaObject(uaString) { + if (!this.isMobileApp(uaString)) { + return {}; + } + + const splitUA = uaString.split(this.uaSeparator); + + let obj = { + device: { + type: this.device, + }, + app: { + name: splitUA[0], + }, + }; + + splitUA.shift(); // remove first element + if (splitUA.length === 0) { + return obj; + } + + splitUA.forEach((element, index) => { + const splitProps = element.trim().split(' '); + const key = Object.keys(this.props)[index]; + if (!key) { + return; + } + + const props = this.props[key]; + if (!props.list || !Array.isArray(props.list) || props.list.length === 0) { + return; + } + + const subProps = {}; + splitProps.forEach((value, idx) => { + if (props.list.length > idx) { + const propName = props.list[idx]; + subProps[propName] = props.get ? props.get(propName, value) : value; + } + }); + + const prop = {}; + prop[key] = subProps; + obj = mergeDeep(obj, prop); + }); + + return obj; + }, +}; + +export const UAParserDesktop = { + device: 'desktop-app', + + isDesktopApp(uaString) { + if (!uaString || typeof uaString !== 'string') { + return false; + } + + return uaString.includes(' Electron/'); + }, + + /** + * + * @param {string} uaString + * @returns { device: { type: '' }, os: '' || {}, app: { name: '', version: '' } } + */ + uaObject(uaString) { + if (!this.isDesktopApp(uaString)) { + return {}; + } + + const ua = new UAParser(uaString); + const uaParsed = ua.getResult(); + + const [, name, version] = uaString.match(/(Rocket\.Chat)\/(\d+(\.\d+)+)/) || []; + + return { + device: { + type: this.device, + }, + os: uaParsed.os, + app: { + name, + version, + }, + }; + }, +}; diff --git a/apps/meteor/server/lib/statistics/lib/getAppsStatistics.ts b/apps/meteor/server/lib/statistics/lib/getAppsStatistics.ts new file mode 100644 index 0000000..482d507 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/getAppsStatistics.ts @@ -0,0 +1,84 @@ +import { Apps } from '@rocket.chat/apps'; +import { AppInstallationSource } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem'; +import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import mem from 'mem'; + +import { Info } from '../../../../app/utils/rocketchat.info'; +import { SystemLogger } from '../../logger/system'; + +type AppsStatistics = { + engineVersion: string; + totalInstalled: number | false; + totalActive: number | false; + totalFailed: number | false; + totalPrivateApps: number | false; + totalPrivateAppsEnabled: number | false; +}; + +async function _getAppsStatistics(): Promise { + if (!Apps.self?.isInitialized()) { + return { + engineVersion: Info.marketplaceApiVersion, + totalInstalled: false, + totalActive: false, + totalFailed: false, + totalPrivateApps: false, + totalPrivateAppsEnabled: false, + }; + } + + try { + const apps = await Apps.getManager().get(); + + let totalInstalled = 0; + let totalActive = 0; + let totalFailed = 0; + let totalPrivateApps = 0; + let totalPrivateAppsEnabled = 0; + + await Promise.all( + apps.map(async (app) => { + totalInstalled++; + + const status = await app.getStatus(); + const storageItem = app.getStorageItem(); + + if (storageItem.installationSource === AppInstallationSource.PRIVATE) { + totalPrivateApps++; + + if (AppStatusUtils.isEnabled(status)) { + totalPrivateAppsEnabled++; + } + } + + if (AppStatusUtils.isEnabled(status)) { + totalActive++; + } else if (status !== AppStatus.MANUALLY_DISABLED) { + totalFailed++; + } + }), + ); + + return { + engineVersion: Info.marketplaceApiVersion, + totalInstalled, + totalActive, + totalFailed, + totalPrivateApps, + totalPrivateAppsEnabled, + }; + } catch (err: unknown) { + SystemLogger.error({ msg: 'Exception while getting Apps statistics', err }); + return { + engineVersion: Info.marketplaceApiVersion, + totalInstalled: false, + totalActive: false, + totalFailed: false, + totalPrivateApps: false, + totalPrivateAppsEnabled: false, + }; + } +} + +// since this function is called every 5s by `setPrometheusData` we're memoizing the result since the result won't change that often +export const getAppsStatistics = mem(_getAppsStatistics, { maxAge: 60000 }); diff --git a/apps/meteor/server/lib/statistics/lib/getContactVerificationStatistics.ts b/apps/meteor/server/lib/statistics/lib/getContactVerificationStatistics.ts new file mode 100644 index 0000000..300f8f7 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/getContactVerificationStatistics.ts @@ -0,0 +1,41 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; + +export async function getContactVerificationStatistics(): Promise { + const [ + totalContacts, + totalUnknownContacts, + [{ totalConflicts, avgChannelsPerContact } = { totalConflicts: 0, avgChannelsPerContact: 0 }], + totalBlockedContacts, + totalFullyBlockedContacts, + totalVerifiedContacts, + totalContactsWithoutChannels, + ] = await Promise.all([ + LivechatContacts.estimatedDocumentCount(), + LivechatContacts.countUnknown(), + LivechatContacts.getStatistics().toArray(), + LivechatContacts.countBlocked(), + LivechatContacts.countFullyBlocked(), + LivechatContacts.countVerified(), + LivechatContacts.countContactsWithoutChannels(), + ]); + + return { + totalContacts, + totalUnknownContacts, + totalMergedContacts: settings.get('Merged_Contacts_Count'), + totalConflicts, + totalResolvedConflicts: settings.get('Resolved_Conflicts_Count'), + totalBlockedContacts, + totalPartiallyBlockedContacts: totalBlockedContacts - totalFullyBlockedContacts, + totalFullyBlockedContacts, + totalVerifiedContacts, + avgChannelsPerContact, + totalContactsWithoutChannels, + totalImportedContacts: settings.get('Contacts_Importer_Count'), + totalUpsellViews: settings.get('Advanced_Contact_Upsell_Views_Count'), + totalUpsellClicks: settings.get('Advanced_Contact_Upsell_Clicks_Count'), + }; +} diff --git a/apps/meteor/server/lib/statistics/lib/getEEStatistics.ts b/apps/meteor/server/lib/statistics/lib/getEEStatistics.ts new file mode 100644 index 0000000..1b8b825 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/getEEStatistics.ts @@ -0,0 +1,103 @@ +import { log } from 'console'; + +import { Analytics } from '@rocket.chat/core-services'; +import type { IStats } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { CannedResponse, OmnichannelServiceLevelAgreements, LivechatRooms, LivechatTag, LivechatUnit, Users } from '@rocket.chat/models'; + +type ENTERPRISE_STATISTICS = IStats['enterprise']; + +type GenericStats = Pick; + +type EEOnlyStats = Omit; + +export async function getStatistics(): Promise { + const genericStats: GenericStats = { + modules: License.getModules(), + tags: License.getTags().map(({ name }) => name), + seatRequests: await Analytics.getSeatRequestCount(), + }; + + const eeModelsStats = await getEEStatistics(); + + const statistics = { + ...genericStats, + ...eeModelsStats, + }; + + return statistics; +} + +async function getEEStatistics(): Promise { + if (!License.hasModule('livechat-enterprise')) { + return; + } + + const statsPms: Array> = []; + + const statistics: Partial = {}; + + // Number of livechat tags + statsPms.push( + LivechatTag.estimatedDocumentCount().then((count) => { + statistics.livechatTags = count; + return true; + }), + ); + + // Number of canned responses + statsPms.push( + CannedResponse.estimatedDocumentCount().then((count) => { + statistics.cannedResponses = count; + return true; + }), + ); + + // Number of Service Level Agreements + statsPms.push( + OmnichannelServiceLevelAgreements.estimatedDocumentCount().then((count) => { + statistics.slas = count; + return true; + }), + ); + + statsPms.push( + LivechatRooms.countPrioritizedRooms().then((count) => { + statistics.omnichannelRoomsWithPriorities = count; + return true; + }), + ); + + statsPms.push( + LivechatRooms.countRoomsWithSla().then((count) => { + statistics.omnichannelRoomsWithSlas = count; + return true; + }), + ); + + // Number of business units + statsPms.push( + LivechatUnit.countUnits().then((count) => { + statistics.businessUnits = count; + return true; + }), + ); + + statsPms.push( + // Total livechat monitors + Users.countByRole('livechat-monitor').then((count) => { + statistics.livechatMonitors = count; + return true; + }), + ); + // Number of PDF transcript that succeeded + statsPms.push( + LivechatRooms.countRoomsWithTranscriptSent().then((count) => { + statistics.omnichannelPdfTranscriptSucceeded = count; + }), + ); + + await Promise.all(statsPms).catch(log); + + return statistics as EEOnlyStats; +} diff --git a/apps/meteor/server/lib/statistics/lib/getImporterStatistics.ts b/apps/meteor/server/lib/statistics/lib/getImporterStatistics.ts new file mode 100644 index 0000000..381b4fd --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/getImporterStatistics.ts @@ -0,0 +1,10 @@ +import { settings } from '../../../settings'; + +export function getImporterStatistics(): Record { + return { + totalCSVImportedUsers: settings.get('CSV_Importer_Count'), + totalHipchatEnterpriseImportedUsers: settings.get('Hipchat_Enterprise_Importer_Count'), + totalSlackImportedUsers: settings.get('Slack_Importer_Count'), + totalSlackUsersImportedUsers: settings.get('Slack_Users_Importer_Count'), + }; +} diff --git a/apps/meteor/server/lib/statistics/lib/getServicesStatistics.ts b/apps/meteor/server/lib/statistics/lib/getServicesStatistics.ts new file mode 100644 index 0000000..ef41570 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/getServicesStatistics.ts @@ -0,0 +1,143 @@ +import { Users } from '@rocket.chat/models'; +import { MongoInternals } from 'meteor/mongo'; + +import { readSecondaryPreferred } from '../../../database/readSecondaryPreferred'; +import { settings } from '../../../settings'; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +async function getCustomOAuthServices(): Promise< + Record< + string, + { + enabled: boolean; + mergeRoles: boolean; + users: number; + } + > +> { + const readPreference = readSecondaryPreferred(db); + + const customOauth = settings.getByRegexp(/Accounts_OAuth_Custom-[^-]+$/im); + return Object.fromEntries( + await Promise.all( + Object.entries(customOauth).map(async ([key, value]) => { + const name = key.replace('Accounts_OAuth_Custom-', ''); + return [ + name, + { + enabled: Boolean(value), + mergeRoles: settings.get(`Accounts_OAuth_Custom-${name}-merge_roles`), + users: await Users.countActiveUsersByService(name, { readPreference }), + mapChannels: settings.get(`Accounts_OAuth_Custom-${name}-map_channels`), + rolesToSync: !!settings.get(`Accounts_OAuth_Custom-${name}-roles_to_sync`), + }, + ]; + }), + ), + ); +} + +export async function getServicesStatistics(): Promise> { + const readPreference = readSecondaryPreferred(db); + + return { + ldap: { + users: await Users.countActiveUsersByService('ldap', { readPreference }), + enabled: settings.get('LDAP_Enable'), + loginFallback: settings.get('LDAP_Login_Fallback'), + encryption: settings.get('LDAP_Encryption'), + mergeUsers: settings.get('LDAP_Merge_Existing_Users'), + syncRoles: settings.get('LDAP_Sync_User_Data_Roles'), + syncRolesAutoRemove: settings.get('LDAP_Sync_User_Data_Roles_AutoRemove'), + syncData: settings.get('LDAP_Sync_Custom_Fields'), + syncChannels: settings.get('LDAP_Sync_User_Data_Channels'), + syncAvatar: settings.get('LDAP_Sync_User_Avatar'), + groupFilter: settings.get('LDAP_Group_Filter_Enable'), + backgroundSync: { + enabled: settings.get('LDAP_Background_Sync'), + interval: settings.get('LDAP_Background_Sync_Interval'), + newUsers: settings.get('LDAP_Background_Sync_Import_New_Users'), + existingUsers: settings.get('LDAP_Background_Sync_Keep_Existant_Users_Updated'), + }, + ee: { + syncActiveState: settings.get('LDAP_Sync_User_Active_State'), + syncTeams: settings.get('LDAP_Enable_LDAP_Groups_To_RC_Teams'), + syncRoles: settings.get('LDAP_Sync_User_Data_Roles'), + }, + }, + saml: { + enabled: settings.get('SAML_Custom_Default'), + users: await Users.countActiveUsersByService('saml', { readPreference }), + signatureValidationType: settings.get('SAML_Custom_Default_signature_validation_type'), + generateUsername: settings.get('SAML_Custom_Default_generate_username'), + updateSubscriptionsOnLogin: settings.get('SAML_Custom_Default_channels_update'), + syncRoles: settings.get('SAML_Custom_Default_role_attribute_sync'), + userDataCustomFieldMap: !( + settings.getSetting('SAML_Custom_Default_user_data_custom_fieldmap')?.packageValue === + settings.getSetting('SAML_Custom_Default_user_data_custom_fieldmap')?.value + ), + }, + cas: { + enabled: settings.get('CAS_enabled'), + users: await Users.countActiveUsersByService('cas', { readPreference }), + allowUserCreation: settings.get('CAS_Creation_User_Enabled'), + alwaysSyncUserData: settings.get('CAS_Sync_User_Data_Enabled'), + }, + oauth: { + apple: { + enabled: settings.get('Accounts_OAuth_Apple'), + users: await Users.countActiveUsersByService('apple', { readPreference }), + }, + dolphin: { + enabled: settings.get('Accounts_OAuth_Dolphin'), + users: await Users.countActiveUsersByService('dolphin', { readPreference }), + }, + drupal: { + enabled: settings.get('Accounts_OAuth_Drupal'), + users: await Users.countActiveUsersByService('drupal', { readPreference }), + }, + facebook: { + enabled: settings.get('Accounts_OAuth_Facebook'), + users: await Users.countActiveUsersByService('facebook', { readPreference }), + }, + github: { + enabled: settings.get('Accounts_OAuth_Github'), + users: await Users.countActiveUsersByService('github', { readPreference }), + }, + githubEnterprise: { + enabled: settings.get('Accounts_OAuth_GitHub_Enterprise'), + users: await Users.countActiveUsersByService('github_enterprise', { readPreference }), + }, + gitlab: { + enabled: settings.get('Accounts_OAuth_Gitlab'), + users: await Users.countActiveUsersByService('gitlab', { readPreference }), + }, + google: { + enabled: settings.get('Accounts_OAuth_Google'), + users: await Users.countActiveUsersByService('google', { readPreference }), + }, + linkedin: { + enabled: settings.get('Accounts_OAuth_Linkedin'), + users: await Users.countActiveUsersByService('linkedin', { readPreference }), + }, + meteor: { + enabled: settings.get('Accounts_OAuth_Meteor'), + users: await Users.countActiveUsersByService('meteor', { readPreference }), + }, + nextcloud: { + enabled: settings.get('Accounts_OAuth_Nextcloud'), + users: await Users.countActiveUsersByService('nextcloud', { readPreference }), + }, + twitter: { + enabled: settings.get('Accounts_OAuth_Twitter'), + users: await Users.countActiveUsersByService('twitter', { readPreference }), + }, + wordpress: { + enabled: settings.get('Accounts_OAuth_Wordpress'), + users: await Users.countActiveUsersByService('wordpress', { readPreference }), + }, + custom: await getCustomOAuthServices(), + }, + }; +} diff --git a/apps/meteor/server/lib/statistics/lib/statistics.ts b/apps/meteor/server/lib/statistics/lib/statistics.ts new file mode 100644 index 0000000..154ae71 --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/statistics.ts @@ -0,0 +1,578 @@ +import { log } from 'console'; +import crypto from 'node:crypto'; +import os from 'node:os'; + +import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services'; +import type { IStats, ISetting } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { + NotificationQueue, + Rooms, + Statistics, + Sessions, + Integrations, + Invites, + Uploads, + LivechatDepartment, + LivechatVisitors, + EmailInbox, + LivechatBusinessHours, + Messages, + Roles as RolesRaw, + InstanceStatus, + Settings, + LivechatTrigger, + LivechatCustomField, + Subscriptions, + Users, + LivechatRooms, + AbacAttributes, +} from '@rocket.chat/models'; +import { MongoInternals } from 'meteor/mongo'; +import moment from 'moment'; + +import { getAppsStatistics } from './getAppsStatistics'; +import { getContactVerificationStatistics } from './getContactVerificationStatistics'; +import { getStatistics as getEnterpriseStatistics } from './getEEStatistics'; +import { getImporterStatistics } from './getImporterStatistics'; +import { getServicesStatistics } from './getServicesStatistics'; +import { Info } from '../../../../app/utils/rocketchat.info'; +import { readSecondaryPreferred } from '../../../database/readSecondaryPreferred'; +import { getMatrixFederationStatistics } from '../../../services/federation/infrastructure/rocket-chat/adapters/Statistics'; +import { settings } from '../../../settings'; +import { isRunningMs } from '../../isRunningMs'; +import { getControl } from '../../migrations'; +import { getMongoInfo } from '../../utils/functions/getMongoInfo'; +import { getSettingsStatistics } from '../getSettingsStatistics'; + +const getUserLanguages = async (totalUsers: number): Promise<{ [key: string]: number }> => { + const result = await Users.getUserLanguages(); + + const languages: { [key: string]: number } = { + none: totalUsers, + }; + + result.forEach(({ _id, total }: { _id: string; total: number }) => { + if (!_id) { + return; + } + languages[_id] = total; + languages.none -= total; + }); + + return languages; +}; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +export const statistics = { + get: async (): Promise => { + const readPreference = readSecondaryPreferred(db); + + const statistics = {} as IStats; + const statsPms = []; + + // Setup Wizard + const [organizationType, industry, size, country, language, serverType, registerServer] = await Promise.all([ + settings.get('Organization_Type'), + settings.get('Industry'), + settings.get('Size'), + settings.get('Country'), + settings.get('Language'), + settings.get('Server_Type'), + settings.get('Register_Server'), + ]); + + statistics.wizard = { + organizationType, + industry, + size, + country, + language, + serverType, + registerServer, + }; + + // Version + const uniqueID = await Settings.findOne>('uniqueID', { projection: { createdAt: 1 } }); + statistics.uniqueId = settings.get('uniqueID'); + if (uniqueID) { + statistics.installedAt = uniqueID.createdAt.toISOString(); + } + + statistics.deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + statistics.deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); + + if (Info) { + statistics.version = Info.version; + statistics.tag = Info.tag; + statistics.branch = Info.branch; + } + + // User statistics + statistics.totalUsers = await Users.estimatedDocumentCount(); + statistics.activeUsers = await Users.getActiveLocalUserCount(); + statistics.activeGuests = await Users.getActiveLocalGuestCount(); + statistics.nonActiveUsers = await Users.countDocuments({ active: false }); + statistics.appUsers = await Users.countDocuments({ type: 'app' }); + statistics.onlineUsers = await Users.countDocuments({ status: UserStatus.ONLINE }); + statistics.awayUsers = await Users.countDocuments({ status: UserStatus.AWAY }); + statistics.busyUsers = await Users.countDocuments({ status: UserStatus.BUSY }); + statistics.totalConnectedUsers = statistics.onlineUsers + statistics.awayUsers; + statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers - statistics.busyUsers; + statsPms.push( + getUserLanguages(statistics.totalUsers).then((total) => { + statistics.userLanguages = total; + }), + ); + + // Room statistics + statistics.totalRooms = await Rooms.estimatedDocumentCount(); + statistics.totalChannels = await Rooms.countByType('c'); + statistics.totalPrivateGroups = await Rooms.countByType('p'); + statistics.totalDirect = await Rooms.countByType('d'); + statistics.totalLivechat = await Rooms.countByType('l'); + statistics.totalDiscussions = await Rooms.countDiscussions(); + statistics.totalThreads = await Messages.countThreads(); + + // livechat visitors + statistics.totalLivechatVisitors = await LivechatVisitors.estimatedDocumentCount(); + + // livechat agents + statistics.totalLivechatAgents = await Users.countAgents(); + statistics.totalLivechatManagers = await Users.countDocuments({ roles: 'livechat-manager' }); + + // livechat enabled + statistics.livechatEnabled = settings.get('Livechat_enabled'); + + // Count and types of omnichannel rooms + statsPms.push( + Rooms.allRoomSourcesCount() + .toArray() + .then((roomSources) => { + statistics.omnichannelSources = roomSources.map(({ _id: { id, alias, type }, count }) => ({ + id, + alias, + type, + count, + })); + }), + ); + + // Number of livechat rooms with department + statsPms.push( + LivechatRooms.countLivechatRoomsWithDepartment().then((count) => { + statistics.totalLivechatRoomsWithDepartment = count; + }), + ); + + // Number of departments + statsPms.push( + LivechatDepartment.estimatedDocumentCount().then((count) => { + statistics.departments = count; + }), + ); + + // Number of archived departments + statsPms.push( + LivechatDepartment.countArchived().then((count) => { + statistics.archivedDepartments = count; + }), + ); + + // Workspace allows dpeartment removal + statistics.isDepartmentRemovalEnabled = settings.get('Omnichannel_enable_department_removal'); + + // Number of triggers + statsPms.push( + LivechatTrigger.estimatedDocumentCount().then((count) => { + statistics.totalTriggers = count; + }), + ); + + // Number of custom fields + statsPms.push((statistics.totalCustomFields = await LivechatCustomField.estimatedDocumentCount())); + + // Number of public custom fields + statsPms.push((statistics.totalLivechatPublicCustomFields = await LivechatCustomField.countDocuments({ public: true }))); + + // Livechat Automatic forwarding feature enabled + statistics.livechatAutomaticForwardingUnansweredChats = settings.get('Livechat_auto_transfer_chat_timeout') !== 0; + + // Type of routing algorithm used on omnichannel + statistics.routingAlgorithm = settings.get('Livechat_Routing_Method') || ''; + + // is on-hold active + statistics.onHoldEnabled = settings.get('Livechat_allow_manual_on_hold'); + + // Number of Email Inboxes + statsPms.push( + EmailInbox.estimatedDocumentCount().then((count) => { + statistics.emailInboxes = count; + }), + ); + + statsPms.push( + LivechatBusinessHours.estimatedDocumentCount().then((count) => { + statistics.BusinessHours = { + // Number of Business Hours + total: count, + // Business Hours strategy + strategy: settings.get('Livechat_enable_business_hours') || '', + }; + }), + ); + + // Type of routing algorithm used on omnichannel + statistics.routingAlgorithm = settings.get('Livechat_Routing_Method'); + + // is on-hold active + statistics.onHoldEnabled = settings.get('Livechat_allow_manual_on_hold'); + + // Last-Chatted Agent Preferred (enabled/disabled) + statistics.lastChattedAgentPreferred = settings.get('Livechat_last_chatted_agent_routing'); + + // Assign new conversations to the contact manager (enabled/disabled) + statistics.assignNewConversationsToContactManager = settings.get('Omnichannel_contact_manager_routing'); + + // How to handle Visitor Abandonment setting + statistics.visitorAbandonment = settings.get('Livechat_abandoned_rooms_action'); + + // Amount of chats placed on hold + statsPms.push( + Messages.countRoomsWithMessageType('omnichannel_placed_chat_on_hold', { readPreference }).then((total) => { + statistics.chatsOnHold = total; + }), + ); + + const defaultValue = { contactsCount: 0, conversationsCount: 0, sources: [] }; + const billablePeriod = moment.utc().format('YYYY-MM'); + statsPms.push( + LivechatRooms.getMACStatisticsForPeriod(billablePeriod).then(([result]) => { + statistics.omnichannelContactsBySource = result || defaultValue; + }), + ); + + const monthAgo = moment.utc().subtract(30, 'days').toDate(); + const today = moment.utc().toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(monthAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastMonth = result || defaultValue; + }), + ); + + const weekAgo = moment.utc().subtract(7, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(weekAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastWeek = result || defaultValue; + }), + ); + + const yesterday = moment.utc().subtract(1, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(yesterday, today).then(([result]) => { + statistics.uniqueContactsOfYesterday = result || defaultValue; + }), + ); + + // Message statistics + const channels = await Rooms.findByType('c', { projection: { msgs: 1, prid: 1 } }).toArray(); + const totalChannelDiscussionsMessages = channels.reduce(function _countChannelDiscussionsMessages(num: number, room) { + return num + (room.prid ? room.msgs : 0); + }, 0); + statistics.totalChannelMessages = + channels.reduce(function _countChannelMessages(num: number, room) { + return num + room.msgs; + }, 0) - totalChannelDiscussionsMessages; + + const privateGroups = await Rooms.findByType('p', { projection: { msgs: 1, prid: 1 } }).toArray(); + const totalPrivateGroupsDiscussionsMessages = privateGroups.reduce(function _countPrivateGroupsDiscussionsMessages(num: number, room) { + return num + (room.prid ? room.msgs : 0); + }, 0); + statistics.totalPrivateGroupMessages = + privateGroups.reduce(function _countPrivateGroupMessages(num: number, room) { + return num + room.msgs; + }, 0) - totalPrivateGroupsDiscussionsMessages; + + statistics.totalDiscussionsMessages = totalPrivateGroupsDiscussionsMessages + totalChannelDiscussionsMessages; + + statistics.totalDirectMessages = (await Rooms.findByType('d', { projection: { msgs: 1 } }).toArray()).reduce( + function _countDirectMessages(num: number, room) { + return num + room.msgs; + }, + 0, + ); + statistics.totalLivechatMessages = (await Rooms.findByType('l', { projection: { msgs: 1 } }).toArray()).reduce( + function _countLivechatMessages(num: number, room) { + return num + room.msgs; + }, + 0, + ); + statistics.totalMessages = + statistics.totalChannelMessages + + statistics.totalPrivateGroupMessages + + statistics.totalDiscussionsMessages + + statistics.totalDirectMessages + + statistics.totalLivechatMessages; + + statistics.lastLogin = (await Users.getLastLogin())?.toString() || ''; + statistics.lastMessageSentAt = await Messages.getLastTimestamp(); + statistics.lastSeenSubscription = (await Subscriptions.getLastSeen())?.toString() || ''; + + statistics.os = { + type: os.type(), + platform: os.platform(), + arch: os.arch(), + release: os.release(), + uptime: os.uptime(), + loadavg: os.loadavg(), + totalmem: os.totalmem(), + freemem: os.freemem(), + cpus: os.cpus(), + }; + + statistics.process = { + nodeVersion: process.version, + pid: process.pid, + uptime: process.uptime(), + }; + + statistics.deploy = { + method: process.env.DEPLOY_METHOD || 'tar', + platform: process.env.DEPLOY_PLATFORM || 'selfinstall', + }; + + statistics.fips = !!crypto.getFips(); + + statistics.readReceiptsEnabled = settings.get('Message_Read_Receipt_Enabled'); + statistics.readReceiptsDetailed = settings.get('Message_Read_Receipt_Store_Users'); + + statistics.enterpriseReady = true; + statsPms.push( + Uploads.estimatedDocumentCount().then((count) => { + statistics.uploadsTotal = count; + }), + ); + statsPms.push( + Uploads.col + .aggregate( + [ + { + $group: { _id: 'total', total: { $sum: '$size' } }, + }, + ], + { readPreference }, + ) + .toArray() + .then((agg) => { + const [result] = agg; + statistics.uploadsTotalSize = result ? (result as any).total : 0; + }), + ); + + statistics.fileStoreType = settings.get('FileUpload_Storage_Type'); + + statistics.migration = await getControl(); + statsPms.push( + InstanceStatus.countDocuments({ _updatedAt: { $gt: new Date(Date.now() - process.uptime() * 1000 - 2000) } }).then((count) => { + statistics.instanceCount = count; + }), + ); + + const { mongoVersion, mongoStorageEngine } = await getMongoInfo(); + statistics.msEnabled = isRunningMs(); + statistics.oplogEnabled = false; + statistics.mongoVersion = mongoVersion; + statistics.mongoStorageEngine = mongoStorageEngine || ''; + + statsPms.push( + Sessions.getUniqueUsersOfYesterday().then((result) => { + statistics.uniqueUsersOfYesterday = result; + }), + ); + statsPms.push( + Sessions.getUniqueUsersOfLastWeek().then((result) => { + statistics.uniqueUsersOfLastWeek = result; + }), + ); + statsPms.push( + Sessions.getUniqueUsersOfLastMonth().then((result) => { + statistics.uniqueUsersOfLastMonth = result; + }), + ); + statsPms.push( + Sessions.getUniqueDevicesOfYesterday().then((result) => { + statistics.uniqueDevicesOfYesterday = result; + }), + ); + statsPms.push( + Sessions.getUniqueDevicesOfLastWeek().then((result) => { + statistics.uniqueDevicesOfLastWeek = result; + }), + ); + statsPms.push( + Sessions.getUniqueDevicesOfLastMonth().then((result) => { + statistics.uniqueDevicesOfLastMonth = result; + }), + ); + statsPms.push( + Sessions.getUniqueOSOfYesterday().then((result) => { + statistics.uniqueOSOfYesterday = result; + }), + ); + statsPms.push( + Sessions.getUniqueOSOfLastWeek().then((result) => { + statistics.uniqueOSOfLastWeek = result; + }), + ); + statsPms.push( + Sessions.getUniqueOSOfLastMonth().then((result) => { + statistics.uniqueOSOfLastMonth = result; + }), + ); + + statistics.apps = await getAppsStatistics(); + statistics.services = await getServicesStatistics(); + statistics.importer = getImporterStatistics(); + statistics.videoConf = await VideoConf.getStatistics(); + statistics.contactVerification = await getContactVerificationStatistics(); + + // If getSettingsStatistics() returns an error, save as empty object. + statsPms.push( + getSettingsStatistics().then((res) => { + const settingsStatisticsObject = res || {}; + statistics.settings = settingsStatisticsObject; + }), + ); + + statsPms.push( + Integrations.getStatistics({ readPreference }).then((integrations) => { + statistics.integrations = integrations; + }), + ); + + statsPms.push( + NotificationQueue.estimatedDocumentCount().then((count) => { + statistics.pushQueue = count; + }), + ); + + statsPms.push( + getEnterpriseStatistics().then((result) => { + statistics.enterprise = result; + }), + ); + + statsPms.push( + Team.getStatistics().then((result) => { + statistics.teams = result; + }), + ); + + statsPms.push(Analytics.resetSeatRequestCount()); + + // TODO: Is that the best way to do this? maybe we should use a promise.all() + + statistics.dashboardCount = settings.get('Engagement_Dashboard_Load_Count'); + statistics.messageAuditApply = settings.get('Message_Auditing_Apply_Count'); + statistics.messageAuditLoad = settings.get('Message_Auditing_Panel_Load_Count'); + statistics.joinJitsiButton = settings.get('Jitsi_Click_To_Join_Count'); + statistics.slashCommandsJitsi = settings.get('Jitsi_Start_SlashCommands_Count'); + statistics.totalBroadcastRooms = await Rooms.countByBroadcast({ readPreference }); + statistics.totalTriggeredEmails = settings.get('Triggered_Emails_Count'); + statistics.totalRoomsWithStarred = await Messages.countRoomsWithStarredMessages({ readPreference }); + statistics.totalRoomsWithPinned = await Messages.countRoomsWithPinnedMessages({ readPreference }); + statistics.totalUserTOTP = await Users.countActiveUsersTOTPEnable({ readPreference }); + statistics.totalUserEmail2fa = await Users.countActiveUsersEmail2faEnable({ readPreference }); + statistics.totalPinned = await Messages.countPinned({ readPreference }); + statistics.totalStarred = await Messages.countStarred({ readPreference }); + statistics.totalLinkInvitation = await Invites.estimatedDocumentCount(); + statistics.totalLinkInvitationUses = await Invites.countUses(); + statistics.totalEmailInvitation = settings.get('Invitation_Email_Count'); + statistics.totalE2ERooms = await Rooms.countByE2E({ readPreference }); + statistics.logoChange = Object.keys(settings.get('Assets_logo') || {}).includes('url'); + statistics.showHomeButton = settings.get('Layout_Show_Home_Button'); + statistics.totalEncryptedMessages = await Messages.countByType('e2e', { readPreference }); + statistics.totalManuallyAddedUsers = settings.get('Manual_Entry_User_Count'); + statistics.totalSubscriptionRoles = await RolesRaw.countByScope('Subscriptions', { readPreference }); + statistics.totalUserRoles = await RolesRaw.countByScope('Users', { readPreference }); + statistics.totalCustomRoles = await RolesRaw.countCustomRoles({ readPreference }); + statistics.totalWebRTCCalls = settings.get('WebRTC_Calls_Count'); + statistics.uncaughtExceptionsCount = settings.get('Uncaught_Exceptions_Count'); + + const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; + + // Push notification stats + // one bit for each of the following: + const pushEnabled = settings.get('Push_enable') ? 1 : 0; + const pushGatewayEnabled = settings.get('Push_enable_gateway') ? 2 : 0; + const pushGatewayChanged = settings.get('Push_gateway') !== defaultGateway ? 4 : 0; + + statistics.push = pushEnabled | pushGatewayEnabled | pushGatewayChanged; + statistics.pushSecured = settings.get('Push_request_content_from_server'); + + const defaultHomeTitle = (await Settings.findOneById('Layout_Home_Title'))?.packageValue; + statistics.homeTitleChanged = settings.get('Layout_Home_Title') !== defaultHomeTitle; + + const defaultHomeBody = (await Settings.findOneById('Layout_Home_Body'))?.packageValue; + statistics.homeBodyChanged = settings.get('Layout_Home_Body') !== defaultHomeBody; + + const defaultCustomCSS = (await Settings.findOneById('theme-custom-css'))?.packageValue; + statistics.customCSSChanged = settings.get('theme-custom-css') !== defaultCustomCSS; + + const defaultOnLogoutCustomScript = (await Settings.findOneById('Custom_Script_On_Logout'))?.packageValue; + statistics.onLogoutCustomScriptChanged = settings.get('Custom_Script_On_Logout') !== defaultOnLogoutCustomScript; + + const defaultLoggedOutCustomScript = (await Settings.findOneById('Custom_Script_Logged_Out'))?.packageValue; + statistics.loggedOutCustomScriptChanged = settings.get('Custom_Script_Logged_Out') !== defaultLoggedOutCustomScript; + + const defaultLoggedInCustomScript = (await Settings.findOneById('Custom_Script_Logged_In'))?.packageValue; + statistics.loggedInCustomScriptChanged = settings.get('Custom_Script_Logged_In') !== defaultLoggedInCustomScript; + + try { + statistics.dailyPeakConnections = await Presence.getPeakConnections(true); + } catch { + statistics.dailyPeakConnections = 0; + } + + const peak = await Statistics.findMonthlyPeakConnections(); + statistics.maxMonthlyPeakConnections = Math.max(statistics.dailyPeakConnections, peak?.dailyPeakConnections || 0); + + statistics.matrixFederation = await getMatrixFederationStatistics(); + + // ABAC stats + if (License.hasModule('abac')) { + statistics.abacEnabled = settings.get('ABAC_Enabled'); + statsPms.push( + AbacAttributes.estimatedDocumentCount().then((result) => { + statistics.abacTotalAttributes = result; + }), + ); + statsPms.push( + AbacAttributes.countTotalValues().then((result) => { + statistics.abacTotalAttributeValues = result; + }), + ); + statsPms.push( + Rooms.countAbacEnabled().then((result) => { + statistics.abacRoomsEnrolled = result; + }), + ); + } + + statistics.allowUnsafeQueryAndFieldsApiParamsEnabled = process.env.ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS?.toUpperCase() === 'TRUE'; + + await Promise.all(statsPms).catch(log); + + return statistics; + }, + async save(): Promise { + const rcStatistics = await statistics.get(); + rcStatistics.createdAt = new Date(); + const { insertedId } = await Statistics.insertOne(rcStatistics); + rcStatistics._id = insertedId; + + return rcStatistics; + }, +}; diff --git a/apps/meteor/server/lib/statistics/lib/telemetryEvents.ts b/apps/meteor/server/lib/statistics/lib/telemetryEvents.ts new file mode 100644 index 0000000..347ca7e --- /dev/null +++ b/apps/meteor/server/lib/statistics/lib/telemetryEvents.ts @@ -0,0 +1,24 @@ +import type { TelemetryMap, ITelemetryEvent, TelemetryEvents } from '@rocket.chat/core-services'; + +type TelemetryEventResponse = Promise | void; +type TelemetryEventFunction = (data: TelemetryMap[T]) => TelemetryEventResponse; + +class TelemetryEvent implements ITelemetryEvent { + private events = new Map any>(); + + register(name: T, fn: TelemetryEventFunction): void { + this.events.set(name, fn); + } + + call(eventName: T, data: TelemetryMap[T]): TelemetryEventResponse { + const fn = this.events.get(eventName) as TelemetryEventFunction; + if (!fn) { + throw new Error('event not found'); + } + + return fn(data); + } +} + +const telemetryEvent = new TelemetryEvent(); +export default telemetryEvent; diff --git a/apps/meteor/server/lib/statistics/startup/monitor.ts b/apps/meteor/server/lib/statistics/startup/monitor.ts new file mode 100644 index 0000000..02adfc2 --- /dev/null +++ b/apps/meteor/server/lib/statistics/startup/monitor.ts @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { SAUMonitorClass } from '../lib/SAUMonitor'; + +const SAUMonitor = new SAUMonitorClass(); + +Meteor.startup(() => { + let TroubleshootDisableSessionsMonitor: boolean; + settings.watch('Troubleshoot_Disable_Sessions_Monitor', async (value) => { + if (TroubleshootDisableSessionsMonitor === value) { + return; + } + TroubleshootDisableSessionsMonitor = value; + + if (value) { + return SAUMonitor.stop(); + } + + await SAUMonitor.start(); + }); +}); diff --git a/apps/meteor/server/lib/statusVisibility/StatusVisibilityGate.ts b/apps/meteor/server/lib/statusVisibility/StatusVisibilityGate.ts new file mode 100644 index 0000000..e055498 --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/StatusVisibilityGate.ts @@ -0,0 +1,58 @@ +import type { IServiceClass } from '@rocket.chat/core-services'; +import { Settings, StatusVisibility } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; + +export const STATUS_VISIBILITY_SETTING_ID = 'Accounts_StatusVisibility_Enabled'; + +export class StatusVisibilityGate { + private enabledLookup?: Promise; + + private restricted?: Set; + + private pendingSync?: Promise; + + watch(service: IServiceClass): void { + service.onSettingChanged(STATUS_VISIBILITY_SETTING_ID, async ({ setting }) => { + this.enabledLookup = Promise.resolve(setting.value === true); + }); + } + + ensureEnabled(): Promise { + if (!this.enabledLookup) { + this.enabledLookup = Settings.get(STATUS_VISIBILITY_SETTING_ID) + .then((value) => value === true) + .catch(() => { + this.enabledLookup = undefined; + return true; + }); + } + + return this.enabledLookup; + } + + hasRestrictions(targetId: IUser['_id']): boolean { + if (!this.restricted) { + void this.syncRestrictedUsers(); + return true; + } + + return this.restricted.has(targetId); + } + + syncRestrictedUsers(): Promise { + if (!this.pendingSync) { + this.pendingSync = StatusVisibility.getRestrictedUsers() + .then((users) => { + this.restricted = new Set(users); + }) + .catch(() => undefined) + .finally(() => { + this.pendingSync = undefined; + }); + } + + return this.pendingSync; + } +} + +export const statusVisibilityGate = new StatusVisibilityGate(); diff --git a/apps/meteor/server/lib/statusVisibility/effectiveStatus.spec.ts b/apps/meteor/server/lib/statusVisibility/effectiveStatus.spec.ts new file mode 100644 index 0000000..b25e056 --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/effectiveStatus.spec.ts @@ -0,0 +1,40 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { effectiveStatusExpression, effectiveStatusFilter, excludingOfflineFilter } from './effectiveStatus'; + +describe('effectiveStatusFilter', () => { + it('should filter by the stored status when nobody is hidden', () => { + expect(effectiveStatusFilter([UserStatus.ONLINE])).to.be.deep.equal({ status: { $in: [UserStatus.ONLINE] } }); + expect(effectiveStatusFilter([UserStatus.ONLINE], new Set())).to.be.deep.equal({ status: { $in: [UserStatus.ONLINE] } }); + }); + + it('should exclude hidden users from a filter that does not include offline', () => { + expect(effectiveStatusFilter([UserStatus.ONLINE, UserStatus.BUSY], new Set(['alice']))).to.be.deep.equal({ + $and: [{ _id: { $nin: ['alice'] } }, { status: { $in: [UserStatus.ONLINE, UserStatus.BUSY] } }], + }); + }); + + it('should include every hidden user in a filter that includes offline, whatever their stored status', () => { + expect(effectiveStatusFilter([UserStatus.ONLINE, UserStatus.OFFLINE], new Set(['alice']))).to.be.deep.equal({ + $or: [{ _id: { $nin: ['alice'] }, status: { $in: [UserStatus.ONLINE, UserStatus.OFFLINE] } }, { _id: { $in: ['alice'] } }], + }); + }); +}); + +describe('effectiveStatusExpression', () => { + it('should resolve hidden users to offline and leave everyone else untouched', () => { + expect(effectiveStatusExpression(new Set(['alice', 'bob']))).to.be.deep.equal({ + $cond: [{ $in: ['$_id', ['alice', 'bob']] }, UserStatus.OFFLINE, '$status'], + }); + }); +}); + +describe('excludingOfflineFilter', () => { + it('should leave hidden users out, whatever their stored status', () => { + expect(excludingOfflineFilter()).to.be.deep.equal({ status: { $ne: UserStatus.OFFLINE } }); + expect(excludingOfflineFilter(new Set(['alice']))).to.be.deep.equal({ + $and: [{ _id: { $nin: ['alice'] } }, { status: { $ne: UserStatus.OFFLINE } }], + }); + }); +}); diff --git a/apps/meteor/server/lib/statusVisibility/effectiveStatus.ts b/apps/meteor/server/lib/statusVisibility/effectiveStatus.ts new file mode 100644 index 0000000..08514a5 --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/effectiveStatus.ts @@ -0,0 +1,26 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { Filter } from 'mongodb'; + +export const effectiveStatusFilter = (status: UserStatus[], hidden?: Set): Filter => { + if (!hidden?.size) { + return { status: { $in: status } }; + } + + const ids = [...hidden]; + + if (status.includes(UserStatus.OFFLINE)) { + return { $or: [{ _id: { $nin: ids }, status: { $in: status } }, { _id: { $in: ids } }] }; + } + + return { $and: [{ _id: { $nin: ids } }, { status: { $in: status } }] }; +}; + +export const effectiveStatusExpression = (hidden: Set) => ({ + $cond: [{ $in: ['$_id', [...hidden]] }, UserStatus.OFFLINE, '$status'], +}); + +export const excludingOfflineFilter = (hidden?: Set): Filter => + hidden?.size + ? { $and: [{ _id: { $nin: [...hidden] } }, { status: { $ne: UserStatus.OFFLINE } }] } + : { status: { $ne: UserStatus.OFFLINE } }; diff --git a/apps/meteor/server/lib/statusVisibility/hiddenUsers.ts b/apps/meteor/server/lib/statusVisibility/hiddenUsers.ts new file mode 100644 index 0000000..bea987d --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/hiddenUsers.ts @@ -0,0 +1,21 @@ +import { StatusVisibility } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; + +import { redactStatus } from './redactStatus'; +import { settings } from '../../settings'; + +export const getUsersHiddenFrom = async (viewerId: IUser['_id'] | null | undefined): Promise | undefined> => { + if (!settings.get('Accounts_StatusVisibility_Enabled')) { + return undefined; + } + + const hidden = await StatusVisibility.getHiddenFrom(viewerId); + + return hidden.length ? new Set(hidden) : undefined; +}; + +export const filterHiddenUsers = >(users: T[], hidden?: Set): T[] => + hidden ? users.filter((user) => !hidden.has(user._id)) : users; + +export const redactHiddenUsers = >(users: T[], hidden?: Set): T[] => + hidden ? users.map((user) => (hidden.has(user._id) ? redactStatus(user) : user)) : users; diff --git a/apps/meteor/server/lib/statusVisibility/redactStatus.ts b/apps/meteor/server/lib/statusVisibility/redactStatus.ts new file mode 100644 index 0000000..4cc75e7 --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/redactStatus.ts @@ -0,0 +1,21 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +type WithPresenceFields = Partial> & { + status?: string; +}; + +export const redactStatus = (user: T): T => { + const { statusText, statusSource, statusExpiresAt, statusDefault, statusConnection, ...rest } = user; + + return { ...rest, status: 'offline' } as T; +}; + +export const omitStatusVisibilityConfig = (userSettings: IUser['settings']): IUser['settings'] => { + if (!userSettings?.preferences || !('statusVisibilityDenied' in userSettings.preferences)) { + return userSettings; + } + + const { statusVisibilityDenied: _denied, ...preferences } = userSettings.preferences; + + return { ...userSettings, preferences }; +}; diff --git a/apps/meteor/server/lib/statusVisibility/resolveUsers.spec.ts b/apps/meteor/server/lib/statusVisibility/resolveUsers.spec.ts new file mode 100644 index 0000000..505be3c --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/resolveUsers.spec.ts @@ -0,0 +1,46 @@ +import { resolveUsersByIds, resolveUsersByUsernames } from './resolveUsers'; + +const usersByUsernames = jest.fn(); +const usersByIds = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Users: { + findByUsernames: (...args: unknown[]) => usersByUsernames(...args), + findByIds: (...args: unknown[]) => usersByIds(...args), + }, +})); + +describe('status visibility user resolution', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('pairs every resolved id with its username in either direction', async () => { + usersByUsernames.mockReturnValue([ + { _id: 'bbb222', username: 'bruno' }, + { _id: 'ccc333', username: 'carla' }, + ]); + + const byUsername = await resolveUsersByUsernames(['bruno', 'ghost', 'carla']); + + expect(byUsername.usernames).toEqual(['bruno', 'carla']); + expect(byUsername.ids).toEqual(['bbb222', 'ccc333']); + + usersByIds.mockReturnValue([{ _id: 'bbb222', username: 'bruno' }]); + + const byId = await resolveUsersByIds(['bbb222', 'missing']); + + expect(byId.usernames).toEqual(['bruno']); + expect(byId.ids).toEqual(['bbb222']); + }); + + it('resolves to empty arrays without touching the database', async () => { + const { ids, usernames } = await resolveUsersByUsernames([]); + + expect(ids).toEqual([]); + expect(usernames).toEqual([]); + expect(usersByUsernames).not.toHaveBeenCalled(); + expect((await resolveUsersByIds([])).ids).toEqual([]); + expect(usersByIds).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/server/lib/statusVisibility/resolveUsers.ts b/apps/meteor/server/lib/statusVisibility/resolveUsers.ts new file mode 100644 index 0000000..6a06bd7 --- /dev/null +++ b/apps/meteor/server/lib/statusVisibility/resolveUsers.ts @@ -0,0 +1,34 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import type { FindCursor } from 'mongodb'; + +type ResolvedUsers = { ids: IUser['_id'][]; usernames: NonNullable[] }; + +const collectUsers = async (users: FindCursor>): Promise => { + const resolved: ResolvedUsers = { ids: [], usernames: [] }; + + for await (const { _id, username } of users) { + if (username) { + resolved.ids.push(_id); + resolved.usernames.push(username); + } + } + + return resolved; +}; + +export const resolveUsersByIds = async (ids: IUser['_id'][]): Promise => { + if (!ids.length) { + return { ids: [], usernames: [] }; + } + + return collectUsers(Users.findByIds>(ids, { projection: { username: 1 } })); +}; + +export const resolveUsersByUsernames = async (usernames: string[]): Promise => { + if (!usernames.length) { + return { ids: [], usernames: [] }; + } + + return collectUsers(Users.findByUsernames(usernames, { projection: { username: 1 } })); +}; diff --git a/apps/meteor/server/lib/systemMessage/hideSystemMessage.ts b/apps/meteor/server/lib/systemMessage/hideSystemMessage.ts new file mode 100644 index 0000000..8bfdfe5 --- /dev/null +++ b/apps/meteor/server/lib/systemMessage/hideSystemMessage.ts @@ -0,0 +1,15 @@ +import type { MessageTypesValues } from '@rocket.chat/core-typings'; + +export const isMutedUnmuted = (messageType: string): boolean => { + return messageType === 'user-muted' || messageType === 'user-unmuted'; +}; + +export const isMessageRemoved = (messageType: string): boolean => messageType === 'rm'; + +export const shouldHideSystemMessage = (messageType: MessageTypesValues, hideSystemMessage?: MessageTypesValues[]): boolean => { + if (!hideSystemMessage?.length || isMessageRemoved(messageType)) { + return false; + } + + return hideSystemMessage.includes(messageType) || (isMutedUnmuted(messageType) && hideSystemMessage.includes('mute_unmute')); +}; diff --git a/apps/meteor/server/lib/ui-master/index.ts b/apps/meteor/server/lib/ui-master/index.ts new file mode 100644 index 0000000..2504c6f --- /dev/null +++ b/apps/meteor/server/lib/ui-master/index.ts @@ -0,0 +1,170 @@ +import type { ISettingColor } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import { Inject } from 'meteor/meteorhacks:inject-initial'; +import { Tracker } from 'meteor/tracker'; + +import { applyHeadInjections, headInjections, injectIntoBody, injectIntoHead } from './inject'; +import { getMessageMaxParseLength } from '../../../lib/getMessageMaxParseLength'; +import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; +import { settings } from '../../settings'; +import { getURL } from '../utils/getURL'; + +import './scripts'; + +export * from './inject'; + +Meteor.startup(() => { + Tracker.autorun(() => { + const injections = Object.values(headInjections.all()).filter((injection): injection is NonNullable => !!injection); + Inject.rawModHtml('headInjections', applyHeadInjections(injections)); + }); + + settings.watch('Default_Referrer_Policy', (value) => { + if (!value) { + return injectIntoHead('noreferrer', ''); + } + + injectIntoHead('noreferrer', ``); + }); + + settings.watch('Use_RC_SDK', (value) => { + injectIntoHead('Use_RC_SDK', ``); + }); + + if (process.env.DISABLE_ANIMATION) { + injectIntoHead( + 'disable-animation', + ` + + `, + ); + } + + settings.watch('Assets_SvgFavicon_Enable', (value) => { + const standardFavicons = ` + + `; + + if (value) { + injectIntoHead( + 'Assets_SvgFavicon_Enable', + `${standardFavicons} + `, + ); + } else { + injectIntoHead('Assets_SvgFavicon_Enable', standardFavicons); + } + }); + + settings.watch('theme-color-sidebar-background', (value) => { + const escapedValue = escapeHTML(value); + injectIntoHead( + 'theme-color-sidebar-background', + ``, + ); + }); + + settings.watch('Site_Name', (value = 'Rocket.Chat') => { + const escapedValue = escapeHTML(value); + injectIntoHead( + 'Site_Name', + `${escapedValue}` + + `` + + ``, + ); + }); + + settings.watch('Meta_language', (value = '') => { + const escapedValue = escapeHTML(value); + injectIntoHead( + 'Meta_language', + ``, + ); + }); + + settings.watch('Meta_robots', (value = '') => { + const escapedValue = escapeHTML(value); + injectIntoHead('Meta_robots', ``); + }); + + settings.watch('Meta_msvalidate01', (value = '') => { + const escapedValue = escapeHTML(value); + injectIntoHead('Meta_msvalidate01', ``); + }); + + settings.watch('Meta_google-site-verification', (value = '') => { + const escapedValue = escapeHTML(value); + injectIntoHead('Meta_google-site-verification', ``); + }); + + settings.watch('Meta_fb_app_id', (value = '') => { + const escapedValue = escapeHTML(value); + injectIntoHead('Meta_fb_app_id', ``); + }); + + settings.watch('Meta_custom', (value = '') => { + injectIntoHead('Meta_custom', value); + }); + + const baseUrl = ((prefix) => { + if (!prefix) { + return '/'; + } + + prefix = prefix.trim(); + + if (!prefix) { + return '/'; + } + + return /\/$/.test(prefix) ? prefix : `${prefix}/`; + })(__meteor_runtime_config__.ROOT_URL_PATH_PREFIX); + + injectIntoHead('base', ``); + + const escapedMessageMaxParseLength = escapeHTML(String(getMessageMaxParseLength())); + injectIntoHead('MESSAGE_MAX_PARSE_LENGTH', ``); +}); + +const renderDynamicCssList = withDebouncing({ wait: 500 })(async () => { + // const variables = RocketChat.models.Settings.findOne({_id:'theme-custom-variables'}, {fields: { value: 1}}); + const colors = await Settings.find({ _id: /theme-color-rc/i }, { projection: { value: 1, editor: 1 } }).toArray(); + const css = colors + .filter((color): color is ISettingColor => !!color?.value) + .map(({ _id, value, editor }) => { + if (editor === 'expression') { + return `--${_id.replace('theme-color-', '')}: var(--${value});`; + } + return `--${_id.replace('theme-color-', '')}: ${value};`; + }) + .join('\n'); + injectIntoBody('dynamic-variables', ``); +}); + +await renderDynamicCssList(); + +settings.watchByRegex(/theme-color-rc/i, renderDynamicCssList); + +injectIntoBody( + 'react-root', + ` + +
+ +
+`, +); diff --git a/apps/meteor/server/lib/ui-master/inject.ts b/apps/meteor/server/lib/ui-master/inject.ts new file mode 100644 index 0000000..15632b6 --- /dev/null +++ b/apps/meteor/server/lib/ui-master/inject.ts @@ -0,0 +1,134 @@ +import crypto from 'node:crypto'; + +import type { NextHandleFunction } from 'connect'; +import { Inject } from 'meteor/meteorhacks:inject-initial'; +import { ReactiveDict } from 'meteor/reactive-dict'; +import { WebApp } from 'meteor/webapp'; +import parseRequest from 'parseurl'; + +import { getURL } from '../utils/getURL'; + +type Injection = + | string + | { + content: string; + type: 'JS' | 'CSS'; + tag: string; + }; + +export const headInjections = new ReactiveDict>(); + +const callback: NextHandleFunction = (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') { + next(); + return; + } + try { + const rawPath = parseRequest(req); + const pathname = rawPath?.pathname && decodeURIComponent(rawPath.pathname); + + if (!pathname) { + next(); + return; + } + + const injection = headInjections.get(pathname.replace(/^\//, '').split('_')[0]); + + if (!injection || typeof injection === 'string') { + next(); + return; + } + + const serve = + (contentType: string) => + (content: string, cacheControl = 'public, max-age=31536000'): void => { + res.writeHead(200, { + 'Content-type': contentType, + 'cache-control': cacheControl, + 'Content-Length': content.length, + }); + res.write(content); + res.end(); + }; + + const serveStaticJS = serve('application/javascript; charset=UTF-8'); + const serveStaticCSS = serve('text/css; charset=UTF-8'); + + if (injection.type === 'JS') { + serveStaticJS(injection.content); + return; + } + + if (injection.type === 'CSS') { + serveStaticCSS(injection.content); + return; + } + next(); + } catch (e) { + next(); + } +}; + +WebApp.connectHandlers.use(callback); + +export const injectIntoHead = (key: string, value: Injection): void => { + headInjections.set(key, value); +}; + +export const addScript = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addScript - key cannot contain "_" (underscore)'); + } + + if (!content.trim()) { + injectIntoHead(key, ''); + return; + } + const currentHash = crypto.createHash('sha1').update(content).digest('hex'); + + injectIntoHead(key, { + type: 'JS', + tag: ``, + content, + }); +}; + +export const addStyle = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addStyle - key cannot contain "_" (underscore)'); + } + + if (!content.trim()) { + injectIntoHead(key, ''); + return; + } + const currentHash = crypto.createHash('sha1').update(content).digest('hex'); + + injectIntoHead(key, { + type: 'CSS', + tag: ``, + content, + }); +}; + +export const injectIntoBody = (key: string, value: string): void => { + Inject.rawBody(key, value); +}; + +export const applyHeadInjections = (injections: Injection[]): ((html: string) => string) => { + if (injections.length === 0) { + return (html: string): string => html; + } + + const replacementHtml = `${injections + .map((i) => { + if (typeof i === 'string') { + return i; + } + return i.content.trim().length > 0 ? i.tag : ''; + }) + .join('\n') + .replace(/\$/g, '$$$$')}\n`; + + return (html: string): string => html.replace('', replacementHtml); +}; diff --git a/apps/meteor/server/lib/ui-master/scripts.ts b/apps/meteor/server/lib/ui-master/scripts.ts new file mode 100644 index 0000000..a4267f7 --- /dev/null +++ b/apps/meteor/server/lib/ui-master/scripts.ts @@ -0,0 +1,44 @@ +import { addScript } from './inject'; +import { settings } from '../../settings'; + +const getContent = (): string => ` + +${ + process.env.BUGSNAG_CLIENT + ? `window.__BUGSNAG_KEY__ = "${process.env.BUGSNAG_CLIENT}";\n +window.addEventListener('load', function() { + const event = new Event('bugsnag-error-boundary'); + window.dispatchEvent(event); +}); +` + : '' +} + +${process.env.DISABLE_ANIMATION ? 'window.DISABLE_ANIMATION = true;\n' : ''} + +// Custom_Script_Logged_Out +window.addEventListener('Custom_Script_Logged_Out', function() { + ${settings.get('Custom_Script_Logged_Out')} +}) + + +// Custom_Script_Logged_In +window.addEventListener('Custom_Script_Logged_In', function() { + ${settings.get('Custom_Script_Logged_In')} +}) + + +// Custom_Script_On_Logout +window.addEventListener('Custom_Script_On_Logout', function() { + ${settings.get('Custom_Script_On_Logout')} +}) + +${settings.get('Accounts_ForgetUserSessionOnWindowClose') ? `window.Accounts_ForgetUserSessionOnWindowClose = true;` : ''}`; + +settings.watchMultiple( + ['Custom_Script_Logged_Out', 'Custom_Script_Logged_In', 'Custom_Script_On_Logout', 'Accounts_ForgetUserSessionOnWindowClose'], + () => { + const content = getContent(); + addScript('scripts', content); + }, +); diff --git a/apps/meteor/server/lib/unbanUserFromRoom.ts b/apps/meteor/server/lib/unbanUserFromRoom.ts new file mode 100644 index 0000000..435fb4d --- /dev/null +++ b/apps/meteor/server/lib/unbanUserFromRoom.ts @@ -0,0 +1,36 @@ +import { Rooms, Users } from '@rocket.chat/models'; + +import { canAccessRoomAsync } from './authorization'; +import { hasPermissionAsync } from './authorization/hasPermission'; +import { executeUnbanUserFromRoom } from './rooms/executeUnbanUserFromRoom'; +import { roomCoordinator } from './rooms/roomCoordinator'; +import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; + +export const unbanUserFromRoom = async (fromId: string, data: { rid: string; username: string }): Promise => { + if (!(await hasPermissionAsync(fromId, 'ban-user', data.rid))) { + throw new Error('Not allowed'); + } + + const room = await Rooms.findOneById(data.rid); + if (!room || !(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BAN, fromId))) { + throw new Error('Not allowed'); + } + + const fromUser = await Users.findOneById(fromId); + if (!fromUser) { + throw new Error('Invalid user'); + } + + if (!(await canAccessRoomAsync(room, fromUser))) { + throw new Error('The required "roomId" or "roomName" param provided does not match any group'); + } + + const bannedUser = await Users.findOneByUsernameIgnoringCase(data.username); + if (!bannedUser) { + throw new Error('User not found'); + } + + await executeUnbanUserFromRoom(data.rid, bannedUser, fromUser); + + return true; +}; diff --git a/apps/meteor/server/lib/users/blockUser.ts b/apps/meteor/server/lib/users/blockUser.ts new file mode 100644 index 0000000..30b8224 --- /dev/null +++ b/apps/meteor/server/lib/users/blockUser.ts @@ -0,0 +1,35 @@ +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener'; +import { roomCoordinator } from '../rooms/roomCoordinator'; + +export const blockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const room = await Rooms.findOne({ _id: rid }); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/server/lib/users/checkEmailAvailability.ts b/apps/meteor/server/lib/users/checkEmailAvailability.ts new file mode 100644 index 0000000..900f299 --- /dev/null +++ b/apps/meteor/server/lib/users/checkEmailAvailability.ts @@ -0,0 +1,5 @@ +import { Users } from '@rocket.chat/models'; + +export const checkEmailAvailability = async function (email: string): Promise { + return !(await Users.findOneByEmailAddress(email)); +}; diff --git a/apps/meteor/server/lib/users/checkUsernameAvailability.ts b/apps/meteor/server/lib/users/checkUsernameAvailability.ts new file mode 100644 index 0000000..63b4bce --- /dev/null +++ b/apps/meteor/server/lib/users/checkUsernameAvailability.ts @@ -0,0 +1,73 @@ +import { Team } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { settings } from '../../settings'; +import type { UsernameAvailabilityCheckType } from '../callbacks/checkUsernameAvailabilityCallback'; +import { checkUsernameAvailabilityCallback } from '../callbacks/checkUsernameAvailabilityCallback'; +import { validateName } from '../shared/validateName'; + +let usernameBlackList: RegExp[] = []; + +const toRegExp = (username: string): RegExp => new RegExp(`^${escapeRegExp(username).trim()}$`, 'i'); + +settings.watch('Accounts_BlockedUsernameList', (value: string) => { + usernameBlackList = ['all', 'here'].concat(value.split(',')).map(toRegExp); +}); + +const usernameIsBlocked = (username: string, usernameBlackList: RegExp[]): boolean | number => + usernameBlackList.length && usernameBlackList.some((restrictedUsername) => restrictedUsername.test(escapeRegExp(username).trim())); + +export const checkUsernameAvailabilityWithValidation = async function (userId: string, username: string): Promise { + if (!username) { + throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'setUsername' }); + } + + const user = await Users.findOneById(userId, { projection: { username: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUsername' }); + } + + if (user.username && !settings.get('Accounts_AllowUsernameChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setUsername' }); + } + + if (user.username === username) { + return true; + } + return checkUsernameAvailability(username); +}; + +export const checkUsernameAvailability = async function (username: string, type: UsernameAvailabilityCheckType = 'user'): Promise { + if (usernameIsBlocked(username, usernameBlackList) || !validateName(username)) { + throw new Meteor.Error('error-blocked-username', `${_.escape(username)} is blocked and can't be used!`, { + method: 'checkUsernameAvailability', + field: username, + }); + } + + // Make sure no users are using this username + const existingUser = await Users.findOneByUsernameIgnoringCase(username, { + projection: { _id: 1 }, + }); + if (existingUser) { + return false; + } + + // Make sure no teams are using this username + const existingTeam = await Team.getOneByName(toRegExp(username), { projection: { _id: 1 } }); + if (existingTeam) { + return false; + } + + try { + await checkUsernameAvailabilityCallback.run(username, type); + + return true; + } catch (error) { + return false; + } +}; diff --git a/apps/meteor/server/lib/users/deleteUser.ts b/apps/meteor/server/lib/users/deleteUser.ts new file mode 100644 index 0000000..c355422 --- /dev/null +++ b/apps/meteor/server/lib/users/deleteUser.ts @@ -0,0 +1,186 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { api } from '@rocket.chat/core-services'; +import { isUserFederated, type IUser } from '@rocket.chat/core-typings'; +import { + Integrations, + LivechatVisitors, + LivechatDepartmentAgents, + Messages, + Rooms, + Subscriptions, + Users, + ReadReceipts, + ReadReceiptsArchive, + LivechatUnitMonitors, + ModerationReports, +} from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { i18n } from '../i18n'; +import { FileUpload } from '../media/file-upload'; +import { + notifyOnRoomChangedById, + notifyOnIntegrationChangedByUserId, + notifyOnLivechatDepartmentAgentChanged, + notifyOnUserChange, +} from '../notifyListener'; +import { getSubscribedRoomsForUserWithDetails, shouldRemoveOrChangeOwner } from '../rooms/getRoomsWithSingleOwner'; +import { relinquishRoomOwnerships } from '../rooms/relinquishRoomOwnerships'; +import { updateGroupDMsName } from '../rooms/updateGroupDMsName'; + +export async function deleteUser(userId: string, confirmRelinquish = false, deletedBy?: IUser['_id']): Promise<{ deletedRooms: string[] }> { + if (userId === 'rocket.cat') { + throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', { + method: 'deleteUser', + action: 'Delete_user', + }); + } + + const user = await Users.findOneById(userId, { + projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 }, + }); + + if (!user) { + return { deletedRooms: [] }; + } + + if (isUserFederated(user)) { + throw new Meteor.Error('error-not-allowed', 'User participated in federation, this user can only be deactivated permanently', { + method: 'deleteUser', + }); + } + + const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId); + + if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) { + const rooms = await getUserSingleOwnedRooms(subscribedRooms); + throw new Meteor.Error('user-last-owner', '', rooms); + } + + let deletedRooms: string[] = []; + // Users without username can't do anything, so there is nothing to remove + if (user.username != null) { + let userToReplaceWhenUnlinking: IUser | null = null; + const nameAlias = i18n.t('Removed_User'); + deletedRooms = await relinquishRoomOwnerships(userId, subscribedRooms, true); + + const messageErasureType = settings.get<'Delete' | 'Unlink' | 'Keep'>('Message_ErasureType'); + switch (messageErasureType) { + case 'Delete': + const store = FileUpload.getStore('Uploads'); + const cursor = Messages.findFilesByUserId(userId); + + for await (const { file, files } of cursor) { + const fileIds = files?.map(({ _id }) => _id) || []; + for await (const fileId of fileIds) { + if (fileId) { + await store.deleteById(fileId); + } + } + + if (file?._id && !fileIds.includes(file._id)) { + await store.deleteById(file._id); + } + } + + await Messages.removeByUserId(userId); + await ReadReceipts.removeByUserId(userId); + await ReadReceiptsArchive.removeByUserId(userId); + + await ModerationReports.hideMessageReportsByUserId( + userId, + deletedBy || userId, + deletedBy === userId ? 'user deleted own account' : 'user account deleted', + 'DELETE_USER', + ); + + break; + case 'Unlink': + userToReplaceWhenUnlinking = await Users.findOneById('rocket.cat'); + if (!userToReplaceWhenUnlinking?._id || !userToReplaceWhenUnlinking?.username) { + break; + } + await Messages.unlinkUserId(userId, userToReplaceWhenUnlinking?._id, userToReplaceWhenUnlinking?.username, nameAlias); + break; + } + + await Rooms.updateGroupDMsRemovingUsernamesByUsername(user.username, userId); // Remove direct rooms with the user + await Rooms.removeDirectRoomContainingUsername(user.username); // Remove direct rooms with the user + + const rids = subscribedRooms.map((room) => room.rid); + void notifyOnRoomChangedById(rids); + + await Subscriptions.removeByUserId(userId); + + // Remove user as livechat agent + if (user.roles.includes('livechat-agent')) { + const departmentAgents = await LivechatDepartmentAgents.findByAgentId(userId).toArray(); + + const { deletedCount } = await LivechatDepartmentAgents.removeByAgentId(userId); + + if (deletedCount > 0) { + departmentAgents.forEach((depAgent) => { + void notifyOnLivechatDepartmentAgentChanged( + { + _id: depAgent._id, + agentId: userId, + departmentId: depAgent.departmentId, + }, + 'removed', + ); + }); + } + } + + if (user.roles.includes('livechat-monitor')) { + // Remove user as Unit Monitor + await LivechatUnitMonitors.removeByMonitorId(userId); + } + + // This is for compatibility. Since we allowed any user to be contact manager b4, we need to have the same logic + // for deletion. + await LivechatVisitors.removeContactManagerByUsername(user.username); + + // removes user's avatar + if (user.avatarOrigin === 'upload' || user.avatarOrigin === 'url' || user.avatarOrigin === 'rest') { + await FileUpload.getStore('Avatars').deleteByName(user.username); + } + + // Disables all the integrations which rely on the user being deleted. + await Integrations.disableByUserId(userId); + void notifyOnIntegrationChangedByUserId(userId); + + // Don't broadcast user.deleted for Erasure Type of 'Keep' so that messages don't disappear from logged in sessions + if (messageErasureType === 'Delete') { + void api.broadcast('user.deleted', user, { + messageErasureType, + }); + } + if (messageErasureType === 'Unlink' && userToReplaceWhenUnlinking) { + void api.broadcast('user.deleted', user, { + messageErasureType, + replaceByUser: { _id: userToReplaceWhenUnlinking._id, username: userToReplaceWhenUnlinking?.username, alias: nameAlias }, + }); + } + } + + // Remove user from users database + await Users.removeById(userId); + + // App IPostUserDeleted event hook + if (deletedBy) { + await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Users.findOneById(deletedBy) }); + } + + // update name and fname of group direct messages + await updateGroupDMsName(user); + + void notifyOnUserChange({ clientAction: 'removed', id: user._id }); + + await callbacks.run('afterDeleteUser', user); + + return { deletedRooms }; +} diff --git a/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts new file mode 100644 index 0000000..ba5a29b --- /dev/null +++ b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts @@ -0,0 +1,193 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import Gravatar from 'gravatar'; +import { check } from 'meteor/check'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { warnGravatarDeprecation } from './gravatarDeprecation'; +import { settings } from '../../settings'; + +const avatarProviders = { + facebook(user: IUser) { + if (user.services?.facebook?.id && settings.get('Accounts_OAuth_Facebook')) { + return { + service: 'facebook', + url: `https://graph.facebook.com/${user.services.facebook.id}/picture?type=large`, + }; + } + }, + + google(user: IUser) { + if ( + user.services?.google?.picture && + user.services.google.picture !== 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg' && + settings.get('Accounts_OAuth_Google') + ) { + return { + service: 'google', + url: user.services.google.picture, + }; + } + }, + + github(user: IUser) { + if (user.services?.github?.username && settings.get('Accounts_OAuth_Github')) { + return { + service: 'github', + url: `https://avatars.githubusercontent.com/${user.services.github.username}?s=200`, + }; + } + }, + + linkedin(user: IUser) { + if ( + user.services?.linkedin?.profilePicture?.identifiersUrl && + user.services.linkedin.profilePicture.identifiersUrl.length > 0 && + settings.get('Accounts_OAuth_Linkedin') + ) { + const total = user.services.linkedin.profilePicture.identifiersUrl.length; + return { + service: 'linkedin', + url: user.services.linkedin.profilePicture.identifiersUrl[total - 1], + }; + } + }, + + twitter(user: IUser) { + if (user.services?.twitter?.profile_image_url_https && settings.get('Accounts_OAuth_Twitter')) { + return { + service: 'twitter', + url: user.services.twitter.profile_image_url_https.replace(/_normal|_bigger/, ''), + }; + } + }, + + gitlab(user: IUser) { + if (user.services?.gitlab?.avatar_url && settings.get('Accounts_OAuth_Gitlab')) { + return { + service: 'gitlab', + url: user.services.gitlab.avatar_url, + }; + } + }, + + async customOAuth(user: IUser) { + const avatars: { service: string; url: string }[] = []; + if (!user.services) { + return avatars; + } + + await Promise.all( + Object.keys(user.services).map(async (service) => { + if (!user.services) { + return; + } + + if (user.services[service as keyof typeof user.services]._OAuthCustom) { + const services = await ServiceConfiguration.configurations.find({ service }, { fields: { secret: 0 } }).fetchAsync(); + + if (services.length > 0) { + if (user.services[service as keyof typeof user.services].avatarUrl) { + avatars.push({ + service, + url: user.services[service as keyof typeof user.services].avatarUrl, + }); + } + } + } + }), + ); + + return avatars; + }, + + emails(user: IUser) { + const avatars: { service: string; url: string }[] = []; + + // Offline (air-gapped) licenses suppress Gravatar lookups: every suggested + // URL is fetched server-side below, and gravatar.com is not admin-configured + // infrastructure (unlike OAuth provider avatars, which keep working). + if (License.hasOfflineLicense()) { + return avatars; + } + + if (user.emails && user.emails.length > 0) { + warnGravatarDeprecation(); + + for (const email of user.emails) { + if (email.verified === true) { + avatars.push({ + service: 'gravatar', + url: Gravatar.url(email.address, { + default: '404', + size: '200', + protocol: 'https', + }), + }); + } + + if (email.verified !== true) { + avatars.push({ + service: 'gravatar', + url: Gravatar.url(email.address, { + default: '404', + size: '200', + protocol: 'https', + }), + }); + } + } + } + return avatars; + }, +}; + +/** + * @return {Object} + */ +export async function getAvatarSuggestionForUser( + user: IUser, +): Promise> { + check(user, Object); + + const avatars = []; + + for (const avatarProvider of Object.values(avatarProviders)) { + const avatar = await avatarProvider(user); + if (avatar) { + if (Array.isArray(avatar)) { + avatars.push(...avatar); + } else { + avatars.push(avatar); + } + } + } + + const validAvatars: Record = {}; + for (const avatar of avatars) { + try { + const response = await fetch(avatar.url, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + const newAvatar: { service: string; url: string; blob: string; contentType: string } = { + service: avatar.service, + url: avatar.url, + blob: '', + contentType: '', + }; + + if (response.status === 200) { + let blob = `data:${response.headers.get('content-type')};base64,`; + blob += Buffer.from(await response.arrayBuffer()).toString('base64'); + newAvatar.blob = blob; + newAvatar.contentType = response.headers.get('content-type') as string; + validAvatars[avatar.service] = newAvatar; + } + } catch (error) { + // error; + } + } + return validAvatars; +} diff --git a/apps/meteor/server/lib/users/getFullUserData.ts b/apps/meteor/server/lib/users/getFullUserData.ts new file mode 100644 index 0000000..f9bb707 --- /dev/null +++ b/apps/meteor/server/lib/users/getFullUserData.ts @@ -0,0 +1,158 @@ +import type { IUser, IUserEmail } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../settings'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { getUsersHiddenFrom } from '../statusVisibility/hiddenUsers'; +import { redactStatus } from '../statusVisibility/redactStatus'; +import { resolveUsersByIds } from '../statusVisibility/resolveUsers'; + +const logger = new Logger('getFullUserData'); + +export const defaultFields = { + name: 1, + username: 1, + nickname: 1, + status: 1, + utcOffset: 1, + type: 1, + active: 1, + bio: 1, + reason: 1, + statusText: 1, + statusSource: 1, + statusExpiresAt: 1, + avatarETag: 1, + federated: 1, + statusLivechat: 1, + abacAttributes: 1, + freeSwitchExtension: 1, +} as const; + +export const fullFields = { + emails: 1, + phone: 1, + statusConnection: 1, + bio: 1, + createdAt: 1, + lastLogin: 1, + requirePasswordChange: 1, + requirePasswordChangeReason: 1, + roles: 1, + importIds: 1, +} as const; + +let publicCustomFields: Record = {}; +let customFields: Record = {}; + +settings.watch('Accounts_CustomFields', (settingValue) => { + publicCustomFields = {}; + customFields = {}; + + const value = settingValue?.trim(); + if (!value) { + return; + } + + try { + const customFieldsOnServer = JSON.parse(value); + Object.keys(customFieldsOnServer).forEach((key) => { + const element = customFieldsOnServer[key]; + if (element.public) { + publicCustomFields[`customFields.${key}`] = 1; + } + customFields[`customFields.${key}`] = 1; + }); + } catch (e) { + logger.warn({ + msg: 'The JSON specified for "Accounts_CustomFields" is invalid. The following error was thrown', + err: e, + }); + } +}); + +const getCustomFields = (canViewAllInfo: boolean): Record => (canViewAllInfo ? customFields : publicCustomFields); + +const getFields = (canViewAllInfo: boolean): Record => ({ + ...defaultFields, + ...(canViewAllInfo && fullFields), + ...getCustomFields(canViewAllInfo), +}); + +const findTargetUser = (type: string, value: string, opts: any) => { + if (type === 'importId') return Users.findOneByImportId(value, opts); + if (type === 'email') return Users.findOneByEmailAddress(value, opts); + if (type === 'freeSwitchExtension') return Users.findOneByFreeSwitchExtension(value, opts); + return Users.findOneByIdOrUsername(value, opts); +}; + +export async function getFullUserDataByUniqueSearchTerm( + userId: string, + searchValue: string, + searchType: 'id' | 'username' | 'importId' | 'email' | 'freeSwitchExtension', +): Promise { + const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1, freeSwitchExtension: 1 } }); + + if (!caller) { + return null; + } + + const myself = + (searchType === 'id' && searchValue === userId) || + (searchType === 'username' && searchValue === caller.username) || + (searchType === 'importId' && caller.importIds?.includes(searchValue)) || + (searchType === 'freeSwitchExtension' && caller.freeSwitchExtension === searchValue) || + (searchType === 'email' && + caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase())); + + const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); + + // Only search for importId/email if the user has permission to view them + if (['importId', 'email'].includes(searchType) && !canViewAllInfo) { + return null; + } + + const fields = getFields(canViewAllInfo); + + // When PDP type is not local, Rocket.Chat doesn't manage ABAC attributes, so there's no point in fetching them + if (settings.get('ABAC_PDP_Type') !== 'local') { + delete fields.abacAttributes; + } + + const options = { + projection: { + ...fields, + ...(myself && { + services: 1, + ...(settings.get('Accounts_StatusVisibility_Enabled') && { 'settings.preferences.statusVisibilityDenied': 1 }), + }), + }, + }; + + const user = await findTargetUser(searchType, searchValue, options); + + if (!user) { + return null; + } + + user.canViewAllInfo = canViewAllInfo; + + if (user?.services?.password) { + (user.services.password as any) = true; + } + + delete user?.services?.passwordHistory; + delete user?.services?.resume; + delete user?.services?.email; + + const ownBlockList = myself ? user.settings?.preferences?.statusVisibilityDenied : undefined; + + if (settings.get('Accounts_StatusVisibility_Enabled') && ownBlockList?.length && user.settings?.preferences) { + user.settings.preferences.statusVisibilityDenied = (await resolveUsersByIds(ownBlockList)).usernames; + } + + const hidden = await getUsersHiddenFrom(userId); + + return hidden?.has(user._id) ? redactStatus(user) : user; +} diff --git a/apps/meteor/server/lib/users/getStatusText.ts b/apps/meteor/server/lib/users/getStatusText.ts new file mode 100644 index 0000000..75982aa --- /dev/null +++ b/apps/meteor/server/lib/users/getStatusText.ts @@ -0,0 +1,19 @@ +import { Users } from '@rocket.chat/models'; + +export const getStatusText = async function (userId: string): Promise { + if (!userId) { + return; + } + + const projection = { + statusText: 1, + }; + + const options = { + projection, + limit: 1, + }; + + const data = await Users.findOneById(userId, options); + return data?.statusText; +}; diff --git a/apps/meteor/server/lib/users/getUserCreatedByApp.ts b/apps/meteor/server/lib/users/getUserCreatedByApp.ts new file mode 100644 index 0000000..f697e1f --- /dev/null +++ b/apps/meteor/server/lib/users/getUserCreatedByApp.ts @@ -0,0 +1,13 @@ +import type { UserType } from '@rocket.chat/apps-engine/definition/users'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import type { FindOptions } from 'mongodb'; + +export async function getUserCreatedByApp( + appId: string, + type: UserType.BOT | UserType.APP, + options?: FindOptions, +): Promise> { + const users = await Users.find({ appId, type }, options).toArray(); + return users ?? []; +} diff --git a/apps/meteor/server/lib/users/getUserSingleOwnedRooms.ts b/apps/meteor/server/lib/users/getUserSingleOwnedRooms.ts new file mode 100644 index 0000000..01c9046 --- /dev/null +++ b/apps/meteor/server/lib/users/getUserSingleOwnedRooms.ts @@ -0,0 +1,31 @@ +import { Rooms } from '@rocket.chat/models'; + +import type { SubscribedRoomsForUserWithDetails } from '../rooms/getRoomsWithSingleOwner'; + +export const getUserSingleOwnedRooms = async function (subscribedRooms: SubscribedRoomsForUserWithDetails[]) { + const roomsThatWillChangeOwner = subscribedRooms + .filter(({ shouldChangeOwner }) => shouldChangeOwner) + .map(({ rid }: { rid: string }) => rid); + const roomsThatWillBeRemoved = subscribedRooms.filter(({ shouldBeRemoved }) => shouldBeRemoved).map(({ rid }: { rid: string }) => rid); + + const roomIds = roomsThatWillBeRemoved.concat(roomsThatWillChangeOwner); + const rooms = Rooms.findByIds(roomIds, { projection: { _id: 1, name: 1, fname: 1 } }); + + const result = { + shouldBeRemoved: [] as string[], + shouldChangeOwner: [] as string[], + }; + + await rooms.forEach((room) => { + const name = room.fname || room.name; + if (roomsThatWillBeRemoved.includes(room._id)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result.shouldBeRemoved.push(name!); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + result.shouldChangeOwner.push(name!); + } + }); + + return result; +}; diff --git a/apps/meteor/server/lib/users/getUsernameSuggestion.ts b/apps/meteor/server/lib/users/getUsernameSuggestion.ts new file mode 100644 index 0000000..373bd6b --- /dev/null +++ b/apps/meteor/server/lib/users/getUsernameSuggestion.ts @@ -0,0 +1,79 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import limax from 'limax'; + +import { settings } from '../../settings'; + +function slug(text: string): string { + return limax(text, { replacement: '.' }).replace(/[^0-9a-z-_.]/g, ''); +} + +async function usernameIsAvailable(username: string): Promise { + if (username.length === 0) { + return false; + } + + if (username === 'all') { + return false; + } + + return !(await Users.findOneByUsernameIgnoringCase(username, {})); +} + +const name = (username: string): string => (settings.get('UTF8_Names_Slugify') ? slug(username) : username); + +export async function generateUsernameSuggestion(user: Pick): Promise { + let usernames = []; + + if (user.name) { + usernames.push(name(user.name)); + + const nameParts = user.name.split(' '); + + if (nameParts.length > 1) { + const [first] = nameParts; + const last = nameParts[nameParts.length - 1]; + usernames.push(name(first[0] + last)); + usernames.push(name(first + last[0])); + } + } + + if (user?.name) { + usernames.push(name(user.name)); + } + + if (Array.isArray(user.services)) { + const services = [ + ...new Set(user.services.flatMap(({ name, username, firstName, lastName }) => [name, username, firstName, lastName])), + ]; + usernames.push(...services.map(name)); + } + + if (user.emails && user.emails.length > 0) { + for (const email of user.emails) { + if (email.address && email.verified === true) { + usernames.push(slug(email.address.replace(/@.+$/, ''))); + usernames.push(slug(email.address.replace(/(.+)@(\w+).+/, '$1.$2'))); + } + } + } + + usernames = usernames.filter((e) => e); + + for (const item of usernames) { + if (await usernameIsAvailable(item)) { + return item; + } + } + + usernames.push(settings.get('Accounts_DefaultUsernamePrefixSuggestion')); + + let index = await Users.countDocuments({ username: new RegExp(`^${usernames[0]}-[0-9]+`) }); + const username = ''; + while (!username) { + if (await usernameIsAvailable(`${usernames[0]}-${index}`)) { + return `${usernames[0]}-${index}`; + } + index++; + } +} diff --git a/apps/meteor/server/lib/users/gravatarDeprecation.ts b/apps/meteor/server/lib/users/gravatarDeprecation.ts new file mode 100644 index 0000000..1869ebb --- /dev/null +++ b/apps/meteor/server/lib/users/gravatarDeprecation.ts @@ -0,0 +1,23 @@ +// Removal checklist (next major): gravatar branch in saveNewUser.ts, `emails` +// provider in getAvatarSuggestionForUser.ts, the `service !== 'gravatar'` +// filters in setUsername.ts and auth/startup.js, the `gravatar` and +// `@types/gravatar` dependencies, and the Accounts_SetDefaultAvatar +// deprecation alert (setting registration + i18n key). Existing user documents +// keep +// `avatarOrigin: 'gravatar'` (and their stored Avatars file) — harmless, no +// migration needed; avatarOrigin is only compared against upload/url/rest. +import { SystemLogger } from '../logger/system'; + +let warned = false; + +export function warnGravatarDeprecation(): void { + if (warned) { + return; + } + + warned = true; + + SystemLogger.warn( + 'Gravatar integration (default avatars and avatar suggestions based on the user e-mail address) is deprecated and will be removed in a future major release.', + ); +} diff --git a/apps/meteor/server/lib/users/runAfterVerifyEmail.ts b/apps/meteor/server/lib/users/runAfterVerifyEmail.ts new file mode 100644 index 0000000..f2bf9ef --- /dev/null +++ b/apps/meteor/server/lib/users/runAfterVerifyEmail.ts @@ -0,0 +1,31 @@ +import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { addUserRolesAsync } from '../roles/addUserRoles'; +import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles'; + +const rolesToChangeTo: Map = new Map([['anonymous', ['user']]]); + +export const runAfterVerifyEmail = async (userId: IUser['_id']): Promise => { + const user = await Users.findOneById(userId); + if (!user?.emails || !Array.isArray(user.emails)) { + return; + } + + const verifiedEmail = user.emails.find((email) => email.verified); + if (!verifiedEmail) { + return; + } + + const rolesThatNeedChanges = user.roles.filter((role) => rolesToChangeTo.has(role)); + + await Promise.all( + rolesThatNeedChanges.map(async (role) => { + const rolesToAdd = rolesToChangeTo.get(role); + if (rolesToAdd) { + await addUserRolesAsync(userId, rolesToAdd); + } + await removeUserFromRolesAsync(user._id, [role]); + }), + ); +}; diff --git a/apps/meteor/server/lib/users/saveCustomFields.ts b/apps/meteor/server/lib/users/saveCustomFields.ts new file mode 100644 index 0000000..1df74c5 --- /dev/null +++ b/apps/meteor/server/lib/users/saveCustomFields.ts @@ -0,0 +1,21 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +import { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation'; +import { validateCustomFields } from './validateCustomFields'; +import { trim } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; + +export const saveCustomFields = async function ( + userId: string, + formData: Record, + options?: { _updater?: Updater; session?: ClientSession }, +): Promise { + if (trim(settings.get('Accounts_CustomFields')).length === 0) { + return; + } + + validateCustomFields(formData); + return saveCustomFieldsWithoutValidation(userId, formData, options); +}; diff --git a/apps/meteor/server/lib/users/saveCustomFieldsWithoutValidation.ts b/apps/meteor/server/lib/users/saveCustomFieldsWithoutValidation.ts new file mode 100644 index 0000000..78f9c60 --- /dev/null +++ b/apps/meteor/server/lib/users/saveCustomFieldsWithoutValidation.ts @@ -0,0 +1,72 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { settings } from '../../settings'; +import { notifyOnSubscriptionChangedByUserIdAndRoomType } from '../notifyListener'; + +const getCustomFieldsMeta = function (customFieldsMeta: string) { + try { + return JSON.parse(customFieldsMeta); + } catch (e) { + throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields'); + } +}; +export const saveCustomFieldsWithoutValidation = async function ( + userId: string, + formData: Record, + options?: { + _updater?: Updater; + session?: ClientSession; + }, +): Promise { + const customFieldsSetting = settings.get('Accounts_CustomFields'); + if (!customFieldsSetting || trim(customFieldsSetting).length === 0) { + return; + } + + // configured custom fields in setting + const customFieldsMeta = getCustomFieldsMeta(customFieldsSetting); + + const customFields = Object.fromEntries( + Object.keys(formData) + .filter((key) => Object.hasOwn(customFieldsMeta, key)) + .map((key) => [key, formData[key]]), + ); + + const { _updater, session } = options || {}; + + const updater = _updater || Users.getUpdater(); + // add modified records to updater + Object.keys(customFields).forEach((fieldName) => { + // @ts-expect-error TODO `Updater.set` does not support `customFields.${fieldName}` syntax + updater.set(`customFields.${fieldName}`, customFields[fieldName]); + + if (!customFieldsMeta[fieldName].modifyRecordField) { + return; + } + + const { modifyRecordField } = customFieldsMeta[fieldName]; + + if (modifyRecordField.array) { + updater.addToSet(modifyRecordField.field, customFields[fieldName]); + } else { + updater.set(modifyRecordField.field, customFields[fieldName]); + } + }); + + if (!_updater) { + await Users.updateFromUpdater({ _id: userId }, updater, { session }); + } + + await onceTransactionCommitedSuccessfully(async () => { + const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields); + if (setCustomFieldsResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd'); + } + }, session); +}; diff --git a/apps/meteor/server/lib/users/saveUser/handleBio.ts b/apps/meteor/server/lib/users/saveUser/handleBio.ts new file mode 100644 index 0000000..71dcbb7 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/handleBio.ts @@ -0,0 +1,20 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/model-typings'; + +import type { SaveUserData } from './saveUser'; + +const MAX_BIO_LENGTH = 260; + +export const handleBio = (userUpdater: Updater, bio: SaveUserData['bio']) => { + if (bio?.trim()) { + if (bio.length > MAX_BIO_LENGTH) { + throw new MeteorError('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + userUpdater.set('bio', bio); + } else { + userUpdater.unset('bio'); + } +}; diff --git a/apps/meteor/server/lib/users/saveUser/handleNickname.ts b/apps/meteor/server/lib/users/saveUser/handleNickname.ts new file mode 100644 index 0000000..164f393 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/handleNickname.ts @@ -0,0 +1,20 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/model-typings'; + +import type { SaveUserData } from './saveUser'; + +const MAX_NICKNAME_LENGTH = 120; + +export const handleNickname = (userUpdater: Updater, nickname: SaveUserData['nickname']) => { + if (nickname?.trim()) { + if (nickname.length > MAX_NICKNAME_LENGTH) { + throw new MeteorError('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + userUpdater.set('nickname', nickname); + } else { + userUpdater.unset('nickname'); + } +}; diff --git a/apps/meteor/server/lib/users/saveUser/index.ts b/apps/meteor/server/lib/users/saveUser/index.ts new file mode 100644 index 0000000..3fd0668 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/index.ts @@ -0,0 +1,2 @@ +export { saveUser } from './saveUser'; +export { validateUserEditing } from './validateUserEditing'; diff --git a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts new file mode 100644 index 0000000..0eb3fe9 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts @@ -0,0 +1,96 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { Users } from '@rocket.chat/models'; +import Gravatar from 'gravatar'; +import { Accounts } from 'meteor/accounts-base'; + +import { notifyOnUserChangeById } from '../../notifyListener'; +import { validateEmailDomain } from '../../validateEmailDomain'; +import { warnGravatarDeprecation } from '../gravatarDeprecation'; +import { setUserAvatar } from '../setUserAvatar'; +import { handleBio } from './handleBio'; +import { handleNickname } from './handleNickname'; +import type { SaveUserData } from './saveUser'; +import { sendPasswordEmail, sendWelcomeEmail } from './sendUserEmail'; +import { getNewUserRoles } from '../../../services/user/lib/getNewUserRoles'; +import { settings } from '../../../settings'; + +export const saveNewUser = async function (userData: SaveUserData, sendPassword: boolean, performedBy: IUser) { + await validateEmailDomain(userData.email); + + const roles = (!!userData.roles && userData.roles.length > 0 && userData.roles) || getNewUserRoles(); + const isGuest = roles?.length === 1 && roles.includes('guest'); + + // insert user + const createUser: Record = { + username: userData.username, + password: userData.password, + ...(userData.name && { name: userData.name }), + joinDefaultChannels: userData.joinDefaultChannels, + isGuest, + globalRoles: roles, + skipNewUserRolesSetting: true, + performedBy, + }; + if (userData.email) { + createUser.email = userData.email; + } + + const _id = await Accounts.createUserAsync(createUser); + + const updater = Users.getUpdater(); + + updater.set('settings', userData.settings || {}); + if (typeof userData.name !== 'undefined') { + updater.set('name', userData.name); + } + + if (typeof userData.requirePasswordChange !== 'undefined') { + updater.set('requirePasswordChange', userData.requirePasswordChange); + } + + if (typeof userData.verified === 'boolean') { + updater.set('emails.0.verified', userData.verified); + } + + if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== '') { + updater.set('freeSwitchExtension', userData.freeSwitchExtension); + } + + handleBio(updater, userData.bio); + handleNickname(updater, userData.nickname); + + await Users.updateFromUpdater({ _id }, updater); + + if (userData.sendWelcomeEmail) { + await sendWelcomeEmail(userData); + } + + if (sendPassword) { + await sendPasswordEmail(userData); + } + + userData._id = _id; + + // Offline (air-gapped) licenses suppress the default Gravatar fetch — a + // default-on outbound call the workspace must never initiate on its own. + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email && !License.hasOfflineLicense()) { + warnGravatarDeprecation(); + + const gravatarUrl = Gravatar.url(userData.email, { + default: '404', + size: '200', + protocol: 'https', + }); + + try { + await setUserAvatar({ ...userData, _id }, gravatarUrl, '', 'url'); + } catch (e) { + // Ignore this error for now, as it not being successful isn't bad + } + } + + void notifyOnUserChangeById({ clientAction: 'inserted', id: _id }); + + return _id; +}; diff --git a/apps/meteor/server/lib/users/saveUser/saveUser.ts b/apps/meteor/server/lib/users/saveUser/saveUser.ts new file mode 100644 index 0000000..0b2430c --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/saveUser.ts @@ -0,0 +1,261 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { MeteorError } from '@rocket.chat/core-services'; +import { isUserFederated } from '@rocket.chat/core-typings'; +import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; + +import { handleBio } from './handleBio'; +import { handleNickname } from './handleNickname'; +import { saveNewUser } from './saveNewUser'; +import { sendPasswordEmail } from './sendUserEmail'; +import { setPasswordUpdater } from './setPasswordUpdater'; +import { validateUserData } from './validateUserData'; +import { validateUserEditing } from './validateUserEditing'; +import { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../database/utils'; +import type { UserChangedAuditStore } from '../../auditServerEvents/userChanged'; +import { generatePassword } from '../../auth/generatePassword'; +import { passwordPolicy } from '../../auth/passwordPolicy'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { callbacks } from '../../callbacks'; +import { notifyOnUserChange } from '../../notifyListener'; +import { shouldBreakInVersion } from '../../shouldBreakInVersion'; +import { saveCustomFields } from '../saveCustomFields'; +import { saveUserIdentity } from '../saveUserIdentity'; +import { setEmail } from '../setEmail'; +import { setStatusText } from '../setStatusText'; + +export type SaveUserData = { + _id?: IUser['_id']; + setRandomPassword?: boolean; + + password?: string; + requirePasswordChange?: boolean; + + username?: string; + name?: string; + + statusText?: string; + email?: string; + verified?: boolean; + + bio?: string; + nickname?: string; + + roles?: IRole['_id'][]; + settings?: Partial; + language?: string; + + joinDefaultChannels?: boolean; + sendWelcomeEmail?: boolean; + + customFields?: Record; + active?: boolean; + + freeSwitchExtension?: string; +}; +export type UpdateUserData = RequiredField; +export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id; + +type SaveUserOptions = { + auditStore?: UserChangedAuditStore; +}; + +const findUserById = async (uid: IUser['_id']): Promise => { + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + return user; +}; + +const _saveUser = (session?: ClientSession) => + async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) { + const performedBy = await findUserById(userId); + + const oldUserData = userData._id && (await Users.findOneById(userData._id)); + if (oldUserData && isUserFederated(oldUserData)) { + throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user'); + } + + await validateUserData(userId, userData); + + await callbacks.run('beforeSaveUser', { + user: userData, + oldUser: oldUserData, + }); + + let sendPassword = false; + + if (userData.hasOwnProperty('setRandomPassword')) { + if (userData.setRandomPassword) { + userData.password = generatePassword(); + userData.requirePasswordChange = true; + sendPassword = true; + } + + delete userData.setRandomPassword; + } + + if (!isUpdateUserData(userData)) { + // TODO audit new users + return saveNewUser(userData, sendPassword, performedBy); + } + + if (!oldUserData) { + throw new MeteorError('error-user-not-found', 'User not found', { + method: 'saveUser', + }); + } + + options?.auditStore?.setOriginalUser(oldUserData); + + await validateUserEditing(userId, userData); + + // update user + const updater = Users.getUpdater(); + + if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) { + if ( + !(await saveUserIdentity({ + _id: userData._id, + username: userData.username, + name: userData.name, + updateUsernameInBackground: true, + updater, + session, + })) + ) { + throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { + method: 'saveUser', + }); + } + } + + if (typeof userData.statusText === 'string') { + await setStatusText(oldUserData, userData.statusText, { updater, session }); + } + + if (userData.email) { + const shouldSendVerificationEmailToUser = userData.verified !== true; + await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater); + } + + if ( + userData.password?.trim() && + (await hasPermissionAsync(userId, 'edit-other-user-password')) && + passwordPolicy.validate(userData.password) + ) { + await setPasswordUpdater(updater, userData.password.trim()); + } else { + sendPassword = false; + } + + handleBio(updater, userData.bio); + handleNickname(updater, userData.nickname); + + if (userData.roles) { + updater.set('roles', userData.roles); + } + if (userData.settings) { + updater.set('settings', { preferences: userData.settings.preferences }); + } + + if (userData.language) { + updater.set('language', userData.language); + } + + if (typeof userData.requirePasswordChange !== 'undefined') { + updater.set('requirePasswordChange', userData.requirePasswordChange); + if (!userData.requirePasswordChange) { + updater.unset('requirePasswordChangeReason'); + } + } + + if (typeof userData.freeSwitchExtension === 'string' && userData.freeSwitchExtension !== (oldUserData?.freeSwitchExtension ?? '')) { + if (userData.freeSwitchExtension.trim() === '') { + updater.unset('freeSwitchExtension'); + } else { + updater.set('freeSwitchExtension', userData.freeSwitchExtension); + } + } + + if (typeof userData.verified === 'boolean') { + if (oldUserData && 'emails' in oldUserData && oldUserData.emails?.some(({ address }) => address === userData.email)) { + const index = oldUserData.emails.findIndex(({ address }) => address === userData.email); + updater.set(`emails.${index}.verified`, userData.verified); + } + if (!userData.email) { + updater.set(`emails.0.verified`, userData.verified); + } + } + + if (userData.customFields) { + await saveCustomFields(userData._id, userData.customFields, { _updater: updater, session }); + } + + await Users.updateFromUpdater({ _id: userData._id }, updater, { session }); + + await onceTransactionCommitedSuccessfully(async () => { + if (session && options?.auditStore) { + // setting this inside here to avoid moving `executeSetUserActiveStatus` from the endpoint fn + // updater will be commited by this point, so it won't affect the external user activation/deactivation + if (userData.active !== undefined) { + updater.set('active', userData.active); + } + options.auditStore.setUpdateFilter(updater.getRawUpdateFilter()); + void options.auditStore.commitAuditEvent(); + } + + // App IPostUserUpdated event hook + // We need to pass the session here to ensure this record is fetched + // with the uncommited transaction data. + const userUpdated = await Users.findOneById(userData._id); + + await callbacks.run('afterSaveUser', { + user: userUpdated, + oldUser: oldUserData, + }); + + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { + user: userUpdated, + previousUser: oldUserData, + performedBy, + }); + + if (sendPassword) { + await sendPasswordEmail(userData); + } + + if (typeof userData.verified === 'boolean') { + delete userData.verified; + } + void notifyOnUserChange({ + clientAction: 'updated', + id: userData._id, + diff: { + ...userData, + emails: userUpdated?.emails, + }, + }); + }, session); + + return true; + }; + +const isBroken = shouldBreakInVersion('9.0.0'); +export const saveUser = (() => { + if (!process.env.DEBUG_DISABLE_USER_AUDIT) { + return wrapInSessionTransaction(_saveUser); + } + + if (isBroken) { + throw new Error('DEBUG_DISABLE_USER_AUDIT flag is deprecated and should be removed'); + } + const saveUserNoSession = _saveUser(); + return function saveUser(userId: IUser['_id'], userData: SaveUserData, _options?: any) { + return saveUserNoSession(userId, userData); + }; +})(); diff --git a/apps/meteor/server/lib/users/saveUser/sendUserEmail.ts b/apps/meteor/server/lib/users/saveUser/sendUserEmail.ts new file mode 100644 index 0000000..5a9f71b --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/sendUserEmail.ts @@ -0,0 +1,55 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { Meteor } from 'meteor/meteor'; + +import type { SaveUserData } from './saveUser'; +import { settings } from '../../../settings'; +import * as Mailer from '../../notifications/email/api'; + +let html = ''; +let passwordChangedHtml = ''; +Meteor.startup(() => { + Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { + html = template; + }); + + Mailer.getTemplate('Password_Changed_Email', (template) => { + passwordChangedHtml = template; + }); +}); + +export async function sendUserEmail(subject: string, html: string, userData: SaveUserData): Promise { + if (!userData.email) { + return; + } + + const email = { + to: userData.email, + from: settings.get('From_Email'), + subject, + html, + data: { + email: userData.email, + password: userData.password ?? '******', + ...(typeof userData.name !== 'undefined' ? { name: userData.name } : {}), + }, + }; + + try { + await Mailer.send(email); + } catch (error) { + const errorMessage = typeof error === 'object' && error && 'message' in error ? error.message : ''; + + throw new MeteorError('error-email-send-failed', `Error trying to send email: ${errorMessage}`, { + function: 'RocketChat.saveUser', + message: errorMessage, + }); + } +} + +export async function sendWelcomeEmail(userData: Pick) { + return sendUserEmail(settings.get('Accounts_UserAddedEmail_Subject'), html, userData); +} + +export async function sendPasswordEmail(userData: Pick) { + return sendUserEmail(settings.get('Password_Changed_Email_Subject'), passwordChangedHtml, userData); +} diff --git a/apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts b/apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts new file mode 100644 index 0000000..7f51b45 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/setPasswordUpdater.ts @@ -0,0 +1,25 @@ +import crypto from 'node:crypto'; + +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/model-typings'; +import bcrypt from 'bcrypt'; +import { Accounts } from 'meteor/accounts-base'; + +const hashPassword = async (password: string) => { + const hash = crypto.createHash('sha256'); + hash.update(password); + const hashedPassword = hash.digest('hex'); + return bcrypt.hash(hashedPassword, Accounts._bcryptRounds()); +}; + +export async function setPasswordUpdater( + updater: Updater, + newPlaintextPassword: string, + options: { logout?: boolean } = { logout: true }, +) { + updater.set('services.password.bcrypt', await hashPassword(newPlaintextPassword)); + + if (options.logout) { + updater.unset('services.resume.loginTokens'); + } +} diff --git a/apps/meteor/server/lib/users/saveUser/validateUserData.ts b/apps/meteor/server/lib/users/saveUser/validateUserData.ts new file mode 100644 index 0000000..d604f05 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/validateUserData.ts @@ -0,0 +1,108 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import escape from 'lodash.escape'; + +import type { SaveUserData } from './saveUser'; +import { isUpdateUserData } from './saveUser'; +import { trim } from '../../../../lib/utils/stringUtils'; +import { settings } from '../../../settings'; +import { getRoleIds } from '../../authorization/getRoles'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; +import { checkEmailAvailability } from '../checkEmailAvailability'; +import { checkUsernameAvailability } from '../checkUsernameAvailability'; + +export const validateUserData = makeFunction(async (userId: IUser['_id'], userData: SaveUserData): Promise => { + const existingRoles = await getRoleIds(); + + if (userData.verified && userData._id && userId === userData._id) { + throw new MeteorError('error-action-not-allowed', 'Editing email verification is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + + if (isUpdateUserData(userData) && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { + throw new MeteorError('error-action-not-allowed', 'Editing user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + + if (!isUpdateUserData(userData) && !(await hasPermissionAsync(userId, 'create-user'))) { + throw new MeteorError('error-action-not-allowed', 'Adding user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Adding_user', + }); + } + + if (userData.roles) { + const newRoles = userData.roles.filter((roleId) => !existingRoles.includes(roleId)); + if (newRoles.length > 0) { + throw new MeteorError('error-action-not-allowed', 'The field Roles consist invalid role id', { + method: 'insertOrUpdateUser', + action: 'Assign_role', + }); + } + } + + if (userData.roles?.includes('admin') && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { + throw new MeteorError('error-action-not-allowed', 'Assigning admin is not allowed', { + method: 'insertOrUpdateUser', + action: 'Assign_admin', + }); + } + + if (settings.get('Accounts_RequireNameForSignUp') && !isUpdateUserData(userData) && !trim(userData.name)) { + throw new MeteorError('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateUser', + field: 'Name', + }); + } + + if (!isUpdateUserData(userData) && !trim(userData.username)) { + throw new MeteorError('error-the-field-is-required', 'The field Username is required', { + method: 'insertOrUpdateUser', + field: 'Username', + }); + } + + let nameValidation; + + try { + nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); + } catch (e) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (userData.username && !nameValidation.test(userData.username)) { + throw new MeteorError('error-input-is-not-a-valid-field', `${escape(userData.username)} is not a valid username`, { + method: 'insertOrUpdateUser', + input: userData.username, + field: 'Username', + }); + } + + if (!isUpdateUserData(userData) && !userData.password && !userData.setRandomPassword) { + throw new MeteorError('error-the-field-is-required', 'The field Password is required', { + method: 'insertOrUpdateUser', + field: 'Password', + }); + } + + if (!isUpdateUserData(userData)) { + if (userData.username && !(await checkUsernameAvailability(userData.username))) { + throw new MeteorError('error-field-unavailable', `${escape(userData.username)} is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.username, + }); + } + + if (userData.email && !(await checkEmailAvailability(userData.email))) { + throw new MeteorError('error-field-unavailable', `${escape(userData.email)} is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.email, + }); + } + } +}); diff --git a/apps/meteor/server/lib/users/saveUser/validateUserEditing.ts b/apps/meteor/server/lib/users/saveUser/validateUserEditing.ts new file mode 100644 index 0000000..8257db4 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUser/validateUserEditing.ts @@ -0,0 +1,123 @@ +/* eslint-disable complexity */ +import { MeteorError } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import type { UpdateUserData } from './saveUser'; +import { settings } from '../../../settings'; +import { hasPermissionAsync } from '../../authorization/hasPermission'; + +const isEditingUserRoles = (previousRoles: IUser['roles'], newRoles?: IUser['roles']) => + newRoles !== undefined && + (newRoles.some((item) => !previousRoles.includes(item)) || previousRoles.some((item) => !newRoles.includes(item))); +const isEditingField = (previousValue?: string, newValue?: string) => typeof newValue !== 'undefined' && newValue !== previousValue; + +export const canEditExtension = async (newExtension?: string) => { + if (!settings.get('VoIP_TeamCollab_SIP_Integration_Enabled')) { + return false; + } + + if (newExtension && (await Users.findOneByFreeSwitchExtension(newExtension, { projection: { _id: 1 } }))) { + throw new MeteorError('error-extension-not-available', 'Extension is already assigned to another user'); + } + + return true; +}; + +/** + * Validate permissions to edit user fields + * + * @param {string} userId + * @param {{ _id: string, roles?: string[], username?: string, name?: string, statusText?: string, email?: string, password?: string}} userData + */ +export async function validateUserEditing(userId: IUser['_id'], userData: UpdateUserData): Promise { + const editingMyself = userData._id && userId === userData._id; + + const canEditOtherUserInfo = await hasPermissionAsync(userId, 'edit-other-user-info'); + const canEditOtherUserPassword = await hasPermissionAsync(userId, 'edit-other-user-password'); + const user = await Users.findOneById(userData._id); + + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user'); + } + + if (isEditingUserRoles(user.roles, userData.roles) && !(await hasPermissionAsync(userId, 'assign-roles'))) { + throw new MeteorError('error-action-not-allowed', 'Assign roles is not allowed', { + method: 'insertOrUpdateUser', + action: 'Assign_role', + }); + } + + if (!settings.get('Accounts_AllowUserProfileChange') && !canEditOtherUserInfo && !canEditOtherUserPassword) { + throw new MeteorError('error-action-not-allowed', 'Edit user profile is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.username, userData.username) && + !settings.get('Accounts_AllowUsernameChange') && + (editingMyself ? user.username : !canEditOtherUserInfo) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit username is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.statusText, userData.statusText) && + !settings.get('Accounts_AllowUserStatusMessageChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user status is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.name, userData.name) && + !settings.get('Accounts_AllowRealNameChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user real name is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + user.emails?.[0] && + isEditingField(user.emails[0].address, userData.email) && + !settings.get('Accounts_AllowEmailChange') && + (!canEditOtherUserInfo || editingMyself) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user email is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + userData.password && + !settings.get('Accounts_AllowPasswordChange') && + (editingMyself ? user.services?.password || !user.requirePasswordChange : !canEditOtherUserPassword) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user password is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + + if ( + isEditingField(user.freeSwitchExtension ?? '', userData.freeSwitchExtension) && + !(await canEditExtension(userData.freeSwitchExtension)) + ) { + throw new MeteorError('error-action-not-allowed', 'Edit user voice call extension is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } +} diff --git a/apps/meteor/server/lib/users/saveUserIdentity.ts b/apps/meteor/server/lib/users/saveUserIdentity.ts new file mode 100644 index 0000000..1c43042 --- /dev/null +++ b/apps/meteor/server/lib/users/saveUserIdentity.ts @@ -0,0 +1,188 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users, CallHistory } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +import { setRealName } from './setRealName'; +import { _setUsername } from './setUsername'; +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { SystemLogger } from '../logger/system'; +import { FileUpload } from '../media/file-upload'; +import { + notifyOnRoomChangedByUsernamesOrUids, + notifyOnSubscriptionChangedByUserId, + notifyOnSubscriptionChangedByNameAndRoomType, +} from '../notifyListener'; +import { updateGroupDMsName } from '../rooms/updateGroupDMsName'; +import { validateName } from '../shared/validateName'; + +/** + * + * @param {object} changes changes to the user + */ + +export async function saveUserIdentity({ + _id, + name: rawName, + username: rawUsername, + updateUsernameInBackground = false, + updater, + session, +}: { + _id: string; + name?: string; + username?: string; + updateUsernameInBackground?: boolean; // TODO: remove this + updater?: Updater; + session?: ClientSession; +}) { + if (!_id) { + return false; + } + + const name = String(rawName).trim(); + const username = String(rawUsername).trim(); + + const user = await Users.findOneById(_id, { session }); + if (!user) { + return false; + } + + const previousUsername = user.username; + const previousName = user.name; + const nameChanged = previousName !== name; + const usernameChanged = previousUsername !== username; + + if (typeof rawUsername !== 'undefined' && usernameChanged) { + if (!validateName(username)) { + return false; + } + + if (!(await _setUsername(_id, username, user, updater, session))) { + return false; + } + user.username = username; + } + + if (typeof rawName !== 'undefined' && nameChanged) { + if (!(await setRealName(_id, name, user, updater, session))) { + return false; + } + } + + const updateReferences = async () => { + if (previousUsername) { + const handleUpdateParams = { + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, + }; + if (updateUsernameInBackground) { + setImmediate(async () => { + try { + await updateUsernameReferences(handleUpdateParams); + } catch (err) { + SystemLogger.error({ err }); + } + }); + } else { + await updateUsernameReferences(handleUpdateParams); + } + } + }; + + await onceTransactionCommitedSuccessfully(updateReferences, session); + + return true; +} + +async function updateUsernameReferences({ + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, +}: { + username: string; + previousUsername: string; + rawUsername?: string; + usernameChanged: boolean; + user: IUser; + name: string; + previousName: string | undefined; + rawName?: string; + nameChanged: boolean; +}): Promise { + if (usernameChanged && typeof rawUsername !== 'undefined') { + const fileStore = FileUpload.getStore('Avatars'); + const previousFile = await fileStore.model.findOneByName(previousUsername); + const file = await fileStore.model.findOneByName(username); + if (file) { + await fileStore.model.deleteFile(file._id); + } + if (previousFile) { + await fileStore.model.updateFileNameById(previousFile._id, username); + } + + await Messages.updateAllUsernamesByUserId(user._id, username); + await Messages.updateUsernameOfEditByUserId(user._id, username); + + const cursor = Messages.findByMention(previousUsername); + for await (const msg of cursor) { + const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); + await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); + } + + const responses = await Promise.all([ + Rooms.replaceUsername(previousUsername, username), + Rooms.replaceMutedUsername(previousUsername, username), + Rooms.replaceUsernameOfUserByUserId(user._id, username), + Subscriptions.setUserUsernameByUserId(user._id, username), + LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username), + ]); + + if (responses[3]?.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } + + if (responses[0]?.modifiedCount || responses[1]?.modifiedCount || responses[2]?.modifiedCount) { + void notifyOnRoomChangedByUsernamesOrUids([user._id], [previousUsername, username]); + } + } + + // update other references if either the name or username has changed + if (usernameChanged || nameChanged) { + // update name and fname of 1-on-1 direct messages + const updateDirectNameResponse = await Subscriptions.updateDirectNameAndFnameByName( + previousUsername, + rawUsername && username, + rawName && name, + ); + + if (updateDirectNameResponse?.modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ + t: 'd', + name: username, + }); + } + + // update name and fname of group direct messages + await updateGroupDMsName(user); + + // update name and username of users on video conferences + await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + + // update name and username of users on call history + await CallHistory.updateUserReferences(user._id, username || previousUsername, name || previousName); + } +} diff --git a/apps/meteor/server/lib/users/setEmail.ts b/apps/meteor/server/lib/users/setEmail.ts new file mode 100644 index 0000000..baca493 --- /dev/null +++ b/apps/meteor/server/lib/users/setEmail.ts @@ -0,0 +1,106 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; + +import { checkEmailAvailability } from './checkEmailAvailability'; +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { sendConfirmationEmail } from '../../meteor-methods/auth/sendConfirmationEmail'; +import { settings } from '../../settings'; +import * as Mailer from '../notifications/email/api'; +import { validateEmailDomain } from '../validateEmailDomain'; + +let html = ''; +Meteor.startup(() => { + Mailer.getTemplate('Email_Changed_Email', (template) => { + html = template; + }); +}); + +const _sendEmailChangeNotification = async function (to: string, newEmail: string) { + const subject = String(settings.get('Email_Changed_Email_Subject')); + const email = { + to, + from: String(settings.get('From_Email')), + subject, + html, + data: { + email: escapeHTML(newEmail), + }, + }; + + try { + await Mailer.send(email); + } catch (error: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { + function: 'setEmail', + message: error.message, + }); + } +}; + +export const setEmail = async function ( + userId: string, + email: string, + shouldSendVerificationEmail = true, + verified = false, + updater?: Updater, + session?: ClientSession, +) { + email = email.trim(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' }); + } + + if (!email) { + throw new Meteor.Error('error-invalid-email', 'Invalid email', { function: '_setEmail' }); + } + + await validateEmailDomain(email); + + const user = await Users.findOneById(userId, { session }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: '_setEmail' }); + } + + // User already has desired username, return + if (user?.emails?.[0] && user.emails[0].address === email) { + return user; + } + + // Check email availability + if (!(await checkEmailAvailability(email))) { + throw new Meteor.Error('error-field-unavailable', `${email} is already in use :(`, { + function: '_setEmail', + field: email, + }); + } + + const oldEmail = user?.emails?.[0]; + + if (oldEmail) { + await onceTransactionCommitedSuccessfully(async () => { + await _sendEmailChangeNotification(oldEmail.address, email); + }, session); + } + + // Set new email + if (updater) { + updater.set('emails', [{ address: email, verified }]); + } else { + await Users.setEmail(user?._id, email, verified, { session }); + } + + const result = { + ...user, + email, + }; + if (shouldSendVerificationEmail === true) { + await onceTransactionCommitedSuccessfully(async () => { + await sendConfirmationEmail(result.email); + }, session); + } + return result; +}; diff --git a/apps/meteor/server/lib/users/setRealName.ts b/apps/meteor/server/lib/users/setRealName.ts new file mode 100644 index 0000000..3be8baa --- /dev/null +++ b/apps/meteor/server/lib/users/setRealName.ts @@ -0,0 +1,64 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { settings } from '../../settings'; + +export const setRealName = async function ( + userId: string, + name: string, + fullUser?: IUser, + updater?: Updater, + session?: ClientSession, +): Promise { + name = name.trim(); + + if (!userId || (settings.get('Accounts_RequireNameForSignUp') && !name)) { + return; + } + + const user = fullUser || (await Users.findOneById(userId, { session })); + + if (!user) { + return; + } + + // User already has desired name, return + if (user.name && user.name.trim() === name) { + return user; + } + + // Set new name + if (name) { + if (updater) { + updater.set('name', name); + } else { + await Users.setName(user._id, name, { session }); + } + } else if (updater) { + updater.unset('name'); + } else { + await Users.unsetName(user._id, { session }); + } + user.name = name; + + await onceTransactionCommitedSuccessfully(() => { + if (settings.get('UI_Use_Real_Name') === true) { + void api.broadcast('user.nameChanged', { + _id: user._id, + name: user.name, + username: user.username, + }); + } + void api.broadcast('user.realNameChanged', { + _id: user._id, + name, + username: user.username, + }); + }, session); + + return user; +}; diff --git a/apps/meteor/server/lib/users/setStatusText.ts b/apps/meteor/server/lib/users/setStatusText.ts new file mode 100644 index 0000000..463ae98 --- /dev/null +++ b/apps/meteor/server/lib/users/setStatusText.ts @@ -0,0 +1,45 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; +import type { ClientSession } from 'mongodb'; + +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; + +export async function setStatusText( + user: Pick, + statusText: string, + { + updater, + session, + emit = true, + }: { + updater?: Updater; + session?: ClientSession; + emit?: boolean; + } = {}, +): Promise { + statusText = statusText.trim().substr(0, 120); + + if (user.statusText === statusText) { + return true; + } + + if (updater) { + updater.set('statusText', statusText); + } else { + await Users.updateStatusText(user._id, statusText, { session }); + } + + if (emit) { + const { _id, username, status, name, roles } = user; + await onceTransactionCommitedSuccessfully(() => { + void api.broadcast('presence.status', { + user: { _id, username, status, statusText, name, roles }, + previousStatus: status, + }); + }, session); + } + + return true; +} diff --git a/apps/meteor/server/lib/users/setUserActiveStatus.ts b/apps/meteor/server/lib/users/setUserActiveStatus.ts new file mode 100644 index 0000000..c3b2ea1 --- /dev/null +++ b/apps/meteor/server/lib/users/setUserActiveStatus.ts @@ -0,0 +1,169 @@ +import type { IUserEmail } from '@rocket.chat/core-typings'; +import { isUserFederated, isDirectMessageRoom } from '@rocket.chat/core-typings'; +import { Rooms, Users, Subscriptions, OAuthAccessTokens, OAuthRefreshTokens, OAuthAuthCodes } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { getUserSingleOwnedRooms } from './getUserSingleOwnedRooms'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import * as Mailer from '../notifications/email/api'; +import { + notifyOnRoomChangedById, + notifyOnRoomChangedByUserDM, + notifyOnSubscriptionChangedByNameAndRoomType, + notifyOnUserChange, +} from '../notifyListener'; +import { closeOmnichannelConversations } from '../omnichannel/closeOmnichannelConversations'; +import { shouldRemoveOrChangeOwner, getSubscribedRoomsForUserWithDetails } from '../rooms/getRoomsWithSingleOwner'; +import { relinquishRoomOwnerships } from '../rooms/relinquishRoomOwnerships'; + +async function reactivateDirectConversations(userId: string) { + // since both users can be deactivated at the same time, we should just reactivate rooms if both users are active + // for that, we need to fetch the direct messages, fetch the users involved and then the ids of rooms we can reactivate + const directConversations = await Rooms.getDirectConversationsByUserId(userId, { + projection: { _id: 1, uids: 1, t: 1 }, + }).toArray(); + + const userIds = directConversations.reduce((acc: string[], r) => { + if (isDirectMessageRoom(r)) { + acc.push(...r.uids); + } + return acc; + }, []); + const uniqueUserIds = [...new Set(userIds)]; + const activeUsers = await Users.findActiveByUserIds(uniqueUserIds, { projection: { _id: 1 } }).toArray(); + const activeUserIds = activeUsers.map((u) => u._id); + const roomsToReactivate = directConversations.reduce((acc: string[], room) => { + const otherUserId = isDirectMessageRoom(room) ? room.uids.find((u: string) => u !== userId) : undefined; + if (otherUserId && activeUserIds.includes(otherUserId)) { + acc.push(room._id); + } + return acc; + }, []); + + const setDmReadOnlyResponse = await Rooms.setDmReadOnlyByUserId(userId, roomsToReactivate, false, false); + if (setDmReadOnlyResponse.modifiedCount) { + void notifyOnRoomChangedById(roomsToReactivate); + } +} + +export async function setUserActiveStatus( + userId: string, + active: boolean, + confirmRelinquish = false, + executedBy?: string, +): Promise { + check(userId, String); + check(active, Boolean); + + const user = await Users.findOneById(userId); + + if (!user) { + return false; + } + + if (isUserFederated(user)) { + throw new Meteor.Error('error-user-is-federated', 'Cannot change federated users status', { + method: 'setUserActiveStatus', + }); + } + + // Users without username can't do anything, so there is no need to check for owned rooms + if (user.username != null && !active) { + const userAdmin = await Users.findOneAdmin(userId || ''); + const adminsCount = await Users.countActiveUsersInRoles(['admin']); + if (userAdmin && adminsCount === 1) { + throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without an active admin is not allowed', { + method: 'removeUserFromRole', + action: 'Remove_last_admin', + }); + } + + const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId); + // give omnichannel rooms a special treatment :) + const chatSubscribedRooms = subscribedRooms.filter(({ t }) => t !== 'l'); + const livechatSubscribedRooms = subscribedRooms.filter(({ t }) => t === 'l'); + + if (shouldRemoveOrChangeOwner(chatSubscribedRooms) && !confirmRelinquish) { + const rooms = await getUserSingleOwnedRooms(chatSubscribedRooms as []); + throw new Meteor.Error('user-last-owner', '', rooms); + } + + // We don't want one killing the other :) + await Promise.allSettled([ + closeOmnichannelConversations(user, livechatSubscribedRooms, executedBy), + relinquishRoomOwnerships(user._id, chatSubscribedRooms, false), + ]); + } + + if (active && !user.active) { + await callbacks.run('beforeActivateUser', user); + } + + await Users.setUserActive(userId, active); + + if (active && !user.active) { + await callbacks.run('afterActivateUser', user); + } + + if (!active && user.active) { + await callbacks.run('afterDeactivateUser', user); + } + + if (user.username) { + const { modifiedCount } = await Subscriptions.setArchivedForDMsWithUsername(user.username, !active); + if (modifiedCount) { + void notifyOnSubscriptionChangedByNameAndRoomType({ t: 'd', name: user.username }); + } + } + + if (active === false) { + await Users.unsetLoginTokens(userId); + await Promise.all([ + OAuthAccessTokens.deleteByUserId(userId), + OAuthRefreshTokens.deleteByUserId(userId), + OAuthAuthCodes.deleteByUserId(userId), + ]); + await Rooms.setDmReadOnlyByUserId(userId, undefined, true, false); + + void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [], active } }); + void notifyOnRoomChangedByUserDM(userId); + } else { + await Users.unsetReason(userId); + + void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { active } }); + await reactivateDirectConversations(userId); + } + + if (active && !settings.get('Accounts_Send_Email_When_Activating')) { + return true; + } + if (!active && !settings.get('Accounts_Send_Email_When_Deactivating')) { + return true; + } + if (!user.emails || !Array.isArray(user.emails) || user.emails.length === 0) { + return true; + } + + const destinations = user.emails.map((email: IUserEmail) => `${user.name || user.username}<${email.address}>`); + + type UserActivated = { + subject: (params: { active: boolean }) => string; + html: (params: { active: boolean; name: string; username: string }) => string; + }; + const { subject, html } = (Accounts.emailTemplates as unknown as { userActivated: UserActivated }).userActivated; + const email = { + to: String(destinations), + from: String(settings.get('From_Email')), + subject: subject({ active } as any), + html: html({ + active, + name: user.name, + username: user.username, + } as any), + }; + + void Mailer.sendNoWrap(email); +} diff --git a/apps/meteor/server/lib/users/setUserAvatar.ts b/apps/meteor/server/lib/users/setUserAvatar.ts new file mode 100644 index 0000000..7101d29 --- /dev/null +++ b/apps/meteor/server/lib/users/setUserAvatar.ts @@ -0,0 +1,218 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; +import type { Response } from '@rocket.chat/server-fetch'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; + +import { isRenderableImageType } from '../../../lib/renderableImageTypes'; +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { settings } from '../../settings'; +import { hasPermissionAsync } from '../authorization/hasPermission'; +import { SystemLogger } from '../logger/system'; +import { RocketChatFile } from '../media/file'; +import { FileUpload } from '../media/file-upload'; + +export const setAvatarFromServiceWithValidation = async ( + userId: string, + dataURI: string, + contentType?: string, + service?: string, + targetUserId?: string, +): Promise => { + if (!dataURI) { + throw new Meteor.Error('error-invalid-data', 'Invalid dataURI', { + method: 'setAvatarFromService', + }); + } + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'setAvatarFromService', + }); + } + + if (!settings.get('Accounts_AllowUserAvatarChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'setAvatarFromService', + }); + } + + let user: Pick | null; + + if (targetUserId && targetUserId !== userId) { + if (!(await hasPermissionAsync(userId, 'edit-other-user-avatar'))) { + throw new Meteor.Error('error-unauthorized', 'Unauthorized', { + method: 'setAvatarFromService', + }); + } + + user = await Users.findOneById(targetUserId, { projection: { _id: 1, username: 1 } }); + } else { + user = await Users.findOneById(userId, { projection: { _id: 1, username: 1 } }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-desired-user', 'Invalid desired user', { + method: 'setAvatarFromService', + }); + } + + return setUserAvatar(user, dataURI, contentType, service); +}; + +export function setUserAvatar( + user: Pick, + dataURI: Buffer, + contentType: string, + service: 'rest', + etag?: string, + updater?: Updater, + session?: ClientSession, +): Promise; +export function setUserAvatar( + user: Pick, + dataURI: string, + contentType?: string, + service?: 'initials' | 'url' | 'rest' | string, + etag?: string, + updater?: Updater, + session?: ClientSession, +): Promise; +export async function setUserAvatar( + user: Pick, + dataURI: string | Buffer, + contentType: string | undefined, + service?: 'initials' | 'url' | 'rest' | string, + etag?: string, + updater?: Updater, + session?: ClientSession, +): Promise { + if (service === 'initials') { + if (updater) { + updater.set('avatarOrigin', service); + } else { + await Users.setAvatarData(user._id, service, null, { session }); + } + return; + } + + const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => { + if (service === 'url' && typeof dataURI === 'string') { + let response: Response; + + try { + response = await fetch(dataURI, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + } catch (e) { + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + url: dataURI, + err: e, + }); + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { + function: 'setUserAvatar', + url: dataURI, + }); + } + + if (response.status !== 200) { + if (response.status !== 404) { + SystemLogger.info({ + msg: 'Error while handling the setting of the avatar from a url', + url: dataURI, + username: user.username, + status: response.status, + }); + throw new Meteor.Error( + 'error-avatar-url-handling', + `Error while handling avatar setting from a URL (${dataURI}) for ${user.username}`, + { function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username }, + ); + } + + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + status: response.status, + url: dataURI, + }); + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { + function: 'setUserAvatar', + url: dataURI, + }); + } + + const contentType = response.headers.get('content-type'); + if (!isRenderableImageType(contentType)) { + SystemLogger.info({ + msg: 'Not a valid content-type from the provided avatar url', + contentType, + url: dataURI, + }); + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { + function: 'setUserAvatar', + url: dataURI, + }); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') || '', + }; + } + + if (service === 'rest') { + if (!contentType) { + throw new Meteor.Error('error-avatar-invalid-content-type', 'Invalid avatar content type', { + function: 'setUserAvatar', + }); + } + + return { + buffer: typeof dataURI === 'string' ? Buffer.from(dataURI, 'binary') : dataURI, + type: contentType, + }; + } + + const fileData = RocketChatFile.dataURIParse(dataURI); + + return { + buffer: Buffer.from(fileData.image, 'base64'), + type: fileData.contentType, + }; + })(); + + const fileStore = FileUpload.getStore('Avatars'); + user.username && (await fileStore.deleteByName(user.username, { session })); + + const file = { + userId: user._id, + type, + size: buffer.length, + }; + + const result = await fileStore.insert(file, buffer, { session }); + + const avatarETag = etag || result?.etag || ''; + + if (service) { + if (updater) { + updater.set('avatarOrigin', service); + updater.set('avatarETag', avatarETag); + } else { + // TODO: Why was this timeout added? + setTimeout(async () => Users.setAvatarData(user._id, service, avatarETag, { session }), 500); + } + + await onceTransactionCommitedSuccessfully(async () => { + void api.broadcast('user.avatarUpdate', { + username: user.username, + avatarETag, + }); + }, session); + } +} diff --git a/apps/meteor/server/lib/users/setUsername.ts b/apps/meteor/server/lib/users/setUsername.ts new file mode 100644 index 0000000..292408d --- /dev/null +++ b/apps/meteor/server/lib/users/setUsername.ts @@ -0,0 +1,171 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { isUserNativeFederated } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; +import { Invites, Users, Subscriptions } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; +import _ from 'underscore'; + +import { checkUsernameAvailability } from './checkUsernameAvailability'; +import { getAvatarSuggestionForUser } from './getAvatarSuggestionForUser'; +import { saveUserIdentity } from './saveUserIdentity'; +import { setUserAvatar } from './setUserAvatar'; +import { validateUsername } from './validateUsername'; +import { onceTransactionCommitedSuccessfully } from '../../database/utils'; +import { settings } from '../../settings'; +import { callbacks } from '../callbacks'; +import { SystemLogger } from '../logger/system'; +import { notifyOnUserChange } from '../notifyListener'; +import { addUserToRoom } from '../rooms/addUserToRoom'; +import { joinDefaultChannels } from '../rooms/joinDefaultChannels'; + +const isUserInFederatedRooms = async (userId: string): Promise => { + const cursor = Subscriptions.findUserFederatedRoomIds(userId); + const hasAny = await cursor.hasNext(); + await cursor.close(); + return hasAny; +}; + +export const setUsernameWithValidation = async (userId: string, username: string): Promise => { + if (!username) { + throw new Meteor.Error('error-invalid-username', 'Invalid username', { method: 'setUsername' }); + } + + const user = await Users.findOneById(userId); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUsername' }); + } + + if (isUserNativeFederated(user) || (await isUserInFederatedRooms(userId))) { + throw new Meteor.Error('error-not-allowed', 'Cannot change username for federated users or users in federated rooms', { + method: 'setUsername', + }); + } + + if (user.username && !settings.get('Accounts_AllowUsernameChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + if (user.username === username || (user.username && user.username.toLowerCase() === username.toLowerCase())) { + return; + } + + if (!validateUsername(username)) { + throw new Meteor.Error( + 'username-invalid', + `${_.escape(username)} is not a valid username, use only letters, numbers, dots, hyphens and underscores`, + ); + } + + if (!(await checkUsernameAvailability(username))) { + throw new Meteor.Error('error-field-unavailable', `${_.escape(username)} is already in use :(`, { + method: 'setUsername', + field: username, + }); + } + + if (!(await saveUserIdentity({ _id: user._id, username }))) { + throw new Meteor.Error('error-could-not-change-username', 'Could not change username', { + method: 'setUsername', + }); + } + + void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff: { username } }); +}; + +export const _setUsername = async function ( + userId: string, + u: string, + fullUser: IUser, + updater?: Updater, + session?: ClientSession, +): Promise { + const username = u.trim(); + + if (!userId || !username) { + return false; + } + + if (!validateUsername(username)) { + return false; + } + + if (isUserNativeFederated(fullUser) || (await isUserInFederatedRooms(userId))) { + throw new Meteor.Error('error-not-allowed', 'Cannot change username for federated users or users in federated rooms', { + method: 'setUsername', + }); + } + + const user = fullUser || (await Users.findOneById(userId, { session })); + // User already has desired username, return + if (user.username === username) { + return user; + } + const previousUsername = user.username; + // Check username availability or if the user already owns a different casing of the name + if (!previousUsername || !(username.toLowerCase() === previousUsername.toLowerCase())) { + if (!(await checkUsernameAvailability(username))) { + return false; + } + } + // If first time setting username, send Enrollment Email + if (!previousUsername && user.emails && user.emails.length > 0 && settings.get('Accounts_Enrollment_Email')) { + await onceTransactionCommitedSuccessfully(() => { + try { + setImmediate(() => { + Accounts.sendEnrollmentEmail(user._id); + }); + } catch (err: any) { + SystemLogger.error({ err }); + } + }, session); + } + // Set new username* + // TODO: use updater for setting the username and handle possible side effects in addUserToRoom + await Users.setUsername(user._id, username, { session }); + user.username = username; + + if (!previousUsername && settings.get('Accounts_SetDefaultAvatar') === true) { + const avatarSuggestions = await getAvatarSuggestionForUser(user); + let avatarData; + let serviceName = 'gravatar'; + + for (const service of Object.keys(avatarSuggestions)) { + avatarData = avatarSuggestions[service]; + if (service !== 'gravatar') { + serviceName = service; + break; + } + } + + if (avatarData) { + await setUserAvatar(user, avatarData.blob, avatarData.contentType, serviceName, undefined, updater, session); + } + } + + await onceTransactionCommitedSuccessfully(async () => { + if (!previousUsername) { + await joinDefaultChannels(user._id); + callbacks.runAsync('afterCreateUser', user); + } + + // If it's the first username and the user has an invite Token, then join the invite room + if (!previousUsername && user.inviteToken) { + const inviteData = await Invites.findOneById(user.inviteToken); + if (inviteData?.rid) { + await addUserToRoom(inviteData.rid, user); + } + } + + void api.broadcast('user.nameChanged', { + _id: user._id, + name: user.name, + username: user.username, + }); + }, session); + + return user; +}; diff --git a/apps/meteor/server/lib/users/unarchiveUserSubscriptions.ts b/apps/meteor/server/lib/users/unarchiveUserSubscriptions.ts new file mode 100644 index 0000000..aafb438 --- /dev/null +++ b/apps/meteor/server/lib/users/unarchiveUserSubscriptions.ts @@ -0,0 +1,40 @@ +import { Rooms, Subscriptions } from '@rocket.chat/models'; + +const BATCH_SIZE = 100_000; + +async function getArchivedRoomIds(rids: string[]): Promise> { + const archivedRoomIds = new Set(); + + for (let i = 0; i < rids.length; i += BATCH_SIZE) { + const batch = await Rooms.findManyArchivedByRoomIds(rids.slice(i, i + BATCH_SIZE), { projection: { _id: 1 } }).toArray(); + for (const r of batch) { + archivedRoomIds.add(r._id); + } + } + + return archivedRoomIds; +} + +async function unarchiveSubscriptionsByIds(ids: string[]): Promise { + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + await Subscriptions.unarchiveByIds(ids.slice(i, i + BATCH_SIZE)); + } +} + +export const unarchiveUserSubscriptions = async (userId: string): Promise => { + const archivedSubs = await Subscriptions.findArchivedByUserId(userId, { projection: { rid: 1 } }).toArray(); + + if (!archivedSubs.length) { + return false; + } + + const archivedRoomIds = await getArchivedRoomIds(archivedSubs.map((s) => s.rid)); + const idsToUnarchive = archivedSubs.filter((s) => !archivedRoomIds.has(s.rid)).map((s) => s._id); + + if (!idsToUnarchive.length) { + return false; + } + + await unarchiveSubscriptionsByIds(idsToUnarchive); + return true; +}; diff --git a/apps/meteor/server/lib/users/unblockUser.ts b/apps/meteor/server/lib/users/unblockUser.ts new file mode 100644 index 0000000..b47b105 --- /dev/null +++ b/apps/meteor/server/lib/users/unblockUser.ts @@ -0,0 +1,23 @@ +import { Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../notifyListener'; + +export const unblockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'unblockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/server/lib/users/validateCustomFields.js b/apps/meteor/server/lib/users/validateCustomFields.js new file mode 100644 index 0000000..306955f --- /dev/null +++ b/apps/meteor/server/lib/users/validateCustomFields.js @@ -0,0 +1,48 @@ +import { Meteor } from 'meteor/meteor'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; + +export const validateCustomFields = function (fields) { + // Special Case: + // If an admin didn't set any custom fields there's nothing to validate against so consider any customFields valid + if (trim(settings.get('Accounts_CustomFields')) === '') { + return; + } + + let customFieldsMeta; + try { + customFieldsMeta = JSON.parse(settings.get('Accounts_CustomFields')); + } catch (e) { + throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields'); + } + + const customFields = {}; + + Object.keys(customFieldsMeta).forEach((fieldName) => { + const field = customFieldsMeta[fieldName]; + + customFields[fieldName] = fields[fieldName]; + const fieldValue = trim(fields[fieldName]); + + if (field.required && fieldValue === '') { + throw new Meteor.Error('error-user-registration-custom-field', `Field ${fieldName} is required`, { method: 'registerUser' }); + } + + if (field.type === 'select' && field.options.indexOf(fields[fieldName]) === -1) { + throw new Meteor.Error('error-user-registration-custom-field', `Value for field ${fieldName} is invalid`, { method: 'registerUser' }); + } + + if (field.maxLength && fieldValue.length > field.maxLength) { + throw new Meteor.Error('error-user-registration-custom-field', `Max length of field ${fieldName} ${field.maxLength}`, { + method: 'registerUser', + }); + } + + if (field.minLength && fieldValue.length > 0 && fieldValue.length < field.minLength) { + throw new Meteor.Error('error-user-registration-custom-field', `Min length of field ${fieldName} ${field.minLength}`, { + method: 'registerUser', + }); + } + }); +}; diff --git a/apps/meteor/server/lib/users/validateUsername.ts b/apps/meteor/server/lib/users/validateUsername.ts new file mode 100644 index 0000000..6dd934a --- /dev/null +++ b/apps/meteor/server/lib/users/validateUsername.ts @@ -0,0 +1,15 @@ +import { settings } from '../../settings'; + +export const validateUsername = (username: string): boolean => { + const settingsRegExp = settings.get('UTF8_User_Names_Validation'); + const defaultPattern = /^[0-9a-zA-Z-_.]+$/; + + let usernameRegExp: RegExp; + try { + usernameRegExp = settingsRegExp ? new RegExp(`^${settingsRegExp}$`) : defaultPattern; + } catch (e) { + usernameRegExp = defaultPattern; + } + + return usernameRegExp.test(username); +}; diff --git a/apps/meteor/server/lib/users/verifyEmail.ts b/apps/meteor/server/lib/users/verifyEmail.ts new file mode 100644 index 0000000..de9aacb --- /dev/null +++ b/apps/meteor/server/lib/users/verifyEmail.ts @@ -0,0 +1,26 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function verifyEmail(user: Pick, token: string): Promise { + if (user.services?.email?.verificationTokens?.length === 0) { + throw new Error('error-invalid-token'); + } + + const tokenRecord = await user.services?.email?.verificationTokens?.find((t) => t.token === token); + if (!tokenRecord) { + throw new Error('error-invalid-token'); + } + + const emailsRecord = user.emails?.find((e) => e.address === tokenRecord.address); + + if (!emailsRecord) { + throw new Error('error-invalid-token'); + } + + const result = await Users.verifyEmailByAddress(user._id, tokenRecord.address); + if (result.modifiedCount === 0) { + throw new Error('error-invalid-token'); + } + + return true; +} diff --git a/apps/meteor/server/lib/utils/functions/getBaseUserFields.ts b/apps/meteor/server/lib/utils/functions/getBaseUserFields.ts new file mode 100644 index 0000000..acd5233 --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/getBaseUserFields.ts @@ -0,0 +1,37 @@ +type UserFields = { + [k: string]: number; +}; + +export const getBaseUserFields = (allowServiceKeys = false): UserFields => ({ + 'name': 1, + 'username': 1, + 'nickname': 1, + 'emails': 1, + 'status': 1, + 'statusDefault': 1, + 'statusText': 1, + 'statusSource': 1, + 'statusExpiresAt': 1, + 'statusConnection': 1, + 'bio': 1, + 'avatarOrigin': 1, + 'utcOffset': 1, + 'language': 1, + 'settings': 1, + 'enableAutoAway': 1, + 'idleTimeLimit': 1, + 'roles': 1, + 'active': 1, + 'defaultRoom': 1, + 'customFields': 1, + 'requirePasswordChange': 1, + 'requirePasswordChangeReason': 1, + 'statusLivechat': 1, + 'banners': 1, + 'oauth.authorizedClients': 1, + '_updatedAt': 1, + 'avatarETag': 1, + 'openBusinessHours': 1, + 'abacAttributes': 1, + ...(allowServiceKeys && { 'services.totp.enabled': 1, 'services.email2fa.enabled': 1 }), +}); diff --git a/apps/meteor/server/lib/utils/functions/getDefaultUserFields.ts b/apps/meteor/server/lib/utils/functions/getDefaultUserFields.ts new file mode 100644 index 0000000..c9d7cb0 --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/getDefaultUserFields.ts @@ -0,0 +1,12 @@ +import { getBaseUserFields } from './getBaseUserFields'; + +type UserFields = { + [k: string]: number; +}; + +export const getDefaultUserFields = (): UserFields => ({ + ...getBaseUserFields(true), + 'services.github': 1, + 'services.gitlab': 1, + 'services.password.bcrypt': 1, +}); diff --git a/apps/meteor/server/lib/utils/functions/getMongoInfo.ts b/apps/meteor/server/lib/utils/functions/getMongoInfo.ts new file mode 100644 index 0000000..a73436b --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/getMongoInfo.ts @@ -0,0 +1,61 @@ +import { MongoInternals, type MongoConnection } from 'meteor/mongo'; + +function getOplogInfo(): { mongo: MongoConnection } { + const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); + + return { mongo }; +} + +async function fallbackMongoInfo(): Promise<{ + mongoVersion: string; + mongoStorageEngine?: string; + mongo: MongoConnection; +}> { + let mongoVersion; + let mongoStorageEngine; + + const { mongo } = getOplogInfo(); + + try { + const { version } = await mongo.db.command({ buildinfo: 1 }); + mongoVersion = version; + mongoStorageEngine = 'unknown'; + } catch (e) { + console.error('=== Error getting MongoDB info ==='); + console.error(e?.toString()); + console.error('----------------------------------'); + console.error("Without mongodb version we can't ensure you are running a compatible version."); + console.error('If you are running your mongodb with auth enabled and an user different from admin'); + console.error('you may need to grant permissions for this user to check cluster data.'); + console.error('You can do it via mongo shell running the following command replacing'); + console.error("the string YOUR_USER by the correct user's name:"); + console.error(''); + console.error(' db.runCommand({ grantRolesToUser: "YOUR_USER" , roles: [{role: "clusterMonitor", db: "admin"}]})'); + console.error(''); + console.error('=================================='); + } + + return { mongoVersion, mongoStorageEngine, mongo }; +} + +export async function getMongoInfo(): Promise<{ + mongoVersion: string; + mongoStorageEngine?: string; + mongo: MongoConnection; +}> { + let mongoVersion; + let mongoStorageEngine; + + const { mongo } = getOplogInfo(); + + try { + const { version, storageEngine } = await mongo.db.command({ serverStatus: 1 }); + + mongoVersion = version; + mongoStorageEngine = storageEngine.name; + } catch (e) { + return fallbackMongoInfo(); + } + + return { mongoVersion, mongoStorageEngine, mongo }; +} diff --git a/apps/meteor/server/lib/utils/functions/isDocker.ts b/apps/meteor/server/lib/utils/functions/isDocker.ts new file mode 100644 index 0000000..56d0747 --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/isDocker.ts @@ -0,0 +1,31 @@ +import fs from 'node:fs'; + +function hasDockerEnv(): boolean { + try { + fs.statSync('/.dockerenv'); + return true; + } catch (err) { + return false; + } +} + +function hasDockerCGroup(): boolean { + try { + return fs.readFileSync('/proc/self/cgroup', 'utf8').indexOf('docker') !== -1; + } catch (err) { + return false; + } +} + +function check(): boolean { + return hasDockerEnv() || hasDockerCGroup(); +} + +let _isDocker: boolean; +export const isDocker = function (): boolean { + if (_isDocker === undefined) { + _isDocker = check(); + } + + return _isDocker; +}; diff --git a/apps/meteor/server/lib/utils/functions/isSMTPConfigured.ts b/apps/meteor/server/lib/utils/functions/isSMTPConfigured.ts new file mode 100644 index 0000000..e4ca9b6 --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/isSMTPConfigured.ts @@ -0,0 +1,6 @@ +import { settings } from '../../../settings'; + +export const isSMTPConfigured = (): boolean => { + const isMailURLSet = !(process.env.MAIL_URL === 'undefined' || process.env.MAIL_URL === undefined); + return Boolean(settings.get('SMTP_Host')) || isMailURLSet; +}; diff --git a/apps/meteor/server/lib/utils/functions/normalizeMessageFileUpload.ts b/apps/meteor/server/lib/utils/functions/normalizeMessageFileUpload.ts new file mode 100644 index 0000000..00d0484 --- /dev/null +++ b/apps/meteor/server/lib/utils/functions/normalizeMessageFileUpload.ts @@ -0,0 +1,30 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Uploads } from '@rocket.chat/models'; + +import { FileUpload } from '../../media/file-upload'; +import { getURL } from '../getURL'; + +export const normalizeMessageFileUpload = async (message: Omit): Promise> => { + if (message.file && !message.fileUpload) { + const jwt = FileUpload.generateJWTToFileUrls({ + rid: message.rid, + userId: message.u._id, + fileId: message.file._id, + }); + const file = await Uploads.findOne({ _id: message.file._id }); + if (!file) { + return message; + } + message.fileUpload = { + publicFilePath: file.name + ? getURL(`${FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`).substring(1)}${jwt ? `?token=${jwt}` : ''}`, { + cdn: false, + full: true, + }) + : '', + type: file.type, + size: file.size, + }; + } + return message; +}; diff --git a/apps/meteor/server/lib/utils/getAvatarURL.ts b/apps/meteor/server/lib/utils/getAvatarURL.ts new file mode 100644 index 0000000..b03903d --- /dev/null +++ b/apps/meteor/server/lib/utils/getAvatarURL.ts @@ -0,0 +1,10 @@ +import { getURL } from './getURL'; + +export const getAvatarURL = ({ username, roomId, cache }: { username?: string; roomId?: string; cache?: string }): string | undefined => { + if (username) { + return getURL(`/avatar/${encodeURIComponent(username)}${cache ? `?etag=${cache}` : ''}`); + } + if (roomId) { + return getURL(`/avatar/room/${encodeURIComponent(roomId)}${cache ? `?etag=${cache}` : ''}`); + } +}; diff --git a/apps/meteor/server/lib/utils/getURL.ts b/apps/meteor/server/lib/utils/getURL.ts new file mode 100644 index 0000000..0abe82d --- /dev/null +++ b/apps/meteor/server/lib/utils/getURL.ts @@ -0,0 +1,19 @@ +import { getURLWithoutSettings } from '../../../app/utils/lib/getURL'; +import { settings } from '../../settings'; + +export const getURL = function ( + path: string, + params: { + cdn?: boolean; + full?: boolean; + cloud?: boolean; + cloud_route?: string; + cloud_params?: Record; + } = {}, + cloudDeepLinkUrl?: string, +): string { + const cdnPrefix = settings.get('CDN_PREFIX') || ''; + const siteUrl = settings.get('Site_Url') || ''; + + return getURLWithoutSettings(path, params, cdnPrefix, siteUrl, cloudDeepLinkUrl); +}; diff --git a/apps/meteor/server/lib/utils/getUserAvatarURL.ts b/apps/meteor/server/lib/utils/getUserAvatarURL.ts new file mode 100644 index 0000000..d5fdd2b --- /dev/null +++ b/apps/meteor/server/lib/utils/getUserAvatarURL.ts @@ -0,0 +1,9 @@ +import { getAvatarURL } from './getAvatarURL'; + +export const getUserAvatarURL = function (username: string, cache = ''): string | undefined { + if (username == null) { + return; + } + + return getAvatarURL({ username, cache }); +}; diff --git a/apps/meteor/server/lib/utils/getUserNotificationPreference.ts b/apps/meteor/server/lib/utils/getUserNotificationPreference.ts new file mode 100644 index 0000000..c38e86b --- /dev/null +++ b/apps/meteor/server/lib/utils/getUserNotificationPreference.ts @@ -0,0 +1,51 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../settings'; + +export const getUserNotificationPreference = async (user: IUser | string, pref: string) => { + if (typeof user === 'string') { + const u = await Users.findOneById(user); + if (!u) { + return null; + } + user = u; + } + + let preferenceKey: 'desktopNotifications' | 'pushNotifications' | 'emailNotificationMode' | undefined; + switch (pref) { + case 'desktop': + preferenceKey = 'desktopNotifications'; + break; + case 'mobile': + preferenceKey = 'pushNotifications'; + break; + case 'email': + preferenceKey = 'emailNotificationMode'; + break; + } + + if (!preferenceKey) { + return null; + } + + if ( + user?.settings?.preferences && + typeof user.settings.preferences[preferenceKey] !== 'undefined' && + user.settings.preferences[preferenceKey] !== 'default' + ) { + return { + value: user.settings.preferences[preferenceKey], + origin: 'user', + }; + } + const serverValue = settings.get(`Accounts_Default_User_Preferences_${preferenceKey}`); + if (typeof serverValue !== 'undefined') { + return { + value: serverValue, + origin: 'server', + }; + } + + return null; +}; diff --git a/apps/meteor/server/lib/utils/lib/JWTHelper.spec.ts b/apps/meteor/server/lib/utils/lib/JWTHelper.spec.ts new file mode 100644 index 0000000..ce93e8e --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/JWTHelper.spec.ts @@ -0,0 +1,299 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const jsrsasignStub = { + KJUR: { + jws: { + IntDate: { + get: sinon.stub(), + }, + JWS: { + sign: sinon.stub(), + verify: sinon.stub(), + parse: sinon.stub(), + }, + }, + }, +}; + +const { generateJWT, validateAndDecodeJWT } = proxyquire.noCallThru().load('./JWTHelper', { + jsrsasign: jsrsasignStub, +}); + +describe('JWTHelper', () => { + beforeEach(() => { + jsrsasignStub.KJUR.jws.IntDate.get.reset(); + jsrsasignStub.KJUR.jws.JWS.sign.reset(); + jsrsasignStub.KJUR.jws.JWS.verify.reset(); + jsrsasignStub.KJUR.jws.JWS.parse.reset(); + }); + + describe('generateJWT', () => { + it('should generate a JWT with the correct payload structure', () => { + const now = 1609459200; + const expiryDuration = 3600; // 1 hour in seconds + const expiryTime = now + expiryDuration; + + jsrsasignStub.KJUR.jws.IntDate.get.callsFake((timeStr) => { + if (timeStr === 'now') return now; + if (timeStr === 'now + 1hour') return expiryTime; + return now; + }); + jsrsasignStub.KJUR.jws.JWS.sign.returns('mocked.jwt.token'); + + const payload = { userId: 'user-123', roomId: 'room-456' }; + const secret = 'test-secret'; + + const result = generateJWT(payload, secret); + + expect(result).to.equal('mocked.jwt.token'); + expect(jsrsasignStub.KJUR.jws.JWS.sign.calledOnce).to.be.true; + + const signArgs = jsrsasignStub.KJUR.jws.JWS.sign.firstCall.args; + expect(signArgs[0]).to.equal('HS256'); + expect(signArgs[1]).to.equal(JSON.stringify({ typ: 'JWT', alg: 'HS256' })); + + const tokenPayload = JSON.parse(signArgs[2]); + expect(tokenPayload.iat).to.equal(now); + expect(tokenPayload.nbf).to.equal(now); + expect(tokenPayload.exp).to.equal(expiryTime); + expect(tokenPayload.aud).to.equal('RocketChat'); + expect(tokenPayload.context).to.deep.equal(payload); + + expect(signArgs[3]).to.deep.equal({ rstr: secret }); + }); + + it('should include the provided payload in the context field', () => { + jsrsasignStub.KJUR.jws.IntDate.get.returns(1609459200); + jsrsasignStub.KJUR.jws.JWS.sign.returns('mocked.jwt.token'); + + const payload = { fileId: 'file-123', rid: 'room-123', userId: 'user-123' }; + const secret = 'secret-key'; + + generateJWT(payload, secret); + + const signArgs = jsrsasignStub.KJUR.jws.JWS.sign.firstCall.args; + const tokenPayload = JSON.parse(signArgs[2]); + + expect(tokenPayload.context).to.deep.equal(payload); + }); + + it('should generate a JWT with custom audience when provided in options', () => { + jsrsasignStub.KJUR.jws.IntDate.get.returns(1609459200); + jsrsasignStub.KJUR.jws.JWS.sign.returns('mocked.jwt.token'); + + const payload = { userId: 'user-123' }; + const secret = 'test-secret'; + const customAudience = 'CustomApp'; + + generateJWT(payload, secret, { aud: customAudience }); + + const signArgs = jsrsasignStub.KJUR.jws.JWS.sign.firstCall.args; + const tokenPayload = JSON.parse(signArgs[2]); + + expect(tokenPayload.aud).to.equal(customAudience); + }); + }); + + describe('validateAndDecodeJWT', () => { + it('should return null if jwt is empty', () => { + const result = validateAndDecodeJWT('', 'secret'); + expect(result).to.be.null; + }); + + it('should return null if jwt is null', () => { + const result = validateAndDecodeJWT(null as any, 'secret'); + expect(result).to.be.null; + }); + + it('should return null if secret is empty', () => { + const result = validateAndDecodeJWT('some.jwt.token', ''); + expect(result).to.be.null; + }); + + it('should return null if secret is null', () => { + const result = validateAndDecodeJWT('some.jwt.token', null as any); + expect(result).to.be.null; + }); + + it('should return null if signature is invalid', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(false); + + const result = validateAndDecodeJWT('invalid.jwt.token', 'secret'); + + expect(result).to.be.null; + expect(jsrsasignStub.KJUR.jws.JWS.verify.calledOnce).to.be.true; + expect(jsrsasignStub.KJUR.jws.JWS.verify.calledWith('invalid.jwt.token', 'secret', ['HS256'])).to.be.true; + }); + + it('should return null if token is expired', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + nbf: Math.floor(Date.now() / 1000) - 7200, + aud: 'RocketChat', + context: { userId: 'user-123' }, + }, + }); + + const result = validateAndDecodeJWT('expired.jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return null if nbf (not before) is in the future', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) + 7200, + nbf: Math.floor(Date.now() / 1000) + 3600, // Not valid until 1 hour from now + aud: 'RocketChat', + context: { userId: 'user-123' }, + }, + }); + + const result = validateAndDecodeJWT('future.jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return null if audience is not RocketChat', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) + 3600, + nbf: Math.floor(Date.now() / 1000) - 60, + aud: 'WrongAudience', + context: { userId: 'user-123' }, + }, + }); + + const result = validateAndDecodeJWT('wrong-audience.jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return the context payload for a valid token with correct signature, timestamps, and audience', () => { + const contextPayload = { userId: 'user-123', fileId: 'file-456', rid: 'room-789' }; + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + nbf: Math.floor(Date.now() / 1000) - 60, // Valid since 1 minute ago + aud: 'RocketChat', + context: contextPayload, + }, + }); + + const result = validateAndDecodeJWT('valid.jwt.token', 'secret'); + + expect(result).to.deep.equal(contextPayload); + expect(jsrsasignStub.KJUR.jws.JWS.verify.calledOnce).to.be.true; + expect(jsrsasignStub.KJUR.jws.JWS.parse.calledOnce).to.be.true; + }); + + it('should return the context payload if exp and nbf are not provided', () => { + const contextPayload = { userId: 'user-123' }; + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + aud: 'RocketChat', + context: contextPayload, + }, + }); + + const result = validateAndDecodeJWT('valid.jwt.token', 'secret'); + + expect(result).to.deep.equal(contextPayload); + }); + + it('should return null if token cannot be parsed', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.throws(new Error('Parse error')); + + const result = validateAndDecodeJWT('malformed.jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return null if verification throws an error', () => { + jsrsasignStub.KJUR.jws.JWS.verify.throws(new Error('Verification error')); + + const result = validateAndDecodeJWT('error.jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should validate and decode token with custom audience when provided in options', () => { + const contextPayload = { userId: 'user-123' }; + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) + 3600, + nbf: Math.floor(Date.now() / 1000) - 60, + aud: 'CustomApp', + context: contextPayload, + }, + }); + + const result = validateAndDecodeJWT('valid.jwt.token', 'secret', { aud: 'CustomApp' }); + + expect(result).to.deep.equal(contextPayload); + }); + + it('should return null if custom audience does not match token audience', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + exp: Math.floor(Date.now() / 1000) + 3600, + nbf: Math.floor(Date.now() / 1000) - 60, + aud: 'CustomApp', + context: { userId: 'user-123' }, + }, + }); + + const result = validateAndDecodeJWT('valid.jwt.token', 'secret', { aud: 'DifferentApp' }); + + expect(result).to.be.null; + }); + + it('should return null if payloadObj is null', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: null, + }); + + const result = validateAndDecodeJWT('jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return null if context is missing from payload', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns({ + payloadObj: { + iat: 1609459200, + nbf: 1609459200, + exp: 1609462800, + aud: 'RocketChat', + }, + }); + + const result = validateAndDecodeJWT('jwt.token', 'secret'); + + expect(result).to.be.null; + }); + + it('should return null if parse returns undefined', () => { + jsrsasignStub.KJUR.jws.JWS.verify.returns(true); + jsrsasignStub.KJUR.jws.JWS.parse.returns(undefined); + + const result = validateAndDecodeJWT('jwt.token', 'secret'); + + expect(result).to.be.null; + }); + }); +}); diff --git a/apps/meteor/server/lib/utils/lib/JWTHelper.ts b/apps/meteor/server/lib/utils/lib/JWTHelper.ts new file mode 100644 index 0000000..1ad727a --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/JWTHelper.ts @@ -0,0 +1,56 @@ +import jsr from 'jsrsasign'; + +const HEADER = { + typ: 'JWT', + alg: 'HS256', +}; + +export const generateJWT = (payload: Record, secret: string, options?: { aud?: string }): string => { + const tokenPayload = { + iat: jsr.KJUR.jws.IntDate.get('now'), + nbf: jsr.KJUR.jws.IntDate.get('now'), + exp: jsr.KJUR.jws.IntDate.get('now + 1hour'), + aud: options?.aud || 'RocketChat', + context: payload, + }; + + const header = JSON.stringify(HEADER); + + return jsr.KJUR.jws.JWS.sign(HEADER.alg, header, JSON.stringify(tokenPayload), { rstr: secret }); +}; + +export const validateAndDecodeJWT = (jwt: string, secret: string, options?: { aud?: string }): Record | null => { + if (!jwt || !secret) { + return null; + } + try { + const isSignatureValid = jsr.KJUR.jws.JWS.verify(jwt, secret, [HEADER.alg]); + if (!isSignatureValid) { + return null; + } + + const decoded = jsr.KJUR.jws.JWS.parse(jwt); + if (!decoded?.payloadObj) { + return null; + } + + const payload = decoded.payloadObj as Record; + + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === 'number' && now >= payload.exp) { + return null; + } + + if (typeof payload.nbf === 'number' && now < payload.nbf) { + return null; + } + + if (payload.aud !== (options?.aud || 'RocketChat')) { + return null; + } + + return payload.context || null; + } catch (error) { + return null; + } +}; diff --git a/apps/meteor/server/lib/utils/lib/getAvatarColor.ts b/apps/meteor/server/lib/utils/lib/getAvatarColor.ts new file mode 100644 index 0000000..109a42d --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/getAvatarColor.ts @@ -0,0 +1,3 @@ +const colors = ['#158D65', '#7F1B9F', '#B68D00', '#E26D0E', '#10529E', '#6C727A']; + +export const getAvatarColor = (name: string): string => colors[name.length % colors.length]; diff --git a/apps/meteor/server/lib/utils/lib/getDefaultSubscriptionPref.ts b/apps/meteor/server/lib/utils/lib/getDefaultSubscriptionPref.ts new file mode 100644 index 0000000..adb4c2a --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/getDefaultSubscriptionPref.ts @@ -0,0 +1,35 @@ +import type { AtLeast, ISubscription, IUser } from '@rocket.chat/core-typings'; + +/** + * @type {(userPref: Pick) => { + * desktopPrefOrigin: 'user'; + * mobilePrefOrigin: 'user'; + * emailPrefOrigin: 'user'; + * }} + */ +export const getDefaultSubscriptionPref = (userPref: AtLeast) => { + const subscription: Partial = {}; + + const { desktopNotifications, pushNotifications, emailNotificationMode, highlights } = userPref.settings?.preferences || {}; + + if (Array.isArray(highlights) && highlights.length) { + subscription.userHighlights = highlights; + } + + if (desktopNotifications && desktopNotifications !== 'default') { + subscription.desktopNotifications = desktopNotifications; + subscription.desktopPrefOrigin = 'user'; + } + + if (pushNotifications && pushNotifications !== 'default') { + subscription.mobilePushNotifications = pushNotifications; + subscription.mobilePrefOrigin = 'user'; + } + + if (emailNotificationMode && emailNotificationMode !== 'default') { + subscription.emailNotifications = emailNotificationMode; + subscription.emailPrefOrigin = 'user'; + } + + return subscription; +}; diff --git a/apps/meteor/server/lib/utils/lib/getTimezone.ts b/apps/meteor/server/lib/utils/lib/getTimezone.ts new file mode 100644 index 0000000..76ef6af --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/getTimezone.ts @@ -0,0 +1,32 @@ +import moment from 'moment-timezone'; + +import { settings } from '../../../settings'; + +const padOffset = (offset: string | number): string => { + const numberOffset = Number(offset); + const absOffset = Math.abs(numberOffset); + const isNegative = !(numberOffset === absOffset); + + return `${isNegative ? '-' : '+'}${absOffset < 10 ? `0${absOffset}` : absOffset}:00`; +}; + +const guessTimezoneFromOffset = (offset?: string | number): string => { + if (!offset) { + return moment.tz.guess(); + } + + return moment.tz.names().find((tz) => padOffset(offset) === moment.tz(tz).format('Z').toString()) || moment.tz.guess(); +}; + +export const getTimezone = (user?: { utcOffset?: string | number } | null): string => { + const timezone = settings.get('Default_Timezone_For_Reporting'); + + switch (timezone) { + case 'custom': + return settings.get('Default_Custom_Timezone'); + case 'user': + return guessTimezoneFromOffset(user?.utcOffset); + default: + return moment.tz.guess(); + } +}; diff --git a/apps/meteor/server/lib/utils/lib/getUserPreference.ts b/apps/meteor/server/lib/utils/lib/getUserPreference.ts new file mode 100644 index 0000000..91c2e4f --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/getUserPreference.ts @@ -0,0 +1,32 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; + +/** + * @summary Get a user preference + * @param {String} userId The user ID + * @param {String} preference The preference name + * @param {unknown?} defaultValue The default value + * @returns {unknown} The preference value + */ +export const getUserPreference = async (user: IUser | string, key: string, defaultValue: any = undefined) => { + let preference; + if (typeof user === 'string') { + const dbUser = await Users.findOneById(user, { projection: { [`settings.preferences.${key}`]: 1 } }); + if (dbUser) { + user = dbUser; + } + } + if (typeof user === 'string') { + return defaultValue; + } + + if (user?.settings?.preferences?.hasOwnProperty(key)) { + preference = user.settings.preferences[key]; + } else if (defaultValue === undefined) { + preference = settings.get(`Accounts_Default_User_Preferences_${key}`); + } + + return preference !== undefined ? preference : defaultValue; +}; diff --git a/apps/meteor/server/lib/utils/lib/getValidRoomName.ts b/apps/meteor/server/lib/utils/lib/getValidRoomName.ts new file mode 100644 index 0000000..6f73138 --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/getValidRoomName.ts @@ -0,0 +1,73 @@ +import { Rooms } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/tools'; +import limax from 'limax'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../../settings'; +import { validateName } from '../../shared/validateName'; + +export const getValidRoomName = async (displayName: string, rid = '', options: { allowDuplicates?: boolean } = {}) => { + let slugifiedName = displayName; + + if (settings.get('UI_Allow_room_names_with_special_chars')) { + const cleanName = limax(displayName, { maintainCase: true }); + if (options.allowDuplicates !== true) { + const room = await Rooms.findOneByDisplayName(displayName); + if (room && room._id !== rid) { + if (room.archived) { + throw new Meteor.Error('error-archived-duplicate-name', `There's an archived channel with name ${cleanName}`, { + function: 'RocketChat.getValidRoomName', + channel_name: cleanName, + }); + } else { + throw new Meteor.Error('error-duplicate-channel-name', `A channel with name '${cleanName}' exists`, { + function: 'RocketChat.getValidRoomName', + channel_name: cleanName, + }); + } + } + } + slugifiedName = cleanName; + } + + let nameValidation; + + try { + nameValidation = new RegExp(`^${settings.get('UTF8_Channel_Names_Validation')}$`); + } catch (error) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (!nameValidation.test(slugifiedName) || !validateName(slugifiedName)) { + throw new Meteor.Error('error-invalid-room-name', `${escapeHTML(slugifiedName)} is not a valid room name.`, { + function: 'RocketChat.getValidRoomName', + channel_name: escapeHTML(slugifiedName), + }); + } + + if (options.allowDuplicates !== true) { + const room = await Rooms.findOneByName(slugifiedName); + if (room && room._id !== rid) { + if (settings.get('UI_Allow_room_names_with_special_chars')) { + let tmpName = slugifiedName; + let next = 0; + while (await Rooms.findOneByNameAndNotId(tmpName, rid)) { + tmpName = `${slugifiedName}-${++next}`; + } + slugifiedName = tmpName; + } else if (room.archived) { + throw new Meteor.Error('error-archived-duplicate-name', `There's an archived channel with name ${escapeHTML(slugifiedName)}`, { + function: 'RocketChat.getValidRoomName', + channel_name: escapeHTML(slugifiedName), + }); + } else { + throw new Meteor.Error('error-duplicate-channel-name', `A channel with name '${escapeHTML(slugifiedName)}' exists`, { + function: 'RocketChat.getValidRoomName', + channel_name: escapeHTML(slugifiedName), + }); + } + } + } + + return slugifiedName; +}; diff --git a/apps/meteor/server/lib/utils/lib/normalizeMessagesForUser.ts b/apps/meteor/server/lib/utils/lib/normalizeMessagesForUser.ts new file mode 100644 index 0000000..ff15e06 --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/normalizeMessagesForUser.ts @@ -0,0 +1,88 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings'; + +type NormalizableMessage = Pick; + +const filterStarred = >(message: T, uid?: string): T => { + // if Allow_anonymous_read is enabled, uid will be undefined + if (!uid) return message; + + // only return starred field if user has it starred + if (message.starred && Array.isArray(message.starred)) { + message.starred = message.starred.filter((star) => star._id === uid); + } + return message; +}; + +// TODO: we should let clients get user names on demand instead of doing this + +function getNameOfUsername(users: Map, username: string): string { + return users.get(username) || username; +} + +export const normalizeMessagesForUser = async (messages: T[], uid?: string): Promise => { + // if not using real names, there is nothing else to do + if (!settings.get('UI_Use_Real_Name')) { + return messages.map((message) => filterStarred(message, uid)); + } + + const usernames: Set = new Set(); + + messages.forEach((message) => { + message = filterStarred(message, uid); + + if (!message.u?.username) { + return; + } + usernames.add(message.u.username); + + (message.mentions || []).forEach(({ username }) => { + if (username) { + usernames.add(username); + } + }); + + Object.values(message.reactions || {}).forEach((reaction) => reaction.usernames.forEach((username) => usernames.add(username))); + }); + + const names = new Map(); + + ( + await Users.findUsersByUsernames([...usernames.values()], { + projection: { + username: 1, + name: 1, + }, + }).toArray() + ).forEach((user) => { + names.set(user.username, user.name); + }); + + messages.forEach((message) => { + if (!message.u) { + return; + } + message.u.name = getNameOfUsername(names, message.u.username); + + (message.mentions || []).forEach((mention) => { + if (mention.username) { + mention.name = getNameOfUsername(names, mention.username); + } + }); + + if (!message.reactions) { + return messages; + } + + message.reactions = Object.fromEntries( + Object.entries(message.reactions).map(([keys, reaction]) => { + reaction.names = reaction.usernames.map((username) => getNameOfUsername(names, username)); + return [keys, reaction]; + }), + ); + }); + + return messages; +}; diff --git a/apps/meteor/server/lib/utils/lib/templateVarHandler.ts b/apps/meteor/server/lib/utils/lib/templateVarHandler.ts new file mode 100644 index 0000000..047ad24 --- /dev/null +++ b/apps/meteor/server/lib/utils/lib/templateVarHandler.ts @@ -0,0 +1,33 @@ +import { Logger } from '@rocket.chat/logger'; + +const logger = new Logger('TemplateVarHandler'); + +export const templateVarHandler = function (variable: string, object: Record): string | undefined { + const templateRegex = /#{([\w\-]+)}/gi; + let match = templateRegex.exec(variable); + let tmpVariable = variable; + + if (match == null) { + if (!object.hasOwnProperty(variable)) { + logger?.debug({ msg: 'User does not have attribute', attribute: variable }); + return; + } + return object[variable]; + } + logger?.debug('template found. replacing values'); + while (match != null) { + const tmplVar = match[0]; + const tmplAttrName = match[1]; + + if (!object.hasOwnProperty(tmplAttrName)) { + logger?.debug({ msg: 'User does not have attribute', attribute: tmplAttrName }); + return; + } + + const attrVal = object[tmplAttrName]; + logger?.debug({ msg: 'Replacing template var', templateVar: tmplVar, value: attrVal }); + tmpVariable = tmpVariable.replace(tmplVar, attrVal); + match = templateRegex.exec(variable); + } + return tmpVariable; +}; diff --git a/apps/meteor/server/lib/utils/placeholders.ts b/apps/meteor/server/lib/utils/placeholders.ts new file mode 100644 index 0000000..e717eed --- /dev/null +++ b/apps/meteor/server/lib/utils/placeholders.ts @@ -0,0 +1,35 @@ +import { strLeft, strRightBack } from '../../../lib/utils/stringUtils'; +import { settings } from '../../settings'; + +export const placeholders = { + replace: ( + str: string, + data: { name?: string; email?: string; password?: string; reason?: string; user?: string; room?: string; unsubscribe?: string }, + ) => { + if (!str) { + return ''; + } + + str = str.replace(/\[Site_Name\]/g, settings.get('Site_Name') || ''); + str = str.replace(/\[Site_URL\]/g, settings.get('Site_Url') || ''); + + if (data) { + str = str.replace(/\[name\]/g, data.name || ''); + str = str.replace(/\[fname\]/g, strLeft(data.name, ' ') || ''); + str = str.replace(/\[lname\]/g, strRightBack(data.name, ' ') || ''); + str = str.replace(/\[email\]/g, data.email || ''); + str = str.replace(/\[password\]/g, data.password || ''); + str = str.replace(/\[reason\]/g, data.reason || ''); + str = str.replace(/\[User\]/g, data.user || ''); + str = str.replace(/\[Room\]/g, data.room || ''); + + if (data.unsubscribe) { + str = str.replace(/\[unsubscribe\]/g, data.unsubscribe); + } + } + + str = str.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + return str; + }, +}; diff --git a/apps/meteor/server/lib/utils/restrictions.ts b/apps/meteor/server/lib/utils/restrictions.ts new file mode 100644 index 0000000..1604101 --- /dev/null +++ b/apps/meteor/server/lib/utils/restrictions.ts @@ -0,0 +1,9 @@ +import { fileUploadIsValidContentTypeFromSettings } from '../../../app/utils/lib/restrictions'; +import { settings } from '../../settings'; + +export const fileUploadIsValidContentType = function (type: string | undefined, customWhiteList?: string): boolean { + const blackList = settings.get('FileUpload_MediaTypeBlackList'); + const whiteList = customWhiteList || settings.get('FileUpload_MediaTypeWhiteList'); + + return fileUploadIsValidContentTypeFromSettings(type, whiteList, blackList); +}; diff --git a/apps/meteor/server/lib/utils/slashCommand.ts b/apps/meteor/server/lib/utils/slashCommand.ts new file mode 100644 index 0000000..e3ce3ae --- /dev/null +++ b/apps/meteor/server/lib/utils/slashCommand.ts @@ -0,0 +1,166 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { + IMessage, + SlashCommand, + SlashCommandOptions, + RequiredField, + SlashCommandPreviewItem, + SlashCommandPreviews, +} from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../deprecationWarningLogger'; + +interface ISlashCommandAddParams { + command: string; + callback?: SlashCommand['callback']; + options?: SlashCommandOptions; + result?: SlashCommand['result']; + providesPreview?: boolean; + previewer?: SlashCommand['previewer']; + previewCallback?: SlashCommand['previewCallback']; + appId?: string; + description?: string; +} + +export const slashCommands = { + commands: {} as Record, + add({ + command, + callback, + options = {}, + result, + providesPreview = false, + previewer, + previewCallback, + appId, + description = '', + }: ISlashCommandAddParams): void { + if (this.commands[command]) { + return; + } + this.commands[command] = { + command, + callback, + params: options.params, + description: options.description || description, + permission: options.permission, + clientOnly: options.clientOnly || false, + result, + providesPreview: Boolean(providesPreview), + previewer, + previewCallback, + appId, + } as SlashCommand; + }, + async run({ + command, + message, + params, + triggerId, + userId, + }: { + command: string; + params: string; + message: RequiredField, 'rid' | '_id'>; + userId: string; + triggerId?: string | undefined; + }): Promise { + const cmd = this.commands[command]; + if (typeof cmd?.callback !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + return cmd.callback({ command, params, message, triggerId, userId }); + }, + async getPreviews( + command: string, + params: string, + message: RequiredField, 'rid'>, + userId: string, + ): Promise { + const cmd = this.commands[command]; + if (typeof cmd?.previewer !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + const previewInfo = await cmd.previewer(command, params, message, userId); + + if (!previewInfo?.items?.length) { + return; + } + + // A limit of ten results, to save time and bandwidth + if (previewInfo.items.length >= 10) { + previewInfo.items = previewInfo.items.slice(0, 10); + } + + return previewInfo; + }, + async executePreview( + command: string, + params: string, + message: Pick & Partial>, + preview: SlashCommandPreviewItem, + userId: string, + triggerId?: string, + ) { + const cmd = this.commands[command]; + if (typeof cmd?.previewCallback !== 'function') { + return; + } + + if (!message?.rid) { + throw new MeteorError('invalid-command-usage', 'Executing a command requires at least a message with a room id.'); + } + + // { id, type, value } + if (!preview.id || !preview.type || !preview.value) { + throw new MeteorError('error-invalid-preview', 'Preview Item must have an id, type, and value.'); + } + + return cmd.previewCallback(command, params, message, preview, userId, triggerId); + }, +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + slashCommand(params: { cmd: string; params: string; msg: IMessage; triggerId: string }): unknown; + } +} + +Meteor.methods({ + async slashCommand(command) { + methodDeprecationLogger.method('slashCommand', '9.0.0', '/v1/commands.run'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'slashCommand', + }); + } + + if (!command?.cmd || !slashCommands.commands[command.cmd]) { + throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', { + method: 'executeSlashCommandPreview', + }); + } + + return slashCommands.run({ + command: command.cmd, + params: command.params, + message: command.msg, + triggerId: command.triggerId, + userId, + }); + }, +}); diff --git a/apps/meteor/server/lib/validateEmailDomain.js b/apps/meteor/server/lib/validateEmailDomain.js new file mode 100644 index 0000000..8cd8ea9 --- /dev/null +++ b/apps/meteor/server/lib/validateEmailDomain.js @@ -0,0 +1,72 @@ +import dns from 'node:dns'; +import util from 'node:util'; + +import { validateEmail } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { emailDomainDefaultBlackList } from './defaultBlockedDomainsList'; +import { settings } from '../settings'; + +const dnsResolveMx = util.promisify(dns.resolveMx); + +let emailDomainBlackList = []; +let emailDomainWhiteList = []; + +settings.watch('Accounts_BlockedDomainsList', (value) => { + if (!value) { + emailDomainBlackList = []; + return; + } + + emailDomainBlackList = value + .split(',') + .filter(Boolean) + .map((domain) => domain.trim()); +}); +settings.watch('Accounts_AllowedDomainsList', (value) => { + if (!value) { + emailDomainWhiteList = []; + return; + } + + emailDomainWhiteList = value + .split(',') + .filter(Boolean) + .map((domain) => domain.trim()); +}); + +export const validateEmailDomain = async function (email) { + if (!validateEmail(email)) { + throw new Meteor.Error('error-invalid-email', `Invalid email ${email}`, { + function: 'RocketChat.validateEmailDomain', + email, + }); + } + + const emailDomain = email.substr(email.lastIndexOf('@') + 1); + + if (emailDomainWhiteList.length && !emailDomainWhiteList.includes(emailDomain)) { + throw new Meteor.Error('error-invalid-domain', 'The email domain is not in whitelist', { + function: 'RocketChat.validateEmailDomain', + }); + } + if ( + emailDomainBlackList.length && + (emailDomainBlackList.indexOf(emailDomain) !== -1 || + (settings.get('Accounts_UseDefaultBlockedDomainsList') && emailDomainDefaultBlackList.indexOf(emailDomain) !== -1)) + ) { + throw new Meteor.Error('error-email-domain-blacklisted', 'The email domain is blacklisted', { + function: 'RocketChat.validateEmailDomain', + }); + } + + if (settings.get('Accounts_UseDNSDomainCheck')) { + try { + await dnsResolveMx(emailDomain); + } catch (e) { + throw new Meteor.Error('error-invalid-domain', 'Invalid domain', { + function: 'RocketChat.validateEmailDomain', + }); + } + } +}; diff --git a/apps/meteor/server/lib/videoConfProviders.ts b/apps/meteor/server/lib/videoConfProviders.ts new file mode 100644 index 0000000..4c753c1 --- /dev/null +++ b/apps/meteor/server/lib/videoConfProviders.ts @@ -0,0 +1,70 @@ +import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings'; + +import { settings } from '../settings'; + +const providers = new Map(); + +export const videoConfProviders = { + registerProvider(providerName: string, capabilities: VideoConferenceCapabilities, appId: string): void { + providers.set(providerName.toLowerCase(), { capabilities, label: providerName, appId }); + }, + + unRegisterProvider(providerName: string): void { + const key = providerName.toLowerCase(); + + if (providers.has(key)) { + providers.delete(key); + } + }, + + getActiveProvider(): string | undefined { + if (providers.size === 0) { + return; + } + const defaultProvider = settings.get('VideoConf_Default_Provider'); + + if (defaultProvider) { + if (providers.has(defaultProvider)) { + return defaultProvider; + } + + return; + } + + if (providers.size === 1) { + const [[name]] = [...providers]; + return name; + } + }, + + hasAnyProvider(): boolean { + return providers.size > 0; + }, + + getProviderList(): { key: string; label: string }[] { + return [...providers.keys()].map((key) => ({ key, label: providers.get(key)?.label || key })); + }, + + isProviderAvailable(name: string): boolean { + return providers.has(name); + }, + + getProviderCapabilities(name: string): VideoConferenceCapabilities | undefined { + const key = name.toLowerCase(); + if (!providers.has(key)) { + return; + } + + return providers.get(key)?.capabilities; + }, + + getProviderAppId(name: string): string | undefined { + const key = name.toLowerCase(); + + if (!providers.has(key)) { + return; + } + + return providers.get(key)?.appId; + }, +}; diff --git a/apps/meteor/server/lib/videoConfTypes.ts b/apps/meteor/server/lib/videoConfTypes.ts new file mode 100644 index 0000000..7529115 --- /dev/null +++ b/apps/meteor/server/lib/videoConfTypes.ts @@ -0,0 +1,51 @@ +import type { + AtLeast, + ExternalVideoConference, + IRoom, + VideoConference, + VideoConferenceCreateData, + VideoConferenceType, +} from '@rocket.chat/core-typings'; + +type RoomRequiredFields = AtLeast; +type VideoConferenceTypeCondition = (room: RoomRequiredFields, allowRinging: boolean) => Promise; + +const typeConditions: { + data: VideoConferenceType | AtLeast; + condition: VideoConferenceTypeCondition; + priority: number; +}[] = []; + +export const videoConfTypes = { + registerVideoConferenceType( + data: VideoConferenceType | AtLeast, + condition: VideoConferenceTypeCondition, + priority = 1, + ): void { + typeConditions.push({ data, condition, priority }); + typeConditions.sort(({ priority: prior1 }, { priority: prior2 }) => prior2 - prior1); + }, + + async getTypeForRoom(room: RoomRequiredFields, allowRinging: boolean): Promise> { + for await (const { data, condition } of typeConditions) { + if (await condition(room, allowRinging)) { + if (typeof data === 'string') { + return { + type: data, + }; + } + + return data; + } + } + + return { type: 'videoconference' }; + }, + + isCallManagedByApp(call: VideoConference): call is ExternalVideoConference { + return call.type !== 'voip'; + }, +}; + +videoConfTypes.registerVideoConferenceType('voip', async () => false); +videoConfTypes.registerVideoConferenceType({ type: 'livechat' }, async ({ t }) => t === 'l'); diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts new file mode 100644 index 0000000..70e26cc --- /dev/null +++ b/apps/meteor/server/main.ts @@ -0,0 +1,41 @@ +import './tracing'; +import './models'; + +/** + * ./settings uses top level await, in theory the settings creation + * and the startup should be done in parallel + */ +import './settings/definitions'; + +import { startRestAPI } from './api/api'; +import { configureServer } from './configuration'; +import { SystemLogger } from './lib/logger/system'; +import { registerServices } from './services/startup'; +import { settings } from './settings'; +import { startup } from './startup'; +import { startCronJobs } from './startup/cron'; +import { startupApp } from '../ee/server'; +import { startRocketChat } from '../startRocketChat'; + +import './routes'; +import './startup/rateLimiter'; +import './startup/robots'; +import './importPackages'; +import './meteor-methods'; +import './publications'; +import '../lib/oauthRedirectUriServer'; +import './lib/pushConfig'; +import './features/EmailInbox/index'; + +await Promise.all([configureServer(settings), registerServices(), startup()]); + +await startRocketChat(); + +setImmediate(() => { + startCronJobs().catch((err) => { + SystemLogger.error({ msg: 'Failed to start cron jobs', err }); + }); +}); + +await startupApp(); +await startRestAPI(); diff --git a/apps/meteor/server/meteor-methods/auth/addOAuthService.ts b/apps/meteor/server/meteor-methods/auth/addOAuthService.ts new file mode 100644 index 0000000..719ff0f --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/addOAuthService.ts @@ -0,0 +1,40 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { addOAuthService } from '../../lib/oauth/addOAuthService'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addOAuthService(name: string): void; + } +} + +export const addOAuthServiceMethod = async (userId: string, name: string): Promise => { + if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) { + throw new Meteor.Error('error-action-not-allowed', 'Adding OAuth Services is not allowed', { + method: 'addOAuthService', + action: 'Adding_OAuth_Services', + }); + } + + await addOAuthService(name); +}; + +Meteor.methods({ + async addOAuthService(name) { + methodDeprecationLogger.method('addOAuthService', '9.0.0', '/v1/settings.addCustomOAuth'); + check(name, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addOAuthService' }); + } + + return addOAuthServiceMethod(userId, name); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/addPermissionToRole.ts b/apps/meteor/server/meteor-methods/auth/addPermissionToRole.ts new file mode 100644 index 0000000..195af72 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/addPermissionToRole.ts @@ -0,0 +1,23 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { addPermissionToRoleMethod } from '../../lib/authorization/permissionRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'authorization:addPermissionToRole'(permissionId: string, role: string): void; + } +} + +Meteor.methods({ + async 'authorization:addPermissionToRole'(permissionId, role) { + methodDeprecationLogger.method('authorization:addPermissionToRole', '9.0.0', '/v1/permissions.addRole'); + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'authorization:addPermissionToRole' }); + } + await addPermissionToRoleMethod(uid, permissionId, role); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/addSamlService.ts b/apps/meteor/server/meteor-methods/auth/addSamlService.ts new file mode 100644 index 0000000..077a431 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/addSamlService.ts @@ -0,0 +1,17 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { addSamlService } from '../../lib/saml/lib/settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addSamlService(name: string): void; + } +} + +Meteor.methods({ + addSamlService(name) { + addSamlService(name); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/addUserToRole.ts b/apps/meteor/server/meteor-methods/auth/addUserToRole.ts new file mode 100644 index 0000000..cefb9c4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/addUserToRole.ts @@ -0,0 +1,73 @@ +import { api } from '@rocket.chat/core-services'; +import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Roles, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { addUserRolesAsync } from '../../lib/roles/addUserRoles'; +import { settings } from '../../settings'; + +export const addUserToRole = async (userId: string, roleId: string, username: IUser['username'], scope?: string): Promise => { + if (!(await hasPermissionAsync(userId, 'access-permissions'))) { + throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed', { + method: 'authorization:addUserToRole', + action: 'Accessing_permissions', + }); + } + + if (!roleId || typeof roleId.valueOf() !== 'string' || !username || typeof username.valueOf() !== 'string') { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'authorization:addUserToRole', + }); + } + + const role = await Roles.findOneById>(roleId, { projection: { _id: 1 } }); + + if (!role) { + throw new Meteor.Error('error-invalid-role', 'Invalid Role', { + method: 'authorization:addUserToRole', + }); + } + + if (role._id === 'admin' && !(await hasPermissionAsync(userId, 'assign-admin-role'))) { + throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { + method: 'authorization:addUserToRole', + action: 'Assign_admin', + }); + } + + const user = await Users.findOneByUsernameIgnoringCase(username, { + projection: { + _id: 1, + }, + }); + + if (!user?._id) { + throw new Meteor.Error('error-user-not-found', 'User not found', { + method: 'authorization:addUserToRole', + }); + } + + // verify if user can be added to given scope + if (scope && !(await Roles.canAddUserToRole(user._id, role._id, scope))) { + throw new Meteor.Error('error-invalid-user', 'User is not part of given room', { + method: 'authorization:addUserToRole', + }); + } + + const add = await addUserRolesAsync(user._id, [role._id], scope); + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', { + type: 'added', + _id: role._id, + u: { + _id: user._id, + username, + }, + scope, + }); + } + + return add; +}; diff --git a/apps/meteor/server/meteor-methods/auth/afterVerifyEmail.ts b/apps/meteor/server/meteor-methods/auth/afterVerifyEmail.ts new file mode 100644 index 0000000..dd7dc0c --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/afterVerifyEmail.ts @@ -0,0 +1,28 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { runAfterVerifyEmail } from '../../lib/users/runAfterVerifyEmail'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + afterVerifyEmail(): void; + } +} + +Meteor.methods({ + async afterVerifyEmail() { + methodDeprecationLogger.method('afterVerifyEmail', '9.0.0', '/v1/users.verifyEmail'); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'afterVerifyEmail', + }); + } + + await runAfterVerifyEmail(userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts b/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts new file mode 100644 index 0000000..9982c4f --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/checkCodesRemaining.ts @@ -0,0 +1,19 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { codesRemainingTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + '2fa:checkCodesRemaining': () => { remaining: number }; + } +} + +Meteor.methods({ + async '2fa:checkCodesRemaining'() { + methodDeprecationLogger.method('2fa:checkCodesRemaining', '9.0.0', '/v1/users.totpCodesRemaining'); + return codesRemainingTotp(Meteor.userId()); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/checkRegistrationSecretURL.ts b/apps/meteor/server/meteor-methods/auth/checkRegistrationSecretURL.ts new file mode 100644 index 0000000..4dfcd40 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/checkRegistrationSecretURL.ts @@ -0,0 +1,13 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.methods({ + checkRegistrationSecretURL(hash) { + check(hash, String); + + return hash === settings.get('Accounts_RegistrationForm_SecretURL'); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/createToken.ts b/apps/meteor/server/meteor-methods/auth/createToken.ts new file mode 100644 index 0000000..ec93a65 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/createToken.ts @@ -0,0 +1,32 @@ +import { Authorization, MeteorError, User } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + createToken(userId: string): { userId: string; authToken: string }; + } +} + +const { CREATE_TOKENS_FOR_USERS_SECRET } = process.env; + +export async function generateAccessToken(userId: string, secret: string, caller: IUser) { + if (secret !== CREATE_TOKENS_FOR_USERS_SECRET) { + throw new MeteorError('error-not-authorized', 'Not authorized'); + } + + if (caller._id !== userId && !(await Authorization.hasPermission(caller, 'user-generate-access-token'))) { + throw new MeteorError('error-not-authorized', 'Not authorized'); + } + + const token = Accounts._generateStampedLoginToken(); + await Accounts._insertLoginToken(userId, token); + + await User.ensureLoginTokensLimit(userId); + + return { + userId, + authToken: token.token, + }; +} diff --git a/apps/meteor/server/meteor-methods/auth/crowd.ts b/apps/meteor/server/meteor-methods/auth/crowd.ts new file mode 100644 index 0000000..7babe2f --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/crowd.ts @@ -0,0 +1,81 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; + +import { CROWD } from '../../lib/auth-providers/crowd/crowd'; +import { logger } from '../../lib/auth-providers/crowd/logger'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + crowd_test_connection(): { message: TranslationKey; params: string[] }; + crowd_sync_users(): { message: string; params: string[] }; + } +} + +Meteor.methods({ + async crowd_test_connection() { + const user = await Meteor.userAsync(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'crowd_test_connection', + }); + } + + if (!(await hasPermissionAsync(user, 'test-admin-options'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'crowd_test_connection', + }); + } + + if (settings.get('CROWD_Enable') !== true) { + throw new Meteor.Error('crowd_disabled'); + } + + try { + const crowd = new CROWD(); + await crowd.checkConnection(); + + return { + message: 'Crowd_Connection_successful' as const, + params: [], + }; + } catch (err) { + logger.error({ + msg: 'Invalid crowd connection details, check the url and application username/password and make sure this server is allowed to speak to crowd', + err, + }); + throw new Meteor.Error('Invalid connection details', '', { method: 'crowd_test_connection' }); + } + }, + async crowd_sync_users() { + const user = await Meteor.userAsync(); + if (settings.get('CROWD_Enable') !== true) { + throw new Meteor.Error('crowd_disabled'); + } + + if (!user || !(await hasPermissionAsync(user, 'sync-auth-services-users'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'crowd_sync_users', + }); + } + + try { + const crowd = new CROWD(); + const startTime = Date.now(); + await crowd.sync(); + const stopTime = Date.now(); + const actual = Math.ceil((stopTime - startTime) / 1000); + + return { + message: `User data synced in ${actual} seconds`, + params: [], + }; + } catch (err) { + logger.error({ msg: 'Error syncing user data. ', err }); + throw new Meteor.Error('Error syncing user data', '', { method: 'crowd_sync_users' }); + } + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/deleteOAuthApp.ts b/apps/meteor/server/meteor-methods/auth/deleteOAuthApp.ts new file mode 100644 index 0000000..142b78f --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/deleteOAuthApp.ts @@ -0,0 +1,43 @@ +import type { IOAuthApps } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { OAuthAccessTokens, OAuthApps, OAuthAuthCodes } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteOAuthApp(applicationId: IOAuthApps['_id']): boolean; + } +} + +export const deleteOAuthApp = async (userId: string, applicationId: IOAuthApps['_id']): Promise => { + if (!(await hasPermissionAsync(userId, 'manage-oauth-apps'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'deleteOAuthApp' }); + } + + const application = await OAuthApps.findOneAndDeleteById(applicationId, { projection: { clientId: 1 } }); + if (!application) { + throw new Meteor.Error('error-application-not-found', 'Application not found', { + method: 'deleteOAuthApp', + }); + } + + await OAuthAccessTokens.deleteMany({ clientId: application.clientId }); + await OAuthAuthCodes.deleteMany({ clientId: application.clientId }); + + return true; +}; + +Meteor.methods({ + async deleteOAuthApp(applicationId) { + methodDeprecationLogger.method('deleteOAuthApp', '9.0.0', '/v1/oauth-apps.delete'); + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'deleteOAuthApp' }); + } + + return deleteOAuthApp(this.userId, applicationId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/disable.ts b/apps/meteor/server/meteor-methods/auth/disable.ts new file mode 100644 index 0000000..56cd832 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/disable.ts @@ -0,0 +1,19 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { disableTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + '2fa:disable': (code: string) => Promise; + } +} + +Meteor.methods({ + async '2fa:disable'(code) { + methodDeprecationLogger.method('2fa:disable', '9.0.0', '/v1/users.disableTotp'); + return disableTotp(Meteor.userId(), code); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/enable.ts b/apps/meteor/server/meteor-methods/auth/enable.ts new file mode 100644 index 0000000..5422679 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/enable.ts @@ -0,0 +1,19 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { enableTotp } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + '2fa:enable': () => Promise<{ secret: string; url: string }>; + } +} + +Meteor.methods({ + async '2fa:enable'() { + methodDeprecationLogger.method('2fa:enable', '9.0.0', '/v1/users.enableTotp'); + return enableTotp(Meteor.userId()); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/logoutCleanUp.ts b/apps/meteor/server/meteor-methods/auth/logoutCleanUp.ts new file mode 100644 index 0000000..6bf5078 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/logoutCleanUp.ts @@ -0,0 +1,29 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + logoutCleanUp(user: IUser): Promise; + } +} + +Meteor.methods({ + async logoutCleanUp(user) { + methodDeprecationLogger.method('logoutCleanUp', '9.0.0', '/v1/users.logout'); + check(user, Object); + + setImmediate(() => { + void afterLogoutCleanUpCallback.run(user); + }); + + // App IPostUserLogout event hook + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/refreshOAuthService.ts b/apps/meteor/server/meteor-methods/auth/refreshOAuthService.ts new file mode 100644 index 0000000..0f550b7 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/refreshOAuthService.ts @@ -0,0 +1,36 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { refreshLoginServices } from '../../lib/refreshLoginServices'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + refreshOAuthService(): Promise; + } +} + +Meteor.methods({ + async refreshOAuthService() { + methodDeprecationLogger.method('refreshOAuthService', '9.0.0', '/v1/settings.refreshOAuthServices'); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'refreshOAuthService', + }); + } + + if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) { + throw new Meteor.Error('error-action-not-allowed', 'Refresh OAuth Services is not allowed', { + method: 'refreshOAuthService', + action: 'Refreshing_OAuth_Services', + }); + } + + await refreshLoginServices(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts b/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts new file mode 100644 index 0000000..1a02b60 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/regenerateCodes.ts @@ -0,0 +1,19 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { regenerateTotpCodes } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + '2fa:regenerateCodes': (userToken: string) => { codes: string[] } | undefined; + } +} + +Meteor.methods({ + async '2fa:regenerateCodes'(userToken) { + methodDeprecationLogger.method('2fa:regenerateCodes', '9.0.0', '/v1/users.regenerateTotpCodes'); + return regenerateTotpCodes(Meteor.userId(), userToken); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/removeOAuthService.ts b/apps/meteor/server/meteor-methods/auth/removeOAuthService.ts new file mode 100644 index 0000000..e0a3233 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/removeOAuthService.ts @@ -0,0 +1,86 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Settings } from '@rocket.chat/models'; +import { capitalize } from '@rocket.chat/tools'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSettingChangedById } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeOAuthService(name: string): Promise; + } +} + +export const removeCustomOAuthSettings = async (name: string): Promise => { + const normalized = capitalize(name.toLowerCase().replace(/[^a-z0-9_]/g, '')); + + if (!normalized) { + throw new Meteor.Error('error-invalid-name', 'Invalid OAuth service name', { method: 'removeOAuthService' }); + } + + const settingsIds = [ + `Accounts_OAuth_Custom-${normalized}`, + `Accounts_OAuth_Custom-${normalized}-url`, + `Accounts_OAuth_Custom-${normalized}-token_path`, + `Accounts_OAuth_Custom-${normalized}-identity_path`, + `Accounts_OAuth_Custom-${normalized}-authorize_path`, + `Accounts_OAuth_Custom-${normalized}-scope`, + `Accounts_OAuth_Custom-${normalized}-access_token_param`, + `Accounts_OAuth_Custom-${normalized}-token_sent_via`, + `Accounts_OAuth_Custom-${normalized}-identity_token_sent_via`, + `Accounts_OAuth_Custom-${normalized}-id`, + `Accounts_OAuth_Custom-${normalized}-secret`, + `Accounts_OAuth_Custom-${normalized}-button_label_text`, + `Accounts_OAuth_Custom-${normalized}-button_label_color`, + `Accounts_OAuth_Custom-${normalized}-button_color`, + `Accounts_OAuth_Custom-${normalized}-login_style`, + `Accounts_OAuth_Custom-${normalized}-key_field`, + `Accounts_OAuth_Custom-${normalized}-username_field`, + `Accounts_OAuth_Custom-${normalized}-email_field`, + `Accounts_OAuth_Custom-${normalized}-name_field`, + `Accounts_OAuth_Custom-${normalized}-avatar_field`, + `Accounts_OAuth_Custom-${normalized}-roles_claim`, + `Accounts_OAuth_Custom-${normalized}-merge_roles`, + `Accounts_OAuth_Custom-${normalized}-roles_to_sync`, + `Accounts_OAuth_Custom-${normalized}-merge_users`, + `Accounts_OAuth_Custom-${normalized}-show_button`, + `Accounts_OAuth_Custom-${normalized}-groups_claim`, + `Accounts_OAuth_Custom-${normalized}-channels_admin`, + `Accounts_OAuth_Custom-${normalized}-map_channels`, + `Accounts_OAuth_Custom-${normalized}-groups_channel_map`, + `Accounts_OAuth_Custom-${normalized}-merge_users_distinct_services`, + ]; + + const promises = settingsIds.map((id) => Settings.removeById(id)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.deletedCount) { + void notifyOnSettingChangedById(settingsIds[index], 'removed'); + } + }); +}; + +Meteor.methods({ + async removeOAuthService(name) { + methodDeprecationLogger.method('removeOAuthService', '9.0.0', '/v1/settings.removeCustomOAuth'); + check(name, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeOAuthService', + }); + } + + if ((await hasPermissionAsync(userId, 'add-oauth-service')) !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'removeOAuthService' }); + } + + await removeCustomOAuthSettings(name); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/removeRoleFromPermission.ts b/apps/meteor/server/meteor-methods/auth/removeRoleFromPermission.ts new file mode 100644 index 0000000..ef083d3 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/removeRoleFromPermission.ts @@ -0,0 +1,23 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { removeRoleFromPermissionMethod } from '../../lib/authorization/permissionRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'authorization:removeRoleFromPermission'(permissionId: string, role: string): void; + } +} + +Meteor.methods({ + async 'authorization:removeRoleFromPermission'(permissionId, role) { + methodDeprecationLogger.method('authorization:removeRoleFromPermission', '9.0.0', '/v1/permissions.removeRole'); + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'authorization:removeRoleFromPermission' }); + } + await removeRoleFromPermissionMethod(uid, permissionId, role); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/removeUserFromRole.ts b/apps/meteor/server/meteor-methods/auth/removeUserFromRole.ts new file mode 100644 index 0000000..bc64cc0 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/removeUserFromRole.ts @@ -0,0 +1,77 @@ +import { api } from '@rocket.chat/core-services'; +import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Roles, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { removeUserFromRolesAsync } from '../../lib/roles/removeUserFromRoles'; +import { settings } from '../../settings'; + +export const removeUserFromRole = async (userId: string, roleId: string, username: IUser['username'], scope?: string): Promise => { + if (!(await hasPermissionAsync(userId, 'access-permissions'))) { + throw new Meteor.Error('error-action-not-allowed', 'Access permissions is not allowed', { + method: 'authorization:removeUserFromRole', + action: 'Accessing_permissions', + }); + } + + if (!roleId || typeof roleId.valueOf() !== 'string' || !username || typeof username.valueOf() !== 'string') { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'authorization:removeUserFromRole', + }); + } + + const role = await Roles.findOneById>(roleId, { projection: { _id: 1 } }); + if (!role) { + throw new Meteor.Error('error-invalid-role', 'Invalid Role', { + method: 'authorization:removeUserFromRole', + }); + } + + const user = await Users.findOneByUsernameIgnoringCase(username, { + projection: { + _id: 1, + roles: 1, + }, + }); + + if (!user?._id) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'authorization:removeUserFromRole', + }); + } + + // prevent removing last user from admin role + if (role._id === 'admin') { + const adminCount = await Users.countDocuments({ + roles: { + $in: ['admin'], + }, + }); + + const userIsAdmin = user.roles?.indexOf('admin') > -1; + if (adminCount === 1 && userIsAdmin) { + throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without admins is not allowed', { + method: 'removeUserFromRole', + action: 'Remove_last_admin', + }); + } + } + + const remove = await removeUserFromRolesAsync(user._id, [role._id], scope); + const event = { + type: 'removed', + _id: role._id, + u: { + _id: user._id, + username, + }, + scope, + } as const; + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: userId }); + + return remove; +}; diff --git a/apps/meteor/server/meteor-methods/auth/samlLogout.ts b/apps/meteor/server/meteor-methods/auth/samlLogout.ts new file mode 100644 index 0000000..6035da9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/samlLogout.ts @@ -0,0 +1,78 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import type { IServiceProviderOptions } from '../../lib/saml/definition/IServiceProviderOptions'; +import { SAMLServiceProvider } from '../../lib/saml/lib/ServiceProvider'; +import { SAMLUtils } from '../../lib/saml/lib/Utils'; + +/** + * Fetch SAML provider configs for given 'provider'. + */ +function getSamlServiceProviderOptions(provider: string): IServiceProviderOptions { + if (!provider) { + throw new Meteor.Error('no-saml-provider', 'SAML internal error', { + method: 'getSamlServiceProviderOptions', + }); + } + + const providers = SAMLUtils.serviceProviders; + + const samlProvider = function (element: IServiceProviderOptions): boolean { + return element.provider === provider; + }; + + return providers.filter(samlProvider)[0]; +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + samlLogout(provider: string): string | undefined; + } +} + +Meteor.methods({ + async samlLogout(provider: string) { + const userId = Meteor.userId(); + // Make sure the user is logged in before we initiate SAML Logout + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'samlLogout' }); + } + const providerConfig = getSamlServiceProviderOptions(provider); + + SAMLUtils.log({ msg: 'Logout request', providerConfig }); + // This query should respect upcoming array of SAML logins + const user = await Users.getSAMLByIdAndSAMLProvider(userId, provider); + if (!user?.services?.saml) { + return; + } + + const { nameID, idpSession } = user.services.saml; + SAMLUtils.log({ msg: `NameID for user ${Meteor.userId()} found`, nameID }); + + const _saml = new SAMLServiceProvider(providerConfig); + + if (!nameID || !idpSession) { + SAMLUtils.log({ msg: 'No NameID or idpSession found for user', userId }); + return; + } + + const request = _saml.generateLogoutRequest({ + nameID: nameID || idpSession, + sessionIndex: idpSession, + }); + + SAMLUtils.log({ request, msg: '----Logout Request---' }); + + // request.request: actual XML SAML Request + // request.id: comminucation id which will be mentioned in the ResponseTo field of SAMLResponse + + await Users.setSamlInResponseTo(userId, request.id); + + const result = await _saml.requestToUrl(request.request, 'logout'); + SAMLUtils.log({ msg: 'SAML Logout Request URL generated', result }); + + return result; + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/sendConfirmationEmail.ts b/apps/meteor/server/meteor-methods/auth/sendConfirmationEmail.ts new file mode 100644 index 0000000..7eeea1f --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/sendConfirmationEmail.ts @@ -0,0 +1,31 @@ +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +export const sendConfirmationEmail = async (to: string): Promise => { + check(to, String); + + const email = to.trim(); + + const user = await Users.findOneByEmailAddress(email, { projection: { emails: 1 } }); + if (!user) { + return false; + } + + // Do not re-send a verification mail to an address that is already verified. + const target = user.emails?.find((e) => e.address.toLowerCase() === email.toLowerCase() && !e.verified); + if (!target) { + return false; + } + + try { + Accounts.sendVerificationEmail(user._id, target.address); + return true; + } catch (error: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { + method: 'registerUser', + message: error.message, + }); + } +}; diff --git a/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts b/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts new file mode 100644 index 0000000..95fb48e --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts @@ -0,0 +1,59 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + sendForgotPasswordEmail(to: string): boolean | undefined; + } +} + +export const sendForgotPasswordEmail = async (to: string): Promise => { + const email = to.trim().toLowerCase(); + + const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1, services: 1 } }); + + if (!user) { + return true; + } + + if (user.services && !user.services.password) { + if (!settings.get('Accounts_AllowPasswordChangeForOAuthUsers')) { + return false; + } + } + + try { + Accounts.sendResetPasswordEmail(user._id, email); + return true; + } catch (err) { + SystemLogger.error({ err }); + } +}; + +Meteor.methods({ + async sendForgotPasswordEmail(to) { + check(to, String); + + return sendForgotPasswordEmail(to); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'sendForgotPasswordEmail', + clientAddress() { + return true; + }, + }, + 10, + 60000, +); diff --git a/apps/meteor/server/meteor-methods/auth/updateOAuthApp.ts b/apps/meteor/server/meteor-methods/auth/updateOAuthApp.ts new file mode 100644 index 0000000..8aaefc3 --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/updateOAuthApp.ts @@ -0,0 +1,82 @@ +import type { IOAuthApps } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { OAuthApps, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { parseUriList } from '../../lib/auth/oauth2-server/parseUriList'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + updateOAuthApp( + applicationId: IOAuthApps['_id'], + application: Pick, + ): Promise; + } +} + +export const updateOAuthApp = async ( + userId: string, + applicationId: IOAuthApps['_id'], + application: Pick, +): Promise => { + if (!(await hasPermissionAsync(userId, 'manage-oauth-apps'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'updateOAuthApp' }); + } + + if (!application.name || typeof application.name.valueOf() !== 'string' || application.name.trim() === '') { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'updateOAuthApp' }); + } + + if (!application.redirectUri || typeof application.redirectUri.valueOf() !== 'string' || application.redirectUri.trim() === '') { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { + method: 'updateOAuthApp', + }); + } + + if (typeof application.active !== 'boolean') { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'updateOAuthApp', + }); + } + + const redirectUri = parseUriList(application.redirectUri); + + if (redirectUri.length === 0) { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { + method: 'updateOAuthApp', + }); + } + + const updatedApplication = await OAuthApps.updateById(applicationId, { + name: application.name, + active: application.active, + redirectUri, + _updatedBy: await Users.findOneById(userId, { + projection: { + username: 1, + }, + }), + }); + + if (updatedApplication == null) { + throw new Meteor.Error('error-application-not-found', 'Application not found', { + method: 'updateOAuthApp', + }); + } + + return updatedApplication; +}; + +Meteor.methods({ + async updateOAuthApp(applicationId, application) { + methodDeprecationLogger.method('updateOAuthApp', '9.0.0', '/v1/oauth-apps.update'); + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOAuthApp' }); + } + + return updateOAuthApp(this.userId, applicationId, application); + }, +}); diff --git a/apps/meteor/server/meteor-methods/auth/validateTempToken.ts b/apps/meteor/server/meteor-methods/auth/validateTempToken.ts new file mode 100644 index 0000000..e8ad96e --- /dev/null +++ b/apps/meteor/server/meteor-methods/auth/validateTempToken.ts @@ -0,0 +1,20 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { validateTotpTempToken } from '../../lib/2fa/functions/totp'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + '2fa:validateTempToken': (userToken: string) => { codes: string[] } | undefined; + } +} + +Meteor.methods({ + async '2fa:validateTempToken'(userToken) { + methodDeprecationLogger.method('2fa:validateTempToken', '9.0.0', '/v1/users.validateTotp'); + const { 'x-auth-token': xAuthToken } = this.connection?.httpHeaders ?? {}; + return validateTotpTempToken(Meteor.userId(), userToken, xAuthToken); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts b/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts new file mode 100644 index 0000000..2a1b68b --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import type { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { Import } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { Meteor } from 'meteor/meteor'; + +import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { Importers } from '../../lib/import'; +import { RocketChatImportFileInstance } from '../../lib/import/startup/store'; +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +async function getHttpFileStream(fileUrl: string): Promise { + const response = await fetch(fileUrl, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + + const body = response.body as Readable; + if (!response.ok) { + body.resume(); + throw new Error(`Unexpected response status ${response.status}`); + } + + return body; +} + +function copyLocalFile(filePath: fs.PathLike, writeStream: fs.WriteStream): void { + const readStream = fs.createReadStream(filePath); + readStream.pipe(writeStream); +} + +export const executeDownloadPublicImportFile = async (userId: IUser['_id'], fileUrl: string, importerKey: string): Promise => { + const importer = Importers.get(importerKey); + const isUrl = fileUrl.startsWith('http'); + if (!importer) { + throw new Meteor.Error( + 'error-importer-not-defined', + `The importer (${importerKey}) has no import class defined.`, + 'downloadImportFile', + ); + } + // Check if it's a valid url or path before creating a new import record + if (!isUrl && !fs.existsSync(fileUrl)) { + throw new Meteor.Error('error-import-file-missing', fileUrl, 'downloadPublicImportFile'); + } + + const operation = await Import.newOperation(userId, importer.name, importer.key); + const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap + + const oldFileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1).split('?')[0]; + const date = new Date(); + const dateStr = `${date.getUTCFullYear()}${date.getUTCMonth()}${date.getUTCDate()}${date.getUTCHours()}${date.getUTCMinutes()}${date.getUTCSeconds()}`; + const newFileName = `${dateStr}_${userId}_${oldFileName}`; + + // Store the file name on the imports collection + await instance.startFileUpload(newFileName); + await instance.updateProgress(ProgressStep.DOWNLOADING_FILE); + + const writeStream = RocketChatImportFileInstance.createWriteStream(newFileName); + let errorProgressUpdate: Promise | undefined; + const markImportAsFailed = (): Promise => { + errorProgressUpdate ??= instance.updateProgress(ProgressStep.ERROR).catch((error) => { + SystemLogger.error({ msg: 'Failed to update import progress to ERROR', err: error }); + }); + return errorProgressUpdate; + }; + + writeStream.on('error', () => { + void markImportAsFailed(); + }); + + let readStream: Readable | undefined; + if (isUrl) { + try { + readStream = await getHttpFileStream(fileUrl); + } catch (error) { + writeStream.destroy(); + await markImportAsFailed(); + throw error; + } + } + + writeStream.on('finish', () => { + void instance.updateProgress(ProgressStep.FILE_LOADED); + }); + + if (readStream) { + void pipeline(readStream, writeStream).catch(() => markImportAsFailed()); + return; + } + + // If the url is actually a folder path on the current machine, skip moving it to the file store + if (fs.statSync(fileUrl).isDirectory()) { + await instance.updateRecord({ file: fileUrl }); + await instance.updateProgress(ProgressStep.FILE_LOADED); + return; + } + + copyLocalFile(fileUrl, writeStream); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + downloadPublicImportFile(fileUrl: string, importerKey: string): void; + } +} + +Meteor.methods({ + async downloadPublicImportFile(fileUrl: string, importerKey: string) { + methodDeprecationLogger.method('downloadPublicImportFile', '9.0.0', '/v1/downloadPublicImportFile'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'downloadPublicImportFile'); + } + + if (!(await hasPermissionAsync(userId, 'run-import'))) { + throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'downloadPublicImportFile'); + } + + await executeDownloadPublicImportFile(userId, fileUrl, importerKey); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/getImportFileData.ts b/apps/meteor/server/meteor-methods/import/getImportFileData.ts new file mode 100644 index 0000000..7f4a03e --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/getImportFileData.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { IImportProgress, IImporterSelection } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Imports } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { Importers } from '../../lib/import'; +import { RocketChatImportFileInstance } from '../../lib/import/startup/store'; + +export const executeGetImportFileData = async (): Promise => { + const operation = await Imports.findLastImport(); + if (!operation) { + throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'getImportFileData'); + } + + const { importerKey } = operation; + + const importer = Importers.get(importerKey); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'getImportFileData'); + } + + const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap + + const waitingSteps: IImportProgress['step'][] = [ + ProgressStep.DOWNLOADING_FILE, + ProgressStep.PREPARING_CHANNELS, + ProgressStep.PREPARING_MESSAGES, + ProgressStep.PREPARING_USERS, + ProgressStep.PREPARING_CONTACTS, + ProgressStep.PREPARING_STARTED, + ]; + + if (waitingSteps.indexOf(instance.progress.step) >= 0) { + if (instance.importRecord?.valid) { + return { waiting: true }; + } + throw new Meteor.Error('error-import-operation-invalid', 'Invalid Import Operation', 'getImportFileData'); + } + + const readySteps: IImportProgress['step'][] = [ + ProgressStep.USER_SELECTION, + ProgressStep.DONE, + ProgressStep.CANCELLED, + ProgressStep.ERROR, + ]; + + if (readySteps.indexOf(instance.progress.step) >= 0) { + return instance.buildSelection(); + } + + const fileName = instance.importRecord.file; + if (fileName) { + const fullFilePath = fs.existsSync(fileName) ? fileName : path.join(RocketChatImportFileInstance.absolutePath, fileName); + await instance.prepareUsingLocalFile(fullFilePath); + } + + return instance.buildSelection(); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getImportFileData(): IImporterSelection | { waiting: true }; + } +} + +Meteor.methods({ + async getImportFileData() { + methodDeprecationLogger.method('getImportFileData', '9.0.0', '/v1/getImportFileData'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getImportFileData'); + } + + if (!(await hasPermissionAsync(userId, 'run-import'))) { + throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'getImportFileData'); + } + + return executeGetImportFileData(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/getImportProgress.ts b/apps/meteor/server/meteor-methods/import/getImportProgress.ts new file mode 100644 index 0000000..8802751 --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/getImportProgress.ts @@ -0,0 +1,48 @@ +import type { IImportProgress } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Imports } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { Importers } from '../../lib/import'; + +export const executeGetImportProgress = async (): Promise => { + const operation = await Imports.findLastImport(); + if (!operation) { + throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'getImportProgress'); + } + + const { importerKey } = operation; + const importer = Importers.get(importerKey); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'getImportProgress'); + } + + const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap + + return instance.getProgress(); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getImportProgress(): IImportProgress; + } +} + +Meteor.methods({ + async getImportProgress() { + methodDeprecationLogger.method('getImportProgress', '9.0.0', '/v1/getImportProgress'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getImportProgress'); + } + + if (!(await hasPermissionAsync(userId, 'run-import'))) { + throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'setupImporter'); + } + + return executeGetImportProgress(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/getLatestImportOperations.ts b/apps/meteor/server/meteor-methods/import/getLatestImportOperations.ts new file mode 100644 index 0000000..3537edd --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/getLatestImportOperations.ts @@ -0,0 +1,43 @@ +import type { IImport } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Imports } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +export const executeGetLatestImportOperations = async () => { + const data = Imports.find( + {}, + { + sort: { _updatedAt: -1 }, + limit: 20, + }, + ); + + return data.toArray(); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getLatestImportOperations(): IImport[]; + } +} + +Meteor.methods({ + async getLatestImportOperations() { + methodDeprecationLogger.method('getLatestImportOperations', '9.0.0', '/v1/getLatestImportOperations'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getLatestImportOperations'); + } + + if (!(await hasPermissionAsync(userId, 'view-import-operations'))) { + throw new Meteor.Error('not_authorized', 'User not authorized', 'getLatestImportOperations'); + } + + return executeGetLatestImportOperations(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/startImport.ts b/apps/meteor/server/meteor-methods/import/startImport.ts new file mode 100644 index 0000000..3ad56c8 --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/startImport.ts @@ -0,0 +1,54 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Imports } from '@rocket.chat/models'; +import { isStartImportParamsPOST, type StartImportParamsPOST } from '@rocket.chat/rest-typings'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { Importers } from '../../lib/import'; + +export const executeStartImport = async ({ input }: StartImportParamsPOST, startedByUserId: IUser['_id']) => { + const operation = await Imports.findLastImport(); + if (!operation) { + throw new Meteor.Error('error-operation-not-found', 'Import Operation Not Found', 'startImport'); + } + + const { importerKey } = operation; + const importer = Importers.get(importerKey); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'startImport'); + } + + const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap + + await instance.startImport(input, startedByUserId); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + startImport(params: StartImportParamsPOST): void; + } +} + +Meteor.methods({ + async startImport({ input }: StartImportParamsPOST) { + methodDeprecationLogger.method('startImport', '9.0.0', '/v1/startImport'); + if (!input || typeof input !== 'object' || !isStartImportParamsPOST({ input })) { + throw new Meteor.Error(`Invalid Selection data provided to the importer.`); + } + + const userId = Meteor.userId(); + // Takes name and object with users / channels selected to import + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'startImport'); + } + + if (!(await hasPermissionAsync(userId, 'run-import'))) { + throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'startImport'); + } + + return executeStartImport({ input }, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/import/uploadImportFile.ts b/apps/meteor/server/meteor-methods/import/uploadImportFile.ts new file mode 100644 index 0000000..afa243d --- /dev/null +++ b/apps/meteor/server/meteor-methods/import/uploadImportFile.ts @@ -0,0 +1,81 @@ +import { Import } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { Importers } from '../../lib/import'; +import { RocketChatImportFileInstance } from '../../lib/import/startup/store'; +import { RocketChatFile } from '../../lib/media/file'; + +export const executeUploadImportFile = async ( + userId: IUser['_id'], + binaryContent: string, + contentType: string, + fileName: string, + importerKey: string, +): Promise => { + const importer = Importers.get(importerKey); + if (!importer) { + throw new Meteor.Error('error-importer-not-defined', `The importer (${importerKey}) has no import class defined.`, 'uploadImportFile'); + } + + const operation = await Import.newOperation(userId, importer.name, importer.key); + + const instance = new importer.importer(importer, operation); // eslint-disable-line new-cap + + const date = new Date(); + const dateStr = `${date.getUTCFullYear()}${date.getUTCMonth()}${date.getUTCDate()}${date.getUTCHours()}${date.getUTCMinutes()}${date.getUTCSeconds()}`; + const newFileName = `${dateStr}_${userId}_${fileName}`; + + // Store the file name and content type on the imports collection + await instance.startFileUpload(newFileName, contentType); + + // Save the file on the File Store + const file = Buffer.from(binaryContent, 'base64'); + const readStream = RocketChatFile.bufferToStream(file); + const writeStream = RocketChatImportFileInstance.createWriteStream(newFileName, contentType); + + await new Promise((resolve, reject) => { + try { + writeStream.on('end', () => { + resolve(); + }); + writeStream.on('error', (e: Error) => { + reject(e); + }); + + readStream.pipe(writeStream); + } catch (error) { + reject(error); + } + }); + + await instance.updateProgress(ProgressStep.FILE_LOADED); +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + uploadImportFile(binaryContent: string, contentType: string, fileName: string, importerKey: string): void; + } +} + +Meteor.methods({ + async uploadImportFile(binaryContent, contentType, fileName, importerKey) { + methodDeprecationLogger.method('uploadImportFile', '9.0.0', '/v1/uploadImportFile'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', 'uploadImportFile'); + } + + if (!(await hasPermissionAsync(userId, 'run-import'))) { + throw new Meteor.Error('error-action-not-allowed', 'Importing is not allowed', 'uploadImportFile'); + } + + await executeUploadImportFile(userId, binaryContent, contentType, fileName, importerKey); + }, +}); diff --git a/apps/meteor/server/meteor-methods/index.ts b/apps/meteor/server/meteor-methods/index.ts new file mode 100644 index 0000000..1dd0b3c --- /dev/null +++ b/apps/meteor/server/meteor-methods/index.ts @@ -0,0 +1,148 @@ +import '../../imports/personal-access-tokens/server/api/methods'; + +import './auth/addOAuthService'; +import './auth/addPermissionToRole'; +import './auth/addSamlService'; +import './auth/addUserToRole'; +import './auth/afterVerifyEmail'; +import './auth/checkCodesRemaining'; +import './auth/checkRegistrationSecretURL'; +import './auth/crowd'; +import './auth/disable'; +import './auth/enable'; +import './auth/logoutCleanUp'; +import './auth/refreshOAuthService'; +import './auth/regenerateCodes'; +import './auth/removeOAuthService'; +import './auth/removeRoleFromPermission'; +import './auth/samlLogout'; +import './auth/sendConfirmationEmail'; +import './auth/sendForgotPasswordEmail'; +import './auth/validateTempToken'; +import './import/downloadPublicImportFile'; +import './import/getImportFileData'; +import './import/getImportProgress'; +import './import/getLatestImportOperations'; +import './import/startImport'; +import './import/uploadImportFile'; +import './integrations/clearIntegrationHistory'; +import './integrations/incoming/addIncomingIntegration'; +import './integrations/incoming/deleteIncomingIntegration'; +import './integrations/incoming/updateIncomingIntegration'; +import './integrations/outgoing/addOutgoingIntegration'; +import './integrations/outgoing/deleteOutgoingIntegration'; +import './integrations/outgoing/replayOutgoingIntegration'; +import './integrations/outgoing/updateOutgoingIntegration'; +import './media/deleteCustomSound'; +import './media/deleteEmojiCustom'; +import './media/getS3FileUrl'; +import './media/insertOrUpdateEmoji'; +import './media/insertOrUpdateSound'; +import './media/listCustomSounds'; +import './media/uploadCustomSound'; +import './media/uploadEmojiCustom'; +import './messages/createDirectMessage'; +import './messages/createDiscussion'; +import './messages/deleteFileMessage'; +import './messages/executeSlashCommandPreview'; +import './messages/followMessage'; +import './messages/getChannelHistory'; +import './messages/getMessages'; +import './messages/getSingleMessage'; +import './messages/getSlashCommandPreviews'; +import './messages/getUserMentionsByChannel'; +import './messages/getThreadMessages'; +import './messages/getThreadsList'; +import './messages/loadHistory'; +import './messages/loadMissedMessages'; +import './messages/loadNextMessages'; +import './messages/loadSurroundingMessages'; +import './messages/messageSearch'; +import './messages/readMessages'; +import './messages/readThreads'; +import './messages/sendFileMessage'; +import './messages/sendMessage'; +import './messages/unfollowMessage'; +import './messages/updateMessage'; +import './omnichannel/sendFileLivechatMessage'; +import './omnichannel/sendMessageLivechat'; +import './platform/OEmbedCacheCleanup'; +import './platform/banner_dismiss'; +import './platform/checkFederationConfiguration'; +import './platform/cloud'; +import './platform/fetchMyKeys'; +import './platform/getProviderUiMetadata'; +import './platform/getStatistics'; +import './platform/getSupportedLanguages'; +import './platform/getUsersOfRoomWithoutKey'; +import './platform/loadLocale'; +import './platform/push'; +import './platform/requestDataDownload'; +import './platform/requestSubscriptionKeys'; +import './platform/resetOwnE2EKey'; +import './platform/restartServer'; +import './platform/search'; +import './platform/saveSettings'; +import './platform/setRoomKeyID'; +import './platform/setUserPublicAndPrivateKeys'; +import './platform/translateMessage'; +import './platform/updateGroupKey'; +import './rooms/addAllUserToRoom'; +import './rooms/addRoomLeader'; +import './rooms/addRoomModerator'; +import './rooms/addRoomOwner'; +import './rooms/addUserToRoom'; +import './rooms/addUsersToRoom'; +import './rooms/archiveRoom'; +import './rooms/browseChannels'; +import './rooms/channelsList'; +import './rooms/cleanRoomHistory'; +import './rooms/createChannel'; +import './rooms/createPrivateGroup'; +import './rooms/getRoomById'; +import './rooms/getRoomIdByNameOrId'; +import './rooms/getRoomJoinCode'; +import './rooms/getRoomNameById'; +import './rooms/getRoomRoles'; +import './rooms/getTotalChannels'; +import './rooms/hideRoom'; +import './rooms/joinDefaultChannels'; +import './rooms/joinRoom'; +import './rooms/leaveRoom'; +import './rooms/muteUserInRoom'; +import './rooms/openRoom'; +import './rooms/removeRoomLeader'; +import './rooms/removeRoomModerator'; +import './rooms/removeRoomOwner'; +import './rooms/removeUserFromRoom'; +import './rooms/saveRoomSettings'; +import './rooms/toggleFavorite'; +import './rooms/unarchiveRoom'; +import './rooms/unmuteUserInRoom'; +import './settings/getSetupWizardParameters'; +import './settings/saveSetting'; +import './settings/saveSettings'; +import './settings/sendSMTPTestEmail'; +import './users/blockUser'; +import './users/deleteCustomUserStatus'; +import './users/deleteUser'; +import './users/deleteUserOwnAccount'; +import './users/getUsernameSuggestion'; +import './users/getUsersOfRoom'; +import './users/getUserStatusText'; +import './users/ignoreUser'; +import './users/insertOrUpdateUserStatus'; +import './users/listCustomUserStatus'; +import './users/registerUser'; +import './users/resetAvatar'; +import './users/saveNotificationSettings'; +import './users/saveUserPreferences'; +import './users/saveUserProfile'; +import './users/setAvatarFromService'; +import './users/setEmail'; +import './users/setRealName'; +import './users/setUserActiveStatus'; +import './users/setUserStatus'; +import './users/unblockUser'; +import './users/userPresence'; +import './users/userSetUtcOffset'; diff --git a/apps/meteor/server/meteor-methods/integrations/clearIntegrationHistory.ts b/apps/meteor/server/meteor-methods/integrations/clearIntegrationHistory.ts new file mode 100644 index 0000000..3c19390 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/clearIntegrationHistory.ts @@ -0,0 +1,23 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { clearIntegrationHistoryMethod } from '../../lib/integrations/functions/clearIntegrationHistory'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + clearIntegrationHistory(integrationId: string): Promise; + } +} + +Meteor.methods({ + async clearIntegrationHistory(integrationId) { + methodDeprecationLogger.method('clearIntegrationHistory', '9.0.0', '/v1/integrations.clearHistory'); + if (!this.userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' }); + } + await clearIntegrationHistoryMethod(this.userId, integrationId); + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/incoming/addIncomingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/incoming/addIncomingIntegration.ts new file mode 100644 index 0000000..a42a9d4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/incoming/addIncomingIntegration.ts @@ -0,0 +1,197 @@ +import type { INewIncomingIntegration, IIncomingIntegration } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { removeEmpty } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync, hasAllPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { compileIntegrationScript } from '../../../lib/integrations/lib/compileIntegrationScript'; +import { validateScriptEngine, isScriptEngineFrozen } from '../../../lib/integrations/lib/validateScriptEngine'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +const validChannelChars = ['@', '#']; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addIncomingIntegration(integration: INewIncomingIntegration): Promise; + } +} + +export const addIncomingIntegration = async (userId: string, integration: INewIncomingIntegration): Promise => { + check( + integration, + Match.ObjectIncluding({ + type: String, + name: String, + enabled: Boolean, + username: String, + channel: String, + alias: Match.Maybe(String), + emoji: Match.Maybe(String), + scriptEnabled: Boolean, + scriptEngine: Match.Maybe(String), + overrideDestinationChannelEnabled: Match.Maybe(Boolean), + script: Match.Maybe(String), + avatar: Match.Maybe(String), + }), + ); + + if ( + !userId || + (!(await hasPermissionAsync(userId, 'manage-incoming-integrations')) && + !(await hasPermissionAsync(userId, 'manage-own-incoming-integrations'))) + ) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'addIncomingIntegration', + }); + } + + if (!integration.channel || typeof integration.channel.valueOf() !== 'string') { + throw new Meteor.Error('error-invalid-channel', 'Invalid channel', { + method: 'addIncomingIntegration', + }); + } + + if (integration.channel.trim() === '') { + throw new Meteor.Error('error-invalid-channel', 'Invalid channel', { + method: 'addIncomingIntegration', + }); + } + + const channels = integration.channel.split(',').map((channel) => channel.trim()); + + for (const channel of channels) { + if (!validChannelChars.includes(channel[0])) { + throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', { + method: 'updateIncomingIntegration', + }); + } + } + + if (!integration.username || typeof integration.username.valueOf() !== 'string' || integration.username.trim() === '') { + throw new Meteor.Error('error-invalid-username', 'Invalid username', { + method: 'addIncomingIntegration', + }); + } + + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); + } + + const user = await Users.findOneByUsername(integration.username, { projection: { _id: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addIncomingIntegration', + }); + } + + if (!(await hasPermissionAsync(user._id, 'message-impersonate'))) { + throw new Meteor.Error( + 'error-user-lacks-message-impersonate-permission', + "User selected for the incoming integration lacks the 'message-impersonate' permission.", + { + method: 'addIncomingIntegration', + }, + ); + } + + // Default to transpiling with Babel for backwards compatibility; integrations + // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0). + const skipTranspile = integration.skipTranspile === true; + + const integrationData: IIncomingIntegration = { + ...integration, + scriptEngine: integration.scriptEngine ?? 'isolated-vm', + skipTranspile, + type: 'webhook-incoming', + channel: channels, + overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled ?? false, + token: Random.id(48), + userId: user._id, + _createdAt: new Date(), + _createdBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), + }; + + if ( + !isScriptEngineFrozen(integrationData.scriptEngine) && + integration.scriptEnabled === true && + integration.script && + integration.script.trim() !== '' + ) { + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + if (error) { + integrationData.scriptCompiled = undefined; + integrationData.scriptError = error; + } else { + integrationData.scriptCompiled = script; + delete integrationData.scriptError; + } + } + + for (let channel of channels) { + let record; + const channelType = channel[0]; + channel = channel.substr(1); + + switch (channelType) { + case '#': + record = await Rooms.findOne({ + $or: [{ _id: channel }, { name: channel }], + }); + break; + case '@': + record = await Users.findOne({ + $or: [{ _id: channel }, { username: channel }], + }); + break; + } + + if (!record) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addIncomingIntegration', + }); + } + + if ( + !(await hasAllPermissionAsync(userId, ['manage-incoming-integrations', 'manage-own-incoming-integrations'])) && + !(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } })) + ) { + throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', { + method: 'addIncomingIntegration', + }); + } + } + + const strippedIntegrationData = removeEmpty(integrationData); + + const { insertedId } = await Integrations.insertOne(strippedIntegrationData); + + const integrationStored = await Integrations.findOne({ _id: insertedId }); + + if (!integrationStored) { + throw new Error('Error inserting integration'); + } + void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted'); + + return integrationStored as IIncomingIntegration; +}; + +Meteor.methods({ + async addIncomingIntegration(integration: INewIncomingIntegration): Promise { + methodDeprecationLogger.method('addIncomingIntegration', '9.0.0', '/v1/integrations.create'); + const { userId } = this; + + if (!userId) { + throw new Meteor.Error('invalid-user', 'Invalid User', { + method: 'addIncomingIntegration', + }); + } + + return addIncomingIntegration(userId, integration); + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/incoming/deleteIncomingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/incoming/deleteIncomingIntegration.ts new file mode 100644 index 0000000..57435c6 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/incoming/deleteIncomingIntegration.ts @@ -0,0 +1,55 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteIncomingIntegration(integrationId: string): Promise; + } +} + +export const deleteIncomingIntegration = async (integrationId: string, userId: string): Promise => { + const canManageAllIntegrations = !!userId && (await hasPermissionAsync(userId, 'manage-incoming-integrations')); + const canManageOwnIntegrations = + !canManageAllIntegrations && !!userId && (await hasPermissionAsync(userId, 'manage-own-incoming-integrations')); + + if (!canManageAllIntegrations && !canManageOwnIntegrations) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'deleteIncomingIntegration', + }); + } + + const integration = await Integrations.removeByIdAndCreatedByIfExists({ + _id: integrationId, + ...(canManageOwnIntegrations && { createdBy: userId }), + }); + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { + method: 'deleteIncomingIntegration', + }); + } + + void notifyOnIntegrationChanged(integration, 'removed'); +}; + +Meteor.methods({ + async deleteIncomingIntegration(integrationId) { + methodDeprecationLogger.method('deleteIncomingIntegration', '9.0.0', '/v1/integrations.remove'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'deleteIncomingIntegration', + }); + } + + await deleteIncomingIntegration(integrationId, userId); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/incoming/updateIncomingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/incoming/updateIncomingIntegration.ts new file mode 100644 index 0000000..663a1c9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/incoming/updateIncomingIntegration.ts @@ -0,0 +1,211 @@ +import type { IIntegration, INewIncomingIntegration, IUpdateIncomingIntegration } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations, Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { hasAllPermissionAsync, hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { compileIntegrationScript } from '../../../lib/integrations/lib/compileIntegrationScript'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +const validChannelChars = ['@', '#']; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + updateIncomingIntegration( + integrationId: string, + integration: INewIncomingIntegration | IUpdateIncomingIntegration, + ): IIntegration | null; + } +} + +function validateChannels(channelString: string | undefined): string[] { + if (!channelString || typeof channelString.valueOf() !== 'string' || channelString.trim() === '') { + throw new Meteor.Error('error-invalid-channel', 'Invalid channel', { + method: 'updateIncomingIntegration', + }); + } + + const channels = channelString.split(',').map((channel) => channel.trim()); + + for (const channel of channels) { + if (!validChannelChars.includes(channel[0])) { + throw new Meteor.Error('error-invalid-channel-start-with-chars', 'Invalid channel. Start with @ or #', { + method: 'updateIncomingIntegration', + }); + } + } + + return channels; +} + +export const updateIncomingIntegration = async ( + userId: string, + integrationId: string, + integration: INewIncomingIntegration | IUpdateIncomingIntegration, +): Promise => { + const channels = validateChannels(integration.channel); + + let currentIntegration; + + if (await hasPermissionAsync(userId, 'manage-incoming-integrations')) { + currentIntegration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-incoming-integrations')) { + currentIntegration = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + } else { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'updateIncomingIntegration', + }); + } + + if (!currentIntegration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { + method: 'updateIncomingIntegration', + }); + } + + const oldScriptEngine = currentIntegration.scriptEngine; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine ?? 'isolated-vm'; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); + } + + const isFrozen = isScriptEngineFrozen(scriptEngine); + + // Default to transpiling with Babel for backwards compatibility; integrations + // can opt-out per-record by setting `skipTranspile: true` (removed in 9.0.0). + const skipTranspile = integration.skipTranspile === true; + + if (!isFrozen && integration.scriptEnabled === true && integration.script && integration.script.trim() !== '') { + const { script, error } = compileIntegrationScript(integration.script, { transpile: !skipTranspile }); + if (error) { + await Integrations.updateOne( + { _id: integrationId }, + { + $set: { scriptError: error, skipTranspile }, + $unset: { scriptCompiled: 1 as const }, + }, + ); + } else { + await Integrations.updateOne( + { _id: integrationId }, + { + $set: { scriptCompiled: script, skipTranspile }, + $unset: { scriptError: 1 as const }, + }, + ); + } + } + + for (let channel of channels) { + const channelType = channel[0]; + channel = channel.slice(1); + let record; + + switch (channelType) { + case '#': + record = await Rooms.findOne({ + $or: [{ _id: channel }, { name: channel }], + }); + break; + case '@': + record = await Users.findOne({ + $or: [{ _id: channel }, { username: channel }], + }); + break; + } + + if (!record) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'updateIncomingIntegration', + }); + } + + if ( + !(await hasAllPermissionAsync(userId, ['manage-incoming-integrations', 'manage-own-incoming-integrations'])) && + !(await Subscriptions.findOneByRoomIdAndUserId(record._id, userId, { projection: { _id: 1 } })) + ) { + throw new Meteor.Error('error-invalid-channel', 'Invalid Channel', { + method: 'updateIncomingIntegration', + }); + } + } + + const username = 'username' in integration ? integration.username : currentIntegration.username; + const user = await Users.findOneByUsername(username, { projection: { _id: 1, username: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-post-as-user', 'Invalid Post As User', { + method: 'updateIncomingIntegration', + }); + } + + if (!(await hasPermissionAsync(user._id, 'message-impersonate'))) { + throw new Meteor.Error( + 'error-user-lacks-message-impersonate-permission', + "User selected for the incoming integration lacks the 'message-impersonate' permission.", + { + method: 'updateIncomingIntegration', + }, + ); + } + + const updatedIntegration = await Integrations.findOneAndUpdate( + { _id: integrationId }, + { + $set: { + enabled: integration.enabled, + name: integration.name, + ...(typeof integration.avatar !== 'undefined' && { avatar: integration.avatar }), + ...(typeof integration.emoji !== 'undefined' && { emoji: integration.emoji }), + ...(typeof integration.alias !== 'undefined' && { alias: integration.alias }), + ...(channels && { channel: channels }), + ...('username' in integration && { username: user.username, userId: user._id }), + ...(isFrozen + ? {} + : { + ...(typeof integration.script !== 'undefined' && { script: integration.script }), + scriptEnabled: integration.scriptEnabled, + ...(scriptEngine && { scriptEngine }), + skipTranspile, + }), + ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { + overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, + }), + _updatedAt: new Date(), + _updatedBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), + }, + }, + { returnDocument: 'after' }, + ); + + if (updatedIntegration) { + void notifyOnIntegrationChanged(updatedIntegration); + } + + return updatedIntegration; +}; + +Meteor.methods({ + async updateIncomingIntegration(integrationId, integration) { + methodDeprecationLogger.method('updateIncomingIntegration', '9.0.0', '/v1/integrations.update'); + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'updateOutgoingIntegration', + }); + } + + return updateIncomingIntegration(this.userId, integrationId, integration); + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/outgoing/addOutgoingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/outgoing/addOutgoingIntegration.ts new file mode 100644 index 0000000..9c899cb --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/outgoing/addOutgoingIntegration.ts @@ -0,0 +1,87 @@ +import type { INewOutgoingIntegration, IOutgoingIntegration } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations } from '@rocket.chat/models'; +import { removeEmpty } from '@rocket.chat/tools'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { validateOutgoingIntegration } from '../../../lib/integrations/lib/validateOutgoingIntegration'; +import { validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addOutgoingIntegration(integration: INewOutgoingIntegration): Promise; + } +} + +export const addOutgoingIntegration = async (userId: string, integration: INewOutgoingIntegration): Promise => { + check( + integration, + Match.ObjectIncluding({ + type: String, + name: String, + enabled: Boolean, + username: String, + channel: String, + alias: Match.Maybe(String), + emoji: Match.Maybe(String), + scriptEnabled: Boolean, + script: Match.Maybe(String), + scriptEngine: Match.Maybe(String), + urls: Match.Maybe([String]), + event: Match.Maybe(String), + triggerWords: Match.Maybe([String]), + avatar: Match.Maybe(String), + token: Match.Maybe(String), + impersonateUser: Match.Maybe(Boolean), + retryCount: Match.Maybe(Number), + retryDelay: Match.Maybe(String), + retryFailedCalls: Match.Maybe(Boolean), + runOnEdits: Match.Maybe(Boolean), + targetRoom: Match.Maybe(String), + triggerWordAnywhere: Match.Maybe(Boolean), + }), + ); + + if ( + !userId || + (!(await hasPermissionAsync(userId, 'manage-outgoing-integrations')) && + !(await hasPermissionAsync(userId, 'manage-own-outgoing-integrations'))) + ) { + throw new Meteor.Error('not_authorized'); + } + + if (integration.script?.trim()) { + validateScriptEngine(integration.scriptEngine ?? 'isolated-vm'); + } + + const integrationData = await validateOutgoingIntegration(integration, userId); + + const { insertedId } = await Integrations.insertOne(removeEmpty(integrationData)); + + const integrationStored = await Integrations.findOne({ _id: insertedId }); + + if (!integrationStored) { + throw new Error('Error inserting integration'); + } + + void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted'); + + return integrationStored as IOutgoingIntegration; +}; + +Meteor.methods({ + async addOutgoingIntegration(integration: INewOutgoingIntegration): Promise { + methodDeprecationLogger.method('addOutgoingIntegration', '9.0.0', '/v1/integrations.create'); + const { userId } = this; + if (!userId) { + throw new Meteor.Error('Invalid User'); + } + + return addOutgoingIntegration(userId, integration); + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/outgoing/deleteOutgoingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/outgoing/deleteOutgoingIntegration.ts new file mode 100644 index 0000000..ff04326 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/outgoing/deleteOutgoingIntegration.ts @@ -0,0 +1,62 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations, IntegrationHistory } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteOutgoingIntegration(integrationId: string): Promise; + } +} + +export const deleteOutgoingIntegration = async (integrationId: string, userId: string): Promise => { + if (!userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'deleteOutgoingIntegration', + }); + } + + const canManageAllIntegrations = await hasPermissionAsync(userId, 'manage-outgoing-integrations'); + const canManageOwnIntegrations = !canManageAllIntegrations && (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')); + + if (!canManageAllIntegrations && !canManageOwnIntegrations) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'deleteOutgoingIntegration', + }); + } + + const integration = await Integrations.removeByIdAndCreatedByIfExists({ + _id: integrationId, + ...(canManageOwnIntegrations && { createdBy: userId }), + }); + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { + method: 'deleteOutgoingIntegration', + }); + } + + // Don't sending to IntegrationHistory listener since it don't waits for 'removed' events. + await IntegrationHistory.removeByIntegrationId(integrationId); + void notifyOnIntegrationChanged(integration, 'removed'); +}; + +Meteor.methods({ + async deleteOutgoingIntegration(integrationId) { + methodDeprecationLogger.method('deleteOutgoingIntegration', '9.0.0', '/v1/integrations.remove'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'deleteOutgoingIntegration', + }); + } + + await deleteOutgoingIntegration(integrationId, userId); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/outgoing/replayOutgoingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/outgoing/replayOutgoingIntegration.ts new file mode 100644 index 0000000..589a6e5 --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/outgoing/replayOutgoingIntegration.ts @@ -0,0 +1,23 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { replayOutgoingIntegrationMethod } from '../../../lib/integrations/functions/clearIntegrationHistory'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + replayOutgoingIntegration(params: { integrationId: string; historyId: string }): Promise; + } +} + +Meteor.methods({ + async replayOutgoingIntegration({ integrationId, historyId }) { + methodDeprecationLogger.method('replayOutgoingIntegration', '9.0.0', '/v1/integrations.replayOutgoing'); + if (!this.userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'replayOutgoingIntegration' }); + } + await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId }); + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/integrations/outgoing/updateOutgoingIntegration.ts b/apps/meteor/server/meteor-methods/integrations/outgoing/updateOutgoingIntegration.ts new file mode 100644 index 0000000..7fb5ddf --- /dev/null +++ b/apps/meteor/server/meteor-methods/integrations/outgoing/updateOutgoingIntegration.ts @@ -0,0 +1,132 @@ +import type { IIntegration, INewOutgoingIntegration, IUpdateOutgoingIntegration } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Integrations, Users } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/deprecationWarningLogger'; +import { validateOutgoingIntegration } from '../../../lib/integrations/lib/validateOutgoingIntegration'; +import { isScriptEngineFrozen, validateScriptEngine } from '../../../lib/integrations/lib/validateScriptEngine'; +import { notifyOnIntegrationChanged } from '../../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + updateOutgoingIntegration( + integrationId: string, + integration: INewOutgoingIntegration | IUpdateOutgoingIntegration, + ): IIntegration | null; + } +} + +export const updateOutgoingIntegration = async ( + userId: string, + integrationId: string, + _integration: INewOutgoingIntegration | IUpdateOutgoingIntegration, +): Promise => { + const integration = await validateOutgoingIntegration(_integration, userId); + + if (!integration.token || integration.token.trim() === '') { + throw new Meteor.Error('error-invalid-token', 'Invalid token', { + method: 'updateOutgoingIntegration', + }); + } + + let currentIntegration: IIntegration | null; + + if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) { + currentIntegration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) { + currentIntegration = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + } else { + throw new Meteor.Error('not_authorized', 'Unauthorized', { + method: 'updateOutgoingIntegration', + }); + } + + if (!currentIntegration) { + throw new Meteor.Error('invalid_integration', '[methods] updateOutgoingIntegration -> integration not found'); + } + + const oldScriptEngine = currentIntegration.scriptEngine; + const scriptEngine = integration.scriptEngine ?? oldScriptEngine ?? 'isolated-vm'; + if ( + integration.script?.trim() && + (scriptEngine !== oldScriptEngine || integration.script?.trim() !== currentIntegration.script?.trim()) + ) { + wrapExceptions(() => validateScriptEngine(scriptEngine)).catch((e) => { + throw new Meteor.Error(e.message); + }); + } + + const isFrozen = isScriptEngineFrozen(scriptEngine); + + const updatedIntegration = await Integrations.findOneAndUpdate( + { _id: integrationId }, + { + $set: { + event: integration.event, + enabled: integration.enabled, + name: integration.name, + avatar: integration.avatar, + emoji: integration.emoji, + alias: integration.alias, + channel: typeof integration.channel === 'string' ? [integration.channel] : integration.channel, + targetRoom: integration.targetRoom, + impersonateUser: integration.impersonateUser, + username: integration.username, + userId: integration.userId, + urls: integration.urls, + token: integration.token, + ...(isFrozen + ? {} + : { + script: integration.script, + scriptEnabled: integration.scriptEnabled, + scriptEngine, + skipTranspile: integration.skipTranspile, + ...(integration.scriptCompiled ? { scriptCompiled: integration.scriptCompiled } : { scriptError: integration.scriptError }), + }), + triggerWords: integration.triggerWords, + retryFailedCalls: integration.retryFailedCalls, + retryCount: integration.retryCount, + retryDelay: integration.retryDelay, + triggerWordAnywhere: integration.triggerWordAnywhere, + runOnEdits: integration.runOnEdits, + _updatedAt: new Date(), + _updatedBy: await Users.findOne({ _id: userId }, { projection: { username: 1 } }), + }, + ...(isFrozen + ? {} + : { + $unset: { + ...(integration.scriptCompiled ? { scriptError: 1 as const } : { scriptCompiled: 1 as const }), + }, + }), + }, + { returnDocument: 'after' }, + ); + + if (updatedIntegration) { + await notifyOnIntegrationChanged(updatedIntegration); + } + + return updatedIntegration; +}; + +Meteor.methods({ + async updateOutgoingIntegration(integrationId, _integration) { + methodDeprecationLogger.method('updateOutgoingIntegration', '9.0.0', '/v1/integrations.update'); + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'updateOutgoingIntegration', + }); + } + + return updateOutgoingIntegration(this.userId, integrationId, _integration); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/deleteCustomSound.ts b/apps/meteor/server/meteor-methods/media/deleteCustomSound.ts new file mode 100644 index 0000000..065b2b0 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/deleteCustomSound.ts @@ -0,0 +1,27 @@ +import type { ICustomSound } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { deleteCustomSound } from '../../lib/media/custom-sounds/lib/deleteCustomSound'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteCustomSound(_id: ICustomSound['_id']): Promise; + } +} + +Meteor.methods({ + async deleteCustomSound(_id) { + methodDeprecationLogger.method('deleteCustomSound', '9.0.0', '/v1/custom-sounds.delete'); + if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { + throw new Meteor.Error('not_authorized'); + } + check(_id, String); + await deleteCustomSound(_id); + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/deleteEmojiCustom.ts b/apps/meteor/server/meteor-methods/media/deleteEmojiCustom.ts new file mode 100644 index 0000000..0bb4372 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/deleteEmojiCustom.ts @@ -0,0 +1,46 @@ +import { api } from '@rocket.chat/core-services'; +import type { ICustomEmojiDescriptor } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { EmojiCustom } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { RocketChatFileEmojiCustomInstance } from '../../lib/media/emoji-custom/startup/emoji-custom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteEmojiCustom(emojiID: ICustomEmojiDescriptor['_id']): boolean; + } +} + +export const deleteEmojiCustom = async (userId: string, emojiID: ICustomEmojiDescriptor['_id']): Promise => { + if (!(await hasPermissionAsync(userId, 'manage-emoji'))) { + throw new Meteor.Error('not_authorized'); + } + + const emoji = await EmojiCustom.findOneById(emojiID); + if (emoji == null) { + throw new Meteor.Error('Custom_Emoji_Error_Invalid_Emoji', 'Invalid emoji', { + method: 'deleteEmojiCustom', + }); + } + + await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emoji.name}.${emoji.extension}`)); + await EmojiCustom.removeById(emojiID); + void api.broadcast('emoji.deleteCustom', emoji); + + return true; +}; + +Meteor.methods({ + async deleteEmojiCustom(emojiID) { + methodDeprecationLogger.method('deleteEmojiCustom', '9.0.0', '/v1/emoji-custom.delete'); + if (!this.userId) { + throw new Meteor.Error('not_authorized'); + } + + return deleteEmojiCustom(this.userId, emojiID); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/getS3FileUrl.ts b/apps/meteor/server/meteor-methods/media/getS3FileUrl.ts new file mode 100644 index 0000000..1431719 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/getS3FileUrl.ts @@ -0,0 +1,35 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Uploads } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { settings } from '../../settings'; +import { UploadFS } from '../../ufs'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getS3FileUrl(fileId: string): string; + } +} + +Meteor.methods({ + async getS3FileUrl(fileId) { + check(fileId, String); + const uid = Meteor.userId(); + if (settings.get('FileUpload_ProtectFiles') && !uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' }); + } + const file = await Uploads.findOneById(fileId); + if (!file?.rid) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + const room = await Rooms.findOneById(file.rid); + if (uid && room && !(await canAccessRoomAsync(room, { _id: uid }))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + return UploadFS.getStore('AmazonS3:Uploads').getRedirectURL(file); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/insertOrUpdateEmoji.ts b/apps/meteor/server/meteor-methods/media/insertOrUpdateEmoji.ts new file mode 100644 index 0000000..fd8cdc0 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/insertOrUpdateEmoji.ts @@ -0,0 +1,33 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { insertOrUpdateEmoji } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + insertOrUpdateEmoji(emojiData: { + _id?: string; + name: string; + aliases: string; + extension: string; + previousName?: string; + previousExtension?: string; + newFile?: boolean; + }): string | boolean; + } +} + +Meteor.methods({ + async insertOrUpdateEmoji(emojiData) { + methodDeprecationLogger.method('insertOrUpdateEmoji', '9.0.0', ['/v1/emoji-custom.create', '/v1/emoji-custom.update']); + const emoji = await insertOrUpdateEmoji(this.userId, emojiData); + + if (!emojiData._id) { + return emoji._id; + } + + return !!emoji; + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/insertOrUpdateSound.ts b/apps/meteor/server/meteor-methods/media/insertOrUpdateSound.ts new file mode 100644 index 0000000..31525b2 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/insertOrUpdateSound.ts @@ -0,0 +1,40 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { insertOrUpdateSound } from '../../lib/media/custom-sounds/lib/insertOrUpdateSound'; + +export type ICustomSoundData = { + _id?: string; + name: string; + extension: string; + previousName?: string; + previousSound?: { + extension?: string; + }; + previousExtension?: string; + newFile?: boolean; + random?: number; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + insertOrUpdateSound(soundData: ICustomSoundData): Promise; + } +} + +Meteor.methods({ + async insertOrUpdateSound(soundData) { + methodDeprecationLogger.method('insertOrUpdateSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']); + if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { + throw new Meteor.Error('not_authorized'); + } + if (soundData._id) { + check(soundData._id, String); + } + return insertOrUpdateSound(soundData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/listCustomSounds.ts b/apps/meteor/server/meteor-methods/media/listCustomSounds.ts new file mode 100644 index 0000000..14f4b81 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/listCustomSounds.ts @@ -0,0 +1,20 @@ +import type { ICustomSound } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + listCustomSounds(): ICustomSound[]; + } +} + +Meteor.methods({ + async listCustomSounds() { + methodDeprecationLogger.method('listCustomSounds', '9.0.0', '/v1/custom-sounds.list'); + return CustomSounds.find({}).toArray(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/uploadCustomSound.ts b/apps/meteor/server/meteor-methods/media/uploadCustomSound.ts new file mode 100644 index 0000000..ee00274 --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/uploadCustomSound.ts @@ -0,0 +1,26 @@ +import type { RequiredField } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import type { ICustomSoundData } from './insertOrUpdateSound'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { uploadCustomSound } from '../../lib/media/custom-sounds/lib/uploadCustomSound'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + uploadCustomSound(binaryContent: string, contentType: string, soundData: RequiredField): void; + } +} + +Meteor.methods({ + async uploadCustomSound(binaryContent, contentType, soundData) { + methodDeprecationLogger.method('uploadCustomSound', '9.0.0', ['/v1/custom-sounds.create', '/v1/custom-sounds.update']); + if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { + throw new Meteor.Error('not_authorized'); + } + const buffer = Buffer.from(binaryContent, 'binary'); + await uploadCustomSound(buffer, contentType, soundData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/media/uploadEmojiCustom.ts b/apps/meteor/server/meteor-methods/media/uploadEmojiCustom.ts new file mode 100644 index 0000000..231cdbb --- /dev/null +++ b/apps/meteor/server/meteor-methods/media/uploadEmojiCustom.ts @@ -0,0 +1,27 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { uploadEmojiCustom } from '../../lib/media/emoji-custom/lib/uploadEmojiCustom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + uploadEmojiCustom( + binaryContent: string, + contentType: string, + emojiData: { + name: string; + aliases?: string; + extension: string; + }, + ): void; + } +} + +Meteor.methods({ + async uploadEmojiCustom(binaryContent, contentType, emojiData) { + methodDeprecationLogger.method('uploadEmojiCustom', '9.0.0', '/v1/emoji-custom.create'); + await uploadEmojiCustom(this.userId, binaryContent, contentType, emojiData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/createDirectMessage.ts b/apps/meteor/server/meteor-methods/messages/createDirectMessage.ts new file mode 100644 index 0000000..d4f9c61 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/createDirectMessage.ts @@ -0,0 +1,117 @@ +import type { ICreateRoomParams } from '@rocket.chat/core-services'; +import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Users } from '@rocket.chat/models'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { createRoom } from '../../lib/rooms/createRoom'; +import { settings } from '../../settings'; + +export async function createDirectMessage( + usernames: IUser['username'][], + userId: IUser['_id'] | null, + excludeSelf = false, +): Promise> { + check(usernames, [String]); + check(userId, String); + check(excludeSelf, Match.Optional(Boolean)); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createDirectMessage', + }); + } + + const me = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!me?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createDirectMessage', + }); + } + + if (settings.get('Message_AllowDirectMessagesToYourself') === false && usernames.length === 1 && me.username === usernames[0]) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createDirectMessage', + }); + } + + const users = await Promise.all(usernames.filter((username) => username !== me.username)); + const options: Exclude = { creator: me._id }; + const roomUsers = excludeSelf ? users : [me, ...users]; + + // allow self-DMs + if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createDirectMessage', + }); + } + + if (!(await hasPermissionAsync(userId, 'create-d'))) { + // If the user can't create DMs but can access already existing ones + if ((await hasPermissionAsync(userId, 'view-d-room')) && !Object.keys(roomUsers).some((user) => typeof user === 'string')) { + // Check if the direct room already exists, then return it + const uids = (roomUsers as IUser[]).map(({ _id }) => _id).sort(); + // No projection: the full room is spread into the ICreatedRoom-shaped return below. + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uids); + if (room) { + return { + ...room, + t: 'd', + rid: room._id, + }; + } + } + + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'createDirectMessage', + }); + } + + if (excludeSelf && (await hasPermissionAsync(userId, 'view-room-administration'))) { + options.subscriptionExtra = { open: true }; + } + + const extraData = {}; + + try { + await callbacks.run('federation.beforeCreateDirectMessage', roomUsers, extraData); + } catch (error) { + throw new Meteor.Error((error as any)?.message); + } + const { + _id: rid, + inserted, + ...room + } = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, extraData, options); + + return { + // @ts-expect-error - room type is already defined in the `createRoom` return type + t: 'd', + // @ts-expect-error - room id is not defined in the `createRoom` return type + rid, + ...room, + }; +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + createDirectMessage(...usernames: Exclude[]): Omit; + } +} + +Meteor.methods({ + async createDirectMessage(...usernames) { + return createDirectMessage(usernames, Meteor.userId()); + }, +}); + +RateLimiter.limitMethod('createDirectMessage', 10, 60000, { + async userId(userId: IUser['_id']) { + return !(await hasPermissionAsync(userId, 'send-many-messages')); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/createDiscussion.ts b/apps/meteor/server/meteor-methods/messages/createDiscussion.ts new file mode 100644 index 0000000..304ef3e --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/createDiscussion.ts @@ -0,0 +1,277 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IMessage, IRoom, IUser, MessageAttachmentDefault } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { afterSaveMessageAsync } from '../../hooks/messages/afterSaveMessage'; +import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; +import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { i18n } from '../../lib/i18n'; +import { attachMessage } from '../../lib/messages/attachMessage'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; +import { createRoom } from '../../lib/rooms/createRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { settings } from '../../settings'; + +const getParentRoom = async (rid: IRoom['_id']) => { + const room = await Rooms.findOne(rid); + return room && (room.prid ? Rooms.findOne(room.prid, { projection: { _id: 1 } }) : room); +}; + +async function createDiscussionMessage( + rid: IRoom['_id'], + user: IUser, + drid: IRoom['_id'], + msg: IMessage['msg'], + messageEmbedded?: MessageAttachmentDefault, +): Promise { + return Message.saveSystemMessage('discussion-created', rid, msg, user, { + drid, + ...(messageEmbedded && { attachments: [messageEmbedded] }), + }); +} + +async function mentionMessage( + rid: IRoom['_id'], + { _id, username, name }: Pick, + messageEmbedded?: MessageAttachmentDefault, +) { + if (!username) { + return null; + } + await Messages.insertOne({ + rid, + msg: '', + u: { _id, username, name }, + ts: new Date(), + _updatedAt: new Date(), + ...(messageEmbedded && { attachments: [messageEmbedded] }), + }); +} + +type CreateDiscussionProperties = { + prid: IRoom['_id']; + pmid?: IMessage['_id']; + t_name: string; + reply?: string; + users: Array>; + user: IUser; + encrypted?: boolean; + topic?: string; +}; + +const create = async ({ + prid, + pmid, + t_name: discussionName, + reply, + users, + user, + encrypted, + topic, +}: CreateDiscussionProperties): Promise => { + // if you set both, prid and pmid, and the rooms dont match... should throw an error) + let message: null | IMessage = null; + if (pmid) { + message = await Messages.findOneById(pmid); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { + method: 'DiscussionCreation', + }); + } + if (prid) { + const parentRoom = await getParentRoom(message.rid); + if (prid !== parentRoom?._id) { + throw new Meteor.Error('error-invalid-arguments', 'Root message room ID does not match parent room ID ', { + method: 'DiscussionCreation', + }); + } + } else { + prid = message.rid; + } + } + + if (!prid) { + throw new Meteor.Error('error-invalid-arguments', 'Missing parent room ID', { method: 'DiscussionCreation' }); + } + + let parentRoom; + try { + parentRoom = await canSendMessageAsync(prid, { uid: user._id, username: user.username, type: user.type }); + } catch (error) { + throw new Meteor.Error((error as Error).message); + } + + if (parentRoom.prid) { + throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', { + method: 'DiscussionCreation', + }); + } + + if (typeof encrypted !== 'boolean') { + encrypted = Boolean(parentRoom.encrypted); + } + + if (encrypted && reply) { + throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', { + method: 'DiscussionCreation', + }); + } + + if (pmid) { + // No projection: the full room is spread into the returned discussion below. + const discussionAlreadyExists = await Rooms.findOne({ + prid, + pmid, + }); + if (discussionAlreadyExists) { + // do not allow multiple discussions to the same message'\ + await addUserToRoom(discussionAlreadyExists._id, user); + return { ...discussionAlreadyExists, rid: discussionAlreadyExists._id }; + } + } + + const name = Random.id(); + + // auto invite the replied message owner + const invitedUsers = message ? [message.u.username, ...users] : users; + + const type = await roomCoordinator.getRoomDirectives(parentRoom.t).getDiscussionType(parentRoom); + const description = parentRoom.encrypted ? '' : message?.msg; + const discussionTopic = topic || parentRoom.name; + + if (!type) { + throw new Meteor.Error('error-invalid-type', 'Cannot define discussion room type', { + method: 'DiscussionCreation', + }); + } + + if ( + type === 'p' && + !encrypted && + settings.get('E2E_Enable') && + settings.get('E2E_Force_Encryption_For_Private_Rooms') + ) { + throw new Meteor.Error( + 'error-encrypted-private-rooms-enforced-discussion', + 'Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.', + { method: 'DiscussionCreation' }, + ); + } + + const discussion = await createRoom( + type, + name, + user, + [...new Set(invitedUsers)].filter(Boolean), + false, + false, + { + fname: discussionName, + description, // TODO discussions remove + topic: discussionTopic, + prid, + encrypted, + }, + { + creator: user._id, + }, + ); + + let discussionMsg; + if (message) { + if (parentRoom.encrypted) { + message.msg = i18n.t('Encrypted_message'); + } + await mentionMessage(discussion._id, user, attachMessage(message, parentRoom)); + + discussionMsg = await createDiscussionMessage(message.rid, user, discussion._id, discussionName, attachMessage(message, parentRoom)); + } else { + discussionMsg = await createDiscussionMessage(prid, user, discussion._id, discussionName); + } + + if (reply) { + await sendMessage(user, { msg: reply }, discussion); + } + + if (discussionMsg) { + afterSaveMessageAsync(discussionMsg, parentRoom, user); + } + + return discussion; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + createDiscussion: typeof create; + } +} + +export const createDiscussion = async ( + userId: string, + { prid, pmid, t_name: discussionName, reply, users, encrypted, topic }: Omit, +): Promise< + IRoom & { + rid: string; + } +> => { + if (!settings.get('Discussion_enabled')) { + throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); + } + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'DiscussionCreation', + }); + } + + if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) { + throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); + } + const user = await Users.findOneById(userId, { projection: { services: 0 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createDiscussion', + }); + } + + return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic }); +}; + +Meteor.methods({ + /** + * Create discussion by room or message + * @constructor + * @param {string} prid - Parent Room Id - The room id, optional if you send pmid. + * @param {string} pmid - Parent Message Id - Create the discussion by a message, optional. + * @param {string} reply - The reply, optional + * @param {string} t_name - discussion name + * @param {string[]} users - users to be added + * @param {boolean} encrypted - if the discussion's e2e encryption should be enabled. + */ + async createDiscussion({ prid, pmid, t_name: discussionName, reply, users, encrypted }: CreateDiscussionProperties) { + methodDeprecationLogger.method('createDiscussion', '9.0.0', '/v1/rooms.createDiscussion'); + check(prid, Match.Maybe(String)); + check(pmid, Match.Maybe(String)); + check(reply, Match.Maybe(String)); + check(discussionName, String); + check(users, [String]); + check(encrypted, Match.Maybe(Boolean)); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'DiscussionCreation', + }); + } + + return createDiscussion(uid, { prid, pmid, t_name: discussionName, reply, users, encrypted }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/deleteFileMessage.ts b/apps/meteor/server/meteor-methods/messages/deleteFileMessage.ts new file mode 100644 index 0000000..75a04e5 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/deleteFileMessage.ts @@ -0,0 +1,58 @@ +import { Upload } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Users, Uploads } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { DeleteResult } from 'mongodb'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { FileUpload } from '../../lib/media/file-upload'; +import { deleteMessageValidatingPermission } from '../../lib/messages/deleteMessage'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteFileMessage(fileID: string): Promise; + } +} + +Meteor.methods({ + async deleteFileMessage(fileID) { + methodDeprecationLogger.method('deleteFileMessage', '9.0.0', '/v1/chat.delete'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } + check(fileID, String); + + const msg = await Messages.getMessageByFileId(fileID); + + if (msg) { + return deleteMessageValidatingPermission(msg, userId); + } + + const user = await Users.findOneById(userId, { projection: { username: 1 } }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } + + const file = await Uploads.findOneById(fileID, { projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 } }); + if (!file) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'deleteFileMessage', + }); + } + + if (!(await Upload.canDeleteFile(user, file, null))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'deleteFileMessage', + }); + } + + return FileUpload.getStore('Uploads').deleteById(fileID); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/executeSlashCommandPreview.ts b/apps/meteor/server/meteor-methods/messages/executeSlashCommandPreview.ts new file mode 100644 index 0000000..e40bca4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/executeSlashCommandPreview.ts @@ -0,0 +1,65 @@ +import type { IMessage, RequiredField, SlashCommandPreviewItem } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { slashCommands } from '../../lib/utils/slashCommand'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + executeSlashCommandPreview( + command: { + cmd: string; + params: string; + msg: RequiredField, 'rid'>; + triggerId?: string; + }, + preview: SlashCommandPreviewItem, + ): void; + } +} + +export const executeSlashCommandPreview = async ( + command: { + cmd: string; + params: string; + msg: RequiredField, 'rid'>; + triggerId?: string; + }, + preview: SlashCommandPreviewItem, + userId: string, +): Promise => { + if (!command?.cmd || !slashCommands.commands[command.cmd]) { + throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', { + method: 'executeSlashCommandPreview', + }); + } + + const theCmd = slashCommands.commands[command.cmd]; + if (!theCmd.providesPreview) { + throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', { + method: 'executeSlashCommandPreview', + }); + } + + if (!preview) { + throw new Meteor.Error('error-invalid-command-preview', 'Invalid Preview Provided', { + method: 'executeSlashCommandPreview', + }); + } + + return slashCommands.executePreview(command.cmd, command.params, command.msg, preview, userId, command.triggerId); +}; + +Meteor.methods({ + executeSlashCommandPreview(command, preview) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getSlashCommandPreview', + }); + } + + return executeSlashCommandPreview(command, preview, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/followMessage.ts b/apps/meteor/server/meteor-methods/messages/followMessage.ts new file mode 100644 index 0000000..a4c95ae --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/followMessage.ts @@ -0,0 +1,70 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { follow } from '../../lib/messaging/threads/functions'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + followMessage(message: { mid: IMessage['_id'] }): false | undefined; + } +} + +export const followMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise => { + if (mid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + } + + const message = await Messages.findOneById(mid); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { + method: 'followMessage', + }); + } + + if (!(await canAccessRoomIdAsync(message.rid, user._id))) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + } + + const id = message.tmid || message._id; + + const followResult = await follow({ tmid: id, uid: user._id }); + + void notifyOnMessageChange({ + id, + }); + + const isFollowed = true; + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed); + + return followResult; +}; + +Meteor.methods({ + async followMessage({ mid }) { + methodDeprecationLogger.method('followMessage', '9.0.0', '/v1/chat.followMessage'); + check(mid, String); + + const user = (await Meteor.userAsync()) as IUser; + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'followMessage' }); + } + + return followMessage(user, { mid }); + }, +}); + +RateLimiter.limitMethod('followMessage', 5, 5000, { + userId() { + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getChannelHistory.ts b/apps/meteor/server/meteor-methods/messages/getChannelHistory.ts new file mode 100644 index 0000000..71acf04 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getChannelHistory.ts @@ -0,0 +1,173 @@ +import { Authorization } from '@rocket.chat/core-services'; +import type { IMessage, MessageTypesValues } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getHiddenSystemMessages } from '../../lib/messaging/getHiddenSystemMessages'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { settings } from '../../settings/cached'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getChannelHistory(params: { + rid: string; + latest?: Date; + oldest?: Date; + inclusive?: boolean; + offset?: number; + count?: number; + unreads?: boolean; + showThreadMessages?: boolean; + }): boolean | IMessage[] | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number }; + } +} + +export const getChannelHistory = async ({ + rid, + fromUserId, + latest, + oldest, + inclusive, + offset = 0, + count = 20, + unreads, + showThreadMessages = true, +}: { + rid: string; + fromUserId: string; + latest?: Date; + oldest?: Date; + inclusive?: boolean; + offset?: number; + count?: number; + unreads?: boolean; + showThreadMessages?: boolean; +}): Promise => { + check(rid, String); + + if (!fromUserId) { + return false; + } + + const room = await Rooms.findOneById(rid); + if (!room) { + return false; + } + + // Make sure they can access the room + if (!(await Authorization.canReadRoom(room, { _id: fromUserId }))) { + return false; + } + + // Ensure latest is always defined. + if (latest === undefined) { + latest = new Date(); + } + + // Verify oldest is a date if it exists + + if (oldest !== undefined && {}.toString.call(oldest) !== '[object Date]') { + throw new Meteor.Error('error-invalid-date', 'Invalid date', { method: 'getChannelHistory' }); + } + + const hiddenSystemMessages = settings.get('Hide_System_Messages'); + + const hiddenMessageTypes = getHiddenSystemMessages(room, hiddenSystemMessages); + + const options: Record = { + sort: { + ts: -1, + }, + skip: offset, + limit: count, + }; + + const records = + oldest === undefined + ? await Messages.findVisibleByRoomIdBeforeTimestampNotContainingTypes( + rid, + latest, + hiddenMessageTypes, + options, + showThreadMessages, + inclusive, + ).toArray() + : await Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + latest, + hiddenMessageTypes, + options, + showThreadMessages, + inclusive, + ).toArray(); + + const messages = await normalizeMessagesForUser(records, fromUserId); + + if (unreads) { + let unreadNotLoaded = 0; + let firstUnread = undefined; + + if (oldest !== undefined) { + const firstMsg = messages[messages.length - 1]; + if (firstMsg !== undefined && firstMsg.ts > oldest) { + const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + firstMsg.ts, + hiddenMessageTypes, + { + limit: 1, + sort: { + ts: 1, + }, + }, + showThreadMessages, + ); + + const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( + rid, + oldest, + firstMsg.ts, + hiddenMessageTypes, + showThreadMessages, + ); + + firstUnread = (await unreadMessages.toArray())[0]; + unreadNotLoaded = totalCursor; + } + } + + return { + messages: messages || [], + firstUnread, + unreadNotLoaded, + }; + } + + return { + messages: messages || [], + }; +}; + +Meteor.methods({ + async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) { + methodDeprecationLogger.method('getChannelHistory', '9.0.0', '/v1/channels.history'); + check(rid, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getChannelHistory' }); + } + + const fromUserId = Meteor.userId(); + if (!fromUserId) { + return false; + } + + return getChannelHistory({ rid, fromUserId, latest, oldest, inclusive, offset, count, unreads, showThreadMessages }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getMessages.ts b/apps/meteor/server/meteor-methods/messages/getMessages.ts new file mode 100644 index 0000000..d249ebb --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getMessages.ts @@ -0,0 +1,45 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdsAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getMessages(messages: IMessage['_id'][]): Promise; + } +} + +Meteor.methods({ + async getMessages(messages) { + methodDeprecationLogger.method('getMessages', '9.0.0', '/v1/chat.getMessages'); + check(messages, [String]); + + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getMessages' }); + } + + const msgs = await Messages.findVisibleByIds(messages).toArray(); + + if (!msgs.length) { + return msgs; + } + + if ( + !(await canAccessRoomIdsAsync( + msgs.map((m) => m.rid), + user, + )) + ) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getMessages' }); + } + + return msgs; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getSingleMessage.ts b/apps/meteor/server/meteor-methods/messages/getSingleMessage.ts new file mode 100644 index 0000000..94e4e8e --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getSingleMessage.ts @@ -0,0 +1,42 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getSingleMessage(mid: IMessage['_id']): Promise; + } +} + +export const getSingleMessage = async (userId: string, mid: IMessage['_id']): Promise => { + const msg = await Messages.findOneById(mid); + + if (!msg?.rid) { + return null; + } + + if (!(await canAccessRoomIdAsync(msg.rid, userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' }); + } + + return msg; +}; + +Meteor.methods({ + async getSingleMessage(mid) { + check(mid, String); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getSingleMessage' }); + } + + return getSingleMessage(uid, mid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getSlashCommandPreviews.ts b/apps/meteor/server/meteor-methods/messages/getSlashCommandPreviews.ts new file mode 100644 index 0000000..73f78a0 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getSlashCommandPreviews.ts @@ -0,0 +1,51 @@ +import type { IMessage, RequiredField, SlashCommandPreviews } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { slashCommands } from '../../lib/utils/slashCommand'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getSlashCommandPreviews(command: { + cmd: string; + params: string; + msg: RequiredField, 'rid'>; + }): SlashCommandPreviews | undefined; + } +} + +export const getSlashCommandPreviews = async (command: { + cmd: string; + params: string; + msg: RequiredField, 'rid'>; + userId: string; +}): Promise => { + if (!command?.cmd || !slashCommands.commands[command.cmd]) { + throw new Meteor.Error('error-invalid-command', 'Invalid Command Provided', { + method: 'executeSlashCommandPreview', + }); + } + + const theCmd = slashCommands.commands[command.cmd]; + if (!theCmd.providesPreview) { + throw new Meteor.Error('error-invalid-command', 'Command Does Not Provide Previews', { + method: 'executeSlashCommandPreview', + }); + } + + return slashCommands.getPreviews(command.cmd, command.params, command.msg, command.userId); +}; + +Meteor.methods({ + async getSlashCommandPreviews(command) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getSlashCommandPreview', + }); + } + + return getSlashCommandPreviews({ ...command, userId }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts b/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts new file mode 100644 index 0000000..46977fb --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts @@ -0,0 +1,68 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { callbacks } from '../../lib/callbacks'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { readThread } from '../../lib/messaging/threads/functions'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getThreadMessages(params: { tmid: IMessage['_id']; limit?: number; skip?: number }): Promise; + } +} + +const MAX_LIMIT = 100; + +Meteor.methods({ + async getThreadMessages({ tmid, limit, skip }) { + methodDeprecationLogger.method('getThreadMessages', '9.0.0', '/v1/chat.getThreadMessages'); + + if ((limit ?? 0) > MAX_LIMIT) { + throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, { + method: 'getThreadMessages', + }); + } + + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { + method: 'getThreadMessages', + }); + } + + if (typeof tmid !== 'string') { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'getThreadMessages' }); + } + + const thread = await Messages.findOneById(tmid); + if (!thread) { + return []; + } + + const user = await Meteor.userAsync(); + const room = await Rooms.findOneById(thread.rid); + + if (!user || !room || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' }); + } + + if (!thread.tcount) { + return []; + } + + await callbacks.run('beforeReadMessages', thread.rid, user._id); + await readThread({ user: user as IUser, room, tmid: thread._id }); + + const result = await Messages.findVisibleThreadByThreadId(thread._id, { + ...(skip && { skip }), + ...(limit && { limit }), + sort: { ts: -1 }, + }).toArray(); + + return [thread, ...result]; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getThreadsList.ts b/apps/meteor/server/meteor-methods/messages/getThreadsList.ts new file mode 100644 index 0000000..8ecf9dc --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getThreadsList.ts @@ -0,0 +1,45 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { settings } from '../../settings'; + +const MAX_LIMIT = 100; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getThreadsList(params: { rid: IRoom['_id']; limit?: number; skip?: number }): IMessage[]; + } +} + +Meteor.methods({ + async getThreadsList({ rid, limit = 50, skip = 0 }) { + methodDeprecationLogger.method('getThreadsList', '9.0.0', '/v1/chat.getThreadsList'); + if (limit > MAX_LIMIT) { + throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, { + method: 'getThreadsList', + }); + } + + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadsList' }); + } + + if (typeof rid !== 'string') { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getThreadsList' }); + } + + const user = await Meteor.userAsync(); + const room = await Rooms.findOneById(rid); + + if (!user || !room || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'getThreadsList' }); + } + + return Messages.findThreadsByRoomId(room._id, skip, limit).toArray(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/getUserMentionsByChannel.ts b/apps/meteor/server/meteor-methods/messages/getUserMentionsByChannel.ts new file mode 100644 index 0000000..47dd75b --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/getUserMentionsByChannel.ts @@ -0,0 +1,53 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Users, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getUserMentionsByChannel(params: { roomId: string; options: { limit: number; skip: number; sort: { ts: -1 | 1 } } }): IMessage[]; + } +} + +export const getUserMentionsByChannel = async ( + userId: string, + roomId: string, + options: { limit?: number; skip?: number; sort?: { ts?: -1 | 1 } }, +) => { + check(roomId, String); + + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const room = await Rooms.findOneById(roomId); + + if (!room || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getUserMentionsByChannel', + }); + } + + return Messages.findVisibleByMentionAndRoomId(user.username, roomId, options).toArray(); +}; + +Meteor.methods({ + async getUserMentionsByChannel({ roomId, options }) { + methodDeprecationLogger.method('getUserMentionsByChannel', '9.0.0', '/v1/channels.getAllUserMentionsByChannel'); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getUserMentionsByChannel', + }); + } + + return getUserMentionsByChannel(uid, roomId, options); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/loadHistory.ts b/apps/meteor/server/meteor-methods/messages/loadHistory.ts new file mode 100644 index 0000000..849ea3c --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/loadHistory.ts @@ -0,0 +1,67 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync, roomAccessAttributes } from '../../lib/authorization'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { loadMessageHistory } from '../../lib/messages/loadMessageHistory'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + loadHistory( + rid: IRoom['_id'], + ts?: Date, + limit?: number, + ls?: string | Date, + showThreadMessages?: boolean, + ): + | { + messages: IMessage[]; + firstUnread: IMessage | undefined; + unreadNotLoaded: number; + } + | false; + } +} + +Meteor.methods({ + async loadHistory(rid, end, limit = 20, ls, showThreadMessages = true) { + methodDeprecationLogger.method('loadHistory', '9.0.0', '/v1/rooms.history'); + check(rid, String); + const fromUser = await Meteor.userAsync(); + + if (!fromUser && settings.get('Accounts_AllowAnonymousRead') === false) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'loadHistory', + }); + } + + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } }); + if (!room) { + return false; + } + + // this checks the Allow Anonymous Read setting, so no need to check again + if (!(await canAccessRoomAsync(room, fromUser || undefined))) { + return false; + } + + // if fromId is undefined and it passed the previous check, the user is reading anonymously + if (!fromUser) { + return loadMessageHistory({ rid, end, limit, ls, showThreadMessages, room }); + } + + const canPreview = await hasPermissionAsync(fromUser, 'preview-c-room'); + + if (room.t === 'c' && !canPreview && !(await Subscriptions.findOneByRoomIdAndUserId(rid, fromUser._id, { projection: { _id: 1 } }))) { + return false; + } + + return loadMessageHistory({ userId: fromUser._id, rid, end, limit, ls, showThreadMessages, room }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/loadMissedMessages.ts b/apps/meteor/server/meteor-methods/messages/loadMissedMessages.ts new file mode 100644 index 0000000..535e2fd --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/loadMissedMessages.ts @@ -0,0 +1,39 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + loadMissedMessages(rid: IRoom['_id'], ts: Date): Promise; + } +} + +Meteor.methods({ + async loadMissedMessages(rid, start) { + methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages'); + check(rid, String); + check(start, Date); + + const fromId = Meteor.userId() ?? undefined; + + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUsersOfRoom' }); + } + + if (!(await canAccessRoomIdAsync(rid, fromId))) { + return false; + } + + return Messages.findVisibleByRoomIdAfterTimestamp(rid, start, true, { + sort: { + ts: -1, + }, + }).toArray(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/loadNextMessages.ts b/apps/meteor/server/meteor-methods/messages/loadNextMessages.ts new file mode 100644 index 0000000..404e689 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/loadNextMessages.ts @@ -0,0 +1,61 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + loadNextMessages(rid: IRoom['_id'], end?: Date, limit?: number): Promise<{ messages: IMessage[] }>; + } +} + +Meteor.methods({ + async loadNextMessages(rid, end, limit = 20) { + methodDeprecationLogger.method('loadNextMessages', '9.0.0', '/v1/rooms.history'); + check(rid, String); + check(limit, Number); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'loadNextMessages', + }); + } + + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'loadNextMessages' }); + } + + const fromId = Meteor.userId(); + + if (!fromId || !(await canAccessRoomIdAsync(rid, fromId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'loadNextMessages' }); + } + + let records; + if (end) { + records = await Messages.findVisibleByRoomIdAfterTimestamp(rid, end, true, { + sort: { + ts: 1, + }, + limit, + }).toArray(); + } else { + records = await Messages.findVisibleByRoomId(rid, { + sort: { + ts: 1, + }, + limit, + }).toArray(); + } + + return { + messages: await normalizeMessagesForUser(records, fromId), + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/loadSurroundingMessages.ts b/apps/meteor/server/meteor-methods/messages/loadSurroundingMessages.ts new file mode 100644 index 0000000..0ab364c --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/loadSurroundingMessages.ts @@ -0,0 +1,102 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions } from 'mongodb'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + loadSurroundingMessages( + message: Pick & { ts?: Date }, + limit?: number, + showThreadMessages?: boolean, + ): + | { + messages: IMessage[]; + moreBefore: boolean; + moreAfter: boolean; + } + | false; + } +} + +Meteor.methods({ + async loadSurroundingMessages(message, limit = 50, showThreadMessages = true) { + methodDeprecationLogger.method('loadSurroundingMessages', '9.0.0', '/v1/rooms.history'); + check(message, Object); + check(limit, Number); + check(showThreadMessages, Boolean); + + const fromId = Meteor.userId() ?? undefined; + + if (!fromId && settings.get('Accounts_AllowAnonymousRead') === false) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'loadSurroundingMessages', + }); + } + + if (!message._id) { + return false; + } + + const mainMessage = await Messages.findOneById(message._id); + + if (!mainMessage?.rid) { + return false; + } + + if (!(await canAccessRoomIdAsync(mainMessage.rid, fromId))) { + return false; + } + + limit -= 1; + + const options: FindOptions = { + sort: { + ts: -1, + }, + limit: Math.ceil(limit / 2), + }; + + const messages = await Messages.findVisibleByRoomIdBeforeTimestamp( + mainMessage.rid, + mainMessage.ts, + showThreadMessages, + options, + ).toArray(); + + const moreBefore = messages.length === options.limit; + + messages.push(mainMessage); + + options.sort = { + ts: 1, + }; + + options.limit = Math.floor(limit / 2); + + const afterMessages = await Messages.findVisibleByRoomIdAfterTimestamp( + mainMessage.rid, + mainMessage.ts, + showThreadMessages, + options, + ).toArray(); + + const moreAfter = afterMessages.length === options.limit; + + messages.push(...afterMessages); + + return { + messages: fromId ? await normalizeMessagesForUser(messages, fromId) : messages, + moreBefore, + moreAfter, + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/messageSearch.ts b/apps/meteor/server/meteor-methods/messages/messageSearch.ts new file mode 100644 index 0000000..b18b890 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/messageSearch.ts @@ -0,0 +1,121 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Logger } from '@rocket.chat/logger'; +import { Messages, Subscriptions, Users } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { parseMessageSearchQuery } from '../../lib/parseMessageSearchQuery'; +import type { IRawSearchResult } from '../../lib/search/model/ISearchResult'; +import { settings } from '../../settings'; + +const logger = new Logger('MessageSearch'); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + messageSearch(text: string, rid?: string, limit?: number, offset?: number): IRawSearchResult | false; + } +} + +export const messageSearch = async function ( + userId: string, + text: string, + rid?: string, + limit?: number, + offset?: number, +): Promise { + check(text, String); + check(rid, Match.Maybe(String)); + check(limit, Match.Optional(Number)); + check(offset, Match.Optional(Number)); + + // Don't process anything else if the user can't access the room + if (rid) { + if (!(await canAccessRoomIdAsync(rid, userId))) { + return false; + } + } else if (settings.get('Search.defaultProvider.GlobalSearchEnabled') !== true) { + return { + message: { + docs: [], + }, + }; + } + + const user = (await Users.findOneById(userId)) || undefined; + + let parsedQuery: ReturnType; + + try { + parsedQuery = parseMessageSearchQuery(text, { + user, + offset, + limit, + forceRegex: settings.get('Message_AlwaysSearchRegExp'), + }); + } catch (error: unknown) { + logger.error({ msg: 'Error while parsing message search query', error }); + if (error instanceof SyntaxError) { + return { message: { docs: [] } }; + } + throw error; + } + + const { query, options } = parsedQuery; + + if (Object.keys(query).length === 0) { + return { + message: { + docs: [], + }, + }; + } + + query.t = { + $ne: 'rm', // hide removed messages (useful when searching for user messages) + }; + query._hidden = { + $ne: true, // don't return _hidden messages + }; + + if (rid) { + query.rid = rid; + } else { + query.rid = { + $in: user?._id ? (await Subscriptions.findByUserId(user._id).toArray()).map((subscription: ISubscription) => subscription.rid) : [], + }; + } + + try { + return { + message: { + docs: await Messages.find(query, { + // @ts-expect-error col.s.db is not typed + readPreference: readSecondaryPreferred(Messages.col.s.db), + ...options, + }).toArray(), + }, + }; + } catch (error) { + logger.error({ msg: 'Error while finding messages', error }); + throw new Error('error-while-finding-messages', { cause: error }); + } +}; + +Meteor.methods({ + async messageSearch(text, rid, limit, offset) { + methodDeprecationLogger.method('messageSearch', '9.0.0', '/v1/chat.search'); + const currentUserId = Meteor.userId(); + if (!currentUserId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'messageSearch', + }); + } + + return messageSearch(currentUserId, text, rid, limit, offset); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/readMessages.ts b/apps/meteor/server/meteor-methods/messages/readMessages.ts new file mode 100644 index 0000000..4f84126 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/readMessages.ts @@ -0,0 +1,39 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { readMessages } from '../../lib/readMessages'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + readMessages(rid: string, readThreads?: boolean): Promise; + } +} + +Meteor.methods({ + async readMessages(rid, readThreads = false) { + check(rid, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'readMessages', + }); + } + + const user = ((await Meteor.userAsync()) as IUser | null) ?? undefined; + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readMessages' }); + } + if (!(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readMessages' }); + } + + await readMessages(room, userId, readThreads); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/readThreads.ts b/apps/meteor/server/meteor-methods/messages/readThreads.ts new file mode 100644 index 0000000..6d7a3f9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/readThreads.ts @@ -0,0 +1,53 @@ +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { callbacks } from '../../lib/callbacks'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { readThread } from '../../lib/messaging/threads/functions'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + readThreads(tmid: IMessage['_id']): void; + } +} + +Meteor.methods({ + async readThreads(tmid) { + methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/chat.readThread'); + + check(tmid, String); + + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { + method: 'getThreadMessages', + }); + } + + const thread = await Messages.findOneById(tmid); + if (!thread) { + return; + } + + const user = (await Meteor.userAsync()) ?? undefined; + + const room = await Rooms.findOneById(thread.rid); + if (!room) { + throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' }); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' }); + } + + await callbacks.run('beforeReadMessages', thread.rid, user?._id); + if (user?._id) { + await readThread({ user: user as IUser, room, tmid }); + } + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/sendFileMessage.spec.ts b/apps/meteor/server/meteor-methods/messages/sendFileMessage.spec.ts new file mode 100644 index 0000000..900858a --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/sendFileMessage.spec.ts @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const findOneByIdAndUserIdAndRoomId = sinon.stub(); +const updateFileMetadata = sinon.stub().resolves(); +const getPath = sinon.stub().returns('/path/to/file.txt'); +const isImagePreviewSupported = sinon.stub().returns(false); +const getFileExtension = sinon.stub().returns('txt'); + +const { parseFileIntoMessageAttachments } = proxyquire.noCallThru().load('./sendFileMessage', { + '@rocket.chat/models': { + Uploads: { findOneByIdAndUserIdAndRoomId, updateFileMetadata }, + Rooms: { findOneById: sinon.stub() }, + Users: { findOneById: sinon.stub() }, + }, + 'meteor/check': { + check: sinon.stub(), + Match: { + Maybe: sinon.stub(), + Optional: sinon.stub(), + ObjectIncluding: sinon.stub(), + }, + }, + 'meteor/meteor': { + Meteor: { + Error: class Error extends global.Error {}, + methods: sinon.stub(), + }, + }, + '../../lib/media/file-upload/lib/FileUpload': { + FileUpload: { getPath }, + }, + '../../lib/media/file-upload/isImagePreviewSupported': { isImagePreviewSupported }, + '../../../lib/utils/getFileExtension': { getFileExtension }, + '../../lib/callbacks': { callbacks: { runAsync: sinon.stub() } }, + '../../lib/logger/system': { SystemLogger: { error: sinon.stub() } }, + '../../lib/authorization/canAccessRoom': { canAccessRoomAsync: sinon.stub().resolves(true) }, + './sendMessage': { executeSendMessage: sinon.stub().resolves({}) }, +}); + +describe('sendFileMessage - Mass Assignment & Type Pollution Prevention', () => { + const mockUser = { _id: 'user123' }; + const roomId = 'room123'; + + beforeEach(() => { + findOneByIdAndUserIdAndRoomId.reset(); + updateFileMetadata.reset(); + + findOneByIdAndUserIdAndRoomId.resolves({ _id: 'file123' }); + }); + + it('should filter out invalid types, nulls, and malicious fields before updating the database', async () => { + const maliciousFilePayload = { + _id: 'file123', + name: null, // invalid type, must be ignored + type: 'text/plain', + size: 1024, + description: 12345, // invalid type, must be ignored + typeGroup: 'image', // only valid field + content: null, // invalid type, must be ignored + maliciousRoleAssignment: 'admin', // mass assignment, must be ignored + $set: { bypassSecurity: true }, // mongo injection, must be ignored + }; + + await parseFileIntoMessageAttachments(maliciousFilePayload as any, roomId, mockUser as any); + + expect(updateFileMetadata.calledOnce).to.equal(true); + + const [fileId, userId, safeMetadata] = updateFileMetadata.getCall(0).args; + + expect(fileId).to.equal('file123'); + expect(userId).to.equal('user123'); + + expect(safeMetadata).to.deep.equal({ + typeGroup: 'image', + }); + }); + + it('should pass valid fields correctly to the database', async () => { + const validFilePayload = { + _id: 'file123', + name: 'picture.jpg', + type: 'image/jpeg', + size: 2048, + description: 'Description', + typeGroup: 'image', + content: { + algorithm: 'rc.v1.aes-sha2', + ciphertext: 'test', + }, + }; + + await parseFileIntoMessageAttachments(validFilePayload as any, roomId, mockUser as any); + + expect(updateFileMetadata.calledOnce).to.equal(true); + + const [, , safeMetadata] = updateFileMetadata.getCall(0).args; + + expect(safeMetadata).to.deep.equal({ + name: 'picture.jpg', + description: 'Description', + typeGroup: 'image', + content: { + algorithm: 'rc.v1.aes-sha2', + ciphertext: 'test', + }, + }); + }); +}); diff --git a/apps/meteor/server/meteor-methods/messages/sendFileMessage.ts b/apps/meteor/server/meteor-methods/messages/sendFileMessage.ts new file mode 100644 index 0000000..122946f --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/sendFileMessage.ts @@ -0,0 +1,255 @@ +import type { + MessageAttachment, + FileAttachmentProps, + IUser, + IUpload, + AtLeast, + FilesAndAttachments, + IMessage, + FileProp, +} from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Uploads, Users } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { executeSendMessage } from './sendMessage'; +import { getFileExtension } from '../../../lib/utils/getFileExtension'; +import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom'; +import { callbacks } from '../../lib/callbacks'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { SystemLogger } from '../../lib/logger/system'; +import { isImagePreviewSupported } from '../../lib/media/file-upload/isImagePreviewSupported'; +import { FileUpload } from '../../lib/media/file-upload/lib/FileUpload'; + +function validateFileRequiredFields(file: Partial): asserts file is AtLeast { + const requiredFields = ['_id', 'name', 'type', 'size']; + requiredFields.forEach((field) => { + if (!Object.keys(file).includes(field)) { + throw new Meteor.Error('error-invalid-file', 'Invalid file'); + } + }); +} + +export const parseFileIntoMessageAttachments = async ( + file: Partial, + roomId: string, + user: IUser, +): Promise => { + validateFileRequiredFields(file); + + const upload = await Uploads.findOneByIdAndUserIdAndRoomId(file._id, user._id, roomId, { projection: { _id: 1 } }); + if (!upload) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'sendFileMessage', + }); + } + + const safeMetadata = { + ...(typeof file.name === 'string' && { name: file.name }), + ...(typeof file.description === 'string' && { description: file.description }), + ...(typeof file.typeGroup === 'string' && { typeGroup: file.typeGroup }), + ...(file.content && typeof file.content === 'object' && { content: file.content }), + }; + + await Uploads.updateFileMetadata(file._id, user._id, safeMetadata); + + const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); + + const attachments: MessageAttachment[] = []; + + const files: FileProp[] = [ + { + _id: file._id, + name: file.name || '', + type: file.type || 'file', + size: file.size || 0, + format: file.identify?.format || '', + typeGroup: file.typeGroup, + }, + ]; + + if (isImagePreviewSupported(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + image_alt: file?.description, + title_link: fileUrl, + title_link_download: true, + image_url: fileUrl, + image_type: file.type as string, + image_size: file.size, + fileId: file._id, + }; + + if (file.identify?.size) { + attachment.image_dimensions = file.identify.size; + } + + try { + attachment.image_preview = await FileUpload.resizeImagePreview(file); + const thumbResult = await FileUpload.createImageThumbnail(file); + if (thumbResult) { + const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult; + const thumbnail = await FileUpload.uploadImageThumbnail( + { + thumbFileName, + thumbFileType, + originalFileId, + }, + thumbBuffer, + roomId, + user._id, + ); + const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`); + attachment.image_url = thumbUrl; + attachment.image_type = thumbnail.type; + attachment.image_dimensions = { + width, + height, + }; + files.push({ + _id: thumbnail._id, + name: thumbnail.name || '', + type: thumbnail.type || 'file', + size: thumbnail.size || 0, + format: thumbnail.identify?.format || '', + typeGroup: thumbnail.typeGroup || '', + }); + } + } catch (err) { + SystemLogger.error({ err }); + } + attachments.push(attachment); + } else if (/^audio\/.+/.test(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + audio_url: fileUrl, + audio_type: file.type as string, + audio_size: file.size, + fileId: file._id, + }; + attachments.push(attachment); + } else if (/^video\/.+/.test(file.type as string)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + video_url: fileUrl, + video_type: file.type as string, + video_size: file.size as number, + fileId: file._id, + }; + attachments.push(attachment); + } else { + const attachment = { + title: file.name, + type: 'file', + format: getFileExtension(file.name), + description: file.description, + title_link: fileUrl, + title_link_download: true, + size: file.size as number, + fileId: file._id, + }; + attachments.push(attachment); + } + return { files, attachments }; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + sendFileMessage: (roomId: string, _store: string, file: Partial, msgData?: Record) => boolean; + } +} + +export const sendFileMessage = async ( + userId: string, + { + roomId, + file, + msgData, + }: { + roomId: string; + file: Partial; + msgData?: Record; + }, +): Promise => { + const user = await Users.findOneById(userId, { projection: { services: 0 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendFileMessage', + } as any); + } + + const room = await Rooms.findOneById(roomId); + if (!room) { + return false; + } + + if (user?.type !== 'app' && !(await canAccessRoomAsync(room, user))) { + return false; + } + + check( + msgData, + Match.Maybe({ + avatar: Match.Optional(String), + emoji: Match.Optional(String), + alias: Match.Optional(String), + groupable: Match.Optional(Boolean), + msg: Match.Optional(String), + tmid: Match.Optional(String), + customFields: Match.Optional(String), + t: Match.Optional(String), + content: Match.Optional( + Match.ObjectIncluding({ + algorithm: String, + ciphertext: String, + }), + ), + }), + ); + + const data = { + rid: roomId, + ts: new Date(), + ...(msgData as Partial), + ...(msgData?.customFields && { customFields: JSON.parse(msgData.customFields) }), + msg: msgData?.msg ?? '', + groupable: msgData?.groupable ?? false, + }; + + const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user); + data.file = files[0]; + data.files = files; + data.attachments = attachments; + + const msg = await executeSendMessage(userId, data); + + callbacks.runAsync('afterFileUpload', { user, room, message: msg }); + + return msg; +}; + +Meteor.methods({ + async sendFileMessage(roomId, _store, file, msgData = {}) { + methodDeprecationLogger.method('sendFileMessage', '9.0.0', '/v1/rooms.mediaConfirm/:rid/:fileId'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendFileMessage', + } as any); + } + + return sendFileMessage(userId, { roomId, file, msgData }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/sendMessage.ts b/apps/meteor/server/meteor-methods/messages/sendMessage.ts new file mode 100644 index 0000000..a408e92 --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/sendMessage.ts @@ -0,0 +1,187 @@ +import { api } from '@rocket.chat/core-services'; +import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { Messages, Users } from '@rocket.chat/models'; +import type { TOptions } from 'i18next'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import moment from 'moment'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { i18n } from '../../lib/i18n'; +import { SystemLogger } from '../../lib/logger/system'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { metrics } from '../../lib/metrics'; +import { settings } from '../../settings'; +/** + * + * @param uid + * @param message + * @param extraInfo + * - ts: The timestamp of the message. the message object already has a ts, but this value is validated and only a window of 10 seconds is allowed to be used. this value overrides the message.ts value without validation. + * + * + * @returns + */ +export async function executeSendMessage( + uid: IUser['_id'] | IUser, + message: AtLeast, + extraInfo?: { ts?: Date; previewUrls?: string[] }, +) { + if (message.tshow && !message.tmid) { + throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', { + method: 'sendMessage', + }); + } + + if (message.tmid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { + method: 'sendMessage', + }); + } + + const isTimestampFromClient = Boolean(!extraInfo?.ts && message.ts); + const now = new Date(); + message.ts = extraInfo?.ts ?? message.ts ?? now; + if (isTimestampFromClient) { + const tsDiff = Math.abs(moment(message.ts).diff(Date.now())); + if (tsDiff > 60000) { + throw new Meteor.Error('error-message-ts-out-of-sync', 'Message timestamp is out of sync', { + method: 'sendMessage', + message_ts: message.ts, + server_ts: new Date().getTime(), + }); + } + if (tsDiff > 10000) { + message.ts = now; + } + } + + if (message.msg) { + if (message.msg.length > (settings.get('Message_MaxAllowedSize') ?? 0)) { + throw new Meteor.Error('error-message-size-exceeded', 'Message size exceeds Message_MaxAllowedSize', { + method: 'sendMessage', + }); + } + } + + const user = typeof uid === 'string' ? await Users.findOneById(uid) : uid; + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + let { rid } = message; + + // do not allow nested threads + if (message.tmid) { + const parentMessage = await Messages.findOneById(message.tmid, { projection: { rid: 1, tmid: 1 } }); + message.tmid = parentMessage?.tmid || message.tmid; + + if (parentMessage?.rid) { + rid = parentMessage?.rid; + } + } + + if (!rid) { + throw new Error("The 'rid' property on the message object is missing."); + } + + check(rid, String); + + try { + const room = await canSendMessageAsync(rid, user); + + if (room.encrypted && settings.get('E2E_Enable') && !settings.get('E2E_Allow_Unencrypted_Messages')) { + if (message.t !== 'e2e') { + throw new Meteor.Error('error-not-allowed', 'Not allowed to send un-encrypted messages in an encrypted room', { + method: 'sendMessage', + }); + } + } + + const result = await sendMessage(user, message, room, { previewUrls: extraInfo?.previewUrls }); + + metrics.messagesSent.inc(); + metrics.messagesSentTotal.inc(); + + return result; + } catch (err: any) { + SystemLogger.error({ msg: 'Error sending message:', err }); + + const errorMessage: RocketchatI18nKeys = typeof err === 'string' ? err : err.error || err.message; + const errorContext: TOptions = err.details ?? {}; + void api.broadcast('notify.ephemeralMessage', user._id, message.rid, { + msg: i18n.t(errorMessage, { ...errorContext, lng: user.language }), + }); + + if (typeof err === 'string') { + throw new Error(err); + } + + throw err; + } +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + sendMessage(message: AtLeast, previewUrls?: string[]): any; + } +} + +Meteor.methods({ + async sendMessage(message, previewUrls) { + methodDeprecationLogger.method('sendMessage', '9.0.0', '/v1/chat.sendMessage'); + + check(message, { + _id: Match.Maybe(String), + rid: Match.Maybe(String), + msg: Match.Maybe(String), + tmid: Match.Maybe(String), + tshow: Match.Maybe(Boolean), + ts: Match.Maybe(Date), + t: Match.Maybe(String), + bot: Match.Maybe(Object), + content: Match.Maybe(Object), + e2e: Match.Maybe(String), + e2eMentions: Match.Maybe(Object), + customFields: Match.Maybe(Object), + federation: Match.Maybe(Object), + groupable: Match.Maybe(Boolean), + sentByEmail: Match.Maybe(Boolean), + }); + + const user = (await Meteor.userAsync()) as IUser; + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendMessage', + }); + } + + if (MessageTypes.isSystemMessage(message)) { + throw new Error("Cannot send system messages using 'sendMessage'"); + } + + try { + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(user, message, { previewUrls })); + } catch (error: any) { + if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { + throw new Meteor.Error(error.error || error.message, error.reason, { + method: 'sendMessage', + }); + } + } + }, +}); +// Limit a user, who does not have the "bot" role, to sending 5 msgs/second +RateLimiter.limitMethod('sendMessage', 5, 1000, { + async userId(userId: IUser['_id']) { + return !(await hasPermissionAsync(userId, 'send-many-messages')); + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/unfollowMessage.ts b/apps/meteor/server/meteor-methods/messages/unfollowMessage.ts new file mode 100644 index 0000000..7224a2a --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/unfollowMessage.ts @@ -0,0 +1,70 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { unfollow } from '../../lib/messaging/threads/functions'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + unfollowMessage(message: { mid: IMessage['_id'] }): false | undefined; + } +} + +export const unfollowMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] }): Promise => { + if (mid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); + } + + const message = await Messages.findOneById(mid); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { + method: 'unfollowMessage', + }); + } + + if (!(await canAccessRoomIdAsync(message.rid, user._id))) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); + } + + const id = message.tmid || message._id; + + const unfollowResult = await unfollow({ rid: message.rid, tmid: id, uid: user._id }); + + void notifyOnMessageChange({ + id, + }); + + const isFollowed = false; + await Apps.self?.triggerEvent(AppEvents.IPostMessageFollowed, message, user, isFollowed); + + return unfollowResult; +}; + +Meteor.methods({ + async unfollowMessage({ mid }) { + methodDeprecationLogger.method('unfollowMessage', '9.0.0', '/v1/chat.unfollowMessage'); + check(mid, String); + + const user = (await Meteor.userAsync()) as IUser; + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unfollowMessage' }); + } + + return unfollowMessage(user, { mid }); + }, +}); + +RateLimiter.limitMethod('unfollowMessage', 5, 5000, { + userId() { + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/messages/updateMessage.ts b/apps/meteor/server/meteor-methods/messages/updateMessage.ts new file mode 100644 index 0000000..963befc --- /dev/null +++ b/apps/meteor/server/meteor-methods/messages/updateMessage.ts @@ -0,0 +1,121 @@ +import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Users } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import moment from 'moment'; + +import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; +import { updateMessage } from '../../lib/messages/updateMessage'; +import { settings } from '../../settings'; + +const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions']; + +export async function executeUpdateMessage( + uid: IUser['_id'], + message: AtLeast | AtLeast, + previewUrls?: string[], +) { + const originalMessage = await Messages.findOneById(message._id); + if (!originalMessage?._id) { + return; + } + + Object.entries(message).forEach(([key, value]) => { + if (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) { + throw new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, { + method: 'updateMessage', + }); + } + }); + + // IF the message has custom fields, always update + // Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of + // custom fields, as it's user defined, we're gonna update + const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg; + if (msgText === message.msg && !previewUrls && !message.customFields) { + return; + } + + if (!!message.tmid && originalMessage._id === message.tmid) { + throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', { + method: 'updateMessage', + }); + } + + if (!originalMessage.tmid && !!message.tmid) { + throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' }); + } + + const _hasPermission = await hasPermissionAsync(uid, 'edit-message', message.rid); + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === uid; + + if (!_hasPermission && (!editAllowed || !editOwn)) { + throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { + method: 'updateMessage', + action: 'Message_editing', + }); + } + + const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); + const bypassBlockTimeLimit = await hasPermissionAsync(uid, 'bypass-time-limit-edit-and-delete', message.rid); + + if (!bypassBlockTimeLimit && Match.test(blockEditInMinutes, Number) && blockEditInMinutes !== 0) { + let currentTsDiff = 0; + let msgTs; + + if (originalMessage.ts instanceof Date || Match.test(originalMessage.ts, Number)) { + msgTs = moment(originalMessage.ts); + } + if (msgTs) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + if (currentTsDiff >= blockEditInMinutes) { + throw new Meteor.Error('error-message-editing-blocked', 'Message editing is blocked', { + method: 'updateMessage', + }); + } + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); + } + await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); + + // It is possible to have an empty array as the attachments property, so ensure both things exist + if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) { + originalMessage.attachments[0].description = message.msg; + message.attachments = originalMessage.attachments; + message.msg = originalMessage.msg; + } + + message.u = originalMessage.u; + + return updateMessage(message, user, originalMessage, previewUrls); +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + updateMessage(message: IEditedMessage, previewUrls?: string[]): void; + } +} + +Meteor.methods({ + async updateMessage(message: IEditedMessage, previewUrls?: string[]) { + check(message, Match.ObjectIncluding({ _id: String })); + check(previewUrls, Match.Maybe([String])); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' }); + } + + return applyAirGappedRestrictionsValidation(() => executeUpdateMessage(uid, message, previewUrls)); + }, +}); diff --git a/apps/meteor/server/meteor-methods/omnichannel/sendFileLivechatMessage.ts b/apps/meteor/server/meteor-methods/omnichannel/sendFileLivechatMessage.ts new file mode 100644 index 0000000..6e53f16 --- /dev/null +++ b/apps/meteor/server/meteor-methods/omnichannel/sendFileLivechatMessage.ts @@ -0,0 +1,97 @@ +import type { + FileAttachmentProps, + ImageAttachmentProps, + AudioAttachmentProps, + VideoAttachmentProps, + IUpload, +} from '@rocket.chat/core-typings'; +import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import { Match, check } from 'meteor/check'; + +import { sendMessageLivechat } from './sendMessageLivechat'; +import { FileUpload } from '../../lib/media/file-upload'; + +interface ISendFileLivechatMessage { + roomId: string; + visitorToken: string; + file: IUpload; + msgData?: { avatar?: string; emoji?: string; alias?: string; groupable?: boolean; msg?: string }; +} + +export const sendFileLivechatMessage = async ({ roomId, visitorToken, file, msgData = {} }: ISendFileLivechatMessage): Promise => { + const visitor = await LivechatVisitors.getVisitorByToken(visitorToken); + + if (!visitor) { + return false; + } + + const room = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(roomId, visitorToken); + + if (!room) { + return false; + } + + check(msgData, { + avatar: Match.Optional(String), + emoji: Match.Optional(String), + alias: Match.Optional(String), + groupable: Match.Optional(Boolean), + msg: Match.Optional(String), + }); + + const fileUrl = file.name && FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`); + + const attachment: Partial = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + }; + + if (file.type && /^image\/.+/.test(file.type)) { + (attachment as ImageAttachmentProps).image_url = fileUrl || ''; + (attachment as ImageAttachmentProps).image_type = file.type; + (attachment as ImageAttachmentProps).image_size = file.size; + if (file.identify?.size) { + (attachment as ImageAttachmentProps).image_dimensions = file.identify.size; + } + (attachment as ImageAttachmentProps).image_preview = await FileUpload.resizeImagePreview(file); + } else if (file.type && /^audio\/.+/.test(file.type)) { + (attachment as AudioAttachmentProps).audio_url = fileUrl || ''; + (attachment as AudioAttachmentProps).audio_type = file.type; + (attachment as AudioAttachmentProps).audio_size = file.size; + } else if (file.type && /^video\/.+/.test(file.type)) { + (attachment as VideoAttachmentProps).video_url = fileUrl || ''; + (attachment as VideoAttachmentProps).video_type = file.type; + (attachment as VideoAttachmentProps).video_size = file.size || 0; + } + + const msg = Object.assign( + { + _id: Random.id(), + rid: roomId, + ts: new Date(), + msg: '', + file: { + _id: file._id, + name: file.name, + type: file.type, + }, + files: [ + { + _id: file._id, + name: file.name, + type: file.type, + }, + ], + groupable: false, + attachments: [attachment], + token: visitorToken, + }, + msgData, + ); + + return sendMessageLivechat({ message: msg }); +}; diff --git a/apps/meteor/server/meteor-methods/omnichannel/sendMessageLivechat.ts b/apps/meteor/server/meteor-methods/omnichannel/sendMessageLivechat.ts new file mode 100644 index 0000000..18d9b07 --- /dev/null +++ b/apps/meteor/server/meteor-methods/omnichannel/sendMessageLivechat.ts @@ -0,0 +1,82 @@ +import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { LivechatVisitors } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import type { ILivechatMessage } from '../../lib/omnichannel/localTypes'; +import { sendMessage } from '../../lib/omnichannel/messages'; +import { settings } from '../../settings'; + +interface ILivechatMessageAgent { + agentId: string; + username: string; +} + +interface ISendMessageLivechat { + message: ILivechatMessage; + agent?: ILivechatMessageAgent; +} + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + sendMessageLivechat(message: ILivechatMessage, agent: ILivechatMessageAgent): boolean; + } +} + +export const sendMessageLivechat = async ({ + message: { token, _id, rid, msg, file, files, attachments }, + agent, +}: ISendMessageLivechat): Promise => { + check(token, String); + check(_id, String); + check(rid, String); + check(msg, String); + + check( + agent, + Match.Maybe({ + agentId: String, + username: String, + }), + ); + + // No projection: the guest is forwarded into the message-sending pipeline, which expects a full visitor. + const guest = await LivechatVisitors.getVisitorByToken(token); + + if (!guest) { + throw new Meteor.Error('invalid-token'); + } + + if (settings.get('Livechat_enable_message_character_limit') && msg.length > parseInt(settings.get('Livechat_message_character_limit'))) { + throw new Meteor.Error('message-length-exceeds-character-limit'); + } + + return sendMessage({ + guest, + message: { + _id, + rid, + msg, + token, + file, + files, + attachments, + }, + agent, + roomInfo: { + source: { + type: OmnichannelSourceType.API, + }, + }, + }); +}; + +Meteor.methods({ + async sendMessageLivechat({ token, _id, rid, msg, file, files, attachments }: ILivechatMessage, agent: ILivechatMessageAgent) { + methodDeprecationLogger.method('sendMessageLivechat', '9.0.0', '/v1/livechat/message'); + return sendMessageLivechat({ message: { token, _id, rid, msg, file, files, attachments }, agent }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/OEmbedCacheCleanup.ts b/apps/meteor/server/meteor-methods/platform/OEmbedCacheCleanup.ts new file mode 100644 index 0000000..4fac448 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/OEmbedCacheCleanup.ts @@ -0,0 +1,36 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { OEmbedCache } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + OEmbedCacheCleanup(): { message: string }; + } +} + +export const executeClearOEmbedCache = async () => { + const date = new Date(); + const expirationDays = settings.get('API_EmbedCacheExpirationDays'); + date.setDate(date.getDate() - expirationDays); + return OEmbedCache.removeBeforeDate(date); +}; + +Meteor.methods({ + async OEmbedCacheCleanup() { + const uid = Meteor.userId(); + if (!uid || !(await hasPermissionAsync(uid, 'clear-oembed-cache'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'OEmbedCacheCleanup', + }); + } + + await executeClearOEmbedCache(); + return { + message: 'cache_cleared', + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/banner_dismiss.ts b/apps/meteor/server/meteor-methods/platform/banner_dismiss.ts new file mode 100644 index 0000000..b7048c2 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/banner_dismiss.ts @@ -0,0 +1,33 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnUserChange } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'banner/dismiss'({ id }: { id: string }): void; + } +} + +Meteor.methods({ + async 'banner/dismiss'({ id }) { + methodDeprecationLogger.method('banner/dismiss', '9.0.0', '/v1/banners.dismiss'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'banner/dismiss' }); + } + + await Users.setBannerReadById(userId, id); + + void notifyOnUserChange({ + id: userId, + clientAction: 'updated', + diff: { + [`banners.${id}.read`]: true, + }, + }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/checkFederationConfiguration.ts b/apps/meteor/server/meteor-methods/platform/checkFederationConfiguration.ts new file mode 100644 index 0000000..9c8c277 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/checkFederationConfiguration.ts @@ -0,0 +1,84 @@ +import { Federation, FederationEE, Authorization } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + checkFederationConfiguration(): Promise<{ message: string }>; + } +} + +Meteor.methods({ + async checkFederationConfiguration() { + methodDeprecationLogger.method('checkFederationConfiguration', '9.0.0', []); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'checkFederationConfiguration', + }); + } + + if (!(await Authorization.hasPermission(uid, 'view-privileged-setting'))) { + throw new Meteor.Error('error-not-allowed', 'Action not allowed', { + method: 'checkFederationConfiguration', + }); + } + + const errors: string[] = []; + + const successes: string[] = []; + + const service = License.hasValidLicense() ? FederationEE : Federation; + + const status = await service.configurationStatus(); + + if (status.externalReachability.ok) { + successes.push('homeserver configuration looks good'); + } else { + let err = 'external reachability could not be verified'; + + const { error } = status.externalReachability; + if (error) { + err += `, error: ${error}`; + } + + errors.push(err); + } + + const { + roundTrip: { durationMs: duration }, + } = status.appservice; + + if (status.appservice.ok) { + successes.push(`appservice configuration looks good, total round trip time to homeserver ${duration}ms`); + } else { + errors.push(`failed to verify appservice configuration: ${status.appservice.error}`); + } + + if (errors.length) { + void service.markConfigurationInvalid(); + + if (successes.length) { + const message = ['Configuration could only be partially verified'].concat(successes).concat(errors).join(', '); + + throw new Meteor.Error('error-invalid-configuration', message, { method: 'checkFederationConfiguration' }); + } + + throw new Meteor.Error('error-invalid-configuration', ['Invalid configuration'].concat(errors).join(', '), { + method: 'checkFederationConfiguration', + }); + } + + void service.markConfigurationValid(); + + return { + message: ['All configuration looks good'].concat(successes).join(', '), + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/cloud.ts b/apps/meteor/server/meteor-methods/platform/cloud.ts new file mode 100644 index 0000000..8593dd4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/cloud.ts @@ -0,0 +1,214 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { buildWorkspaceRegistrationData } from '../../lib/cloud/buildRegistrationData'; +import { checkUserHasCloudLogin } from '../../lib/cloud/checkUserHasCloudLogin'; +import { connectWorkspace } from '../../lib/cloud/connectWorkspace'; +import { finishOAuthAuthorization } from '../../lib/cloud/finishOAuthAuthorization'; +import { getOAuthAuthorizationUrl } from '../../lib/cloud/getOAuthAuthorizationUrl'; +import { retrieveRegistrationStatus } from '../../lib/cloud/retrieveRegistrationStatus'; +import { startRegisterWorkspace } from '../../lib/cloud/startRegisterWorkspace'; +import { syncWorkspace } from '../../lib/cloud/syncWorkspace'; +import { userLogout } from '../../lib/cloud/userLogout'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'cloud:checkRegisterStatus': () => { + workspaceRegistered: boolean; + workspaceId: string; + uniqueId: string; + token: string; + email: string; + }; + 'cloud:getWorkspaceRegisterData': () => string; + 'cloud:registerWorkspace': () => boolean; + 'cloud:syncWorkspace': () => boolean; + 'cloud:connectWorkspace': (token: string) => boolean | Error; + 'cloud:getOAuthAuthorizationUrl': () => string; + 'cloud:finishOAuthAuthorization': (code: string, state: string) => boolean; + 'cloud:checkUserLoggedIn': () => boolean; + 'cloud:logout': () => Promise; + } +} + +Meteor.methods({ + /** + * @deprecated this method is deprecated and will be removed soon. + * Prefer using cloud.registrationStatus rest api. + */ + async 'cloud:checkRegisterStatus'() { + methodDeprecationLogger.method('cloud:checkRegisterStatus', '9.0.0', '/v1/cloud.registrationStatus'); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:checkRegisterStatus', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:checkRegisterStatus', + }); + } + + return retrieveRegistrationStatus(); + }, + /** + * @deprecated this method is deprecated and will be removed soon. + * Prefer using cloud.workspaceRegisterData rest api. + */ + async 'cloud:getWorkspaceRegisterData'() { + methodDeprecationLogger.method('cloud:getWorkspaceRegisterData', '9.0.0', '/v1/cloud.workspaceRegisterData'); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:getWorkspaceRegisterData', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:getWorkspaceRegisterData', + }); + } + + return Buffer.from(JSON.stringify(await buildWorkspaceRegistrationData(undefined))).toString('base64'); + }, + async 'cloud:registerWorkspace'() { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:registerWorkspace', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:registerWorkspace', + }); + } + + return startRegisterWorkspace(); + }, + async 'cloud:syncWorkspace'() { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:syncWorkspace', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:syncWorkspace', + }); + } + + await syncWorkspace(); + + return true; + }, + async 'cloud:connectWorkspace'(token) { + methodDeprecationLogger.method('cloud:connectWorkspace', '9.0.0', '/v1/cloud.connectWorkspace'); + check(token, String); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:connectWorkspace', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:connectWorkspace', + }); + } + + if (!token) { + throw new Meteor.Error('error-invalid-payload', 'Token is required.', { + method: 'cloud:connectWorkspace', + }); + } + + return connectWorkspace(token); + }, + // Currently unused but will link local account to Rocket.Chat Cloud account. + async 'cloud:getOAuthAuthorizationUrl'() { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:getOAuthAuthorizationUrl', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:getOAuthAuthorizationUrl', + }); + } + + return getOAuthAuthorizationUrl(); + }, + async 'cloud:finishOAuthAuthorization'(code, state) { + check(code, String); + check(state, String); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:finishOAuthAuthorization', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:finishOAuthAuthorization', + }); + } + + return finishOAuthAuthorization(code, state); + }, + async 'cloud:checkUserLoggedIn'() { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:checkUserLoggedIn', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:checkUserLoggedIn', + }); + } + + return checkUserHasCloudLogin(uid); + }, + async 'cloud:logout'() { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'cloud:logout', + }); + } + + if (!(await hasPermissionAsync(uid, 'manage-cloud'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'cloud:logout', + }); + } + + return userLogout(uid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/fetchMyKeys.ts b/apps/meteor/server/meteor-methods/platform/fetchMyKeys.ts new file mode 100644 index 0000000..919edf1 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/fetchMyKeys.ts @@ -0,0 +1,23 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.fetchMyKeys'(): { public_key?: string; private_key?: string }; + } +} + +Meteor.methods({ + async 'e2e.fetchMyKeys'() { + methodDeprecationLogger.method('e2e.fetchMyKeys', '9.0.0', '/v1/e2e.fetchMyKeys'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.fetchMyKeys' }); + } + return Users.fetchKeysByUserId(userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/getProviderUiMetadata.ts b/apps/meteor/server/meteor-methods/platform/getProviderUiMetadata.ts new file mode 100644 index 0000000..bc12114 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/getProviderUiMetadata.ts @@ -0,0 +1,28 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { TranslationProviderRegistry } from '../../lib/autotranslate/autotranslate'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'autoTranslate.getProviderUiMetadata'(): Record; + } +} + +Meteor.methods({ + 'autoTranslate.getProviderUiMetadata'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-action-not-allowed', 'Login neccessary', { + method: 'autoTranslate.getProviderUiMetadata', + }); + } + + return Object.fromEntries( + TranslationProviderRegistry.getProviders().map((provider) => { + const { name, displayName } = provider._getProviderMetadata(); + return [name, { name, displayName }]; + }), + ); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/getStatistics.ts b/apps/meteor/server/meteor-methods/platform/getStatistics.ts new file mode 100644 index 0000000..917ed85 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/getStatistics.ts @@ -0,0 +1,27 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getLastStatistics } from '../../lib/statistics/functions/getLastStatistics'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getStatistics(refresh?: boolean): IStats; + } +} + +Meteor.methods({ + async getStatistics(refresh) { + methodDeprecationLogger.method('getStatistics', '9.0.0', '/v1/statistics'); + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getStatistics' }); + } + return getLastStatistics({ + userId: uid, + refresh, + }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/getSupportedLanguages.ts b/apps/meteor/server/meteor-methods/platform/getSupportedLanguages.ts new file mode 100644 index 0000000..9b926a3 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/getSupportedLanguages.ts @@ -0,0 +1,39 @@ +import type { ISupportedLanguage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { getSupportedLanguages } from '../../lib/autotranslate/functions/getSupportedLanguages'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'autoTranslate.getSupportedLanguages'(targetLanguage: string): ISupportedLanguage[] | undefined; + } +} + +Meteor.methods({ + async 'autoTranslate.getSupportedLanguages'(targetLanguage) { + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getSupportedLanguages', + }); + } + + return getSupportedLanguages(userId, targetLanguage); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'autoTranslate.getSupportedLanguages', + userId(/* userId*/) { + return true; + }, + }, + 5, + 60000, +); diff --git a/apps/meteor/server/meteor-methods/platform/getUsersOfRoomWithoutKey.ts b/apps/meteor/server/meteor-methods/platform/getUsersOfRoomWithoutKey.ts new file mode 100644 index 0000000..fc3a73e --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/getUsersOfRoomWithoutKey.ts @@ -0,0 +1,56 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.getUsersOfRoomWithoutKey'(rid: IRoom['_id']): { users: Pick[] }; + } +} + +export const getUsersOfRoomWithoutKeyMethod = async ( + userId: string, + rid: IRoom['_id'], +): Promise<{ users: Pick[] }> => { + if (!(await canAccessRoomIdAsync(rid, userId))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.getUsersOfRoomWithoutKey' }); + } + + const subscriptions = await Subscriptions.findByRidWithoutE2EKey(rid, { + projection: { 'u._id': 1 }, + }).toArray(); + const userIds = subscriptions.map((s) => s.u._id); + const options = { projection: { 'e2e.public_key': 1 } }; + + const users = await Users.findByIdsWithPublicE2EKey(userIds, options).toArray(); + + return { + users, + }; +}; + +Meteor.methods({ + async 'e2e.getUsersOfRoomWithoutKey'(rid) { + check(rid, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'e2e.getUsersOfRoomWithoutKey', + }); + } + + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'e2e.getUsersOfRoomWithoutKey', + }); + } + + return getUsersOfRoomWithoutKeyMethod(userId, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/loadLocale.ts b/apps/meteor/server/meteor-methods/platform/loadLocale.ts new file mode 100644 index 0000000..6a2355a --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/loadLocale.ts @@ -0,0 +1,24 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { getMomentLocale } from '../../lib/getMomentLocale'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + loadLocale(locale: string): string | undefined; + } +} + +Meteor.methods({ + loadLocale(locale) { + check(locale, String); + + try { + return getMomentLocale(locale); + } catch (error: any) { + throw new Meteor.Error(error.message, `Moment locale not found: ${locale}`); + } + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/push.ts b/apps/meteor/server/meteor-methods/platform/push.ts new file mode 100644 index 0000000..c7d17a9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/push.ts @@ -0,0 +1,80 @@ +import { Push } from '@rocket.chat/core-services'; +import type { IPushToken } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { PushToken } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { logger } from '../../lib/notifications/push/logger'; +import { _matchToken } from '../../lib/notifications/push/push'; + +type PushUpdateOptions = { + id?: string; + token: IPushToken['token']; + authToken: string; + appName: string; + userId: string | null; + metadata?: Record; +}; +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'raix:push-update'(options: PushUpdateOptions): Promise>; + 'raix:push-setuser'(options: { id: string; userId: string }): Promise; + } +} + +Meteor.methods({ + async 'raix:push-update'(options) { + methodDeprecationLogger.method('raix:push-update', '9.0.0', '/v1/push.token'); + logger.debug({ msg: 'Got push token from app', options }); + + check(options, { + id: Match.Optional(String), + token: _matchToken, + authToken: String, + appName: String, + userId: Match.OneOf(String, null), + metadata: Match.Optional(Object), + }); + + // The if user id is set then user id should match on client and connection + if (!this.userId || (options.userId && options.userId !== this.userId)) { + throw new Meteor.Error(403, 'Forbidden access'); + } + + // Retain old behavior: if id is not specified but userId is explicitly set, then update the user's first token + if (!options.id && options.userId) { + const firstDoc = await PushToken.findFirstByUserId(options.userId, { projection: { _id: 1 } }); + if (firstDoc) { + options.id = firstDoc._id; + } + } + + const authToken = Accounts._hashLoginToken(options.authToken); + + return Push.registerPushToken({ + ...(options.id && { _id: options.id }), + token: options.token, + appName: options.appName, + authToken, + userId: this.userId, + ...(options.metadata && { metadata: options.metadata }), + }); + }, + async 'raix:push-setuser'(id) { + methodDeprecationLogger.method('raix:push-setuser', '9.0.0', []); + + check(id, String); + if (!this.userId) { + throw new Meteor.Error(403, 'Forbidden access'); + } + + logger.debug({ msg: 'Setting userId for app', userId: this.userId, appId: id }); + const found = await PushToken.updateOne({ _id: id }, { $set: { userId: this.userId } }); + + return !!found; + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/requestDataDownload.ts b/apps/meteor/server/meteor-methods/platform/requestDataDownload.ts new file mode 100644 index 0000000..9f16d99 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/requestDataDownload.ts @@ -0,0 +1,123 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path, { join } from 'node:path'; + +import type { IExportOperation, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { ExportOperations, UserDataFiles } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import * as dataExport from '../../lib/dataExport'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + requestDataDownload(params: { fullExport?: boolean }): Promise<{ + requested: boolean; + exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; + }>; + } +} + +export const requestDataDownload = async ({ + userData, + fullExport = false, +}: { + userData: IUser; + fullExport?: boolean; +}): Promise<{ + requested: boolean; + exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; +}> => { + const currentUserData = userData; + + if (!currentUserData) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const userId = currentUserData._id; + + const lastOperation = await ExportOperations.findLastOperationByUser(userId, fullExport); + const requestDay = lastOperation ? lastOperation.createdAt : new Date(); + const pendingOperationsBeforeMyRequestCount = await ExportOperations.countAllPendingBeforeMyRequest(requestDay); + + if (lastOperation) { + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + + if (lastOperation.createdAt > yesterday) { + if (lastOperation.status === 'completed') { + const file = lastOperation.fileId + ? await UserDataFiles.findOneById(lastOperation.fileId) + : await UserDataFiles.findLastFileByUser(userId); + if (file) { + return { + requested: false, + exportOperation: lastOperation, + url: dataExport.getPath(file._id), + pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount, + }; + } + } + + return { + requested: false, + exportOperation: lastOperation, + url: null, + pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount, + }; + } + } + + const tempFolder = settings.get('UserData_FileSystemPath')?.trim() || (await mkdtemp(join(tmpdir(), 'userData'))); + + const exportOperation = { + status: 'preparing', + userId: currentUserData._id, + roomList: undefined, + fileList: [], + generatedFile: undefined, + fullExport, + userData: currentUserData, + userNameTable: {}, + } as unknown as IExportOperation; // @todo yikes! + + const id = await ExportOperations.create(exportOperation); + exportOperation._id = id; + + const folderName = path.join(tempFolder, id); + + const assetsFolder = path.join(folderName, 'assets'); + + exportOperation.exportPath = folderName; + exportOperation.assetsPath = assetsFolder; + exportOperation.status = 'pending'; + + await ExportOperations.updateOperation(exportOperation); + + return { + requested: true, + exportOperation, + url: null, + pendingOperationsBeforeMyRequest: pendingOperationsBeforeMyRequestCount, + }; +}; + +Meteor.methods({ + async requestDataDownload({ fullExport = false }) { + methodDeprecationLogger.method('requestDataDownload', '9.0.0', '/v1/users.requestDataDownload'); + const currentUserData = await Meteor.userAsync(); + + if (!currentUserData) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + return requestDataDownload({ userData: currentUserData as IUser, fullExport }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/requestSubscriptionKeys.ts b/apps/meteor/server/meteor-methods/platform/requestSubscriptionKeys.ts new file mode 100644 index 0000000..0d2e5c5 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/requestSubscriptionKeys.ts @@ -0,0 +1,51 @@ +import { api } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.requestSubscriptionKeys'(): boolean; + } +} + +export const requestSubscriptionKeysMethod = async (userId: string): Promise => { + // Get all encrypted rooms that the user is subscribed to and has no E2E key yet + const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray(); + const roomIds = subscriptions.map((subscription) => subscription.rid); + + // For all subscriptions without E2E key, get the rooms that have encryption enabled + const query = { + e2eKeyId: { + $exists: true, + }, + _id: { + $in: roomIds, + }, + }; + + const rooms = Rooms.find(query); + await rooms.forEach((room) => { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + }); +}; + +Meteor.methods({ + async 'e2e.requestSubscriptionKeys'() { + methodDeprecationLogger.method('e2e.requestSubscriptionKeys', '9.0.0', '/v1/e2e.requestSubscriptionKeys'); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'requestSubscriptionKeys', + }); + } + + await requestSubscriptionKeysMethod(userId); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/resetOwnE2EKey.ts b/apps/meteor/server/meteor-methods/platform/resetOwnE2EKey.ts new file mode 100644 index 0000000..f89e8c6 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/resetOwnE2EKey.ts @@ -0,0 +1,34 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { resetUserE2EEncriptionKey } from '../../lib/resetUserE2EKey'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.resetOwnE2EKey'(): Promise; + } +} + +Meteor.methods({ + 'e2e.resetOwnE2EKey': twoFactorRequired(async () => { + methodDeprecationLogger.method('e2e.resetOwnE2EKey', '9.0.0', '/v1/users.resetE2EKey'); + const userId = Meteor.userId(); + + if (!userId) { + throw new MeteorError('error-invalid-user', 'Invalid user', { + method: 'resetOwnE2EKey', + }); + } + + if (!(await resetUserE2EEncriptionKey(userId, false))) { + throw new MeteorError('failed-reset-e2e-password', 'Failed to reset E2E password', { + method: 'resetOwnE2EKey', + }); + } + return true; + }), +}); diff --git a/apps/meteor/server/meteor-methods/platform/restartServer.ts b/apps/meteor/server/meteor-methods/platform/restartServer.ts new file mode 100644 index 0000000..337131f --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/restartServer.ts @@ -0,0 +1,41 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + restart_server(): { + message: string; + params: [number]; + }; + } +} + +Meteor.methods({ + async restart_server() { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'restart_server' }); + } + + if ((await hasPermissionAsync(uid, 'restart-server')) !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'restart_server' }); + } + + setTimeout(() => { + setTimeout(() => { + console.warn('Call to process.exit() timed out, aborting.'); + process.abort(); + }, 1000); + process.exit(1); + }, 1000); + + return { + message: 'The_server_will_restart_in_s_seconds', + params: [2], + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/saveSettings.ts b/apps/meteor/server/meteor-methods/platform/saveSettings.ts new file mode 100644 index 0000000..550bff4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/saveSettings.ts @@ -0,0 +1,26 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { saveAutoTranslateSettings } from '../../lib/autotranslate/functions/saveSettings'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'autoTranslate.saveSettings'(rid: string, field: string, value: string, options: { defaultLanguage: string }): boolean; + } +} + +Meteor.methods({ + async 'autoTranslate.saveSettings'(rid, field, value, options) { + methodDeprecationLogger.method('autoTranslate.saveSettings', '9.0.0', '/v1/autotranslate.saveSettings'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveAutoTranslateSettings', + }); + } + + return saveAutoTranslateSettings(userId, rid, field, value, options); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/search.ts b/apps/meteor/server/meteor-methods/platform/search.ts new file mode 100644 index 0000000..a951651 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/search.ts @@ -0,0 +1,94 @@ +import type { IMessageSearchProvider, IMessageSearchSuggestion, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { SearchLogger } from '../../lib/search/logger/logger'; +import type { IRawSearchResult, ISearchResult } from '../../lib/search/model/ISearchResult'; +import { searchProviderService, validationService } from '../../lib/search/service'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'rocketchatSearch.getProvider'(): IMessageSearchProvider | undefined; + 'rocketchatSearch.search'(text: string, context: { uid?: IUser['_id']; rid: IRoom['_id'] }, payload: unknown): Promise; + 'rocketchatSearch.suggest'( + text: string, + context: { uid?: IUser['_id']; rid: IRoom['_id'] }, + payload: unknown, + ): Promise; + } +} + +Meteor.methods({ + /** + * Get the current provider with key, description, resultTemplate, suggestionItemTemplate and settings (as Map) + */ + 'rocketchatSearch.getProvider'() { + const provider = searchProviderService.activeProvider; + if (!provider) { + return undefined; + } + + return { + key: provider.key, + description: provider.i18nDescription, + icon: provider.iconName, + resultTemplate: provider.resultTemplate, + supportsSuggestions: provider.supportsSuggestions, + suggestionItemTemplate: provider.suggestionItemTemplate, + settings: Object.fromEntries(Object.values(provider.settingsAsMap).map((setting) => [setting.key, setting.value] as const)), + }; + }, + /** + * Search using the current search provider and check if results are valid for the user. The search result has + * the format `{messages:{start:0,numFound:1,docs:[{...}]},users:{...},rooms:{...}}` + * @param text the search text + * @param context the context (uid, rid) + * @param payload custom payload (e.g. for paging) + */ + async 'rocketchatSearch.search'(text, context, payload) { + payload = payload !== null ? payload : undefined; // TODO is this cleanup necessary? + + if (!searchProviderService.activeProvider) { + throw new Error('Provider currently not active'); + } + + SearchLogger.debug({ msg: 'search', text, context, payload }); + + const userId = Meteor.userId(); + if (!userId) { + throw new Error('User not logged in'); + } + + return new Promise((resolve, reject) => { + void searchProviderService.activeProvider?.search(userId, text, context, payload, (error, data) => { + if (error) { + return reject(error); + } + + return resolve(data); + }); + }).then((result) => validationService.validateSearchResult(result)); + }, + + async 'rocketchatSearch.suggest'(text, context, payload) { + payload ??= undefined; // TODO is this cleanup necessary? + + if (!searchProviderService.activeProvider) { + throw new Error('Provider currently not active'); + } + + SearchLogger.debug({ msg: 'suggest', text, context, payload }); + + return new Promise((resolve, reject) => { + searchProviderService.activeProvider?.suggest(text, context, payload, (error, data) => { + if (error) { + reject(error); + return; + } + + resolve(data); + }); + }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/setRoomKeyID.ts b/apps/meteor/server/meteor-methods/platform/setRoomKeyID.ts new file mode 100644 index 0000000..b44f64d --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/setRoomKeyID.ts @@ -0,0 +1,49 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { notifyOnRoomChanged } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.setRoomKeyID'(rid: IRoom['_id'], keyID: string): void; + } +} + +export const setRoomKeyIDMethod = async (userId: string, rid: IRoom['_id'], keyID: string): Promise => { + if (!(await canAccessRoomIdAsync(rid, userId))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); + } + + const room = await Rooms.setE2eKeyId(rid, keyID); + + if (!room) { + throw new Meteor.Error('error-room-e2e-key-already-exists', 'E2E Key ID already exists', { + method: 'e2e.setRoomKeyID', + }); + } + + void notifyOnRoomChanged(room); +}; + +Meteor.methods({ + async 'e2e.setRoomKeyID'(rid, keyID) { + check(rid, String); + check(keyID, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.setRoomKeyID' }); + } + + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' }); + } + + await setRoomKeyIDMethod(userId, rid, keyID); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/setUserPublicAndPrivateKeys.ts b/apps/meteor/server/meteor-methods/platform/setUserPublicAndPrivateKeys.ts new file mode 100644 index 0000000..56967c9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/setUserPublicAndPrivateKeys.ts @@ -0,0 +1,63 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnRoomChangedById } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'e2e.setUserPublicAndPrivateKeys'({ public_key, private_key }: { public_key: string; private_key: string; force?: boolean }): void; + } +} + +const isKeysResult = (result: any): result is { public_key: string; private_key: string } => { + return result.private_key && result.public_key; +}; + +export const setUserPublicAndPrivateKeysMethod = async ( + userId: string, + keyPair: { public_key: string; private_key: string; force?: boolean }, +): Promise => { + if (!keyPair.force) { + const keys = await Users.fetchKeysByUserId(userId); + + if (isKeysResult(keys)) { + throw new Meteor.Error('error-keys-already-set', 'Keys already set', { + method: 'e2e.setUserPublicAndPrivateKeys', + }); + } + } + + await Users.setE2EPublicAndPrivateKeysByUserId(userId, { + private_key: keyPair.private_key, + public_key: keyPair.public_key, + }); + + const subscribedRoomIds = await Rooms.getSubscribedRoomIdsWithoutE2EKeys(userId); + await Rooms.addUserIdToE2EEQueueByRoomIds(subscribedRoomIds, userId); + + void notifyOnRoomChangedById(subscribedRoomIds); +}; + +Meteor.methods({ + async 'e2e.setUserPublicAndPrivateKeys'(keyPair) { + methodDeprecationLogger.method('e2e.setUserPublicAndPrivateKeys', '9.0.0', '/v1/e2e.setUserPublicAndPrivateKeys'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'e2e.setUserPublicAndPrivateKeys', + }); + } + + if (!keyPair.public_key || !keyPair.private_key) { + throw new Meteor.Error('error-invalid-keys', 'Invalid keys', { + method: 'e2e.setUserPublicAndPrivateKeys', + }); + } + + await setUserPublicAndPrivateKeysMethod(userId, keyPair); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/translateMessage.ts b/apps/meteor/server/meteor-methods/platform/translateMessage.ts new file mode 100644 index 0000000..5d50431 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/translateMessage.ts @@ -0,0 +1,37 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { translateMessage } from '../../lib/autotranslate/functions/translateMessage'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'autoTranslate.translateMessage'(message: IMessage | undefined, targetLanguage: string): Promise; + } +} + +Meteor.methods({ + async 'autoTranslate.translateMessage'(message, targetLanguage) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'autoTranslate.translateMessage', + }); + } + check(message?._id, String); + check(targetLanguage, String); + const msg = await Messages.findOneById(message._id); + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'Message not found'); + } + const room = await Rooms.findOneById(msg.rid); + if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + return translateMessage(targetLanguage, msg); + }, +}); diff --git a/apps/meteor/server/meteor-methods/platform/updateGroupKey.ts b/apps/meteor/server/meteor-methods/platform/updateGroupKey.ts new file mode 100644 index 0000000..cb96088 --- /dev/null +++ b/apps/meteor/server/meteor-methods/platform/updateGroupKey.ts @@ -0,0 +1,34 @@ +import { Subscriptions, Rooms } from '@rocket.chat/models'; + +import { notifyOnSubscriptionChangedById, notifyOnSubscriptionChanged, notifyOnRoomChangedById } from '../../lib/notifyListener'; + +export async function updateGroupKey(rid: string, uid: string, key: string, callerUserId: string) { + // I have a subscription to this room + const mySub = await Subscriptions.findOneByRoomIdAndUserId(rid, callerUserId); + + if (mySub) { + // Setting the key to myself, can set directly to the final field + if (callerUserId === uid) { + const setGroupE2EKeyResponse = await Subscriptions.setGroupE2EKey(mySub._id, key); + // Case: I create an encrypted room before setting up my keys, and I reset the e2e keys + // Next login, I'll create the keys for the room, and set them here. + // However as I reset my keys, I'm on the `usersWaitingForKeys` queue + // So I need to remove myself from the queue and notify the time i reach here + // This way, I can provide the keys to other users + const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(mySub.rid, [callerUserId]); + if (setGroupE2EKeyResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(mySub._id); + } + if (modifiedCount) { + void notifyOnRoomChangedById(mySub.rid); + } + return; + } + + // uid also has subscription to this room + const value = await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); + if (value) { + void notifyOnSubscriptionChanged(value); + } + } +} diff --git a/apps/meteor/server/meteor-methods/rooms/addAllUserToRoom.ts b/apps/meteor/server/meteor-methods/rooms/addAllUserToRoom.ts new file mode 100644 index 0000000..8287681 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addAllUserToRoom.ts @@ -0,0 +1,98 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { beforeAddUserToRoom } from '../../hooks/rooms/beforeAddUserToRoom'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { getDefaultSubscriptionPref } from '../../lib/utils/lib/getDefaultSubscriptionPref'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addAllUserToRoom(rid: IRoom['_id'], activeUsersOnly?: boolean): Promise; + } +} + +export const addAllUserToRoomFn = async (userId: string, rid: IRoom['_id'], activeUsersOnly = false): Promise => { + check(rid, String); + check(activeUsersOnly, Boolean); + + if (!(await hasPermissionAsync(userId, 'add-all-to-room'))) { + throw new Meteor.Error(403, 'Access to Method Forbidden', { + method: 'addAllToRoom', + }); + } + + const userFilter: { + active?: boolean; + } = {}; + if (activeUsersOnly === true) { + userFilter.active = true; + } + + const users = await Users.find(userFilter).toArray(); + if (users.length > settings.get('API_User_Limit')) { + throw new Meteor.Error('error-user-limit-exceeded', 'User Limit Exceeded', { + method: 'addAllToRoom', + }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addAllToRoom', + }); + } + + await beforeAddUserToRoom( + users.map((u) => u.username!), + room, + ); + + const now = new Date(); + for await (const user of users) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + if (subscription != null) { + continue; + } + await callbacks.run('beforeJoinRoom', user, room); + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, user, { + ts: now, + open: true, + alert: true, + unread: 1, + userMentions: 1, + groupMentions: 0, + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(user), + }); + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + await Message.saveSystemMessage('uj', rid, user.username || '', user, { ts: now }); + await callbacks.run('afterJoinRoom', user, room); + } + return true; +}; + +Meteor.methods({ + async addAllUserToRoom(rid, activeUsersOnly = false) { + methodDeprecationLogger.method('addAllUserToRoom', '9.0.0', ['/v1/channels.addAll', '/v1/groups.addAll']); + if (!this.userId) { + throw new Meteor.Error(403, 'Access to Method Forbidden', { + method: 'addAllToRoom', + }); + } + + return addAllUserToRoomFn(this.userId, rid, activeUsersOnly); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/addRoomLeader.ts b/apps/meteor/server/meteor-methods/rooms/addRoomLeader.ts new file mode 100644 index 0000000..baeccab --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addRoomLeader.ts @@ -0,0 +1,103 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addRoomLeader(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const addRoomLeader = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise => { + check(rid, String); + check(userId, String); + + if (!(await hasPermissionAsync(fromUserId, 'set-leader', rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'addRoomLeader', + }); + } + + const user = await Users.findOneById(userId); + + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomLeader', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'addRoomLeader', + }); + } + + if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('leader') === true) { + throw new Meteor.Error('error-user-already-leader', 'User is already a leader', { + method: 'addRoomLeader', + }); + } + + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'leader'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['leader']) || ['leader']); + + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomLeader', + }); + } + + await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'leader' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.addRolesToMember(team._id, userId, ['leader']); + } + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', { + type: 'added', + _id: 'leader', + u: { + _id: user._id, + username: user.username, + }, + scope: rid, + }); + } + + return true; +}; + +Meteor.methods({ + async addRoomLeader(rid, userId) { + methodDeprecationLogger.method('addRoomLeader', '9.0.0', ['/v1/channels.addLeader', '/v1/groups.addLeader']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomLeader', + }); + } + + return addRoomLeader(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/addRoomModerator.ts b/apps/meteor/server/meteor-methods/rooms/addRoomModerator.ts new file mode 100644 index 0000000..31faa72 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addRoomModerator.ts @@ -0,0 +1,125 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../../services/federation/utils'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addRoomModerator(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const addRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addRoomModerator', + }); + } + + const isFederated = isRoomFederated(room); + + if (!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && !isFederated) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'addRoomModerator', + }); + } + + if (isFederated && !isFederationEnabled()) { + throw new FederationMatrixInvalidConfigurationError('unable to change room owners'); + } + + const user = await Users.findOneById(userId); + + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomModerator', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'addRoomModerator', + }); + } + + if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('moderator') === true) { + throw new Meteor.Error('error-user-already-moderator', 'User is already a moderator', { + method: 'addRoomModerator', + }); + } + + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'moderator' }); + + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'moderator'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['moderator']) || ['moderator']); + + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomLeader', + }); + } + + await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'moderator' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.addRolesToMember(team._id, userId, ['moderator']); + } + + const event = { + type: 'added', + _id: 'moderator', + u: { + _id: user._id, + username: user.username, + name: fromUser.name, + }, + scope: rid, + } as const; + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + + return true; +}; + +Meteor.methods({ + async addRoomModerator(rid, userId) { + methodDeprecationLogger.method('addRoomModerator', '9.0.0', ['/v1/channels.addModerator', '/v1/groups.addModerator']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomModerator', + }); + } + + return addRoomModerator(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/addRoomOwner.ts b/apps/meteor/server/meteor-methods/rooms/addRoomOwner.ts new file mode 100644 index 0000000..bbb3fd0 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addRoomOwner.ts @@ -0,0 +1,122 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../../services/federation/utils'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addRoomOwner(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const addRoomOwner = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addRoomOwner', + }); + } + + const isFederated = isRoomFederated(room); + + if (!(await hasPermissionAsync(fromUserId, 'set-owner', rid)) && !isFederated) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'addRoomOwner', + }); + } + + if (isFederated && !isFederationEnabled()) { + throw new FederationMatrixInvalidConfigurationError('unable to change room owners'); + } + + const user = await Users.findOneById(userId); + + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomOwner', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'addRoomOwner', + }); + } + + if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('owner') === true) { + throw new Meteor.Error('error-user-already-owner', 'User is already an owner', { + method: 'addRoomOwner', + }); + } + + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'owner' }); + + const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'owner'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['owner']) || ['owner']); + + if (addRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomLeader', + }); + } + + await Message.saveSystemMessage('subscription-role-added', rid, user.username, fromUser, { role: 'owner' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.addRolesToMember(team._id, userId, ['owner']); + } + const event = { + type: 'added', + _id: 'owner', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + } as const; + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + + return true; +}; + +Meteor.methods({ + async addRoomOwner(rid, userId) { + methodDeprecationLogger.method('addRoomOwner', '9.0.0', ['/v1/channels.addOwner', '/v1/groups.addOwner']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addRoomOwner', + }); + } + + return addRoomOwner(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/addUserToRoom.ts b/apps/meteor/server/meteor-methods/rooms/addUserToRoom.ts new file mode 100644 index 0000000..535f8ef --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addUserToRoom.ts @@ -0,0 +1,29 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { addUsersToRoomMethod } from './addUsersToRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addUserToRoom(data: { rid: string; username: string }): void; + } +} + +Meteor.methods({ + async addUserToRoom(data) { + methodDeprecationLogger.method('addUserToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addUserToRoom', + }); + } + + await addUsersToRoomMethod(userId, { + rid: data.rid, + users: [data.username], + }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/addUsersToRoom.ts b/apps/meteor/server/meteor-methods/rooms/addUsersToRoom.ts new file mode 100644 index 0000000..5447d1d --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/addUsersToRoom.ts @@ -0,0 +1,137 @@ +import { api } from '@rocket.chat/core-services'; +import { isBannedSubscription, isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { beforeAddUsersToRoom } from '../../lib/callbacks/beforeAddUserToRoom'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { i18n } from '../../lib/i18n'; +import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + addUsersToRoom(data: { rid: string; users: string[] }): boolean; + } +} + +export const sanitizeUsername = (username: string) => { + const isFederatedUsername = username.includes('@') && username.includes(':'); + if (isFederatedUsername) { + return username; + } + + return username.replace(/(^@)|( @)/, ''); +}; + +export const addUsersToRoomMethod = async (userId: string, data: { rid: string; users: string[] }, user?: IUser): Promise => { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addUsersToRoom', + }); + } + + if (!Match.test(data.rid, String)) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addUsersToRoom', + }); + } + + // Get user and room details + const room = await Rooms.findOneById(data.rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addUsersToRoom', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, userId, { + projection: { _id: 1 }, + }); + const userInRoom = subscription != null; + + if (room.t === 'd' && !isRoomNativeFederated(room)) { + throw new Meteor.Error('error-cant-invite-for-direct-room', "Can't invite user to direct rooms", { + method: 'addUsersToRoom', + }); + } + + // Can add to any room you're in, with permission, otherwise need specific room type permission + let canAddUser = false; + if (userInRoom && (await hasPermissionAsync(userId, 'add-user-to-joined-room', room._id))) { + canAddUser = true; + } else if (room.t === 'c' && (await hasPermissionAsync(userId, 'add-user-to-any-c-room'))) { + canAddUser = true; + } else if (room.t === 'p' && (await hasPermissionAsync(userId, 'add-user-to-any-p-room'))) { + canAddUser = true; + } + + // Adding wasn't allowed + if (!canAddUser) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'addUsersToRoom', + }); + } + + // Missing the users to be added + if (!Array.isArray(data.users)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { + method: 'addUsersToRoom', + }); + } + + await beforeAddUsersToRoom.run({ usernames: data.users, inviter: user }, room); + + await Promise.all( + data.users.map(async (username) => { + const sanitizedUsername = sanitizeUsername(username); + + const newUser = await Users.findOneByUsernameIgnoringCase(sanitizedUsername); + if (!newUser) { + throw new Meteor.Error('error-user-not-found', 'User not found', { + method: 'addUsersToRoom', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); + if (subscription && isBannedSubscription(subscription)) { + throw new Meteor.Error('error-user-is-banned', 'User is banned from this room', { + method: 'addUsersToRoom', + }); + } + if (!subscription) { + return addUserToRoom(data.rid, newUser, user); + } + if (!newUser.username) { + return; + } + void api.broadcast('notify.ephemeralMessage', userId, data.rid, { + msg: i18n.t('Username_is_already_in_here', { + interpolation: { escapeValue: false }, + username: newUser.username, + lng: user?.language, + }), + }); + }), + ); + + return true; +}; + +Meteor.methods({ + async addUsersToRoom(data) { + methodDeprecationLogger.method('addUsersToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']); + const uid = Meteor.userId(); + // Validate user and room + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'addUsersToRoom', + }); + } + + return addUsersToRoomMethod(uid, data, ((await Meteor.userAsync()) as IUser | null) ?? undefined); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/archiveRoom.ts b/apps/meteor/server/meteor-methods/rooms/archiveRoom.ts new file mode 100644 index 0000000..041d6a6 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/archiveRoom.ts @@ -0,0 +1,54 @@ +import { isRegisterUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { archiveRoom } from '../../lib/rooms/archiveRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + archiveRoom(rid: string): Promise; + } +} + +export const executeArchiveRoom = async (userId: string, rid: string) => { + check(rid, String); + + const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!user || !isRegisterUser(user)) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'archiveRoom' }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) { + throw new Meteor.Error('error-direct-message-room', `rooms type: ${room.t} can not be archived`, { method: 'archiveRoom' }); + } + + if (!(await hasPermissionAsync(userId, 'archive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'archiveRoom' }); + } + + return archiveRoom(rid, user); +}; + +Meteor.methods({ + async archiveRoom(rid) { + methodDeprecationLogger.method('archiveRoom', '9.0.0', '/v1/channels.archive'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + return executeArchiveRoom(userId, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/browseChannels.ts b/apps/meteor/server/meteor-methods/rooms/browseChannels.ts new file mode 100644 index 0000000..696bce3 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/browseChannels.ts @@ -0,0 +1,365 @@ +import { Team } from '@rocket.chat/core-services'; +import type { IUser, AtLeast } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; +import { escapeRegExp, isTruthy } from '@rocket.chat/tools'; +import mem from 'mem'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions, SortDirection } from 'mongodb'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { settings } from '../../settings'; + +const sortChannels = (field: string, direction: 'asc' | 'desc'): Record => { + switch (field) { + case 'createdAt': + return { + ts: direction === 'asc' ? 1 : -1, + }; + case 'lastMessage': + return { + 'lastMessage.ts': direction === 'asc' ? 1 : -1, + }; + default: + return { + [field]: direction === 'asc' ? 1 : -1, + }; + } +}; + +const sortUsers = (field: string, direction: 'asc' | 'desc'): Record => { + switch (field) { + case 'email': + return { + 'emails.address': direction === 'asc' ? 1 : -1, + 'username': direction === 'asc' ? 1 : -1, + }; + default: + return { + [field]: direction === 'asc' ? 1 : -1, + }; + } +}; + +const getChannelsAndGroups = async ( + user: AtLeast, + canViewAnon: boolean, + searchTerm: string, + sort: Record, + pagination: { + skip: number; + limit: number; + }, +) => { + if ((!user && !canViewAnon) || (user && !(await hasPermissionAsync(user, 'view-c-room')))) { + return; + } + + const teams = await Team.getAllPublicTeams(); + const publicTeamIds = teams.map(({ _id }) => _id); + + const userTeamsIds = (await Team.listTeamsBySubscriberUserId(user._id, { projection: { teamId: 1 } }))?.map(({ teamId }) => teamId) || []; + const userRooms = user.__rooms ?? []; + + const { cursor, totalCount } = Rooms.findPaginatedByNameOrFNameAndRoomIdsIncludingTeamRooms( + searchTerm ? new RegExp(searchTerm, 'i') : null, + [...userTeamsIds, ...publicTeamIds], + userRooms, + { + ...pagination, + sort: { + featured: -1, + ...sort, + }, + projection: { + t: 1, + description: 1, + topic: 1, + name: 1, + fname: 1, + lastMessage: 1, + ts: 1, + archived: 1, + default: 1, + featured: 1, + usersCount: 1, + prid: 1, + teamId: 1, + federated: 1, + }, + }, + ); + + const [result, total] = await Promise.all([cursor.toArray(), totalCount]); + + const teamIds = result.map(({ teamId }) => teamId).filter(isTruthy); + const teamsMains = await Team.listByIds([...new Set(teamIds)], { projection: { _id: 1, name: 1 } }); + const teamsById = new Map(teamsMains.map((team) => [team._id, team])); + + const results = result.map((room) => { + if (room.teamId) { + const team = teamsById.get(room.teamId); + if (team) { + return { ...room, belongsTo: team.name }; + } + } + return room; + }); + + return { + total, + results, + }; +}; + +const getChannelsCountForTeam = mem((teamId) => Rooms.countByTeamId(teamId), { + maxAge: 2000, +}); + +const getTeams = async ( + user: AtLeast, + searchTerm: string, + sort: Record, + pagination: { + skip: number; + limit: number; + }, +) => { + if (!user) { + return; + } + + const userSubs = await Subscriptions.findByUserId(user._id).toArray(); + const ids = userSubs.map((sub) => sub.rid); + const { cursor, totalCount } = Rooms.findPaginatedContainingNameOrFNameInIdsAsTeamMain( + searchTerm ? new RegExp(searchTerm, 'i') : null, + ids, + { + ...pagination, + sort: { + featured: -1, + ...sort, + }, + projection: { + t: 1, + description: 1, + topic: 1, + name: 1, + fname: 1, + lastMessage: 1, + ts: 1, + archived: 1, + default: 1, + featured: 1, + usersCount: 1, + prid: 1, + teamId: 1, + teamMain: 1, + }, + }, + ); + const results = await Promise.all( + (await cursor.toArray()).map(async (room) => ({ + ...room, + roomsCount: await getChannelsCountForTeam(room.teamId), + })), + ); + + return { + total: await totalCount, + results, + }; +}; + +const findUsers = async ({ + text, + sort, + pagination, + workspace, + viewFullOtherUserInfo, +}: { + text: string; + sort: Record; + pagination: { + skip: number; + limit: number; + }; + workspace: string; + viewFullOtherUserInfo: boolean; +}) => { + const searchFields = + workspace === 'all' ? ['username', 'name', 'emails.address'] : settings.get('Accounts_SearchFields').trim().split(','); + + const options: FindOptions = { + ...pagination, + sort, + projection: { + username: 1, + name: 1, + nickname: 1, + bio: 1, + createdAt: 1, + ...(viewFullOtherUserInfo && { emails: 1 }), + federation: 1, + avatarETag: 1, + }, + }; + + type FederatedUser = + | IUser + | { + _id?: string; + username?: string; + name?: string; + bio?: string; + nickname?: string; + emails?: string; + federation?: unknown; + isRemote: true; + }; + + if (workspace === 'all') { + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(text, [], options, searchFields); + const [results, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + total, + results, + }; + } + + if (workspace === 'external') { + const { cursor, totalCount } = Users.findPaginatedByActiveExternalUsersExcept(text, [], options, searchFields); + const [results, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + total, + results, + }; + } + + const { cursor, totalCount } = Users.findPaginatedByActiveLocalUsersExcept(text, [], options, searchFields); + const [results, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + total, + results, + }; +}; + +const getUsers = async ( + user: AtLeast | undefined, + text: string, + workspace: string, + sort: Record, + pagination: { + skip: number; + limit: number; + }, +) => { + if (!user || !(await hasPermissionAsync(user, 'view-outside-room')) || !(await hasPermissionAsync(user, 'view-d-room'))) { + return; + } + + const viewFullOtherUserInfo = await hasPermissionAsync(user, 'view-full-other-user-info'); + + const { total, results } = await findUsers({ text, sort, pagination, workspace, viewFullOtherUserInfo }); + + return { + total, + results, + }; +}; + +type BrowseChannelsParams = { + text?: string; + workspace?: string; + type?: 'channels' | 'users' | 'teams' | string; + sortBy?: 'name' | 'createdAt' | 'usersCount' | 'lastMessage' | 'usernames' | string; + sortDirection?: 'asc' | 'desc'; + page?: number; + offset?: number; + limit?: number; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + browseChannels: (params: BrowseChannelsParams) => Promise; + } +} + +export const browseChannelsMethod = async ( + { + text = '', + workspace = '', + type = 'channels', + sortBy = 'name', + sortDirection = 'asc', + page = 0, + offset = 0, + limit = 10, + }: BrowseChannelsParams, + user: AtLeast | undefined | null, +) => { + const searchTerm = trim(escapeRegExp(text)); + + if ( + !['channels', 'users', 'teams'].includes(type) || + !['asc', 'desc'].includes(sortDirection) || + (!page && page !== 0 && !offset && offset !== 0) + ) { + return; + } + + const roomParams = ['channels', 'teams'].includes(type) ? ['usernames', 'lastMessage'] : []; + const userParams = type === 'users' ? ['username', 'email', 'bio'] : []; + + if (!['name', 'createdAt', 'usersCount', ...roomParams, ...userParams].includes(sortBy)) { + return; + } + + const skip = Math.max(0, offset || (page > -1 ? limit * page : 0)); + + limit = limit > 0 ? limit : 10; + + const pagination = { + skip, + limit, + }; + + const canViewAnonymous = !!settings.get('Accounts_AllowAnonymousRead'); + + if (!user) { + return; + } + + switch (type) { + case 'channels': + return getChannelsAndGroups(user, canViewAnonymous, searchTerm, sortChannels(sortBy, sortDirection), pagination); + case 'teams': + return getTeams(user, searchTerm, sortChannels(sortBy, sortDirection), pagination); + case 'users': + return getUsers(user, text, workspace, sortUsers(sortBy, sortDirection), pagination); + default: + } +}; + +Meteor.methods({ + async browseChannels(params: BrowseChannelsParams) { + methodDeprecationLogger.method('browseChannels', '9.0.0', '/v1/directory'); + return browseChannelsMethod(params, await Meteor.userAsync()); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'browseChannels', + userId(/* userId*/) { + return true; + }, + }, + 100, + 100000, +); diff --git a/apps/meteor/server/meteor-methods/rooms/channelsList.ts b/apps/meteor/server/meteor-methods/rooms/channelsList.ts new file mode 100644 index 0000000..c71a12d --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/channelsList.ts @@ -0,0 +1,117 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions } from 'mongodb'; +import _ from 'underscore'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + channelsList(filter: string, channelType: string, limit?: number, sort?: string): { channels: IRoom[] }; + } +} + +Meteor.methods({ + async channelsList(filter, channelType, limit, sort) { + methodDeprecationLogger.method('channelsList', '9.0.0', '/v1/channels.list'); + check(filter, String); + check(channelType, String); + check(limit, Match.Optional(Number)); + check(sort, Match.Optional(String)); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'channelsList', + }); + } + + const options: FindOptions = { + projection: { + name: 1, + t: 1, + }, + sort: { + msgs: -1, + }, + }; + + if (_.isNumber(limit)) { + options.limit = limit; + } + + if (trim(sort)) { + switch (sort) { + case 'name': + options.sort = { + name: 1, + }; + break; + case 'msgs': + options.sort = { + msgs: -1, + }; + } + } + + let channels: IRoom[] = []; + + if (channelType !== 'private') { + if (await hasPermissionAsync(userId, 'view-c-room')) { + if (filter) { + channels = channels.concat(await Rooms.findByTypeAndNameContaining('c', filter, options).toArray()); + } else { + channels = channels.concat(await Rooms.findByType('c', options).toArray()); + } + } else if (await hasPermissionAsync(userId, 'view-joined-room')) { + const roomIds = (await Subscriptions.findByTypeAndUserId('c', userId, { projection: { rid: 1 } }).toArray()).map((s) => s.rid); + if (filter) { + channels = channels.concat(await Rooms.findByTypeInIdsAndNameContaining('c', roomIds, filter, options).toArray()); + } else { + channels = channels.concat(await Rooms.findByTypeInIds('c', roomIds, options).toArray()); + } + } + } + + if (channelType !== 'public' && (await hasPermissionAsync(userId, 'view-p-room'))) { + const user = await Users.findOne(userId, { + projection: { + 'username': 1, + 'settings.preferences.sidebarGroupByType': 1, + }, + }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'channelsList', + }); + } + + const userPref = await getUserPreference(user, 'sidebarGroupByType'); + // needs to negate globalPref because userPref represents its opposite + const groupByType = userPref !== undefined ? userPref : settings.get('UI_Group_Channels_By_Type'); + + if (!groupByType) { + const roomIds = (await Subscriptions.findByTypeAndUserId('p', userId, { projection: { rid: 1 } }).toArray()).map((s) => s.rid); + if (filter) { + channels = channels.concat(await Rooms.findByTypeInIdsAndNameContaining('p', roomIds, filter, options).toArray()); + } else { + channels = channels.concat(await Rooms.findByTypeInIds('p', roomIds, options).toArray()); + } + } + } + + return { + channels, + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/cleanRoomHistory.ts b/apps/meteor/server/meteor-methods/rooms/cleanRoomHistory.ts new file mode 100644 index 0000000..161ace3 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/cleanRoomHistory.ts @@ -0,0 +1,110 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { findRoomByIdOrName } from '../../api/v1/rooms'; +import { canAccessRoomAsync } from '../../lib/authorization'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { cleanRoomHistory } from '../../lib/rooms/cleanRoomHistory'; + +type CleanRoomHistoryParams = { + roomId: string; + latest: Date; + oldest: Date; + inclusive?: boolean; + limit?: number; + excludePinned?: boolean; + ignoreDiscussion?: boolean; + filesOnly?: boolean; + fromUsers?: string[]; + ignoreThreads?: boolean; +}; +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + cleanRoomHistory(data: CleanRoomHistoryParams): number; + } +} + +export const cleanRoomHistoryMethod = async ( + userId: string, + { + roomId, + latest, + oldest, + inclusive = true, + limit, + excludePinned = false, + ignoreDiscussion = true, + filesOnly = false, + fromUsers = [], + ignoreThreads, + }: CleanRoomHistoryParams, +): Promise => { + if (!(await hasPermissionAsync(userId, 'clean-channel-history', roomId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' }); + } + + const room = await findRoomByIdOrName({ params: { roomId } }); + + if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' }); + } + + return cleanRoomHistory({ + rid: roomId, + latest, + oldest, + inclusive, + limit, + excludePinned, + ignoreDiscussion, + filesOnly, + fromUsers, + ignoreThreads, + }); +}; + +Meteor.methods({ + async cleanRoomHistory({ + roomId, + latest, + oldest, + inclusive = true, + limit, + excludePinned = false, + ignoreDiscussion = true, + filesOnly = false, + fromUsers = [], + ignoreThreads, + }) { + check(roomId, String); + check(latest, Date); + check(oldest, Date); + check(inclusive, Boolean); + check(limit, Match.Maybe(Number)); + check(excludePinned, Match.Maybe(Boolean)); + check(filesOnly, Match.Maybe(Boolean)); + check(ignoreThreads, Match.Maybe(Boolean)); + check(fromUsers, Match.Maybe([String])); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cleanRoomHistory' }); + } + + return cleanRoomHistoryMethod(userId, { + roomId, + latest, + oldest, + inclusive, + limit, + excludePinned, + ignoreDiscussion, + filesOnly, + fromUsers, + ignoreThreads, + }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/createChannel.ts b/apps/meteor/server/meteor-methods/rooms/createChannel.ts new file mode 100644 index 0000000..8936dbc --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/createChannel.ts @@ -0,0 +1,73 @@ +import type { ICreatedRoom, ITeam } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users, Team } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { createRoom } from '../../lib/rooms/createRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + createChannel( + name: string, + members: string[], + readOnly?: boolean, + customFields?: Record, + extraData?: Record, + ): ICreatedRoom; + } +} + +export const createChannelMethod = async ( + userId: string, + name: string, + members: string[], + readOnly = false, + customFields?: Record, + extraData: Record = {}, + excludeSelf = false, +) => { + check(name, String); + check(members, Match.Optional([String])); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); + } + + const user = await Users.findOneById(userId, { projection: { services: 0 } }); + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); + } + + if (extraData.teamId) { + const team = await Team.findOneById>(extraData.teamId, { projection: { roomId: 1 } }); + if (!team) { + throw new Meteor.Error('error-team-not-found', 'The "teamId" param provided does not match any team', { method: 'createChannel' }); + } + if (!(await hasPermissionAsync(userId, 'create-team-channel', team.roomId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' }); + } + } else if (!(await hasPermissionAsync(userId, 'create-c'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' }); + } + + return createRoom('c', name, user, members, excludeSelf, readOnly, { + ...(customFields && Object.keys(customFields).length && { customFields }), + ...extraData, + }); +}; + +Meteor.methods({ + async createChannel(name, members, readOnly = false, customFields = {}, extraData = {}) { + methodDeprecationLogger.method('createChannel', '9.0.0', '/v1/channels.create'); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); + } + + return createChannelMethod(uid, name, members, readOnly, customFields, extraData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/createPrivateGroup.ts b/apps/meteor/server/meteor-methods/rooms/createPrivateGroup.ts new file mode 100644 index 0000000..2cec220 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/createPrivateGroup.ts @@ -0,0 +1,78 @@ +import type { ICreatedRoom, IUser, ITeam } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users, Team } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { createRoom } from '../../lib/rooms/createRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + createPrivateGroup( + name: string, + members: string[], + readOnly?: boolean, + customFields?: Record, + extraData?: Record, + ): ICreatedRoom; + } +} + +export const createPrivateGroupMethod = async ( + user: IUser, + name: string, + members: string[], + readOnly = false, + customFields?: Record, + extraData: Record = {}, + excludeSelf = false, +): Promise< + ICreatedRoom & { + rid: string; + } +> => { + check(name, String); + check(members, Match.Optional([String])); + + if (extraData.teamId) { + const team = await Team.findOneById>(extraData.teamId, { projection: { roomId: 1 } }); + if (!team) { + throw new Meteor.Error('error-team-not-found', 'The "teamId" param provided does not match any team', { + method: 'createPrivateGroup', + }); + } + if (!(await hasPermissionAsync(user, 'create-team-group', team.roomId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' }); + } + } else if (!(await hasPermissionAsync(user, 'create-p'))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' }); + } + + return createRoom('p', name, user, members, excludeSelf, readOnly, { + ...(customFields && Object.keys(customFields).length && { customFields }), + ...extraData, + }); +}; + +Meteor.methods({ + async createPrivateGroup(name, members, readOnly = false, customFields = {}, extraData = {}) { + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createPrivateGroup', + }); + } + + const user = await Users.findOneById(uid, { projection: { services: 0 } }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createPrivateGroup', + }); + } + + return createPrivateGroupMethod(user, name, members, readOnly, customFields, extraData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/getRoomById.ts b/apps/meteor/server/meteor-methods/rooms/getRoomById.ts new file mode 100644 index 0000000..72cd1e4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getRoomById.ts @@ -0,0 +1,52 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getRoomById(rid: IRoom['_id']): IRoom; + } +} + +Meteor.methods({ + async getRoomById(rid) { + check(rid, String); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getRoomNameById', + }); + } + + const room = await Rooms.findOneById(rid); + if (room == null) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomNameById', + }); + } + if (!(await canAccessRoomAsync(room, (await Meteor.userAsync()) as IUser))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomById', + }); + } + return room; + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'getRoomById', + userId() { + return true; + }, + }, + 10, + 60000, +); diff --git a/apps/meteor/server/meteor-methods/rooms/getRoomIdByNameOrId.ts b/apps/meteor/server/meteor-methods/rooms/getRoomIdByNameOrId.ts new file mode 100644 index 0000000..4f2143d --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getRoomIdByNameOrId.ts @@ -0,0 +1,44 @@ +// DEPRECATE +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getRoomIdByNameOrId(rid: string): string; + } +} + +Meteor.methods({ + async getRoomIdByNameOrId(rid) { + methodDeprecationLogger.method('getRoomIdByNameOrId', '9.0.0', '/v1/rooms.info'); + check(rid, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getRoomIdByNameOrId', + }); + } + + const room = (await Rooms.findOneById(rid)) || (await Rooms.findOneByName(rid)); + + if (room == null) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomIdByNameOrId', + }); + } + + if (!(await canAccessRoomAsync(room, (await Meteor.userAsync()) ?? undefined))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomIdByNameOrId', + }); + } + + return room._id; + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/getRoomJoinCode.ts b/apps/meteor/server/meteor-methods/rooms/getRoomJoinCode.ts new file mode 100644 index 0000000..a136052 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getRoomJoinCode.ts @@ -0,0 +1,38 @@ +import { isRoomWithJoinCode } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getRoomJoinCode(rid: string): string | false; + } +} + +Meteor.methods({ + async getRoomJoinCode(rid) { + methodDeprecationLogger.method('getRoomJoinCode', '9.0.0', []); + + check(rid, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getJoinCode' }); + } + + if (!(await hasPermissionAsync(userId, 'view-join-code'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'getJoinCode' }); + } + + const room = await Rooms.findById(rid); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return Boolean(room) && (isRoomWithJoinCode(room!) ? room.joinCode : false); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/getRoomNameById.ts b/apps/meteor/server/meteor-methods/rooms/getRoomNameById.ts new file mode 100644 index 0000000..b374b5d --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getRoomNameById.ts @@ -0,0 +1,51 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getRoomNameById(rid: IRoom['_id']): Promise; + } +} + +Meteor.methods({ + async getRoomNameById(rid) { + methodDeprecationLogger.method('getRoomNameById', '9.0.0', '/v1/rooms.info'); + check(rid, String); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getRoomNameById', + }); + } + + const room = await Rooms.findOneById(rid); + + if (room == null) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomNameById', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { + projection: { _id: 1 }, + }); + if (subscription) { + return room.name; + } + + if (room.t !== 'c' || (await hasPermissionAsync(userId, 'view-c-room')) !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'getRoomNameById', + }); + } + + return room.name; + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/getRoomRoles.ts b/apps/meteor/server/meteor-methods/rooms/getRoomRoles.ts new file mode 100644 index 0000000..088068c --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getRoomRoles.ts @@ -0,0 +1,35 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import type { RoomRoles } from '../../lib/roles/getRoomRoles'; +import { getRoomRoles } from '../../lib/roles/getRoomRoles'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getRoomRoles(rid: IRoom['_id']): RoomRoles[]; + } +} + +export const executeGetRoomRoles = async (rid: IRoom['_id'], fromUser?: IUser | null) => { + check(rid, String); + + if (!fromUser && settings.get('Accounts_AllowAnonymousRead') === false) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getRoomRoles' }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getRoomRoles' }); + } + + if (fromUser && !(await canAccessRoomAsync(room, fromUser))) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getRoomRoles' }); + } + + return getRoomRoles(rid); +}; diff --git a/apps/meteor/server/meteor-methods/rooms/getTotalChannels.ts b/apps/meteor/server/meteor-methods/rooms/getTotalChannels.ts new file mode 100644 index 0000000..393c78c --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/getTotalChannels.ts @@ -0,0 +1,25 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getTotalChannels(): number; + } +} + +Meteor.methods({ + getTotalChannels() { + methodDeprecationLogger.method('getTotalChannels', '9.0.0', '/v1/channels.list'); + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getTotalChannels', + }); + } + + return Rooms.countDocuments({ t: 'c' }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/hideRoom.ts b/apps/meteor/server/meteor-methods/rooms/hideRoom.ts new file mode 100644 index 0000000..dc7d8b5 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/hideRoom.ts @@ -0,0 +1,47 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + hideRoom(rid: string): Promise; + } +} + +export const hideRoomMethod = async (userId: string, rid: string): Promise => { + check(rid, String); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'hideRoom', + }); + } + + const { modifiedCount } = await Subscriptions.hideByRoomIdAndUserId(rid, userId); + + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + } + + return modifiedCount; +}; + +Meteor.methods({ + async hideRoom(rid) { + methodDeprecationLogger.method('hideRoom', '9.0.0', '/v1/rooms.hide'); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'hideRoom', + }); + } + + return hideRoomMethod(uid, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/joinDefaultChannels.ts b/apps/meteor/server/meteor-methods/rooms/joinDefaultChannels.ts new file mode 100644 index 0000000..f46e370 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/joinDefaultChannels.ts @@ -0,0 +1,27 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { addUserToDefaultChannels } from '../../lib/rooms/addUserToDefaultChannels'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + joinDefaultChannels(silenced?: boolean): void; + } +} + +Meteor.methods({ + async joinDefaultChannels(silenced) { + check(silenced, Match.Optional(Boolean)); + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'joinDefaultChannels', + }); + } + return addUserToDefaultChannels(user as IUser, Boolean(silenced)); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/joinRoom.ts b/apps/meteor/server/meteor-methods/rooms/joinRoom.ts new file mode 100644 index 0000000..412d781 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/joinRoom.ts @@ -0,0 +1,34 @@ +import { Room } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + joinRoom(rid: IRoom['_id'], code?: string): boolean | undefined; + } +} + +Meteor.methods({ + async joinRoom(rid, code) { + methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/rooms.join'); + check(rid, String); + + const user = await Meteor.userAsync(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'joinRoom' }); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'joinRoom' }); + } + + return Room.join({ room, user, ...(code ? { joinCode: code } : {}) }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/leaveRoom.ts b/apps/meteor/server/meteor-methods/rooms/leaveRoom.ts new file mode 100644 index 0000000..fd35730 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/leaveRoom.ts @@ -0,0 +1,77 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Roles, Subscriptions, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { hasRoleAsync } from '../../lib/authorization/hasRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { removeUserFromRoom } from '../../lib/rooms/removeUserFromRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + leaveRoom(rid: string): Promise; + } +} + +export const leaveRoomMethod = async (user: IUser, rid: string): Promise => { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'leaveRoom' }); + } + + if (!user || !(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.LEAVE, user._id))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'leaveRoom' }); + } + + if ( + (room.t === 'c' && !(await hasPermissionAsync(user, 'leave-c'))) || + (room.t === 'p' && !(await hasPermissionAsync(user, 'leave-p'))) + ) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'leaveRoom' }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { + projection: { _id: 1 }, + }); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'You are not in this room', { + method: 'leaveRoom', + }); + } + + // If user is room owner, check if there are other owners. If there isn't anyone else, warn user to set a new owner. + if (await hasRoleAsync(user._id, 'owner', room._id)) { + const numOwners = await Roles.countUsersInRole('owner', room._id); + if (numOwners === 1) { + throw new Meteor.Error('error-you-are-last-owner', 'You are the last owner. Please set new owner before leaving the room.', { + method: 'leaveRoom', + }); + } + } + + return removeUserFromRoom(rid, user); +}; + +Meteor.methods({ + async leaveRoom(rid) { + methodDeprecationLogger.method('leaveRoom', '9.0.0', ['/v1/channels.leave', '/v1/groups.leave', '/v1/im.leave']); + check(rid, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'leaveRoom' }); + } + + const user = (await Meteor.userAsync()) as unknown as IUser; + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'leaveRoom' }); + } + + return leaveRoomMethod(user, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/muteUserInRoom.ts b/apps/meteor/server/meteor-methods/rooms/muteUserInRoom.ts new file mode 100644 index 0000000..926d5cf --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/muteUserInRoom.ts @@ -0,0 +1,80 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { notifyOnRoomChangedById } from '../../lib/notifyListener'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + muteUserInRoom(data: { rid: IRoom['_id']; username: string }): boolean; + } +} + +export const muteUserInRoom = async (fromId: string, data: { rid: IRoom['_id']; username: string }): Promise => { + if (!fromId || !(await hasPermissionAsync(fromId, 'mute-user', data.rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'muteUserInRoom', + }); + } + + const room = await Rooms.findOneById(data.rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'muteUserInRoom', + }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.MUTE, fromId))) { + throw new Meteor.Error('error-invalid-room-type', `${room.t} is not a valid room type`, { + method: 'muteUserInRoom', + type: room.t, + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUsername(data.rid, data.username, { + projection: { _id: 1 }, + }); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'muteUserInRoom', + }); + } + + const mutedUser = await Users.findOneByUsernameIgnoringCase(data.username); + + if (!mutedUser?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user to mute', { + method: 'muteUserInRoom', + }); + } + + const fromUser = await Users.findOneById(fromId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'muteUserInRoom', + }); + } + + await callbacks.run('beforeMuteUser', { mutedUser, fromUser }, room); + + if (room.ro) { + await Rooms.muteReadOnlyUsernameByRoomId(data.rid, mutedUser.username); + } else { + await Rooms.muteUsernameByRoomId(data.rid, mutedUser.username); + } + + await Message.saveSystemMessage('user-muted', data.rid, mutedUser.username, fromUser); + + await callbacks.run('afterMuteUser', { mutedUser, fromUser }, room); + + void notifyOnRoomChangedById(data.rid); + + return true; +}; diff --git a/apps/meteor/server/meteor-methods/rooms/openRoom.ts b/apps/meteor/server/meteor-methods/rooms/openRoom.ts new file mode 100644 index 0000000..c1995ce --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/openRoom.ts @@ -0,0 +1,29 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { openRoom } from '../../lib/openRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + openRoom(rid: IRoom['_id']): Promise; + } +} + +Meteor.methods({ + async openRoom(rid) { + check(rid, String); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'openRoom', + }); + } + + return openRoom(uid, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/removeRoomLeader.ts b/apps/meteor/server/meteor-methods/rooms/removeRoomLeader.ts new file mode 100644 index 0000000..eea33a9 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/removeRoomLeader.ts @@ -0,0 +1,103 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeRoomLeader(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const removeRoomLeader = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise => { + check(rid, String); + check(userId, String); + + if (!(await hasPermissionAsync(fromUserId, 'set-leader', rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeRoomLeader', + }); + } + + const user = await Users.findOneById(userId); + + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomLeader', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeRoomLeader', + }); + } + + if (subscription.roles && Array.isArray(subscription.roles) === true && subscription.roles.includes('leader') === false) { + throw new Meteor.Error('error-user-not-leader', 'User is not a leader', { + method: 'removeRoomLeader', + }); + } + + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'leader'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'leader') || []); + + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomLeader', + }); + } + + await Message.saveSystemMessage('subscription-role-removed', rid, user.username, fromUser, { role: 'leader' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.removeRolesFromMember(team._id, userId, ['leader']); + } + + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', { + type: 'removed', + _id: 'leader', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + }); + } + + return true; +}; + +Meteor.methods({ + async removeRoomLeader(rid, userId) { + methodDeprecationLogger.method('removeRoomLeader', '9.0.0', ['/v1/channels.removeLeader', '/v1/groups.removeLeader']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomLeader', + }); + } + + return removeRoomLeader(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/removeRoomModerator.ts b/apps/meteor/server/meteor-methods/rooms/removeRoomModerator.ts new file mode 100644 index 0000000..c622e48 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/removeRoomModerator.ts @@ -0,0 +1,116 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeRoomModerator(rid: IRoom['_id'], userId: IUser['_id']): boolean; + } +} + +export const removeRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom['_id'], userId: IUser['_id']): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'removeRoomModerator', + }); + } + + if (!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && !isRoomFederated(room)) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeRoomModerator', + }); + } + + const user = await Users.findOneById(userId); + + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomModerator', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'removeRoomModerator', + }); + } + + if (subscription.roles && (!Array.isArray(subscription.roles) || !subscription.roles.includes('moderator'))) { + throw new Meteor.Error('error-user-not-moderator', 'User is not a moderator', { + method: 'removeRoomModerator', + }); + } + + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'user' }); + + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'moderator'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'moderator') || []); + + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomModerator', + }); + } + + await Message.saveSystemMessage('subscription-role-removed', rid, user.username, fromUser, { role: 'moderator' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.removeRolesFromMember(team._id, userId, ['moderator']); + } + + const event = { + type: 'removed', + _id: 'moderator', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + } as const; + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + + return true; +}; + +Meteor.methods({ + async removeRoomModerator(rid, userId) { + methodDeprecationLogger.method('removeRoomModerator', '9.0.0', ['/v1/channels.removeModerator', '/v1/groups.removeModerator']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomModerator', + }); + } + + return removeRoomModerator(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/removeRoomOwner.ts b/apps/meteor/server/meteor-methods/rooms/removeRoomOwner.ts new file mode 100644 index 0000000..a1c562c --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/removeRoomOwner.ts @@ -0,0 +1,121 @@ +import { api, Message, Team } from '@rocket.chat/core-services'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users, Roles } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { beforeChangeRoomRole } from '../../lib/callbacks/beforeChangeRoomRole'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { syncRoomRolePriorityForUserAndRoom } from '../../lib/roles/syncRoomRolePriority'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeRoomOwner(rid: string, userId: string): boolean; + } +} + +export const removeRoomOwner = async (fromUserId: string, rid: string, userId: string): Promise => { + check(rid, String); + check(userId, String); + + const room = await Rooms.findOneById(rid, { projection: { t: 1, federated: 1, federation: 1 } }); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'removeRoomOwner', + }); + } + + if (!(await hasPermissionAsync(fromUserId, 'set-owner', rid)) && !isRoomFederated(room)) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeRoomOwner', + }); + } + + const user = await Users.findOneById(userId); + if (!user?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomOwner', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id); + + if (!subscription) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'removeRoomOwner', + }); + } + + if (Array.isArray(subscription.roles) === false || subscription.roles?.includes('owner') === false) { + throw new Meteor.Error('error-user-not-owner', 'User is not an owner', { + method: 'removeRoomOwner', + }); + } + + const numOwners = await Roles.countUsersInRole('owner', rid); + + if (numOwners === 1) { + throw new Meteor.Error('error-remove-last-owner', 'This is the last owner. Please set a new owner before removing this one.', { + method: 'removeRoomOwner', + }); + } + + await beforeChangeRoomRole.run({ fromUserId, userId, room, role: 'user' }); + + const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'owner'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'owner') || []); + + if (removeRoleResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + const fromUser = await Users.findOneById(fromUserId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomOwner', + }); + } + + await Message.saveSystemMessage('subscription-role-removed', rid, user.username, fromUser, { role: 'owner' }); + + const team = await Team.getOneByMainRoomId(rid); + if (team) { + await Team.removeRolesFromMember(team._id, userId, ['owner']); + } + + const event = { + type: 'removed', + _id: 'owner', + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + scope: rid, + } as const; + if (settings.get('UI_DisplayRoles')) { + void api.broadcast('user.roleUpdate', event); + } + void api.broadcast('federation.userRoleChanged', { ...event, givenByUserId: fromUserId }); + return true; +}; + +Meteor.methods({ + async removeRoomOwner(rid, userId) { + methodDeprecationLogger.method('removeRoomOwner', '9.0.0', ['/v1/channels.removeOwner', '/v1/groups.removeOwner']); + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeRoomOwner', + }); + } + + return removeRoomOwner(uid, rid, userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/removeUserFromRoom.ts b/apps/meteor/server/meteor-methods/rooms/removeUserFromRoom.ts new file mode 100644 index 0000000..7d56846 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/removeUserFromRoom.ts @@ -0,0 +1,151 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { Message, Team, Room } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms, Users, Roles } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { canAccessRoomAsync } from '../../lib/authorization'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { hasRoleAsync } from '../../lib/authorization/hasRole'; +import { callbacks } from '../../lib/callbacks'; +import { afterRemoveFromRoomCallback } from '../../lib/callbacks/afterRemoveFromRoomCallback'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../lib/notifyListener'; +import { removeUserFromRolesAsync } from '../../lib/roles/removeUserFromRoles'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + removeUserFromRoom(data: { rid: string; username: string }): boolean; + } +} + +export const removeUserFromRoomMethod = async (fromId: string, data: { rid: string; username: string }): Promise => { + if (!(await hasPermissionAsync(fromId, 'remove-user', data.rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeUserFromRoom', + }); + } + + const room = await Rooms.findOneById(data.rid); + + if (!room || !(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.REMOVE_USER, fromId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'removeUserFromRoom', + }); + } + + const fromUser = await Users.findOneById(fromId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeUserFromRoom', + }); + } + + // did this way so a ctrl-f would find the permission being used + const kickAnyUserPermission = room.t === 'c' ? 'kick-user-from-any-c-room' : 'kick-user-from-any-p-room'; + + const canKickAnyUser = await hasPermissionAsync(fromId, kickAnyUserPermission); + if (!canKickAnyUser && !(await canAccessRoomAsync(room, fromUser))) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); + if (!removedUser) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeUserFromRoom', + }); + } + + await Room.beforeUserRemoved(room); + + if (!canKickAnyUser) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { + projection: { _id: 1 }, + }); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeUserFromRoom', + }); + } + } + + if (await hasRoleAsync(removedUser._id, 'owner', room._id)) { + const numOwners = await Roles.countUsersInRole('owner', room._id); + + if (numOwners === 1) { + throw new Meteor.Error('error-you-are-last-owner', 'You are the last owner. Please set new owner before leaving the room.', { + method: 'removeUserFromRoom', + }); + } + } + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, removedUser, fromUser); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + await callbacks.run('beforeRemoveFromRoom', { removedUser, userWhoRemoved: fromUser }, room); + + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(data.rid, removedUser._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + + if (['c', 'p'].includes(room.t) === true) { + await removeUserFromRolesAsync(removedUser._id, ['moderator', 'owner'], data.rid); + } + + await Message.saveSystemMessage('ru', data.rid, removedUser.username || '', fromUser); + + if (room.teamId && room.teamMain) { + // if a user is kicked from the main team room, delete the team membership + await Team.removeMember(room.teamId, removedUser._id); + } + + if (room.encrypted && settings.get('E2E_Enable')) { + await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [removedUser._id]); + } + + setImmediate(() => { + void afterRemoveFromRoomCallback.run({ removedUser, userWhoRemoved: fromUser }, room); + void notifyOnRoomChanged(room); + }); + + await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, removedUser, fromUser); + + return true; +}; + +Meteor.methods({ + async removeUserFromRoom(data) { + methodDeprecationLogger.method('removeUserFromRoom', '9.0.0', ['/v1/channels.kick', '/v1/groups.kick']); + check( + data, + Match.ObjectIncluding({ + rid: String, + username: String, + }), + ); + + const fromId = Meteor.userId(); + + if (!fromId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'removeUserFromRoom', + }); + } + + return removeUserFromRoomMethod(fromId, data); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/saveRoomSettings.ts b/apps/meteor/server/meteor-methods/rooms/saveRoomSettings.ts new file mode 100644 index 0000000..59a6e84 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/saveRoomSettings.ts @@ -0,0 +1,568 @@ +import { Team } from '@rocket.chat/core-services'; +import type { IRoom, IRoomWithRetentionPolicy, IUser, MessageTypesValues, ITeam, RequiredField } from '@rocket.chat/core-typings'; +import { TeamType } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms, Users } from '@rocket.chat/models'; +import { Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { isABACManagedRoom } from '../../lib/authorization/isABACManagedRoom'; +import { SystemLogger } from '../../lib/logger/system'; +import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage'; +import { notifyOnRoomChangedById } from '../../lib/notifyListener'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { setRoomAvatar } from '../../lib/rooms/setRoomAvatar'; +import { saveReactWhenReadOnly } from '../../lib/rooms/settings/saveReactWhenReadOnly'; +import { saveRoomAnnouncement } from '../../lib/rooms/settings/saveRoomAnnouncement'; +import { saveRoomCustomFields } from '../../lib/rooms/settings/saveRoomCustomFields'; +import { saveRoomDescription } from '../../lib/rooms/settings/saveRoomDescription'; +import { saveRoomEncrypted } from '../../lib/rooms/settings/saveRoomEncrypted'; +import { saveRoomName } from '../../lib/rooms/settings/saveRoomName'; +import { saveRoomReadOnly } from '../../lib/rooms/settings/saveRoomReadOnly'; +import { saveRoomSystemMessages } from '../../lib/rooms/settings/saveRoomSystemMessages'; +import { saveRoomTopic } from '../../lib/rooms/settings/saveRoomTopic'; +import { saveRoomType } from '../../lib/rooms/settings/saveRoomType'; +import { settings } from '../../settings'; + +type RoomSettings = { + roomAvatar: string; + featured: boolean; + roomName: string | undefined; + roomTopic: string; + roomAnnouncement: string; + roomCustomFields: Record; + roomDescription: string; + roomType: IRoom['t']; + readOnly: boolean; + reactWhenReadOnly: boolean; + systemMessages: MessageTypesValues[]; + default: boolean; + joinCode: string; + retentionEnabled: boolean; + retentionMaxAge: number; + retentionExcludePinned: boolean; + retentionFilesOnly: boolean; + retentionIgnoreThreads: boolean; + retentionOverrideGlobal: boolean; + encrypted: boolean; + favorite: { + favorite: boolean; + defaultValue: boolean; + }; +}; + +type RoomSettingsValidators = { + [TRoomSetting in keyof RoomSettings]?: (params: { + userId: IUser['_id']; + value: RoomSettings[TRoomSetting]; + room: IRoom; + rid: IRoom['_id']; + }) => Promise | void; +}; + +const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy => + 'retention' in room && room.retention !== undefined; + +const isAbacManagedTeam = (team: Partial | null, teamRoom: IRoom): boolean => { + return ( + team?.type === TeamType.PRIVATE && + settings.get('ABAC_Enabled') && + Array.isArray(teamRoom?.abacAttributes) && + teamRoom.abacAttributes.length > 0 + ); +}; + +const guardABACManagedField = (room: IRoom, value: string | undefined, current: string | undefined, fieldName: string): void => { + if (!value && !current) { + return; + } + if (value === current) { + return; + } + if (isABACManagedRoom(room)) { + throw new Meteor.Error('error-action-not-allowed', `Editing an ABAC managed room's ${fieldName} is not allowed`, { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } +}; + +const validators: RoomSettingsValidators = { + async default({ userId, room, value }) { + if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { + throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } + if (isABACManagedRoom(room) && value) { + throw new Meteor.Error('error-action-not-allowed', 'Setting an ABAC managed room as default is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } + }, + async featured({ userId }) { + if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { + throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } + }, + + async roomType({ userId, room, value }) { + if (value === room.t) { + return; + } + + if (value === 'c' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-c'))) { + throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (value === 'p' && (!room.teamId || room.teamMain) && !(await hasPermissionAsync(userId, 'create-p'))) { + throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (isABACManagedRoom(room) && value !== 'p') { + throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private room to public is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (!room.teamId || room.teamMain) { + return; + } + const team = await Team.getInfoById(room.teamId); + + if (value === 'c' && !(await hasPermissionAsync(userId, 'create-team-channel', team?.roomId))) { + throw new Meteor.Error('error-action-not-allowed', `Changing a team's private group to a public channel is not allowed`, { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (value === 'p' && !(await hasPermissionAsync(userId, 'create-team-group', team?.roomId))) { + throw new Meteor.Error('error-action-not-allowed', `Changing a team's public channel to a private room is not allowed`, { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + + if (isAbacManagedTeam(team, room) && value !== 'p') { + throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private team room to public is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + }, + async roomAnnouncement({ room, value }) { + guardABACManagedField(room, value, room.announcement, 'announcement'); + }, + async roomTopic({ room, value }) { + guardABACManagedField(room, value, room.topic, 'topic'); + }, + async roomDescription({ room, value }) { + guardABACManagedField(room, value, room.description, 'description'); + }, + async encrypted({ userId, value, room, rid }) { + if (value !== room.encrypted) { + if (!(await roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E))) { + throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', { + method: 'saveRoomSettings', + action: 'Change_Room_Encrypted', + }); + } + + if (room.t !== 'd' && !(await hasPermissionAsync(userId, 'toggle-room-e2e-encryption', rid))) { + throw new Meteor.Error('error-action-not-allowed', 'You do not have permission to toggle E2E encryption', { + method: 'saveRoomSettings', + action: 'Change_Room_Encrypted', + }); + } + } + }, + async retentionEnabled({ userId, value, room, rid }) { + if ( + !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && + (!hasRetentionPolicy(room) || value !== room.retention.enabled) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + async retentionMaxAge({ userId, value, room, rid }) { + if ( + !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && + (!hasRetentionPolicy(room) || value !== room.retention.maxAge) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + async retentionExcludePinned({ userId, value, room, rid }) { + if ( + !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && + (!hasRetentionPolicy(room) || value !== room.retention.excludePinned) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + async retentionFilesOnly({ userId, value, room, rid }) { + if ( + !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && + (!hasRetentionPolicy(room) || value !== room.retention.filesOnly) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + async retentionIgnoreThreads({ userId, value, room, rid }) { + if ( + !(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) && + (!hasRetentionPolicy(room) || value !== room.retention.ignoreThreads) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, + async roomAvatar({ userId, rid }) { + if (!(await hasPermissionAsync(userId, 'edit-room-avatar', rid))) { + throw new Meteor.Error('error-action-not-allowed', 'Editing a room avatar is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + }, +}; + +type RoomSettingsSavers = { + [TRoomSetting in keyof RoomSettings]?: (params: { + userId: IUser['_id']; + user: RequiredField; + value: RoomSettings[TRoomSetting]; + room: IRoom; + rid: IRoom['_id']; + }) => void | Promise; +}; + +const settingSavers: RoomSettingsSavers = { + async roomName({ value, rid, user, room }) { + if (!(await saveRoomName(rid, value, user))) { + return; + } + + if (room.teamId && room.teamMain) { + void Team.update(user._id, room.teamId, { + type: room.t === 'c' ? TeamType.PUBLIC : TeamType.PRIVATE, + name: value, + updateRoom: false, + }); + } + }, + async roomTopic({ value, room, rid, user }) { + if (!value && !room.topic) { + return; + } + if (value !== room.topic) { + await saveRoomTopic(rid, value, user); + } + }, + async roomAnnouncement({ value, room, rid, user }) { + if (!value && !room.announcement) { + return; + } + if (value !== room.announcement) { + await saveRoomAnnouncement(rid, value, user); + } + }, + async roomCustomFields({ value, room, rid }) { + if (value !== room.customFields) { + await saveRoomCustomFields(rid, value); + } + }, + async roomDescription({ value, room, rid, user }) { + if (!value && !room.description) { + return; + } + if (value !== room.description) { + await saveRoomDescription(rid, value, user); + } + }, + async roomType({ value, room, rid, user }) { + if (value === room.t) { + return; + } + + if (!(await saveRoomType(rid, value, user))) { + return; + } + + if (room.teamId && room.teamMain) { + const type = value === 'c' ? TeamType.PUBLIC : TeamType.PRIVATE; + void Team.update(user._id, room.teamId, { type, updateRoom: false }); + } + }, + async readOnly({ value, room, rid, user }) { + if (value !== room.ro) { + await saveRoomReadOnly(rid, value, user); + } + }, + async reactWhenReadOnly({ value, room, rid, user }) { + if (value !== room.reactWhenReadOnly) { + await saveReactWhenReadOnly(rid, value, user); + } + }, + async systemMessages({ value, room, rid }) { + if (JSON.stringify(value) !== JSON.stringify(room.sysMes)) { + await saveRoomSystemMessages(rid, value); + if (room?.prid) { + try { + await updateAndNotifyParentRoomWithParentMessage({ ...room, sysMes: value }); + } catch (err) { + SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid }); + } + } + } + }, + async joinCode({ value, rid }) { + await Rooms.setJoinCodeById(rid, String(value)); + }, + async default({ value, rid }) { + await Rooms.saveDefaultById(rid, value); + }, + async featured({ value, rid }) { + await Rooms.saveFeaturedById(rid, value); + }, + async retentionEnabled({ value, rid }) { + await Rooms.saveRetentionEnabledById(rid, value); + }, + async retentionMaxAge({ value, rid }) { + await Rooms.saveRetentionMaxAgeById(rid, value); + }, + async retentionExcludePinned({ value, rid }) { + await Rooms.saveRetentionExcludePinnedById(rid, value); + }, + async retentionFilesOnly({ value, rid }) { + await Rooms.saveRetentionFilesOnlyById(rid, value); + }, + async retentionIgnoreThreads({ value, rid }) { + await Rooms.saveRetentionIgnoreThreadsById(rid, value); + }, + async retentionOverrideGlobal({ value, rid }) { + await Rooms.saveRetentionOverrideGlobalById(rid, value); + }, + async encrypted({ value, room, rid, user }) { + await saveRoomEncrypted(rid, value, user, Boolean(room.encrypted) !== Boolean(value)); + }, + async favorite({ value, rid }) { + await Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue); + }, + async roomAvatar({ value, rid, user }) { + await setRoomAvatar(rid, value, user); + }, +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveRoomSettings(rid: IRoom['_id'], settings: Partial): Promise<{ result: true; rid: IRoom['_id'] }>; + saveRoomSettings( + rid: IRoom['_id'], + setting: RoomSettingName, + value: RoomSettings[RoomSettingName], + ): Promise<{ result: true; rid: IRoom['_id'] }>; + } +} + +const fields: (keyof RoomSettings)[] = [ + 'roomAvatar', + 'featured', + 'roomName', + 'roomTopic', + 'roomAnnouncement', + 'roomCustomFields', + 'roomDescription', + 'roomType', + 'readOnly', + 'reactWhenReadOnly', + 'systemMessages', + 'default', + 'joinCode', + 'retentionEnabled', + 'retentionMaxAge', + 'retentionExcludePinned', + 'retentionFilesOnly', + 'retentionIgnoreThreads', + 'retentionOverrideGlobal', + 'encrypted', + 'favorite', +]; + +const validate = ( + setting: TRoomSetting, + params: { + userId: IUser['_id']; + value: RoomSettings[TRoomSetting]; + room: IRoom; + rid: IRoom['_id']; + }, +) => { + const validator = validators[setting]; + return validator?.(params); +}; + +async function save( + setting: TRoomSetting, + params: { + userId: IUser['_id']; + user: RequiredField; + value: RoomSettings[TRoomSetting]; + room: IRoom; + rid: IRoom['_id']; + }, +) { + const saver = settingSavers[setting]; + await saver?.(params); +} + +export async function saveRoomSettings( + userId: IUser['_id'], + rid: IRoom['_id'], + settings: Partial, +): Promise<{ result: true; rid: IRoom['_id'] }>; +export async function saveRoomSettings( + userId: IUser['_id'], + rid: IRoom['_id'], + setting: RoomSettingName, + value: RoomSettings[RoomSettingName], +): Promise<{ result: true; rid: IRoom['_id'] }>; +export async function saveRoomSettings( + userId: IUser['_id'], + rid: IRoom['_id'], + settings: Partial | keyof RoomSettings, + value?: RoomSettings[keyof RoomSettings], +): Promise<{ result: true; rid: IRoom['_id'] }> { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.saveRoomName', + }); + } + if (!Match.test(rid, String)) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'saveRoomSettings', + }); + } + + if (typeof settings !== 'object') { + settings = { + [settings]: value, + }; + } + + if (!Object.keys(settings).every((key) => fields.includes(key as keyof typeof settings))) { + throw new Meteor.Error('error-invalid-settings', 'Invalid settings provided', { + method: 'saveRoomSettings', + }); + } + + const room = await Rooms.findOneById(rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'saveRoomSettings', + }); + } + + if (!(await hasPermissionAsync(userId, 'edit-room', rid))) { + if (!(Object.keys(settings).includes('encrypted') && room.t === 'd')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing room is not allowed', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + settings = { encrypted: settings.encrypted }; + } + + if (room.broadcast && (settings.readOnly || settings.reactWhenReadOnly)) { + throw new Meteor.Error('error-action-not-allowed', 'Editing readOnly/reactWhenReadOnly are not allowed for broadcast rooms', { + method: 'saveRoomSettings', + action: 'Editing_room', + }); + } + + const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveRoomSettings', + }); + } + + // validations + for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { + await validate(setting, { + userId, + value: settings[setting], + room, + rid, + }); + + if (setting === 'retentionOverrideGlobal' && settings.retentionOverrideGlobal === false) { + delete settings.retentionMaxAge; + delete settings.retentionExcludePinned; + delete settings.retentionFilesOnly; + delete settings.retentionIgnoreThreads; + } + } + + // saving data + for (const setting of Object.keys(settings) as (keyof RoomSettings)[]) { + await save(setting, { + userId, + user: user as RequiredField, + value: settings[setting], + room, + rid, + }); + } + + void notifyOnRoomChangedById(rid); + + return { + result: true, + rid: room._id, + }; +} + +Meteor.methods({ + saveRoomSettings: (...args) => { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'RocketChat.saveRoomName', + }); + } + + return saveRoomSettings(userId, ...args); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/toggleFavorite.ts b/apps/meteor/server/meteor-methods/rooms/toggleFavorite.ts new file mode 100644 index 0000000..1e8cd97 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/toggleFavorite.ts @@ -0,0 +1,47 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + toggleFavorite(rid: IRoom['_id'], favorite?: boolean): Promise; + } +} + +export const toggleFavoriteMethod = async (userId: string, rid: IRoom['_id'], favorite?: boolean): Promise => { + const userSubscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + if (!userSubscription) { + throw new Meteor.Error('error-invalid-subscription', 'You must be part of a room to favorite it', { method: 'toggleFavorite' }); + } + + const { modifiedCount } = await Subscriptions.setFavoriteByRoomIdAndUserId(rid, userId, favorite); + + if (modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(rid, userId); + } + + return modifiedCount; +}; + +Meteor.methods({ + async toggleFavorite(rid, favorite) { + methodDeprecationLogger.method('toggleFavorite', '9.0.0', '/v1/rooms.favorite'); + check(rid, String); + check(favorite, Match.Optional(Boolean)); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'toggleFavorite', + }); + } + + return toggleFavoriteMethod(userId, rid, favorite); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/unarchiveRoom.ts b/apps/meteor/server/meteor-methods/rooms/unarchiveRoom.ts new file mode 100644 index 0000000..19f0818 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/unarchiveRoom.ts @@ -0,0 +1,50 @@ +import { isRegisterUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { unarchiveRoom } from '../../lib/rooms/unarchiveRoom'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + unarchiveRoom(rid: string): Promise; + } +} + +export const executeUnarchiveRoom = async (userId: string, rid: string) => { + check(rid, String); + + const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!user || !isRegisterUser(user)) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + const room = await Rooms.findOneById(rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'unarchiveRoom' }); + } + + if (!(await hasPermissionAsync(userId, 'unarchive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'unarchiveRoom' }); + } + + return unarchiveRoom(rid, user); +}; + +Meteor.methods({ + async unarchiveRoom(rid) { + methodDeprecationLogger.method('unarchiveRoom', '9.0.0', '/v1/channels.unarchive'); + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unarchiveRoom' }); + } + + return executeUnarchiveRoom(userId, rid); + }, +}); diff --git a/apps/meteor/server/meteor-methods/rooms/unmuteUserInRoom.ts b/apps/meteor/server/meteor-methods/rooms/unmuteUserInRoom.ts new file mode 100644 index 0000000..cf0da88 --- /dev/null +++ b/apps/meteor/server/meteor-methods/rooms/unmuteUserInRoom.ts @@ -0,0 +1,81 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { callbacks } from '../../lib/callbacks'; +import { notifyOnRoomChangedById } from '../../lib/notifyListener'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + unmuteUserInRoom(data: { rid: IRoom['_id']; username: string }): boolean; + } +} + +export const unmuteUserInRoom = async (fromId: string, data: { rid: IRoom['_id']; username: string }): Promise => { + if (!fromId || !(await hasPermissionAsync(fromId, 'mute-user', data.rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'unmuteUserInRoom', + }); + } + + const room = await Rooms.findOneById(data.rid); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'unmuteUserInRoom', + }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.MUTE, fromId))) { + throw new Meteor.Error('error-invalid-room-type', `${room.t} is not a valid room type`, { + method: 'unmuteUserInRoom', + type: room.t, + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUsername(data.rid, data.username, { + projection: { _id: 1 }, + }); + + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'unmuteUserInRoom', + }); + } + + const unmutedUser = await Users.findOneByUsernameIgnoringCase(data.username); + if (!unmutedUser?.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user to unmute', { + method: 'unmuteUserInRoom', + }); + } + + const fromUser = await Users.findOneById(fromId); + if (!fromUser) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'unmuteUserInRoom', + }); + } + + await callbacks.run('beforeUnmuteUser', { unmutedUser, fromUser }, room); + + if (room.ro) { + await Rooms.unmuteReadOnlyUsernameByRoomId(data.rid, unmutedUser.username); + } else { + await Rooms.unmuteMutedUsernameByRoomId(data.rid, unmutedUser.username); + } + + await Message.saveSystemMessage('user-unmuted', data.rid, unmutedUser.username, fromUser); + + setImmediate(() => { + void callbacks.run('afterUnmuteUser', { unmutedUser, fromUser }, room); + void notifyOnRoomChangedById(data.rid); + }); + + return true; +}; diff --git a/apps/meteor/server/meteor-methods/settings/getSetupWizardParameters.ts b/apps/meteor/server/meteor-methods/settings/getSetupWizardParameters.ts new file mode 100644 index 0000000..e3889ba --- /dev/null +++ b/apps/meteor/server/meteor-methods/settings/getSetupWizardParameters.ts @@ -0,0 +1,28 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getSetupWizardParameters } from '../../lib/getSetupWizardParameters'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getSetupWizardParameters(): Promise<{ + settings: ISetting[]; + serverAlreadyRegistered: boolean; + }>; + } +} + +Meteor.methods({ + /** + * @deprecated this method is deprecated and will be removed soon. + * Prefer using the setupWizard.parameters rest api. + */ + async getSetupWizardParameters() { + methodDeprecationLogger.method('getSetupWizardParameters', '9.0.0', '/v1/setupWizard.parameters'); + + return getSetupWizardParameters(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/settings/saveSetting.ts b/apps/meteor/server/meteor-methods/settings/saveSetting.ts new file mode 100644 index 0000000..4a4aae6 --- /dev/null +++ b/apps/meteor/server/meteor-methods/settings/saveSetting.ts @@ -0,0 +1,95 @@ +import type { SettingEditor, SettingValue } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Settings } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { getSettingPermissionId } from '../../../app/authorization/lib'; +import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; +import { hasPermissionAsync, hasAllPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSettingChanged } from '../../lib/notifyListener'; +import { SettingValidationError, validateSettingRules } from '../../lib/settingValidationRules'; +import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; +import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveSetting(_id: string, value: SettingValue, editor: SettingEditor): Promise; + } +} + +Meteor.methods({ + saveSetting: twoFactorRequired(async function (_id: string, value: SettingValue, editor: SettingEditor) { + methodDeprecationLogger.method('saveSetting', '9.0.0', '/v1/settings/:_id'); + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSetting', + }); + } + + if ( + !(await hasPermissionAsync(uid, 'edit-privileged-setting')) && + !(await hasAllPermissionAsync(uid, ['manage-selected-settings', getSettingPermissionId(_id)])) + ) { + // TODO use the same function + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSetting', + settingId: _id, + }); + } + + // Verify the _id passed in is a string. + check(_id, String); + + // Disable custom scripts in cloud trials to prevent phishing campaigns + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSetting', + settingId: _id, + }); + } + + const setting = await Settings.findOneById(_id); + + // Verify the value is what it should be + switch (setting?.type) { + case 'roomPick': + check(value, Match.OneOf([Object], '')); + break; + case 'boolean': + check(value, Boolean); + break; + case 'int': + check(value, Number); + break; + default: + check(value, String); + break; + } + + try { + validateSettingRules([{ _id, value }]); + } catch (error) { + if (error instanceof SettingValidationError) { + throw new Meteor.Error('error-setting-validation-failed', error.message); + } + throw error; + } + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: (await Meteor.userAsync())!.username!, + ip: this.connection?.clientAddress || '', + useragent: this.connection?.httpHeaders['user-agent'] || '', + }); + + (await auditSettingOperation(Settings.updateValueAndEditorById, _id, value, editor)).modifiedCount && + setting && + void notifyOnSettingChanged({ ...setting, editor, value }); + + return true; + }), +}); diff --git a/apps/meteor/server/meteor-methods/settings/saveSettings.ts b/apps/meteor/server/meteor-methods/settings/saveSettings.ts new file mode 100644 index 0000000..ff6137d --- /dev/null +++ b/apps/meteor/server/meteor-methods/settings/saveSettings.ts @@ -0,0 +1,53 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { SettingValidationError } from '../../lib/settingValidationRules'; +import { saveSettingsBulk } from '../../settings/lib/saveSettingsBulk'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveSettings( + changes: { + _id: ISetting['_id']; + value: ISetting['value']; + }[], + ): Promise; + } +} + +Meteor.methods({ + saveSettings: twoFactorRequired(async function ( + params: { + _id: ISetting['_id']; + value: ISetting['value']; + }[] = [], + ) { + methodDeprecationLogger.method('saveSettings', '9.0.0', '/v1/settings'); + + const uid = Meteor.userId(); + if (uid === null) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSetting', + }); + } + + try { + await saveSettingsBulk(uid, params, { + username: (await Meteor.userAsync())!.username!, + ip: this.connection.clientAddress || '', + useragent: this.connection.httpHeaders['user-agent'] || '', + }); + } catch (error) { + if (error instanceof SettingValidationError) { + throw new Meteor.Error('error-setting-validation-failed', error.message); + } + throw error; + } + + return true; + }, {}), +}); diff --git a/apps/meteor/server/meteor-methods/settings/sendSMTPTestEmail.ts b/apps/meteor/server/meteor-methods/settings/sendSMTPTestEmail.ts new file mode 100644 index 0000000..e03d98d --- /dev/null +++ b/apps/meteor/server/meteor-methods/settings/sendSMTPTestEmail.ts @@ -0,0 +1,61 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import * as Mailer from '../../lib/notifications/email/api'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + sendSMTPTestEmail(): { + message: string; + params: string[]; + }; + } +} + +Meteor.methods({ + async sendSMTPTestEmail() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendSMTPTestEmail', + }); + } + const user = await Meteor.userAsync(); + if (!user?.emails?.[0]?.address) { + throw new Meteor.Error('error-invalid-email', 'Invalid email', { + method: 'sendSMTPTestEmail', + }); + } + try { + await Mailer.send({ + to: user.emails[0].address, + from: settings.get('From_Email'), + subject: 'SMTP Test Email', + html: '

You have successfully sent an email

', + }); + } catch ({ message }: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, { + method: 'sendSMTPTestEmail', + message, + }); + } + return { + message: 'Sending_your_mail_to_s', + params: [user.emails[0].address], + }; + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'sendSMTPTestEmail', + userId() { + return true; + }, + }, + 1, + 1000, +); diff --git a/apps/meteor/server/meteor-methods/users/blockUser.ts b/apps/meteor/server/meteor-methods/users/blockUser.ts new file mode 100644 index 0000000..617c2e1 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/blockUser.ts @@ -0,0 +1,31 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { blockUserMethod } from '../../lib/users/blockUser'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + blockUser({ rid, blocked }: { rid: string; blocked: string }): boolean; + } +} + +Meteor.methods({ + async blockUser({ rid, blocked }) { + methodDeprecationLogger.method('blockUser', '9.0.0', '/v1/im.blockUser'); + check(rid, String); + check(blocked, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); + } + + await blockUserMethod(userId, { rid, blocked }); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/deleteCustomUserStatus.ts b/apps/meteor/server/meteor-methods/users/deleteCustomUserStatus.ts new file mode 100644 index 0000000..7296cf8 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/deleteCustomUserStatus.ts @@ -0,0 +1,40 @@ +import { api } from '@rocket.chat/core-services'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { CustomUserStatus } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteCustomUserStatus(userStatusID: string): Promise; + } +} + +export const deleteCustomUserStatus = async (userId: string, userStatusID: string): Promise => { + if (!(await hasPermissionAsync(userId, 'manage-user-status'))) { + throw new Meteor.Error('not_authorized'); + } + + const userStatus = await CustomUserStatus.findOneAndDeleteById(userStatusID); + if (userStatus == null) { + throw new Meteor.Error('Custom_User_Status_Error_Invalid_User_Status', 'Invalid user status', { method: 'deleteCustomUserStatus' }); + } + + void api.broadcast('user.deleteCustomStatus', userStatus); + + return true; +}; + +Meteor.methods({ + async deleteCustomUserStatus(userStatusID) { + methodDeprecationLogger.method('deleteCustomUserStatus', '9.0.0', '/v1/custom-user-status.delete'); + if (!this.userId) { + throw new Meteor.Error('not_authorized'); + } + + return deleteCustomUserStatus(this.userId, userStatusID); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/deleteUser.ts b/apps/meteor/server/meteor-methods/users/deleteUser.ts new file mode 100644 index 0000000..ab5fed4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/deleteUser.ts @@ -0,0 +1,68 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { deleteUser } from '../../lib/users/deleteUser'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteUser(userId: IUser['_id'], confirmRelinquish?: boolean): boolean; + } +} + +export const executeDeleteUser = async (fromUserId: IUser['_id'], userId: IUser['_id'], confirmRelinquish = false): Promise => { + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user to delete', { + method: 'deleteUser', + }); + } + + if (user.type === 'app') { + throw new Meteor.Error('error-cannot-delete-app-user', 'Deleting app user is not allowed', { + method: 'deleteUser', + }); + } + + const adminCount = await Users.countDocuments({ roles: 'admin' }); + + const userIsAdmin = user.roles?.indexOf('admin') > -1; + + if (adminCount === 1 && userIsAdmin) { + throw new Meteor.Error('error-action-not-allowed', 'Leaving the app without admins is not allowed', { + method: 'deleteUser', + action: 'Remove_last_admin', + }); + } + + await deleteUser(userId, confirmRelinquish, fromUserId); + + return true; +}; + +Meteor.methods({ + async deleteUser(userId, confirmRelinquish = false) { + methodDeprecationLogger.method('deleteUser', '9.0.0', '/v1/users.delete'); + check(userId, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'deleteUser', + }); + } + + if ((await hasPermissionAsync(uid, 'delete-user')) !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'deleteUser', + }); + } + + return executeDeleteUser(uid, userId, confirmRelinquish); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/deleteUserOwnAccount.ts b/apps/meteor/server/meteor-methods/users/deleteUserOwnAccount.ts new file mode 100644 index 0000000..0ce8928 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/deleteUserOwnAccount.ts @@ -0,0 +1,79 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { SHA256 } from '@rocket.chat/sha256'; +import { Accounts } from 'meteor/accounts-base'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { deleteUser } from '../../lib/users/deleteUser'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + deleteUserOwnAccount(password: string, confirmRelinquish?: boolean): Promise; + } +} + +export const deleteUserOwnAccount = async (fromUserId: string, password: string, confirmRelinquish = false): Promise => { + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'deleteUserOwnAccount', + }); + } + + if (!fromUserId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteUserOwnAccount', + }); + } + + const user = await Users.findOneById(fromUserId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteUserOwnAccount', + }); + } + + if (user.services?.password && trim(user.services.password.bcrypt)) { + const result = await Accounts._checkPasswordAsync(user as Meteor.User, { + digest: password.toLowerCase(), + algorithm: 'sha-256', + }); + if (result.error) { + throw new Meteor.Error('error-invalid-password', 'Invalid password', { + method: 'deleteUserOwnAccount', + }); + } + } else if (!user.username || SHA256(user.username) !== password.trim()) { + throw new Meteor.Error('error-invalid-username', 'Invalid username', { + method: 'deleteUserOwnAccount', + }); + } + + await deleteUser(fromUserId, confirmRelinquish); + + // App IPostUserDeleted event hook + await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user }); + + return true; +}; + +Meteor.methods({ + async deleteUserOwnAccount(password, confirmRelinquish) { + methodDeprecationLogger.method('deleteUserOwnAccount', '9.0.0', '/v1/users.deleteOwnAccount'); + check(password, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteUserOwnAccount', + }); + } + + return deleteUserOwnAccount(uid, password, confirmRelinquish); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/getUserStatusText.ts b/apps/meteor/server/meteor-methods/users/getUserStatusText.ts new file mode 100644 index 0000000..c719cc5 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/getUserStatusText.ts @@ -0,0 +1,27 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { getStatusText } from '../../lib/users/getStatusText'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getUserStatusText(userId: string): Promise; + } +} + +Meteor.methods({ + async getUserStatusText(userId) { + methodDeprecationLogger.method('getUserStatusText', '9.0.0', '/v1/users.presence'); + const currentUserId = Meteor.userId(); + if (!currentUserId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getUserStatusText' }); + } + + const hidden = await getUsersHiddenFrom(currentUserId); + + return hidden?.has(userId) ? undefined : getStatusText(userId); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/getUsernameSuggestion.ts b/apps/meteor/server/meteor-methods/users/getUsernameSuggestion.ts new file mode 100644 index 0000000..d567b4e --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/getUsernameSuggestion.ts @@ -0,0 +1,27 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { generateUsernameSuggestion } from '../../lib/users/getUsernameSuggestion'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getUsernameSuggestion(): Promise; + } +} + +Meteor.methods({ + async getUsernameSuggestion() { + methodDeprecationLogger.method('getUsernameSuggestion', '9.0.0', '/v1/users.getUsernameSuggestion'); + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getUsernameSuggestion', + }); + } + + return generateUsernameSuggestion(user); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/getUsersOfRoom.ts b/apps/meteor/server/meteor-methods/users/getUsersOfRoom.ts new file mode 100644 index 0000000..01f17c4 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/getUsersOfRoom.ts @@ -0,0 +1,72 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync, roomAccessAttributes } from '../../lib/authorization'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { findUsersOfRoom } from '../../lib/findUsersOfRoom'; +import { getUsersHiddenFrom, redactHiddenUsers } from '../../lib/statusVisibility/hiddenUsers'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + getUsersOfRoom( + rid: IRoom['_id'], + showAll?: boolean, + options?: { limit?: number; skip?: number }, + filter?: string, + ): { + total: number; + records: IUser[]; + }; + } +} + +Meteor.methods({ + async getUsersOfRoom(rid, showAll, { limit, skip } = {}, filter) { + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getUsersOfRoom' }); + } + + check(rid, String); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getUsersOfRoom' }); + } + + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, broadcast: 1 } }); + if (!room) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getUsersOfRoom' }); + } + + if (!(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'getUsersOfRoom' }); + } + + if (room.broadcast && !(await hasPermissionAsync(userId, 'view-broadcast-member-list', rid))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getUsersOfRoom' }); + } + + // TODO this is currently counting deactivated users + const total = await Subscriptions.countByRoomIdWhenUsernameExists(rid); + + const hidden = await getUsersHiddenFrom(userId); + + const { cursor } = await findUsersOfRoom({ + rid, + ...(!showAll && { status: 'not-offline' as const }), + hidden, + limit, + skip, + filter, + }); + + return { + total, + records: redactHiddenUsers(await cursor.toArray(), hidden), + }; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/ignoreUser.ts b/apps/meteor/server/meteor-methods/users/ignoreUser.ts new file mode 100644 index 0000000..cbb65f6 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/ignoreUser.ts @@ -0,0 +1,62 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + ignoreUser(params: { rid: string; userId: string; ignore?: boolean }): boolean; + } +} + +export const ignoreUser = async ( + fromUserId: string, + { rid, userId: ignoredUser, ignore }: { rid: string; userId: string; ignore?: boolean }, +): Promise => { + const [subscription, subscriptionIgnoredUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, fromUserId), + Subscriptions.findOneByRoomIdAndUserId(rid, ignoredUser), + ]); + + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { + method: 'ignoreUser', + }); + } + + if (!subscriptionIgnoredUser) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { + method: 'ignoreUser', + }); + } + + const result = await Subscriptions.ignoreUser({ _id: subscription._id, ignoredUser, ignore }); + + if (result.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + return !!result; +}; + +Meteor.methods({ + async ignoreUser({ rid, userId: ignoredUser, ignore = true }) { + methodDeprecationLogger.method('ignoreUser', '9.0.0', '/v1/chat.ignoreUser'); + check(ignoredUser, String); + check(rid, String); + check(ignore, Boolean); + + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'ignoreUser', + }); + } + + return ignoreUser(userId, { rid, userId: ignoredUser, ignore }); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/insertOrUpdateUserStatus.ts b/apps/meteor/server/meteor-methods/users/insertOrUpdateUserStatus.ts new file mode 100644 index 0000000..81835d2 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/insertOrUpdateUserStatus.ts @@ -0,0 +1,111 @@ +import { api } from '@rocket.chat/core-services'; +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { CustomUserStatus } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +type InsertOrUpdateUserStatus = { + _id?: string; + name: string; + statusType: string; + status?: string; + emoji?: string; + message?: string; + order?: number; + previousName?: string; + previousStatusType?: string; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + insertOrUpdateUserStatus(userStatusData: InsertOrUpdateUserStatus): string | boolean; + } +} + +export const insertOrUpdateUserStatus = async (userId: string, userStatusData: InsertOrUpdateUserStatus): Promise => { + if (!(await hasPermissionAsync(userId, 'manage-user-status'))) { + throw new Meteor.Error('not_authorized'); + } + + if (!trim(userStatusData.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateUserStatus', + field: 'Name', + }); + } + + // allow all characters except >, <, &, ", ' + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[><&"']/; + + if (nameValidation.test(userStatusData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${userStatusData.name} is not a valid name`, { + method: 'insertOrUpdateUserStatus', + input: userStatusData.name, + field: 'Name', + }); + } + + const conflictingUserStatus = userStatusData._id + ? await CustomUserStatus.findOneByNameExceptId(userStatusData.name, userStatusData._id, { projection: { _id: 1 } }) + : await CustomUserStatus.findOneByName(userStatusData.name, { projection: { _id: 1 } }); + + if (conflictingUserStatus) { + throw new Meteor.Error('Custom_User_Status_Error_Name_Already_In_Use', 'The custom user status name is already in use', { + method: 'insertOrUpdateUserStatus', + }); + } + + const validStatusTypes = ['online', 'away', 'busy', 'offline']; + if (userStatusData.statusType && validStatusTypes.indexOf(userStatusData.statusType) < 0) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${userStatusData.statusType} is not a valid status type`, { + method: 'insertOrUpdateUserStatus', + input: userStatusData.statusType, + field: 'StatusType', + }); + } + + if (!userStatusData._id) { + // insert user status + const createUserStatus: InsertionModel = { + name: userStatusData.name, + statusType: userStatusData.statusType, + }; + + const _id = (await CustomUserStatus.create(createUserStatus)).insertedId; + + void api.broadcast('user.updateCustomStatus', { ...createUserStatus, _id }); + + return _id; + } + + // update User status + if (userStatusData.name !== userStatusData.previousName) { + await CustomUserStatus.setName(userStatusData._id, userStatusData.name); + } + + if (userStatusData.statusType !== userStatusData.previousStatusType) { + await CustomUserStatus.setStatusType(userStatusData._id, userStatusData.statusType); + } + + void api.broadcast('user.updateCustomStatus', { ...userStatusData, _id: userStatusData._id }); + + return true; +}; + +Meteor.methods({ + async insertOrUpdateUserStatus(userStatusData) { + methodDeprecationLogger.method('insertOrUpdateUserStatus', '9.0.0', ['/v1/custom-user-status.create', '/v1/custom-user-status.update']); + if (!this.userId) { + throw new Meteor.Error('not_authorized'); + } + + return insertOrUpdateUserStatus(this.userId, userStatusData); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/listCustomUserStatus.ts b/apps/meteor/server/meteor-methods/users/listCustomUserStatus.ts new file mode 100644 index 0000000..d9a58f8 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/listCustomUserStatus.ts @@ -0,0 +1,24 @@ +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { CustomUserStatus } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + listCustomUserStatus(): ICustomUserStatus[]; + } +} + +Meteor.methods({ + async listCustomUserStatus() { + const currentUserId = Meteor.userId(); + if (!currentUserId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'listCustomUserStatus', + }); + } + + return CustomUserStatus.find({}).toArray(); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/registerUser.ts b/apps/meteor/server/meteor-methods/users/registerUser.ts new file mode 100644 index 0000000..ae01874 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/registerUser.ts @@ -0,0 +1,167 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { trim } from '../../../lib/utils/stringUtils'; +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { passwordPolicy } from '../../lib/auth/passwordPolicy'; +import { validateInviteToken } from '../../lib/rooms/invites/validateInviteToken'; +import { validateEmailDomain } from '../../lib/validateEmailDomain'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + registerUser( + formData: + { email: string; pass: string; username: IUser['username']; name?: string; secretURL?: string; reason?: string } | { email?: null }, + ): + | { + token: string; + when: Date; + } + | string; + } +} + +export const registerUser = async ( + formData: + { email: string; pass: string; username: IUser['username']; name?: string; secretURL?: string; reason?: string } | { email?: null }, +): Promise< + | { + token: string; + when: Date; + } + | string +> => { + const AllowAnonymousRead = settings.get('Accounts_AllowAnonymousRead'); + const AllowAnonymousWrite = settings.get('Accounts_AllowAnonymousWrite'); + const manuallyApproveNewUsers = settings.get('Accounts_ManuallyApproveNewUsers'); + if (AllowAnonymousRead === true && AllowAnonymousWrite === true && !formData.email) { + const userId = await Accounts.insertUserDoc( + {}, + { + globalRoles: ['anonymous'], + active: true, + }, + ); + + const stampedLoginToken = await Accounts._generateStampedLoginToken(); + + await Accounts._insertLoginToken(userId, stampedLoginToken); + return stampedLoginToken; + } + check( + formData, + Match.ObjectIncluding({ + email: String, + pass: String, + name: String, + secretURL: Match.Optional(String), + reason: Match.Optional(String), + }), + ); + + if (settings.get('Accounts_RegistrationForm') === 'Disabled') { + throw new Meteor.Error('error-user-registration-disabled', 'User registration is disabled', { + method: 'registerUser', + }); + } + + if ( + settings.get('Accounts_RegistrationForm') === 'Secret URL' && + (!formData.secretURL || formData.secretURL !== settings.get('Accounts_RegistrationForm_SecretURL')) + ) { + if (!formData.secretURL) { + throw new Meteor.Error('error-user-registration-secret', 'User registration is only allowed via Secret URL', { + method: 'registerUser', + }); + } + + try { + await validateInviteToken(formData.secretURL); + } catch (e) { + throw new Meteor.Error('error-user-registration-secret', 'User registration is only allowed via Secret URL', { + method: 'registerUser', + }); + } + } + + passwordPolicy.validate(formData.pass); + + await validateEmailDomain(formData.email); + + const userData = { + email: trim(formData.email.toLowerCase()), + password: formData.pass, + ...(formData.name?.trim() && { name: formData.name?.trim() }), + reason: formData.reason, + }; + + let userId; + try { + userId = await Accounts.createUserAsync(userData); + } catch (e) { + if (e instanceof Meteor.Error) { + throw e; + } + + if (e instanceof Error) { + throw new Meteor.Error(e.message); + } + + throw new Meteor.Error(String(e)); + } + + const reason = trim(formData.reason); + if (manuallyApproveNewUsers && reason) { + await Users.setReason(userId, reason); + } + + try { + Accounts.sendVerificationEmail(userId, userData.email); + } catch (error) { + // throw new Meteor.Error 'error-email-send-failed', 'Error trying to send email: ' + error.message, { method: 'registerUser', message: error.message } + } + + return userId; +}; + +Meteor.methods({ + async registerUser(formData) { + return registerUser(formData); + }, +}); + +let registerUserRuleId = RateLimiter.limitMethod( + 'registerUser', + settings.get('Rate_Limiter_Limit_RegisterUser'), + settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), + { + userId() { + return true; + }, + }, +); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { + // When running on testMode, there's no rate limiting added, so this function throws an error + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { + return; + } + + if (!registerUserRuleId) { + throw new Error('Rate limiter rule for "registerUser" not found'); + } + // remove old DDP rate limiter rule and create a new one with the updated setting value + DDPRateLimiter.removeRule(registerUserRuleId); + registerUserRuleId = RateLimiter.limitMethod('registerUser', value, settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), { + userId() { + return true; + }, + }); +}); diff --git a/apps/meteor/server/meteor-methods/users/resetAvatar.ts b/apps/meteor/server/meteor-methods/users/resetAvatar.ts new file mode 100644 index 0000000..bbd1f6a --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/resetAvatar.ts @@ -0,0 +1,75 @@ +import { Upload } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + resetAvatar(userId: IUser['_id']): void; + } +} + +export const resetAvatar = async (fromUserId: IUser['_id'], userId: IUser['_id']): Promise => { + const canEditOtherUserAvatar = await hasPermissionAsync(fromUserId, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'resetAvatar', + }); + } + + let user; + + if (userId !== fromUserId) { + if (!canEditOtherUserAvatar) { + throw new Meteor.Error('error-unauthorized', 'Unauthorized', { + method: 'resetAvatar', + }); + } + + user = await Users.findOneById(userId, { projection: { _id: 1, username: 1 } }); + } else { + user = await Users.findOneById(fromUserId, { projection: { _id: 1, username: 1 } }); + } + + if (!user?.username) { + throw new Meteor.Error('error-invalid-desired-user', 'Invalid desired user', { + method: 'resetAvatar', + }); + } + + await Upload.resetUserAvatar(user); +}; + +Meteor.methods({ + async resetAvatar(userId) { + methodDeprecationLogger.method('resetAvatar', '9.0.0', '/v1/users.resetAvatar'); + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'resetAvatar', + }); + } + + return resetAvatar(uid, userId); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'resetAvatar', + userId() { + return true; + }, + }, + 1, + 60000, +); diff --git a/apps/meteor/server/meteor-methods/users/saveNotificationSettings.ts b/apps/meteor/server/meteor-methods/users/saveNotificationSettings.ts new file mode 100644 index 0000000..bd7ad4d --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/saveNotificationSettings.ts @@ -0,0 +1,172 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { notifyOnSubscriptionChangedById } from '../../lib/notifyListener'; +import { getUserNotificationPreference } from '../../lib/utils/getUserNotificationPreference'; + +const saveAudioNotificationValue = (subId: ISubscription['_id'], value: string) => + value === 'default' ? Subscriptions.clearAudioNotificationValueById(subId) : Subscriptions.updateAudioNotificationValueById(subId, value); + +export type NotificationFieldType = + | 'desktopNotifications' + | 'mobilePushNotifications' + | 'emailNotifications' + | 'unreadAlert' + | 'disableNotifications' + | 'hideUnreadStatus' + | 'hideMentionStatus' + | 'muteGroupMentions' + | 'audioNotificationValue'; +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveNotificationSettings(roomId: string, field: NotificationFieldType, value: string): boolean; + saveAudioNotificationValue(subId: string, value: string): boolean; + } +} + +export const saveNotificationSettingsMethod = async ( + userId: string, + roomId: string, + field: NotificationFieldType, + value: string, +): Promise => { + const getNotificationPrefValue = async (field: string, value: unknown) => { + if (value === 'default') { + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveNotificationSettings', + }); + } + + const userPref = await getUserNotificationPreference(userId, field); + return userPref?.origin === 'server' ? null : userPref; + } + return { value, origin: 'subscription' }; + }; + + const notifications = { + desktopNotifications: { + updateMethod: async (subscription: ISubscription, value: unknown) => + Subscriptions.updateNotificationsPrefById( + subscription._id, + await getNotificationPrefValue('desktop', value), + 'desktopNotifications', + 'desktopPrefOrigin', + ), + }, + mobilePushNotifications: { + updateMethod: async (subscription: ISubscription, value: unknown) => + Subscriptions.updateNotificationsPrefById( + subscription._id, + await getNotificationPrefValue('mobile', value), + 'mobilePushNotifications', + 'mobilePrefOrigin', + ), + }, + emailNotifications: { + updateMethod: async (subscription: ISubscription, value: unknown) => + Subscriptions.updateNotificationsPrefById( + subscription._id, + await getNotificationPrefValue('email', value), + 'emailNotifications', + 'emailPrefOrigin', + ), + }, + unreadAlert: { + // @ts-expect-error - Check types of model. The way the method is defined makes difficult to type it, check proper types for `value` + updateMethod: (subscription: ISubscription, value: string) => Subscriptions.updateUnreadAlertById(subscription._id, value), + }, + disableNotifications: { + updateMethod: (subscription: ISubscription, value: unknown) => + Subscriptions.updateDisableNotificationsById(subscription._id, value === '1'), + }, + hideUnreadStatus: { + updateMethod: (subscription: ISubscription, value: string) => + Subscriptions.updateHideUnreadStatusById(subscription._id, value === '1'), + }, + hideMentionStatus: { + updateMethod: (subscription: ISubscription, value: unknown) => + Subscriptions.updateHideMentionStatusById(subscription._id, value === '1'), + }, + muteGroupMentions: { + updateMethod: (subscription: ISubscription, value: unknown) => Subscriptions.updateMuteGroupMentions(subscription._id, value === '1'), + }, + audioNotificationValue: { + updateMethod: (subscription: ISubscription, value: string) => saveAudioNotificationValue(subscription._id, value), + }, + }; + const isInvalidNotification = !Object.keys(notifications).includes(field); + const basicValuesForNotifications = ['all', 'mentions', 'nothing', 'default']; + const fieldsMustHaveBasicValues = ['emailNotifications', 'mobilePushNotifications', 'desktopNotifications']; + + if (isInvalidNotification) { + throw new Meteor.Error('error-invalid-settings', 'Invalid settings field', { + method: 'saveNotificationSettings', + }); + } + + if (fieldsMustHaveBasicValues.includes(field) && !basicValuesForNotifications.includes(value)) { + throw new Meteor.Error('error-invalid-settings', 'Invalid settings value', { + method: 'saveNotificationSettings', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, userId); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { + method: 'saveNotificationSettings', + }); + } + + const updateResponse = await notifications[field].updateMethod(subscription, value); + if (updateResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + return true; +}; + +Meteor.methods({ + async saveNotificationSettings(roomId, field, value) { + methodDeprecationLogger.method('saveNotificationSettings', '9.0.0', '/v1/rooms.saveNotification'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveNotificationSettings', + }); + } + check(roomId, String); + check(field, String); + check(value, String); + + return saveNotificationSettingsMethod(userId, roomId, field, value); + }, + + async saveAudioNotificationValue(rid, value) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveAudioNotificationValue', + }); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId); + if (!subscription) { + throw new Meteor.Error('error-invalid-subscription', 'Invalid subscription', { + method: 'saveAudioNotificationValue', + }); + } + + const saveAudioNotificationResponse = await saveAudioNotificationValue(subscription._id, value); + if (saveAudioNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedById(subscription._id); + } + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/saveUserPreferences.ts b/apps/meteor/server/meteor-methods/users/saveUserPreferences.ts new file mode 100644 index 0000000..44f2f4f --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/saveUserPreferences.ts @@ -0,0 +1,297 @@ +import { StatusVisibility } from '@rocket.chat/core-services'; +import type { ISidebarCategory, ISubscription, ThemePreference } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions, Users } from '@rocket.chat/models'; +import type { FontSize } from '@rocket.chat/rest-typings'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { + notifyOnSubscriptionChangedByAutoTranslateAndUserId, + notifyOnSubscriptionChangedByUserId, + notifyOnSubscriptionChangedByUserPreferences, + notifyOnUserChange, +} from '../../lib/notifyListener'; +import { resolveUsersByUsernames } from '../../lib/statusVisibility/resolveUsers'; +import { settings as rcSettings } from '../../settings'; + +type UserPreferences = { + language: string; + newRoomNotification: string; + newMessageNotification: string; + clockMode: number; + useEmojis: boolean; + convertAsciiEmoji: boolean; + saveMobileBandwidth: boolean; + collapseMediaByDefault: boolean; + autoImageLoad: boolean; + emailNotificationMode: string; + unreadAlert: boolean; + masterVolume: number; + notificationsSoundVolume: number; + voipRingerVolume: number; + desktopNotifications: string; + pushNotifications: string; + enableAutoAway: boolean; + highlights: string[]; + hideUsernames: boolean; + hideRoles: boolean; + displayAvatars: boolean; + hideFlexTab: boolean; + sendOnEnter: string; + idleTimeLimit: number; + sidebarShowFavorites: boolean; + sidebarShowUnread: boolean; + sidebarSortby: string; + sidebarViewMode: string; + sidebarDisplayAvatar: boolean; + sidebarGroupByType: boolean; + sidebarCategories: ISidebarCategory[]; + muteFocusedConversations: boolean; + dontAskAgainList: { action: string; label: string }[]; + themeAppearence: ThemePreference; + fontSize?: FontSize; + receiveLoginDetectionEmail: boolean; + notifyCalendarEvents: boolean; + enableMobileRinging: boolean; + mentionsWithSymbol?: boolean; + utcOffset?: number; + statusVisibilityDenied?: string[]; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveUserPreferences(preferences: Partial): boolean; + } +} + +async function updateNotificationPreferences( + userId: ISubscription['u']['_id'], + setting: keyof ISubscription, + newValue: string, + oldValue: string, + preferenceType: keyof ISubscription, +) { + if (newValue === oldValue) { + return; + } + + if (newValue === 'default') { + const clearNotificationResponse = await Subscriptions.clearNotificationUserPreferences(userId, setting, preferenceType); + if (clearNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserPreferences(userId, preferenceType, 'user'); + } + return; + } + + const updateNotificationResponse = await Subscriptions.updateNotificationUserPreferences(userId, newValue, setting, preferenceType); + if (updateNotificationResponse.modifiedCount) { + void notifyOnSubscriptionChangedByUserPreferences(userId, preferenceType, 'subscription'); + } +} + +const MAX_CATEGORY_NAME_LENGTH = 30; + +export const validateSidebarCategories = (categories: ISidebarCategory[]): void => { + for (const category of categories) { + if (category.default) { + continue; + } + const trimmed = category.name.trim(); + if (!trimmed) { + throw new Meteor.Error('error-invalid-param', 'sidebarCategories contains a blank category name'); + } + if (trimmed.length > MAX_CATEGORY_NAME_LENGTH) { + throw new Meteor.Error('error-invalid-param', 'sidebarCategories category name exceeds maximum length'); + } + } + + const hasDuplicates = (values: string[]): boolean => new Set(values).size !== values.length; + if (hasDuplicates(categories.map((category) => category._id))) { + throw new Meteor.Error('error-invalid-param', 'sidebarCategories contains duplicate category _id values'); + } +}; + +export const saveUserPreferences = async (settings: Partial, userId: string): Promise => { + const keys = { + language: Match.Optional(String), + newRoomNotification: Match.Optional(String), + newMessageNotification: Match.Optional(String), + clockMode: Match.Optional(Number), + useEmojis: Match.Optional(Boolean), + convertAsciiEmoji: Match.Optional(Boolean), + saveMobileBandwidth: Match.Optional(Boolean), + collapseMediaByDefault: Match.Optional(Boolean), + autoImageLoad: Match.Optional(Boolean), + emailNotificationMode: Match.Optional(String), + unreadAlert: Match.Optional(Boolean), + masterVolume: Match.Optional(Number), + notificationsSoundVolume: Match.Optional(Number), + voipRingerVolume: Match.Optional(Number), + desktopNotifications: Match.Optional(String), + pushNotifications: Match.Optional(String), + enableAutoAway: Match.Optional(Boolean), + highlights: Match.Optional([String]), + hideUsernames: Match.Optional(Boolean), + hideRoles: Match.Optional(Boolean), + displayAvatars: Match.Optional(Boolean), + hideFlexTab: Match.Optional(Boolean), + sendOnEnter: Match.Optional(String), + idleTimeLimit: Match.Optional(Number), + sidebarShowFavorites: Match.Optional(Boolean), + sidebarShowUnread: Match.Optional(Boolean), + sidebarSortby: Match.Optional(String), + sidebarViewMode: Match.Optional(String), + sidebarDisplayAvatar: Match.Optional(Boolean), + sidebarGroupByType: Match.Optional(Boolean), + sidebarCategories: Match.Optional([ + { + _id: String, + name: String, + default: Match.Optional(Boolean), + showUnreads: Match.Optional(Boolean), + keepUnreadsOnTop: Match.Optional(Boolean), + }, + ]), + muteFocusedConversations: Match.Optional(Boolean), + themeAppearence: Match.Optional(String), + fontSize: Match.Optional(String), + omnichannelTranscriptEmail: Match.Optional(Boolean), + omnichannelTranscriptPDF: Match.Optional(Boolean), + omnichannelHideConversationAfterClosing: Match.Optional(Boolean), + notifyCalendarEvents: Match.Optional(Boolean), + enableMobileRinging: Match.Optional(Boolean), + mentionsWithSymbol: Match.Optional(Boolean), + utcOffset: Match.Optional(Number), + statusVisibilityDenied: Match.Optional([String]), + }; + check(settings, Match.ObjectIncluding(keys)); + + if (settings.sidebarCategories) { + validateSidebarCategories(settings.sidebarCategories); + } + + const user = await Users.findOneById(userId); + if (!user) { + return; + } + + const { + desktopNotifications: oldDesktopNotifications, + pushNotifications: oldMobileNotifications, + emailNotificationMode: oldEmailNotifications, + language: oldLanguage, + } = user.settings?.preferences || {}; + + if (user.settings == null) { + await Users.clearSettings(user._id); + } + + if (settings.language != null) { + await Users.setLanguage(user._id, settings.language); + } + + // utcOffset lives at the user-document root (not under settings.preferences) + if (settings.utcOffset != null) { + await Users.setUtcOffset(user._id, settings.utcOffset); + delete settings.utcOffset; + } + + // Keep compatibility with old values + if (settings.emailNotificationMode === 'all') { + settings.emailNotificationMode = 'mentions'; + } else if (settings.emailNotificationMode === 'disabled') { + settings.emailNotificationMode = 'nothing'; + } + + if (settings.idleTimeLimit != null && settings.idleTimeLimit < 60) { + throw new Meteor.Error('invalid-idle-time-limit-value', 'Invalid idleTimeLimit'); + } + + const requested = settings.statusVisibilityDenied?.filter((username) => username !== user.username); + const denied = requested ? await resolveUsersByUsernames(requested) : undefined; + + if (denied) { + settings.statusVisibilityDenied = denied.usernames; + } + + await Users.setPreferences(user._id, denied ? { ...settings, statusVisibilityDenied: denied.ids } : settings); + + const diff = (Object.keys(settings) as (keyof UserPreferences)[]).reduce>((data, key) => { + data[`settings.preferences.${key}`] = settings[key]; + + return data; + }, {}); + + void notifyOnUserChange({ + id: user._id, + clientAction: 'updated', + diff: { + ...diff, + ...(settings.language != null && { language: settings.language }), + }, + }); + + if (settings.statusVisibilityDenied != null && rcSettings.get('Accounts_StatusVisibility_Enabled')) { + void StatusVisibility.invalidate([user._id]); + } + + // propagate changed notification preferences + setImmediate(async () => { + const { desktopNotifications, pushNotifications, emailNotificationMode, highlights, language } = settings; + const promises = []; + + if (desktopNotifications) { + promises.push( + updateNotificationPreferences(user._id, 'desktopNotifications', desktopNotifications, oldDesktopNotifications, 'desktopPrefOrigin'), + ); + } + + if (pushNotifications) { + promises.push( + updateNotificationPreferences(user._id, 'mobilePushNotifications', pushNotifications, oldMobileNotifications, 'mobilePrefOrigin'), + ); + } + + if (emailNotificationMode) { + promises.push( + updateNotificationPreferences(user._id, 'emailNotifications', emailNotificationMode, oldEmailNotifications, 'emailPrefOrigin'), + ); + } + + await Promise.allSettled(promises); + + if (Array.isArray(highlights)) { + const response = await Subscriptions.updateUserHighlights(user._id, highlights); + if (response.modifiedCount) { + void notifyOnSubscriptionChangedByUserId(user._id); + } + } + + if (language && oldLanguage !== language && rcSettings.get('AutoTranslate_AutoEnableOnJoinRoom')) { + const workspaceLanguage = rcSettings.get('Language'); + const targetLanguage = language === 'default' || language === workspaceLanguage ? null : language; + + const response = await Subscriptions.setAutoTranslateByUserId(user._id, targetLanguage); + if (response.modifiedCount) { + void notifyOnSubscriptionChangedByAutoTranslateAndUserId(user._id); + } + } + }); +}; + +Meteor.methods({ + async saveUserPreferences(settings) { + methodDeprecationLogger.method('saveUserPreferences', '9.0.0', '/v1/users.setPreferences'); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveUserPreferences' }); + } + + await saveUserPreferences(settings, userId); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/saveUserProfile.ts b/apps/meteor/server/meteor-methods/users/saveUserProfile.ts new file mode 100644 index 0000000..2d0df93 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/saveUserProfile.ts @@ -0,0 +1,246 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { UserStatus, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { UpdateFilter } from 'mongodb'; + +import { setEmailFunction } from './setEmail'; +import { setUserStatusMethod } from './setUserStatus'; +import { getUserInfo } from '../../api/lib/getUserInfo'; +import { type AuthenticatedContext, twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; +import { passwordPolicy } from '../../lib/auth/passwordPolicy'; +import { callbacks } from '../../lib/callbacks'; +import { compareUserPassword } from '../../lib/compareUserPassword'; +import { compareUserPasswordHistory } from '../../lib/compareUserPasswordHistory'; +import { notifyOnUserChange } from '../../lib/notifyListener'; +import { saveCustomFields } from '../../lib/users/saveCustomFields'; +import { validateUserEditing } from '../../lib/users/saveUser'; +import { saveUserIdentity } from '../../lib/users/saveUserIdentity'; +import { settings as rcSettings } from '../../settings'; + +const MAX_BIO_LENGTH = 260; +const MAX_NICKNAME_LENGTH = 120; + +async function saveUserProfile( + this: AuthenticatedContext, + settings: { + email?: string; + username?: string; + realname?: string; + newPassword?: string; + statusText?: string; + statusType?: string; + bio?: string; + nickname?: string; + }, + customFields: Record, + ..._: unknown[] +) { + const unset: UpdateFilter = {}; + if (!rcSettings.get('Accounts_AllowUserProfileChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'saveUserProfile', + }); + } + + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveUserProfile', + }); + } + + await validateUserEditing(this.userId, { + _id: this.userId, + email: settings.email, + username: settings.username, + name: settings.realname, + password: settings.newPassword, + statusText: settings.statusText, + }); + + const user = await Users.findOneById(this.userId); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'saveUserProfile', + }); + } + + if (settings.realname || settings.username) { + if ( + !(await saveUserIdentity({ + _id: this.userId, + name: settings.realname, + username: settings.username, + })) + ) { + throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { + method: 'saveUserProfile', + }); + } + } + + if (settings.statusType || settings.statusText != null) { + await setUserStatusMethod(user, settings.statusType as UserStatus, settings.statusText); + } + + if (user && (settings.bio || settings.bio === '')) { + if (typeof settings.bio !== 'string') { + throw new Meteor.Error('error-invalid-field', 'bio', { + method: 'saveUserProfile', + }); + } + if (settings.bio.length > MAX_BIO_LENGTH) { + throw new Meteor.Error('error-bio-size-exceeded', `Bio size exceeds ${MAX_BIO_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + await Users.setBio(user._id, settings.bio.trim()); + } + + if (user && (settings.nickname || settings.nickname === '')) { + if (typeof settings.nickname !== 'string') { + throw new Meteor.Error('error-invalid-field', 'nickname', { + method: 'saveUserProfile', + }); + } + if (settings.nickname.length > MAX_NICKNAME_LENGTH) { + throw new Meteor.Error('error-nickname-size-exceeded', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters`, { + method: 'saveUserProfile', + }); + } + await Users.setNickname(user._id, settings.nickname.trim()); + } + + if (user && settings.email) { + await setEmailFunction(settings.email, user); + } + + const canChangePasswordForOAuth = rcSettings.get('Accounts_AllowPasswordChangeForOAuthUsers'); + if (canChangePasswordForOAuth || user?.services?.password) { + // Should be the last check to prevent error when trying to check password for users without password + if (settings.newPassword && rcSettings.get('Accounts_AllowPasswordChange') === true && user?.services?.password?.bcrypt) { + // don't let user change to same password + if (user && (await compareUserPassword(user, { plain: settings.newPassword }))) { + throw new Meteor.Error('error-password-same-as-current', 'Entered password same as current password', { + method: 'saveUserProfile', + }); + } + + if (user?.services?.passwordHistory && !(await compareUserPasswordHistory(user, { plain: settings.newPassword }))) { + throw new Meteor.Error('error-password-in-history', 'Entered password has been previously used', { + method: 'saveUserProfile', + }); + } + + passwordPolicy.validate(settings.newPassword); + + await Accounts.setPasswordAsync(this.userId, settings.newPassword, { + logout: false, + }); + + if (user.requirePasswordChange) { + await Users.unsetRequirePasswordChange(user._id); + unset.requirePasswordChange = true; + unset.requirePasswordChangeReason = true; + } + + await Users.addPasswordToHistory( + this.userId, + user.services?.password.bcrypt, + rcSettings.get('Accounts_Password_History_Amount'), + ); + + try { + await Users.removeNonLoginTokensExcept(this.userId, this.token); + } catch (e) { + Accounts._clearAllLoginTokens(this.userId); + } + } + } + + await Users.setProfile(this.userId, {}); + + if (customFields && Object.keys(customFields).length) { + await saveCustomFields(this.userId, customFields); + } + + // App IPostUserUpdated event hook + const updatedUser = await Users.findOneById(this.userId); + + // This should never happen, but since `Users.findOneById` might return null, we'll handle it just in case + if (!updatedUser) { + throw new Error('Unexpected error after saving user profile: user not found'); + } + + await callbacks.run('afterSaveUser', { + user: updatedUser, + oldUser: user, + }); + + void notifyOnUserChange({ + clientAction: 'updated', + id: updatedUser._id, + diff: await getUserInfo(updatedUser), + ...(Object.keys(unset).length > 0 && { unset }), + }); + + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: updatedUser, previousUser: user }); + + return true; +} + +const saveUserProfileWithTwoFactor = twoFactorRequired(saveUserProfile, { + requireSecondFactor: true, +}); + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + saveUserProfile( + settings: { + email?: string; + username?: string; + realname?: string; + newPassword?: string; + statusText?: string; + statusType?: string; + bio?: string; + nickname?: string; + }, + customFields: Record, + ...args: unknown[] + ): boolean; + } +} + +export function executeSaveUserProfile( + this: AuthenticatedContext, + user: IUser, + settings: { + email?: string; + username?: string; + realname?: string; + newPassword?: string; + statusText?: string; + statusType?: string; + bio?: string; + nickname?: string; + }, + customFields: Record = {}, + ...args: unknown[] +) { + check(settings, Object); + check(customFields, Match.Maybe(Object)); + + if ( + (settings.email && user.emails?.length) || + (settings.newPassword && Object.keys(user.services || {}).length && !user.requirePasswordChange) + ) { + return saveUserProfileWithTwoFactor.call(this, settings, customFields, ...args); + } + + return saveUserProfile.call(this, settings, customFields, ...args); +} diff --git a/apps/meteor/server/meteor-methods/users/setAvatarFromService.ts b/apps/meteor/server/meteor-methods/users/setAvatarFromService.ts new file mode 100644 index 0000000..4470f57 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/setAvatarFromService.ts @@ -0,0 +1,47 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Match, check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { setAvatarFromServiceWithValidation } from '../../lib/users/setUserAvatar'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setAvatarFromService(dataURI: Buffer | Blob, contentType?: string, service?: string, targetUserId?: string): void; + } +} + +Meteor.methods({ + async setAvatarFromService(dataURI, contentType, service, targetUserId) { + methodDeprecationLogger.method('setAvatarFromService', '9.0.0', '/v1/users.setAvatar'); + + check(dataURI, String); + check(contentType, Match.Optional(String)); + check(service, Match.Optional(String)); + check(targetUserId, Match.Optional(String)); + + const uid = Meteor.userId(); + + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'setAvatarFromService', + }); + } + + return setAvatarFromServiceWithValidation(uid, dataURI, contentType, service, targetUserId); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'setAvatarFromService', + userId() { + return true; + }, + }, + 1, + 5000, +); diff --git a/apps/meteor/server/meteor-methods/users/setEmail.ts b/apps/meteor/server/meteor-methods/users/setEmail.ts new file mode 100644 index 0000000..7268514 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/setEmail.ts @@ -0,0 +1,58 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { setEmail } from '../../lib/users/setEmail'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setEmail(email: string): string; + } +} + +export const setEmailFunction = async (email: string, user: Meteor.User | IUser) => { + check(email, String); + + if (!settings.get('Accounts_AllowEmailChange')) { + throw new Meteor.Error('error-action-not-allowed', 'Changing email is not allowed', { + method: 'setEmail', + action: 'Changing_email', + }); + } + + if (user.emails?.[0]?.address === email) { + return email; + } + + if (!(await setEmail(user._id, email))) { + throw new Meteor.Error('error-could-not-change-email', 'Could not change email', { + method: 'setEmail', + }); + } + + return email; +}; + +Meteor.methods({ + async setEmail(email) { + methodDeprecationLogger.method('setEmail', '9.0.0', '/v1/users.updateOwnBasicInfo'); + const user = await Meteor.userAsync(); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setEmail' }); + } + + return setEmailFunction(email, user); + }, +}); + +RateLimiter.limitMethod('setEmail', 1, 1000, { + userId(/* userId*/) { + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/setRealName.ts b/apps/meteor/server/meteor-methods/users/setRealName.ts new file mode 100644 index 0000000..62864aa --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/setRealName.ts @@ -0,0 +1,42 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { setRealName } from '../../lib/users/setRealName'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setRealName(name: string): string; + } +} + +Meteor.methods({ + async setRealName(name) { + methodDeprecationLogger.method('setRealName', '9.0.0', '/v1/users.updateOwnBasicInfo'); + check(name, String); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setRealName' }); + } + + if (!settings.get('Accounts_AllowRealNameChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setRealName' }); + } + + if (!(await setRealName(userId, name))) { + throw new Meteor.Error('error-could-not-change-name', 'Could not change name', { + method: 'setRealName', + }); + } + + return name; + }, +}); + +RateLimiter.limitMethod('setRealName', 1, 1000, { + userId: () => true, +}); diff --git a/apps/meteor/server/meteor-methods/users/setUserActiveStatus.ts b/apps/meteor/server/meteor-methods/users/setUserActiveStatus.ts new file mode 100644 index 0000000..b4a940c --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/setUserActiveStatus.ts @@ -0,0 +1,46 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { setUserActiveStatus } from '../../lib/users/setUserActiveStatus'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setUserActiveStatus(userId: string, active: boolean, confirmRelinquish?: boolean): boolean; + } +} + +export const executeSetUserActiveStatus = async ( + fromUserId: string, + userId: string, + active: boolean, + confirmRelinquish?: boolean, +): Promise => { + check(userId, String); + check(active, Boolean); + + if (!fromUserId || (await hasPermissionAsync(fromUserId, 'edit-other-user-active-status')) !== true) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'setUserActiveStatus', + }); + } + + await setUserActiveStatus(userId, active, confirmRelinquish, fromUserId); + + return true; +}; + +Meteor.methods({ + async setUserActiveStatus(userId, active, confirmRelinquish) { + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'setUserActiveStatus', + }); + } + + return executeSetUserActiveStatus(uid, userId, active, confirmRelinquish); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/setUserStatus.ts b/apps/meteor/server/meteor-methods/users/setUserStatus.ts new file mode 100644 index 0000000..a9f8a1b --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/setUserStatus.ts @@ -0,0 +1,51 @@ +import { Presence } from '@rocket.chat/core-services'; +import { UserStatus, type IUser } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; +import { settings } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + setUserStatus(statusType: IUser['status'], statusText: IUser['statusText']): void; + } +} + +export const setUserStatusMethod = async ( + user: Pick, + statusType: IUser['status'], + statusText: IUser['statusText'], +): Promise => { + if (statusText != null && !settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'setUserStatus', + }); + } + + const effectiveStatus = statusType || user.statusDefault || UserStatus.ONLINE; + + if (effectiveStatus === UserStatus.OFFLINE && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'setUserStatus', + }); + } + + await Presence.setStatus(user._id, effectiveStatus, statusText); +}; + +Meteor.methods({ + setUserStatus: async (statusType, statusText) => { + const user = (await Meteor.userAsync()) as IUser; + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setUserStatus' }); + } + + await setUserStatusMethod(user, statusType, statusText); + }, +}); + +RateLimiter.limitMethod('setUserStatus', 1, 1000, { + userId: () => true, +}); diff --git a/apps/meteor/server/meteor-methods/users/unblockUser.ts b/apps/meteor/server/meteor-methods/users/unblockUser.ts new file mode 100644 index 0000000..29ed93e --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/unblockUser.ts @@ -0,0 +1,31 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { unblockUserMethod } from '../../lib/users/unblockUser'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + unblockUser({ rid, blocked }: { rid: string; blocked: string }): boolean; + } +} + +Meteor.methods({ + async unblockUser({ rid, blocked }) { + methodDeprecationLogger.method('unblockUser', '9.0.0', '/v1/im.blockUser'); + check(rid, String); + check(blocked, String); + + const userId = Meteor.userId(); + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unblockUser' }); + } + + await unblockUserMethod(userId, { rid, blocked }); + + return true; + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/userPresence.ts b/apps/meteor/server/meteor-methods/users/userPresence.ts new file mode 100644 index 0000000..aa3a2fb --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/userPresence.ts @@ -0,0 +1,40 @@ +import { Presence } from '@rocket.chat/core-services'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'UserPresence:setDefaultStatus'(status: UserStatus): boolean | undefined; + 'UserPresence:online'(): boolean | undefined; + 'UserPresence:away'(): boolean | undefined; + } +} + +Meteor.methods({ + 'UserPresence:setDefaultStatus'(status) { + methodDeprecationLogger.method('UserPresence:setDefaultStatus', '9.0.0', '/v1/users.setStatus'); + const { userId } = this; + if (!userId) { + return; + } + return Presence.setStatus(userId, status); + }, + 'UserPresence:online'() { + const { userId, connection } = this; + if (!userId || !connection) { + return; + } + return Presence.setConnectionStatus(userId, UserStatus.ONLINE, connection.id); + }, + 'UserPresence:away'() { + const { userId, connection } = this; + if (!userId || !connection) { + return; + } + return Presence.setConnectionStatus(userId, UserStatus.AWAY, connection.id); + }, +}); diff --git a/apps/meteor/server/meteor-methods/users/userSetUtcOffset.ts b/apps/meteor/server/meteor-methods/users/userSetUtcOffset.ts new file mode 100644 index 0000000..4ccfcc1 --- /dev/null +++ b/apps/meteor/server/meteor-methods/users/userSetUtcOffset.ts @@ -0,0 +1,39 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Users } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + userSetUtcOffset(utcOffset: number): void; + } +} + +Meteor.methods({ + async userSetUtcOffset(utcOffset) { + methodDeprecationLogger.method('userSetUtcOffset', '9.0.0', '/v1/users.setPreferences'); + check(utcOffset, Number); + + if (!this.userId) { + return; + } + + await Users.setUtcOffset(this.userId, utcOffset); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'userSetUtcOffset', + userId() { + return true; + }, + }, + 1, + 60000, +); diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts new file mode 100644 index 0000000..b984186 --- /dev/null +++ b/apps/meteor/server/models.ts @@ -0,0 +1,165 @@ +import type { RocketChatRecordDeleted, ISubscription, ILivechatInquiryRecord, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { + AnalyticsRaw, + AppsLogsModel, + AppsModel, + AppsPersistenceModel, + AvatarsRaw, + BannersDismissRaw, + BannersRaw, + CalendarEventRaw, + CallHistoryRaw, + CredentialTokensRaw, + CronHistoryRaw, + CustomSoundsRaw, + CustomUserStatusRaw, + EmailInboxRaw, + EmailMessageHistoryRaw, + EmojiCustomRaw, + ExportOperationsRaw, + FederationKeysRaw, + ImportDataRaw, + ImportsModel, + InstanceStatusRaw, + IntegrationHistoryRaw, + IntegrationsRaw, + InvitesRaw, + LivechatAgentActivityRaw, + LivechatBusinessHoursRaw, + LivechatContactsRaw, + LivechatCustomFieldRaw, + LivechatDepartmentAgentsRaw, + LivechatDepartmentRaw, + LivechatInquiryRaw, + LivechatPriorityRaw, + LivechatRoomsRaw, + LivechatTriggerRaw, + LivechatVisitorsRaw, + LoginServiceConfigurationRaw, + MediaCallsRaw, + MediaCallNegotiationsRaw, + MessageReadsRaw, + MessagesRaw, + MigrationsRaw, + ModerationReportsRaw, + NotificationQueueRaw, + NpsRaw, + NpsVoteRaw, + OAuthAccessTokensRaw, + OAuthAppsRaw, + OAuthAuthCodesRaw, + LoginCodesRaw, + OAuthRefreshTokensRaw, + OEmbedCacheRaw, + PermissionsRaw, + PushTokenRaw, + ReadReceiptsDummy, + registerModel, + ReportsRaw, + RolesRaw, + RoomsRaw, + ServerEventsRaw, + SessionsRaw, + SettingsRaw, + SmarshHistoryRaw, + StatisticsRaw, + SubscriptionsRaw, + TeamMemberRaw, + TeamRaw, + UploadsRaw, + UserDataFilesRaw, + UsersRaw, + UsersSessionsRaw, + VideoConferenceRaw, + WebdavAccountsRaw, + WorkspaceCredentialsRaw, + AbacAttributesRaw, + TwoFactorChallengesRaw, + SamlUsedAssertionsRaw, +} from '@rocket.chat/models'; +import type { Collection } from 'mongodb'; + +import { trashCollection } from './database/trash'; +import { db } from './database/utils'; + +registerModel('IAnalyticsModel', new AnalyticsRaw(db)); +registerModel('IAppLogsModel', new AppsLogsModel(db)); +registerModel('IAppsModel', new AppsModel(db)); +registerModel('IAppsPersistenceModel', new AppsPersistenceModel(db)); +registerModel('IAvatarsModel', new AvatarsRaw(db)); +registerModel('IBannersDismissModel', new BannersDismissRaw(db)); +registerModel('IBannersModel', new BannersRaw(db)); +registerModel('ICalendarEventModel', new CalendarEventRaw(db)); +registerModel('ICallHistoryModel', new CallHistoryRaw(db)); +registerModel('ICredentialTokensModel', new CredentialTokensRaw(db)); +registerModel('ICronHistoryModel', new CronHistoryRaw(db)); +registerModel('ICustomSoundsModel', new CustomSoundsRaw(db)); +registerModel('ICustomUserStatusModel', new CustomUserStatusRaw(db)); +registerModel('IEmailInboxModel', new EmailInboxRaw(db)); +registerModel('IEmailMessageHistoryModel', new EmailMessageHistoryRaw(db)); +registerModel('IEmojiCustomModel', new EmojiCustomRaw(db, trashCollection)); +registerModel('IExportOperationsModel', new ExportOperationsRaw(db)); +registerModel('IFederationKeysModel', new FederationKeysRaw(db)); +registerModel('IImportDataModel', new ImportDataRaw(db)); +registerModel('IImportsModel', new ImportsModel(db)); +registerModel('IInstanceStatusModel', new InstanceStatusRaw(db)); +registerModel('IIntegrationHistoryModel', new IntegrationHistoryRaw(db)); +registerModel('IIntegrationsModel', new IntegrationsRaw(db)); +registerModel('IInvitesModel', new InvitesRaw(db)); +registerModel('ILivechatAgentActivityModel', new LivechatAgentActivityRaw(db)); +registerModel('ILivechatBusinessHoursModel', new LivechatBusinessHoursRaw(db)); +registerModel('ILivechatContactsModel', new LivechatContactsRaw(db)); +registerModel('ILivechatCustomFieldModel', new LivechatCustomFieldRaw(db)); +registerModel( + 'ILivechatDepartmentAgentsModel', + new LivechatDepartmentAgentsRaw(db, trashCollection as Collection>), +); +registerModel('ILivechatDepartmentModel', new LivechatDepartmentRaw(db, trashCollection)); +registerModel( + 'ILivechatInquiryModel', + new LivechatInquiryRaw(db, trashCollection as Collection>), +); +registerModel('ILivechatRoomsModel', new LivechatRoomsRaw(db, trashCollection)); +registerModel('ILivechatPriorityModel', new LivechatPriorityRaw(db)); +registerModel('ILivechatTriggerModel', new LivechatTriggerRaw(db)); +registerModel('ILivechatVisitorsModel', new LivechatVisitorsRaw(db)); +registerModel('ILoginServiceConfigurationModel', new LoginServiceConfigurationRaw(db)); +registerModel('IMediaCallsModel', new MediaCallsRaw(db)); +registerModel('IMediaCallNegotiationsModel', new MediaCallNegotiationsRaw(db)); +registerModel('IMessageReadsModel', new MessageReadsRaw(db)); +registerModel('IMessagesModel', new MessagesRaw(db, trashCollection)); +registerModel('IMigrationsModel', new MigrationsRaw(db)); +registerModel('IModerationReportsModel', new ModerationReportsRaw(db)); +registerModel('INotificationQueueModel', new NotificationQueueRaw(db)); +registerModel('INpsModel', new NpsRaw(db)); +registerModel('INpsVoteModel', new NpsVoteRaw(db)); +registerModel('IOAuthAccessTokensModel', new OAuthAccessTokensRaw(db)); +registerModel('IOAuthAppsModel', new OAuthAppsRaw(db)); +registerModel('IOAuthAuthCodesModel', new OAuthAuthCodesRaw(db)); +registerModel('ILoginCodesModel', new LoginCodesRaw(db)); +registerModel('IOAuthRefreshTokensModel', new OAuthRefreshTokensRaw(db)); +registerModel('IOEmbedCacheModel', new OEmbedCacheRaw(db)); +registerModel('IPermissionsModel', new PermissionsRaw(db, trashCollection)); +registerModel('IPushTokenModel', new PushTokenRaw(db)); +registerModel('IReadReceiptsModel', new ReadReceiptsDummy(), false); +registerModel('IReportsModel', new ReportsRaw(db)); +registerModel('IRolesModel', new RolesRaw(db, trashCollection)); +registerModel('IRoomsModel', new RoomsRaw(db, trashCollection)); +registerModel('IServerEventsModel', new ServerEventsRaw(db)); +registerModel('ISessionsModel', new SessionsRaw(db)); +registerModel('ISettingsModel', new SettingsRaw(db, trashCollection)); +registerModel('ISmarshHistoryModel', new SmarshHistoryRaw(db)); +registerModel('IStatisticsModel', new StatisticsRaw(db)); +registerModel('ISubscriptionsModel', new SubscriptionsRaw(db, trashCollection as Collection>)); +registerModel('ITeamMemberModel', new TeamMemberRaw(db)); +registerModel('ITeamModel', new TeamRaw(db)); +registerModel('IUploadsModel', new UploadsRaw(db)); +registerModel('IUserDataFilesModel', new UserDataFilesRaw(db)); +registerModel('IUsersModel', new UsersRaw(db, trashCollection)); +registerModel('IUsersSessionsModel', new UsersSessionsRaw(db)); +registerModel('IVideoConferenceModel', new VideoConferenceRaw(db)); +registerModel('IWebdavAccountsModel', new WebdavAccountsRaw(db)); +registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); +registerModel('IAbacAttributesModel', new AbacAttributesRaw(db)); +registerModel('ITwoFactorChallengesModel', new TwoFactorChallengesRaw(db)); +registerModel('ISamlUsedAssertionsModel', new SamlUsedAssertionsRaw(db)); diff --git a/apps/meteor/server/modules/core-apps/banner.module.ts b/apps/meteor/server/modules/core-apps/banner.module.ts new file mode 100644 index 0000000..2af0411 --- /dev/null +++ b/apps/meteor/server/modules/core-apps/banner.module.ts @@ -0,0 +1,36 @@ +import { Banner } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +export class BannerModule implements IUiKitCoreApp { + appId = 'banner-core'; + + // when banner view is closed we need to dismiss that banner for that user + async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { + const { + payload: { view: { viewId: bannerId } = {} }, + user: { _id: userId } = {}, + } = payload; + + if (!userId) { + throw new Error('invalid user'); + } + + if (!bannerId) { + throw new Error('invalid banner'); + } + + if (!payload.triggerId) { + throw new Error('invalid triggerId'); + } + + await Banner.dismiss(userId, bannerId); + + return { + type: 'banner.close', + triggerId: payload.triggerId, + appId: payload.appId, + viewId: bannerId, + }; + } +} diff --git a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts new file mode 100644 index 0000000..e72a837 --- /dev/null +++ b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts @@ -0,0 +1,297 @@ +import { Banner } from '@rocket.chat/core-services'; +import type { + IUiKitCoreApp, + UiKitCoreAppBlockActionPayload, + UiKitCoreAppViewClosedPayload, + UiKitCoreAppViewSubmitPayload, +} from '@rocket.chat/core-services'; +import type { Cloud, IUser } from '@rocket.chat/core-typings'; +import { Banners } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { isTruthy } from '@rocket.chat/tools'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; +import { InvalidCloudAnnouncementInteractionError } from '../../../lib/errors/InvalidCloudAnnouncementInteractionError'; +import { InvalidCoreAppInteractionError } from '../../../lib/errors/InvalidCoreAppInteractionError'; +import { getWorkspaceAccessToken } from '../../lib/cloud'; +import { assertNotOfflineLicense } from '../../lib/cloud/offlineLicense'; +import { syncWorkspace } from '../../lib/cloud/syncWorkspace'; +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +type CloudAnnouncementInteractant = + | { + user: Pick; + } + | { + visitor: Pick, 'id' | 'username' | 'name' | 'department' | 'phone'>; + }; + +type CloudAnnouncementInteractionRequest = UiKit.UserInteraction & CloudAnnouncementInteractant; + +export class CloudAnnouncementsModule implements IUiKitCoreApp { + appId = 'cloud-announcements-core'; + + protected async getWorkspaceAccessToken() { + return getWorkspaceAccessToken(true); + } + + protected getCloudUrl() { + return settings.get('Cloud_Url'); + } + + blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { + return this.handlePayload(payload); + } + + viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise { + return this.handlePayload(payload); + } + + async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { + const { + payload: { view: { viewId, id } = {} }, + user: { _id: userId } = {}, + } = payload; + + if (!userId) { + throw new Error('invalid user'); + } + + if (!id && !viewId) { + throw new Error('invalid view'); + } + + if (!payload.triggerId) { + throw new Error('invalid triggerId'); + } + + // For backwards compatibility: we prefer to use viewId, but some legacy banners + // may only have id. We fetch all matching banners and prioritize viewId match. + const bannerIds = [viewId, id].filter((bannerId) => isTruthy(bannerId)); + const banners = await Banners.findByIds(bannerIds).toArray(); + const announcement = banners.find((b) => b._id === viewId) || banners.find((b) => b._id === id); + if (!announcement) { + throw new Error('Banner not found'); + } + + await Banner.dismiss(userId, announcement._id); + + const type = announcement.surface === 'banner' ? 'banner.close' : 'modal.close'; + + // for viewClosed we just need to let Cloud know that the banner was closed, no need to wait for the response + + void this.handlePayload(payload); + + return { + type, + triggerId: payload.triggerId, + appId: payload.appId, + viewId: announcement._id, + }; + } + + protected async handlePayload( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): Promise { + const interactant = this.getInteractant(payload); + const interaction = this.getInteraction(payload); + + try { + const serverInteraction = await this.pushUserInteraction(interactant, interaction); + + if (serverInteraction.appId !== this.appId) { + throw new InvalidCloudAnnouncementInteractionError(`Invalid appId`); + } + + if (serverInteraction.triggerId !== interaction.triggerId) { + throw new InvalidCloudAnnouncementInteractionError(`Invalid triggerId`); + } + + return serverInteraction; + } catch (err) { + SystemLogger.error({ err }); + return undefined; + } + } + + protected getInteractant( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): CloudAnnouncementInteractant { + if (payload.user) { + return { + user: { + _id: payload.user._id, + username: payload.user.username, + name: payload.user.name, + }, + }; + } + + if ('visitor' in payload && payload.visitor) { + return { + visitor: { + id: payload.visitor.id, + username: payload.visitor.username, + name: payload.visitor.name, + department: payload.visitor.department, + phone: payload.visitor.phone, + }, + }; + } + + throw new CloudWorkspaceConnectionError(`Invalid user data received from Rocket.Chat Cloud`); + } + + /** + * Transform the payload received from the Core App back to the format the UI sends from the client + */ + protected getInteraction( + payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload, + ): UiKit.UserInteraction { + if (payload.type === 'blockAction' && payload.container?.type === 'message') { + const { + actionId, + payload: { blockId, value }, + message, + room, + triggerId, + } = payload; + + if (!actionId || !blockId || !triggerId) { + throw new InvalidCoreAppInteractionError(); + } + + return { + type: 'blockAction', + actionId, + payload: { + blockId, + value, + }, + container: { + type: 'message', + id: String(message), + }, + mid: String(message), + tmid: undefined, + rid: String(room), + triggerId, + }; + } + + if (payload.type === 'blockAction' && payload.container?.type === 'view') { + const { + actionId, + payload: { blockId, value }, + container: { id }, + triggerId, + } = payload; + + if (!actionId || !blockId || !triggerId) { + throw new InvalidCoreAppInteractionError(); + } + + return { + type: 'blockAction', + actionId, + payload: { + blockId, + value, + }, + container: { + type: 'view', + id, + }, + triggerId, + }; + } + + if (payload.type === 'viewClosed') { + const { + payload: { view, isCleared }, + triggerId, + } = payload; + + if (!view?.id || !triggerId) { + throw new InvalidCoreAppInteractionError(); + } + + return { + type: 'viewClosed', + payload: { + viewId: view.id, + view: view as any, + isCleared: Boolean(isCleared), + }, + triggerId, + }; + } + + if (payload.type === 'viewSubmit') { + const { + payload: { view }, + triggerId, + } = payload; + + if (!view?.id || !triggerId) { + throw new InvalidCoreAppInteractionError(); + } + + return { + type: 'viewSubmit', + payload: { + view: view as any, + }, + triggerId, + viewId: view.id, + }; + } + + throw new InvalidCoreAppInteractionError(); + } + + protected async pushUserInteraction( + interactant: CloudAnnouncementInteractant, + userInteraction: UiKit.UserInteraction, + ): Promise { + assertNotOfflineLicense(); + + const token = await this.getWorkspaceAccessToken(); + + const request: CloudAnnouncementInteractionRequest = { + ...interactant, + ...userInteraction, + }; + + const response = await fetch(`${this.getCloudUrl()}/api/v3/comms/workspace/interaction`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(request), + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } + + const payload: Cloud.WorkspaceInteractionResponsePayload = await response.json(); + + const { serverInteraction, serverAction } = payload; + + if (serverAction) { + switch (serverAction) { + case 'syncWorkspace': { + await syncWorkspace(); + break; + } + } + } + + return serverInteraction; + } +} diff --git a/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts b/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts new file mode 100644 index 0000000..37becfb --- /dev/null +++ b/apps/meteor/server/modules/core-apps/cloudSubscriptionCommunication.module.ts @@ -0,0 +1,37 @@ +import type { UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +import { CloudAnnouncementsModule } from './cloudAnnouncements.module'; + +export class CloudSubscriptionCommunication extends CloudAnnouncementsModule { + override appId = 'cloud-communication-core'; + + override async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise { + const { + payload: { view: { viewId } = {} }, + user: { _id: userId } = {}, + } = payload; + + if (!userId) { + throw new Error('invalid user'); + } + + if (!viewId) { + throw new Error('invalid view'); + } + + if (!payload.triggerId) { + throw new Error('invalid triggerId'); + } + + // for viewClosed we just need to let Cloud know that the banner was closed, no need to wait for the response + + void this.handlePayload(payload); + + return { + type: 'modal.close', + triggerId: payload.triggerId, + appId: payload.appId, + }; + } +} diff --git a/apps/meteor/server/modules/core-apps/mention.module.ts b/apps/meteor/server/modules/core-apps/mention.module.ts new file mode 100644 index 0000000..4edc4db --- /dev/null +++ b/apps/meteor/server/modules/core-apps/mention.module.ts @@ -0,0 +1,118 @@ +import { api } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from '../../lib/i18n'; +import { processWebhookMessage } from '../../lib/messages/processWebhookMessage'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; + +const retrieveMentionsFromPayload = (stringifiedMentions: string): Exclude => { + try { + const mentions = JSON.parse(stringifiedMentions); + if (!Array.isArray(mentions) || !mentions.length || !('username' in mentions[0])) { + throw new Error('Invalid payload'); + } + return mentions; + } catch (error) { + throw new Error('Invalid payload'); + } +}; + +export class MentionModule implements IUiKitCoreApp { + appId = 'mention-core'; + + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { + const { + actionId, + payload: { value: stringifiedMentions, blockId: referenceMessageId }, + } = payload; + + const user = payload.user!; + const room = payload.room!; + + const mentions = retrieveMentionsFromPayload(stringifiedMentions as string); + + const usernames = mentions.map(({ username }) => username); + + const message = await Messages.findOneById(referenceMessageId, { projection: { _id: 1, tmid: 1 } }); + + if (!message) { + throw new Error('Mention bot - Failed to retrieve message information'); + } + + const joinedUsernames = `@${usernames.join(', @')}`; + + if (actionId === 'dismiss') { + void api.broadcast('notify.ephemeralMessage', user._id, room, { + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { + mentions: joinedUsernames, + lng: user.language, + }), + _id: payload.message, + tmid: message.tmid, + mentions, + }); + return undefined; + } + + if (actionId === 'add-users') { + void addUsersToRoomMethod(user._id, { rid: room, users: usernames as string[] }, user); + void api.broadcast('notify.ephemeralMessage', user._id, room, { + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', { + mentions: joinedUsernames, + lng: user.language, + }), + tmid: message.tmid, + _id: payload.message, + mentions, + }); + return undefined; + } + + if (actionId === 'share-message') { + const sub = await Subscriptions.findOneByRoomIdAndUserId(room, user._id, { projection: { t: 1, rid: 1, name: 1 } }); + // this should exist since the event is fired from withing the room (e.g the user sent a message) + if (!sub) { + throw new Error('Mention bot - Failed to retrieve room information'); + } + + const roomPath = roomCoordinator.getRouteLink(sub.t, { rid: sub.rid, name: sub.name }); + if (!roomPath) { + throw new Error('Mention bot - Failed to retrieve path to room'); + } + + const messageText = i18n.t('Youre_not_a_part_of__channel__and_I_mentioned_you_there', { + channel: `#${sub.name}`, + lng: user.language, + }); + + const link = new URL(Meteor.absoluteUrl(roomPath)); + link.searchParams.set('msg', message._id); + const text = `[ ](${link.toString()})\n${messageText}`; + + // forwards message to all DMs + await processWebhookMessage( + { + roomId: mentions.map(({ _id }) => _id), + text, + separateResponse: true, // so that messages are sent to other DMs even if one or more fails + }, + user as IUser & { username: string }, + ); + + void api.broadcast('notify.ephemeralMessage', user._id, room, { + msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm', { + mentions: joinedUsernames, + lng: user.language, + }), + tmid: message.tmid, + _id: payload.message, + mentions, + }); + return undefined; + } + } +} diff --git a/apps/meteor/server/modules/core-apps/nps.module.ts b/apps/meteor/server/modules/core-apps/nps.module.ts new file mode 100644 index 0000000..16e1763 --- /dev/null +++ b/apps/meteor/server/modules/core-apps/nps.module.ts @@ -0,0 +1,70 @@ +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload, UiKitCoreAppViewSubmitPayload } from '@rocket.chat/core-services'; +import { Banner, NPS } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +import { createModal } from './nps/createModal'; + +export class Nps implements IUiKitCoreApp { + appId = 'nps-core'; + + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { + const { + triggerId, + actionId, + container: { id: viewId } = {}, + payload: { value: score, blockId: npsId }, + user, + } = payload; + + if (!viewId || !triggerId || !user || !npsId) { + throw new Error('Invalid payload'); + } + + const bannerId = viewId.replace(`${npsId}-`, ''); + + return createModal({ + type: actionId === 'nps-score' ? 'modal.update' : 'modal.open', + id: `${npsId}-${bannerId}`, + appId: this.appId, + npsId, + triggerId, + score: String(score), + user, + }); + } + + async viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise { + if (!payload.payload?.view?.state || !payload.payload?.view?.id) { + throw new Error('Invalid payload'); + } + + const { + payload: { + view: { state, id: viewId }, + }, + user: { _id: userId, roles } = {}, + } = payload; + + const [npsId] = Object.keys(state); + + const bannerId = viewId.replace(`${npsId}-`, ''); + + const { + [npsId]: { 'nps-score': score, comment }, + } = state; + + await NPS.vote({ + npsId, + userId, + comment: String(comment), + roles, + score: Number(score), + }); + + if (!userId) { + throw new Error('invalid user'); + } + + await Banner.dismiss(userId, bannerId); + } +} diff --git a/apps/meteor/server/modules/core-apps/nps/createModal.ts b/apps/meteor/server/modules/core-apps/nps/createModal.ts new file mode 100644 index 0000000..284690a --- /dev/null +++ b/apps/meteor/server/modules/core-apps/nps/createModal.ts @@ -0,0 +1,89 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import { i18n } from '../../../lib/i18n'; +import { settings } from '../../../settings'; + +type ModalParams = { + id: string; + type: string; + appId: string; + npsId: string; + triggerId: string; + score: string; + user: IUser; +}; + +export const createModal = ({ type = 'modal.open', id, appId, npsId, triggerId, score, user }: ModalParams): any => { + const language = user.language || settings.get('Language') || 'en'; + + return { + type, + triggerId, + appId, + view: { + appId, + type: 'modal', + id, + title: { + type: 'plain_text', + text: i18n.t('We_appreciate_your_feedback', { lng: language }), + emoji: false, + }, + submit: { + type: 'button', + text: { + type: 'plain_text', + text: i18n.t('Send', { lng: language }), + emoji: false, + }, + actionId: 'send-vote', + }, + close: { + type: 'button', + text: { + type: 'plain_text', + text: i18n.t('Cancel', { lng: language }), + emoji: false, + }, + actionId: 'cancel', + }, + blocks: [ + { + blockId: npsId, + type: 'actions', + elements: [ + { + type: 'linear_scale', + initialValue: score, + actionId: 'nps-score', + preLabel: { type: 'plain_text', text: i18n.t('Not_likely', { lng: language }) }, + postLabel: { + type: 'plain_text', + text: i18n.t('Extremely_likely', { lng: language }), + }, + }, + ], + label: { + type: 'plain_text', + text: i18n.t('Score', { lng: language }), + emoji: false, + }, + }, + { + blockId: npsId, + type: 'input', + element: { + type: 'plain_text_input', + multiline: true, + actionId: 'comment', + }, + label: { + type: 'plain_text', + text: i18n.t('Why_did_you_chose__score__', { score, lng: language }), + emoji: false, + }, + }, + ], + }, + }; +}; diff --git a/apps/meteor/server/modules/core-apps/videoconf.module.ts b/apps/meteor/server/modules/core-apps/videoconf.module.ts new file mode 100644 index 0000000..1f2765b --- /dev/null +++ b/apps/meteor/server/modules/core-apps/videoconf.module.ts @@ -0,0 +1,57 @@ +import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services'; +import { VideoConf } from '@rocket.chat/core-services'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +import { i18n } from '../../lib/i18n'; + +export class VideoConfModule implements IUiKitCoreApp { + appId = 'videoconf-core'; + + async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise { + const { + triggerId, + actionId, + payload: { blockId: callId }, + user: { _id: userId } = {}, + } = payload; + + if (!callId) { + throw new Error('invalid call'); + } + + if (actionId === 'join') { + await VideoConf.join(userId, callId, {}); + } + + if (actionId === 'info') { + const blocks = await VideoConf.getInfo(callId, userId); + + return { + type: 'modal.open', + triggerId, + appId: this.appId, + view: { + appId: this.appId, + id: `${callId}-info`, + title: { + type: 'plain_text', + text: i18n.t('Video_Conference_Info'), + emoji: false, + }, + close: { + type: 'button', + appId: this.appId, + blockId: callId, + text: { + type: 'plain_text', + text: i18n.t('Close'), + emoji: false, + }, + actionId: 'cancel', + }, + blocks, + }, + }; + } + } +} diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts new file mode 100644 index 0000000..332aea5 --- /dev/null +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -0,0 +1,552 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; +import type { IServiceClass } from '@rocket.chat/core-services'; +import { EnterpriseSettings, StatusVisibility } from '@rocket.chat/core-services'; +import { isSettingColor, isSettingEnterprise, UserStatus } from '@rocket.chat/core-typings'; +import type { IUser, IRoom, IRole, VideoConference, ISetting, IOmnichannelRoom, PresenceStatusCode } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import { parse } from '@rocket.chat/message-parser'; + +import { refreshVisibility } from '../../lib/notifications/core/lib/Presence'; +import { statusVisibilityGate } from '../../lib/statusVisibility/StatusVisibilityGate'; +import { settings } from '../../settings/cached'; +import type { NotificationsModule } from '../notifications/notifications.module'; + +const isMessageParserDisabled = process.env.DISABLE_MESSAGE_PARSER === 'true'; + +const STATUS_MAP: Record = { + [UserStatus.OFFLINE]: 0, + [UserStatus.ONLINE]: 1, + [UserStatus.AWAY]: 2, + [UserStatus.BUSY]: 3, + [UserStatus.DISABLED]: 0, +} as const; + +const minimongoChangeMap: Record = { + inserted: 'added', + updated: 'changed', + removed: 'removed', +} as const; + +export class ListenersModule { + constructor(service: IServiceClass, notifications: NotificationsModule) { + statusVisibilityGate.watch(service); + + const logger = new Logger('ListenersModule'); + + service.onEvent('license.sync', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('license.actions', () => notifications.notifyAllInThisInstance('license')); + + service.onEvent('emoji.deleteCustom', (emoji) => { + notifications.notifyLoggedInThisInstance('deleteEmojiCustom', { + emojiData: emoji, + }); + }); + + service.onEvent('emoji.updateCustom', (emoji) => { + notifications.notifyLoggedInThisInstance('updateEmojiCustom', { + emojiData: emoji, + }); + }); + + service.onEvent('user.forceLogout', (uid: string, sessionId?: string) => { + notifications.notifyUserInThisInstance(uid, 'force_logout', sessionId); + }); + + service.onEvent('notify.ephemeralMessage', (uid, rid, message) => { + if (!isMessageParserDisabled && message.msg) { + const customDomains = settings.get('Message_CustomDomain_AutoLink') + ? settings + .get('Message_CustomDomain_AutoLink') + .split(',') + .map((domain) => domain.trim()) + : []; + + message.md = parse(message.msg, { + colors: settings.get('HexColorPreview_Enabled'), + emoticons: true, + customDomains, + ...(settings.get('Katex_Enabled') && { + katex: { + dollarSyntax: settings.get('Katex_Dollar_Syntax'), + parenthesisSyntax: settings.get('Katex_Parenthesis_Syntax'), + }, + }), + }); + } + + notifications.notifyUserInThisInstance(uid, 'message', { + groupable: false, + u: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + private: true, + _id: message._id || String(Date.now()), + rid, + ts: new Date(), + _updatedAt: new Date(), + ...message, + }); + }); + + service.onEvent('permission.changed', ({ clientAction, data }) => { + notifications.notifyLoggedInThisInstance('permissions-changed', clientAction, data); + }); + + service.onEvent('room.avatarUpdate', ({ _id: rid, avatarETag: etag }) => { + notifications.notifyLoggedInThisInstance('updateAvatar', { + rid, + etag, + }); + }); + + service.onEvent('user.avatarUpdate', ({ username, avatarETag: etag }) => { + notifications.notifyLoggedInThisInstance('updateAvatar', { + username, + etag, + }); + }); + + service.onEvent('user.deleted', ({ _id: userId }, data) => { + notifications.notifyLoggedInThisInstance('Users:Deleted', { + userId, + ...data, + }); + }); + + service.onEvent('user.deleteCustomStatus', (userStatus) => { + notifications.notifyLoggedInThisInstance('deleteCustomUserStatus', { + userStatusData: userStatus, + }); + }); + + service.onEvent('user.nameChanged', (user) => { + notifications.notifyLoggedInThisInstance('Users:NameChanged', user); + }); + + service.onEvent('user.roleUpdate', (update) => { + notifications.notifyLoggedInThisInstance('roles-change', update); + }); + + service.onEvent( + 'user.video-conference', + ({ + userId, + action, + params, + }: { + userId: string; + action: string; + params: { + callId: VideoConference['_id']; + uid: IUser['_id']; + rid: IRoom['_id']; + }; + }) => { + notifications.notifyUserInThisInstance(userId, 'video-conference', { action, params }); + }, + ); + + service.onEvent('user.media-signal', ({ userId, signal }: { userId: string; signal: ServerMediaSignal }) => { + notifications.notifyUserInThisInstance(userId, 'media-signal', signal); + }); + + service.onEvent('room.video-conference', ({ rid, callId }) => { + /* deprecated */ + (notifications.notifyRoom as any)(rid, callId); + + notifications.notifyRoom(rid, 'videoconf', callId); + }); + + service.onEvent('presence.invalidateVisibility', ({ targets, viewers }) => { + void StatusVisibility.refresh(targets) + .then(async (users) => { + await Promise.all([refreshVisibility(viewers), statusVisibilityGate.syncRestrictedUsers()]); + + for (const { _id, username, status, statusText, statusSource, statusExpiresAt } of users) { + if (username) { + notifications.sendPresence( + _id, + username, + STATUS_MAP[status ?? UserStatus.OFFLINE], + statusText, + statusSource, + statusExpiresAt, + ); + } + } + }) + .catch((err) => logger.error({ msg: 'Failed to refresh status visibility', err, targets })); + }); + + service.onEvent('presence.status', ({ user }) => { + const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user; + if (!status || !username) { + return; + } + + if (settings.get('Presence_broadcast_disabled')) { + return; + } + + notifications.notifyUserInThisInstance(_id, 'userData', { + type: 'updated', + id: _id, + diff: { + status, + ...(statusText && { statusText }), + ...(statusSource && { statusSource }), + ...(statusExpiresAt && { statusExpiresAt }), + }, + unset: { + ...(!statusText && { statusText: 1 }), + ...(!statusSource && { statusSource: 1 }), + ...(!statusExpiresAt && { statusExpiresAt: 1 }), + }, + }); + + // TODO: no client in this repo subscribes to notify-logged/user-status; if mobile and the + // SDK don't either, this check and the restricted mirror behind it can be dropped + if (!statusVisibilityGate.hasRestrictions(_id)) { + notifications.notifyLoggedInThisInstance('user-status', [ + _id, + username, + STATUS_MAP[status], + statusText, + name, + roles, + statusSource, + statusExpiresAt, + ]); + } + + if (_id) { + notifications.sendPresence(_id, username, STATUS_MAP[status], statusText, statusSource, statusExpiresAt); + } + }); + + service.onEvent('user.updateCustomStatus', (userStatus) => { + notifications.notifyLoggedInThisInstance('updateCustomUserStatus', { + userStatusData: userStatus, + }); + }); + + service.onEvent('user.activity', ({ isTyping, roomId, user }) => { + notifications.notifyRoomInThisInstance(roomId, 'user-activity', user, isTyping ? ['user-typing'] : []); + }); + + service.onEvent('watch.messages', async ({ message }) => { + if (!message.rid) { + return; + } + + notifications.streamRoomMessage._emit('__my_messages__', [message], undefined, false, (streamer, _sub, eventName, args, allowed) => + streamer.changedPayload(streamer.subscriptionName, 'id', { + eventName, + args: [...args, allowed], + }), + ); + + notifications.streamRoomMessage.emitWithoutBroadcast(message.rid, message); + }); + + service.onEvent('notify.messagesRead', ({ rid, until, tmid }): void => { + notifications.notifyRoomInThisInstance(rid, 'messagesRead', { tmid, until }); + }); + + service.onEvent('watch.subscriptions', ({ clientAction, subscription }) => { + if (!subscription.u?._id) { + return; + } + + // emit a removed event on msg stream to remove the user's stream-room-messages subscription when the user is removed from room + if (clientAction === 'removed') { + notifications.streamRoomMessage.__emit(subscription.u._id, clientAction, subscription); + } + + notifications.streamUser.__emit(subscription.u._id, clientAction, subscription); + + notifications.notifyUserInThisInstance(subscription.u._id, 'subscriptions-changed', clientAction, subscription as any); + }); + + service.onEvent('watch.roles', ({ clientAction, role }): void => { + notifications.streamRoles.emitWithoutBroadcast('roles', { + type: clientAction, + ...(role as IRole), + }); + }); + + service.onEvent('watch.inquiries', async ({ clientAction, inquiry, diff }): Promise => { + const type = minimongoChangeMap[clientAction] as 'added' | 'changed' | 'removed'; + + const isOnlyQueueMetadataUpdate = (diff: Record | undefined): boolean => { + if (!diff) { + return false; + } + + const queueMetadataKeys = ['lockedAt', 'locked', '_updatedAt']; + return Object.keys(diff).length === queueMetadataKeys.length && queueMetadataKeys.every((key) => diff.hasOwnProperty(key)); + }; + + // Always notify the specific inquiry channel + notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, { + _id: inquiry._id, + ...(clientAction !== 'removed' && { ...inquiry }), + clientAction, + }); + + // Skip further notifications if it's just a queue metadata update + if (clientAction === 'updated' && isOnlyQueueMetadataUpdate(diff)) { + return; + } + + // Notify the defaultAgent if exists + if (inquiry.defaultAgent?.agentId) { + notifications.streamLivechatQueueData.emitWithoutBroadcast(`agent/${inquiry.defaultAgent.agentId}`, { + type, + ...inquiry, + }); + } + + // Prioritize department-specific channel over public + if (inquiry.department) { + notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { + type, + ...inquiry, + }); + } + + if (!inquiry.department && !inquiry.defaultAgent?.agentId) { + notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { + type, + ...inquiry, + }); + } + }); + + service.onEvent('watch.settings', async ({ clientAction, setting }): Promise => { + // if a EE setting changed make sure we broadcast the correct value according to license + if (clientAction !== 'removed' && isSettingEnterprise(setting)) { + try { + const result = await EnterpriseSettings.changeSettingValue(setting); + if (result !== undefined && !(result instanceof Error)) { + setting.value = result; + } + } catch (err: unknown) { + logger.error({ msg: 'Error getting proper enterprise setting value. Returning `invalidValue` instead.', err }); + setting.value = setting.invalidValue; + } + } + + if (setting.hidden) { + return; + } + + const value = { + _id: setting._id, + value: setting.value, + ...(isSettingColor(setting) && { editor: setting.editor }), + properties: setting.properties, + enterprise: setting.enterprise, + requiredOnWizard: setting.requiredOnWizard, + } as ISetting; + + if (setting.public === true) { + notifications.notifyAllInThisInstance('public-settings-changed', clientAction, value); + notifications.notifyAllInThisInstance('public-info', ['public-settings-changed', [clientAction, value]]); + } + + notifications.notifyLoggedInThisInstance('private-settings-changed', clientAction, value); + }); + + service.onEvent('watch.rooms', ({ clientAction, room }): void => { + // this emit will cause the user to receive a 'rooms-changed' event + notifications.streamUser.__emit(room._id, clientAction, room); + + notifications.streamRoomData.emitWithoutBroadcast(room._id, room as IOmnichannelRoom); + }); + + service.onEvent('watch.users', (event): void => { + switch (event.clientAction) { + case 'updated': + notifications.notifyUserInThisInstance(event.id, 'userData', { + id: event.id, + diff: event.diff, + unset: event.unset, + type: 'updated', + }); + break; + case 'inserted': + notifications.notifyUserInThisInstance(event.id, 'userData', { id: event.id, data: event.data, type: 'inserted' }); + break; + case 'removed': + notifications.notifyUserInThisInstance(event.id, 'userData', { id: event.id, type: 'removed' }); + break; + } + }); + + service.onEvent('watch.integrationHistory', ({ clientAction, data, diff, id }): void => { + if (!data?.integration?._id) { + return; + } + switch (clientAction) { + case 'updated': { + notifications.streamIntegrationHistory.emitWithoutBroadcast(data.integration._id, { + id, + diff, + type: clientAction, + }); + break; + } + case 'inserted': { + notifications.streamIntegrationHistory.emitWithoutBroadcast(data.integration._id, { + data, + type: clientAction, + }); + break; + } + } + }); + + service.onEvent('watch.livechatDepartmentAgents', ({ clientAction, data }): void => { + const { agentId } = data; + if (!agentId) { + return; + } + + notifications.notifyUserInThisInstance(agentId, 'departmentAgentData', { + action: clientAction, + ...data, + }); + }); + + service.onEvent('banner.user', (userId, banner): void => { + notifications.notifyUserInThisInstance(userId, 'banners', banner); + }); + + service.onEvent('banner.new', (bannerId): void => { + notifications.notifyLoggedInThisInstance('new-banner', { bannerId }); // deprecated + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); + }); + + service.onEvent('banner.disabled', (bannerId): void => { + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); + }); + + service.onEvent('banner.enabled', (bannerId): void => { + notifications.notifyLoggedInThisInstance('banner-changed', { bannerId }); + }); + + service.onEvent('call.callerhangup', (userId, data): void => { + notifications.notifyUserInThisInstance(userId, 'call.hangup', data); + }); + + service.onEvent('notify.desktop', (uid, notification): void => { + notifications.notifyUserInThisInstance(uid, 'notification', notification); + }); + + service.onEvent('notify.uiInteraction', (uid, interaction): void => { + notifications.notifyUserInThisInstance(uid, 'uiInteraction', interaction); + }); + + service.onEvent('notify.updateInvites', (uid, data): void => { + notifications.notifyUserInThisInstance(uid, 'updateInvites', data); + }); + + service.onEvent('notify.webdav', (uid, data): void => { + notifications.notifyUserInThisInstance(uid, 'webdav', data); + }); + + service.onEvent('notify.e2e.keyRequest', (rid, data): void => { + notifications.notifyRoomInThisInstance(rid, 'e2e.keyRequest', data); + }); + + service.onEvent('notify.deleteMessage', (rid, data): void => { + notifications.notifyRoomInThisInstance(rid, 'deleteMessage', data); + }); + + service.onEvent('notify.deleteMessageBulk', (rid, data): void => { + notifications.notifyRoomInThisInstance(rid, 'deleteMessageBulk', data); + }); + + service.onEvent('notify.deleteCustomSound', (data): void => { + notifications.notifyAllInThisInstance('deleteCustomSound', data); + notifications.notifyAllInThisInstance('public-info', ['deleteCustomSound', [data]]); + }); + + service.onEvent('notify.updateCustomSound', (data): void => { + notifications.notifyAllInThisInstance('updateCustomSound', data); + notifications.notifyAllInThisInstance('public-info', ['updateCustomSound', [data]]); + }); + + service.onEvent('notify.calendar', (uid, data): void => { + notifications.notifyUserInThisInstance(uid, 'calendar', data); + }); + + service.onEvent('notify.importedMessages', ({ roomIds }): void => { + roomIds.forEach((rid) => { + // couldnt get TS happy by providing no data, so had to provide null + notifications.notifyRoomInThisInstance(rid, 'messagesImported', null); + }); + }); + + service.onEvent('omnichannel.room', (roomId, data): void => { + notifications.streamLivechatRoom.emitWithoutBroadcast(roomId, data); + }); + service.onEvent('watch.priorities', async ({ clientAction, diff, id }): Promise => { + notifications.notifyLoggedInThisInstance('omnichannel.priority-changed', { id, clientAction, name: diff?.name }); + }); + + service.onEvent('apps.added', (appId: string) => { + notifications.streamApps.emitWithoutBroadcast('app/added', appId); + notifications.streamApps.emitWithoutBroadcast('apps', ['app/added', [appId]]); + }); + + service.onEvent('apps.removed', (appId: string) => { + notifications.streamApps.emitWithoutBroadcast('app/removed', appId); + notifications.streamApps.emitWithoutBroadcast('apps', ['app/removed', [appId]]); + }); + + service.onEvent('apps.updated', (appId: string) => { + notifications.streamApps.emitWithoutBroadcast('app/updated', appId); + notifications.streamApps.emitWithoutBroadcast('apps', ['app/updated', [appId]]); + }); + + service.onEvent('apps.statusUpdate', (appId: string, status: AppStatus) => { + notifications.streamApps.emitWithoutBroadcast('app/statusUpdate', { appId, status }); + notifications.streamApps.emitWithoutBroadcast('apps', ['app/statusUpdate', [{ appId, status }]]); + }); + + service.onEvent('apps.settingUpdated', (appId: string, setting: AppsSetting) => { + notifications.streamApps.emitWithoutBroadcast('app/settingUpdated', { appId, setting }); + notifications.streamApps.emitWithoutBroadcast('apps', ['app/settingUpdated', [{ appId, setting }]]); + }); + + service.onEvent('command.added', (command: string) => { + notifications.streamApps.emitWithoutBroadcast('command/added', command); + notifications.streamApps.emitWithoutBroadcast('apps', ['command/added', [command]]); + }); + + service.onEvent('command.disabled', (command: string) => { + notifications.streamApps.emitWithoutBroadcast('command/disabled', command); + notifications.streamApps.emitWithoutBroadcast('apps', ['command/disabled', [command]]); + }); + + service.onEvent('command.updated', (command: string) => { + notifications.streamApps.emitWithoutBroadcast('command/updated', command); + notifications.streamApps.emitWithoutBroadcast('apps', ['command/updated', [command]]); + }); + + service.onEvent('command.removed', (command: string) => { + notifications.streamApps.emitWithoutBroadcast('command/removed', command); + notifications.streamApps.emitWithoutBroadcast('apps', ['command/removed', [command]]); + }); + + service.onEvent('actions.changed', () => { + notifications.streamApps.emitWithoutBroadcast('actions/changed'); + notifications.streamApps.emitWithoutBroadcast('apps', ['actions/changed', []]); + }); + } +} diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..65be495 --- /dev/null +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -0,0 +1,536 @@ +import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services'; +import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource, PresenceStatusCode } from '@rocket.chat/core-typings'; +import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress'; +import { SystemLogger } from '../../lib/logger/system'; +import { emit, StreamPresence } from '../../lib/notifications/core/lib/Presence'; +import { getCachedUserForPublication } from '../streamer/publication-user-cache'; +import { Streamer as StreamerModule } from '../streamer/streamer.module'; +import type { IStreamer, IStreamerConstructor } from '../streamer/types'; + +export class NotificationsModule { + public readonly streamLogged: IStreamer<'notify-logged'>; + + public readonly streamAll: IStreamer<'notify-all'>; + + public readonly streamRoom: IStreamer<'notify-room'>; + + public readonly streamRoomUsers: IStreamer<'notify-room-users'>; + + public readonly streamUser: IStreamer<'notify-user'> & { + on(event: string, fn: (...data: any[]) => void): void; + }; + + public readonly streamRoomMessage: IStreamer<'room-messages'>; + + public readonly streamImporters: IStreamer<'importers'>; + + public readonly streamRoles: IStreamer<'roles'>; + + public readonly streamApps: IStreamer<'apps'>; + + public readonly streamAppsEngine: IStreamer<'apps-engine'>; + + public readonly streamCannedResponses: IStreamer<'canned-responses'>; + + public readonly streamIntegrationHistory: IStreamer<'integrationHistory'>; + + public readonly streamLivechatRoom: IStreamer<'livechat-room'>; + + public readonly streamLivechatQueueData: IStreamer<'livechat-inquiry-queue-observer'>; + + public readonly streamRoomData: IStreamer<'room-data'>; + + public readonly streamLocal: IStreamer<'local'>; + + public readonly streamPresence: IStreamer<'user-presence'>; + + constructor(private Streamer: IStreamerConstructor) { + this.streamAll = new this.Streamer('notify-all'); + this.streamLogged = new this.Streamer('notify-logged'); + this.streamRoom = new this.Streamer('notify-room'); + this.streamRoomUsers = new this.Streamer('notify-room-users'); + this.streamImporters = new this.Streamer('importers', { retransmit: false }); + this.streamRoles = new this.Streamer('roles'); + this.streamApps = new this.Streamer('apps', { retransmit: false }); + this.streamAppsEngine = new this.Streamer('apps-engine', { retransmit: false }); + this.streamCannedResponses = new this.Streamer('canned-responses'); + this.streamIntegrationHistory = new this.Streamer('integrationHistory'); + this.streamLivechatRoom = new this.Streamer('livechat-room'); + this.streamLivechatQueueData = new this.Streamer('livechat-inquiry-queue-observer'); + this.streamRoomData = new this.Streamer('room-data'); + this.streamPresence = StreamPresence.getInstance(Streamer, 'user-presence'); + this.streamRoomMessage = new this.Streamer('room-messages'); + + this.streamRoomMessage.on('_afterPublish', async (streamer, publication, eventName): Promise => { + if (!StreamerModule.isPublicationActive(publication)) { + return; + } + + const { userId } = publication._session; + if (!userId) { + return; + } + + const userEvent = (clientAction: string, { rid }: { rid: string }): void => { + switch (clientAction) { + case 'removed': + streamer.removeListener(userId, userEvent); + const sub = [...streamer.subscriptions].find((sub) => sub.eventName === rid && sub.subscription.userId === userId); + sub && streamer.removeSubscription(sub, eventName); + break; + } + }; + + streamer.on(userId, userEvent); + + publication.onStop(() => streamer.removeListener(userId, userEvent)); + }); + + this.streamUser = new this.Streamer('notify-user'); + this.streamLocal = new this.Streamer('local'); + } + + configure(): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + this.streamRoomMessage.allowWrite('none'); + this.streamRoomMessage.allowRead(async function (eventName, extraData) { + const room = await Rooms.findOneById(eventName); + if (!room) { + return false; + } + + const user = await getCachedUserForPublication(this); + return Authorization.canReadRoom(room, user ?? undefined, extraData); + }); + + this.streamRoomMessage.allowRead('__my_messages__', 'all'); + this.streamRoomMessage.allowEmit('__my_messages__', async function (_eventName, { rid }) { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + + try { + const room = await Rooms.findOneById(rid); + if (!room) { + return false; + } + + const canAccess = await Authorization.canAccessRoom(room, user); + if (!canAccess) { + return false; + } + + const roomParticipant = await Subscriptions.countByRoomIdAndUserId(room._id, user._id); + + return { + roomParticipant: roomParticipant > 0, + roomType: room.t, + roomName: room.name, + }; + } catch (error) { + /* error*/ + return false; + } + }); + + this.streamAll.allowWrite('none'); + this.streamAll.allowRead('all'); + this.streamLogged.allowRead('private-settings-changed', async function () { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + return Authorization.hasAtLeastOnePermission(user, [ + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + ]); + }); + + this.streamLogged.allowWrite('none'); + this.streamLogged.allowRead('logged'); + + this.streamRoom.allowRead(async function (eventName, extraData): Promise { + const [rid] = eventName.split('/'); + + const room = await Rooms.findOneById>(rid, { + projection: { 't': 1, 'v.token': 1 }, + }); + + if (!room) { + return false; + } + + // typing from livechat widget + if (extraData?.token) { + // TODO improve this to make a query 'v.token' + const room = await Rooms.findOneById>(rid, { + projection: { 't': 1, 'v.token': 1 }, + }); + return !!room && room.t === 'l' && room.v.token === extraData.token; + } + + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + const canAccess = await Authorization.canAccessRoom(room, user); + + return canAccess; + }); + + async function canType({ + userId, + username, + extraData, + rid, + }: { + userId?: string; + username: string; + extraData?: { token: string }; + rid: string; + }): Promise { + try { + // typing from livechat widget + if (extraData?.token) { + // TODO improve this to make a query 'v.token' + const room = await Rooms.findOneById>(rid, { + projection: { 't': 1, 'v.token': 1 }, + }); + return !!room && room.t === 'l' && room.v.token === extraData.token; + } + + if (!userId) { + return false; + } + + // TODO consider using something to cache settings + const key = (await Settings.get('UI_Use_Real_Name')) ? 'name' : 'username'; + + const user = await Users.findOneById>(userId, { + projection: { + [key]: 1, + }, + }); + + if (!user) { + return false; + } + + return user[key] === username; + } catch (err) { + SystemLogger.error({ err }); + return false; + } + } + + this.streamRoom.allowWrite(async function (eventName, username, _activity, extraData): Promise { + const [rid, e] = eventName.split('/'); + + if (e !== 'user-activity') { + return false; + } + + if (!(await canType({ extraData, rid, username, userId: this.userId ?? undefined }))) { + return false; + } + + return true; + }); + + this.streamRoomUsers.allowRead('none'); + this.streamRoomUsers.allowWrite(async function ( + eventName: `${string}/video-conference` | `${string}/userData`, + ...args: [{ action: string; params: { callId: string; uid: string; rid: string } }] | [IUserDataEvent] + ) { + const [roomId, e] = eventName.split('/') as [string, 'video-conference' | 'userData']; + if ( + this.userId && + ['video-conference', 'userData'].includes(e) && + (await Subscriptions.countByRoomIdAndUserId(roomId, this.userId)) > 0 + ) { + const subscriptions: ISubscription[] = await Subscriptions.findByRoomIdAndNotUserId(roomId, this.userId, { + projection: { 'u._id': 1, '_id': 0 }, + }).toArray(); + + subscriptions.forEach((subscription) => self.notifyUser(subscription.u._id, e, ...args)); + } + return false; + }); + + this.streamUser.allowWrite(async function (eventName, data: unknown) { + const [, e] = eventName.split('/'); + if (e === 'video-conference') { + if (!this.userId || !data || typeof data !== 'object') { + return false; + } + + const { action: videoAction, params } = data as { + action: string | undefined; + params: { callId?: string; uid?: string; rid?: string }; + }; + + if (!videoAction || typeof videoAction !== 'string' || !params || typeof params !== 'object') { + return false; + } + + const callId = 'callId' in params && typeof params.callId === 'string' ? params.callId : ''; + const uid = 'uid' in params && typeof params.uid === 'string' ? params.uid : ''; + const rid = 'rid' in params && typeof params.rid === 'string' ? params.rid : ''; + + return VideoConf.validateAction(videoAction, this.userId, { + callId, + uid, + rid, + }); + } + + if (e === 'media-calls') { + if (!this.userId || !data || typeof data !== 'string') { + return false; + } + + void MediaCall.processSerializedSignal(this.userId, data).catch(() => null); + + // media call signals don't ever need to be broadcasted + return false; + } + + return false; + }); + this.streamUser.allowRead(async function (eventName) { + const [userId] = eventName.split('/'); + + return Boolean(this.userId) && this.userId === userId; + }); + + this.streamImporters.allowRead('all'); + this.streamImporters.allowEmit('all'); + this.streamImporters.allowWrite('none'); + + this.streamApps.serverOnly = true; + this.streamApps.allowRead('all'); + this.streamApps.allowEmit('all'); + this.streamApps.allowWrite('none'); + + this.streamAppsEngine.serverOnly = true; + this.streamAppsEngine.allowRead('none'); + this.streamAppsEngine.allowEmit('all'); + this.streamAppsEngine.allowWrite('none'); + + this.streamCannedResponses.allowWrite('none'); + this.streamCannedResponses.allowRead(async function () { + const user = await getCachedUserForPublication(this); + return !!user && !!(await Settings.get('Canned_Responses_Enable')) && Authorization.hasPermission(user, 'view-canned-responses'); + }); + + this.streamIntegrationHistory.allowWrite('none'); + this.streamIntegrationHistory.allowRead(async function () { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + return Authorization.hasAtLeastOnePermission(user, ['manage-outgoing-integrations', 'manage-own-outgoing-integrations']); + }); + + this.streamLivechatRoom.allowRead(async (roomId, extraData) => { + const room = await Rooms.findOneById>(roomId, { + projection: { _id: 0, t: 1, v: 1 }, + }); + + if (!room) { + console.warn(`Invalid eventName: "${roomId}"`); + return false; + } + + if (room.t === 'l' && extraData?.visitorToken && room.v.token === extraData.visitorToken) { + return true; + } + return false; + }); + + this.streamLivechatQueueData.allowWrite('none'); + this.streamLivechatQueueData.allowRead(async function () { + const user = await getCachedUserForPublication(this); + return user ? Authorization.hasPermission(user, 'view-l-room') : false; + }); + + this.streamRoomData.allowWrite('none'); + this.streamRoomData.allowRead(async function (rid) { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + + try { + const room = await Rooms.findOneById(rid); + if (!room) { + return false; + } + + const canAccess = await Authorization.canAccessRoom(room, user); + if (!canAccess) { + return false; + } + + return true; + } catch (error) { + return false; + } + }); + + this.streamRoles.allowWrite('none'); + this.streamRoles.allowRead('logged'); + + this.streamUser.on('_afterPublish', async (streamer, publication, eventName): Promise => { + // after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong) + // we were just not counting on this, session is _session so we actually should not use it + // now after any await, the session can potentially be null, so we need to check for that + if (!StreamerModule.isPublicationActive(publication)) { + return; + } + + const { userId } = publication._session; + if (!userId) { + return; + } + + if (/rooms-changed/.test(eventName)) { + // TODO: change this to serialize only once + const roomEvent = (...args: any[]): void => { + // TODO if receive a removed event could do => streamer.removeListener(rid, roomEvent); + const payload = streamer.changedPayload(streamer.subscriptionName, 'id', { + eventName: `${userId}/rooms-changed`, + args, + }); + if (!payload) { + return; + } + + // after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong) + // we were just not counting on this, session is _session so we actually should not use it + // now after any await, the session can potentially be null, so we need to check for that + if (!StreamerModule.isPublicationActive(publication)) { + return; + } + publication._session.socket.send(payload); + }; + + const subscriptions = await Subscriptions.find>( + { 'u._id': userId }, + { projection: { rid: 1 } }, + ).toArray(); + + subscriptions.forEach(({ rid }) => { + streamer.on(rid, roomEvent); + }); + + const userEvent = async (clientAction: string, { rid }: Partial = {}): Promise => { + if (!rid) { + return; + } + + switch (clientAction) { + case 'inserted': + subscriptions.push({ rid }); + streamer.on(rid, roomEvent); + + // after a subscription is added need to emit the room again + roomEvent('inserted', await Rooms.findOneById(rid)); + break; + + case 'removed': + streamer.removeListener(rid, roomEvent); + break; + } + }; + streamer.on(userId, userEvent); + + publication.onStop(() => { + streamer.removeListener(userId, userEvent); + subscriptions.forEach(({ rid }) => streamer.removeListener(rid, roomEvent)); + }); + } + }); + + this.streamLocal.serverOnly = true; + this.streamLocal.allowRead('none'); + this.streamLocal.allowEmit('all'); + this.streamLocal.allowWrite('none'); + + this.streamPresence.allowRead('logged'); + this.streamPresence.allowWrite('none'); + } + + // notifyAll>(eventName: E, ...args: StreamerCallbackArgs<'notify-all', E>): void { + // return this.streamAll.emit(eventName, ...args); + // } + + notifyLogged>(eventName: E, ...args: StreamerCallbackArgs<'notify-logged', E>): void { + return this.streamLogged.emit(eventName, ...args); + } + + notifyRoom

( + room: P, + eventName: E extends ExtractNotifyUserEventName<'notify-room', P> ? E : never, + ...args: E extends ExtractNotifyUserEventName<'notify-room', P> ? StreamerCallbackArgs<'notify-room', `${P}/${E}`> : never + ): void { + return this.streamRoom.emit(`${room}/${eventName}`, ...args); + } + + notifyUser

( + userId: P, + eventName: E extends ExtractNotifyUserEventName<'notify-user', P> ? E : never, + ...args: E extends ExtractNotifyUserEventName<'notify-user', P> ? StreamerCallbackArgs<'notify-user', `${P}/${E}`> : never + ): void { + return this.streamUser.emit(`${userId}/${eventName}`, ...args); + } + + notifyAllInThisInstance>(eventName: E, ...args: StreamerCallbackArgs<'notify-all', E>): void { + return this.streamAll.emitWithoutBroadcast(eventName, ...args); + } + + notifyLoggedInThisInstance>(eventName: E, ...args: StreamerCallbackArgs<'notify-logged', E>): void { + return this.streamLogged.emitWithoutBroadcast(eventName, ...args); + } + + notifyRoomInThisInstance

( + room: P, + eventName: E extends ExtractNotifyUserEventName<'notify-room', P> ? E : never, + ...args: E extends ExtractNotifyUserEventName<'notify-room', P> ? StreamerCallbackArgs<'notify-room', `${P}/${E}`> : never + ): void { + return this.streamRoom.emitWithoutBroadcast(`${room}/${eventName}`, ...args); + } + + notifyUserInThisInstance

( + userId: P, + eventName: E extends ExtractNotifyUserEventName<'notify-user', P> ? E : never, + ...args: E extends ExtractNotifyUserEventName<'notify-user', P> ? StreamerCallbackArgs<'notify-user', `${P}/${E}`> : never + ): void { + return this.streamUser.emitWithoutBroadcast(`${userId}/${eventName}`, ...args); + } + + sendPresence( + uid: string, + ...args: [username: string, status?: PresenceStatusCode, statusText?: string, statusSource?: PresenceSource, statusExpiresAt?: Date] + ): void { + emit(uid, [args]); + return this.streamPresence.emitWithoutBroadcast(uid, args); + } + + progressUpdated(progress: { rate: number } | ImporterProgress): void { + this.streamImporters.emit('progress', progress); + } +} + +type ExtractNotifyUserEventName< + T extends StreamNames, + P extends string, + E extends StreamKeys = StreamKeys, +> = E extends `${infer X}/${infer I}` ? (P extends X ? I : never) : never; diff --git a/apps/meteor/server/modules/streamer/publication-user-cache.ts b/apps/meteor/server/modules/streamer/publication-user-cache.ts new file mode 100644 index 0000000..7801e3a --- /dev/null +++ b/apps/meteor/server/modules/streamer/publication-user-cache.ts @@ -0,0 +1,55 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import type { IPublication } from './types'; + +type CachedUser = IUser; // Pick; + +type CacheEntry = { + user: Promise; + timeout: NodeJS.Timeout; +}; + +const CACHE_PROJECTION = { _id: 1, roles: 1 } as const; +const CACHE_TIMEOUT = 1000 * 60; +const cacheByUserId = new Map(); + +export function getCachedUserForPublication(publication: IPublication): Promise { + const userId = publication.userId ?? publication._session?.userId; + if (!userId) { + return Promise.resolve(null); + } + + const existing = cacheByUserId.get(userId); + if (existing) { + return existing.user; + } + + const userPromise = Users.findOneById(userId, { projection: CACHE_PROJECTION }); + + const timeout = setTimeout(() => { + invalidate(userId); + }, CACHE_TIMEOUT); + + cacheByUserId.set(userId, { user: userPromise, timeout }); + + userPromise.then( + (user: CachedUser | null) => { + if (!user) { + invalidate(userId); + } + }, + () => invalidate(userId), + ); + + return userPromise; +} + +export function invalidate(userId: string): void { + const entry = cacheByUserId.get(userId); + if (!entry) { + return; + } + clearTimeout(entry.timeout); + cacheByUserId.delete(userId); +} diff --git a/apps/meteor/server/modules/streamer/streamer.module.ts b/apps/meteor/server/modules/streamer/streamer.module.ts new file mode 100644 index 0000000..d985730 --- /dev/null +++ b/apps/meteor/server/modules/streamer/streamer.module.ts @@ -0,0 +1,348 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { StreamerEvents } from '@rocket.chat/ddp-client'; +import { EventEmitter } from 'eventemitter3'; + +import type { IPublication, Rule, Connection, DDPSubscription, IStreamer, IRules, TransformMessage } from './types'; +import { SystemLogger } from '../../lib/logger/system'; + +class StreamerCentralClass extends EventEmitter { + public instances: Record> = {}; + + constructor() { + super(); + } +} + +type ActivePublication = IPublication & { + _session: NonNullable & { socket: NonNullable['socket']> }; +}; + +export const StreamerCentral = new StreamerCentralClass(); + +export abstract class Streamer extends EventEmitter implements IStreamer { + public subscriptions = new Set(); + + protected subscriptionsByEventName = new Map>(); + + public retransmit = true; + + public retransmitToSelf = false; + + public serverOnly = false; + + private _allowRead: IRules = {}; + + private _allowWrite: IRules = {}; + + private _allowEmit: IRules = {}; + + constructor( + public name: string, + { retransmit = true, retransmitToSelf = false }: { retransmit?: boolean; retransmitToSelf?: boolean } = {}, + ) { + super(); + + if (StreamerCentral.instances[name]) { + console.warn('Streamer instance already exists:', name); + return StreamerCentral.instances[name]; + } + + StreamerCentral.instances[name] = this; + + this.retransmit = retransmit; + this.retransmitToSelf = retransmitToSelf; + + this.iniPublication(); + // DDPStreamer doesn't have this + this.initMethod(); + + this.allowRead('none'); + this.allowEmit('all'); + this.allowWrite('none'); + } + + get subscriptionName(): string { + return `stream-${this.name}`; + } + + private allow(rules: IRules, name: string) { + return (eventName: string | boolean | Rule, fn?: string | boolean | Rule): void => { + if (fn === undefined) { + fn = eventName; + eventName = '__all__'; + } + + if (typeof eventName !== 'string') { + return; + } + + if (typeof fn === 'function') { + rules[eventName] = fn; + return; + } + + if (typeof fn === 'string' && ['all', 'none', 'logged'].indexOf(fn) === -1) { + SystemLogger.error({ msg: 'shortcut is invalid', name, fn }); + } + + if (fn === 'all' || fn === true) { + rules[eventName] = async function (): Promise { + return true; + }; + return; + } + + if (fn === 'none' || fn === false) { + rules[eventName] = async function (): Promise { + return false; + }; + return; + } + + if (fn === 'logged') { + rules[eventName] = async function (): Promise { + return Boolean(this.userId); + }; + } + }; + } + + allowRead(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void { + this.allow(this._allowRead, 'allowRead')(eventName, fn); + } + + allowWrite(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void { + this.allow(this._allowWrite, 'allowWrite')(eventName, fn); + } + + allowEmit(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void { + this.allow(this._allowEmit, 'allowEmit')(eventName, fn); + } + + private isAllowed(rules: IRules) { + return async (scope: IPublication, eventName: string, args: any): Promise => { + if (rules[eventName]) { + return rules[eventName].call(scope, eventName, ...args); + } + + return rules.__all__.call(scope, eventName, ...args); + }; + } + + async isReadAllowed(scope: IPublication, eventName: string, args: any): Promise { + return this.isAllowed(this._allowRead)(scope, eventName, args); + } + + async isEmitAllowed(scope: IPublication, eventName: string, ...args: any[]): Promise { + return this.isAllowed(this._allowEmit)(scope, eventName, args); + } + + async isWriteAllowed(scope: IPublication, eventName: string, args: any): Promise { + return this.isAllowed(this._allowWrite)(scope, eventName, args); + } + + addSubscription(subscription: DDPSubscription, eventName: string): void { + this.subscriptions.add(subscription); + + const subByEventName = this.subscriptionsByEventName.get(eventName) || new Set(); + subByEventName.add(subscription); + + this.subscriptionsByEventName.set(eventName, subByEventName); + } + + removeSubscription(subscription: DDPSubscription, eventName: string): void { + this.subscriptions.delete(subscription); + + const subByEventName = this.subscriptionsByEventName.get(eventName); + if (subByEventName) { + subByEventName.delete(subscription); + } + } + + async _publish( + publication: IPublication, + eventName: string, + options: boolean | { useCollection?: boolean; args?: any } = false, + ): Promise { + let useCollection; + let args = []; + + if (typeof options === 'boolean') { + useCollection = options; + } else { + if (options.useCollection) { + useCollection = options.useCollection; + } + + if (options.args) { + args = options.args; + } + } + + if (eventName.length === 0) { + throw new MeteorError('invalid-event-name'); + } + + if ((await this.isReadAllowed(publication, eventName, args)) !== true) { + throw new MeteorError('not-allowed'); + } + + // after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong) + // we were just not counting on this, session is _session so we actually should not use it + // now after any await, the session can potentially be null, so we need to check for that + if (!Streamer.isPublicationActive(publication)) { + // if the client is disconnected, we don't want to do anything, it will not have an disconnect event to undo anymore + throw new MeteorError('publication-client-disconnected'); + } + + const subscription = { + subscription: publication, + eventName, + }; + + this.addSubscription(subscription, eventName); + + publication.onStop(() => { + this.removeSubscription(subscription, eventName); + }); + + // DDPStreamer doesn't have this + if (useCollection === true) { + // Collection compatibility + publication._session.sendAdded(this.subscriptionName, 'id', { + eventName, + }); + } + + publication.ready(); + + super.emit('_afterPublish', this, publication, eventName, options); + } + + abstract registerPublication( + name: string, + fn: (eventName: string, options: boolean | { useCollection?: boolean; args?: any }) => Promise, + ): void; + + iniPublication(): void { + const _publish = this._publish.bind(this); + this.registerPublication( + this.subscriptionName, + async function (this: IPublication, eventName: string, options: boolean | { useCollection?: boolean; args?: any }) { + return _publish(this, eventName, options); + }, + ); + } + + abstract registerMethod(methods: Record any>): void; + + initMethod(): void { + const isWriteAllowed = this.isWriteAllowed.bind(this); + const __emit = this.__emit.bind(this); + const _emit = this._emit.bind(this); + const { retransmit } = this; + + const method: Record any> = { + async [this.subscriptionName](this: IPublication, eventName, ...args): Promise { + if ((await isWriteAllowed(this, eventName, args)) !== true) { + return; + } + + __emit(eventName, ...args); + + if (retransmit === true) { + _emit(eventName, args, this.connection, true); + } + }, + }; + + try { + this.registerMethod(method); + } catch (err) { + SystemLogger.error({ err }); + } + } + + abstract changedPayload(collection: string, id: string, fields: Record): string | false; + + _emit(eventName: string, args: any[], origin: Connection | undefined, broadcast: boolean, transform?: TransformMessage): boolean { + if (broadcast === true) { + StreamerCentral.emit('broadcast', this.name, eventName, args); + } + + const subscriptions = this.subscriptionsByEventName.get(eventName); + if (!subscriptions?.size) { + return false; + } + + if (transform) { + void this.sendToManySubscriptions(subscriptions, origin, eventName, args, transform); + + return true; + } + + const msg = this.changedPayload(this.subscriptionName, 'id', { + eventName, + args, + }); + + if (!msg) { + return false; + } + + void this.sendToManySubscriptions(subscriptions, origin, eventName, args, msg); + + return true; + } + + static isPublicationActive(publication: IPublication): publication is ActivePublication { + // After Meteor 3.5 the socket can become null independently of session deactivation + // (e.g. right after a disconnection), so "active" must also mean the socket is still there. + return !publication._isDeactivated() && publication._session?.socket != null; + } + + async sendToManySubscriptions( + subscriptions: Set, + origin: Connection | undefined, + eventName: string, + args: any[], + getMsg: string | TransformMessage, + ): Promise { + await Promise.all( + [...subscriptions].map(async (subscription) => { + try { + if (this.retransmitToSelf === false && origin && origin === subscription.subscription.connection) { + return; + } + + const allowed = await this.isEmitAllowed(subscription.subscription, eventName, ...args); + if (allowed) { + const msg = typeof getMsg === 'string' ? getMsg : getMsg(this, subscription, eventName, args, allowed); + if (msg) { + subscription.subscription._session?.socket?.send(msg); + } + } + } catch (err) { + SystemLogger.error({ + msg: 'Error while delivering streamer event', + eventName, + streamName: this.name, + err, + }); + } + }), + ); + } + + override emit(eventName: string | symbol, ...args: any[]): boolean { + return this._emit(eventName as string, args, undefined, true); + } + + __emit(eventName: string, ...args: any[]): boolean { + return super.emit(eventName, ...args); + } + + emitWithoutBroadcast(eventName: string, ...args: any[]): void { + this._emit(eventName, args, undefined, false); + } +} diff --git a/apps/meteor/server/modules/streamer/types.ts b/apps/meteor/server/modules/streamer/types.ts new file mode 100644 index 0000000..02a25e9 --- /dev/null +++ b/apps/meteor/server/modules/streamer/types.ts @@ -0,0 +1,107 @@ +import type { StreamNames, StreamKeys, StreamerCallbackArgs } from '@rocket.chat/ddp-client'; + +export type Connection = any; + +export interface IPublication { + connection: Connection; + _isDeactivated(): this is IPublication & { _session: null }; + _session: { + sendAdded(publicationName: string, id: string, fields: Record): void; + userId: string | undefined; + socket: { + send: (payload: string) => void; + } | null; + } | null; + client: { + meteorClient: boolean; + ws: any; + userId?: string; + send: (payload: string) => void; + }; + + onStop(func: () => void): void; + /** + * Call inside the publish function. Informs the subscriber that an initial, complete snapshot of the record set has been sent. This will trigger a call on the client to the `onReady` + * callback passed to `Meteor.subscribe`, if any. + */ + ready(): void; + /** + * Call inside the publish function. Informs the subscriber that a document has been removed from the record set. + * @param collection The name of the collection that the document has been removed from. + * @param id The ID of the document that has been removed. + */ + removed(collection: string, id: string): void; + /** + * Access inside the publish function. The incoming connection for this subscription. + */ + stop(): void; + /** + * Call inside the publish function. Allows subsequent methods or subscriptions for the client of this subscription + * to begin running without waiting for the publishing to become ready. + */ + unblock(): void; + /** Access inside the publish function. The id of the logged-in user, or `null` if no user is logged in. */ + userId: string | null; +} + +export type Rule = (this: IPublication, eventName: K, ...args: any) => Promise; + +export interface IRules { + [k: string]: Rule; +} + +export type DDPSubscription = { + eventName: string; + subscription: IPublication; +}; + +export type TransformMessage = ( + streamer: IStreamer, + subscription: DDPSubscription, + eventName: string, + args: any[], + allowed: boolean | object, +) => string | false; + +export interface IStreamer { + serverOnly: boolean; + + subscriptions: Set; + + subscriptionName: string; + + allowEmit>(eventName: K, fn?: Rule | 'all' | 'none' | 'logged'): void; + allowEmit(rule: Rule> | 'all' | 'none' | 'logged'): void; + + allowWrite>(eventName: K | boolean, fn: Rule | 'all' | 'none' | 'logged'): void; + allowWrite(rule: Rule> | 'all' | 'none' | 'logged'): void; + + allowRead>(eventName: K | boolean, fn: Rule | 'all' | 'none' | 'logged'): void; + allowRead(rule: Rule> | 'all' | 'none' | 'logged'): void; + + emit>(event: K, ...data: StreamerCallbackArgs): void; + + on>(event: K, fn: (...data: any[]) => void): void; + + on(event: '_afterPublish', fn: (streamer: this, publication: IPublication, eventName: string, ...data: any[]) => void): void; + + removeSubscription(subscription: DDPSubscription, eventName: string): void; + + removeListener(event: string, fn: (...data: any[]) => void): void; + + removeAllListeners(event: string): void; + + __emit(...data: any[]): void; + + _emit(eventName: string, args: any[], origin: Connection | undefined, broadcast: boolean, transform?: TransformMessage): boolean; + + emitWithoutBroadcast>(event: K, ...data: StreamerCallbackArgs): void; + + changedPayload(collection: string, id: string, fields: Record): string | false; + + _publish(publication: IPublication, eventName: string, options: boolean | { useCollection?: boolean; args?: any }): Promise; +} + +export interface IStreamerConstructor { + new (name: N, options?: { retransmit?: boolean; retransmitToSelf?: boolean }): IStreamer; +} diff --git a/apps/meteor/server/oauth2-server/model.ts b/apps/meteor/server/oauth2-server/model.ts new file mode 100644 index 0000000..a9ba672 --- /dev/null +++ b/apps/meteor/server/oauth2-server/model.ts @@ -0,0 +1,285 @@ +import type { + AuthorizationCode, + AuthorizationCodeModel, + Client, + Falsey, + RefreshToken, + RefreshTokenModel, + Token, + User, +} from '@node-oauth/oauth2-server'; +import { OAuthApps, OAuthAuthCodes, OAuthAccessTokens, OAuthRefreshTokens, Users } from '@rocket.chat/models'; + +export type ModelConfig = { + debug?: boolean; +}; + +export class Model implements AuthorizationCodeModel, RefreshTokenModel { + private debug: boolean; + + private grants = ['authorization_code', 'refresh_token']; + + constructor(config: ModelConfig = {}) { + this.debug = !!config.debug; + } + + async verifyScope(token: Token, scope: string | string[]): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', 'in grantTypeAllowed (clientId:', token.client.id, ', grantType:', `${scope})`); + } + + if (!Array.isArray(scope)) { + scope = [scope]; + } + + const allowed = this.grants.filter((t) => scope.includes(t)).length > 0; + return allowed; + } + + async getAccessToken(accessToken: string): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', 'in getAccessToken (bearerToken:', accessToken, ')'); + } + + const token = await OAuthAccessTokens.findOneByAccessToken(accessToken); + + if (!token) { + return; + } + + const client = await this.getClient(token.clientId); + + if (!client) { + throw new Error('Invalid clientId'); + } + + const user = await Users.findOneActiveById(token.userId, { projection: { _id: 1 } }); + if (!user) { + return; + } + + const result: Token = { + accessToken: token.accessToken, + client, + user: { + id: token.userId, + }, + }; + + return result; + } + + async getClient(clientId: string, clientSecret?: string): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', 'in getClient (clientId:', clientId, ', clientSecret:', clientSecret, ')'); + } + + let client; + if (clientSecret == null) { + client = await OAuthApps.findOneActiveByClientId(clientId); + } else { + client = await OAuthApps.findOneActiveByClientIdAndClientSecret(clientId, clientSecret); + } + + if (!client) { + throw new Error('Invalid clientId'); + } + + const result: Client = { + grants: this.grants, + redirectUris: client.redirectUri.split(','), + id: client.clientId, + }; + + return result; + } + + async getAuthorizationCode(authorizationCode: string): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', `in getAuthorizationCode (authCode: ${authorizationCode})`); + } + + const code = await OAuthAuthCodes.findOneByAuthCode(authorizationCode); + if (!code) { + throw new Error('Invalid authCode'); + } + + const client = await this.getClient(code.clientId); + + if (!client) { + throw new Error('Invalid clientId'); + } + + const authCode: AuthorizationCode = { + authorizationCode, + expiresAt: code.expires, + user: { + id: code.userId, + }, + client: { + id: client.id, + grants: client.grants, + }, + redirectUri: code.redirectUri, + }; + + return authCode; + } + + async saveAuthorizationCode( + code: Pick, + client: Client, + user: User, + ): Promise { + if (this.debug === true) { + console.log( + '[OAuth2Server]', + 'in saveAuthCode (code:', + code.authorizationCode, + ', clientId:', + client.id, + ', expires:', + code.expiresAt, + ', user:', + user, + ')', + ); + } + + await OAuthAuthCodes.updateOne( + { authCode: code.authorizationCode }, + { + $set: { + authCode: code.authorizationCode, + clientId: client.id, + userId: user.id, + expires: code.expiresAt, + redirectUri: code.redirectUri, + }, + }, + { upsert: true }, + ); + + const newCode = { + ...code, + client, + user, + }; + + return newCode; + } + + async saveToken(token: Token, client: Client, user: User): Promise { + if (this.debug === true) { + console.log( + '[OAuth2Server]', + 'in saveToken (token:', + token.accessToken, + ', refreshToken:', + token.refreshToken, + ', clientId:', + client.id, + ', user:', + user, + ', expires:', + token.accessTokenExpiresAt, + ', refreshTokenExpires:', + token.refreshTokenExpiresAt, + ')', + ); + } + + const tokenId = ( + await OAuthAccessTokens.insertOne({ + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expires: token.accessTokenExpiresAt, + refreshTokenExpiresAt: token.refreshTokenExpiresAt, + clientId: client.id, + userId: user.id, + }) + ).insertedId; + + const result: Token = { + id: tokenId, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + accessTokenExpiresAt: token.accessTokenExpiresAt, + refreshTokenExpiresAt: token.refreshTokenExpiresAt, + client, + user, + }; + + return result; + } + + async getRefreshToken(refreshToken: string): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', `in getRefreshToken (refreshToken: ${refreshToken})`); + } + + // Keep compatibility with old collection + // Deprecated: Remove on next major within a migration + const token = + (await OAuthAccessTokens.findOneByRefreshToken(refreshToken)) || (await OAuthRefreshTokens.findOneByRefreshToken(refreshToken)); + + if (!token) { + throw new Error('Invalid token'); + } + + const client = await this.getClient(token.clientId); + + if (!client) { + throw new Error('Invalid clientId'); + } + + const user = await Users.findOneActiveById(token.userId, { projection: { _id: 1 } }); + if (!user) { + throw new Error('Invalid token'); + } + + const result: RefreshToken = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + refreshToken: token.refreshToken!, + // Keep compatibility with old collection + // Deprecated: Remove on next major within a migration + refreshTokenExpiresAt: 'refreshTokenExpiresAt' in token ? token.refreshTokenExpiresAt : token.expires, + client: { + id: client.id, + grants: client.grants, + }, + user: { + id: token.userId, + }, + }; + + return result; + } + + async revokeToken(token: RefreshToken | Token): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', `in revokeToken (token: ${token.accessToken})`); + } + + if (token.refreshToken) { + await OAuthAccessTokens.deleteOne({ refreshToken: token.refreshToken }); + // Keep compatibility with old collection + // Deprecated: Remove on next major within a migration + await OAuthRefreshTokens.deleteOne({ refreshToken: token.refreshToken }); + } + + if (token.accessToken) { + await OAuthAccessTokens.deleteOne({ accessToken: token.accessToken }); + } + + return true; + } + + async revokeAuthorizationCode(code: AuthorizationCode): Promise { + if (this.debug === true) { + console.log('[OAuth2Server]', `in revokeAuthorizationCode (code: ${code.authorizationCode})`); + } + await OAuthAuthCodes.deleteOne({ authCode: code.authorizationCode }); + return true; + } +} diff --git a/apps/meteor/server/oauth2-server/oauth.ts b/apps/meteor/server/oauth2-server/oauth.ts new file mode 100644 index 0000000..45886a2 --- /dev/null +++ b/apps/meteor/server/oauth2-server/oauth.ts @@ -0,0 +1,209 @@ +import OAuthServer, { OAuthError, UnauthorizedRequestError } from '@node-oauth/oauth2-server'; +import { OAuthApps, Users } from '@rocket.chat/models'; +import express from 'express'; +import type { Express, NextFunction, Request, Response } from 'express'; +import { Accounts } from 'meteor/accounts-base'; + +import type { ModelConfig } from './model'; +import { Model } from './model'; + +export class OAuth2Server { + public app: Express; + + private oauth: OAuthServer; + + private config: ModelConfig; + + constructor(config: ModelConfig) { + if (config == null) { + config = {}; + } + + this.config = config; + this.app = express(); + this.app.use( + '/oauth/*', + express.json({ + limit: '50mb', + }), + express.urlencoded({ + extended: true, + limit: '50mb', + }), + express.query({}), + ); + + this.oauth = new OAuthServer({ + model: new Model(this.config), + }); + + this.initRoutes(); + + return this; + } + + initRoutes() { + const { config, oauth } = this; + + const debugMiddleware = function (req: Request, _res: Response, next: NextFunction) { + if (config.debug === true) { + console.log('[OAuth2Server]', req.method, req.url); + } + return next(); + }; + + const handleResponse = function (res: Response, response: OAuthServer.Response, next: NextFunction) { + if (response.status === 302 && response.headers?.location) { + const { location } = response.headers; + delete response.headers.location; + res.set(response.headers); + res.redirect(location); + } else if (response.status) { + res.set(response.headers); + res.status(response.status).send(response.body); + } else { + next(); + } + }; + + // Transforms requests which are POST and aren't "x-www-form-urlencoded" content type + // and they pass the required information as query strings + const transformRequestsNotUsingFormUrlencodedType = function (req: Request, _res: Response, next: NextFunction) { + if (!req.is('application/x-www-form-urlencoded') && req.method === 'POST') { + if (config.debug === true) { + console.log('[OAuth2Server]', 'Transforming a request to form-urlencoded with the query going to the body.'); + } + req.headers['content-type'] = 'application/x-www-form-urlencoded'; + req.body = Object.assign({}, req.body, req.query); + } + return next(); + }; + + const validateTokenPayload = function (req: Request, res: Response, next: NextFunction) { + const grantParams = ['client_id', 'client_secret', 'refresh_token', 'code', 'grant_type', 'scope', 'redirect_uri']; + + for (const param of grantParams) { + for (const source of [req.body, req.query]) { + if (source?.[param] !== undefined && typeof source[param] !== 'string') { + return res.status(400).send({ + error: 'invalid_request', + error_description: `Invalid parameter: ${param} must be a string`, + }); + } + } + } + return next(); + }; + + this.app.all( + '/oauth/token', + debugMiddleware, + transformRequestsNotUsingFormUrlencodedType, + validateTokenPayload, + async (req, res, next) => { + const request = new OAuthServer.Request(req); + const response = new OAuthServer.Response(res); + + try { + await oauth.token(request, response); + + handleResponse(res, response, next); + } catch (e: any) { + next(e); + } + }, + ); + + this.app.get('/oauth/authorize', debugMiddleware, async (req, res, next) => { + if (typeof req.query.client_id !== 'string') { + return res.redirect('/oauth/error/404'); + } + + const client = await OAuthApps.findOneActiveByClientId(req.query.client_id); + if (client == null) { + return res.redirect('/oauth/error/404'); + } + + const redirectUris: string[] = client.redirectUri.split(','); + + if (typeof req.query.redirect_uri === 'string' && !redirectUris.includes(req.query.redirect_uri)) { + return res.redirect('/oauth/error/invalid_redirect_uri'); + } + + return next(); + }); + + this.app.post('/oauth/authorize', debugMiddleware, async (req, res, next) => { + if (req.body.allow !== 'yes') { + res.status(401); + return res.send({ error: 'access_denied', error_description: 'The user denied access to your application' }); + } + + // The new version of the library is expecting a new name. Doing this for compatibility + if (req.body.token && !req.body.access_token) { + req.body.access_token = req.body.token; + } + + if (req.body.access_token == null) { + return res.status(401).send('No token'); + } + + const user = await Users.findOne( + { + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(req.body.access_token), + }, + { projection: { _id: 1 } }, + ); + + if (user == null) { + return res.status(401).send('Invalid token'); + } + + res.locals.user = { id: user._id }; + + return next(); + }); + + this.app.post('/oauth/authorize', debugMiddleware, async (req: Request, res: Response, next: NextFunction) => { + const request = new OAuthServer.Request(req); + const response = new OAuthServer.Response(res); + + try { + await oauth.authorize(request, response, { + authenticateHandler: { + async handle() { + const clientId = request.body.client_id || request.query?.client_id; + + if (!clientId) { + throw new Error('Missing parameter: `client_id`'); + } + + if (req.body.allow === 'yes') { + await Users.updateOne({ _id: res.locals.user.id }, { $addToSet: { 'oauth.authorizedClients': clientId } }); + } + return { id: res.locals.user.id }; + }, + }, + }); + + handleResponse(res, response, next); + } catch (e: any) { + next(e); + } + }); + + this.app.use('/oauth/*', (err: Error, _req: Request, res: Response, next: NextFunction) => { + if (!(err instanceof OAuthError)) return next(err); + + delete err.stack; + + res.status(err.code); + + if (err instanceof UnauthorizedRequestError) { + return res.send(); + } + + res.send({ error: err.name, error_description: err.message }); + }); + } +} diff --git a/apps/meteor/server/publications/index.ts b/apps/meteor/server/publications/index.ts new file mode 100644 index 0000000..b880933 --- /dev/null +++ b/apps/meteor/server/publications/index.ts @@ -0,0 +1,5 @@ +import './messages'; +import './room'; +import './settings'; +import './spotlight'; +import './subscription'; diff --git a/apps/meteor/server/publications/messages.ts b/apps/meteor/server/publications/messages.ts new file mode 100644 index 0000000..d5e2131 --- /dev/null +++ b/apps/meteor/server/publications/messages.ts @@ -0,0 +1,305 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { FindOptions } from 'mongodb'; + +import { canAccessRoomIdAsync } from '../lib/authorization/canAccessRoom'; +import { getChannelHistory } from '../meteor-methods/messages/getChannelHistory'; + +type CursorPaginationType = 'UPDATED' | 'DELETED'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'messages/get': ( + rid: IRoom['_id'], + options: { + lastUpdate?: Date; + latestDate?: Date; + oldestDate?: Date; + inclusive?: boolean; + count?: number; + unreads?: boolean; + next?: string; + previous?: string; + type?: CursorPaginationType; + }, + ) => Promise< + | { + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; + } + | boolean + | IMessage[] + | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number } + >; + } +} + +export function extractTimestampFromCursor(cursor: string): Date { + const timestamp = parseInt(cursor, 10); + + if (isNaN(timestamp) || new Date(timestamp).toString() === 'Invalid Date') { + throw new Error('Invalid Date'); + } + + return new Date(timestamp); +} + +export function mountCursorQuery({ next, previous, count }: { next?: string; previous?: string; count: number }): { + query: { $gt: Date } | { $lt: Date }; + options: FindOptions; +} { + const options: FindOptions = { + sort: { _updatedAt: 1 }, + ...(next || previous ? { limit: count + 1 } : {}), + }; + + if (next) { + return { query: { $gt: extractTimestampFromCursor(next) }, options }; + } + + if (previous) { + return { query: { $lt: extractTimestampFromCursor(previous) }, options: { ...options, sort: { _updatedAt: -1 } } }; + } + + return { query: { $gt: new Date(0) }, options }; +} + +export function mountCursorFromMessage(message: IMessage & { _deletedAt?: Date }, type: 'UPDATED' | 'DELETED'): string { + if (type === 'UPDATED' && message._updatedAt) { + return `${message._updatedAt.getTime()}`; + } + + if (type === 'DELETED' && message._deletedAt) { + return `${message._deletedAt.getTime()}`; + } + + throw new Meteor.Error('error-cursor-not-found', 'Cursor not found', { method: 'messages/get' }); +} + +export function mountNextCursor( + messages: IMessage[], + count: number, + type: CursorPaginationType, + next?: string, + previous?: string, +): string | null { + if (messages.length === 0) { + return null; + } + + if (previous) { + return mountCursorFromMessage(messages[0], type); + } + + if (messages.length <= count && next) { + return null; + } + + if (messages.length > count && next) { + return mountCursorFromMessage(messages[messages.length - 2], type); + } + + return mountCursorFromMessage(messages[messages.length - 1], type); +} + +export function mountPreviousCursor( + messages: IMessage[], + count: number, + type: CursorPaginationType, + next?: string, + previous?: string, +): string | null { + if (messages.length === 0) { + return null; + } + + if (messages.length <= count && next) { + return mountCursorFromMessage(messages[0], type); + } + + if (messages.length > count && next) { + return mountCursorFromMessage(messages[0], type); + } + + if (messages.length <= count && previous) { + return null; + } + + if (messages.length > count && previous) { + return mountCursorFromMessage(messages[messages.length - 2], type); + } + + return mountCursorFromMessage(messages[0], type); +} + +export async function handleWithoutPagination(rid: IRoom['_id'], lastUpdate: Date, fromTs?: Date) { + const options: FindOptions = { sort: { ts: -1 } }; + + const [updatedMessages, deletedMessages] = await Promise.all([ + Messages.findForUpdates(rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, options).toArray(), + Messages.trashFindDeletedAfter( + lastUpdate, + { rid, ...(fromTs && { ts: { $gte: fromTs } }) }, + { projection: { _id: 1, _deletedAt: 1 }, ...options }, + ).toArray(), + ]); + + return { + updated: updatedMessages, + deleted: deletedMessages, + }; +} + +export async function handleCursorPagination( + type: CursorPaginationType, + rid: IRoom['_id'], + count: number, + next?: string, + previous?: string, +): Promise<{ + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; +}> { + const { query, options } = mountCursorQuery({ next, previous, count }); + + const response = + type === 'UPDATED' + ? await Messages.findForUpdates(rid, { updatedAt: query }, options).toArray() + : ((await Messages.trashFind( + { rid, _deletedAt: query }, + { projection: { _id: 1, _deletedAt: 1 }, ...options }, + )!.toArray()) as IMessage[]); + + const cursor = { + next: mountNextCursor(response, count, type, next, previous), + previous: mountPreviousCursor(response, count, type, next, previous), + }; + + if (response.length > count) { + response.pop(); + } + + return { + updated: type === 'UPDATED' ? response : [], + deleted: type === 'DELETED' ? response : [], + cursor, + }; +} + +export const getMessageHistory = async ( + rid: IRoom['_id'], + fromId: string, + { + lastUpdate, + fromTs, + latestDate = new Date(), + oldestDate, + inclusive = false, + count = 20, + unreads = false, + next, + previous, + type, + }: { + lastUpdate?: Date; + fromTs?: Date; + latestDate?: Date; + oldestDate?: Date; + inclusive?: boolean; + count?: number; + unreads?: boolean; + next?: string; + previous?: string; + type?: CursorPaginationType; + }, +): Promise< + | { + updated: IMessage[]; + deleted: IMessage[]; + cursor?: { + next: string | null; + previous: string | null; + }; + } + | false + | IMessage[] + | { messages: IMessage[]; firstUnread?: any; unreadNotLoaded?: number } +> => { + if (!(await canAccessRoomIdAsync(rid, fromId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'messages/get' }); + } + + if (type && !['UPDATED', 'DELETED'].includes(type)) { + throw new Meteor.Error('error-type-param-not-supported', 'The "type" parameter must be either "UPDATED" or "DELETED"'); + } + + if ((next || previous) && !type) { + throw new Meteor.Error('error-type-param-required', 'The "type" parameter is required when using the "next" or "previous" parameters'); + } + + if (next && previous) { + throw new Meteor.Error('error-cursor-conflict', 'You cannot provide both "next" and "previous" parameters'); + } + + if ((next || previous) && lastUpdate) { + throw new Meteor.Error( + 'error-cursor-and-lastUpdate-conflict', + 'The attributes "next", "previous" and "lastUpdate" cannot be used together', + ); + } + + // `fromTs` only bounds the query on the `lastUpdate` path; neither cursor pagination nor the channel + // history fallback honors it, so accepting it there would silently widen the result set. + if (fromTs && !lastUpdate) { + throw new Meteor.Error('error-fromTs-requires-lastUpdate', 'The "fromTs" parameter can only be used together with "lastUpdate"'); + } + + const hasCursorPagination = !!((next || previous) && count !== null && type); + + if (!hasCursorPagination && !lastUpdate) { + return getChannelHistory({ rid, fromUserId: fromId, latest: latestDate, oldest: oldestDate, inclusive, count, unreads }); + } + + if (lastUpdate) { + return handleWithoutPagination(rid, lastUpdate, fromTs); + } + + if (!type) { + throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); + } + + return handleCursorPagination(type, rid, count, next, previous); +}; + +Meteor.methods({ + async 'messages/get'( + rid, + { lastUpdate, latestDate = new Date(), oldestDate, inclusive = false, count = 20, unreads = false, next, previous, type }, + ) { + check(rid, String); + + const fromId = Meteor.userId(); + + if (!fromId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'messages/get' }); + } + + if (!rid) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'messages/get' }); + } + + return getMessageHistory(rid, fromId, { lastUpdate, latestDate, oldestDate, inclusive, count, unreads, next, previous, type }); + }, +}); diff --git a/apps/meteor/server/publications/room/index.ts b/apps/meteor/server/publications/room/index.ts new file mode 100644 index 0000000..a6b80a6 --- /dev/null +++ b/apps/meteor/server/publications/room/index.ts @@ -0,0 +1,99 @@ +import type { IOmnichannelRoom, IRoom, RoomType } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { roomFields } from '../../../lib/publishFields'; +import { canAccessRoomAsync } from '../../lib/authorization'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { settings } from '../../settings'; + +type PublicRoomField = keyof typeof roomFields; +type PublicRoom = Pick & Pick; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'rooms/get'(updatedAt?: Date): IRoom[] | { update: IRoom[]; remove: IRoom[] }; + 'getRoomByTypeAndName': (type: RoomType, name: string) => PublicRoom; + } +} + +const roomMap = (record: IRoom | IOmnichannelRoom) => { + return _.pick(record, ...Object.keys(roomFields)) as PublicRoom; +}; + +export const roomsGetMethod = async (userId?: string | null, updatedAt?: Date): Promise => { + const options = { projection: roomFields }; + + if (!userId) { + if (settings.get('Accounts_AllowAnonymousRead')) { + return Rooms.findByDefaultAndTypes(true, ['c'], options).toArray(); + } + return []; + } + + if (updatedAt instanceof Date) { + return { + update: await (await Rooms.findBySubscriptionUserIdUpdatedAfter(userId, updatedAt, options)).toArray(), + remove: await Rooms.trashFindDeletedAfter(updatedAt, {}, { projection: { _id: 1, _deletedAt: 1 } }).toArray(), + }; + } + + return (await Rooms.findBySubscriptionUserId(userId, options)).toArray(); +}; + +Meteor.methods({ + async 'rooms/get'(updatedAt) { + return roomsGetMethod(Meteor.userId(), updatedAt); + }, + + async 'getRoomByTypeAndName'(type, name) { + if (!type || !name) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getRoomByTypeAndName', + }); + } + + const user = await Meteor.userAsync(); + const isAnonymous = !user?._id; + + if (isAnonymous) { + const allowAnon = settings.get('Accounts_AllowAnonymousRead'); + if (!allowAnon || type !== 'c') { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getRoomByTypeAndName', + }); + } + } + + const roomFind = roomCoordinator.getRoomFind(type); + + const room = roomFind ? await roomFind.call(this, name, user?._id) : await Rooms.findByTypeAndNameOrId(type, name); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getRoomByTypeAndName', + }); + } + + if ( + user && + !(await canAccessRoomAsync(room, user, { + includeInvitations: true, + })) + ) { + throw new Meteor.Error('error-no-permission', 'No permission', { + method: 'getRoomByTypeAndName', + }); + } + + if (settings.get('Store_Last_Message') && user && !(await hasPermissionAsync(user, 'preview-c-room'))) { + delete room.lastMessage; + } + + return roomMap(room); + }, +}); diff --git a/apps/meteor/server/publications/settings/index.ts b/apps/meteor/server/publications/settings/index.ts new file mode 100644 index 0000000..48490d2 --- /dev/null +++ b/apps/meteor/server/publications/settings/index.ts @@ -0,0 +1,114 @@ +import type { ISetting, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Settings } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import type { WithId } from 'mongodb'; + +import { getSettingPermissionId } from '../../../app/authorization/lib'; +import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; +import { SettingsEvents } from '../../settings'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'public-settings/get'( + updatedSince?: Date, + ): Promise>[] }>; + 'private-settings/get'( + updatedSince?: Date, + ): Promise>[] }>; + } +} + +Meteor.methods({ + async 'public-settings/get'(updatedAt) { + if (updatedAt instanceof Date) { + const records = await Settings.findNotHiddenPublicUpdatedAfter(updatedAt).toArray(); + SettingsEvents.emit('fetch-settings', records); + + return { + update: records, + remove: await Settings.trashFindDeletedAfter( + updatedAt, + { + hidden: { + $ne: true, + }, + public: true, + }, + { + projection: { + _id: 1, + _deletedAt: 1, + }, + }, + ).toArray(), + }; + } + + const publicSettings = (await Settings.findNotHiddenPublic().toArray()) as ISetting[]; + SettingsEvents.emit('fetch-settings', publicSettings); + + return publicSettings; + }, + async 'private-settings/get'(updatedAfter) { + const uid = Meteor.userId(); + + if (!uid) { + return []; + } + + const privilegedSetting = await hasAtLeastOnePermissionAsync(uid, ['view-privileged-setting', 'edit-privileged-setting']); + const manageSelectedSettings = privilegedSetting || (await hasPermissionAsync(uid, 'manage-selected-settings')); + + if (!manageSelectedSettings) { + return []; + } + + const bypass = async (settings: T): Promise => settings; + + const applyFilter = (fn: (args: T) => Promise, args: T): Promise => fn(args); + + const getAuthorizedSettingsFiltered = async (settings: ISetting[]): Promise => + ( + await Promise.all( + settings.map(async (record) => { + if (await hasPermissionAsync(uid, getSettingPermissionId(record._id))) { + return record; + } + }), + ) + ).filter(Boolean) as ISetting[]; + + const getAuthorizedSettings = async (updatedAfter: Date | undefined, privilegedSetting: boolean): Promise => + applyFilter( + privilegedSetting ? bypass : getAuthorizedSettingsFiltered, + await Settings.findNotHidden(updatedAfter && { updatedAfter }).toArray(), + ); + + if (!(updatedAfter instanceof Date)) { + // this does not only imply an unfiltered setting range, it also identifies the caller's context: + // If called *with* filter (see below), the user wants a collection as a result. + // in this case, it shall only be a plain array + return getAuthorizedSettings(updatedAfter, privilegedSetting); + } + + return { + update: await getAuthorizedSettings(updatedAfter, privilegedSetting), + remove: await Settings.trashFindDeletedAfter( + updatedAfter, + { + hidden: { + $ne: true, + }, + }, + { + projection: { + _id: 1, + _deletedAt: 1, + }, + }, + ).toArray(), + }; + }, +}); diff --git a/apps/meteor/server/publications/spotlight.ts b/apps/meteor/server/publications/spotlight.ts new file mode 100644 index 0000000..3f63f19 --- /dev/null +++ b/apps/meteor/server/publications/spotlight.ts @@ -0,0 +1,92 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; + +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; +import { Spotlight } from '../lib/spotlight'; +import { getUsersHiddenFrom, redactHiddenUsers } from '../lib/statusVisibility/hiddenUsers'; + +type SpotlightType = { + users?: boolean; + rooms?: boolean; + mentions?: boolean; + includeFederatedRooms?: boolean; +}; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + spotlight( + text: string, + usernames?: string[], + type?: SpotlightType, + rid?: string, + ): { + rooms: { _id: string; name: string; t: string; uids?: string[] }[]; + users: { + _id: string; + status: 'offline' | 'online' | 'busy' | 'away'; + name: string; + username: string; + outside: boolean; + avatarETag?: string; + nickname?: string; + }[]; + }; + } +} + +export const spotlightMethod = async ({ + text, + usernames = [], + type = { users: true, rooms: true, mentions: false, includeFederatedRooms: false }, + rid, + userId, +}: { + text: string; + usernames?: string[]; + type?: SpotlightType; + rid?: string; + userId?: string | null; +}) => { + const spotlight = new Spotlight(); + const { mentions, includeFederatedRooms } = type; + + if (text.startsWith('#')) { + type.users = false; + text = text.slice(1); + } + + if (text.startsWith('@')) { + type.rooms = false; + text = text.slice(1); + } + + const [users, rooms] = await Promise.all([ + type.users ? spotlight.searchUsers({ userId, rid, text, usernames, mentions }) : [], + type.rooms ? spotlight.searchRooms({ userId, text, includeFederatedRooms }) : [], + ]); + + const hidden = await getUsersHiddenFrom(userId); + + return { users: redactHiddenUsers(users, hidden), rooms }; +}; + +Meteor.methods({ + async spotlight(text, usernames = [], type = { users: true, rooms: true, mentions: false, includeFederatedRooms: false }, rid) { + methodDeprecationLogger.method('spotlight', '9.0.0', '/v1/spotlight'); + return spotlightMethod({ text, usernames, type, rid, userId: this.userId }); + }, +}); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'spotlight', + userId(/* userId*/) { + return true; + }, + }, + 100, + 100000, +); diff --git a/apps/meteor/server/publications/subscription/index.ts b/apps/meteor/server/publications/subscription/index.ts new file mode 100644 index 0000000..fdd05cf --- /dev/null +++ b/apps/meteor/server/publications/subscription/index.ts @@ -0,0 +1,55 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { subscriptionFields } from '../../../lib/publishFields'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + 'subscriptions/get'(updatedAt?: Date): ISubscription[] | { update: ISubscription[]; remove: { _id: string; _deletedAt: Date }[] }; + } +} + +export const getSubscriptions = async ( + uid: string, + updatedAt?: Date, +): Promise => { + const options = { projection: subscriptionFields }; + + const records: ISubscription[] = await Subscriptions.findByUserId(uid, options).toArray(); + + if (updatedAt instanceof Date) { + return { + update: records.filter((record) => { + return record._updatedAt > updatedAt; + }), + remove: await Subscriptions.trashFindDeletedAfter( + updatedAt, + { + 'u._id': uid, + }, + { + projection: { + _id: 1, + _deletedAt: 1, + }, + }, + ).toArray(), + }; + } + + return records; +}; + +Meteor.methods({ + async 'subscriptions/get'(updatedAt) { + const uid = Meteor.userId(); + if (!uid) { + return []; + } + + return getSubscriptions(uid, updatedAt); + }, +}); diff --git a/apps/meteor/server/routes/avatar/index.ts b/apps/meteor/server/routes/avatar/index.ts new file mode 100644 index 0000000..5a6adff --- /dev/null +++ b/apps/meteor/server/routes/avatar/index.ts @@ -0,0 +1,10 @@ +import { WebApp } from 'meteor/webapp'; + +import { roomAvatar } from './room'; +import { userAvatarByUsername, userAvatarById } from './user'; + +import './middlewares'; + +WebApp.connectHandlers.use('/avatar/room/', roomAvatar); +WebApp.connectHandlers.use('/avatar/uid/', userAvatarById); +WebApp.connectHandlers.use('/avatar/', userAvatarByUsername); diff --git a/apps/meteor/server/routes/avatar/middlewares/auth.spec.ts b/apps/meteor/server/routes/avatar/middlewares/auth.spec.ts new file mode 100644 index 0000000..05292fb --- /dev/null +++ b/apps/meteor/server/routes/avatar/middlewares/auth.spec.ts @@ -0,0 +1,124 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const mocks = { + utils: { + userCanAccessAvatar: sinon.stub(), + renderSVGLetters: sinon.stub(), + }, +}; + +const { protectAvatarsWithFallback, protectAvatars } = proxyquire.noCallThru().load('./auth.ts', { + '../utils': mocks.utils, +}); + +describe('#protectAvatarsWithFallback()', () => { + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + + Object.values(mocks.utils).forEach((mock) => mock.reset()); + }); + + it(`should write 404 to head if no url provided`, async () => { + await protectAvatarsWithFallback({}, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should write 200 to head and write fallback to body (user avatar)`, async () => { + mocks.utils.renderSVGLetters.returns('fallback'); + + await protectAvatarsWithFallback({ url: '/jon' }, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + + expect(response.writeHead.calledWith(200, { 'Content-Type': 'image/svg+xml' })).to.be.true; + expect(mocks.utils.renderSVGLetters.calledWith('jon')).to.be.true; + expect(response.write.calledWith('fallback')).to.be.true; + + expect(response.end.calledOnce).to.be.true; + }); + + it(`should write 200 to head and write fallback to body (room avatar)`, async () => { + mocks.utils.renderSVGLetters.returns('fallback'); + + await protectAvatarsWithFallback({ url: '/room/jon' }, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(200, { 'Content-Type': 'image/svg+xml' })).to.be.true; + expect(response.write.calledWith('fallback')).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should call next if user can access avatar`, async () => { + mocks.utils.userCanAccessAvatar.returns(true); + const request = { url: '/jon' }; + + await protectAvatarsWithFallback(request, response, next); + expect(mocks.utils.userCanAccessAvatar.calledWith(request)).to.be.true; + expect(next.called).to.be.true; + }); +}); + +describe('#protectAvatars()', () => { + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + + Object.values(mocks.utils).forEach((mock) => mock.reset()); + }); + + it(`should write 404 to head if no url provided`, async () => { + await protectAvatars({}, response, next); + + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should write 404 to head if access is denied`, async () => { + mocks.utils.userCanAccessAvatar.returns(false); + + await protectAvatars({ url: '/room/jon' }, response, next); + + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should call next if user can access avatar`, async () => { + mocks.utils.userCanAccessAvatar.returns(true); + const request = { url: '/jon' }; + + await protectAvatars(request, response, next); + expect(mocks.utils.userCanAccessAvatar.calledWith(request)).to.be.true; + expect(next.called).to.be.true; + }); +}); diff --git a/apps/meteor/server/routes/avatar/middlewares/auth.ts b/apps/meteor/server/routes/avatar/middlewares/auth.ts new file mode 100644 index 0000000..33cb5cb --- /dev/null +++ b/apps/meteor/server/routes/avatar/middlewares/auth.ts @@ -0,0 +1,47 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { IIncomingMessage } from '@rocket.chat/core-typings'; +import type { NextFunction } from 'connect'; + +import { userCanAccessAvatar, renderSVGLetters } from '../utils'; + +const renderFallback = (req: IncomingMessage, res: ServerResponse) => { + if (!req.url) { + res.writeHead(404); + res.end(); + return; + } + + let roomOrUsername; + + if (req.url.startsWith('/room')) { + roomOrUsername = req.url.split('/')[2] || 'Room'; + } else { + roomOrUsername = req.url.split('/')[1] || 'Anonymous'; + } + + res.writeHead(200, { 'Content-Type': 'image/svg+xml' }); + res.write(renderSVGLetters(roomOrUsername, 200)); + res.end(); +}; + +const getProtectAvatars = (callback?: typeof renderFallback) => async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => { + if (!(await userCanAccessAvatar(req as IIncomingMessage))) { + if (callback) { + callback(req, res); + return; + } + + res.writeHead(404); + res.end(); + return; + } + + return next(); +}; + +// If unauthorized returns the SVG fallback (letter avatar) +export const protectAvatarsWithFallback = getProtectAvatars(renderFallback); + +// Just returns 404 +export const protectAvatars = getProtectAvatars(); diff --git a/apps/meteor/server/routes/avatar/middlewares/browserVersion.spec.ts b/apps/meteor/server/routes/avatar/middlewares/browserVersion.spec.ts new file mode 100644 index 0000000..8480480 --- /dev/null +++ b/apps/meteor/server/routes/avatar/middlewares/browserVersion.spec.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// const getCookie = sinon.stub(); +class CookiesMock { + public get = (_key: any, value: any) => value; +} +const { handleBrowserVersionCheck, isIEOlderThan11 } = proxyquire.noCallThru().load('./browserVersion', { + 'meteor/ostrio:cookies': { + Cookies: CookiesMock, + }, + '../../../lib/utils/getURL': { + getURL: () => '', + }, +}); + +describe('#isIEOlderThan11()', () => { + it('should return false if user agent is IE11', () => { + const userAgent = { + browser: { + name: 'IE', + version: '11.0', + }, + }; + expect(isIEOlderThan11(userAgent)).to.be.false; + }); + + it('should return true if user agent is IE < 11', () => { + const userAgent = { + browser: { + name: 'IE', + version: '10.0', + }, + }; + expect(isIEOlderThan11(userAgent)).to.be.true; + }); +}); + +describe('#handleBrowserVersionCheck()', () => { + it('should call next if browser_version_check cookie is set to "bypass"', async () => { + const next = sinon.spy(); + const request = { + headers: { + cookie: 'bypass', + }, + }; + handleBrowserVersionCheck(request as any, {} as any, next); + + expect(next.calledOnce).to.be.true; + }); + + it('should call next if browser_version_check cookie is not set to "force" and user agent is not IE < 11', async () => { + const next = sinon.spy(); + const request = { + headers: { + 'cookie': 'anything', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', + }, + }; + handleBrowserVersionCheck(request as any, {} as any, next); + + expect(next.calledOnce).to.be.true; + }); + + it('should respond with Browser not supported', async () => { + const next = sinon.spy(); + const request = { + headers: { + 'cookie': 'anything', + 'user-agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 7.0; InfoPath.3; .NET CLR 3.1.40767; Trident/6.0; en-IN)', + }, + }; + + const response = { + setHeader: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + handleBrowserVersionCheck(request as any, response as any, next); + + expect(response.setHeader.calledWith('content-type', 'text/html; charset=utf-8')).to.be.true; + expect(response.write.calledWith(sinon.match('Browser not supported'))).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/server/routes/avatar/middlewares/browserVersion.ts b/apps/meteor/server/routes/avatar/middlewares/browserVersion.ts new file mode 100644 index 0000000..82d3606 --- /dev/null +++ b/apps/meteor/server/routes/avatar/middlewares/browserVersion.ts @@ -0,0 +1,147 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { IIncomingMessage } from '@rocket.chat/core-typings'; +import type { NextFunction } from 'connect'; +import { Cookies } from 'meteor/ostrio:cookies'; +import parser from 'ua-parser-js'; + +import { getURL } from '../../../lib/utils/getURL'; + +const cookies = new Cookies(); + +export const isIEOlderThan11 = (userAgent: ReturnType) => { + if (!userAgent?.browser.name || !userAgent?.browser.version) { + return false; + } + return userAgent.browser.name === 'IE' && parseInt(userAgent.browser.version) < 11; +}; + +export const handleBrowserVersionCheck = (request: IncomingMessage, res: ServerResponse, next: NextFunction) => { + const req = request as IIncomingMessage; + + const browserVersionCheck = cookies.get('browser_version_check', req.headers.cookie); + if (browserVersionCheck === 'bypass') { + next(); + return; + } + + const userAgent = parser(req.headers['user-agent']); + if (browserVersionCheck !== 'force' && !isIEOlderThan11(userAgent)) { + next(); + return; + } + + res.setHeader('content-type', 'text/html; charset=utf-8'); + + res.write(` + + +

+
+
Browser not supported
+ Check the documentation + Proceed anyway +
+
+ `); + + res.end(); +}; diff --git a/apps/meteor/server/routes/avatar/middlewares/index.ts b/apps/meteor/server/routes/avatar/middlewares/index.ts new file mode 100644 index 0000000..659c6e9 --- /dev/null +++ b/apps/meteor/server/routes/avatar/middlewares/index.ts @@ -0,0 +1,8 @@ +import { WebApp } from 'meteor/webapp'; + +import { protectAvatarsWithFallback, protectAvatars } from './auth'; +import { handleBrowserVersionCheck } from './browserVersion'; + +WebApp.connectHandlers.use(handleBrowserVersionCheck); +WebApp.connectHandlers.use('/avatar/uid/', protectAvatars); +WebApp.connectHandlers.use('/avatar/', protectAvatarsWithFallback); diff --git a/apps/meteor/server/routes/avatar/room.spec.ts b/apps/meteor/server/routes/avatar/room.spec.ts new file mode 100644 index 0000000..a7bd494 --- /dev/null +++ b/apps/meteor/server/routes/avatar/room.spec.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const mocks = { + settingsGet: sinon.stub(), + findOneById: sinon.stub(), + utils: { + serveSvgAvatarInRequestedFormat: sinon.spy(), + wasFallbackModified: sinon.stub(), + setCacheAndDispositionHeaders: sinon.spy(), + serveAvatarFile: sinon.spy(), + }, + avatarFindOneByRoomId: sinon.stub(), + getRoomName: sinon.stub(), +}; + +class CookiesMock { + public get = (_key: any, value: any) => value; +} + +const { roomAvatar } = proxyquire.noCallThru().load('./room', { + '@rocket.chat/models': { + Rooms: { + findOneById: mocks.findOneById, + }, + Avatars: { + findOneByRoomId: mocks.avatarFindOneByRoomId, + }, + }, + './utils': mocks.utils, + '../../lib/rooms/roomCoordinator': { + roomCoordinator: { + getRoomName: mocks.getRoomName, + }, + }, + 'meteor/ostrio:cookies': { + Cookies: CookiesMock, + }, +}); + +describe('#roomAvatar()', () => { + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + mocks.settingsGet.reset(); + mocks.avatarFindOneByRoomId.reset(); + mocks.findOneById.reset(); + + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + + Object.values(mocks.utils).forEach((mock) => ('reset' in mock ? mock.reset() : mock.resetHistory())); + }); + + it(`should do nothing if url is not in request object`, async () => { + await roomAvatar({}, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.called).to.be.false; + expect(response.end.called).to.be.false; + }); + + it(`should write 404 if room is not found`, async () => { + mocks.findOneById.returns(null); + await roomAvatar({ url: '/' }, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should serve avatar file if found`, async () => { + const request = { url: '/roomId' }; + + const file = { uploadedAt: new Date(0), type: 'image/png', size: 100 }; + + mocks.findOneById.withArgs('roomId').returns({ _id: 'roomId' }); + mocks.avatarFindOneByRoomId.withArgs('roomId').returns(file); + + await roomAvatar(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveAvatarFile.calledWith(file, request, response, next)).to.be.true; + }); + + it(`should serve parent room avatar file if current room avatar is not found`, async () => { + const request = { url: '/roomId' }; + + const file = { uploadedAt: new Date(0), type: 'image/png', size: 100 }; + + mocks.findOneById.withArgs('roomId').returns({ _id: 'roomId', prid: 'roomId2' }); + mocks.findOneById.withArgs('roomId2').returns({ _id: 'roomId2' }); + mocks.avatarFindOneByRoomId.withArgs('roomId2').returns(file); + + await roomAvatar(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveAvatarFile.calledWith(file, request, response, next)).to.be.true; + }); + + it(`should write 304 if fallback content is not modified`, async () => { + const request = { url: '/roomId', headers: {} }; + + mocks.findOneById.withArgs('roomId').returns({ _id: 'roomId' }); + mocks.utils.wasFallbackModified.returns(false); + await roomAvatar(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(response.writeHead.calledWith(304)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should serve svg fallback if no file found`, async () => { + const request = { url: '/roomId', headers: { cookie: 'userId' } }; + + mocks.utils.wasFallbackModified.returns(true); + mocks.findOneById.withArgs('roomId').returns({ _id: 'roomId' }); + mocks.getRoomName.returns('roomName'); + + await roomAvatar(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ nameOrUsername: 'roomName', req: request, res: response })).to.be.true; + }); +}); diff --git a/apps/meteor/server/routes/avatar/room.ts b/apps/meteor/server/routes/avatar/room.ts new file mode 100644 index 0000000..9cdb0c1 --- /dev/null +++ b/apps/meteor/server/routes/avatar/room.ts @@ -0,0 +1,61 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { IIncomingMessage, IRoom, IUpload } from '@rocket.chat/core-typings'; +import { Avatars, Rooms } from '@rocket.chat/models'; +import type { NextFunction } from 'connect'; +import { Cookies } from 'meteor/ostrio:cookies'; + +import { serveSvgAvatarInRequestedFormat, wasFallbackModified, setCacheAndDispositionHeaders, serveAvatarFile } from './utils'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +const cookie = new Cookies(); +const getRoomAndAvatarFile = async (roomId: IRoom['_id']): Promise<{ room: IRoom; file: IUpload | null } | void> => { + const room = await Rooms.findOneById(roomId); + if (!room) { + return; + } + + const file = await Avatars.findOneByRoomId(room._id); + + // if it is a discussion that doesn't have it's own avatar, returns the parent's room avatar + if (room.prid && !file) { + return getRoomAndAvatarFile(room.prid); + } + + return { room, file }; +}; + +export const roomAvatar = async function (request: IncomingMessage, res: ServerResponse, next: NextFunction) { + const req = request as IIncomingMessage; + + if (!req.url) { + return; + } + + const roomId = decodeURIComponent(req.url.slice(1).replace(/\?.*$/, '')); + + const { room, file } = (await getRoomAndAvatarFile(roomId)) || {}; + if (!room) { + res.writeHead(404); + res.end(); + return; + } + + setCacheAndDispositionHeaders(req, res); + + if (file) { + void serveAvatarFile(file, req, res, next); + return; + } + + if (!wasFallbackModified(req.headers['if-modified-since'])) { + res.writeHead(304); + res.end(); + return; + } + + const uid = req.headers.cookie && cookie.get('rc_uid', req.headers.cookie); + const roomName = await roomCoordinator.getRoomName(room.t, room, uid); + + serveSvgAvatarInRequestedFormat({ nameOrUsername: roomName, req, res }); +}; diff --git a/apps/meteor/server/routes/avatar/user.spec.ts b/apps/meteor/server/routes/avatar/user.spec.ts new file mode 100644 index 0000000..db7aa6c --- /dev/null +++ b/apps/meteor/server/routes/avatar/user.spec.ts @@ -0,0 +1,324 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { Headers } from 'node-fetch'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const mocks = { + settingsGet: sinon.stub(), + findOneByUsernameIgnoringCase: sinon.stub(), + findOneById: sinon.stub(), + utils: { + serveSvgAvatarInRequestedFormat: sinon.spy(), + wasFallbackModified: sinon.stub(), + setCacheAndDispositionHeaders: sinon.spy(), + serveAvatarFile: sinon.spy(), + }, + serverFetch: sinon.stub(), + avatarFindOneByName: sinon.stub(), + avatarFindOneByUserId: sinon.stub(), +}; + +const { userAvatarById, userAvatarByUsername } = proxyquire.noCallThru().load('./user', { + '@rocket.chat/models': { + Users: { + findOneByUsernameIgnoringCase: mocks.findOneByUsernameIgnoringCase, + findOneById: mocks.findOneById, + }, + Avatars: { + findOneByName: mocks.avatarFindOneByName, + findOneByUserId: mocks.avatarFindOneByUserId, + }, + }, + '../../settings': { + settings: { + get: mocks.settingsGet, + }, + }, + './utils': mocks.utils, + '@rocket.chat/server-fetch': { + serverFetch: mocks.serverFetch, + }, +}); + +describe('#userAvatarById()', () => { + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + mocks.settingsGet.reset(); + mocks.avatarFindOneByUserId.reset(); + + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + + Object.values(mocks.utils).forEach((mock) => ('reset' in mock ? mock.reset() : mock.resetHistory())); + }); + + it(`should do nothing if url is not in request object`, async () => { + await userAvatarById({}, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.called).to.be.false; + expect(response.end.called).to.be.false; + }); + + it(`should write 404 if Id is not provided`, async () => { + await userAvatarById({ url: '/' }, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should call external provider`, async () => { + const userId = 'xvf5Tr34'; + const request = { url: `/${userId}` }; + + const pipe = sinon.spy(); + const mockResponseHeaders = new Headers(); + mockResponseHeaders.set('header1', 'true'); + mockResponseHeaders.set('header2', 'false'); + + mocks.serverFetch.returns({ + headers: mockResponseHeaders, + body: { pipe }, + }); + + mocks.settingsGet.returns('test123/{username}'); + + mocks.findOneById.returns({ username: 'jon' }); + + await userAvatarById(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.findOneById.calledWith(userId)).to.be.true; + expect(mocks.serverFetch.calledWith('test123/jon')).to.be.true; + expect(response.setHeader.calledTwice).to.be.true; + expect(response.setHeader.getCall(0).calledWith('header1', 'true')).to.be.true; + expect(response.setHeader.getCall(1).calledWith('header2', 'false')).to.be.true; + expect(pipe.calledWith(response)).to.be.true; + }); + + it(`should serve avatar file if found`, async () => { + const request = { url: '/jon' }; + + const file = { uploadedAt: new Date(0), type: 'image/png', size: 100 }; + mocks.avatarFindOneByUserId.returns(file); + + await userAvatarById(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveAvatarFile.calledWith(file, request, response, next)).to.be.true; + }); + + it(`should write 304 to head if content is not modified`, async () => { + const request = { url: '/xyzabc', headers: {} }; + + mocks.utils.wasFallbackModified.returns(false); + + await userAvatarById(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(response.writeHead.calledWith(304)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should write 404 if userId is not found`, async () => { + mocks.utils.wasFallbackModified.returns(true); + mocks.findOneById.returns(null); + + const userId = 'awdasdaw'; + const request = { url: `/${userId}`, headers: {} }; + + await userAvatarById(request, response, next); + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should fallback to SVG if no avatar found`, async () => { + const userId = '2apso9283'; + const request = { url: `/${userId}`, headers: {} }; + + mocks.findOneById.returns({ username: 'jon' }); + mocks.utils.wasFallbackModified.returns(true); + + await userAvatarById(request, response, next); + + expect(mocks.findOneById.calledWith(userId)).to.be.true; + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ nameOrUsername: 'jon', req: request, res: response })).to.be.true; + }); + + it(`should fallback to SVG with user name if UI_Use_Name_Avatar is true`, async () => { + const userId = '2apso9283'; + const request = { url: `/${userId}`, headers: {} }; + + mocks.findOneById.returns({ username: 'jon', name: 'Doe' }); + mocks.utils.wasFallbackModified.returns(true); + mocks.settingsGet.withArgs('UI_Use_Name_Avatar').returns(true); + + await userAvatarById(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect( + mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ nameOrUsername: 'Doe', req: request, res: response, useAllInitials: true }), + ).to.be.true; + }); +}); + +describe('#userAvatarByUsername()', () => { + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + mocks.settingsGet.reset(); + mocks.avatarFindOneByName.reset(); + + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + + Object.values(mocks.utils).forEach((mock) => ('reset' in mock ? mock.reset() : mock.resetHistory())); + }); + + it(`should do nothing if url is not in request object`, async () => { + await userAvatarByUsername({}, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.called).to.be.false; + expect(response.end.called).to.be.false; + }); + + it(`should write 404 if username is not provided`, async () => { + await userAvatarByUsername({ url: '/' }, response, next); + expect(next.called).to.be.false; + expect(response.setHeader.called).to.be.false; + expect(response.writeHead.calledWith(404)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should call external provider`, async () => { + const request = { url: '/jon' }; + + const pipe = sinon.spy(); + const mockResponseHeaders = new Headers(); + mockResponseHeaders.set('header1', 'true'); + mockResponseHeaders.set('header2', 'false'); + + mocks.serverFetch.returns({ + headers: mockResponseHeaders, + body: { pipe }, + }); + + mocks.settingsGet.returns('test123/{username}'); + + await userAvatarByUsername(request, response, next); + + expect(mocks.serverFetch.calledWith('test123/jon')).to.be.true; + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(response.setHeader.calledTwice).to.be.true; + expect(response.setHeader.getCall(0).calledWith('header1', 'true')).to.be.true; + expect(response.setHeader.getCall(1).calledWith('header2', 'false')).to.be.true; + expect(pipe.calledWith(response)).to.be.true; + }); + + describe('should serve svg if requestUsername starts with @', () => { + it('should serve SVG and useAllInitials should be false', async () => { + const request = { url: '/@jon' }; + + mocks.settingsGet.returns(false); + + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect( + mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ + nameOrUsername: 'jon', + req: request, + res: response, + useAllInitials: false, + }), + ).to.be.true; + }); + + it('should serve SVG and useAllInitials should be true', async () => { + const request = { url: '/@baba yaga' }; + + mocks.settingsGet.withArgs('UI_Use_Name_Avatar').returns(true); + + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect( + mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ + nameOrUsername: 'baba yaga', + req: request, + res: response, + useAllInitials: true, + }), + ).to.be.true; + }); + }); + + it(`should serve avatar file if found`, async () => { + const request = { url: '/jon' }; + + const file = { uploadedAt: new Date(0), type: 'image/png', size: 100 }; + mocks.avatarFindOneByName.returns(file); + + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveAvatarFile.calledWith(file, request, response, next)).to.be.true; + }); + + it(`should write 304 to head if content is not modified`, async () => { + const request = { url: '/jon', headers: {} }; + + mocks.utils.wasFallbackModified.returns(false); + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(response.writeHead.calledWith(304)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it(`should fallback to SVG if no avatar found`, async () => { + const request = { url: '/jon', headers: {} }; + + mocks.utils.wasFallbackModified.returns(true); + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect(mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ nameOrUsername: 'jon', req: request, res: response })).to.be.true; + }); + + it(`should fallback to SVG with user name if UI_Use_Name_Avatar is true`, async () => { + const request = { url: '/jon', headers: {} }; + + mocks.utils.wasFallbackModified.returns(true); + mocks.settingsGet.withArgs('UI_Use_Name_Avatar').returns(true); + mocks.findOneByUsernameIgnoringCase.returns({ name: 'Doe' }); + + await userAvatarByUsername(request, response, next); + + expect(mocks.utils.setCacheAndDispositionHeaders.calledWith(request, response)).to.be.true; + expect( + mocks.utils.serveSvgAvatarInRequestedFormat.calledWith({ nameOrUsername: 'Doe', req: request, res: response, useAllInitials: true }), + ).to.be.true; + }); +}); diff --git a/apps/meteor/server/routes/avatar/user.ts b/apps/meteor/server/routes/avatar/user.ts new file mode 100644 index 0000000..165324e --- /dev/null +++ b/apps/meteor/server/routes/avatar/user.ts @@ -0,0 +1,142 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { IIncomingMessage } from '@rocket.chat/core-typings'; +import { Avatars, Users } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type { NextFunction } from 'connect'; + +import { serveSvgAvatarInRequestedFormat, wasFallbackModified, setCacheAndDispositionHeaders, serveAvatarFile } from './utils'; +import { settings } from '../../settings'; + +const handleExternalProvider = async (externalProviderUrl: string, username: string, res: ServerResponse): Promise => { + const response = await fetch(externalProviderUrl.replace('{username}', username), { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + response.headers.forEach((value, key) => res.setHeader(key, value)); + response.body.pipe(res); +}; +// request /avatar/@name forces returning the svg +export const userAvatarByUsername = async function (request: IncomingMessage, res: ServerResponse, next: NextFunction) { + const req = request as IIncomingMessage; + + if (!req.url) { + return; + } + + // replace removes the query string + const requestUsername = decodeURIComponent(req.url.slice(1).replace(/\?.*$/, '')); + if (!requestUsername) { + res.writeHead(404); + res.end(); + return; + } + + setCacheAndDispositionHeaders(req, res); + + const externalProviderUrl = settings.get('Accounts_AvatarExternalProviderUrl'); + if (externalProviderUrl) { + void handleExternalProvider(externalProviderUrl, requestUsername, res); + return; + } + + const file = await Avatars.findOneByName(requestUsername); + if (file) { + void serveAvatarFile(file, req, res, next); + return; + } + + // if still not found and starts with @, return SVG with username without @ + if (requestUsername[0] === '@') { + serveSvgAvatarInRequestedFormat({ + nameOrUsername: requestUsername.slice(1), + req, + res, + useAllInitials: settings.get('UI_Use_Name_Avatar'), + }); + return; + } + + // if still using "letters fallback" + if (!wasFallbackModified(req.headers['if-modified-since'])) { + res.writeHead(304); + res.end(); + return; + } + + // Use real name for SVG letters + if (settings.get('UI_Use_Name_Avatar')) { + const user = await Users.findOneByUsernameIgnoringCase(requestUsername, { + projection: { + name: 1, + }, + }); + + if (user?.name) { + serveSvgAvatarInRequestedFormat({ nameOrUsername: user.name, req, res, useAllInitials: true }); + return; + } + } + + serveSvgAvatarInRequestedFormat({ nameOrUsername: requestUsername, req, res }); +}; + +export const userAvatarById = async function (request: IncomingMessage, res: ServerResponse, next: NextFunction) { + const req = request as IIncomingMessage; + + if (!req.url) { + return; + } + + // replace removes the query string + const requestUserId = decodeURIComponent(req.url.slice(1).replace(/\?.*$/, '')); + if (!requestUserId) { + res.writeHead(404); + res.end(); + return; + } + + setCacheAndDispositionHeaders(req, res); + + const externalProviderUrl = settings.get('Accounts_AvatarExternalProviderUrl'); + if (externalProviderUrl) { + const user = await Users.findOneById>(requestUserId, { projection: { username: 1 } }); + + if (!user?.username) { + res.writeHead(404); + res.end(); + return; + } + + void handleExternalProvider(externalProviderUrl, user.username, res); + return; + } + + const file = await Avatars.findOneByUserId(requestUserId); + if (file) { + void serveAvatarFile(file, req, res, next); + return; + } + + if (!wasFallbackModified(req.headers['if-modified-since'])) { + res.writeHead(304); + res.end(); + return; + } + + const user = await Users.findOneById>(requestUserId, { projection: { username: 1, name: 1 } }); + if (!user?.username) { + res.writeHead(404); + res.end(); + return; + } + + // Use real name for SVG letters + if (settings.get('UI_Use_Name_Avatar') && user?.name) { + serveSvgAvatarInRequestedFormat({ nameOrUsername: user.name, req, res, useAllInitials: true }); + return; + } + + serveSvgAvatarInRequestedFormat({ nameOrUsername: user.username, req, res }); +}; diff --git a/apps/meteor/server/routes/avatar/utils.spec.ts b/apps/meteor/server/routes/avatar/utils.spec.ts new file mode 100644 index 0000000..9dbb104 --- /dev/null +++ b/apps/meteor/server/routes/avatar/utils.spec.ts @@ -0,0 +1,252 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +class CookiesMock { + public get = (_key: any, value: any) => value; +} + +const mocks = { + settingsGet: sinon.stub(), + findOneByIdAndLoginToken: sinon.stub(), + fileUploadGet: sinon.spy(), +}; + +const { + getAvatarSizeFromRequest, + MIN_SVG_AVATAR_SIZE, + MAX_SVG_AVATAR_SIZE, + renderSVGLetters, + setCacheAndDispositionHeaders, + userCanAccessAvatar, + wasFallbackModified, + serveSvgAvatarInRequestedFormat, + serveAvatarFile, +} = proxyquire.noCallThru().load('./utils', { + 'meteor/ostrio:cookies': { + Cookies: CookiesMock, + }, + '@rocket.chat/models': { + Users: { + findOneByIdAndLoginToken: mocks.findOneByIdAndLoginToken, + }, + }, + '../../lib/media/file-upload': { + FileUpload: { + get: mocks.fileUploadGet, + }, + }, + '../../settings': { + settings: { + get: mocks.settingsGet, + }, + }, + 'sharp': () => ({ toFormat: (format: any) => ({ pipe: (res: any) => res.write(format) }) }), +}); + +describe('#serveAvatarFile()', () => { + const file = { uploadedAt: new Date(0), type: 'image/png', size: 100 }; + const response = { + setHeader: sinon.spy(), + writeHead: sinon.spy(), + end: sinon.spy(), + }; + const next = sinon.spy(); + + afterEach(() => { + response.setHeader.resetHistory(); + response.writeHead.resetHistory(); + response.end.resetHistory(); + next.resetHistory(); + }); + + it(`should return code 304 if avatar was not modified`, () => { + serveAvatarFile(file, { headers: { 'if-modified-since': new Date(0).toUTCString() } }, response, next); + expect(response.setHeader.getCall(0).calledWith('Content-Security-Policy', "default-src 'none'")).to.be.true; + expect(response.setHeader.getCall(1).calledWith('Last-Modified', new Date(0).toUTCString())).to.be.true; + expect(response.writeHead.calledWith(304)).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + + it('should serve avatar', () => { + const request = { headers: { 'if-modified-since': new Date(200000).toUTCString() } }; + serveAvatarFile(file, request, response, next); + expect(response.setHeader.getCall(0).calledWith('Content-Security-Policy', "default-src 'none'")).to.be.true; + expect(response.setHeader.getCall(1).calledWith('Last-Modified', new Date(0).toUTCString())).to.be.true; + expect(response.setHeader.getCall(2).calledWith('Content-Type', file.type)).to.be.true; + expect(response.setHeader.getCall(3).calledWith('Content-Length', file.size)).to.be.true; + expect(mocks.fileUploadGet.calledWith(file, request, response, next)).to.be.true; + }); +}); + +describe('#serveSvgAvatarInRequestedFormat()', () => { + it('should serve SVG avatar in requested format', () => { + ['png', 'jpg', 'jpeg'].forEach((format) => { + const response = { + setHeader: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + serveSvgAvatarInRequestedFormat({ req: { query: { format, size: 100 } }, res: response, nameOrUsername: 'name' }); + + expect(response.setHeader.getCall(0).calledWith('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT')).to.be.true; + expect(response.setHeader.getCall(1).calledWith('Content-Type', `image/${format}`)).to.be.true; + expect(response.write.calledWith(format)).to.be.true; + }); + }); + it(`should serve avatar in SVG format if requested format is not png, jpg or jpeg`, () => { + const response = { + setHeader: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + serveSvgAvatarInRequestedFormat({ req: { query: { format: 'anythingElse', size: 100 } }, res: response, nameOrUsername: 'name' }); + + expect(response.setHeader.getCall(0).calledWith('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT')).to.be.true; + expect(response.setHeader.getCall(1).calledWith('Content-Type', 'image/svg+xml')).to.be.true; + expect(response.write.called).to.be.true; + expect(response.end.calledOnce).to.be.true; + }); + it(`should default size to 200 if not provided in query`, () => { + const response = { + setHeader: sinon.spy(), + write: sinon.spy(), + end: sinon.spy(), + }; + serveSvgAvatarInRequestedFormat({ req: { query: {} }, res: response, nameOrUsername: 'name' }); + + expect(response.setHeader.getCall(0).calledWith('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT')).to.be.true; + expect(response.setHeader.getCall(1).calledWith('Content-Type', 'image/svg+xml')).to.be.true; + expect(response.write.called).to.be.true; + expect(response.write.getCall(0).args[0]).to.include('viewBox="0 0 200 200"'); + expect(response.end.calledOnce).to.be.true; + }); +}); + +describe('#getAvatarSizeFromRequest()', () => { + it('should return undefined if size is not provided', () => { + expect(getAvatarSizeFromRequest({ query: {} })).to.equal(undefined); + }); + it('should return value passed in the request if it falls in the range limit', () => { + expect(getAvatarSizeFromRequest({ query: { size: 100 } })).to.equal(100); + }); + it(`should return ${MIN_SVG_AVATAR_SIZE} if requested size is smaller than the limit`, () => { + expect(getAvatarSizeFromRequest({ query: { size: 2 } })).to.equal(16); + }); + it(`should return ${MAX_SVG_AVATAR_SIZE} if requested size is bigger than the limit`, () => { + expect(getAvatarSizeFromRequest({ query: { size: 2000 } })).to.equal(1024); + }); +}); + +describe('#wasFallbackModified()', () => { + it('should return true if reqModifiedHeader is different from FALLBACK_LAST_MODIFIED', () => { + expect(wasFallbackModified('')).to.equal(true); + }); + it('should false if reqModifiedHeader is the same as FALLBACK_LAST_MODIFIED', () => { + expect(wasFallbackModified('Thu, 01 Jan 2015 00:00:00 GMT')).to.equal(false); + }); +}); + +describe('#renderSvgLetters', () => { + it('should show capitalized initial letter in the svg', () => { + expect(renderSVGLetters('arthur', 16)).to.include('>\nA\n
'); + expect(renderSVGLetters('Bob', 16)).to.include('>\nB\n'); + expect(renderSVGLetters('yan', 16)).to.include('>\nY\n'); + expect(renderSVGLetters('山田 太郎', 16)).to.include('>\n山\n'); + }); + it('should render question mark with color #000', () => { + expect(renderSVGLetters('?', 16)).to.include('>\n?\n').and.to.include('fill="#000"'); + }); + it('should include size in viewBox property', () => { + expect(renderSVGLetters('arthur', 16)).to.include('viewBox="0 0 16 16"'); + expect(renderSVGLetters('Bob', 32)).to.include('viewBox="0 0 32 32"'); + expect(renderSVGLetters('yan', 64)).to.include('viewBox="0 0 64 64"'); + }); + it('should return a default size of 125 for a single letter', () => { + expect(renderSVGLetters('a', 200)).to.include('font-size="125"'); + }); + it('should render a single letter when useAllInitials is false', () => { + expect(renderSVGLetters('arthur', 16, false)).to.include('>\nA\n'); + }); + it('should render a single letter when useAllInitials is true but username has no spaces', () => { + expect(renderSVGLetters('arthur', 16, true)).to.include('>\nA\n'); + }); + it('should render more than one letter when useAllInitials is true', () => { + expect(renderSVGLetters('arthur void', 16, true)).to.include('>\nAV\n'); + expect(renderSVGLetters('arthur void jackson', 16, true)).to.include('>\nAVJ\n'); + }); + it('should cap generated avatar to 3 letters at most', () => { + expect(renderSVGLetters('arthur void jackson billie', 16, true)).to.include('>\nAVJ\n'); + expect(renderSVGLetters('arthur void jackson billie jean', 16, true)).to.include('>\nAVJ\n'); + }); + it('should decrease the font size when username has more than 1 word', () => { + expect(renderSVGLetters('arthur void', 200, true)).to.include('font-size="100"'); + }); + it('should decrease the font size when username has 3 words', () => { + expect(renderSVGLetters('this is three_words', 200, true)).to.include('font-size="80"'); + }); +}); + +describe('#setCacheAndDispositionHeaders', () => { + it('should set the Cache-Control header based on the query cacheTime', () => { + const request = { + query: { + cacheTime: 100, + }, + }; + + const response = { + setHeader: sinon.spy(), + }; + setCacheAndDispositionHeaders(request as any, response as any); + expect(response.setHeader.calledWith('Cache-Control', 'public, max-age=100')).to.be.true; + expect(response.setHeader.calledWith('Content-Disposition', 'inline')).to.be.true; + }); + + it('should set the Cache-Control header based on the setting if the query cacheTime is not set', () => { + const request = { + query: {}, + }; + + const response = { + setHeader: sinon.spy(), + }; + + mocks.settingsGet.returns(100); + setCacheAndDispositionHeaders(request as any, response as any); + expect(response.setHeader.calledWith('Cache-Control', 'public, max-age=100')).to.be.true; + expect(response.setHeader.calledWith('Content-Disposition', 'inline')).to.be.true; + }); +}); + +describe('#userCanAccessAvatar()', async () => { + beforeEach(() => { + mocks.findOneByIdAndLoginToken.reset(); + mocks.settingsGet.reset(); + }); + it('should return true if setting is set to not block avatars', async () => { + await expect(userCanAccessAvatar({})).to.eventually.equal(true); + }); + it('should return true if user is authenticated', async () => { + mocks.settingsGet.returns(true); + mocks.findOneByIdAndLoginToken.returns({ _id: 'id' }); + + await expect(userCanAccessAvatar({ query: { rc_token: 'token', rc_uid: 'id' } })).to.eventually.equal(true); + await expect(userCanAccessAvatar({ headers: { cookie: 'rc_token=token; rc_uid=id' } })).to.eventually.equal(true); + }); + it('should return false and warn if user is unauthenticated', async () => { + console.warn = sinon.spy(); + mocks.findOneByIdAndLoginToken.returns(undefined); + mocks.settingsGet.returns(true); + + await expect(userCanAccessAvatar({})).to.eventually.equal(false); + expect((console.warn as sinon.SinonSpy).calledWith(sinon.match('unauthenticated'))).to.be.true; + + await expect(userCanAccessAvatar({ headers: { cookie: 'rc_token=token; rc_uid=id' } })).to.eventually.equal(false); + expect((console.warn as sinon.SinonSpy).calledWith(sinon.match('unauthenticated'))).to.be.true; + + await expect(userCanAccessAvatar({ query: { rc_token: 'token', rc_uid: 'id' } })).to.eventually.equal(false); + expect((console.warn as sinon.SinonSpy).calledWith(sinon.match('unauthenticated'))).to.be.true; + }); +}); diff --git a/apps/meteor/server/routes/avatar/utils.ts b/apps/meteor/server/routes/avatar/utils.ts new file mode 100644 index 0000000..d4509e4 --- /dev/null +++ b/apps/meteor/server/routes/avatar/utils.ts @@ -0,0 +1,157 @@ +import type { ServerResponse } from 'node:http'; + +import { hashLoginToken } from '@rocket.chat/account-utils'; +import type { IIncomingMessage, IUpload } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import type { NextFunction } from 'connect'; +import { Cookies } from 'meteor/ostrio:cookies'; +import sanitizeHtml from 'sanitize-html'; +import sharp from 'sharp'; +import { throttle } from 'underscore'; + +import { FileUpload } from '../../lib/media/file-upload'; +import { getAvatarColor } from '../../lib/utils/lib/getAvatarColor'; +import { settings } from '../../settings'; + +const FALLBACK_LAST_MODIFIED = 'Thu, 01 Jan 2015 00:00:00 GMT'; + +const cookie = new Cookies(); + +export const MAX_SVG_AVATAR_SIZE = 1024; +export const MIN_SVG_AVATAR_SIZE = 16; +const MAX_SVG_AVATAR_INITIALS = 3; + +export const serveAvatarFile = (file: IUpload, req: IIncomingMessage, res: ServerResponse, next: NextFunction) => { + res.setHeader('Content-Security-Policy', "default-src 'none'"); + const reqModifiedHeader = req.headers['if-modified-since']; + + if (reqModifiedHeader && reqModifiedHeader === file.uploadedAt?.toUTCString()) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); + return; + } + + if (file.uploadedAt) { + res.setHeader('Last-Modified', file.uploadedAt?.toUTCString()); + } + + if (file.type) { + res.setHeader('Content-Type', file.type); + } + + if (file.size) { + res.setHeader('Content-Length', file.size); + } + + return FileUpload.get(file, req, res, next); +}; + +export const getAvatarSizeFromRequest = (req: IIncomingMessage) => { + const requestSize = req.query.size && parseInt(req.query.size); + if (!requestSize) { + return; + } + return Math.min(Math.max(requestSize, MIN_SVG_AVATAR_SIZE), MAX_SVG_AVATAR_SIZE); +}; +export const serveSvgAvatarInRequestedFormat = ({ + nameOrUsername, + req, + res, + useAllInitials = false, +}: { + nameOrUsername: string; + req: IIncomingMessage; + res: ServerResponse; + useAllInitials?: boolean; +}) => { + const size = getAvatarSizeFromRequest(req); + const avatar = renderSVGLetters(nameOrUsername, size, useAllInitials); + res.setHeader('Last-Modified', FALLBACK_LAST_MODIFIED); + + const { format } = req.query; + + if (['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${format}`); + sharp(Buffer.from(avatar)).toFormat(format).pipe(res); + return; + } + res.setHeader('Content-Type', 'image/svg+xml'); + + res.write(avatar); + res.end(); +}; + +export const wasFallbackModified = (reqModifiedHeader?: string) => { + if (!reqModifiedHeader || reqModifiedHeader !== FALLBACK_LAST_MODIFIED) { + return true; + } + return false; +}; + +async function isUserAuthenticated({ headers, query }: Pick) { + let { rc_uid, rc_token } = query; + + if (!rc_uid && headers.cookie) { + rc_uid = cookie.get('rc_uid', headers.cookie); + rc_token = cookie.get('rc_token', headers.cookie); + } + + if (rc_uid == null || rc_token == null) { + return false; + } + + const userFound = await Users.findOneByIdAndLoginToken(rc_uid, hashLoginToken(rc_token), { projection: { _id: 1 } }); // TODO memoize find + + return !!userFound; +} + +const warnUnauthenticatedAccess = throttle(() => { + console.warn('The server detected an unauthenticated access to an user avatar. This type of request will soon be blocked by default.'); +}, 60000 * 30); // 30 minutes + +export async function userCanAccessAvatar({ headers = {}, query = {} }: IIncomingMessage) { + if (!settings.get('Accounts_AvatarBlockUnauthenticatedAccess')) { + return true; + } + + const isAuthenticated = await isUserAuthenticated({ headers, query }); + if (!isAuthenticated) { + warnUnauthenticatedAccess(); + } + + return isAuthenticated; +} + +const getFirstLetter = (name: string) => { + const sanitizedName = sanitizeHtml(name); + return sanitizedName.substring(0, 1).toUpperCase(); +}; + +const getInitials = (name: string) => name.split(' ').slice(0, MAX_SVG_AVATAR_INITIALS).map(getFirstLetter).join(''); + +export const renderSVGLetters = (name: string, viewSize = 200, useAllInitials = false) => { + let color = ''; + let initials = ''; + + if (name === '?') { + color = '#000'; + initials = name; + } else { + color = getAvatarColor(name); + initials = !useAllInitials ? getFirstLetter(name) : getInitials(name); + } + + const reductionFactor = initials.length > 1 ? Math.pow(initials.length, 2) / 10 : 0; + const fontSize = viewSize / (1.6 + reductionFactor); + + return `\n\n\n${initials}\n\n`; +}; + +const getCacheTime = (cacheTime: number) => cacheTime || settings.get('Accounts_AvatarCacheTime'); + +export function setCacheAndDispositionHeaders(req: IIncomingMessage, res: ServerResponse) { + const cacheTime = getCacheTime(req.query.cacheTime); + res.setHeader('Cache-Control', `public, max-age=${cacheTime}`); + res.setHeader('Content-Disposition', 'inline'); +} diff --git a/apps/meteor/server/routes/fileDecrypt.ts b/apps/meteor/server/routes/fileDecrypt.ts new file mode 100644 index 0000000..005c020 --- /dev/null +++ b/apps/meteor/server/routes/fileDecrypt.ts @@ -0,0 +1,7 @@ +import { WebApp } from 'meteor/webapp'; + +WebApp.connectHandlers.use('/file-decrypt/', (_, res) => { + // returns 404 + res.writeHead(404); + res.end('Not found'); +}); diff --git a/apps/meteor/server/routes/health.ts b/apps/meteor/server/routes/health.ts new file mode 100644 index 0000000..ad29c6a --- /dev/null +++ b/apps/meteor/server/routes/health.ts @@ -0,0 +1,151 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { monitorEventLoopDelay } from 'node:perf_hooks'; +import { getHeapStatistics } from 'node:v8'; + +import { MongoInternals } from 'meteor/mongo'; +import { WebApp } from 'meteor/webapp'; + +import { SystemLogger } from '../lib/logger/system'; + +const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); + +export function pingMongo() { + return mongo.db.command({ ping: 1 }); +} + +function setDefaultHeaders(res: ServerResponse) { + // Set headers to prevent any caching of the health check response. + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Content-Type', 'application/json'); +} + +/** + * DEPRECATED Liveness Probe (`/health`) + * Maintained for backward compatibility. Behaves like a simple liveness probe. + * @deprecated Update infrastructure to use /livez and /readyz. + */ +WebApp.rawHandlers.use('/health', (_req: IncomingMessage, res: ServerResponse) => { + setDefaultHeaders(res); + + res.writeHead(200); + res.end(JSON.stringify({ status: 'ok' })); +}); + +/** + * Readiness Probe Configuration + * Defines tunable thresholds for readiness checks. + */ + +const eventLoopHistogramLiveness = monitorEventLoopDelay(); +eventLoopHistogramLiveness.enable(); + +const eventLoopHistogramReadiness = monitorEventLoopDelay(); +eventLoopHistogramReadiness.enable(); + +const READINESS_THRESHOLDS = { + EVENT_LOOP_LAG_MS: Number.parseFloat(process.env.EVENT_LOOP_LAG_MS ?? '') || 70, + HEAP_USAGE_PERCENT: Number.parseFloat(process.env.HEAP_USAGE_PERCENT ?? '') || 0.85, // 85% +} as const; + +/** + * Checks event loop lag using the provided histogram. Can optionally reset the histogram after reading. + * @param histogram - The event loop histogram to use. + * @returns The status and p99 lag. + */ +function checkEventLoopLag(histogram: ReturnType) { + const lagInNs = histogram.percentile(99); + const lagInMs = lagInNs / 1_000_000; + + histogram.reset(); + + const lagValue = Number.parseFloat(lagInMs.toFixed(2)); + + if (lagInMs > READINESS_THRESHOLDS.EVENT_LOOP_LAG_MS) { + return { status: 'degraded', lagMs: lagValue }; + } + return { status: 'ok', lagMs: lagValue }; +} + +/** + * Checks heap memory usage against a configured threshold. + * @returns The status and memory usage percentage. + */ +function checkMemoryUsage() { + const { heap_size_limit, used_heap_size } = getHeapStatistics(); + const usageRatio = used_heap_size / heap_size_limit; + const usageValue = Number.parseFloat((usageRatio * 100).toFixed(2)); + + if (usageRatio > READINESS_THRESHOLDS.HEAP_USAGE_PERCENT) { + return { status: 'degraded', percentile: usageValue }; + } + return { status: 'ok', percentile: usageValue }; +} + +async function checkMongo() { + try { + await pingMongo(); + return { status: 'ok' }; + } catch (err) { + return { status: 'degraded', error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Performs the core health checks (memory, event loop) and returns the result. + * @param shouldResetHistogram - Controls whether the event loop histogram is reset. + */ +async function performHealthChecks(histogram: ReturnType) { + const checks = { + memory: checkMemoryUsage(), + eventLoop: checkEventLoopLag(histogram), + mongo: await checkMongo(), + }; + + const isHealthy = checks.memory.status === 'ok' && checks.eventLoop.status === 'ok' && checks.mongo.status === 'ok'; + const statusCode = isHealthy ? 200 : 503; + + const body = { + status: isHealthy ? 'ok' : 'unavailable', + checks, + }; + + return { statusCode, body, isHealthy }; +} + +/** + * Liveness Probe (`/livez`) + * Performs a non-destructive health check. A failure here indicates a pod is + * unrecoverable and should be restarted. + */ +WebApp.rawHandlers.use('/livez', async (_req: IncomingMessage, res: ServerResponse) => { + const { statusCode, body, isHealthy } = await performHealthChecks(eventLoopHistogramLiveness); + + if (!isHealthy) { + SystemLogger.error({ msg: 'Liveness check failed', details: body }); + } + + setDefaultHeaders(res); + + res.writeHead(statusCode); + res.end(JSON.stringify(body)); +}); + +/** + * Readiness Probe Endpoint (`/readyz`) + * Performs a destructive health check, resetting the histogram for the next interval. + * A failure tells the orchestrator to stop sending traffic to this instance. + */ +WebApp.rawHandlers.use('/readyz', async (_req: IncomingMessage, res: ServerResponse) => { + const { statusCode, body, isHealthy } = await performHealthChecks(eventLoopHistogramReadiness); + + if (!isHealthy) { + SystemLogger.warn({ msg: 'Readiness check failed', details: body }); + } + + setDefaultHeaders(res); + + res.writeHead(statusCode); + res.end(JSON.stringify(body)); +}); diff --git a/apps/meteor/server/routes/i18n.ts b/apps/meteor/server/routes/i18n.ts new file mode 100644 index 0000000..eb941b3 --- /dev/null +++ b/apps/meteor/server/routes/i18n.ts @@ -0,0 +1,37 @@ +import type { ServerResponse } from 'node:http'; + +import type { IncomingMessage } from 'connect'; +import { WebApp } from 'meteor/webapp'; +import { match } from 'path-to-regexp'; + +const matchRoute = match<{ lng: string }>('/:lng.json', { decode: decodeURIComponent }); + +const i18nHandler = async function (req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? '/', `https://${req.headers.host}`); + const match = matchRoute(url.pathname); + + if (match === false) { + res.writeHead(400); + res.end(); + return; + } + + const { lng } = match.params; + + try { + const data = await Assets.getTextAsync(`i18n/${lng}.i18n.json`); + if (!data) { + throw new Error(); + } + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Length', data.length); + res.writeHead(200); + res.end(data); + } catch (e) { + res.writeHead(400); + res.end(); + } +}; + +WebApp.connectHandlers.use('/i18n/', i18nHandler); diff --git a/apps/meteor/server/routes/index.ts b/apps/meteor/server/routes/index.ts new file mode 100644 index 0000000..4abc6c6 --- /dev/null +++ b/apps/meteor/server/routes/index.ts @@ -0,0 +1,6 @@ +import './avatar'; +import './health'; +import './i18n'; +import './timesync'; +import './fileDecrypt'; +import './userDataDownload'; diff --git a/apps/meteor/server/routes/timesync.ts b/apps/meteor/server/routes/timesync.ts new file mode 100644 index 0000000..f4718e2 --- /dev/null +++ b/apps/meteor/server/routes/timesync.ts @@ -0,0 +1,18 @@ +import { WebApp } from 'meteor/webapp'; +// Use rawConnectHandlers so we get a response as quickly as possible +// https://github.com/meteor/meteor/blob/devel/packages/webapp/webapp_server.js + +const syncUrl = `${(global as any)?.ROOT_URL_PATH_PREFIX || ''}/_timesync`; + +WebApp.rawConnectHandlers.use(syncUrl, (_req, res) => { + // Never ever cache this, otherwise weird times are shown on reload + // http://stackoverflow.com/q/18811286/586086 + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', 0); + + // Avoid MIME type warnings in browsers + res.setHeader('Content-Type', 'text/plain'); + + res.end(Date.now().toString()); +}); diff --git a/apps/meteor/server/routes/userDataDownload.ts b/apps/meteor/server/routes/userDataDownload.ts new file mode 100644 index 0000000..cc4c02d --- /dev/null +++ b/apps/meteor/server/routes/userDataDownload.ts @@ -0,0 +1,88 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { hashLoginToken } from '@rocket.chat/account-utils'; +import type { IIncomingMessage, IUser, IUserDataFile } from '@rocket.chat/core-typings'; +import { UserDataFiles, Users } from '@rocket.chat/models'; +import { Cookies } from 'meteor/ostrio:cookies'; +import { WebApp } from 'meteor/webapp'; +import { match } from 'path-to-regexp'; + +import { FileUpload } from '../lib/media/file-upload'; +import { settings } from '../settings'; + +const cookies = new Cookies(); + +const matchUID = async (uid: string | undefined, token: string | undefined, ownerUID: string) => { + return ( + uid && + token && + uid === ownerUID && + Boolean(await Users.findOneByIdAndLoginToken(uid, hashLoginToken(token), { projection: { _id: 1 } })) + ); +}; + +const isRequestFromOwner = async (req: IIncomingMessage, ownerUID: IUser['_id']) => { + if (await matchUID(req.query.rc_uid, req.query.rc_token, ownerUID)) { + return true; + } + + if ( + req.headers.cookie && + (await matchUID(cookies.get('rc_uid', req.headers.cookie), cookies.get('rc_token', req.headers.cookie), ownerUID)) + ) { + return true; + } + + for await (const uid of [req.headers['x-user-id']].flat()) { + for await (const token of [req.headers['x-auth-token']].flat()) { + if (await matchUID(uid, token, ownerUID)) { + return true; + } + } + } + + return false; +}; + +const sendUserDataFile = (file: IUserDataFile) => (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const userDataStore = FileUpload.getStore('UserDataFiles'); + if (!userDataStore?.get) { + res.writeHead(403).end(); // @todo: maybe we should return a better error? + return; + } + + res.setHeader('Content-Security-Policy', "default-src 'none'"); + res.setHeader('Cache-Control', 'max-age=31536000'); + void userDataStore.get(file, req, res, next); +}; + +const matchFileRoute = match<{ fileID: string }>('/:fileID', { decode: decodeURIComponent }); + +const userDataDownloadHandler = async (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const downloadEnabled = settings.get('UserData_EnableDownload'); + if (!downloadEnabled) { + res.writeHead(403).end(); + return; + } + + const match = matchFileRoute(req.url ?? '/'); + if (match === false) { + res.writeHead(404).end(); + return; + } + + const file = await UserDataFiles.findOneById(match.params.fileID); + if (!file) { + res.writeHead(404).end(); + return; + } + + if (!file.userId || !(await isRequestFromOwner(req as IIncomingMessage, file.userId))) { + res.writeHead(403).end(); + return; + } + + sendUserDataFile(file)(req, res, next); +}; + +WebApp.connectHandlers.use('/data-export/', userDataDownloadHandler); diff --git a/apps/meteor/server/services/ai-search/service.ts b/apps/meteor/server/services/ai-search/service.ts new file mode 100644 index 0000000..67cb473 --- /dev/null +++ b/apps/meteor/server/services/ai-search/service.ts @@ -0,0 +1,434 @@ +import { + AI_LICENSE_MODULE, + AI_SEARCH_PAGE_SIZE, + buildIntelligentSearchPipelineFilters, + generateOpenAICompatibleSearchAnswer, + listOpenAICompatibleModels, + MAX_SEARCH_ANSWER_MESSAGES, + MAX_SEARCH_ANSWER_TEXT_LENGTH, + MAX_SEARCH_FILTER_VALUES, + normalizeIntelligentSearchCandidates, + searchIntelligentPipeline, + type IntelligentSearchFilters, + type IntelligentSearchPipelineConfig, + type OpenAICompatibleProviderConfig, + type SearchAnswerMessage, +} from '@rocket.chat/ai-search'; +import type { + AISearchAnswerMessage, + AISearchAnswerResult, + AISearchFilters, + AISearchModelOption, + AISearchResult, + AISearchStatus, + IAISearchService, +} from '@rocket.chat/core-services'; +import { License, ServiceClass } from '@rocket.chat/core-services'; +import { isBannedSubscription, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { serverFetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch'; + +import { settings } from '../../settings'; + +const logger = new Logger('AISearchService'); + +const fetchWithSsrfValidation = (url: string, options: Omit) => + serverFetch(url, { + ...options, + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }); + +const asString = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return ''; +}; + +const toDate = (value?: string | Date): Date | undefined => { + if (!value) { + return undefined; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +const limitFilterValues = (values: string[] | undefined): string[] | undefined => { + if (!values) { + return undefined; + } + + const limitedValues: string[] = []; + for (const value of values) { + if (value) { + limitedValues.push(value); + if (limitedValues.length === MAX_SEARCH_FILTER_VALUES) { + break; + } + } + } + return limitedValues; +}; + +const normalizeFilters = (filters: AISearchFilters = {}): IntelligentSearchFilters => ({ + rid: filters.rid, + rids: limitFilterValues(filters.rids), + roomNames: limitFilterValues(filters.roomNames), + fromUsername: filters.fromUsername, + fromUsernames: limitFilterValues(filters.fromUsernames), + startDate: toDate(filters.startDate), + endDate: toDate(filters.endDate), +}); + +type OpenAICompatibleProviderSettings = Pick & { + model?: string; +}; + +export class AISearchService extends ServiceClass implements IAISearchService { + protected name = 'ai-search'; + + private getPipelineConfig(): IntelligentSearchPipelineConfig | undefined { + const baseUrl = settings.get('AI_Intelligent_Search_Pipeline_Base_URL'); + const pipelineId = settings.get('AI_Intelligent_Search_Pipeline_ID'); + const apiKey = settings.get('AI_Intelligent_Search_API_Key'); + const apiKeySecret = settings.get('AI_Intelligent_Search_API_Key_Secret'); + const queryTemplate = settings.get('AI_Intelligent_Search_Query_Template'); + const minimumSimilarityPercent = settings.get('AI_Intelligent_Search_Min_Similarity_Percent'); + + const normalizedBaseUrl = asString(baseUrl); + const normalizedPipelineId = asString(pipelineId); + const normalizedApiKey = asString(apiKey); + const normalizedApiKeySecret = asString(apiKeySecret); + + if (!normalizedBaseUrl || !normalizedPipelineId || !normalizedApiKey || !normalizedApiKeySecret) { + return undefined; + } + + return { + baseUrl: normalizedBaseUrl, + pipelineId: normalizedPipelineId, + apiKey: normalizedApiKey, + apiKeySecret: normalizedApiKeySecret, + queryTemplate: asString(queryTemplate), + minimumSimilarityPercent: Number(minimumSimilarityPercent || 0), + }; + } + + private getAnswerProviderSettings(): OpenAICompatibleProviderSettings | undefined { + const baseUrl = settings.get('AI_LLM_OpenAI_Base_URL'); + const apiKey = settings.get('AI_LLM_OpenAI_API_Key'); + const model = settings.get('AI_LLM_OpenAI_Model'); + + const normalizedBaseUrl = asString(baseUrl); + const normalizedApiKey = asString(apiKey); + const normalizedModel = asString(model); + + if (!normalizedBaseUrl || !normalizedApiKey) { + return undefined; + } + + return { + baseUrl: normalizedBaseUrl, + apiKey: normalizedApiKey, + model: normalizedModel, + }; + } + + private getAnswerProviderConfig(): OpenAICompatibleProviderConfig | undefined { + const provider = this.getAnswerProviderSettings(); + if (!provider?.model) { + return undefined; + } + + return { + name: 'OpenAI compatible', + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + model: provider.model, + }; + } + + async status(): Promise { + const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE); + const intelligentSearchEnabled = settings.get('AI_Intelligent_Search_Enabled'); + const answerGenerationEnabled = settings.get('AI_Intelligent_Search_Answer_Enabled'); + const pipelineConfig = this.getPipelineConfig(); + const answerProviderConfig = this.getAnswerProviderConfig(); + + return { + hasIntelligentSearchLicense, + intelligentSearchEnabled: intelligentSearchEnabled === true, + intelligentSearchConfigured: Boolean(pipelineConfig), + answerGenerationConfigured: answerGenerationEnabled === true && Boolean(answerProviderConfig) && Boolean(pipelineConfig), + }; + } + + private async getRoomMap(roomIds: string[]): Promise>> { + if (!roomIds.length) { + return new Map(); + } + + const rooms = await Rooms.findByIds([...new Set(roomIds)], { + projection: { _id: 1, t: 1, name: 1, fname: 1 }, + }).toArray(); + + return new Map(rooms.map((room) => [room._id, room])); + } + + private async getUserSubscribedRoomIdsForPipeline(userId: string): Promise { + const roomIds = await Subscriptions.findByUserId(userId, { + projection: { rid: 1, status: 1 }, + }) + .map((subscription) => (isBannedSubscription(subscription) ? undefined : subscription.rid)) + .toArray(); + + return roomIds.filter((rid): rid is string => Boolean(rid)); + } + + private async getSubscribedRoomIdSet(userId: string, roomIds: string[]): Promise> { + const uniqueRoomIdSet = new Set(); + for (const roomId of roomIds) { + if (roomId) { + uniqueRoomIdSet.add(roomId); + } + } + const uniqueRoomIds = [...uniqueRoomIdSet]; + if (!uniqueRoomIds.length) { + return new Set(); + } + + const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, { + projection: { rid: 1, status: 1 }, + }).toArray(); + + const subscribedRoomIds = new Set(); + for (const subscription of subscriptions) { + if (!isBannedSubscription(subscription)) { + subscribedRoomIds.add(subscription.rid); + } + } + return subscribedRoomIds; + } + + private async normalizeIntelligentResults( + rawSearchResults: unknown, + userId: string, + limit = AI_SEARCH_PAGE_SIZE, + ): Promise { + const candidates = normalizeIntelligentSearchCandidates(rawSearchResults, [], limit, logger); + const msgIdSet = new Set(); + for (const { msgId } of candidates) { + if (msgId) { + msgIdSet.add(msgId); + } + } + const msgIds = [...msgIdSet]; + let messageMap = new Map>(); + + if (msgIds.length > 0) { + messageMap = new Map( + await Messages.findVisibleByIds(msgIds, { + projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 }, + }) + .map((message) => [String(message._id), message] as const) + .toArray(), + ); + logger.debug({ msg: 'AI search messages fetched from DB', requested: msgIds.length, found: messageMap.size }); + } + + const resultRoomIds = Array.from(messageMap.values(), ({ rid }) => rid); + const [rooms, subscribedRoomIds] = await Promise.all([ + this.getRoomMap(resultRoomIds), + this.getSubscribedRoomIdSet(userId, resultRoomIds), + ]); + + const normalizedResults: AISearchResult[] = []; + for (const result of candidates) { + // candidates without a visible database message could surface stale pipeline text + const dbMessage = result.msgId ? messageMap.get(result.msgId) : undefined; + if (!dbMessage) { + logger.debug({ msg: 'AI search result filtered: message not visible', msgId: result.msgId }); + continue; + } + + const { rid } = dbMessage; + if (!rid || !subscribedRoomIds.has(rid)) { + continue; + } + + const room = rooms.get(rid); + normalizedResults.push({ + _id: result.msgId || result._id, + rid, + msgId: result.msgId, + text: dbMessage.msg || '', + ts: dbMessage.ts?.toISOString(), + u: dbMessage.u ? { username: dbMessage.u.username, name: dbMessage.u.name } : undefined, + ...(Number.isFinite(result.score) && { score: result.score }), + ...(room && { room }), + }); + if (normalizedResults.length === limit) { + break; + } + } + + return normalizedResults; + } + + private async getUserClassifications(userId: string): Promise { + const user = await Users.findOneById>(userId, { projection: { roles: 1 } }); + return Array.from(new Set(['user', ...(user?.roles || [])])); + } + + private async getSubscribedRoomIds(userId: string, roomIds: string[]): Promise { + return [...(await this.getSubscribedRoomIdSet(userId, roomIds))]; + } + + private async getSubscribedRoomIdsByName(userId: string, roomNames: string[] = []): Promise { + if (!roomNames.length) { + return []; + } + + const rooms = await Promise.all( + Array.from(new Set(roomNames)).map((roomName) => Rooms.findOneByNameOrFname(roomName, { projection: { _id: 1 } })), + ); + + const roomIds = rooms.reduce((roomIds, room) => { + if (typeof room?._id === 'string') { + roomIds.push(room._id); + } + + return roomIds; + }, []); + + return this.getSubscribedRoomIds(userId, roomIds); + } + + async search({ + query, + userId, + filters: rawFilters, + limit = AI_SEARCH_PAGE_SIZE, + }: { + query: string; + userId: string; + filters?: AISearchFilters; + limit?: number; + }): Promise { + const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE); + const intelligentSearchEnabled = settings.get('AI_Intelligent_Search_Enabled'); + const config = this.getPipelineConfig(); + + if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || !config) { + logger.debug({ + msg: 'AI search skipped: unavailable', + hasIntelligentSearchLicense, + intelligentSearchEnabled: intelligentSearchEnabled === true, + intelligentSearchConfigured: Boolean(config), + }); + return []; + } + + const filters = normalizeFilters(rawFilters); + const requestedRoomIds = [...new Set([...(filters.rids || []), ...(filters.rid ? [filters.rid] : [])])]; + const roomNameIds = await this.getSubscribedRoomIdsByName(userId, filters.roomNames); + const scopedRoomIds = [...new Set([...requestedRoomIds, ...roomNameIds])]; + + // a room filter that resolves to no subscribed rooms must not fall through to the unscoped path + if (filters.roomNames?.length && !scopedRoomIds.length) { + logger.debug({ msg: 'AI search skipped: room name filters did not resolve to subscribed rooms' }); + return []; + } + const [pipelineRoomIds, classifications] = await Promise.all([ + scopedRoomIds.length ? this.getSubscribedRoomIds(userId, scopedRoomIds) : this.getUserSubscribedRoomIdsForPipeline(userId), + this.getUserClassifications(userId), + ]); + + if (!pipelineRoomIds.length) { + logger.debug({ msg: 'AI search skipped: user has no room subscriptions' }); + return []; + } + + const pipelineFilters = buildIntelligentSearchPipelineFilters(pipelineRoomIds, { + ...filters, + rids: [...(filters.rids || []), ...roomNameIds], + }); + + if (!pipelineFilters) { + logger.debug({ msg: 'AI search skipped: no subscribed rooms for filters', rid: filters.rid }); + return []; + } + + const json = await searchIntelligentPipeline({ + query, + config, + classifications, + pipelineFilters, + limit, + fetch: fetchWithSsrfValidation, + logger, + }); + + return this.normalizeIntelligentResults(json, userId, limit); + } + + async answer({ query, messages }: { query: string; messages: AISearchAnswerMessage[] }): Promise { + const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE); + const intelligentSearchEnabled = settings.get('AI_Intelligent_Search_Enabled'); + const answerGenerationEnabled = settings.get('AI_Intelligent_Search_Answer_Enabled'); + const pipelineConfig = this.getPipelineConfig(); + const provider = this.getAnswerProviderConfig(); + const systemPromptSetting = settings.get('AI_Intelligent_Search_Answer_System_Prompt'); + + if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || answerGenerationEnabled !== true || !pipelineConfig) { + throw new Error('error-ai-not-enabled'); + } + + if (!provider) { + throw new Error('error-ai-provider-not-configured'); + } + + const systemPrompt = asString(systemPromptSetting); + + const sanitizedMessages: SearchAnswerMessage[] = messages.map(({ text, username, roomName, ts, score }) => ({ + text, + username, + roomName, + ts, + score, + })); + + return generateOpenAICompatibleSearchAnswer({ + query, + messages: sanitizedMessages, + provider, + systemPrompt, + fetch: fetchWithSsrfValidation, + logger, + maxMessages: MAX_SEARCH_ANSWER_MESSAGES, + maxTextLength: MAX_SEARCH_ANSWER_TEXT_LENGTH, + }); + } + + async models(): Promise { + if (!(await License.hasModule(AI_LICENSE_MODULE))) { + return []; + } + + const provider = this.getAnswerProviderSettings(); + const selectedModel = settings.get('AI_LLM_OpenAI_Model'); + + return listOpenAICompatibleModels({ + provider, + selectedModel: asString(selectedModel), + fetch: fetchWithSsrfValidation, + logger, + }); + } +} diff --git a/apps/meteor/server/services/analytics/service.ts b/apps/meteor/server/services/analytics/service.ts new file mode 100644 index 0000000..de454fb --- /dev/null +++ b/apps/meteor/server/services/analytics/service.ts @@ -0,0 +1,22 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IAnalyticsService } from '@rocket.chat/core-services'; +import type { IAnalyticsSeatRequest } from '@rocket.chat/core-typings'; +import { Analytics } from '@rocket.chat/models'; + +export class AnalyticsService extends ServiceClassInternal implements IAnalyticsService { + protected name = 'analytics'; + + async saveSeatRequest(): Promise { + await Analytics.update({ type: 'seat-request' }, { $inc: { count: 1 } }, { upsert: true }); + } + + async getSeatRequestCount(): Promise { + const result = (await Analytics.findOne({ type: 'seat-request' }, {})) as IAnalyticsSeatRequest | null; + + return result?.count ? result.count : 0; + } + + async resetSeatRequestCount(): Promise { + await Analytics.update({ type: 'seat-request' }, { $set: { count: 0 } }, { upsert: true }); + } +} diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/server/services/apps-engine/service.ts new file mode 100644 index 0000000..0378593 --- /dev/null +++ b/apps/meteor/server/services/apps-engine/service.ts @@ -0,0 +1,250 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import type { IGetAppsFilter } from '@rocket.chat/apps/dist/server/IGetAppsFilter'; +import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem'; +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import type { AppStatusReport, IAppsEngineService } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import { InstanceStatus } from '@rocket.chat/instance-status'; + +import { isRunningMs } from '../../lib/isRunningMs'; +import { SystemLogger } from '../../lib/logger/system'; + +export class AppsEngineNoNodesFoundError extends Error { + constructor(message = 'Not enough Apps-Engine nodes in deployment') { + super(message); + } +} + +export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService { + protected name = 'apps-engine'; + + constructor() { + super(); + + this.onEvent('presence.status', async ({ user, previousStatus }): Promise => { + await Apps.self?.triggerEvent(AppEvents.IPostUserStatusChanged, { + user, + currentStatus: user.status, + previousStatus, + }); + }); + + this.onEvent('apps.added', async (appId: string): Promise => { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.added" event received for app', + appId, + }); + // if the app already exists in this instance, don't load it again + const app = Apps.self?.getManager()?.getOneById(appId); + + if (app) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.added" event received for app, but it already exists in this instance', + appId, + }); + return; + } + + await Apps.self?.getManager()?.addLocal(appId); + }); + + this.onEvent('apps.removed', async (appId: string): Promise => { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.removed" event received for app', + appId, + }); + const app = Apps.self?.getManager()?.getOneById(appId); + if (!app) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.removed" event received for app, but it could not be found in this instance', + appId, + }); + return; + } + + await Apps.self?.getManager()?.removeLocal(appId); + }); + + this.onEvent('apps.updated', async (appId: string, originInstanceId?: string): Promise => { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.updated" event received for app', + appId, + }); + + if (originInstanceId && originInstanceId === InstanceStatus.id()) { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.updated" event ignored: originated from this instance', + appId, + }); + return; + } + + const storageItem = await Apps.self?.getStorage()?.retrieveOne(appId); + if (!storageItem) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.updated" event received for app, but it could not be found in the storage', + appId, + }); + return; + } + + const appPackage = await Apps.self?.getAppSourceStorage()?.fetch(storageItem); + if (!appPackage) { + return; + } + + const isEnabled = AppStatusUtils.isEnabled(storageItem.status); + if (isEnabled) { + await Apps.self?.getManager()?.updateAndStartupLocal(storageItem, appPackage); + } else { + await Apps.self?.getManager()?.updateAndInitializeLocal(storageItem, appPackage); + } + }); + + this.onEvent('apps.statusUpdate', async (appId: string, status: AppStatus): Promise => { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.statusUpdate" event received for app with status', + appId, + status, + }); + const app = Apps.self?.getManager()?.getOneById(appId); + if (!app) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.statusUpdate" event received for app, but it could not be found in this instance', + appId, + status, + }); + return; + } + + if ((await app.getStatus()) === status) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.statusUpdate" event received for app, but the status is the same', + appId, + status, + }); + return; + } + + if (AppStatusUtils.isEnabled(status)) { + await Apps.self?.getManager()?.enable(appId).catch(SystemLogger.error); + } else if (AppStatusUtils.isDisabled(status)) { + await Apps.self?.getManager()?.disable(appId, status, true).catch(SystemLogger.error); + } + }); + + this.onEvent('apps.settingUpdated', async (appId: string, setting): Promise => { + Apps.self?.getRocketChatLogger().debug({ + msg: '"apps.settingUpdated" event received for app', + appId, + setting, + }); + const app = Apps.self?.getManager()?.getOneById(appId); + const oldSetting = app?.getStorageItem().settings[setting.id].value; + + // avoid updating the setting if the value is the same, + // which caused an infinite loop + // and sometimes the settings can be an array + // so we need to convert it to JSON stringified to compare it + + if (JSON.stringify(oldSetting) === JSON.stringify(setting.value)) { + Apps.self?.getRocketChatLogger().info({ + msg: '"apps.settingUpdated" event received for app, but the setting value is the same', + appId, + settingId: setting.id, + }); + return; + } + + await Apps.self + ?.getManager() + ?.getSettingsManager() + .updateAppSetting(appId, setting as any); + }); + } + + isInitialized(): boolean { + return Boolean(Apps.self?.isInitialized()); + } + + async getApps(query: IGetAppsFilter): Promise { + return (await Apps.self?.getManager()?.get(query))?.map((app) => app.getInfo()); + } + + async getAppStorageItemById(appId: string): Promise { + const app = Apps.self?.getManager()?.getOneById(appId); + + if (!app) { + return; + } + + return app.getStorageItem(); + } + + async getAppsStatusLocal(): Promise<{ status: AppStatus; appId: string }[]> { + const apps = await Apps.self?.getManager()?.get(); + + if (!apps) { + return []; + } + + return Promise.all( + apps.map(async (app) => ({ + status: await app.getStatus(), + appId: app.getID(), + })), + ); + } + + async getAppsStatusInNodes(): Promise { + if (!isRunningMs()) { + throw new Error('Getting apps status in cluster is only available in microservices mode'); + } + + if (!this.api) { + throw new Error('AppsEngineService is not initialized'); + } + + // If we are running MS AND this.api is defined, we KNOW there is a local node + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + const { id: localNodeId } = (await this.api.nodeList()).find((node) => node.local)!; + + const services: { name: string; nodes: string[] }[] = await this.api?.call('$node.services', { onlyActive: true }); + + // We can filter out the local node because we already know its status + const availableNodes = services?.find((service) => service.name === 'apps-engine')?.nodes; + + // Subtract 1 for the local node + if (!availableNodes || availableNodes.length - 1 < 1) { + throw new AppsEngineNoNodesFoundError(); + } + + const statusByApp: AppStatusReport = {}; + + const apps: Promise[] = availableNodes.map(async (nodeID) => { + const appsStatus: Awaited> | undefined = await this.api?.call( + 'apps-engine.getAppsStatusLocal', + [], + { nodeID }, + ); + + if (!appsStatus) { + throw new Error(`Failed to get apps status from node ${nodeID}`); + } + + appsStatus.forEach(({ status, appId }) => { + if (!statusByApp[appId]) { + statusByApp[appId] = []; + } + + statusByApp[appId].push({ instanceId: nodeID, isLocal: nodeID === localNodeId, status }); + }); + }); + + await Promise.all(apps); + + return statusByApp; + } +} diff --git a/apps/meteor/server/services/authorization/canAccessRoom.ts b/apps/meteor/server/services/authorization/canAccessRoom.ts new file mode 100644 index 0000000..8a95a24 --- /dev/null +++ b/apps/meteor/server/services/authorization/canAccessRoom.ts @@ -0,0 +1,139 @@ +import { Authorization, License, Abac, Settings } from '@rocket.chat/core-services'; +import type { RoomAccessValidator } from '@rocket.chat/core-services'; +import { TeamType, AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; +import type { IUser, ITeam, IRoom } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms, TeamMember, Team, Users } from '@rocket.chat/models'; + +import { canAccessRoomLivechat } from './canAccessRoomLivechat'; + +async function canAccessPublicRoom(user?: IUser): Promise { + if (!user?._id) { + const anon = await Settings.get('Accounts_AllowAnonymousRead'); + return !!anon; + } + + return Authorization.hasPermission(user, 'view-c-room'); +} + +type RoomAccessValidatorConverted = ( + room?: Pick, + user?: IUser, + extraData?: Record, +) => Promise; + +const roomAccessValidators: RoomAccessValidatorConverted[] = [ + async function _validateAccessToPublicRoomsInTeams(room, user): Promise { + if (!room) { + return false; + } + if (!room._id || !room.teamId || room.t !== 'c') { + // if the room doesn't belongs to a team || is not a public channel - skip + return false; + } + + // if user is banned from this room, deny access + if (user?._id && (await Subscriptions.findOneBannedSubscription(room._id, user._id))) { + return false; + } + + // if team is public, access is allowed if the user can access public rooms + const team = await Team.findOneById>(room.teamId, { + projection: { type: 1 }, + }); + if (team?.type === TeamType.PUBLIC) { + return canAccessPublicRoom(user); + } + + // otherwise access is allowed only to members of the team + const membership = + user?._id && + (await TeamMember.findOneByUserIdAndTeamId(user._id, room.teamId, { + projection: { _id: 1 }, + })); + return !!membership; + }, + + async function _validateAccessToPublicRooms(room, user): Promise { + if (!room?._id || room.t !== 'c' || room?.teamId) { + return false; + } + + // if user is banned from this room, deny access + if (user?._id && (await Subscriptions.findOneBannedSubscription(room._id, user._id))) { + return false; + } + + return canAccessPublicRoom(user); + }, + + async function _validateIfAlreadyJoined(room, user, extraData): Promise { + if (!room?._id || !user?._id) { + return false; + } + + const [canViewJoined, canViewT] = await Promise.all([ + Authorization.hasPermission(user, 'view-joined-room'), + Authorization.hasPermission(user, `view-${room.t}-room`), + ]); + + // When there's no ABAC setting, license or values on the room, fallback to previous behavior + if (!room?.abacAttributes?.length || !(await License.hasModule('abac')) || !(await Settings.get('ABAC_Enabled'))) { + const includeInvitations = extraData?.includeInvitations ?? false; + if (!(await Subscriptions.countByRoomIdAndUserId(room._id, user._id, includeInvitations))) { + return false; + } + + return canViewJoined || canViewT; + } + + return (canViewJoined || canViewT) && Abac.canAccessObject(room, user, AbacAccessOperation.READ, AbacObjectType.ROOM); + }, + + async function _validateAccessToDiscussionsParentRoom(room, user): Promise { + if (!room?.prid) { + return false; + } + + const parentRoom = await Rooms.findOneById(room.prid); + if (!parentRoom) { + return false; + } + + return Authorization.canAccessRoom(parentRoom, user); + }, + + canAccessRoomLivechat, +]; + +export const isPartialUser = (user: IUser | Pick | undefined): user is Pick => { + return Boolean(user && Object.keys(user).length === 1 && user._id); +}; + +export const canAccessRoom: RoomAccessValidator = async (room, user, extraData): Promise => { + // TODO livechat can send both as null, so they we need to validate nevertheless + // if (!room || !user) { + // return false; + // } + + // TODO: remove this after migrations + // if user only contains _id, convert it to a full IUser object + + if (isPartialUser(user)) { + user = (await Users.findOneById(user._id)) || undefined; + if (!user) { + throw new Error('User not found'); + } + + if (process.env.NODE_ENV === 'development') { + console.log('User converted to full IUser object'); + } + } + + for await (const roomAccessValidator of roomAccessValidators) { + if (await roomAccessValidator(room, user as IUser, extraData)) { + return true; + } + } + + return false; +}; diff --git a/apps/meteor/server/services/authorization/canAccessRoomLivechat.ts b/apps/meteor/server/services/authorization/canAccessRoomLivechat.ts new file mode 100644 index 0000000..146ae3a --- /dev/null +++ b/apps/meteor/server/services/authorization/canAccessRoomLivechat.ts @@ -0,0 +1,31 @@ +import type { IAuthorizationLivechat, RoomAccessValidator } from '@rocket.chat/core-services'; +import { proxify } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Rooms } from '@rocket.chat/models'; + +const AuthorizationLivechat = proxify('authorization-livechat'); + +export const canAccessRoomLivechat: RoomAccessValidator = async (room, user, extraData): Promise => { + // If we received a partial room and its type is not `l` or `v`, skip all checks. + if (room && room.t !== 'l') { + return false; + } + + // if we received a partial room, load the full IOmnichannelRoom data for it + // Otherwise, try to load the data from the extraData (this is the case for file uploads) + const rid = room?._id || extraData?.rid; + const livechatRoom = rid && (await Rooms.findOneById(rid)); + + // if the roomId is invalid skip checks and fail + if (!livechatRoom) { + return false; + } + + // Check the type again in case the room parameter was not received + if (livechatRoom.t !== 'l') { + return false; + } + + // Call back core temporarily + return AuthorizationLivechat.canAccessRoom(livechatRoom as IOmnichannelRoom, user, extraData); +}; diff --git a/apps/meteor/server/services/authorization/canReadRoom.ts b/apps/meteor/server/services/authorization/canReadRoom.ts new file mode 100644 index 0000000..0d0cfee --- /dev/null +++ b/apps/meteor/server/services/authorization/canReadRoom.ts @@ -0,0 +1,25 @@ +import type { RoomAccessValidator } from '@rocket.chat/core-services'; +import { Authorization } from '@rocket.chat/core-services'; +import { Subscriptions, Users } from '@rocket.chat/models'; + +import { canAccessRoom, isPartialUser } from './canAccessRoom'; + +export const canReadRoom: RoomAccessValidator = async (...args) => { + const [room, user] = args; + const userArgument = isPartialUser(user) ? await Users.findOneById(user._id) : user; + + if (!(await canAccessRoom(...args))) { + return false; + } + + if ( + userArgument && + room?.t === 'c' && + !(await Authorization.hasPermission(userArgument, 'preview-c-room')) && + !(await Subscriptions.findOneByRoomIdAndUserId(room?._id, userArgument._id, { projection: { _id: 1 } })) + ) { + return false; + } + + return true; +}; diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts new file mode 100644 index 0000000..a5f92a6 --- /dev/null +++ b/apps/meteor/server/services/authorization/service.ts @@ -0,0 +1,244 @@ +import type { IAuthorization, RoomAccessValidator, UserWithRoles } from '@rocket.chat/core-services'; +import { License, ServiceClass } from '@rocket.chat/core-services'; +import type { IUser, IRole, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Subscriptions, Rooms, Users, Roles, Permissions } from '@rocket.chat/models'; +import mem from 'mem'; + +import { canAccessRoom } from './canAccessRoom'; +import { canReadRoom } from './canReadRoom'; +import { AuthorizationUtils } from '../../../app/authorization/lib/AuthorizationUtils'; + +import './canAccessRoomLivechat'; + +// Register as class +export class Authorization extends ServiceClass implements IAuthorization { + protected name = 'authorization'; + + private getRolesCached = mem(this.getRoles.bind(this), { + maxAge: 1000, + cacheKey: (args: unknown[]) => { + const [user, scope] = args as [string | UserWithRoles, IRoom['_id']?]; + return typeof user === 'string' ? `${user}/${scope ?? ''}` : `${user._id}/${scope ?? ''}/${JSON.stringify(user.roles ?? [])}`; + }, + }); + + private rolesHasPermissionCached = mem(this.rolesHasPermission.bind(this), { + cacheKey: JSON.stringify, + ...((process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') && { maxAge: 1 }), + }); + + constructor() { + super(); + + const clearCache = (): void => { + mem.clear(this.getRolesCached); + mem.clear(this.rolesHasPermissionCached); + }; + + this.onEvent('watch.roles', clearCache); + this.onEvent('permission.changed', clearCache); + this.onEvent('authorization.guestPermissions', (permissions: string[]) => { + AuthorizationUtils.addRolePermissionWhiteList('guest', permissions); + }); + } + + override async started(): Promise { + try { + if (!(await License.hasValidLicense())) { + return; + } + + const permissions = await License.getGuestPermissions(); + if (!permissions) { + return; + } + + AuthorizationUtils.addRolePermissionWhiteList('guest', permissions); + } catch (error) { + console.error('Authorization Service did not start correctly', error); + } + } + + async hasAllPermission(userId: string | UserWithRoles, permissions: string[], scope?: string): Promise { + if (!userId) { + return false; + } + return this.all(userId, permissions, scope); + } + + async hasPermission(userId: string | UserWithRoles, permissionId: string, scope?: string): Promise { + if (!userId) { + return false; + } + return this.all(userId, [permissionId], scope); + } + + async hasAtLeastOnePermission(userId: string | UserWithRoles, permissions: string[], scope?: string): Promise { + if (!userId) { + return false; + } + return this.atLeastOne(userId, permissions, scope); + } + + async canAccessRoom(...args: Parameters): Promise { + return canAccessRoom(...args); + } + + async canReadRoom(...args: Parameters): Promise { + return canReadRoom(...args); + } + + async canAccessRoomId(rid: IRoom['_id'], user: IUser['_id']): Promise { + const room = await Rooms.findOneById>(rid, { + projection: { + _id: 1, + t: 1, + teamId: 1, + prid: 1, + abacAttributes: 1, + }, + }); + + if (!room) { + return false; + } + + return this.canAccessRoom(room, { _id: user }); + } + + async canAccessRoomIds(rids: IRoom['_id'][], user: UserWithRoles): Promise { + const uniqueRids = [...new Set(rids)]; + + if (!uniqueRids.length) { + return false; + } + + if (!user) { + return false; + } + + const rooms = await Rooms.findByIds>(uniqueRids, { + projection: { + _id: 1, + t: 1, + teamId: 1, + prid: 1, + abacAttributes: 1, + }, + }).toArray(); + + if (rooms.length !== uniqueRids.length) { + return false; + } + + const allowed = await Promise.all(rooms.map((room) => this.canAccessRoom(room, user))); + + return allowed.every(Boolean); + } + + async getUsersFromPublicRoles(): Promise< + { + _id: string; + username: string; + roles: string[]; + }[] + > { + const roleIds = await this.getPublicRoles(); + + return this.getUserFromRoles(roleIds); + } + + private getPublicRoles = mem( + async (): Promise => { + const roles = Roles.find>({ scope: 'Users', description: { $exists: true, $ne: '' } }, { projection: { _id: 1 } }); + + return roles.map(({ _id }) => _id).toArray(); + }, + { maxAge: 10000 }, + ); + + private getUserFromRoles = mem( + async (roleIds: string[]) => { + const users = Users.findUsersInRoles, '_id' | 'username' | 'roles'>>(roleIds, null, { + sort: { + username: 1, + }, + projection: { + _id: 1, + username: 1, + roles: 1, + }, + }); + + return users + .map((user) => ({ + ...user, + roles: user.roles.filter((roleId: string) => roleIds.includes(roleId)), + })) + .toArray(); + }, + { maxAge: 10000 }, + ); + + private async rolesHasPermission(permission: string, roles: IRole['_id'][]): Promise { + if (AuthorizationUtils.isPermissionRestrictedForRoleList(permission, roles)) { + return false; + } + + const result = await Permissions.findOne({ _id: permission, roles: { $in: roles } }, { projection: { _id: 1 } }); + return !!result; + } + + private async getRoles(user: string | UserWithRoles, scope?: IRoom['_id']): Promise { + const { roles: userRoles = [] } = typeof user === 'string' ? (await Users.findOneById(user, { projection: { roles: 1 } })) || {} : user; + const { roles: subscriptionsRoles = [] } = + (scope && + (await Subscriptions.findOne>( + { 'rid': scope, 'u._id': typeof user === 'string' ? user : user._id }, + { projection: { roles: 1 } }, + ))) || + {}; + return [...userRoles, ...subscriptionsRoles].sort((a, b) => a.localeCompare(b)); + } + + private async resolveRoles(user: string | UserWithRoles, scope?: IRoom['_id']): Promise { + if (typeof user !== 'string' && !scope) { + return [...(user.roles ?? [])].sort((a, b) => a.localeCompare(b)); + } + return this.getRolesCached(user, scope); + } + + private async atLeastOne(user: string | UserWithRoles, permissions: string[] = [], scope?: string): Promise { + const sortedRoles = await this.resolveRoles(user, scope); + for await (const permission of permissions) { + if (await this.rolesHasPermissionCached(permission, sortedRoles)) { + return true; + } + } + + return false; + } + + private async all(user: string | UserWithRoles, permissions: string[] = [], scope?: string): Promise { + const sortedRoles = await this.resolveRoles(user, scope); + for await (const permission of permissions) { + if (!(await this.rolesHasPermissionCached(permission, sortedRoles))) { + return false; + } + } + + return true; + } + + async hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise { + if (!Array.isArray(roleIds)) { + throw new Error('error-invalid-arguments'); + } + + if (!userId) { + return false; + } + + return Roles.isUserInRoles(userId, roleIds, scope); + } +} diff --git a/apps/meteor/server/services/banner/service.ts b/apps/meteor/server/services/banner/service.ts new file mode 100644 index 0000000..4c829ff --- /dev/null +++ b/apps/meteor/server/services/banner/service.ts @@ -0,0 +1,189 @@ +import { randomUUID } from 'node:crypto'; + +import { api, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IBannerService } from '@rocket.chat/core-services'; +import type { BannerPlatform, IBanner, IBannerDismiss, Optional, IUser } from '@rocket.chat/core-typings'; +import { Banners, BannersDismiss, Users } from '@rocket.chat/models'; + +import { notifyOnUserChange } from '../../lib/notifyListener'; + +export class BannerService extends ServiceClassInternal implements IBannerService { + protected name = 'banner'; + + async getById(bannerId: string): Promise { + return Banners.findOneById(bannerId); + } + + async discardDismissal(bannerId: string): Promise { + const result = await Banners.findOneById(bannerId); + + if (!result) { + return false; + } + + const { _id, ...banner } = result; + + const snapshot = await this.create({ ...banner, snapshot: _id, active: false }); // create a snapshot + + await BannersDismiss.updateMany({ bannerId }, { $set: { bannerId: snapshot._id } }); + return true; + } + + async create(doc: Optional): Promise { + const bannerId = doc._id || randomUUID(); + + doc.view.appId = doc.view.appId ?? 'banner-core'; + doc.view.viewId = bannerId; + + await Banners.createOrUpdate({ + ...doc, + _id: bannerId, + }); + + const banner = await Banners.findOneById(bannerId); + if (!banner) { + throw new Error('error-creating-banner'); + } + + void this.sendToUsers(banner); + + return banner; + } + + async getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise { + const user = await Users.findOneById>(userId, { + projection: { roles: 1 }, + }); + + const { roles } = user || { roles: [] }; + + const banners = await Banners.findActiveByRoleOrId(roles, platform, bannerId).toArray(); + + const bannerIds = banners.map(({ _id }) => _id); + + const result = await BannersDismiss.findByUserIdAndBannerId>(userId, bannerIds, { + projection: { bannerId: 1, _id: 0 }, + }).toArray(); + + const dismissed = new Set(result.map(({ bannerId }) => bannerId)); + + return banners + .filter((banner) => !dismissed.has(banner._id)) + .map((banner) => ({ + ...banner, + view: { + ...banner.view, + // All modern banners should have a viewId, but we have old banners that were created without it + // such as the seatsTaken banner. In this case, we use the bannerId as the viewId + viewId: banner.view.viewId || banner._id, + }, + // add surface to legacy banners + surface: !banner.surface ? 'banner' : banner.surface, + })); + } + + async dismiss(userId: string, bannerId: string): Promise { + if (!userId || !bannerId) { + throw new Error('Invalid params'); + } + + const banner = await Banners.findOneById(bannerId); + if (!banner) { + const { matchedCount } = await Users.setBannerReadById(userId, bannerId); + + if (!matchedCount) { + throw new Error('Banner not found'); + } + + void notifyOnUserChange({ + id: userId, + clientAction: 'updated', + diff: { + [`banners.${bannerId}.read`]: true, + }, + }); + + return true; + } + + const user = await Users.findOneById>(userId, { + projection: { username: 1 }, + }); + if (!user) { + throw new Error('User not found'); + } + + const dismissedBy = { + _id: user._id, + username: user.username, + }; + + const today = new Date(); + + const doc = { + userId, + bannerId, + dismissedBy, + dismissedAt: today, + _updatedAt: today, + }; + + await BannersDismiss.insertOne(doc); + + return true; + } + + async disable(bannerId: string): Promise { + const result = await Banners.disable(bannerId); + + if (result) { + void api.broadcast('banner.disabled', bannerId); + return true; + } + return false; + } + + async enable(bannerId: string, doc: Partial> = {}): Promise { + const result = await Banners.findOneById(bannerId); + + if (!result) { + return false; + } + + const { _id, ...banner } = result; + + const newBanner = { ...banner, ...doc, active: true }; + + await Banners.updateOne({ _id }, { $set: newBanner }); // reenable the banner + + void this.sendToUsers({ _id, ...newBanner }); + + return true; + } + + async sendToUsers(banner: IBanner): Promise { + if (!banner.active) { + return false; + } + + // no roles set, so it should be sent to all users + if (!banner.roles?.length) { + void api.broadcast('banner.enabled', banner._id); + return true; + } + + const total = await Users.countActiveUsersInRoles(banner.roles); + + // if more than 100 users should receive the banner, send it to all users + if (total > 100) { + void api.broadcast('banner.enabled', banner._id); + return true; + } + + await Users.findActiveUsersInRoles(banner.roles, { projection: { _id: 1 } }).forEach((user) => { + void api.broadcast('banner.user', user._id, banner); + }); + + return true; + } +} diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts new file mode 100644 index 0000000..b1a274a --- /dev/null +++ b/apps/meteor/server/services/calendar/service.ts @@ -0,0 +1,390 @@ +import type { ICalendarService } from '@rocket.chat/core-services'; +import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services'; +import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { cronJobs } from '@rocket.chat/cron'; +import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { CalendarEvent, Users } from '@rocket.chat/models'; +import type { UpdateResult, DeleteResult } from 'mongodb'; + +import { getShiftedTime } from './utils/getShiftedTime'; +import { i18n } from '../../lib/i18n'; +import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; +import { settings } from '../../settings'; + +const logger = new Logger('Calendar'); + +const defaultMinutesForNotifications = 5; + +export class CalendarService extends ServiceClassInternal implements ICalendarService { + protected name = 'calendar'; + + public async create(data: Omit, 'reminderTime' | 'notificationSent'>): Promise { + const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, meetingUrl, busy } = data; + const minutes = reminderMinutesBeforeStart ?? defaultMinutesForNotifications; + const reminderTime = minutes ? getShiftedTime(startTime, -minutes) : undefined; + + const insertData: InsertionModel = { + uid, + startTime, + ...(endTime && { endTime }), + subject, + description, + meetingUrl, + reminderMinutesBeforeStart: minutes, + reminderTime, + notificationSent: false, + ...(busy !== undefined && { busy }), + }; + + const insertResult = await CalendarEvent.insertOne(insertData); + await this.setupNextNotification(); + if (busy !== false) { + await this.setupNextStatusChange(); + await this.reconcileInProgressEvent(insertResult.insertedId); + } + + return insertResult.insertedId; + } + + public async import(data: Omit, 'notificationSent'>): Promise { + const { externalId } = data; + if (!externalId) { + return this.create(data); + } + + const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; + const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description); + const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; + + const updateData: Omit, 'uid' | 'notificationSent'> = { + startTime, + ...(endTime && { endTime }), + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + reminderTime, + externalId, + ...(busy !== undefined && { busy }), + }; + + const event = await this.findImportedEvent(externalId, uid); + + if (!event) { + const insertResult = await CalendarEvent.insertOne({ + uid, + notificationSent: false, + ...updateData, + }); + + await this.setupNextNotification(); + if (busy !== false) { + await this.setupNextStatusChange(); + await this.reconcileInProgressEvent(insertResult.insertedId); + } + + return insertResult.insertedId; + } + + const updateResult = await CalendarEvent.updateEvent(event._id, updateData); + if (updateResult.modifiedCount > 0) { + await this.setupNextNotification(); + if (busy !== false) { + await this.setupNextStatusChange(); + await this.reconcileInProgressEvent(event._id); + } + } + + return event._id; + } + + public async get(eventId: ICalendarEvent['_id']): Promise { + return CalendarEvent.findOne({ _id: eventId }); + } + + public async list(uid: IUser['_id'], date: Date): Promise { + return CalendarEvent.findByUserIdAndDate(uid, date).toArray(); + } + + public async update(eventId: ICalendarEvent['_id'], data: Partial): Promise { + const event = await this.get(eventId); + if (!event) { + return null; + } + + const { startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; + + const meetingUrl = await this.getMeetingUrl(data); + const reminderTime = reminderMinutesBeforeStart && startTime ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; + + const updateData: Partial = { + startTime, + ...(endTime && { endTime }), + subject, + description, + meetingUrl, + reminderMinutesBeforeStart, + reminderTime, + ...(busy !== undefined && { busy }), + }; + + const updateResult = await CalendarEvent.updateEvent(eventId, updateData); + + if (updateResult.modifiedCount > 0) { + await this.setupNextNotification(); + + if (startTime || endTime) { + const isBusy = busy !== undefined ? busy : event.busy !== false; + if (isBusy) { + await this.setupNextStatusChange(); + await this.reconcileInProgressEvent(eventId); + } + } + } + + return updateResult; + } + + public async delete(eventId: ICalendarEvent['_id']): Promise { + const event = await this.get(eventId); + const now = new Date(); + const wasInProgress = Boolean(event && event.busy !== false && event.startTime <= now && (!event.endTime || event.endTime > now)); + + const result = await CalendarEvent.deleteOne({ + _id: eventId, + }); + + if (result.deletedCount > 0) { + await this.setupNextStatusChange(); + + // deleting an in-progress busy event: recompute the claim from the events still active + if (event && wasInProgress) { + await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now }); + } + } + + return result; + } + + public async setupNextNotification(): Promise { + return this.doSetupNextNotification(false); + } + + public async setupNextStatusChange(): Promise { + return this.doSetupNextStatusChange(); + } + + private async getMeetingUrl(eventData: Partial): Promise { + if (eventData.meetingUrl !== undefined) { + return eventData.meetingUrl || undefined; + } + + if (eventData.description !== undefined) { + return this.parseDescriptionForMeetingUrl(eventData.description); + } + + return undefined; + } + + private async doSetupNextNotification(isRecursive: boolean): Promise { + const date = await CalendarEvent.findNextNotificationDate(); + if (!date) { + if (await cronJobs.has('calendar-reminders')) { + await cronJobs.remove('calendar-reminders'); + } + return; + } + + date.setSeconds(0); + if (!isRecursive && date.valueOf() < Date.now()) { + return this.sendCurrentNotifications(date); + } + + await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date)); + } + + private async doSetupNextStatusChange(): Promise { + // Schedules only event starts; event end is handled by the presence engine's expiration cron. + + const schedulerJobId = 'calendar-status-scheduler'; + + const busyStatusEnabled = settings.get('Calendar_BusyStatus_Enabled'); + if (!busyStatusEnabled) { + if (await cronJobs.has(schedulerJobId)) { + await cronJobs.remove(schedulerJobId); + } + return; + } + + if (await cronJobs.has(schedulerJobId)) { + await cronJobs.remove(schedulerJobId); + } + + const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date()); + if (!nextStartEvent) { + return; + } + + await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime()); + } + + private async processStatusChangesAtTime(): Promise { + const processTime = new Date(); + + try { + const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray(); + for await (const event of eventsStartingNow) { + await this.processEventStart(event); + } + } catch (err) { + logger.error({ msg: 'Failed to process calendar status changes', err }); + } + + await this.doSetupNextStatusChange(); + } + + private async processEventStart(event: Pick): Promise { + // no endTime → no expiry to set, so the claim could never auto-clear + if (!event.endTime) { + return; + } + + await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime }); + } + + // The start scheduler only fires at start times, so it misses an event imported already in progress. + private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise { + const event = await CalendarEvent.findOne({ _id: eventId }); + if (!event?.endTime || event.busy === false) { + return; + } + + const now = new Date(); + if (event.startTime > now || event.endTime <= now) { + return; + } + + await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime, now }); + } + + // Derives "busy until the latest active meeting ends" from the events in progress now and applies + // it as one calendar claim. `excludeEventId`/`seedEndTime` re-add the triggering event's own end. + private async syncBusyPresence( + uid: IUser['_id'], + { excludeEventId, seedEndTime, now = new Date() }: { excludeEventId?: ICalendarEvent['_id']; seedEndTime?: Date; now?: Date } = {}, + ): Promise { + if (!settings.get('Calendar_BusyStatus_Enabled')) { + return; + } + + const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray(); + const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter( + (date): date is Date => Boolean(date), + ); + + // No busy event left → end the calendar claim (no-op if a higher-priority status took over). + if (endTimes.length === 0) { + await Presence.endActiveState(uid, this.name); + return; + } + + const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest)); + const user = await Users.findOneById>(uid, { projection: { language: 1 } }); + const lng = user?.language || settings.get('Language') || 'en'; + + await Presence.setActiveState(uid, { + statusDefault: UserStatus.BUSY, + statusText: i18n.t('Presence_status_outlook_in_a_meeting', { lng }), + statusSource: 'external', + statusExpiresAt, + statusId: this.name, + }); + } + + private async sendCurrentNotifications(date: Date): Promise { + const events = await CalendarEvent.findEventsToNotify(date, 1).toArray(); + for await (const event of events) { + await this.sendEventNotification(event); + await CalendarEvent.flagNotificationSent(event._id); + } + + await this.doSetupNextNotification(true); + } + + private async sendEventNotification(event: ICalendarEvent): Promise { + if (!(await getUserPreference(event.uid, 'notifyCalendarEvents'))) { + return; + } + + return api.broadcast('notify.calendar', event.uid, { + title: event.subject, + text: event.startTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric', dayPeriod: 'narrow' }), + payload: { + _id: event._id, + startTimeUtc: event.startTime.toISOString(), + }, + }); + } + + private async findImportedEvent( + externalId: Required['externalId'], + uid: ICalendarEvent['uid'], + ): Promise { + return CalendarEvent.findOneByExternalIdAndUserId(externalId, uid); + } + + private async parseDescriptionForMeetingUrl(description: string): Promise { + if (!description) { + return; + } + + const defaultPattern = '(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)'; + const pattern = (settings.get('Calendar_MeetingUrl_Regex') || defaultPattern).trim(); + + if (!pattern) { + return; + } + + const regex: RegExp | undefined = (() => { + try { + return new RegExp(pattern, 'im'); + } catch { + logger.error('Failed to parse regular expression for meeting url.'); + } + })(); + + if (!regex) { + return; + } + + const results = description.match(regex); + if (!results) { + return; + } + + const [, ...urls] = results; + for (const encodedUrl of urls) { + if (!encodedUrl) { + continue; + } + + let url = encodedUrl; + while (!url.includes('://')) { + const decodedUrl = decodeURIComponent(url); + if (decodedUrl === url) { + break; + } + + url = decodedUrl; + } + + if (url.includes('://')) { + return url; + } + } + + return undefined; + } +} diff --git a/apps/meteor/server/services/calendar/utils/getShiftedTime.ts b/apps/meteor/server/services/calendar/utils/getShiftedTime.ts new file mode 100644 index 0000000..aae5ccf --- /dev/null +++ b/apps/meteor/server/services/calendar/utils/getShiftedTime.ts @@ -0,0 +1,5 @@ +export function getShiftedTime(time: Date, minutes: number): Date { + const newTime = new Date(time.valueOf()); + newTime.setMinutes(newTime.getMinutes() + minutes); + return newTime; +} diff --git a/apps/meteor/server/services/call-history/service.ts b/apps/meteor/server/services/call-history/service.ts new file mode 100644 index 0000000..cd3f2d0 --- /dev/null +++ b/apps/meteor/server/services/call-history/service.ts @@ -0,0 +1,39 @@ +import { ServiceClassInternal, type ICallHistoryService } from '@rocket.chat/core-services'; +import type { IUser, CallHistoryItem } from '@rocket.chat/core-typings'; +import { CallHistory } from '@rocket.chat/models'; + +export class CallHistoryService extends ServiceClassInternal implements ICallHistoryService { + protected name = 'call-history'; + + public async search( + uid: IUser['_id'], + filters: { + searchTerm?: string; + direction?: CallHistoryItem['direction']; + inStates?: CallHistoryItem['state'][]; + }, + pagination: { + count: number; + offset: number; + sort?: Record; + }, + ): Promise<{ items: CallHistoryItem[]; total: number }> { + const { offset, count, sort } = pagination || {}; + + const { cursor, totalCount } = CallHistory.findAllByUserIdAndSearchFilters( + uid, + { ...filters }, + { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }, + ); + const [items, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + items, + total, + }; + } +} diff --git a/apps/meteor/server/services/device-management/events.ts b/apps/meteor/server/services/device-management/events.ts new file mode 100644 index 0000000..488b2e9 --- /dev/null +++ b/apps/meteor/server/services/device-management/events.ts @@ -0,0 +1,5 @@ +import { Emitter } from '@rocket.chat/emitter'; + +export const deviceManagementEvents = new Emitter<{ + 'device-login': { userId: string; userAgent: string; clientAddress: string }; +}>(); diff --git a/apps/meteor/server/services/device-management/service.ts b/apps/meteor/server/services/device-management/service.ts new file mode 100644 index 0000000..b3f1402 --- /dev/null +++ b/apps/meteor/server/services/device-management/service.ts @@ -0,0 +1,23 @@ +import type { IDeviceManagementService } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import { getHeader } from '@rocket.chat/tools'; + +import { deviceManagementEvents } from './events'; + +export class DeviceManagementService extends ServiceClassInternal implements IDeviceManagementService { + protected name = 'device-management'; + + constructor() { + super(); + + this.onEvent('accounts.login', async ({ userId, connection }) => { + if (!connection.loginToken) { + deviceManagementEvents.emit('device-login', { + userId, + userAgent: getHeader(connection.httpHeaders, 'user-agent'), + clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'), + }); + } + }); + } +} diff --git a/apps/meteor/server/services/federation/Settings.ts b/apps/meteor/server/services/federation/Settings.ts new file mode 100644 index 0000000..0d63f06 --- /dev/null +++ b/apps/meteor/server/services/federation/Settings.ts @@ -0,0 +1,156 @@ +import crypto from 'node:crypto'; + +import { settings, settingsRegistry } from '../../settings'; + +export const addMatrixBridgeFederationSettings = async (): Promise => { + await settingsRegistry.add('Federation_Matrix_enabled', false, { + readonly: true, + type: 'boolean', + i18nLabel: 'Federation_Matrix_enabled', + i18nDescription: 'Federation_Matrix_enabled_desc', + alert: 'Old_Federation_Alert', + public: true, + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_serve_well_known', true, { + readonly: true, + type: 'boolean', + i18nLabel: 'Federation_Matrix_serve_well_known', + alert: 'Federation_Matrix_serve_well_known_Alert', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_enable_ephemeral_events', false, { + readonly: true, + type: 'boolean', + i18nLabel: 'Federation_Matrix_enable_ephemeral_events', + i18nDescription: 'Federation_Matrix_enable_ephemeral_events_desc', + alert: 'Federation_Matrix_enable_ephemeral_events_Alert', + public: true, + group: 'Federation', + section: 'Matrix Bridge', + }); + + const uniqueId = settings.get('uniqueID') || crypto.randomUUID().slice(0, 15).replaceAll('-', '_'); + const homeserverToken = crypto.createHash('sha256').update(`hs_${uniqueId}`).digest('hex'); + const applicationServiceToken = crypto.createHash('sha256').update(`as_${uniqueId}`).digest('hex'); + + const siteUrl = settings.get('Site_Url'); + + await settingsRegistry.add('Federation_Matrix_id', `rocketchat_${uniqueId}`, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_id', + i18nDescription: 'Federation_Matrix_id_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_hs_token', homeserverToken, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_hs_token', + i18nDescription: 'Federation_Matrix_hs_token_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_as_token', applicationServiceToken, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_as_token', + i18nDescription: 'Federation_Matrix_as_token_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_homeserver_url', 'http://localhost:8008', { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_homeserver_url', + i18nDescription: 'Federation_Matrix_homeserver_url_desc', + alert: 'Federation_Matrix_homeserver_url_alert', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_homeserver_domain', siteUrl, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_homeserver_domain', + i18nDescription: 'Federation_Matrix_homeserver_domain_desc', + alert: 'Federation_Matrix_homeserver_domain_alert', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_bridge_url', 'http://localhost:3300', { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_bridge_url', + i18nDescription: 'Federation_Matrix_bridge_url_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_bridge_localpart', 'rocket.cat', { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_bridge_localpart', + i18nDescription: 'Federation_Matrix_bridge_localpart_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_registration_file', '', { + readonly: true, + type: 'code', + i18nLabel: 'Federation_Matrix_registration_file', + i18nDescription: 'Federation_Matrix_registration_file_desc', + alert: 'Federation_Matrix_registration_file_Alert', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_max_size_of_public_rooms_users', 100, { + readonly: true, + type: 'int', + i18nLabel: 'Federation_Matrix_max_size_of_public_rooms_users', + i18nDescription: 'Federation_Matrix_max_size_of_public_rooms_users_desc', + alert: 'Federation_Matrix_max_size_of_public_rooms_users_Alert', + modules: ['federation'], + public: true, + enterprise: true, + invalidValue: false, + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_configuration_status', 'Invalid', { + readonly: true, + hidden: true, + type: 'string', + i18nLabel: 'Federation_Matrix_configuration_status', + i18nDescription: 'Federation_Matrix_configuration_status_desc', + public: false, + enterprise: false, + invalidValue: '', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_check_configuration_button', 'checkFederationConfiguration', { + readonly: true, + hidden: true, + type: 'action', + actionText: 'Federation_Matrix_check_configuration', + public: false, + enterprise: false, + invalidValue: '', + group: 'Federation', + section: 'Matrix Bridge', + }); +}; diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Statistics.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Statistics.ts new file mode 100644 index 0000000..1852e4e --- /dev/null +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Statistics.ts @@ -0,0 +1,69 @@ +import type { IMatrixFederationStatistics } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; + +import { settings } from '../../../../../settings'; + +class RocketChatStatisticsAdapter { + async getBiggestRoomAvailable(): Promise<{ + _id: string; + name: string; + usersCount: number; + } | null> { + const room = await Rooms.findBiggestFederatedRoomInNumberOfUsers({ projection: { usersCount: 1, fname: 1, name: 1 } }); + if (!room) { + return null; + } + return { + _id: room._id, + name: room.fname || room.name || '', + usersCount: room.usersCount, + }; + } + + async getSmallestRoomAvailable(): Promise<{ + _id: string; + name: string; + usersCount: number; + } | null> { + const room = await Rooms.findSmallestFederatedRoomInNumberOfUsers({ projection: { usersCount: 1, fname: 1, name: 1 } }); + if (!room) { + return null; + } + return { + _id: room._id, + name: room.fname || room.name || '', + usersCount: room.usersCount, + }; + } + + async getAmountOfExternalUsers(): Promise { + return Users.countFederatedExternalUsers(); + } + + async getAmountOfExternalRooms(): Promise { + return Rooms.countFederatedRooms(); + } + + async getAmountOfConnectedExternalServers(): Promise<{ quantity: number; servers: string[] }> { + const externalServers = await Rooms.countDistinctFederationRoomsExcluding(settings.get('Federation_Matrix_homeserver_domain')); + + return { + quantity: externalServers.length, + servers: externalServers, + }; + } +} + +export const getMatrixFederationStatistics = async (): Promise => { + const statisticsService = new RocketChatStatisticsAdapter(); + + return { + enabled: settings.get('Federation_Matrix_enabled'), + maximumSizeOfPublicRoomsUsers: settings.get('Federation_Matrix_max_size_of_public_rooms_users'), + biggestRoom: await statisticsService.getBiggestRoomAvailable(), + smallestRoom: await statisticsService.getSmallestRoomAvailable(), + amountOfExternalUsers: await statisticsService.getAmountOfExternalUsers(), + amountOfFederatedRooms: await statisticsService.getAmountOfExternalRooms(), + externalConnectedServers: await statisticsService.getAmountOfConnectedExternalServers(), + }; +}; diff --git a/apps/meteor/server/services/federation/utils.ts b/apps/meteor/server/services/federation/utils.ts new file mode 100644 index 0000000..591c5fc --- /dev/null +++ b/apps/meteor/server/services/federation/utils.ts @@ -0,0 +1,22 @@ +import { settings } from '../../settings'; + +export function isFederationEnabled(): boolean { + return settings.get('Federation_Service_Enabled'); +} + +export function throwIfFederationNotEnabled(): void { + if (!isFederationEnabled()) { + throw new Error('Federation is not enabled'); + } +} + +export class FederationMatrixInvalidConfigurationError extends Error { + constructor(cause?: string) { + // eslint-disable-next-line prefer-template + const message = 'Federation configuration is invalid' + (cause ? ',' + cause[0].toLowerCase() + cause.slice(1) : ''); + + super(message); + + this.name = 'FederationMatrixInvalidConfiguration'; + } +} diff --git a/apps/meteor/server/services/image/service.ts b/apps/meteor/server/services/image/service.ts new file mode 100644 index 0000000..01a1d09 --- /dev/null +++ b/apps/meteor/server/services/image/service.ts @@ -0,0 +1,108 @@ +import type { Readable } from 'node:stream'; +import stream from 'node:stream'; + +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IMediaService, ResizeResult } from '@rocket.chat/core-services'; +import { streamToBuffer } from '@rocket.chat/tools'; +import ExifTransformer from 'exif-be-gone'; +import ft from 'file-type'; +import isSvg from 'is-svg'; +import sharp from 'sharp'; +import type { FitEnum } from 'sharp'; + +export class MediaService extends ServiceClassInternal implements IMediaService { + protected name = 'media'; + + private imageExts = new Set([ + 'jpg', + 'png', + 'gif', + 'webp', + 'flif', + 'cr2', + 'tif', + 'bmp', + 'jxr', + 'psd', + 'ico', + 'bpg', + 'jp2', + 'jpm', + 'jpx', + 'heic', + 'cur', + 'dcm', + ]); + + async resizeFromBuffer( + input: Buffer, + width: number, + height: number, + keepType: boolean, + blur: boolean, + enlarge: boolean, + fit?: keyof FitEnum | undefined, + ): Promise { + const stream = this.bufferToStream(input); + return this.resizeFromStream(stream, width, height, keepType, blur, enlarge, fit); + } + + async resizeFromStream( + input: stream.Stream, + width: number, + height: number, + keepType: boolean, + blur: boolean, + enlarge: boolean, + fit?: keyof FitEnum | undefined, + ): Promise { + const transformer = sharp({ limitInputChannels: 8 }).resize({ width, height, fit, withoutEnlargement: !enlarge }); + + if (!keepType) { + transformer.jpeg(); + } + + if (blur) { + transformer.blur(); + } + + const result = transformer.toBuffer({ resolveWithObject: true }); + input.pipe(transformer); + + const { + data, + info: { width: widthInfo, height: heightInfo }, + } = await result; + return { + data, + width: widthInfo, + height: heightInfo, + }; + } + + async isImage(buff: Buffer): Promise { + const data = await ft.fromBuffer(buff); + if (!data?.ext) { + return false || this.isSvgImage(buff); + } + return this.imageExts.has(data.ext) || this.isSvgImage(buff); + } + + isSvgImage(buff: Buffer): boolean { + return isSvg(buff.toString('utf-8')); + } + + stripExifFromBuffer(buffer: Buffer): Promise { + return streamToBuffer(this.stripExifFromImageStream(this.bufferToStream(buffer))); + } + + stripExifFromImageStream(stream: stream.Stream): Readable { + return stream.pipe(new ExifTransformer()); + } + + private bufferToStream(buffer: Buffer): stream.PassThrough { + const bufferStream = new stream.PassThrough(); + bufferStream.end(buffer); + return bufferStream; + } +} diff --git a/apps/meteor/server/services/import/service.spec.ts b/apps/meteor/server/services/import/service.spec.ts new file mode 100644 index 0000000..e2f1981 --- /dev/null +++ b/apps/meteor/server/services/import/service.spec.ts @@ -0,0 +1,423 @@ +import type { IImport, IImportUser } from '@rocket.chat/core-typings'; +import { ObjectId } from 'mongodb'; + +const mockImportsInvalidateAllOperations = jest.fn(); +const mockImportsInsertOne = jest.fn(); +const mockImportsFindOneById = jest.fn(); +const mockImportsFindLastImport = jest.fn(); +const mockImportsIncreaseTotalCount = jest.fn(); +const mockImportsSetOperationStatus = jest.fn(); +const mockImportDataDeleteMany = jest.fn(); +const mockImportDataInsertMany = jest.fn(); + +jest.mock('@rocket.chat/core-services', () => ({ + ServiceClassInternal: class { + protected name = ''; + }, +})); + +jest.mock('@rocket.chat/models', () => ({ + Imports: { + invalidateAllOperations: (...args: unknown[]) => mockImportsInvalidateAllOperations(...args), + insertOne: (...args: unknown[]) => mockImportsInsertOne(...args), + findOneById: (...args: unknown[]) => mockImportsFindOneById(...args), + findLastImport: (...args: unknown[]) => mockImportsFindLastImport(...args), + increaseTotalCount: (...args: unknown[]) => mockImportsIncreaseTotalCount(...args), + setOperationStatus: (...args: unknown[]) => mockImportsSetOperationStatus(...args), + }, + ImportData: { + col: { + deleteMany: (...args: unknown[]) => mockImportDataDeleteMany(...args), + insertMany: (...args: unknown[]) => mockImportDataInsertMany(...args), + }, + }, +})); + +const mockImportersGet = jest.fn(); +jest.mock('../../lib/import', () => ({ + Importers: { + get: (...args: unknown[]) => mockImportersGet(...args), + }, +})); + +const mockSettingsGet = jest.fn(); +jest.mock('../../settings', () => ({ + settings: { + get: (...args: unknown[]) => mockSettingsGet(...args), + }, +})); + +const mockValidateRoleList = jest.fn(); +jest.mock('../../lib/roles/validateRoleList', () => ({ + validateRoleList: (...args: unknown[]) => mockValidateRoleList(...args), +})); + +const mockGetNewUserRoles = jest.fn(); +jest.mock('../user/lib/getNewUserRoles', () => ({ + getNewUserRoles: (...args: unknown[]) => mockGetNewUserRoles(...args), +})); + +// eslint-disable-next-line import-x/first +import { ImportService } from './service'; + +const createMockOperation = (overrides?: Partial): IImport => ({ + _id: new ObjectId().toHexString(), + type: 'csv', + importerKey: 'csv', + ts: new Date(), + status: 'importer_new', + valid: true, + user: 'user123', + _updatedAt: new Date(), + ...overrides, +}); + +describe('ImportService', () => { + let service: ImportService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new ImportService(); + mockGetNewUserRoles.mockReturnValue(['user']); + mockValidateRoleList.mockResolvedValue(true); + }); + + describe('clear', () => { + it('should invalidate all operations and delete import data', async () => { + await service.clear(); + + expect(mockImportsInvalidateAllOperations).toHaveBeenCalledTimes(1); + expect(mockImportDataDeleteMany).toHaveBeenCalledWith({}); + }); + }); + + describe('newOperation', () => { + it('should clear previous operations and create a new import', async () => { + const mockOperation = createMockOperation(); + + mockImportsInsertOne.mockResolvedValue({ insertedId: mockOperation._id }); + mockImportsFindOneById.mockResolvedValue(mockOperation); + + const result = await service.newOperation('user123', 'csv', 'csv'); + + expect(mockImportsInvalidateAllOperations).toHaveBeenCalled(); + expect(mockImportDataDeleteMany).toHaveBeenCalledWith({}); + expect(mockImportsInsertOne).toHaveBeenCalledWith({ + type: 'csv', + importerKey: 'csv', + ts: expect.any(Date), + status: 'importer_new', + valid: true, + user: 'user123', + }); + expect(result).toEqual(mockOperation); + }); + + it('should throw an error if operation creation fails', async () => { + mockImportsInsertOne.mockResolvedValue({ insertedId: new ObjectId().toHexString() }); + mockImportsFindOneById.mockResolvedValue(null); + + await expect(service.newOperation('user123', 'csv', 'csv')).rejects.toThrow('failed to create import operation'); + }); + }); + + describe('status', () => { + it('should return state "none" when no operation exists', async () => { + mockImportsFindLastImport.mockResolvedValue(null); + + const result = await service.status(); + + expect(result).toEqual({ state: 'none' }); + }); + + it('should return "new" state for importer_new status', async () => { + const mockOperation = createMockOperation(); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'new', operation: mockOperation }); + }); + + it('should return "loading" state for uploading status', async () => { + const mockOperation = createMockOperation({ status: 'importer_uploading' }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'loading', operation: mockOperation }); + }); + + it('should return "ready" state for user_selection status', async () => { + const mockOperation = createMockOperation({ status: 'importer_user_selection' }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'ready', operation: mockOperation }); + }); + + it('should return "importing" state for importing statuses', async () => { + const mockOperation = createMockOperation({ status: 'importer_importing_users' }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'importing', operation: mockOperation }); + }); + + it('should return "done" state for importer_done status', async () => { + const mockOperation = createMockOperation({ status: 'importer_done' }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'done', operation: mockOperation }); + }); + + it('should return "error" state for invalid operation', async () => { + const mockOperation = createMockOperation({ valid: false }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'error', operation: mockOperation }); + }); + + it('should return "canceled" state for cancelled import', async () => { + const mockOperation = createMockOperation({ status: 'importer_import_cancelled' }); + mockImportsFindLastImport.mockResolvedValue(mockOperation); + + const result = await service.status(); + + expect(result).toEqual({ state: 'canceled', operation: mockOperation }); + }); + }); + + describe('addUsers', () => { + const mockOperation = createMockOperation({ + _id: 'op1', + }); + + beforeEach(() => { + mockImportsFindLastImport.mockResolvedValue(mockOperation); + }); + + it('should return early for empty users array', async () => { + await service.addUsers([]); + + expect(mockImportsFindLastImport).not.toHaveBeenCalled(); + expect(mockImportDataInsertMany).not.toHaveBeenCalled(); + }); + + it('should throw error if operation is not valid', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, valid: false }); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('Import operation not initialized.'); + }); + + it('should throw error if user has no emails', async () => { + const users: Omit[] = [{ emails: [], importIds: ['id1'], type: 'user' }]; + + await expect(service.addUsers(users)).rejects.toThrow('Users are missing required data.'); + }); + + it('should throw error if user has no importIds', async () => { + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: [], type: 'user' }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('Users are missing required data.'); + }); + + it('should throw error if roles are invalid', async () => { + mockValidateRoleList.mockResolvedValue(false); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user', roles: ['invalid-role'] }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('One or more of the users have been assigned invalid roles.'); + }); + + it('should add users with default roles when no roles provided', async () => { + mockGetNewUserRoles.mockReturnValue(['user']); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await service.addUsers(users); + + expect(mockImportDataInsertMany).toHaveBeenCalledWith([ + expect.objectContaining({ + data: expect.objectContaining({ + roles: ['user'], + }), + dataType: 'user', + }), + ]); + }); + + // Test for the condition fixed in the previous commit: + // The fix was changing `[...new Set(...data.roles, ...defaultRoles)]` to `[...new Set([...data.roles, ...defaultRoles])]` + // This ensures user roles are properly merged with default roles without duplicates + it('should correctly merge user roles with default roles without duplicates', async () => { + mockGetNewUserRoles.mockReturnValue(['user', 'guest']); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user', roles: ['admin', 'user'] }, // 'user' is in both + ]; + + await service.addUsers(users); + + const insertCall = mockImportDataInsertMany.mock.calls[0][0]; + const insertedRoles = insertCall[0].data.roles; + + // Verify roles are merged correctly: admin, user, guest (user appears once due to Set deduplication) + expect(insertedRoles).toContain('admin'); + expect(insertedRoles).toContain('user'); + expect(insertedRoles).toContain('guest'); + // Ensure no duplicates - the length should be exactly 3 + expect(insertedRoles.length).toBe(3); + expect(new Set(insertedRoles).size).toBe(3); + }); + + it('should correctly merge multiple user roles with multiple default roles', async () => { + mockGetNewUserRoles.mockReturnValue(['user', 'default-role']); + + const users: Omit[] = [ + { + emails: ['test1@example.com'], + importIds: ['id1'], + type: 'user', + roles: ['admin', 'moderator'], + }, + { + emails: ['test2@example.com'], + importIds: ['id2'], + type: 'user', + roles: ['user'], // Already in default roles + }, + ]; + + await service.addUsers(users); + + const insertCall = mockImportDataInsertMany.mock.calls[0][0]; + + // First user: admin, moderator + user, default-role = 4 unique roles + const firstUserRoles = insertCall[0].data.roles; + expect(firstUserRoles).toContain('admin'); + expect(firstUserRoles).toContain('moderator'); + expect(firstUserRoles).toContain('user'); + expect(firstUserRoles).toContain('default-role'); + expect(new Set(firstUserRoles).size).toBe(4); + + // Second user: user + user, default-role = should dedupe to 2 unique roles + const secondUserRoles = insertCall[1].data.roles; + expect(secondUserRoles).toContain('user'); + expect(secondUserRoles).toContain('default-role'); + expect(new Set(secondUserRoles).size).toBe(2); + }); + + it('should update operation status and count after adding users', async () => { + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await service.addUsers(users); + + expect(mockImportsIncreaseTotalCount).toHaveBeenCalledWith('op1', 'users', 1); + expect(mockImportsSetOperationStatus).toHaveBeenCalledWith('op1', 'importer_user_selection'); + }); + + it('should throw error when operation is in loading state', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_uploading' }); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('The current import operation can not receive new data.'); + }); + + it('should throw error when operation is in importing state', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_importing_users' }); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('The current import operation can not receive new data.'); + }); + + it('should throw error when operation is done', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_done' }); + + const users: Omit[] = [ + { emails: ['test@example.com'], importIds: ['id1'], type: 'user' }, + ]; + + await expect(service.addUsers(users)).rejects.toThrow('The current import operation is already finished.'); + }); + }); + + describe('run', () => { + const mockOperation = createMockOperation({ status: 'importer_user_selection' }); + + it('should throw error if operation not found', async () => { + mockImportsFindLastImport.mockResolvedValue(null); + + await expect(service.run('user123')).rejects.toThrow('error-operation-not-found'); + }); + + it('should throw error if operation is invalid', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, valid: false }); + + await expect(service.run('user123')).rejects.toThrow('error-operation-not-found'); + }); + + it('should throw error if operation status is not user_selection', async () => { + mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_new' }); + + await expect(service.run('user123')).rejects.toThrow('error-invalid-operation-status'); + }); + + it('should throw error if importer not found', async () => { + mockImportsFindLastImport.mockResolvedValue(mockOperation); + mockImportersGet.mockReturnValue(null); + + await expect(service.run('user123')).rejects.toThrow('error-importer-not-defined'); + }); + + it('should start import with correct options', async () => { + mockImportsFindLastImport.mockResolvedValue(mockOperation); + mockSettingsGet.mockReturnValue(true); + + const mockStartImport = jest.fn(); + const MockImporter = jest.fn().mockImplementation(() => ({ + startImport: mockStartImport, + })); + + mockImportersGet.mockReturnValue({ + importer: MockImporter, + }); + + await service.run('user123'); + + expect(mockImportersGet).toHaveBeenCalledWith('csv'); + expect(MockImporter).toHaveBeenCalledWith({ importer: MockImporter }, mockOperation, { + skipUserCallbacks: true, + skipDefaultChannels: true, + enableEmail2fa: true, + quickUserInsertion: true, + skipExistingUsers: true, + }); + expect(mockStartImport).toHaveBeenCalledWith({ users: { all: true } }, 'user123'); + }); + }); +}); diff --git a/apps/meteor/server/services/import/service.ts b/apps/meteor/server/services/import/service.ts new file mode 100644 index 0000000..772f936 --- /dev/null +++ b/apps/meteor/server/services/import/service.ts @@ -0,0 +1,182 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IImportService } from '@rocket.chat/core-services'; +import type { IImportUser, IImport, ImportStatus } from '@rocket.chat/core-typings'; +import { Imports, ImportData } from '@rocket.chat/models'; +import { ObjectId } from 'mongodb'; + +import { Importers } from '../../lib/import'; +import { validateRoleList } from '../../lib/roles/validateRoleList'; +import { settings } from '../../settings'; +import { getNewUserRoles } from '../user/lib/getNewUserRoles'; + +export class ImportService extends ServiceClassInternal implements IImportService { + protected name = 'import'; + + public async clear(): Promise { + await Imports.invalidateAllOperations(); + await ImportData.col.deleteMany({}); + } + + public async newOperation(userId: string, name: string, key: string): Promise { + // Make sure there's no other operation running + await this.clear(); + + const importId = ( + await Imports.insertOne({ + type: name, + importerKey: key, + ts: new Date(), + status: 'importer_new', + valid: true, + user: userId, + }) + ).insertedId; + + const operation = await Imports.findOneById(importId); + if (!operation) { + throw new Error('failed to create import operation'); + } + + return operation; + } + + private getStateOfOperation(operation: IImport): 'none' | 'new' | 'loading' | 'ready' | 'importing' | 'done' | 'error' | 'canceled' { + if (!operation.valid && operation.status !== 'importer_done') { + return 'error'; + } + + switch (operation.status) { + case 'importer_new': + return 'new'; + case 'importer_uploading': + case 'importer_downloading_file': + case 'importer_file_loaded': + case 'importer_preparing_started': + case 'importer_preparing_users': + case 'importer_preparing_channels': + case 'importer_preparing_messages': + case 'importer_preparing_contacts': + return 'loading'; + case 'importer_user_selection': + return 'ready'; + case 'importer_importing_started': + case 'importer_importing_users': + case 'importer_importing_channels': + case 'importer_importing_messages': + case 'importer_importing_contacts': + case 'importer_importing_files': + case 'importer_finishing': + return 'importing'; + case 'importer_done': + return 'done'; + case 'importer_import_failed': + return 'error'; + case 'importer_import_cancelled': + return 'canceled'; + } + } + + public async status(): Promise { + const operation = await Imports.findLastImport(); + + if (!operation) { + return { + state: 'none', + }; + } + + const state = this.getStateOfOperation(operation); + + return { + state, + operation, + }; + } + + private assertsValidStateForNewData(operation: IImport | undefined): asserts operation is IImport { + if (!operation?.valid) { + throw new Error('Import operation not initialized.'); + } + const state = this.getStateOfOperation(operation); + switch (state) { + case 'loading': + case 'importing': + throw new Error('The current import operation can not receive new data.'); + case 'done': + case 'error': + case 'canceled': + throw new Error('The current import operation is already finished.'); + } + } + + public async addUsers(users: Omit[]): Promise { + if (!users.length) { + return; + } + + const operation = await Imports.findLastImport(); + + this.assertsValidStateForNewData(operation); + + const defaultRoles = getNewUserRoles(); + const userRoles = new Set(defaultRoles); + for await (const user of users) { + if (!user.emails?.some((value) => value) || !user.importIds?.some((value) => value)) { + throw new Error('Users are missing required data.'); + } + + if (user.roles?.length) { + for (const roleId of user.roles) { + userRoles.add(roleId); + } + } + } + + if (userRoles.size > 0 && !(await validateRoleList([...userRoles]))) { + throw new Error('One or more of the users have been assigned invalid roles.'); + } + + await ImportData.col.insertMany( + users.map((data) => ({ + _id: new ObjectId().toHexString(), + data: { + ...data, + roles: data.roles ? [...new Set([...data.roles, ...defaultRoles])] : defaultRoles, + }, + dataType: 'user', + _updatedAt: new Date(), + })), + ); + + await Imports.increaseTotalCount(operation._id, 'users', users.length); + await Imports.setOperationStatus(operation._id, 'importer_user_selection'); + } + + public async run(userId: string): Promise { + const operation = await Imports.findLastImport(); + if (!operation?.valid) { + throw new Error('error-operation-not-found'); + } + + if (operation.status !== 'importer_user_selection') { + throw new Error('error-invalid-operation-status'); + } + + const { importerKey } = operation; + const importer = Importers.get(importerKey); + if (!importer) { + throw new Error('error-importer-not-defined'); + } + + // eslint-disable-next-line new-cap + const instance = new importer.importer(importer, operation, { + skipUserCallbacks: true, + skipDefaultChannels: true, + enableEmail2fa: settings.get('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In'), + quickUserInsertion: true, + skipExistingUsers: true, + }); + + await instance.startImport({ users: { all: true } }, userId); + } +} diff --git a/apps/meteor/server/services/ldap/service.ts b/apps/meteor/server/services/ldap/service.ts new file mode 100644 index 0000000..6f4c5a7 --- /dev/null +++ b/apps/meteor/server/services/ldap/service.ts @@ -0,0 +1,25 @@ +import type { ILDAPService } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { LDAPLoginResult } from '@rocket.chat/core-typings'; + +import { LDAPManager } from '../../lib/ldap/Manager'; + +export class LDAPService extends ServiceClassInternal implements ILDAPService { + protected name = 'ldap'; + + async loginRequest(username: string, password: string): Promise { + return LDAPManager.login(username, password); + } + + async loginAuthenticatedUserRequest(username: string): Promise { + return LDAPManager.loginAuthenticatedUser(username); + } + + async testConnection(): Promise { + return LDAPManager.testConnection(); + } + + async testSearch(username: string): Promise { + return LDAPManager.testSearch(username); + } +} diff --git a/apps/meteor/server/services/media-call/logger.ts b/apps/meteor/server/services/media-call/logger.ts new file mode 100644 index 0000000..a021409 --- /dev/null +++ b/apps/meteor/server/services/media-call/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('MediaCall'); diff --git a/apps/meteor/server/services/media-call/push/getPushNotificationType.ts b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts new file mode 100644 index 0000000..8683eaf --- /dev/null +++ b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts @@ -0,0 +1,22 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { VoipPushNotificationType } from '@rocket.chat/media-calls'; + +export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType { + if (call.acceptedAt) { + return 'answeredElsewhere'; + } + + if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') { + return 'declinedElsewhere'; + } + + if (call.endedBy?.id === call.caller.id) { + return 'remoteEnded'; + } + + if (call.ended) { + return 'unanswered'; + } + + return 'incoming_call'; +} diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts new file mode 100644 index 0000000..3885ce0 --- /dev/null +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -0,0 +1,125 @@ +import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls'; +import { MediaCalls, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { getPushNotificationType } from './getPushNotificationType'; +import { metrics } from '../../../lib/metrics/lib/metrics'; +import { Push } from '../../../lib/notifications/push/push'; +import PushNotification from '../../../lib/notifications/push-config/lib/PushNotification'; +import { getUserAvatarURL } from '../../../lib/utils/getUserAvatarURL'; +import { getUserPreference } from '../../../lib/utils/lib/getUserPreference'; +import { settings } from '../../../settings'; +import { logger } from '../logger'; + +async function getActorUser(actor: MediaCallContact): Promise | null> { + const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } }; + + switch (actor.type) { + case 'user': + return Users.findOneById(actor.id, options); + case 'sip': + return Users.findOneByFreeSwitchExtension(actor.id, options); + } +} + +async function getActorUserData( + actor: MediaCallContact, +): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string; username?: string }> { + const actorUsername = actor.type === 'user' ? actor.username : undefined; + const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined); + + const data = { + type: actor.type, + id: actor.id, + name: actor.displayName || actorUsername || actorExtension || '', + } as const; + + const user = await getActorUser(actor); + + if (user) { + const username = user.username || actorUsername; + + return { + ...data, + name: user.name || user.username || user.freeSwitchExtension || data.name, + ...(username && { username, avatarUrl: getUserAvatarURL(username) }), + }; + } + + return { + ...data, + ...(actorUsername && { username: actorUsername, avatarUrl: getUserAvatarURL(actorUsername) }), + }; +} + +async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { + if (settings.get('Push_enable') !== true || settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') !== true) { + return; + } + + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); + return; + } + + if (call.callee.type !== 'user') { + logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId }); + return; + } + + const { + kind, + callee: { id: userId }, + } = call; + + // If the call was accepted, we don't need to notify when it ends + if (call.acceptedAt && event !== 'answer') { + return; + } + + const type = getPushNotificationType(call); + // If the state changed before we had a chance to send the incoming call, skip it altogether + if (event === 'new' && type !== 'incoming_call') { + return; + } + if (type === 'incoming_call' && event !== 'new') { + return; + } + + if (!(await getUserPreference(userId, 'enableMobileRinging'))) { + return; + } + + const caller = await getActorUserData(call.caller); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + const useVoipToken = type === 'incoming_call'; + + await Push.send({ + useVoipToken, + priority: 10, + payload: { + host: Meteor.absoluteUrl(), + hostName: settings.get('Site_Name'), + notificationType: 'voip', + type, + kind, + callId: call._id, + caller, + createdAt: call.createdAt.toISOString(), + }, + ...(useVoipToken && { apn: { expirationSeconds: 60 } }), + userId, + notId: PushNotification.getNotificationId(call._id), + // We should not send state change notifications to the device where the call was accepted/rejected + ...(call.callee.contractId && { skipTokenId: call.callee.contractId }), + }); +} + +export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void { + void sendVoipPushNotificationAsync(callId, event).catch((err) => { + logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event }); + }); +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts new file mode 100644 index 0000000..3e740e4 --- /dev/null +++ b/apps/meteor/server/services/media-call/service.ts @@ -0,0 +1,473 @@ +import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services'; +import type { + IMediaCall, + IUser, + IRoom, + IInternalMediaCallHistoryItem, + CallHistoryItemState, + IExternalMediaCallHistoryItem, +} from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; +import type { + CallFeature, + ClientMediaSignal, + ServerMediaSignal, + ServerMediaCallSignal, + ClientMediaSignalAnswer, +} from '@rocket.chat/media-signaling'; +import { isClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; +import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; + +import { logger } from './logger'; +import { sendVoipPushNotification } from './push/sendVoipPushNotification'; +import { i18n } from '../../lib/i18n'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage'; +import { settings } from '../../settings'; + +export class MediaCallService extends ServiceClassInternal implements IMediaCallService { + protected name = 'media-call'; + + constructor() { + super(); + callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); + callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params)); + callServer.emitter.on('callActivated', ({ callId, uids }) => this.setPresenceForUsers(uids, callId)); + callServer.emitter.on('callEnded', ({ callId, uids }) => this.clearPresenceForUsers(uids, callId)); + callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId))); + callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event)); + this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); + + this.onEvent('watch.settings', async ({ setting }): Promise => { + if (setting._id.startsWith('VoIP_TeamCollab_')) { + setImmediate(() => this.configureMediaCallServer()); + } + }); + + this.configureMediaCallServer(); + } + + public async answerCall(uid: IUser['_id'], params: Omit): Promise { + const { callId, answer } = params; + + const call = await MediaCalls.findOneByIdAndCallee>( + callId, + { type: 'user', id: uid }, + { projection: { _id: 1 } }, + ); + if (!call) { + throw new Error('not-found'); + } + + const signal: ClientMediaSignalAnswer = { + type: 'answer', + ...params, + }; + + await callServer.receiveSignal(uid, signal, { throwIfSkipped: true }); + + const updatedCall = await MediaCalls.findOneById(callId); + if (!updatedCall) { + throw new Error('internal-error'); + } + + switch (answer) { + case 'ack': + if (updatedCall.acceptedAt || updatedCall.ended) { + throw new Error('invalid-call-state'); + } + break; + case 'reject': + if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) { + throw new Error('invalid-call-state'); + } + break; + case 'accept': + if (updatedCall.callee.contractId !== signal.contractId) { + if (updatedCall.callee.contractId) { + throw new Error('invalid-call-state'); + } + throw new Error('internal-error'); + } + break; + } + + return updatedCall; + } + + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { + try { + await callServer.receiveSignal(uid, signal); + } catch (err) { + logger.error({ msg: 'failed to process client signal', err, signal, uid }); + } + } + + public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise { + let signalType: string | null = null; + + try { + const deserialized = await this.deserializeClientSignal(signal); + signalType = deserialized.type; + + await callServer.receiveSignal(uid, deserialized); + } catch (err) { + logger.error({ msg: 'failed to process client signal', err, uid, type: signalType }); + } + } + + public async hangupExpiredCalls(): Promise { + await callServer.hangupExpiredCalls().catch((err) => { + logger.error({ msg: 'Media Call Server failed to hangup expired calls', err }); + }); + + try { + if (await MediaCalls.hasUnfinishedCalls()) { + callServer.scheduleExpirationCheck(); + } + } catch (err) { + logger.error({ msg: 'Media Call Server failed to check if there are expired calls', err }); + } + } + + public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise { + const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + + const signals: ServerMediaCallSignal[] = []; + for (const call of calls) { + const callSignals = await getSignalsForExistingCall(call, uid, contractId); + signals.push(...callSignals); + } + + return signals; + } + + private async saveCallToHistory(callId: IMediaCall['_id']): Promise { + logger.info({ msg: 'saving media call to history', callId }); + + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.warn({ msg: 'Attempt to save an invalid call to history', callId }); + return; + } + if (!call.ended) { + logger.warn({ msg: 'Attempt to save a pending call to history', callId }); + return; + } + + if (call.uids.length !== 2) { + return this.saveExternalCallToHistory(call); + } + + return this.saveInternalCallToHistory(call); + } + + private async saveExternalCallToHistory(call: IMediaCall): Promise { + const callerIsInternal = call.caller.type === 'user'; + const calleeIsInternal = call.callee.type === 'user'; + + if (callerIsInternal && calleeIsInternal) { + logger.warn({ msg: 'Attempt to save an external call history with a call that is not external', callId: call._id }); + return; + } + + if (!callerIsInternal && !calleeIsInternal) { + logger.warn({ msg: 'Attempt to save an external call history with an invalid call', callId: call._id }); + return; + } + + const state = this.getCallHistoryItemState(call); + const duration = this.getCallDuration(call); + const direction = callerIsInternal ? 'outbound' : 'inbound'; + const uid = callerIsInternal ? call.caller.id : call.callee.id; + const contact = callerIsInternal ? call.callee : call.caller; + + const contactExtension = contact.sipExtension || contact.id; + + const historyItem: InsertionModel = { + uid, + ts: call.createdAt, + callId: call._id, + state, + type: 'media-call', + duration, + endedAt: call.endedAt || new Date(), + external: true, + direction, + contactExtension, + }; + + await CallHistory.insertOne(historyItem).catch((err: unknown) => logger.error({ msg: 'Failed to insert item into Call History', err })); + } + + private getContactDataForInternalHistory( + contact: IMediaCall['caller'] | IMediaCall['callee'], + ): Pick { + return { + contactId: contact.id, + contactName: contact.displayName, + contactUsername: contact.username, + }; + } + + private async saveInternalCallToHistory(call: IMediaCall): Promise { + if (call.caller.type !== 'user' || call.callee.type !== 'user') { + logger.warn({ msg: 'Attempt to save an internal call history with a call that is not internal', callId: call._id }); + return; + } + + const room = await this.getRoomIdForInternalCall(call).catch((err) => { + logger.error({ msg: 'Failed to determine room id for Internal Call', err }); + return undefined; + }); + const { _id: rid } = room || {}; + const state = this.getCallHistoryItemState(call); + const duration = this.getCallDuration(call); + + const sharedData: Omit, 'uid' | 'direction' | 'contactId'> = { + ts: call.createdAt, + callId: call._id, + state, + type: 'media-call', + duration, + endedAt: call.endedAt || new Date(), + external: false, + ...(rid && { rid }), + }; + + const outboundHistoryItem = { + ...sharedData, + uid: call.caller.id, + direction: 'outbound', + ...this.getContactDataForInternalHistory(call.callee), + } as const; + + const inboundHistoryItem = { + ...sharedData, + uid: call.callee.id, + direction: 'inbound', + ...this.getContactDataForInternalHistory(call.caller), + } as const; + + await CallHistory.insertMany([outboundHistoryItem, inboundHistoryItem]).catch((err: unknown) => + logger.error({ msg: 'Failed to insert items into Call History', err }), + ); + + if (room) { + return this.sendHistoryMessage(call, room); + } + } + + private getLanguageForUser(language?: string): string { + return language || settings.get('Language') || 'en'; + } + + private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise { + const userId = call.caller.id || call.createdBy?.id; // I think this should always be the caller, since during a transfer the createdBy contact is the one that transferred the call + + const user = await Users.findOneById(userId); + if (!user) { + return; + } + + const state = this.getCallHistoryItemState(call); + const skipNotifications = state !== 'not-answered' || call.hangupReason === 'rejected'; + const i18nKey = callStateToTranslationKey(state).i18n?.key; + + const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user.language) }) : ''; + const duration = this.getCallDuration(call); + + const record = getHistoryMessagePayload(state, duration, call._id, msg); + + try { + const message = await sendMessage(user, record, room, { skipNotifications }); + + if ('_id' in message) { + await CallHistory.updateMany({ callId: call._id }, { $set: { messageId: message._id } }); + return; + } + throw new Error('Failed to save message id in history'); + } catch (err) { + logger.error({ msg: 'Failed to send history message', err, callId: call._id }); + } + } + + private getCallDuration(call: IMediaCall): number { + const { activatedAt, endedAt = new Date() } = call; + if (!activatedAt) { + return 0; + } + + const diff = endedAt.valueOf() - activatedAt.valueOf(); + return Math.floor(diff / 1000); + } + + private getCallHistoryItemState(call: IMediaCall): CallHistoryItemState { + if (call.transferredBy) { + return 'transferred'; + } + + if (call.hangupReason === 'not-answered') { + return 'not-answered'; + } + + if (call.hangupReason?.startsWith('timeout')) { + return 'failed'; + } + + if (call.hangupReason?.includes('error')) { + if (!call.activatedAt) { + return 'failed'; + } + + return 'error'; + } + + if (!call.acceptedAt) { + return 'not-answered'; + } + + if (!call.activatedAt) { + return 'failed'; + } + + return 'ended'; + } + + private async getRoomIdForInternalCall(call: IMediaCall): Promise { + const uniqueUids = [...new Set(call.uids)]; + + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids); + if (room) { + return room; + } + + const requesterId = call.createdBy.type === 'user' && call.createdBy.id; + const callerId = call.caller.type === 'user' && call.caller.id; + + const dmCreatorId = requesterId || callerId || call.uids[0]; + + const usernames = ( + await Users.findByIds(call.uids, { projection: { username: 1 } }) + .map((user) => user.username) + .toArray() + ).filter((username) => username); + + if (usernames.length !== 2) { + throw new Error('Invalid usernames for DM.'); + } + + const dmCreatorIsPartOfTheCall = call.uids.includes(dmCreatorId); + + const newRoom = await createDirectMessage(usernames, dmCreatorId, !dmCreatorIsPartOfTheCall); // If the dm creator is not part of the call, we need to exclude him from the new DM + return { + ...newRoom, + _id: newRoom.rid, + }; + } + + private async setPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise { + const users = await Users.findByIds>(uids, { projection: { language: 1 } }).toArray(); + const languageByUid = new Map(users.map((user) => [user._id, user.language])); + + await Promise.all( + uids.map(async (uid) => { + try { + await Presence.setActiveState(uid, { + statusDefault: UserStatus.BUSY, + statusText: i18n.t('Presence_status_on_a_call', { lng: this.getLanguageForUser(languageByUid.get(uid)) }), + statusSource: 'internal', + statusId: callId, + }); + } catch (err) { + logger.error({ msg: 'Failed to set presence for user on call', uid, err }); + } + }), + ); + } + + private async clearPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise { + // pass callId so only this call's claim is cleared, never another claim that took over + await Promise.all( + uids.map((uid) => + Presence.endActiveState(uid, callId).catch((err) => + logger.error({ msg: 'Failed to clear presence for user after call', uid, err }), + ), + ), + ); + } + + private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { + void api.broadcast('user.media-signal', { userId: toUid, signal }); + } + + private configureMediaCallServer(): void { + callServer.configure(this.getMediaServerSettings()); + } + + private getMediaServerSettings(): IMediaCallServerSettings { + const sipEnabled = settings.get('VoIP_TeamCollab_SIP_Integration_Enabled') ?? false; + const mobileRinging = settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') ?? false; + const forceSip = sipEnabled && (settings.get('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls') ?? false); + + return { + internalCalls: { + requireExtensions: forceSip, + routeExternally: forceSip ? 'always' : 'never', + }, + sip: { + enabled: sipEnabled, + drachtio: { + host: settings.get('VoIP_TeamCollab_Drachtio_Host') ?? '', + port: settings.get('VoIP_TeamCollab_Drachtio_Port') ?? 9022, + secret: settings.get('VoIP_TeamCollab_Drachtio_Password') ?? '', + }, + sipServer: { + host: settings.get('VoIP_TeamCollab_SIP_Server_Host') ?? '', + port: settings.get('VoIP_TeamCollab_SIP_Server_Port') ?? 5060, + }, + }, + mobileRinging, + permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType), + isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature), + }; + } + + private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean { + if (feature === 'audio') { + return true; + } + + if (feature === 'screen-share') { + return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; + } + + return true; + } + + private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise { + if (callType === 'any') { + return Authorization.hasAtLeastOnePermission(uid, ['allow-internal-voice-calls', 'allow-external-voice-calls']); + } + + const permissionId = `allow-${callType}-voice-calls`; + + return Authorization.hasPermission(uid, permissionId); + } + + private async deserializeClientSignal(serialized: string): Promise { + try { + const signal = JSON.parse(serialized); + if (!isClientMediaSignal(signal)) { + throw new Error('signal-format-invalid'); + } + return signal; + } catch (err) { + logger.error({ msg: 'Failed to parse client signal', err }); + throw err; + } + } +} diff --git a/apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts b/apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts new file mode 100644 index 0000000..7bae3f9 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts @@ -0,0 +1,379 @@ +import type { + OEmbedUrlContentResult, + MessageUrl, + OEmbedUrlWithMetadata, + OEmbedMeta, + IMessage, + OEmbedUrlContent, +} from '@rocket.chat/core-typings'; +import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { OEmbedCache, Messages } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { isAbsoluteURL } from '@rocket.chat/tools'; +import he from 'he'; +import iconv from 'iconv-lite'; +import ipRangeCheck from 'ip-range-check'; +import jschardet from 'jschardet'; +import { camelCase } from 'lodash'; + +import { Info } from '../../../../app/utils/rocketchat.info'; +import { settings } from '../../../settings'; +import { afterParseUrlContent, beforeGetUrlContent } from '../lib/oembed/providers'; + +const MAX_EXTERNAL_URL_PREVIEWS = 5; +const log = new Logger('OEmbed'); +// Detect encoding +// Priority: +// Detected == HTTP Header > Detected == HTML meta > HTTP Header > HTML meta > Detected > Default (utf-8) +// See also: https://www.w3.org/International/questions/qa-html-encoding-declarations.en#quickanswer +const getCharset = function (contentType: string, body: Buffer): string { + let detectedCharset; + let httpHeaderCharset; + let htmlMetaCharset; + let result; + + contentType = contentType || ''; + + const binary = body.toString('binary'); + const detected = jschardet.detect(binary); + if (detected.confidence > 0.8) { + detectedCharset = detected.encoding.toLowerCase(); + } + const m1 = contentType.match(/charset=([\w\-]+)/i); + if (m1) { + httpHeaderCharset = m1[1].toLowerCase(); + } + const m2 = binary.match(/]*charset=["']?([\w\-]+)/i); + if (m2) { + htmlMetaCharset = m2[1].toLowerCase(); + } + if (detectedCharset) { + if (detectedCharset === httpHeaderCharset) { + result = httpHeaderCharset; + } else if (detectedCharset === htmlMetaCharset) { + result = htmlMetaCharset; + } + } + if (!result) { + result = httpHeaderCharset || htmlMetaCharset || detectedCharset; + } + return result || 'utf-8'; +}; + +const toUtf8 = function (contentType: string, body: Buffer): string { + return iconv.decode(body, getCharset(contentType, body)); +}; + +const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise => { + const portsProtocol = new Map( + Object.entries({ + 80: 'http:', + 8080: 'http:', + 443: 'https:', + }), + ); + + const ignoredHosts = + settings + .get('API_EmbedIgnoredHosts') + .replace(/\s/g, '') + .split(',') + .filter(Boolean) + .map((host) => host.toLowerCase()) || []; + + const isIgnoredHost = (hostname: string | undefined): boolean => { + hostname = hostname?.toLowerCase(); + if (!hostname || !ignoredHosts.length) { + return false; + } + + const exactHosts = ignoredHosts.filter((h) => !h.includes('*')); + if (exactHosts.includes(hostname) || ipRangeCheck(hostname, exactHosts)) { + return true; + } + + return ignoredHosts + .filter((h) => h.includes('*')) + .some((pattern) => { + const validationRegex = /^(?:\*\.)?(?:\*|[a-z0-9-]+)(?:\.(?:\*|[a-z0-9-]+))*$/i; + if (!validationRegex.test(pattern) || pattern === '*') { + return false; + } + + const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); + const source = `^${escaped.replace(/\*/g, '[^.]*')}$`; + + try { + const regex = new RegExp(source, 'i'); + return regex.test(hostname); + } catch { + // fail safe on invalid patterns + return false; + } + }); + }; + + if (isIgnoredHost(urlObj.hostname)) { + throw new Error('host is ignored'); + } + + const safePorts = settings.get('API_EmbedSafePorts').replace(/\s/g, '').split(',') || []; + + // checks if the URL port is in the safe ports list + if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) { + throw new Error('invalid/unsafe port'); + } + + // if port is not detected, use protocol to verify instead + if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) { + throw new Error('invalid/unsafe port'); + } + + const data = beforeGetUrlContent({ urlObj }); + + const url = data.urlObj.toString(); + const sizeLimit = 250000; + + log.debug({ msg: 'Fetching URL for OEmbed', url, redirectCount }); + const start = Date.now(); + const response = await fetch( + url, + { + compress: true, + follow: redirectCount, + headers: { + 'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version}`, + 'Accept-Language': settings.get('Language') || 'en', + ...data.headerOverrides, + }, + timeout: settings.get('API_EmbedTimeout') * 1000, + size: sizeLimit, // max size of the response body, this was not working as expected so I'm also manually verifying that on the iterator + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + }, + settings.get('Allow_Invalid_SelfSigned_Certs'), + ); + const end = Date.now(); + + let totalSize = 0; + const chunks = []; + for await (const chunk of response.body) { + totalSize += chunk.length; + chunks.push(chunk as Buffer); + + if (totalSize > sizeLimit) { + log.warn({ msg: 'OEmbed request size exceeded', url }); + break; + } + } + + log.debug({ msg: 'Obtained response from server', length: totalSize, timeSpent: `${end - start}ms` }); + const buffer = Buffer.concat(chunks); + + return { + headers: Object.fromEntries(response.headers), + body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer), + statusCode: response.status, + urlObj, + }; +}; + +const parseUrl = async function (url: string): Promise<{ urlPreview: MessageUrl; foundMeta: boolean }> { + const parsedUrlObject: MessageUrl = { url, meta: {} }; + let foundMeta = false; + if (!isAbsoluteURL(url)) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + const data = await getUrlMetaWithCache(url); + if (!data) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + if (isOEmbedUrlWithMetadata(data) && data.meta) { + parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {}; + if (parsedUrlObject.meta?.oembedHtml) { + parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || ''; + } + } + + foundMeta = true; + return { + urlPreview: { + ...parsedUrlObject, + ...((parsedUrlObject.headers || data.headers) && { + headers: { + ...parsedUrlObject.headers, + ...(data.headers?.contentLength && { contentLength: data.headers.contentLength }), + ...(data.headers?.contentType && { contentType: data.headers.contentType }), + }, + }), + }, + foundMeta, + }; +}; + +const getUrlMeta = async function ( + url: string, + withFragment?: boolean, +): Promise { + log.debug({ msg: 'Obtaining metadata for URL', url }); + const urlObj = new URL(url); + + if (withFragment) { + urlObj.searchParams.set('_escaped_fragment_', ''); + } + + log.debug({ msg: 'Fetching URL content', url: urlObj.toString() }); + let content: OEmbedUrlContent | undefined; + try { + content = await getUrlContent(urlObj, 5); + } catch (err) { + log.error({ msg: 'Error fetching URL content', url: urlObj.toString(), err }); + } + + if (!content) { + return; + } + + log.debug({ msg: 'Parsing metadata for URL', url }); + const metas: OEmbedMeta = {} as any; + + if (content?.body) { + const escapeMeta = (name: string, value: string): string => { + metas[name] = metas[name] || he.unescape(value); + return metas[name]; + }; + content.body.replace(/]*>([^<]*)<\/title>/gim, (_meta, title) => { + return escapeMeta('pageTitle', title); + }); + content.body.replace(/]*(?:name|property)=[']([^']*)['][^>]*\scontent=[']([^']*)['][^>]*>/gim, (_meta, name, value) => { + return escapeMeta(camelCase(name), value); + }); + content.body.replace(/]*(?:name|property)=["]([^"]*)["][^>]*\scontent=["]([^"]*)["][^>]*>/gim, (_meta, name, value) => { + return escapeMeta(camelCase(name), value); + }); + content.body.replace(/]*\scontent=[']([^']*)['][^>]*(?:name|property)=[']([^']*)['][^>]*>/gim, (_meta, value, name) => { + return escapeMeta(camelCase(name), value); + }); + content.body.replace(/]*\scontent=["]([^"]*)["][^>]*(?:name|property)=["]([^"]*)["][^>]*>/gim, (_meta, value, name) => { + return escapeMeta(camelCase(name), value); + }); + if (metas.fragment === '!' && withFragment == null) { + return getUrlMeta(url, true); + } + delete metas.oembedHtml; + } + const headers: { [k: string]: string } = {}; + + if (content?.headers) { + const headerObj = content.headers; + Object.keys(headerObj).forEach((header) => { + headers[camelCase(header)] = headerObj[header]; + }); + } + if (content && content.statusCode !== 200) { + return; + } + return afterParseUrlContent({ + url, + meta: metas, + headers, + content, + }); +}; + +const getUrlMetaWithCache = async function ( + url: string, + withFragment?: boolean, +): Promise { + log.debug({ msg: 'Getting OEmbed metadata for URL', url }); + const cache = await OEmbedCache.findOneById(url); + + if (cache) { + log.debug({ msg: 'Found OEmbed metadata in cache for URL', url }); + return cache.data; + } + + const data = await getUrlMeta(url, withFragment); + + if (!data) { + return; + } + + try { + log.debug({ msg: 'Saving OEmbed metadata in cache for URL', url }); + await OEmbedCache.createWithIdAndData(url, data); + } catch (_error) { + log.error({ msg: 'OEmbed duplicated record', url }); + } + + return data; +}; + +const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record | void { + const tags: Record = {}; + Object.keys(metaObj).forEach((key) => { + const value = metaObj[key]; + if (/^(og|fb|twitter|oembed|msapplication).+|description|title|pageTitle$/.test(key.toLowerCase()) && value && value.trim() !== '') { + tags[key] = value; + } + }); + + if (Object.keys(tags).length > 0) { + return tags; + } +}; + +const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined => + oembedHtml?.replace('iframe', 'iframe style="max-width: 100%;width:400px;height:225px"'); + +const rocketUrlParser = async function (message: IMessage): Promise { + log.debug({ msg: 'Parsing message URLs' }); + + if (!settings.get('API_Embed')) { + return message; + } + + if (!Array.isArray(message.urls)) { + return message; + } + + log.debug({ msg: 'URLs found in message', count: message.urls.length }); + + if ( + (message.attachments && message.attachments.length > 0) || + message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS + ) { + log.debug({ msg: 'All URLs ignored for OEmbed' }); + return message; + } + + let changed = false; + for await (const item of message.urls) { + if (item.ignoreParse === true) { + log.debug({ msg: 'URL ignored for OEmbed', url: item.url }); + continue; + } + + const { urlPreview, foundMeta } = await parseUrl(item.url); + + Object.assign(item, foundMeta ? urlPreview : {}); + changed = changed || foundMeta; + } + + if (changed === true) { + await Messages.setUrlsById(message._id, message.urls); + } + + return message; +}; + +export const OEmbed: { + rocketUrlParser: (message: IMessage) => Promise; + parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>; +} = { + rocketUrlParser, + parseUrl, +}; diff --git a/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts new file mode 100644 index 0000000..c3c00ce --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeFederationActions.ts @@ -0,0 +1,18 @@ +import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings'; +import type { AtLeast, IMessage, IRoom } from '@rocket.chat/core-typings'; + +import { isFederationEnabled } from '../../federation/utils'; + +export class FederationActions { + public static shouldPerformAction(_message: IMessage, room: AtLeast): boolean { + if (!isRoomFederated(room)) { + return true; + } + + if (!isRoomNativeFederated(room)) { + return false; + } + + return isFederationEnabled(); + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveBadWords.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveBadWords.ts new file mode 100644 index 0000000..4a75cf4 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveBadWords.ts @@ -0,0 +1,71 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import type BadWordsFilter from 'bad-words'; + +export class BeforeSaveBadWords { + badWords: BadWordsFilter | null = null; + + badWordsRegex: RegExp | null = null; + + protected logger: Logger; + + constructor() { + this.logger = new Logger('BadWordsFilter'); + } + + async configure(badWordsList?: string, goodWordsList?: string) { + const { default: Filter } = await import('bad-words'); + const badWords = + badWordsList + ?.split(',') + .map((word) => word.trim()) + .filter(Boolean) || []; + + const options = { + list: badWords, + // library definition does not allow optional definition + exclude: undefined, + splitRegex: undefined, + placeHolder: undefined, + regex: undefined, + replaceRegex: undefined, + emptyList: undefined, + }; + + this.badWords = new Filter(options); + + try { + this.badWordsRegex = new RegExp(`(?<=^|[\\p{P}\\p{Z}])(${badWords.join('|')})(?=$|[\\p{P}\\p{Z}])`, 'gmiu'); + } catch (err) { + this.badWordsRegex = null; + this.logger.error({ msg: 'Error when initializing bad words filter', err }); + } + + if (goodWordsList?.length) { + this.badWords.removeWords(...goodWordsList.split(',').map((word) => word.trim())); + } + } + + disable() { + this.badWords = null; + this.badWordsRegex = null; + } + + async filterBadWords({ message }: { message: IMessage }): Promise { + if (!message.msg || !this.badWords) { + return message; + } + + try { + message.msg = this.badWords.clean(message.msg); + } catch (error) { + // ignore + } finally { + if (this.badWordsRegex) { + message.msg = message.msg.replace(this.badWordsRegex, (match) => '*'.repeat(match.length)); + } + } + + return message; + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveCheckMAC.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveCheckMAC.ts new file mode 100644 index 0000000..bf97252 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveCheckMAC.ts @@ -0,0 +1,24 @@ +import { MeteorError, Omnichannel } from '@rocket.chat/core-services'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { IRoom, IMessage } from '@rocket.chat/core-typings'; + +export class BeforeSaveCheckMAC { + async isWithinLimits({ message, room }: { message: IMessage; room: IRoom }): Promise { + if (!isOmnichannelRoom(room)) { + return; + } + + if (message.token) { + return; + } + + if (message.t) { + return; + } + + const canSendMessage = await Omnichannel.isWithinMACLimit(room); + if (!canSendMessage) { + throw new MeteorError('error-mac-limit-reached'); + } + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveJumpToMessage.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveJumpToMessage.ts new file mode 100644 index 0000000..d026e35 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveJumpToMessage.ts @@ -0,0 +1,172 @@ +import QueryString from 'node:querystring'; +import URL from 'node:url'; + +import type { MessageAttachment, IMessage, IUser, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, isQuoteAttachment } from '@rocket.chat/core-typings'; + +import { createQuoteAttachment } from '../../../../lib/createQuoteAttachment'; + +const recursiveRemoveAttachments = (attachments: MessageAttachment, deep = 1, quoteChainLimit: number): MessageAttachment => { + if (attachments && isQuoteAttachment(attachments)) { + if (deep < quoteChainLimit - 1) { + attachments.attachments?.map((msg) => recursiveRemoveAttachments(msg, deep + 1, quoteChainLimit)); + } else if (attachments.attachments) { + attachments.attachments = attachments.attachments.filter((attachment) => !isQuoteAttachment(attachment)); + if (attachments.attachments.length === 0) { + delete attachments.attachments; + } + } + } + + return attachments; +}; + +const validateAttachmentDeepness = (message: IMessage, quoteChainLimit: number): IMessage => { + if (!message?.attachments) { + return message; + } + + if ((message.attachments && quoteChainLimit < 2) || isNaN(quoteChainLimit)) { + message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment)); + if (message.attachments.length === 0) { + delete message.attachments; + } + } + + message.attachments = message.attachments?.map((attachment) => recursiveRemoveAttachments(attachment, 1, quoteChainLimit)); + + return message; +}; + +const removeQuoteAttachments = (message: IMessage) => { + if (!message.attachments) { + return; + } + message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment)); +}; + +type JumpToMessageInit = { + getMessages(messageIds: IMessage['_id'][]): Promise; + getRooms(roomIds: IRoom['_id'][]): Promise; + canAccessRoom(room: IRoom, user: Pick): Promise; + getUserAvatarURL(user?: string): string; +}; + +/** + * Transform URLs in messages into quote attachments + */ +export class BeforeSaveJumpToMessage { + private getMessages: JumpToMessageInit['getMessages']; + + private getRooms: JumpToMessageInit['getRooms']; + + private canAccessRoom: JumpToMessageInit['canAccessRoom']; + + private getUserAvatarURL: JumpToMessageInit['getUserAvatarURL']; + + constructor(options: JumpToMessageInit) { + this.getMessages = options.getMessages; + this.getRooms = options.getRooms; + this.canAccessRoom = options.canAccessRoom; + this.getUserAvatarURL = options.getUserAvatarURL; + } + + async createAttachmentForMessageURLs({ + message, + user: currentUser, + config, + }: { + message: IMessage; + user: Pick; + config: { + chainLimit: number; + siteUrl: string; + useRealName: boolean; + }; + }): Promise { + // Quote attachments are always rebuilt. Do not keep old ones since they may not still be linked to the message + removeQuoteAttachments(message); + + // if no message is present, or the message doesn't have any URL, skip + if (!message?.urls?.length) { + return message; + } + + const linkedMessages = message.urls + .filter((item) => item.url.includes(config.siteUrl)) + .map((item) => { + const urlObj = URL.parse(item.url); + + // if the URL doesn't have query params (doesn't reference message) skip + if (!urlObj.query) { + return; + } + + const { msg: msgId } = QueryString.parse(urlObj.query); + + if (typeof msgId !== 'string') { + return; + } + + return { msgId, url: item.url }; + }) + .filter(Boolean); + + const msgs = await this.getMessages(linkedMessages.map((linkedMsg) => linkedMsg?.msgId) as string[]); + + const validMessages = msgs.filter((msg) => validateAttachmentDeepness(msg, config.chainLimit)); + + const rooms = await this.getRooms(validMessages.map((msg) => msg.rid)); + + const roomsWithPermission = + rooms && + (await Promise.all( + rooms.map(async (room) => { + if (!!message.token && isOmnichannelRoom(room) && !!room.v?.token && message.token === room.v.token) { + return room; + } + + if (currentUser && (await this.canAccessRoom(room, currentUser))) { + return room; + } + }), + )); + + const validRooms = roomsWithPermission?.filter((room) => !!room); + + const { useRealName } = config; + + const quotes = []; + + for (const item of message.urls) { + if (!item.url.includes(config.siteUrl)) { + continue; + } + + const linkedMessage = linkedMessages.find((msg) => msg?.url === item.url); + if (!linkedMessage) { + continue; + } + + const messageFromUrl = validMessages.find((msg) => msg._id === linkedMessage.msgId); + if (!messageFromUrl) { + continue; + } + + if (!validRooms?.find((room) => room?._id === messageFromUrl.rid)) { + continue; + } + + item.ignoreParse = true; + + quotes.push(createQuoteAttachment(messageFromUrl, item.url, useRealName, this.getUserAvatarURL(messageFromUrl.u.username))); + } + + if (quotes.length > 0) { + const currentAttachments = message.attachments || []; + message.attachments = [...currentAttachments, ...quotes]; + } + + return message; + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts new file mode 100644 index 0000000..088aff0 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts @@ -0,0 +1,51 @@ +import { isE2EEMessage } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { parse } from '@rocket.chat/message-parser'; + +import { getMessageMaxParseLength } from '../../../../lib/getMessageMaxParseLength'; + +type ParserConfig = { + colors?: boolean; + emoticons?: boolean; + customDomains?: string[]; + katex?: { + dollarSyntax: boolean; + parenthesisSyntax: boolean; + }; +}; + +export class BeforeSaveMarkdownParser { + constructor(private enabled: boolean = true) { + // no op + } + + async parseMarkdown({ message, config }: { message: IMessage; config: ParserConfig }): Promise { + if (!this.enabled) { + return message; + } + + if (isE2EEMessage(message)) { + return message; + } + + const messageMaxParseLength = getMessageMaxParseLength(); + if (messageMaxParseLength > 0 && message.msg && message.msg.length > messageMaxParseLength) { + delete message.md; + return message; + } + + try { + if (message.msg) { + message.md = parse(message.msg, config); + } + + if (message.attachments?.[0]?.description) { + message.attachments[0].descriptionMd = parse(message.attachments[0].description, config); + } + } catch (e) { + console.error(e); // errors logged while the parser is at experimental stage + } + + return message; + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveMentions.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveMentions.ts new file mode 100644 index 0000000..05e4355 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveMentions.ts @@ -0,0 +1,87 @@ +import { api, Team, MeteorError } from '@rocket.chat/core-services'; +import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; + +import { i18n } from '../../../lib/i18n'; +import { MentionsServer } from '../../../lib/messaging/mentions/Mentions'; +import { settings } from '../../../settings'; + +class MentionQueries { + async getUsers(usernames: string[]): Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]> { + const uniqueUsernames = [...new Set(usernames)]; + + const teams = await Team.listByNames(uniqueUsernames, { projection: { name: 1 } }); + + const users = await Users.find>( + { username: { $in: uniqueUsernames } }, + { projection: { _id: 1, username: 1, name: 1 } }, + ).toArray(); + + const taggedUsers = users.map((user) => ({ + ...user, + type: 'user' as const, + })); + + if (settings.get('Troubleshoot_Disable_Teams_Mention')) { + return taggedUsers; + } + + const taggedTeams = teams.map((team) => ({ + ...team, + type: 'team' as const, + })); + + return [...taggedUsers, ...taggedTeams]; + } + + async getUser(userId: string): Promise { + return Users.findOneById(userId); + } + + getTotalChannelMembers(rid: string): Promise { + return Subscriptions.countByRoomId(rid); + } + + getChannels(channels: string[]): Promise[]> { + return Rooms.find( + { + $and: [ + { + $or: [ + { $and: [{ $or: [{ federated: { $exists: false } }, { federated: false }], name: { $in: [...new Set(channels)] } }] }, + { federated: true, fname: { $in: [...new Set(channels)] } }, + ], + }, + ], + t: { $in: ['c', 'p'] }, + }, + { projection: { _id: 1, name: 1, fname: 1, federated: 1 } }, + ).toArray(); + } +} + +const queries = new MentionQueries(); + +export const mentionServer = new MentionsServer({ + pattern: () => settings.get('UTF8_User_Names_Validation'), + messageMaxAll: () => settings.get('Message_MaxAll'), + getUsers: async (usernames: string[]) => queries.getUsers(usernames), + getUser: async (userId: string) => queries.getUser(userId), + getTotalChannelMembers: (rid: string) => queries.getTotalChannelMembers(rid), + getChannels: (channels: string[]) => queries.getChannels(channels), + async onMaxRoomMembersExceeded({ sender, rid }: { sender: IMessage['u']; rid: string }): Promise { + // Get the language of the user for the error notification. + const { language } = (await this.getUser(sender._id)) || {}; + const msg = i18n.t('Group_mentions_disabled_x_members', { total: this.messageMaxAll(), lng: language }); + + void api.broadcast('notify.ephemeralMessage', sender._id, rid, { + msg, + }); + + // Also throw to stop propagation of 'sendMessage'. + throw new MeteorError('error-action-not-allowed', msg, { + method: 'filterATAllTag', + action: msg, + }); + }, +}); diff --git a/apps/meteor/server/services/messages/hooks/BeforeSavePreventMention.ts b/apps/meteor/server/services/messages/hooks/BeforeSavePreventMention.ts new file mode 100644 index 0000000..e0e0123 --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSavePreventMention.ts @@ -0,0 +1,38 @@ +import { Authorization, MeteorError } from '@rocket.chat/core-services'; +import type { IMessage, IUser } from '@rocket.chat/core-typings'; + +import { i18n } from '../../../lib/i18n'; + +export class BeforeSavePreventMention { + async preventMention({ + message, + user, + mention, + permission, + }: { + message: IMessage; + user: Pick; + mention: 'here' | 'all'; + permission: string; + }): Promise { + if (!message.mentions?.some(({ _id }) => _id === mention)) { + return true; + } + + // Check if the user has permissions to use @all in both global and room scopes. + if (await Authorization.hasPermission(message.u._id, permission)) { + return true; + } + + if (await Authorization.hasPermission(message.u._id, permission, message.rid)) { + return true; + } + + const action = i18n.t('Notify_all_in_this_room', { lng: user.language }); + + // Also throw to stop propagation of 'sendMessage'. + throw new MeteorError('error-action-not-allowed', `Notify ${mention} in this room not allowed`, { + action, + }); + } +} diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveSpotify.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveSpotify.ts new file mode 100644 index 0000000..8d0ceca --- /dev/null +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveSpotify.ts @@ -0,0 +1,38 @@ +import type { IMessage } from '@rocket.chat/core-typings'; + +// look for spotify syntax (e.g.: spotify:track:1q6IK1l4qpYykOaWaLJkWG) on the message and add them to the urls array +export class BeforeSaveSpotify { + async convertSpotifyLinks({ message }: { message: IMessage }): Promise { + if (!message.msg?.trim()) { + return message; + } + + const urls = Array.isArray(message.urls) ? message.urls : []; + + let changed = false; + + const msgParts = message.msg.split(/(```\w*[\n ]?[\s\S]*?```+?)|(`(?:[^`]+)`)/); + for (let index = 0; index < msgParts.length; index++) { + const part = msgParts[index]; + + if (!/(?:```(\w*)[\n ]?([\s\S]*?)```+?)|(?:`(?:[^`]+)`)/.test(part)) { + const re = /(?:^|\s)spotify:([^:\s]+):([^:\s]+)(?::([^:\s]+))?(?::(\S+))?(?:\s|$)/g; + + let match; + while ((match = re.exec(part)) != null) { + const data = match.slice(1).filter(Boolean); + const path = data.map((value) => encodeURI(value)).join('/'); + const url = `https://open.spotify.com/${path}`; + urls.push({ url, source: `spotify:${data.join(':')}`, meta: {} }); + changed = true; + } + } + } + + if (changed) { + message.urls = urls; + } + + return message; + } +} diff --git a/apps/meteor/server/services/messages/lib/oembed/providers.ts b/apps/meteor/server/services/messages/lib/oembed/providers.ts new file mode 100644 index 0000000..deaa8c4 --- /dev/null +++ b/apps/meteor/server/services/messages/lib/oembed/providers.ts @@ -0,0 +1,196 @@ +import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings'; +import { camelCase } from 'change-case'; + +import { Info } from '../../../../../app/utils/rocketchat.info'; +import { SystemLogger } from '../../../../lib/logger/system'; +import { settings } from '../../../../settings'; + +class Providers { + private providers: OEmbedProvider[]; + + constructor() { + this.providers = []; + } + + static getConsumerUrl(provider: OEmbedProvider, url: string): string | undefined { + if (!provider.endPoint) { + return; + } + const urlObj = new URL(provider.endPoint); + urlObj.searchParams.set('url', url); + + return urlObj.toString(); + } + + static getCustomHeaders(provider: OEmbedProvider): { [k: string]: string } { + return provider.getHeaderOverrides?.() || {}; + } + + registerProvider(provider: OEmbedProvider): number { + return this.providers.push(provider); + } + + getProviders(): OEmbedProvider[] { + return this.providers; + } + + getProviderForUrl(url: string): OEmbedProvider | undefined { + return this.providers?.find((provider) => { + return ( + provider.urls?.some((re) => { + return re.test(url); + }) ?? false + ); + }); + } +} + +const providers = new Providers(); + +providers.registerProvider({ + urls: [new RegExp('https?://soundcloud\\.com/\\S+')], + endPoint: 'https://soundcloud.com/oembed?format=json&maxheight=150', +}); + +providers.registerProvider({ + urls: [ + new RegExp('https?://vimeo\\.com/[^/]+'), + new RegExp('https?://vimeo\\.com/channels/[^/]+/[^/]+'), + new RegExp('https://vimeo\\.com/groups/[^/]+/videos/[^/]+'), + ], + endPoint: 'https://vimeo.com/api/oembed.json?maxheight=200', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://www\\.youtube\\.com/\\S+'), new RegExp('https?://youtu\\.be/\\S+')], + endPoint: 'https://www.youtube.com/oembed?maxheight=200', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://www\\.rdio\\.com/\\S+'), new RegExp('https?://rd\\.io/\\S+')], + endPoint: 'https://www.rdio.com/api/oembed/?format=json&maxheight=150', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://www\\.slideshare\\.net/[^/]+/[^/]+')], + endPoint: 'https://www.slideshare.net/api/oembed/2?format=json&maxheight=200', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://www\\.dailymotion\\.com/video/\\S+')], + endPoint: 'https://www.dailymotion.com/services/oembed?maxheight=200', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://(twitter|x)\\.com/[^/]+/status/\\S+')], + getHeaderOverrides: () => { + return { + 'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version} Googlebot/2.1`, + }; + }, +}); + +providers.registerProvider({ + urls: [new RegExp('https?://(play|open)\\.spotify\\.com/(track|album|playlist|show)/\\S+')], + endPoint: 'https://open.spotify.com/oembed', +}); + +providers.registerProvider({ + urls: [new RegExp('https?://www\\.loom\\.com/\\S+')], + endPoint: 'https://www.loom.com/v1/oembed?format=json', +}); + +if (process.env.TEST_MODE) { + // resolves the oembed payload from the CI mock-server instead of a real provider — see the oembed suite in tests/end-to-end/api/chat.ts + providers.registerProvider({ + urls: [new RegExp('https?://mock-server\\.dev(:\\d+)?/video/\\S+')], + endPoint: 'http://mock-server.dev:8080/oembed', + }); +} + +export const beforeGetUrlContent = (data: { + urlObj: URL; +}): { + urlObj: URL; + headerOverrides?: { [k: string]: string }; +} => { + if (!data.urlObj) { + return data; + } + + const url = data.urlObj.toString(); + const provider = providers.getProviderForUrl(url); + + if (!provider) { + return data; + } + + const consumerUrl = Providers.getConsumerUrl(provider, url); + + const headerOverrides = Providers.getCustomHeaders(provider); + if (!consumerUrl) { + return { ...data, headerOverrides }; + } + + return { ...data, headerOverrides, urlObj: new URL(consumerUrl) }; +}; + +const cleanupOembed = (data: { + url: string; + meta: OEmbedMeta; + headers: { [k: string]: string }; + content: OEmbedUrlContent; +}): { + url: string; + meta: OEmbedMeta; + headers: { [k: string]: string }; + content: OEmbedUrlContent; +} => { + if (!data?.meta) { + return data; + } + + // remove oembedHtml key from original meta + const { oembedHtml, ...meta } = data.meta; + + return { + ...data, + meta: meta as OEmbedMeta, + }; +}; + +export const afterParseUrlContent = (data: { + url: string; + meta: OEmbedMeta; + headers: { [k: string]: string }; + content: OEmbedUrlContent; +}): { + url: string; + meta: OEmbedMeta; + headers: { [k: string]: string }; + content: OEmbedUrlContent; +} => { + if (!data?.url || !data.content?.body) { + return cleanupOembed(data); + } + + const provider = providers.getProviderForUrl(data.url); + + if (!provider) { + return cleanupOembed(data); + } + + data.meta.oembedUrl = data.url; + + try { + const metas = JSON.parse(data.content.body); + Object.entries(metas).forEach(([key, value]) => { + if (value && typeof value === 'string') { + data.meta[camelCase(`oembed_${key}`)] = value; + } + }); + } catch (err) { + SystemLogger.error({ err }); + } + return data; +}; diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts new file mode 100644 index 0000000..949d3b0 --- /dev/null +++ b/apps/meteor/server/services/messages/service.ts @@ -0,0 +1,352 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { IMessageService } from '@rocket.chat/core-services'; +import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { MessageUrl, IMessage, MessageTypesValues, IUser, IRoom, AtLeast } from '@rocket.chat/core-typings'; +import { Messages, Rooms } from '@rocket.chat/models'; + +import { OEmbed } from './hooks/AfterSaveOEmbed'; +import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse'; +import { FederationMatrixInvalidConfigurationError } from '../federation/utils'; +import { FederationActions } from './hooks/BeforeFederationActions'; +import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords'; +import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC'; +import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage'; +import { BeforeSaveMarkdownParser } from './hooks/BeforeSaveMarkdownParser'; +import { mentionServer } from './hooks/BeforeSaveMentions'; +import { BeforeSavePreventMention } from './hooks/BeforeSavePreventMention'; +import { BeforeSaveSpotify } from './hooks/BeforeSaveSpotify'; +import { closeUnclosedCodeBlock } from '../../../lib/utils/closeUnclosedCodeBlock'; +import { notifyUsersOnSystemMessage } from '../../hooks/messages/notifyUsersOnMessage'; +import { SystemLogger } from '../../lib/logger/system'; +import { deleteMessage } from '../../lib/messages/deleteMessage'; +import { parseUrlsInMessage } from '../../lib/messages/parseUrlsInMessage'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { updateMessage } from '../../lib/messages/updateMessage'; +import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage'; +import { executeSetReaction } from '../../lib/messaging/reactions/setReaction'; +import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/notifyListener'; +import { shouldBreakInVersion } from '../../lib/shouldBreakInVersion'; +import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +import { settings } from '../../settings'; + +const disableMarkdownParser = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_PARSER).toLowerCase()); + +export class MessageService extends ServiceClassInternal implements IMessageService { + protected name = 'message'; + + private preventMention: BeforeSavePreventMention; + + private badWords: BeforeSaveBadWords; + + private spotify: BeforeSaveSpotify; + + private jumpToMessage: BeforeSaveJumpToMessage; + + private cannedResponse: BeforeSaveCannedResponse; + + private markdownParser: BeforeSaveMarkdownParser; + + private checkMAC: BeforeSaveCheckMAC; + + override async created() { + this.preventMention = new BeforeSavePreventMention(); + this.badWords = new BeforeSaveBadWords(); + this.spotify = new BeforeSaveSpotify(); + this.jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages(messageIds) { + return Messages.findVisibleByIds(messageIds).toArray(); + }, + getRooms(roomIds) { + return Rooms.findByIds(roomIds).toArray(); + }, + canAccessRoom(room: IRoom, user: IUser): Promise { + return Authorization.canAccessRoom(room, user); + }, + getUserAvatarURL(user?: string): string { + return (user && getUserAvatarURL(user)) || ''; + }, + }); + this.cannedResponse = new BeforeSaveCannedResponse(); + this.markdownParser = new BeforeSaveMarkdownParser(!disableMarkdownParser); + this.checkMAC = new BeforeSaveCheckMAC(); + + await this.configureBadWords(); + } + + private async configureBadWords() { + settings.watchMultiple( + ['Message_AllowBadWordsFilter', 'Message_BadWordsFilterList', 'Message_BadWordsWhitelist'], + async ([enabled, badWordsList, whiteList]) => { + if (!enabled) { + this.badWords.disable(); + return; + } + await this.badWords.configure(badWordsList as string, whiteList as string); + }, + ); + } + + async sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise { + return executeSendMessage(fromId, { rid, msg }); + } + + async saveMessageFromFederation({ + fromId, + rid, + federation_event_id, + msg, + e2e_content, + file, + files, + attachments, + thread, + ts, + }: { + fromId: string; + rid: string; + federation_event_id: string; + msg?: string; + e2e_content?: { + algorithm: 'm.megolm.v1.aes-sha2'; + ciphertext: string; + }; + file?: IMessage['file']; + files?: IMessage['files']; + attachments?: IMessage['attachments']; + thread?: { tmid: string; tshow: boolean }; + ts: Date; + }): Promise { + return executeSendMessage( + fromId, + { + rid, + msg, + ...thread, + federation: { + eventId: federation_event_id, + version: 1, + }, + ...(file && { file }), + ...(files && { files }), + ...(attachments && { attachments }), + ...(e2e_content && { + t: 'e2e', + content: e2e_content, + }), + }, + { ts }, + ); + } + + async sendMessageWithValidation(user: IUser, message: Partial, room: Partial, upsert = false): Promise { + return sendMessage(user, message, room, { upsert }); + } + + async deleteMessage(user: IUser, message: IMessage): Promise { + return deleteMessage(message, user); + } + + async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise { + return updateMessage(message, user, originalMsg, previewUrls); + } + + async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise { + return executeSetReaction(userId, reaction, messageId, shouldReact); + } + + async saveSystemMessageAndNotifyUser( + type: MessageTypesValues, + rid: string, + messageText: string, + owner: Pick, + extraData?: Partial, + ): Promise { + const createdMessage = await this.saveSystemMessage(type, rid, messageText, owner, extraData); + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('Failed to find the room.'); + } + + await notifyUsersOnSystemMessage(createdMessage, room); + + return createdMessage; + } + + async saveSystemMessage( + type: MessageTypesValues, + rid: string, + message: string, + owner: Pick, + extraData?: Partial, + ): Promise { + const { _id: userId, username, name } = owner; + if (!username) { + throw new Error('The username cannot be empty.'); + } + + const [{ insertedId }, room] = await Promise.all([ + Messages.createWithTypeRoomIdMessageUserAndUnread( + type, + rid, + message, + { _id: userId, username, name }, + settings.get('Message_Read_Receipt_Enabled'), + extraData, + ), + Rooms.findOneAndIncMsgCountById(rid, 1, { projection: { prid: 1, msgs: 1, lm: 1, sysMes: 1 } }), + ]); + + if (!insertedId) { + throw new Error('Failed to save system message.'); + } + + const createdMessage = await Messages.findOneById(insertedId); + if (!createdMessage) { + throw new Error('Failed to find the created message.'); + } + + if (Apps.self?.isLoaded()) { + void Apps.self?.triggerEvent(AppEvents.IPostSystemMessageSent, createdMessage); + } + + void notifyOnMessageChange({ id: createdMessage._id, data: createdMessage }); + void notifyOnRoomChangedById(rid); + + if (room?.prid) { + try { + await updateAndNotifyParentRoomWithParentMessage(room); + } catch (err) { + SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid }); + } + } + + return createdMessage; + } + + async beforeSave({ + message, + room, + user, + previewUrls, + parseUrls = true, + }: { + message: IMessage; + room: IRoom; + user: Pick; + previewUrls?: string[]; + parseUrls?: boolean; + }): Promise { + // TODO looks like this one was not being used (so I'll left it commented) + // await this.joinDiscussionOnMessage({ message, room, user }); + + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to send message'); + } + + message = await this.cannedResponse.replacePlaceholders({ message, room, user }); + message = await this.badWords.filterBadWords({ message }); + // TODO: Auto-close unclosed markdown code blocks for server versions below 9.0.0 + // In 9.0.0, this behavior is handled on the client side, so this block should be removed. + if (!shouldBreakInVersion('9.0.0') && message.msg) { + message = { ...message, msg: closeUnclosedCodeBlock(message.msg) }; + } + message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() }); + message = await mentionServer.execute(message); + if (parseUrls) { + message.urls = parseUrlsInMessage(message, previewUrls); + } + message = await this.spotify.convertSpotifyLinks({ message }); + message = await this.jumpToMessage.createAttachmentForMessageURLs({ + message, + user, + config: { + chainLimit: settings.get('Message_QuoteChainLimit'), + siteUrl: settings.get('Site_Url'), + useRealName: settings.get('UI_Use_Real_Name'), + }, + }); + + if (!this.isEditedOrOld(message)) { + await Promise.all([ + this.checkMAC.isWithinLimits({ message, room }), + this.preventMention.preventMention({ message, user, mention: 'all', permission: 'mention-all' }), + this.preventMention.preventMention({ message, user, mention: 'here', permission: 'mention-here' }), + ]); + } + + return message; + } + + // The actions made on this event should be asynchronous + // That means, caller should not expect to receive updated message + // after calling + async afterSave({ message }: { message: IMessage }): Promise { + await OEmbed.rocketUrlParser(message); + + // Since this will happen after the message is sent and ack on the UI + // we'll notify until after these hooks are finished + void notifyOnMessageChange({ id: message._id }); + } + + private getMarkdownConfig() { + const customDomainAutoLink = settings.get('Message_CustomDomain_AutoLink'); + const customDomains = customDomainAutoLink ? customDomainAutoLink.split(',').map((domain) => domain.trim()) : []; + + return { + colors: settings.get('HexColorPreview_Enabled'), + emoticons: true, + customDomains, + ...(settings.get('Katex_Enabled') && { + katex: { + dollarSyntax: settings.get('Katex_Dollar_Syntax'), + parenthesisSyntax: settings.get('Katex_Parenthesis_Syntax'), + }, + }), + }; + } + + private isEditedOrOld(message: IMessage): boolean { + return isEditedMessage(message) || !message.ts || Math.abs(Date.now() - message.ts.getTime()) > 60000; + } + + // joinDiscussionOnMessage + // private async joinDiscussionOnMessage({ message, room, user }: { message: IMessage; room: IRoom; user: IUser }) { + // // abort if room is not a discussion + // if (!room.prid) { + // return; + // } + + // // check if user already joined the discussion + // const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, { + // projection: { _id: 1 }, + // }); + + // if (sub) { + // return; + // } + + // await Room.join({ room, user }); + // } + + async beforeReacted(message: IMessage, room: AtLeast) { + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to react to message'); + } + } + + async beforeDelete(message: IMessage, room: IRoom) { + if (!FederationActions.shouldPerformAction(message, room)) { + throw new FederationMatrixInvalidConfigurationError('Unable to delete message'); + } + } + + async parseOEmbedUrl(url: string): Promise<{ + urlPreview: MessageUrl; + foundMeta: boolean; + }> { + return OEmbed.parseUrl(url); + } +} diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts new file mode 100644 index 0000000..b2bbf64 --- /dev/null +++ b/apps/meteor/server/services/meteor/service.ts @@ -0,0 +1,218 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { api, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { AutoUpdateRecord, IMeteor } from '@rocket.chat/core-services'; +import type { ILivechatAgent, LoginServiceConfiguration, UserStatus } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Users } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { processOnChange, serviceConfigCallbacks } from './userReactivity'; +import { configureEmailInboxes } from '../../features/EmailInbox/EmailInbox'; +import { isOutgoingIntegration } from '../../lib/integrations/lib/definition'; +import { triggerHandler } from '../../lib/integrations/lib/triggerHandler'; +import { metrics } from '../../lib/metrics'; +import notifications from '../../lib/notifications/core/lib/Notifications'; +import { notifyGuestStatusChanged } from '../../lib/omnichannel/guests'; +import { onlineAgents, monitorAgents } from '../../lib/omnichannel/stream/agentStatus'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { getURL } from '../../lib/utils/getURL'; +import { ListenersModule } from '../../modules/listeners/listeners.module'; +import { invalidate as invalidatePublicationUserCache } from '../../modules/streamer/publication-user-cache'; +import { settings } from '../../settings'; +import { use } from '../../settings/Middleware'; +import { setValue, updateValue } from '../../settings/raw'; + +const disableMsgRoundtripTracking = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_ROUNDTRIP_TRACKING).toLowerCase()); + +settings.set = use(settings.set, (context, next) => { + next(...context); + const [record] = context; + updateValue(record._id, record); +}); + +const clientVersionsStore = new Map(); + +export class MeteorService extends ServiceClassInternal implements IMeteor { + protected name = 'meteor'; + + constructor() { + super(); + + new ListenersModule(this, notifications); + + this.onEvent('user.forceLogout', (uid: string, sessionId?: string) => { + if (sessionId) { + const sessions = Meteor.server.sessions.get(sessionId); + sessions?.connectionHandle.close(); + return; + } + Meteor.server.sessions.forEach((session) => { + if (session.userId === uid) { + session.connectionHandle.close(); + } + }); + }); + + this.onEvent('watch.settings', async ({ clientAction, setting }): Promise => { + if (clientAction !== 'removed') { + settings.set(setting); + return; + } + + settings.set({ ...setting, value: undefined }); + setValue(setting._id, undefined); + }); + + this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => { + if (clientAction === 'removed') { + serviceConfigCallbacks.forEach((callbacks) => { + wrapExceptions(() => callbacks.removed?.(id)).suppress(); + }); + return; + } + + if (data) { + serviceConfigCallbacks.forEach((callbacks) => { + wrapExceptions(() => callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data)).suppress(); + }); + } + }); + + this.onEvent('watch.users', async (data) => { + invalidatePublicationUserCache(data.id); + + if (data.clientAction === 'updated' && data.diff) { + processOnChange(data.diff, data.id); + } + + if (!monitorAgents) { + return; + } + + if (data.clientAction !== 'removed' && 'diff' in data && !data.diff.status && !data.diff.statusLivechat) { + return; + } + + switch (data.clientAction) { + case 'updated': + case 'inserted': + const agent = await Users.findOneAgentById>(data.id, { + projection: { + status: 1, + statusLivechat: 1, + }, + }); + const serviceOnline = agent && agent.status !== 'offline' && agent.statusLivechat === 'available'; + + if (serviceOnline) { + return onlineAgents.add(data.id); + } + + onlineAgents.remove(data.id); + + break; + case 'removed': + onlineAgents.remove(data.id); + break; + } + }); + + this.onEvent('watch.integrations', async ({ clientAction, id, data }) => { + switch (clientAction) { + case 'inserted': + if (isOutgoingIntegration(data)) { + triggerHandler.addIntegration(data); + } + break; + case 'updated': + if (isOutgoingIntegration(data)) { + triggerHandler.removeIntegration(data); + triggerHandler.addIntegration(data); + } + break; + case 'removed': + triggerHandler.removeIntegration({ _id: id }); + break; + } + }); + + this.onEvent('watch.emailInbox', async () => { + await configureEmailInboxes(); + }); + + if (!disableMsgRoundtripTracking) { + this.onEvent('watch.messages', async ({ message }) => { + if (message?._updatedAt instanceof Date) { + const elapsedMs = Date.now() - message._updatedAt.getTime(); + metrics.messageRoundtripTime.observe(elapsedMs); + metrics.messageRoundtripTimeSeconds.observe(elapsedMs / 1000); + } + }); + } + } + + override async started(): Promise { + // Even after server startup, client versions might not be updated yet, the only way + // to make sure we can send the most up to date versions is using the publication below. + // Since it receives each document one at a time, we have to store them to be able to send + // them all when needed (i.e.: on ddp-streamer startup). + Meteor.server.publish_handlers.meteor_autoupdate_clientVersions.call({ + added(_collection: string, _id: string, version: AutoUpdateRecord) { + clientVersionsStore.set(_id, version); + void api.broadcast('meteor.clientVersionUpdated', version); + }, + changed(_collection: string, _id: string, version: AutoUpdateRecord) { + clientVersionsStore.set(_id, version); + void api.broadcast('meteor.clientVersionUpdated', version); + }, + onStop() { + // + }, + ready() { + // + }, + }); + } + + async getAutoUpdateClientVersions(): Promise> { + return Object.fromEntries(clientVersionsStore); + } + + async getLoginServiceConfiguration(): Promise { + return LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); + } + + async callMethodWithToken( + userId: string, + token: string, + method: string, + args: any[], + ): Promise<{ + result: unknown; + }> { + const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { + projection: { _id: 1 }, + }); + if (!user) { + return { + result: await Meteor.callAsync(method, ...args), + }; + } + + return { + result: await Meteor.runAsUser(userId, () => Meteor.callAsync(method, ...args)), + }; + } + + async notifyGuestStatusChanged(token: string, status: UserStatus): Promise { + return notifyGuestStatusChanged(token, status); + } + + async getURL(path: string, params: Record = {}, cloudDeepLinkUrl?: string): Promise { + return getURL(path, params, cloudDeepLinkUrl); + } + + async getMessageURLToReplyTo(roomType: string, roomId: string, messageIdToReplyTo: string): Promise { + return getURL(`${roomCoordinator.getRouteLink(roomType, { rid: roomId })}?msg=${messageIdToReplyTo}`, { full: true }); + } +} diff --git a/apps/meteor/server/services/meteor/userReactivity.ts b/apps/meteor/server/services/meteor/userReactivity.ts new file mode 100644 index 0000000..c6711f9 --- /dev/null +++ b/apps/meteor/server/services/meteor/userReactivity.ts @@ -0,0 +1,136 @@ +import { api } from '@rocket.chat/core-services'; +import { Settings, Users } from '@rocket.chat/models'; +import { getLoginExpirationInMs } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; +import { MongoInternals } from 'meteor/mongo'; + +const { _expireTokens: expireTokensOriginal } = Accounts; + +Accounts._expireTokens = async () => { + const loginExpiration: number | undefined = await Settings.getValueById('Accounts_LoginExpiration'); + const oldestValidDate = new Date(Date.now() - getLoginExpirationInMs(loginExpiration)); + + const users = await Users.find( + { + $or: [ + { 'services.resume.loginTokens.when': { $lt: oldestValidDate } }, + { 'services.resume.loginTokens.when': { $lt: +oldestValidDate } }, + ], + }, + { projection: { services: 1 } }, + ).toArray(); + + const updates: { _id: string; validTokens: any[] }[] = []; + + users.forEach(({ _id, services }) => { + const loginTokens = services?.resume?.loginTokens; + + // remove all tokens that are expired + const validTokens = loginTokens?.filter((token: any) => token.when >= oldestValidDate) || []; + + updates.push({ _id, validTokens }); + }); + + await expireTokensOriginal.call(Accounts); + + updates.forEach(({ _id, validTokens }) => { + void api.broadcast('watch.users', { + clientAction: 'updated', + id: _id, + diff: { + 'services.resume.loginTokens': validTokens, + }, + unset: {}, + }); + }); +}; + +type Callbacks = { + added(id: string, record: object): void; + changed(id: string, record: object): void; + removed(id: string): void; +}; + +export const serviceConfigCallbacks = new Set(); + +// Stores the callbacks for the disconnection reactivity bellow +const userCallbacks = new Map(); + +// Overrides the native observe changes to prevent database polling and stores the callbacks +// for the users' tokens to re-implement the reactivity based on our database listeners +const { mongo } = MongoInternals.defaultRemoteCollectionDriver(); +MongoInternals.Connection.prototype._observeChanges = async function ( + { + collectionName, + selector, + options = {}, + }: { + collectionName: string; + selector: Record; + options?: { + projection?: Record; + fields?: Record; + }; + }, + _ordered: boolean, + callbacks: Callbacks, +): Promise { + let cbs: Set<{ hashedToken: string; callbacks: Callbacks }>; + let data: { hashedToken: string; callbacks: Callbacks }; + if (callbacks?.added) { + const records = await mongo + .rawCollection(collectionName) + .find(selector, { + ...(options.projection || options.fields ? { projection: options.projection || options.fields } : {}), + }) + .toArray(); + + for (const { _id, ...fields } of records) { + callbacks.added(String(_id), fields); + } + + if (collectionName === 'users' && selector['services.resume.loginTokens.hashedToken']) { + cbs = userCallbacks.get(selector._id) || new Set(); + data = { + hashedToken: selector['services.resume.loginTokens.hashedToken'], + callbacks, + }; + + cbs.add(data); + userCallbacks.set(selector._id, cbs); + } + } + + if (collectionName === 'meteor_accounts_loginServiceConfiguration') { + serviceConfigCallbacks.add(callbacks); + } + + return { + stop(): void { + if (cbs) { + cbs.delete(data); + } + serviceConfigCallbacks.delete(callbacks); + }, + }; +}; + +// Re-implement meteor's reactivity that uses observe to disconnect sessions when the token +// associated was removed +export const processOnChange = (diff: Record, id: string): void => { + if (!diff || !('services.resume.loginTokens' in diff)) { + return; + } + const loginTokens: undefined | { hashedToken: string }[] = diff['services.resume.loginTokens']; + const tokens = loginTokens?.map(({ hashedToken }) => hashedToken); + + const cbs = userCallbacks.get(id); + if (cbs) { + [...cbs] + .filter(({ hashedToken }) => !tokens?.includes(hashedToken)) + .forEach((item) => { + item.callbacks.removed(id); + cbs.delete(item); + }); + } +}; diff --git a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts new file mode 100644 index 0000000..c85a477 --- /dev/null +++ b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts @@ -0,0 +1,71 @@ +import { Banner } from '@rocket.chat/core-services'; +import type { IBanner, BannerPlatform } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import type * as UiKit from '@rocket.chat/ui-kit'; + +import { getWorkspaceAccessToken } from '../../lib/cloud'; +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +type NpsSurveyData = { + id: string; + platform: BannerPlatform[]; + roles: string[]; + survey: UiKit.BannerView; + createdAt: Date; + startAt: Date; + expireAt: Date; +}; + +export const getAndCreateNpsSurvey = async function getNpsSurvey(npsId: string) { + const token = await getWorkspaceAccessToken(); + if (!token) { + return false; + } + + const npsEnabled = settings.get('NPS_survey_enabled'); + + if (!npsEnabled) { + return false; + } + + const npsUrl = settings.get('Nps_Url'); + + try { + const result = await fetch(`${npsUrl}/v1/surveys/${npsId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + }); + + if (!result.ok) { + SystemLogger.error({ msg: 'invalid response from the nps service:', result }); + return; + } + + const surveyData = (await result.json()) as NpsSurveyData; + + const banner: IBanner = { + _id: npsId, + platform: surveyData.platform, + createdAt: new Date(surveyData.createdAt), + expireAt: new Date(surveyData.expireAt), + startAt: new Date(surveyData.startAt), + _updatedAt: new Date(), // Needed by the IRocketChatRecord interface + roles: surveyData.roles, + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + view: surveyData.survey, + surface: 'banner', + }; + + await Banner.create(banner); + } catch (err) { + SystemLogger.error({ err }); + return false; + } +}; diff --git a/apps/meteor/server/services/nps/notification.ts b/apps/meteor/server/services/nps/notification.ts new file mode 100644 index 0000000..1e2ef02 --- /dev/null +++ b/apps/meteor/server/services/nps/notification.ts @@ -0,0 +1,54 @@ +import type { IBanner } from '@rocket.chat/core-typings'; +import { BannerPlatform } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import moment from 'moment'; + +import { i18n } from '../../lib/i18n'; +import { sendMessagesToAdmins } from '../../lib/sendMessagesToAdmins'; +import { settings } from '../../settings'; + +export const getBannerForAdmins = (expireAt: Date): Omit => { + const lng = settings.get('Language') || 'en'; + + return { + platform: [BannerPlatform.Web], + createdAt: new Date(), + expireAt, + startAt: new Date(), + roles: ['admin'], + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + _updatedAt: new Date(), + surface: 'banner', + view: { + viewId: Random.id(), + appId: '', + blocks: [ + { + type: 'section', + blockId: 'attention', + text: { + type: 'plain_text', + text: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { + date: moment(expireAt).format('YYYY-MM-DD'), + lng, + }), + emoji: false, + }, + }, + ], + }, + }; +}; + +export const notifyAdmins = (expireAt: Date) => + sendMessagesToAdmins({ + msgs: async ({ adminUser }: { adminUser: any }): Promise => ({ + msg: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { + date: moment(expireAt).format('YYYY-MM-DD'), + lng: adminUser.language, + }), + }), + }); diff --git a/apps/meteor/server/services/nps/sendNpsResults.ts b/apps/meteor/server/services/nps/sendNpsResults.ts new file mode 100644 index 0000000..261f718 --- /dev/null +++ b/apps/meteor/server/services/nps/sendNpsResults.ts @@ -0,0 +1,37 @@ +import type { INpsVote } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { getWorkspaceAccessToken } from '../../lib/cloud'; +import { SystemLogger } from '../../lib/logger/system'; +import { settings } from '../../settings'; + +type NPSResultPayload = { + total: number; + votes: Pick[]; +}; + +export const sendNpsResults = async function sendNpsResults(npsId: string, data: NPSResultPayload) { + const token = await getWorkspaceAccessToken(); + if (!token) { + return false; + } + + const npsUrl = settings.get('Nps_Url'); + + try { + return ( + await fetch(`${npsUrl}/v1/surveys/${npsId}/results`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + // SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + }) + ).json(); + } catch (err) { + SystemLogger.error({ err }); + return false; + } +}; diff --git a/apps/meteor/server/services/nps/service.ts b/apps/meteor/server/services/nps/service.ts new file mode 100644 index 0000000..bdbfeb7 --- /dev/null +++ b/apps/meteor/server/services/nps/service.ts @@ -0,0 +1,192 @@ +import { createHash } from 'node:crypto'; + +import type { INPSService, NPSVotePayload, NPSCreatePayload } from '@rocket.chat/core-services'; +import { ServiceClassInternal, Banner, NPS, Settings } from '@rocket.chat/core-services'; +import type { INpsVote, INps } from '@rocket.chat/core-typings'; +import { NPSStatus, INpsVoteStatus } from '@rocket.chat/core-typings'; +import { Nps, NpsVote } from '@rocket.chat/models'; + +import { getBannerForAdmins, notifyAdmins } from './notification'; +import { sendNpsResults } from './sendNpsResults'; +import { SystemLogger } from '../../lib/logger/system'; + +export class NPSService extends ServiceClassInternal implements INPSService { + protected name = 'nps'; + + async create(nps: NPSCreatePayload): Promise { + const npsEnabled = await Settings.get('NPS_survey_enabled'); + if (!npsEnabled) { + throw new Error('Server opted-out for NPS surveys'); + } + + const any = await Nps.findOne({}, { projection: { _id: 1 } }); + if (!any) { + if (nps.expireAt < nps.startAt || nps.expireAt < new Date()) { + throw new Error('NPS already expired'); + } + await Banner.create(getBannerForAdmins(nps.expireAt)); + + await notifyAdmins(nps.startAt); + } + + const { npsId, startAt, expireAt, createdBy } = nps; + + try { + await Nps.save({ + _id: npsId, + startAt, + expireAt, + createdBy, + status: NPSStatus.OPEN, + }); + } catch (err) { + SystemLogger.error({ msg: 'Error creating NPS', err }); + throw new Error('Error creating NPS'); + } + + return true; + } + + async sendResults(): Promise { + const npsEnabled = await Settings.get('NPS_survey_enabled'); + if (!npsEnabled) { + return; + } + + const npsSending = await Nps.getOpenExpiredAlreadySending(); + + const nps = npsSending || (await Nps.getOpenExpiredAndStartSending()); + if (!nps) { + return; + } + + const total = await NpsVote.countByNpsId(nps._id); + + const votesToSend = await NpsVote.findNotSentByNpsId(nps._id).toArray(); + + // if there is nothing to sent, check if something gone wrong + if (votesToSend.length === 0) { + // check if still has votes left to send + const totalSent = await NpsVote.countByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT); + if (totalSent === total) { + await Nps.updateStatusById(nps._id, NPSStatus.SENT); + return; + } + + // update old votes (sent 5 minutes ago or more) in 'sending' status back to 'new' + await NpsVote.updateOldSendingToNewByNpsId(nps._id); + + // try again in 5 minutes + setTimeout(() => NPS.sendResults(), 5 * 60 * 1000); + return; + } + + const today = new Date(); + + const sending = await Promise.all( + votesToSend.map(async (vote) => { + const value = await NpsVote.findOneAndUpdate( + { + _id: vote._id, + status: INpsVoteStatus.NEW, + }, + { + $set: { + status: INpsVoteStatus.SENDING, + sentAt: today, + }, + }, + { + projection: { + identifier: 1, + roles: 1, + score: 1, + comment: 1, + }, + }, + ); + return value; + }), + ); + + const votes = sending.filter(Boolean) as Pick[]; + if (votes.length > 0) { + const voteIds = votes.map(({ _id }) => _id); + + const votesWithoutIds = votes.map(({ identifier, roles, score, comment }) => ({ + identifier, + roles, + score, + comment, + })); + + const payload = { + total, + votes: votesWithoutIds, + }; + + await sendNpsResults(nps._id, payload); + + await NpsVote.updateVotesToSent(voteIds); + } + + const totalSent = await NpsVote.countByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT); + if (totalSent < total) { + // send more in five minutes + setTimeout(() => NPS.sendResults(), 5 * 60 * 1000); + return; + } + + await Nps.updateStatusById(nps._id, NPSStatus.SENT); + } + + async vote({ userId, npsId, roles, score, comment }: NPSVotePayload): Promise { + const npsEnabled = await Settings.get('NPS_survey_enabled'); + if (!npsEnabled) { + return; + } + + if (!npsId || typeof npsId !== 'string') { + throw new Error('Invalid NPS id'); + } + + const nps = await Nps.findOneById>(npsId, { + projection: { status: 1, startAt: 1, expireAt: 1 }, + }); + if (!nps) { + return; + } + + if (nps.status !== NPSStatus.OPEN) { + throw new Error('NPS not open for votes'); + } + + const today = new Date(); + if (today > nps.expireAt) { + throw new Error('NPS expired'); + } + + if (today < nps.startAt) { + throw new Error('NPS survey not started'); + } + + const identifier = createHash('sha256').update(`${userId}${npsId}`).digest('hex'); + + const result = await NpsVote.save({ + ts: new Date(), + npsId, + identifier, + roles, + score, + comment, + status: INpsVoteStatus.NEW, + }); + if (!result) { + throw new Error('Error saving NPS vote'); + } + } + + async closeOpenSurveys(): Promise { + await Nps.closeAllByStatus(NPSStatus.OPEN); + } +} diff --git a/apps/meteor/server/services/omnichannel-analytics/AgentData.ts b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts new file mode 100644 index 0000000..49717f8 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts @@ -0,0 +1,411 @@ +/* eslint-disable new-cap */ +import type { ConversationData } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import type moment from 'moment'; +import type { Filter } from 'mongodb'; + +import { secondsToHHMMSS } from '../../../lib/utils/secondsToHHMMSS'; + +type AgentOverviewValidActions = + | 'Total_conversations' + | 'Avg_chat_duration' + | 'Total_messages' + | 'Avg_first_response_time' + | 'Best_first_response_time' + | 'Avg_response_time' + | 'Avg_reaction_time'; + +export class AgentOverviewData { + constructor(private readonly roomsModel: ILivechatRoomsModel) {} + + updateMap(map: Map, key: K, value: number) { + const currentKeyValue = map.get(key); + map.set(key, currentKeyValue ? currentKeyValue + value : value); + } + + sortByValue(data: { value: string }[], inv = false) { + data.sort((a, b) => { + // sort array + if (parseFloat(a.value) > parseFloat(b.value)) { + return inv ? -1 : 1; // if inv, reverse sort + } + if (parseFloat(a.value) < parseFloat(b.value)) { + return inv ? 1 : -1; + } + return 0; + }); + } + + isActionAllowed(action: string | undefined): action is AgentOverviewValidActions { + if (!action) { + return false; + } + return [ + 'Total_conversations', + 'Avg_chat_duration', + 'Total_messages', + 'Avg_first_response_time', + 'Best_first_response_time', + 'Avg_response_time', + 'Avg_reaction_time', + ].includes(action); + } + + callAction(action: T, ...args: [moment.Moment, moment.Moment, string?, Filter?]) { + switch (action) { + case 'Total_conversations': + return this.Total_conversations(...args); + case 'Avg_chat_duration': + return this.Avg_chat_duration(...args); + case 'Total_messages': + return this.Total_messages(...args); + case 'Avg_first_response_time': + return this.Avg_first_response_time(...args); + case 'Best_first_response_time': + return this.Best_first_response_time(...args); + case 'Avg_response_time': + return this.Avg_response_time(...args); + case 'Avg_reaction_time': + return this.Avg_reaction_time(...args); + default: + throw new Error('Invalid action'); + } + } + + async Total_conversations(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + let total = 0; + const agentConversations = new Map(); // stores total conversations for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: '%_of_conversations', + }, + ], + data: [], + }; + + await this.roomsModel + .getAnalyticsMetricsBetweenDateWithMessages( + 'l', + date, + { + departmentId, + }, + {}, + extraQuery, + ) + .forEach((room) => { + if (room.servedBy) { + this.updateMap(agentConversations, room.servedBy.username, 1); + total++; + } + }); + + agentConversations.forEach((value, key) => { + // calculate percentage + const percentage = ((value / total) * 100).toFixed(2); + + data.data.push({ + name: key, + value: `${percentage}%`, + }); + }); + + this.sortByValue(data.data, true); // reverse sort array + + return data; + } + + async Avg_chat_duration(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentChatDurations = new Map(); // stores total conversations for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_chat_duration', + }, + ], + data: [], + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.chatDuration) { + if (agentChatDurations.has(servedBy.username)) { + agentChatDurations.set(servedBy.username, { + chatDuration: agentChatDurations.get(servedBy.username).chatDuration + metrics.chatDuration, + total: agentChatDurations.get(servedBy.username).total + 1, + }); + } else { + agentChatDurations.set(servedBy.username, { + chatDuration: metrics.chatDuration, + total: 1, + }); + } + } + }); + + agentChatDurations.forEach((obj, key) => { + // calculate percentage + const avg = (obj.chatDuration / obj.total).toFixed(2); + + data.data.push({ + name: key, + value: secondsToHHMMSS(avg), + }); + }); + + this.sortByValue(data.data, true); // reverse sort array + + return data; + } + + async Total_messages(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentMessages = new Map(); // stores total conversations for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Total_messages', + }, + ], + data: [], + }; + + // we don't want to count visitor messages + const extraFilter = { $lte: ['$token', null] }; + await this.roomsModel + .getAnalyticsMetricsBetweenDateWithMessages('l', date, { departmentId }, extraFilter, extraQuery) + .forEach(({ servedBy, msgs }) => { + if (servedBy) { + this.updateMap(agentMessages, servedBy.username, msgs); + } + }); + + agentMessages.forEach((value, key) => { + // calculate percentage + data.data.push({ + name: key, + value, + }); + }); + + this.sortByValue(data.data, true); // reverse sort array + + return data; + } + + async Avg_first_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentAvgRespTime = new Map(); // stores avg response time for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_first_response_time', + }, + ], + data: [], + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => { + if (responseBy && metrics && metrics.response && metrics.response.ft) { + if (agentAvgRespTime.has(responseBy.username)) { + agentAvgRespTime.set(responseBy.username, { + frt: agentAvgRespTime.get(responseBy.username).frt + metrics.response.ft, + total: agentAvgRespTime.get(responseBy.username).total + 1, + }); + } else { + agentAvgRespTime.set(responseBy.username, { + frt: metrics.response.ft, + total: 1, + }); + } + } + }); + + agentAvgRespTime.forEach((obj, key) => { + // calculate avg + const avg = obj.frt / obj.total; + + data.data.push({ + name: key, + value: secondsToHHMMSS(avg.toFixed(2)), + }); + }); + + this.sortByValue(data.data, false); // sort array + + return data; + } + + async Best_first_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentFirstRespTime = new Map(); // stores best response time for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Best_first_response_time', + }, + ], + data: [], + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => { + if (responseBy && metrics && metrics.response && metrics.response.ft) { + if (agentFirstRespTime.has(responseBy.username)) { + agentFirstRespTime.set(responseBy.username, Math.min(agentFirstRespTime.get(responseBy.username), metrics.response.ft)); + } else { + agentFirstRespTime.set(responseBy.username, metrics.response.ft); + } + } + }); + + agentFirstRespTime.forEach((value, key) => { + // calculate avg + data.data.push({ + name: key, + value: secondsToHHMMSS(value.toFixed(2)), + }); + }); + + this.sortByValue(data.data, false); // sort array + + return data; + } + + async Avg_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentAvgRespTime = new Map(); // stores avg response time for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + data: [], + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.response && metrics.response.avg) { + if (agentAvgRespTime.has(servedBy.username)) { + agentAvgRespTime.set(servedBy.username, { + avg: agentAvgRespTime.get(servedBy.username).avg + metrics.response.avg, + total: agentAvgRespTime.get(servedBy.username).total + 1, + }); + } else { + agentAvgRespTime.set(servedBy.username, { + avg: metrics.response.avg, + total: 1, + }); + } + } + }); + + agentAvgRespTime.forEach((obj, key) => { + // calculate avg + const avg = obj.avg / obj.total; + + data.data.push({ + name: key, + value: secondsToHHMMSS(avg.toFixed(2)), + }); + }); + + this.sortByValue(data.data, false); // sort array + + return data; + } + + async Avg_reaction_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { + const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + const data: ConversationData = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + data: [], + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.reaction && metrics.reaction.ft) { + if (agentAvgReactionTime.has(servedBy.username)) { + agentAvgReactionTime.set(servedBy.username, { + frt: agentAvgReactionTime.get(servedBy.username).frt + metrics.reaction.ft, + total: agentAvgReactionTime.get(servedBy.username).total + 1, + }); + } else { + agentAvgReactionTime.set(servedBy.username, { + frt: metrics.reaction.ft, + total: 1, + }); + } + } + }); + + agentAvgReactionTime.forEach((obj, key) => { + // calculate avg + const avg = obj.frt / obj.total; + + data.data.push({ + name: key, + value: secondsToHHMMSS(avg.toFixed(2)), + }); + }); + + this.sortByValue(data.data, false); // sort array + + return data; + } +} diff --git a/apps/meteor/server/services/omnichannel-analytics/ChartData.ts b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts new file mode 100644 index 0000000..505bff7 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts @@ -0,0 +1,154 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import type { Filter } from 'mongodb'; + +/* eslint-disable new-cap */ +type ChartDataValidActions = + | 'Total_conversations' + | 'Avg_chat_duration' + | 'Total_messages' + | 'Avg_first_response_time' + | 'Avg_reaction_time' + | 'Best_first_response_time' + | 'Avg_response_time'; + +type DateParam = { + gte: Date; + lte: Date; +}; + +export class ChartData { + constructor(private readonly roomsModel: ILivechatRoomsModel) {} + + isActionAllowed(action: string | undefined): action is ChartDataValidActions { + if (!action) { + return false; + } + return [ + 'Total_conversations', + 'Avg_chat_duration', + 'Total_messages', + 'Avg_first_response_time', + 'Avg_reaction_time', + 'Best_first_response_time', + 'Avg_response_time', + ].includes(action); + } + + callAction(action: T, ...args: [DateParam, string?, Filter?]) { + switch (action) { + case 'Total_conversations': + return this.Total_conversations(...args); + case 'Avg_chat_duration': + return this.Avg_chat_duration(...args); + case 'Total_messages': + return this.Total_messages(...args); + case 'Avg_first_response_time': + return this.Avg_first_response_time(...args); + case 'Avg_reaction_time': + return this.Avg_reaction_time(...args); + case 'Best_first_response_time': + return this.Best_first_response_time(...args); + case 'Avg_response_time': + return this.Avg_response_time(...args); + default: + throw new Error('Invalid action'); + } + } + + async Total_conversations(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + // @ts-expect-error - Check extraquery usage on this func + return this.roomsModel.getTotalConversationsBetweenDate('l', date, { departmentId }, extraQuery); + } + + async Avg_chat_duration(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let total = 0; + let count = 0; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.chatDuration) { + total += metrics.chatDuration; + count++; + } + }); + + const avgCD = count ? total / count : 0; + return Math.round(avgCD * 100) / 100; + } + + async Total_messages(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let total = 0; + + // we don't want to count visitor messages + const extraFilter = { $lte: ['$token', null] }; + await this.roomsModel + .getAnalyticsMetricsBetweenDateWithMessages('l', date, { departmentId }, extraFilter, extraQuery) + .forEach(({ msgs }) => { + if (msgs) { + total += msgs; + } + }); + + return total; + } + + async Avg_first_response_time(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let frt = 0; + let count = 0; + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.response?.ft) { + frt += metrics.response.ft; + count++; + } + }); + + const avgFrt = count ? frt / count : 0; + return Math.round(avgFrt * 100) / 100; + } + + async Best_first_response_time(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let maxFrt = 0; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.response?.ft) { + maxFrt = maxFrt ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft; + } + }); + + if (!maxFrt) { + maxFrt = 0; + } + + return Math.round(maxFrt * 100) / 100; + } + + async Avg_response_time(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let art = 0; + let count = 0; + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.response?.avg) { + art += metrics.response.avg; + count++; + } + }); + + const avgArt = count ? art / count : 0; + + return Math.round(avgArt * 100) / 100; + } + + async Avg_reaction_time(date: DateParam, departmentId?: string, extraQuery: Filter = {}) { + let arnt = 0; + let count = 0; + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.reaction?.ft) { + arnt += metrics.reaction.ft; + count++; + } + }); + + const avgArnt = count ? arnt / count : 0; + + return Math.round(avgArnt * 100) / 100; + } +} diff --git a/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts b/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts new file mode 100644 index 0000000..b9e9faa --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/OverviewData.ts @@ -0,0 +1,219 @@ +/* eslint-disable new-cap */ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import moment from 'moment-timezone'; +import type { Filter } from 'mongodb'; + +import { secondsToHHMMSS } from '../../../lib/utils/secondsToHHMMSS'; + +type OverviewDataValidActions = 'Conversations' | 'Productivity'; + +export class OverviewData { + constructor(private readonly roomsModel: ILivechatRoomsModel) {} + + isActionAllowed(action: string | undefined): action is OverviewDataValidActions { + if (!action) { + return false; + } + return ['Conversations', 'Productivity'].includes(action); + } + + callAction( + action: T, + ...args: [moment.Moment, moment.Moment, string?, string?, ((v: string) => string)?, Filter?] + ) { + switch (action) { + case 'Conversations': + return this.Conversations(...args); + case 'Productivity': + return this.Productivity(...args); + default: + throw new Error('Invalid action'); + } + } + + getKeyHavingMaxValue(map: Map, def: T): T { + let maxValue = 0; + let maxKey = def; // default + + map.forEach((value, key) => { + if (value > maxValue) { + maxValue = value; + maxKey = key; + } + }); + + return maxKey; + } + + sumAllMapKeys(map: Map): number { + let sum = 0; + + map.forEach((value) => { + sum += value; + }); + + return sum; + } + + getBusiestDay(map: Map>): string { + let mostMessages = -1; + let busiestDay = '-'; + + map.forEach((value, key) => { + const v = this.sumAllMapKeys(value); + if (v > mostMessages) { + mostMessages = v; + busiestDay = key; + } + }); + + return busiestDay; + } + + getAllMapKeysSize(map: Map>): number { + let size = 0; + + [...map.keys()].forEach((key) => { + size += map.get(key)?.size || 0; + }); + + return size; + } + + async Conversations( + from: moment.Moment, + to: moment.Moment, + departmentId?: string, + timezone = 'UTC', + t = (v: string): string => v, + extraQuery: Filter = {}, + ) { + const analyticsMap = new Map(); + let openConversations = 0; // open conversations + let totalMessages = 0; // total msgs + let totalConversations = 0; // Total conversations + const days = to.diff(from, 'days') + 1; // total days + + const date = { + gte: moment.tz(from, timezone).startOf('day').utc(), + lte: moment.tz(to, timezone).endOf('day').utc(), + }; + + // @ts-expect-error - Check extraquery usage on this func + const cursor = this.roomsModel.getAnalyticsBetweenDate(date, { departmentId }, extraQuery); + + for await (const room of cursor) { + totalConversations++; + + if (room.metrics && !room.metrics.chatDuration && !room.onHold) { + openConversations++; + } + const creationDay = moment.tz(room.ts, timezone).format('DD-MM-YYYY'); // @string: 01-01-2021 + const creationHour = moment.tz(room.ts, timezone).format('H'); // @int : 0, 1, ... 23 + + if (!analyticsMap.has(creationDay)) { + analyticsMap.set(creationDay, new Map()); + } + + const dayMap = analyticsMap.get(creationDay); + + if (!dayMap.has(creationHour)) { + dayMap.set(creationHour, 0); + } + + dayMap.set(creationHour, dayMap.get(creationHour) ? dayMap.get(creationHour) + room.msgs : room.msgs); + totalMessages += room.msgs; + } + + // @ts-expect-error - Check extraquery usage on this func + const onHoldConversations = await this.roomsModel.getOnHoldConversationsBetweenDate(from, to, departmentId, extraQuery); + const busiestDayFromMap = this.getBusiestDay(analyticsMap); // returns busiest day based on the number of messages sent on that day + const busiestHour = this.getKeyHavingMaxValue(analyticsMap.get(busiestDayFromMap) || new Map(), -1); // returns key with max value + const busiestTimeFrom = busiestHour >= 0 ? moment.tz(`${busiestHour}`, 'H', timezone).format('hA') : ''; // @string: 12AM, 1AM ... + const busiestTimeTo = busiestHour >= 0 ? moment.tz(`${busiestHour}`, 'H', timezone).add(1, 'hour').format('hA') : ''; // @string: 1AM, 2AM ... + const busiestDay = busiestDayFromMap !== '-' ? moment.tz(busiestDayFromMap, 'DD-MM-YYYY', timezone).format('dddd') : ''; // @string: Monday, Tuesday ... + + return [ + { + title: 'Total_conversations', + value: totalConversations, + }, + { + title: 'Open_conversations', + value: openConversations, + }, + { + title: 'On_Hold_conversations', + value: onHoldConversations, + }, + { + title: 'Total_messages', + value: totalMessages, + }, + { + title: 'Busiest_day', + value: t(busiestDay) || '-', + }, + { + title: 'Conversations_per_day', + value: (totalConversations / days).toFixed(2), + }, + { + title: 'Busiest_time', + value: `${busiestTimeFrom}${busiestTimeTo ? ` - ${busiestTimeTo}` : ''}` || '-', + }, + ]; + } + + async Productivity( + from: moment.Moment, + to: moment.Moment, + departmentId?: string, + _timezone?: string, + _t = (v: string): string => v, + extraQuery?: Filter, + ) { + let avgResponseTime = 0; + let firstResponseTime = 0; + let avgReactionTime = 0; + let count = 0; + + const date = { + gte: from.toDate(), + lte: to.toDate(), + }; + + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics?.response && metrics.reaction) { + avgResponseTime += metrics.response.avg; + firstResponseTime += metrics.response.ft; + avgReactionTime += metrics.reaction.ft; + count++; + } + }); + + if (count) { + avgResponseTime /= count; + firstResponseTime /= count; + avgReactionTime /= count; + } + + const data = [ + { + title: 'Avg_response_time', + value: secondsToHHMMSS(avgResponseTime.toFixed(2)), + }, + { + title: 'Avg_first_response_time', + value: secondsToHHMMSS(firstResponseTime.toFixed(2)), + }, + { + title: 'Avg_reaction_time', + value: secondsToHHMMSS(avgReactionTime.toFixed(2)), + }, + ]; + + return data; + } +} diff --git a/apps/meteor/server/services/omnichannel-analytics/logger.ts b/apps/meteor/server/services/omnichannel-analytics/logger.ts new file mode 100644 index 0000000..d32a3cc --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const serviceLogger = new Logger('OmnichannelAnalyticsService'); diff --git a/apps/meteor/server/services/omnichannel-analytics/service.ts b/apps/meteor/server/services/omnichannel-analytics/service.ts new file mode 100644 index 0000000..c56b376 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/service.ts @@ -0,0 +1,175 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { + AgentOverviewDataOptions, + AnalyticsOverviewDataOptions, + ChartDataOptions, + IOmnichannelAnalyticsService, +} from '@rocket.chat/core-services'; +import { LivechatRooms } from '@rocket.chat/models'; +import moment from 'moment-timezone'; + +import { AgentOverviewData } from './AgentData'; +import { ChartData } from './ChartData'; +import { OverviewData } from './OverviewData'; +import { serviceLogger } from './logger'; +import { dayIterator } from './utils'; +import { callbacks } from '../../lib/callbacks'; +import { i18n } from '../../lib/i18n'; +import { getTimezone } from '../../lib/utils/lib/getTimezone'; + +const HOURS_IN_DAY = 24; + +// TODO: move EE analytics to this service & remove callback usage +export class OmnichannelAnalyticsService extends ServiceClassInternal implements IOmnichannelAnalyticsService { + protected name = 'omnichannel-analytics'; + + readonly overview: OverviewData; + + readonly chart: ChartData; + + readonly agentOverview: AgentOverviewData; + + constructor() { + super(); + this.overview = new OverviewData(LivechatRooms); + this.chart = new ChartData(LivechatRooms); + this.agentOverview = new AgentOverviewData(LivechatRooms); + } + + async getAgentOverviewData(options: AgentOverviewDataOptions) { + const { departmentId, utcOffset, daterange: { from: fDate, to: tDate } = {}, chartOptions: { name } = {}, executedBy } = options; + const timezone = getTimezone({ utcOffset }); + const from = moment + .tz(fDate || '', 'YYYY-MM-DD', timezone) + .startOf('day') + .utc(); + const to = moment + .tz(tDate || '', 'YYYY-MM-DD', timezone) + .endOf('day') + .utc(); + + if (!moment(from).isValid() || !moment(to).isValid()) { + serviceLogger.error('AgentOverview -> Invalid dates'); + return; + } + + if (!this.agentOverview.isActionAllowed(name)) { + serviceLogger.error({ msg: 'AgentOverview action is not valid', name }); + return; + } + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy }); + return this.agentOverview.callAction(name, from, to, departmentId, extraQuery); + } + + async getAnalyticsChartData(options: ChartDataOptions) { + const { + utcOffset, + departmentId, + daterange: { from: fDate, to: tDate } = {}, + chartOptions: { name: chartLabel }, + executedBy, + } = options; + + // Check if function exists, prevent server error in case property altered + if (!this.chart.isActionAllowed(chartLabel)) { + serviceLogger.error({ msg: 'ChartData action is not valid', chartLabel }); + return; + } + + const timezone = getTimezone({ utcOffset }); + const from = moment + .tz(fDate || '', 'YYYY-MM-DD', timezone) + .startOf('day') + .utc(); + const to = moment + .tz(tDate || '', 'YYYY-MM-DD', timezone) + .endOf('day') + .utc(); + const isSameDay = from.diff(to, 'days') === 0; + + if (!moment(from).isValid() || !moment(to).isValid()) { + serviceLogger.error('ChartData -> Invalid dates'); + return; + } + + const data: { + chartLabel: string; + dataLabels: string[]; + dataPoints: number[]; + } = { + chartLabel, + dataLabels: [], + dataPoints: [], + }; + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy }); + if (isSameDay) { + // data for single day + const m = moment(from); + for await (const currentHour of Array.from({ length: HOURS_IN_DAY }, (_, i) => i)) { + const hour = parseInt(m.add(currentHour ? 1 : 0, 'hour').format('H')); + const label = { + from: moment.utc().set({ hour }).tz(timezone).format('hA'), + to: moment.utc().set({ hour }).endOf('hour').tz(timezone).format('hA'), + }; + data.dataLabels.push(`${label.from}-${label.to}`); + + const date = { + gte: m.toDate(), + lte: moment(m).endOf('hour').toDate(), + }; + + data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery)); + } + } else { + for await (const m of dayIterator(from, to)) { + data.dataLabels.push(m.format('M/D')); + + const date = { + gte: m.toDate(), + lte: moment(m).endOf('day').toDate(), + }; + + data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery)); + } + } + + return data; + } + + async getAnalyticsOverviewData(options: AnalyticsOverviewDataOptions) { + const { + departmentId, + utcOffset = 0, + language, + daterange: { from: fDate, to: tDate } = {}, + analyticsOptions: { name } = {}, + executedBy, + } = options; + const timezone = getTimezone({ utcOffset }); + const from = moment + .tz(fDate || '', 'YYYY-MM-DD', timezone) + .startOf('day') + .utc(); + const to = moment + .tz(tDate || '', 'YYYY-MM-DD', timezone) + .endOf('day') + .utc(); + + if (!moment(from).isValid() || !moment(to).isValid()) { + serviceLogger.error('OverviewData -> Invalid dates'); + return; + } + + if (!this.overview.isActionAllowed(name)) { + serviceLogger.error({ msg: 'OverviewData action is not valid', name }); + return; + } + + const t = i18n.getFixedT(language); + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy }); + return this.overview.callAction(name, from, to, departmentId, timezone, t, extraQuery); + } +} diff --git a/apps/meteor/server/services/omnichannel-analytics/utils.ts b/apps/meteor/server/services/omnichannel-analytics/utils.ts new file mode 100644 index 0000000..bd3966b --- /dev/null +++ b/apps/meteor/server/services/omnichannel-analytics/utils.ts @@ -0,0 +1,30 @@ +import moment from 'moment-timezone'; + +const HOURS_IN_DAY = 24; + +export async function* dayIterator(from: moment.Moment, to: moment.Moment): AsyncGenerator { + const m = from.clone().startOf('day'); + const f = to.clone().startOf('day'); + while (m.diff(f, 'days') <= 0) { + yield m; + m.add(1, 'days'); + } +} + +export async function* weekIterator(from: moment.Moment, to: moment.Moment, timezone: string): AsyncGenerator { + const m = moment.tz(from, timezone); + while (m.diff(to, 'weeks') <= 0) { + yield moment(m); + m.add(1, 'weeks'); + } +} + +export async function* hourIterator(day: moment.Moment): AsyncGenerator { + const m = moment(day).startOf('day'); + let passedHours = 0; + while (passedHours < HOURS_IN_DAY) { + yield moment(m); + m.add(1, 'hours'); + passedHours++; + } +} diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/index.ts b/apps/meteor/server/services/omnichannel-integrations/providers/index.ts new file mode 100644 index 0000000..7b50b75 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-integrations/providers/index.ts @@ -0,0 +1,7 @@ +import type { ISMSProviderConstructor } from '@rocket.chat/core-typings'; + +import { Twilio } from './twilio'; + +export const registerSmsProviders = (register: (n: string, s: ISMSProviderConstructor) => void): void => { + register('twilio', Twilio); +}; diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts new file mode 100644 index 0000000..f08fe24 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts @@ -0,0 +1,295 @@ +import { api } from '@rocket.chat/core-services'; +import type { ISMSProvider, ServiceData, SMSProviderResponse, SMSProviderResult } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import filesize from 'filesize'; +import twilio from 'twilio'; + +import { i18n } from '../../../lib/i18n'; +import { SystemLogger } from '../../../lib/logger/system'; +import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions'; +import { settings } from '../../../settings'; + +type TwilioData = { + From: string; + To: string; + Body: string; + NumMedia?: string; + ToCountry?: string; + ToState?: string; + ToCity?: string; + ToZip?: string; + FromCountry?: string; + FromState?: string; + FromCity?: string; + FromZip?: string; + Latitude?: string; + Longitude?: string; +} & Record<`MediaUrl${number}`, string> & + Record<`MediaContentType${number}`, string>; + +const isTwilioData = (data: unknown): data is TwilioData => { + if (typeof data !== 'object' || data === null) { + return false; + } + + const { From, To, Body } = data as Record; + + return typeof From === 'string' && typeof To === 'string' && typeof Body === 'string'; +}; + +const MAX_FILE_SIZE = 5242880; + +const notifyAgent = (userId: string | undefined, rid: string | undefined, msg: string) => + userId && + rid && + void api.broadcast('notify.ephemeralMessage', userId, rid, { + msg, + }); + +export class Twilio implements ISMSProvider { + parse(data: unknown): ServiceData { + let numMedia = 0; + + if (!isTwilioData(data)) { + throw new Error('Invalid data'); + } + + const returnData: ServiceData = { + from: data.From, + to: data.To, + body: data.Body, + + extra: { + toCountry: data.ToCountry, + toState: data.ToState, + toCity: data.ToCity, + toZip: data.ToZip, + fromCountry: data.FromCountry, + fromState: data.FromState, + fromCity: data.FromCity, + fromZip: data.FromZip, + fromLatitude: data.Latitude, + fromLongitude: data.Longitude, + }, + }; + + if (data.NumMedia) { + numMedia = parseInt(data.NumMedia, 10); + } + + if (isNaN(numMedia)) { + SystemLogger.error({ msg: 'Error parsing NumMedia', numMedia: data.NumMedia }); + return returnData; + } + + returnData.media = []; + + for (let mediaIndex = 0; mediaIndex < numMedia; mediaIndex++) { + const media = { + url: '', + contentType: '', + }; + + const mediaUrl = data[`MediaUrl${mediaIndex}`]; + const contentType = data[`MediaContentType${mediaIndex}`]; + + media.url = mediaUrl; + media.contentType = contentType; + + returnData.media.push(media); + } + + return returnData; + } + + private async getClient(rid?: string, userId?: string) { + const sid = settings.get('SMS_Twilio_Account_SID'); + const token = settings.get('SMS_Twilio_authToken'); + if (!sid || !token) { + await notifyAgent(userId, rid, i18n.t('SMS_Twilio_NotConfigured')); + return; + } + + try { + return twilio(sid, token); + } catch (error) { + await notifyAgent(userId, rid, i18n.t('SMS_Twilio_InvalidCredentials')); + SystemLogger.error({ msg: '(Twilio) ->', err: error }); + } + } + + private async validateFileUpload( + extraData: { + fileUpload?: { size: number; type: string; publicFilePath: string }; + location?: { coordinates: [number, number] }; + rid?: string; + userId?: string; + }, + lang: string, + ): Promise { + const { rid, userId, fileUpload: { size, type, publicFilePath } = { size: 0, type: 'invalid' } } = extraData; + const user = userId ? await Users.findOne({ _id: userId }, { projection: { language: 1 } }) : null; + const lng = user?.language || lang; + + let reason; + if (!settings.get('SMS_Twilio_FileUpload_Enabled')) { + reason = i18n.t('FileUpload_Disabled', { lng }); + } else if (size > MAX_FILE_SIZE) { + reason = i18n.t('File_exceeds_allowed_size_of_bytes', { + size: filesize(MAX_FILE_SIZE), + lng, + }); + } else if (!fileUploadIsValidContentType(type, settings.get('SMS_Twilio_FileUpload_MediaTypeWhiteList'))) { + reason = i18n.t('File_type_is_not_accepted', { lng }); + } else if (!publicFilePath) { + reason = i18n.t('FileUpload_NotAllowed', { lng }); + } + + // Check if JWT is set for public file uploads when protect_files is on + // If it's not, notify user upload won't go to twilio + const protectFileUploads = settings.get('FileUpload_ProtectFiles'); + const jwtEnabled = settings.get('FileUpload_Enable_json_web_token_for_files'); + const isJWTKeySet = jwtEnabled && !!settings.get('FileUpload_json_web_token_secret_for_files'); + + if (protectFileUploads && (!jwtEnabled || !isJWTKeySet)) { + reason = i18n.t('FileUpload_ProtectFilesEnabled_JWTNotSet', { lng }); + } + + if (reason) { + await notifyAgent(userId, rid, reason); + SystemLogger.error({ msg: '(Twilio) ->', reason }); + return ''; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return publicFilePath!; + } + + async send( + fromNumber: string, + toNumber: string, + message: string, + extraData?: { + fileUpload?: { size: number; type: string; publicFilePath: string }; + location?: { coordinates: [number, number] }; + rid?: string; + userId?: string; + }, + ): Promise { + const { rid, userId } = extraData || {}; + + const client = await this.getClient(rid, userId); + if (!client) { + return { + isSuccess: false, + resultMsg: 'Twilio not configured', + }; + } + + let body = message; + + let mediaUrl; + const defaultLanguage = settings.get('Language') || 'en'; + if (extraData?.fileUpload) { + const publicFilePath = await this.validateFileUpload(extraData, defaultLanguage); + if (!publicFilePath) { + return { + isSuccess: false, + resultMsg: 'File upload not allowed', + }; + } + mediaUrl = [publicFilePath]; + } + + let persistentAction; + if (extraData?.location) { + const [longitude, latitude] = extraData.location.coordinates; + persistentAction = [`geo:${latitude},${longitude}`]; + body = i18n.t('Location', { lng: defaultLanguage }); + } + + try { + const result = await client.messages.create({ + to: toNumber, + from: fromNumber, + body, + ...(mediaUrl && { mediaUrl }), + ...(persistentAction && { persistentAction }), + }); + + if (result.errorCode) { + await notifyAgent(userId, rid, result.errorMessage); + SystemLogger.error({ msg: '(Twilio) ->', errorCode: result.errorCode }); + } + + return { + isSuccess: result.status !== 'failed', + resultMsg: result.status, + }; + } catch (e: any) { + await notifyAgent(userId, rid, e.message); + return { + isSuccess: false, + resultMsg: e.message, + }; + } + } + + response(): SMSProviderResponse { + return { + headers: { + 'Content-Type': 'text/xml', + }, + body: '', + }; + } + + private getUrl(url: string, siteUrl: string): string { + const baseUrl = new URL(url); + const newUrl = new URL(siteUrl); + baseUrl.protocol = newUrl.protocol; + baseUrl.host = newUrl.host; + + return baseUrl.toString(); + } + + async isRequestFromTwilio(signature: string, request: Request, requestBody: unknown): Promise { + const authToken = settings.get('SMS_Twilio_authToken'); + let siteUrl = settings.get('Site_Url'); + if (siteUrl.endsWith('/')) { + siteUrl = siteUrl.replace(/.$/, ''); + } + + if (!authToken || !siteUrl) { + SystemLogger.error('(Twilio) -> URL or Twilio token not configured.'); + return false; + } + + const twilioUrl = request.url ? this.getUrl(request.url, siteUrl) : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; + + return twilio.validateRequest(authToken, signature, twilioUrl, requestBody as Record); + } + + async validateRequest(request: Request, requestBody: unknown): Promise { + // We're not getting original twilio requests on CI :p + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { + return true; + } + const twilioHeader = request.headers.get('x-twilio-signature') || ''; + const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader; + return this.isRequestFromTwilio(twilioSignature, request, requestBody); + } + + error(error: Error & { reason?: string }): SMSProviderResponse { + let message = ''; + if (error.reason) { + message = `${error.reason}`; + } + return { + headers: { + 'Content-Type': 'text/xml', + }, + body: `${message}`, + }; + } +} diff --git a/apps/meteor/server/services/omnichannel-integrations/service.ts b/apps/meteor/server/services/omnichannel-integrations/service.ts new file mode 100644 index 0000000..1116487 --- /dev/null +++ b/apps/meteor/server/services/omnichannel-integrations/service.ts @@ -0,0 +1,36 @@ +import { ServiceClassInternal, Settings } from '@rocket.chat/core-services'; +import type { IOmnichannelIntegrationService } from '@rocket.chat/core-services'; +import type { ISMSProviderConstructor, ISMSProvider } from '@rocket.chat/core-typings'; + +import { registerSmsProviders } from './providers'; + +export class OmnichannelIntegrationService extends ServiceClassInternal implements IOmnichannelIntegrationService { + protected name = 'omnichannel-integration'; + + private smsServices: Record = {}; + + registerSmsService(name: string, service: ISMSProviderConstructor) { + this.smsServices[name] = service; + } + + constructor() { + super(); + + registerSmsProviders(this.registerSmsService.bind(this)); + } + + async getSmsService(name: string): Promise { + if (!(await Settings.get('SMS_Enabled'))) { + throw new Error('error-sms-service-disabled'); + } + if (!this.smsServices[name.toLowerCase()]) { + throw new Error('error-sms-service-not-configured'); + } + + return new this.smsServices[name.toLowerCase()](); + } + + async isConfiguredSmsService(name: string): Promise { + return name.toLowerCase() === (await Settings.get('SMS_Service')); + } +} diff --git a/apps/meteor/server/services/omnichannel/logger.ts b/apps/meteor/server/services/omnichannel/logger.ts new file mode 100644 index 0000000..208d1e5 --- /dev/null +++ b/apps/meteor/server/services/omnichannel/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const queueLogger = new Logger('OmnichannelQueue'); diff --git a/apps/meteor/server/services/omnichannel/queue.ts b/apps/meteor/server/services/omnichannel/queue.ts new file mode 100644 index 0000000..ae253c6 --- /dev/null +++ b/apps/meteor/server/services/omnichannel/queue.ts @@ -0,0 +1,281 @@ +import { ServiceStarter } from '@rocket.chat/core-services'; +import { LivechatInquiryStatus, type InquiryWithAgentInfo, type IOmnichannelQueue } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { LivechatInquiry, LivechatRooms } from '@rocket.chat/models'; +import { tracerSpan } from '@rocket.chat/tracing'; + +import { queueLogger } from './logger'; +import { getOmniChatSortQuery } from '../../../app/livechat/lib/inquiries'; +import { metrics } from '../../lib/metrics'; +import { notifyOnLivechatInquiryChangedByRoom } from '../../lib/notifyListener'; +import { dispatchAgentDelegated } from '../../lib/omnichannel/Helper'; +import { RoutingManager } from '../../lib/omnichannel/RoutingManager'; +import { getInquirySortMechanismSetting } from '../../lib/omnichannel/settings'; +import { settings } from '../../settings'; + +const DEFAULT_RACE_TIMEOUT = 5000; + +export class OmnichannelQueue implements IOmnichannelQueue { + private serviceStarter: ServiceStarter; + + private timeoutHandler: ReturnType | null = null; + + constructor() { + this.serviceStarter = new ServiceStarter( + () => this._start(), + () => this._stop(), + ); + } + + private running = false; + + private errorDelay = 10 * 1000; // 10 seconds + + private delay() { + const timeout = settings.get('Omnichannel_queue_delay_timeout') ?? 5; + return timeout < 1 ? DEFAULT_RACE_TIMEOUT : timeout * 1000; + } + + public isRunning() { + return this.running; + } + + private async _start() { + if (this.running) { + return; + } + + const activeQueues = await this.getActiveQueues(); + queueLogger.debug({ msg: 'Active queues', count: activeQueues.length }); + this.running = true; + + queueLogger.info('Service started'); + return this.execute(); + } + + private async _stop() { + if (!this.running) { + return; + } + + await LivechatInquiry.unlockAll(); + + this.running = false; + + if (this.timeoutHandler !== null) { + clearTimeout(this.timeoutHandler); + this.timeoutHandler = null; + } + + queueLogger.info('Service stopped'); + } + + async start() { + return this.serviceStarter.start(); + } + + async stop() { + return this.serviceStarter.stop(); + } + + private async getActiveQueues() { + return (await LivechatInquiry.getDistinctQueuedDepartments({})).map(({ _id }) => _id); + } + + private async execute() { + try { + if (!this.running) { + queueLogger.debug('Queue stopped. Cannot execute'); + return; + } + + if (await License.shouldPreventAction('monthlyActiveContacts', 1)) { + queueLogger.debug('MAC limit reached. Queue wont execute'); + this.running = false; + return; + } + + // We still go 1 by 1, but we go with every queue every cycle instead of just 1 queue per cycle + // And we get tracing :) + const queues = await this.getActiveQueues(); + for await (const queue of queues) { + await tracerSpan( + 'omnichannel.queue', + { attributes: { workerTime: new Date().toISOString(), queue: queue || 'Public' }, root: true }, + () => this.checkQueue(queue), + ); + } + + this.scheduleExecution(); + } catch (e) { + queueLogger.error({ + msg: 'Queue Worker Error. Rescheduling with extra delay', + extraDelay: this.errorDelay, + err: e, + }); + this.scheduleExecution(this.errorDelay); + } + } + + private async checkQueue(queue: string | null) { + queueLogger.debug({ msg: 'Processing items for queue', queue: queue || 'Public' }); + try { + const nextInquiry = await LivechatInquiry.findNextAndLock(getOmniChatSortQuery(getInquirySortMechanismSetting()), queue); + if (!nextInquiry) { + queueLogger.debug({ msg: 'No more items for queue', queue: queue || 'Public' }); + return; + } + + const result = await this.processWaitingQueue(queue, nextInquiry as InquiryWithAgentInfo); + + if (!result) { + queueLogger.debug({ msg: 'Inquiry not taken. Unlocking and re-queueing', inquiry: nextInquiry._id }); + return await LivechatInquiry.unlock(nextInquiry._id); + } + + queueLogger.debug({ msg: 'Inquiry taken successfully. Unlocking', inquiry: nextInquiry._id }); + await LivechatInquiry.unlock(nextInquiry._id); + queueLogger.debug({ + msg: 'Inquiry processed', + inquiry: nextInquiry._id, + queue: queue || 'Public', + result, + }); + } catch (e) { + metrics.totalItemsFailedByQueue.inc({ queue: queue || 'Public' }); + queueLogger.error({ + msg: 'Error processing queue', + queue: queue || 'Public', + err: e, + }); + } + } + + private scheduleExecution(extraDelay?: number): void { + if (this.timeoutHandler !== null) { + return; + } + + this.timeoutHandler = setTimeout( + () => { + this.timeoutHandler = null; + return this.execute(); + }, + this.delay() + (extraDelay || 0), + ); + } + + async shouldStart() { + if (!settings.get('Livechat_enabled')) { + void this.stop(); + return; + } + + const routingSupportsAutoAssign = RoutingManager.getConfig()?.autoAssignAgent; + queueLogger.debug({ + msg: 'Routing method supports auto assignment', + method: settings.get('Livechat_Routing_Method'), + status: routingSupportsAutoAssign ? 'Starting' : 'Stopping', + }); + + void (routingSupportsAutoAssign ? this.start() : this.stop()); + } + + private async reconciliation(reason: 'closed' | 'taken' | 'missing', { roomId, inquiryId }: { roomId: string; inquiryId: string }) { + switch (reason) { + case 'closed': { + queueLogger.debug({ + msg: 'Room closed. Removing inquiry', + roomId, + inquiryId, + step: 'reconciliation', + }); + await LivechatInquiry.removeByRoomId(roomId); + void notifyOnLivechatInquiryChangedByRoom(roomId, 'removed'); + break; + } + case 'taken': { + queueLogger.debug({ + msg: 'Room taken. Updating inquiry status', + roomId, + inquiryId, + step: 'reconciliation', + }); + // Reconciliate served inquiries, by updating their status to taken after queue tried to pick and failed + await LivechatInquiry.takeInquiry(inquiryId); + void notifyOnLivechatInquiryChangedByRoom(roomId, 'updated', { status: LivechatInquiryStatus.TAKEN, takenAt: new Date() }); + break; + } + case 'missing': { + queueLogger.debug({ + msg: 'Room from inquiry missing. Removing inquiry', + roomId, + inquiryId, + step: 'reconciliation', + }); + await LivechatInquiry.removeByRoomId(roomId); + void notifyOnLivechatInquiryChangedByRoom(roomId, 'removed'); + break; + } + default: { + return true; + } + } + + return true; + } + + private async processWaitingQueue(department: string | null, inquiry: InquiryWithAgentInfo) { + const queue = department || 'Public'; + + queueLogger.debug({ msg: 'Processing inquiry', inquiry: inquiry._id, queue }); + const { defaultAgent } = inquiry; + + const roomFromDb = await LivechatRooms.findOneById(inquiry.rid); + + // This is a precaution to avoid taking inquiries tied to rooms that no longer exist. + // This should never happen. + if (!roomFromDb) { + metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'missing_room' }); + return this.reconciliation('missing', { roomId: inquiry.rid, inquiryId: inquiry._id }); + } + + // This is a precaution to avoid taking the same inquiry multiple times. It should not happen, but it's a safety net + if (roomFromDb.servedBy) { + metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'room_taken' }); + return this.reconciliation('taken', { roomId: inquiry.rid, inquiryId: inquiry._id }); + } + + // This is another precaution. If the room is closed, we should not take it + if (roomFromDb.closedAt) { + metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'room_closed' }); + return this.reconciliation('closed', { roomId: inquiry.rid, inquiryId: inquiry._id }); + } + + const room = await RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, roomFromDb); + + if (!room) { + queueLogger.debug({ msg: 'RoutingManager failed to delegate inquiry', inquiry: inquiry._id, queue }); + return false; + } + + if (room.servedBy) { + const { + _id: rid, + servedBy: { _id: agentId }, + } = room; + queueLogger.debug({ msg: 'Inquiry taken successfully by agent. Notifying', inquiry: inquiry._id, agentId }); + setTimeout(() => { + void dispatchAgentDelegated(rid, agentId); + }, 1000); + + const waitTimeSeconds = (Date.now() - inquiry.ts.getTime()) / 1000; + metrics.timeToQueueProcessingByQueue.observe({ queue }, waitTimeSeconds); + metrics.timeToQueueProcessingByQueueHistogram.observe({ queue }, waitTimeSeconds); + metrics.totalItemsProcessedByQueue.inc({ queue }); + return true; + } + + return false; + } +} diff --git a/apps/meteor/server/services/omnichannel/service.ts b/apps/meteor/server/services/omnichannel/service.ts new file mode 100644 index 0000000..fec46e9 --- /dev/null +++ b/apps/meteor/server/services/omnichannel/service.ts @@ -0,0 +1,59 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IOmnichannelService } from '@rocket.chat/core-services'; +import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import moment from 'moment'; + +import { OmnichannelQueue } from './queue'; +import { RoutingManager } from '../../lib/omnichannel/RoutingManager'; +import { notifyAgentStatusChanged } from '../../lib/omnichannel/omni-users'; +import { settings } from '../../settings'; + +export class OmnichannelService extends ServiceClassInternal implements IOmnichannelService { + protected name = 'omnichannel'; + + private queueWorker: IOmnichannelQueue; + + constructor() { + super(); + this.queueWorker = new OmnichannelQueue(); + } + + override async created() { + this.onEvent('presence.status', async ({ user }): Promise => { + if (!user?._id) { + return; + } + const hasRole = user.roles.some((role) => ['livechat-manager', 'livechat-monitor', 'livechat-agent'].includes(role)); + if (hasRole) { + // TODO change `Livechat.notifyAgentStatusChanged` to a service call + await notifyAgentStatusChanged(user._id, user.status); + } + }); + } + + override async started() { + settings.watchMultiple(['Livechat_enabled', 'Livechat_Routing_Method'], () => { + this.queueWorker.shouldStart(); + }); + + License.onLimitReached('monthlyActiveContacts', async (): Promise => { + this.queueWorker.isRunning() && (await this.queueWorker.stop()); + }); + + License.onValidateLicense(async (): Promise => { + RoutingManager.isMethodSet() && (await this.queueWorker.shouldStart()); + }); + + // NOTE: When there's no license or license is invalid, we fallback to CE behavior + // CE behavior means there's no MAC limit, so we start the queue + License.onInvalidateLicense(async (): Promise => { + this.queueWorker.isRunning() && (await this.queueWorker.shouldStart()); + }); + } + + async isWithinMACLimit(room: AtLeast): Promise { + const currentMonth = moment.utc().format('YYYY-MM'); + return room.v?.activity?.includes(currentMonth) || !(await License.shouldPreventAction('monthlyActiveContacts')); + } +} diff --git a/apps/meteor/server/services/push/logger.ts b/apps/meteor/server/services/push/logger.ts new file mode 100644 index 0000000..5793949 --- /dev/null +++ b/apps/meteor/server/services/push/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('Push'); diff --git a/apps/meteor/server/services/push/service.ts b/apps/meteor/server/services/push/service.ts new file mode 100644 index 0000000..986fdd0 --- /dev/null +++ b/apps/meteor/server/services/push/service.ts @@ -0,0 +1,57 @@ +import type { IPushService } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IPushToken, Optional } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; + +import { logger } from './logger'; +import { registerPushToken } from './tokenManagement/registerPushToken'; + +export class PushService extends ServiceClassInternal implements IPushService { + protected name = 'push'; + + constructor() { + super(); + + this.onEvent('watch.users', async (data) => { + // for some reason data.diff can be set to undefined + if (!('diff' in data) || !data.diff || !('services.resume.loginTokens' in data.diff)) { + return; + } + + const loginTokens = Array.isArray(data.diff['services.resume.loginTokens']) ? data.diff['services.resume.loginTokens'] : []; + + if (data.diff['services.resume.loginTokens'] === undefined || loginTokens.length === 0) { + await PushToken.removeAllByUserId(data.id); + return; + } + const tokens = loginTokens.map(({ hashedToken }: { hashedToken: string }) => hashedToken); + if (tokens.length > 0) { + await PushToken.removeByUserIdExceptTokens(data.id, tokens); + } + }); + } + + async registerPushToken( + data: Optional, '_id' | 'metadata'>, + ): Promise> { + const tokenId = await registerPushToken(data); + + const removeResult = await PushToken.removeDuplicateTokens({ + _id: tokenId, + token: data.token, + appName: data.appName, + authToken: data.authToken, + }); + if (removeResult.deletedCount) { + logger.debug({ msg: 'Removed existing app items', removed: removeResult.deletedCount }); + } + + const updatedDoc = await PushToken.findOneById>(tokenId, { projection: { authToken: 0 } }); + if (!updatedDoc) { + logger.error({ msg: 'Could not find PushToken document on mongo after successful operation', tokenId }); + throw new Error('could-not-find-token-document'); + } + + return updatedDoc; + } +} diff --git a/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts new file mode 100644 index 0000000..5d423c6 --- /dev/null +++ b/apps/meteor/server/services/push/tokenManagement/findDocumentToUpdate.ts @@ -0,0 +1,22 @@ +import type { IPushToken } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; + +export async function findDocumentToUpdate(data: Partial): Promise { + if (data._id) { + const existingDoc = await PushToken.findOneById(data._id); + if (existingDoc) { + return existingDoc; + } + } + + // VoIP tokens MUST match the id + if (data.voipToken) { + return null; + } + + if (data.token && data.appName) { + return PushToken.findOneByTokenAndAppName(data.token, data.appName); + } + + return null; +} diff --git a/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts new file mode 100644 index 0000000..0730e3f --- /dev/null +++ b/apps/meteor/server/services/push/tokenManagement/registerPushToken.ts @@ -0,0 +1,68 @@ +import type { IPushToken, Optional } from '@rocket.chat/core-typings'; +import { PushToken } from '@rocket.chat/models'; + +import { findDocumentToUpdate } from './findDocumentToUpdate'; +import { logger } from '../logger'; + +export type PushTokenData = Optional< + Pick, + '_id' | 'metadata' +>; + +function canModifyTokenDocument(doc: IPushToken, data: Partial): boolean { + // If there's no voip on either side of the operation, any doc can be updated + if (!doc.voipToken && !data.voipToken) { + return true; + } + + // VoIP tokens MUST be referenced by id, so if there's no id on the data, do not let this doc be changed + if (!data._id || data._id !== doc._id) { + return false; + } + + return true; +} + +async function insertToken(data: PushTokenData): Promise { + const insertResult = await PushToken.insertToken({ + ...(data._id && { _id: data._id }), + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + ...(data.metadata && { metadata: data.metadata }), + ...(data.voipToken && data._id && { voipToken: data.voipToken }), + }); + + const { authToken: _, ...dataWithNoAuthToken } = data; + logger.debug({ msg: 'Push token added', dataWithNoAuthToken, insertResult }); + + return insertResult.insertedId; +} + +async function updateToken(doc: IPushToken, data: PushTokenData): Promise { + const updateResult = await PushToken.refreshTokenById(doc._id, { + token: data.token, + authToken: data.authToken, + appName: data.appName, + userId: data.userId, + ...(data.voipToken && { voipToken: data.voipToken }), + }); + + if (updateResult.modifiedCount) { + const { authToken: _, ...dataWithNoAuthToken } = data; + logger.debug({ msg: 'Push token updated', dataWithNoAuthToken, updateResult }); + } + + return doc._id; +} + +export async function registerPushToken(data: PushTokenData): Promise { + const doc = await findDocumentToUpdate(data); + + if (!doc || !canModifyTokenDocument(doc, data)) { + return insertToken(data); + } + + return updateToken(doc, data); +} diff --git a/apps/meteor/server/services/room/getNameForDMs.ts b/apps/meteor/server/services/room/getNameForDMs.ts new file mode 100644 index 0000000..a8cc4b3 --- /dev/null +++ b/apps/meteor/server/services/room/getNameForDMs.ts @@ -0,0 +1,33 @@ +import type { AtLeast, IUser } from '@rocket.chat/core-typings'; + +const getFname = (members: AtLeast[]): string | undefined => { + if (members.length === 0) { + return; + } + return members.map(({ name, username }) => name || username).join(', '); +}; +const getName = (members: AtLeast[]): string | undefined => { + if (members.length === 0) { + return; + } + return members.map(({ username }) => username).join(', '); +}; + +type NameMap = { [userId: string]: { fname: string; name: string } }; + +export function getNameForDMs(members: AtLeast[]): NameMap { + const nameMap: NameMap = {}; + + const sortedMembers = members.sort((u1, u2) => (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!)); + + for (const member of sortedMembers) { + const otherMembers = sortedMembers.filter((m) => m._id !== member._id); + + nameMap[member._id] = { + fname: getFname(otherMembers) || member.name || member.username || '', + name: getName(otherMembers) || member.username || '', + }; + } + + return nameMap; +} diff --git a/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts b/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts new file mode 100644 index 0000000..082edcb --- /dev/null +++ b/apps/meteor/server/services/room/hooks/BeforeFederationActions.ts @@ -0,0 +1,30 @@ +import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings'; +import type { IRoomNativeFederated, IRoom } from '@rocket.chat/core-typings'; + +import { throwIfFederationNotEnabled } from '../../federation/utils'; + +export class FederationActions { + public static shouldPerformFederationAction(room: IRoom): room is IRoomNativeFederated { + if (!isRoomFederated(room)) { + return false; + } + + if (!isRoomNativeFederated(room)) { + throw new Error('Room is federated but its not native implementation'); + } + + return true; + } + + public static blockIfRoomFederatedButServiceNotReady(room: IRoom) { + if (!isRoomFederated(room)) { + return; + } + + if (!isRoomNativeFederated(room)) { + throw new Error('Room is federated but its not native implementation'); + } + + throwIfFederationNotEnabled(); + } +} diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts new file mode 100644 index 0000000..0689147 --- /dev/null +++ b/apps/meteor/server/services/room/service.ts @@ -0,0 +1,394 @@ +import { ServiceClassInternal, Authorization, Message, MeteorError, FederationMatrix } from '@rocket.chat/core-services'; +import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; +import { + type AtLeast, + type IRoom, + type IUser, + type MessageTypesValues, + type ISubscription, + isOmnichannelRoom, + isRoomWithJoinCode, +} from '@rocket.chat/core-typings'; +import { isUserNativeFederated } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { getNameForDMs } from './getNameForDMs'; +import { FederationActions } from './hooks/BeforeFederationActions'; +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig'; +import { readThread } from '../../lib/messaging/threads/functions'; +import { + notifyOnSubscriptionChanged, + notifyOnSubscriptionChangedById, + notifyOnSubscriptionChangedByRoomIdAndUserId, +} from '../../lib/notifyListener'; +import { readMessages } from '../../lib/readMessages'; +import { performAcceptRoomInvite } from '../../lib/rooms/acceptRoomInvite'; +import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; +import { performUserBan } from '../../lib/rooms/banUserFromRoom'; +import { createRoom } from '../../lib/rooms/createRoom'; // TODO remove this import +import { executeUnbanUserFromRoom } from '../../lib/rooms/executeUnbanUserFromRoom'; +import { removeUserFromRoom, performUserRemoval } from '../../lib/rooms/removeUserFromRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { saveRoomName } from '../../lib/rooms/settings'; +import { saveRoomTopic } from '../../lib/rooms/settings/saveRoomTopic'; +import { getDefaultSubscriptionPref } from '../../lib/utils/lib/getDefaultSubscriptionPref'; +import { getValidRoomName } from '../../lib/utils/lib/getValidRoomName'; +import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage'; +import { addRoomLeader } from '../../meteor-methods/rooms/addRoomLeader'; +import { addRoomModerator } from '../../meteor-methods/rooms/addRoomModerator'; +import { addRoomOwner } from '../../meteor-methods/rooms/addRoomOwner'; +import { removeRoomLeader } from '../../meteor-methods/rooms/removeRoomLeader'; +import { removeRoomModerator } from '../../meteor-methods/rooms/removeRoomModerator'; +import { removeRoomOwner } from '../../meteor-methods/rooms/removeRoomOwner'; + +export class RoomService extends ServiceClassInternal implements IRoomService { + protected name = 'room'; + + async updateDirectMessageRoomName( + room: IRoom, + ignoreStatusFromSubs?: string[], + updatedNames?: AtLeast[], + ): Promise { + if (room.t !== 'd') { + throw new Error('Invalid room type'); + } + const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1, status: 1 } }).toArray(); + + const uids = subs.map((sub) => sub.u._id).filter((uid) => !updatedNames?.some((user) => user._id === uid)); + + const roomMembers = await Users.findUsersByIds(uids, { projection: { name: 1, username: 1 } }).toArray(); + + const roomNames = getNameForDMs([...roomMembers, ...(updatedNames ?? [])]); + + for await (const sub of subs) { + // don't update the name if the user is invited but hasn't accepted yet + if (!ignoreStatusFromSubs?.includes(sub._id) && sub.status === 'INVITED') { + continue; + } + await Subscriptions.updateOne({ _id: sub._id }, { $set: roomNames[sub.u._id] }); + + void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, sub.u._id, 'updated'); + } + + return true; + } + + async create(uid: string, params: ICreateRoomParams): Promise { + const { type, name, members = [], readOnly, extraData, options } = params; + + const hasPermission = await Authorization.hasPermission(uid, `create-${type}`); + if (!hasPermission) { + throw new Error('no-permission'); + } + + const user = await Users.findOneById(uid); + if (!user?.username) { + throw new Error('User not found'); + } + + // TODO convert `createRoom` function to "raw" and move to here + return createRoom(type, name, user, members, false, readOnly, extraData, options) as unknown as IRoom; + } + + async createDirectMessage({ to, from }: { to: string; from: string }): Promise<{ rid: string }> { + const [toUser, fromUser] = await Promise.all([ + Users.findOneById(to, { projection: { username: 1 } }), + Users.findOneById(from, { projection: { _id: 1 } }), + ]); + + if (!toUser?.username || !fromUser) { + throw new Error('error-invalid-user'); + } + return this.createDirectMessageWithMultipleUsers([toUser.username], fromUser._id); + } + + async createDirectMessageWithMultipleUsers(members: string[], creatorId: string): Promise<{ rid: string }> { + return createDirectMessage(members, creatorId); + } + + async addMember(uid: string, rid: string): Promise { + const hasPermission = await Authorization.hasPermission(uid, 'add-user-to-joined-room', rid); + if (!hasPermission) { + throw new Error('no-permission'); + } + + return true; + } + + async addUserToRoom( + roomId: string, + user: Pick, + inviter?: Pick, + options?: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + }, + ): Promise { + return addUserToRoom(roomId, user, inviter, options); + } + + async removeUserFromRoom( + roomId: string, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, + ): Promise { + return removeUserFromRoom(roomId, user, options); + } + + async performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise { + return performUserRemoval(room, user, options); + } + + async performUserBan(room: IRoom, user: IUser, byUser: IUser): Promise { + return performUserBan(room, user, byUser); + } + + async performUserUnban(room: IRoom, user: IUser, byUser: IUser): Promise { + return executeUnbanUserFromRoom(room._id, user, byUser); + } + + async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { + return performAcceptRoomInvite(room, subscription, user); + } + + async revokeInvite(room: IRoom, user: IUser): Promise { + const subscription = await Subscriptions.removeInvitedByRoomIdAndUserId(room._id, user._id); + if (!subscription) { + return; + } + + void notifyOnSubscriptionChanged(subscription, 'removed'); + } + + async getValidRoomName(displayName: string, roomId = '', options: { allowDuplicates?: boolean } = {}): Promise { + return getValidRoomName(displayName, roomId, options); + } + + async saveRoomTopic( + roomId: string, + roomTopic: string | undefined, + user: Pick, + sendMessage = true, + ): Promise { + await saveRoomTopic(roomId, roomTopic, user, sendMessage); + } + + async getRouteLink(room: AtLeast): Promise { + return roomCoordinator.getRouteLink(room.t as string, { rid: room._id, name: room.name }); + } + + /** + * Method called by users to join a room. + */ + async join({ room, user, joinCode }: { room: IRoom; user: IUser; joinCode?: string }) { + if (!(await roomCoordinator.getRoomDirectives(room.t)?.allowMemberAction(room, RoomMemberActions.JOIN, user._id))) { + throw new MeteorError('error-not-allowed', 'Not allowed', { method: 'joinRoom' }); + } + + if (isOmnichannelRoom(room) && !room.open) { + throw new MeteorError('room-closed', 'Room is closed', { method: 'joinRoom' }); + } + + if (!(await Authorization.canAccessRoom(room, user))) { + throw new MeteorError('error-not-allowed', 'Not allowed', { method: 'joinRoom' }); + } + + if ( + FederationActions.shouldPerformFederationAction(room) && + !isUserNativeFederated(user) && + !(await FederationMatrix.canUserAccessFederation(user)) + ) { + throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation', { method: 'joinRoom' }); + } + + if (isRoomWithJoinCode(room) && !(await Authorization.hasPermission(user._id, 'join-without-join-code'))) { + if (!joinCode) { + throw new MeteorError('error-code-required', 'Code required', { method: 'joinRoom' }); + } + + const isCorrectJoinCode = !!(await Rooms.findOneByJoinCodeAndId(joinCode, room._id, { + projection: { _id: 1 }, + })); + + if (!isCorrectJoinCode) { + throw new MeteorError('error-code-invalid', 'Invalid code', { method: 'joinRoom' }); + } + } + + return addUserToRoom(room._id, user); + } + + async beforeLeave(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeUserRemoved(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeNameChange(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async beforeTopicChange(room: IRoom): Promise { + FederationActions.blockIfRoomFederatedButServiceNotReady(room); + } + + async saveRoomName(roomId: string, userId: string, name: string) { + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('User not found'); + } + await saveRoomName(roomId, name, user); + } + + public async addUserRoleRoomScoped( + fromUserId: string, + userId: string, + roomId: string, + role: 'moderator' | 'owner' | 'leader' | 'user', + ): Promise { + if (role === 'moderator') { + await addRoomModerator(fromUserId, roomId, userId); + return; + } + + if (role === 'owner') { + await addRoomOwner(fromUserId, roomId, userId); + return; + } + + if (role === 'leader') { + await addRoomLeader(fromUserId, roomId, userId); + return; + } + + const sub = await Subscriptions.findByUserIdAndRoomIds(userId, [roomId], { projection: { roles: 1 } }).next(); + if (!sub) { + throw new Error('user and room subsciption not found'); + } + + if (!sub.roles) { + return; // 'user' role essentially + } + + for await (const currentRole of sub.roles) { + if (currentRole === 'owner') { + await removeRoomOwner(fromUserId, roomId, userId); + return; + } + + if (currentRole === 'leader') { + await removeRoomLeader(fromUserId, roomId, userId); + return; + } + + if (currentRole === 'moderator') { + await removeRoomModerator(fromUserId, roomId, userId); + return; + } + } + } + + async createUserSubscription({ + room, + ts, + userToBeAdded, + inviter, + createAsHidden = false, + skipAlertSound = false, + skipSystemMessage = false, + status, + roles, + }: { + room: IRoom; + ts: Date; + userToBeAdded: IUser; + inviter?: Pick; + createAsHidden?: boolean; + skipAlertSound?: boolean; + skipSystemMessage?: boolean; + status?: 'INVITED'; + roles?: ISubscription['roles']; + }): Promise { + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, { + ts, + open: !createAsHidden, + alert: createAsHidden ? false : !skipAlertSound, + unread: 1, + userMentions: 1, + groupMentions: 0, + ...(roles && { roles }), + ...(status && { status }), + ...(inviter && { inviter: { _id: inviter._id, username: inviter.username!, ...(inviter.name && { name: inviter.name }) } }), + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(userToBeAdded), + // `name` is optional for users, so fall back to the username like `getNameForDMs` does + ...(room.t === 'd' && inviter && { fname: inviter.name || inviter.username, name: inviter.username }), + }); + + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + + if (!skipSystemMessage && userToBeAdded.username) { + if (inviter) { + const extraData = { + ts, + u: { + _id: inviter._id, + username: inviter.username, + }, + }; + if (room.teamMain) { + await Message.saveSystemMessage('added-user-to-team', room._id, userToBeAdded.username, userToBeAdded, extraData); + } else if (status === 'INVITED') { + await Message.saveSystemMessage('ui', room._id, userToBeAdded.username, userToBeAdded, { + u: { _id: inviter._id, username: inviter.username }, + }); + } else { + await Message.saveSystemMessage('au', room._id, userToBeAdded.username, userToBeAdded, extraData); + } + } else if (room.prid) { + await Message.saveSystemMessage('ut', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else if (room.teamMain) { + await Message.saveSystemMessage('ujt', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else { + await Message.saveSystemMessage('uj', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } + } + + return insertedId; + } + + async markAsRead(room: IRoom, userId: string, readThreads = false): Promise { + await readMessages(room, userId, readThreads); + } + + async readThread({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }): Promise { + await readThread({ + user, + room, + tmid, + }); + } + + async unbanAndInviteUser( + subscription: ISubscription, + inviteeUser: Pick, + inviterUser: Required> & Pick, + ): Promise { + await Subscriptions.unbanToInvitedById(subscription._id, inviterUser); + + void notifyOnSubscriptionChangedById(subscription._id, 'updated'); + + if (inviteeUser?.username) { + await Message.saveSystemMessage('ui', subscription.rid, inviteeUser.username, inviteeUser, { + u: { _id: inviterUser._id, username: inviterUser.username }, + }); + } + } +} diff --git a/apps/meteor/server/services/sauMonitor/events.ts b/apps/meteor/server/services/sauMonitor/events.ts new file mode 100644 index 0000000..134092c --- /dev/null +++ b/apps/meteor/server/services/sauMonitor/events.ts @@ -0,0 +1,16 @@ +import { Emitter } from '@rocket.chat/emitter'; + +export const sauEvents = new Emitter<{ + 'sau.accounts.login': { + userId: string; + instanceId: string; + connectionId: string; + loginToken?: string; + clientAddress: string; + userAgent: string; + host: string; + }; + 'sau.accounts.logout': { userId: string; sessionId: string }; + 'sau.socket.connected': { instanceId: string; connectionId: string }; + 'sau.socket.disconnected': { instanceId: string; connectionId: string }; +}>(); diff --git a/apps/meteor/server/services/sauMonitor/service.ts b/apps/meteor/server/services/sauMonitor/service.ts new file mode 100644 index 0000000..d4b4892 --- /dev/null +++ b/apps/meteor/server/services/sauMonitor/service.ts @@ -0,0 +1,39 @@ +// import type { Db } from 'mongodb'; + +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { ISAUMonitorService } from '@rocket.chat/core-services'; +import { getHeader } from '@rocket.chat/tools'; + +import { sauEvents } from './events'; + +export class SAUMonitorService extends ServiceClassInternal implements ISAUMonitorService { + protected name = 'sau-monitor'; + + constructor() { + super(); + + this.onEvent('accounts.login', async ({ userId, connection }) => { + sauEvents.emit('sau.accounts.login', { + userId, + instanceId: connection.instanceId, + connectionId: connection.id, + loginToken: connection.loginToken, + clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'), + userAgent: getHeader(connection.httpHeaders, 'user-agent'), + host: getHeader(connection.httpHeaders, 'host'), + }); + }); + + this.onEvent('accounts.logout', async ({ userId, connection }) => { + sauEvents.emit('sau.accounts.logout', { userId, sessionId: connection.id }); + }); + + this.onEvent('socket.disconnected', async (data) => { + sauEvents.emit('sau.socket.disconnected', { instanceId: data.instanceId, connectionId: data.id }); + }); + + this.onEvent('socket.connected', async (data) => { + sauEvents.emit('sau.socket.connected', { instanceId: data.instanceId, connectionId: data.id }); + }); + } +} diff --git a/apps/meteor/server/services/settings/service.ts b/apps/meteor/server/services/settings/service.ts new file mode 100644 index 0000000..2ba7ae3 --- /dev/null +++ b/apps/meteor/server/services/settings/service.ts @@ -0,0 +1,29 @@ +import type { ISettingsService } from '@rocket.chat/core-services'; +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { SettingValue } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +import { notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { settings } from '../../settings'; +import { verifyFingerPrint } from '../../settings/misc'; + +export class SettingsService extends ServiceClassInternal implements ISettingsService { + protected name = 'settings'; + + async get(settingId: string): Promise { + return settings.get(settingId); + } + + async set(settingId: string, value: T): Promise { + const update = await Settings.updateValueById(settingId, value); + if (update.modifiedCount) { + void notifyOnSettingChangedById(settingId); + } + } + + override async started() { + settings.change('Site_Url', () => { + void verifyFingerPrint(); + }); + } +} diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts new file mode 100644 index 0000000..f9c4f91 --- /dev/null +++ b/apps/meteor/server/services/startup.ts @@ -0,0 +1,84 @@ +import { api } from '@rocket.chat/core-services'; +import { Logger } from '@rocket.chat/logger'; +import { OmnichannelTranscript, QueueWorker } from '@rocket.chat/omnichannel-services'; +import { MongoInternals } from 'meteor/mongo'; + +import { isRunningMs } from '../lib/isRunningMs'; +import { AISearchService } from './ai-search/service'; +import { AnalyticsService } from './analytics/service'; +import { AppsEngineService } from './apps-engine/service'; +import { BannerService } from './banner/service'; +import { CalendarService } from './calendar/service'; +import { CallHistoryService } from './call-history/service'; +import { DeviceManagementService } from './device-management/service'; +import { MediaService } from './image/service'; +import { ImportService } from './import/service'; +import { LDAPService } from './ldap/service'; +import { MediaCallService } from './media-call/service'; +import { MessageService } from './messages/service'; +import { MeteorService } from './meteor/service'; +import { NPSService } from './nps/service'; +import { OmnichannelService } from './omnichannel/service'; +import { OmnichannelAnalyticsService } from './omnichannel-analytics/service'; +import { OmnichannelIntegrationService } from './omnichannel-integrations/service'; +import { PushService } from './push/service'; +import { RoomService } from './room/service'; +import { SAUMonitorService } from './sauMonitor/service'; +import { SettingsService } from './settings/service'; +import { StatusVisibilityService } from './statusVisibility/service'; +import { TeamService } from './team/service'; +import { UiKitCoreAppService } from './uikit-core-app/service'; +import { UploadService } from './upload/service'; +import { UserService } from './user/service'; +import { VideoConfService } from './video-conference/service'; +import { i18n } from '../lib/i18n'; +import { AuthorizationLivechat } from '../lib/omnichannel/roomAccessValidator.internalService'; + +export const registerServices = async (): Promise => { + const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + + api.registerService(new AppsEngineService()); + api.registerService(new AnalyticsService()); + api.registerService(new AuthorizationLivechat()); + api.registerService(new BannerService()); + api.registerService(new CalendarService()); + api.registerService(new LDAPService()); + api.registerService(new MediaService()); + api.registerService(new MeteorService()); + api.registerService(new NPSService()); + api.registerService(new RoomService()); + api.registerService(new SAUMonitorService()); + api.registerService(new OmnichannelService()); + api.registerService(new TeamService()); + api.registerService(new UiKitCoreAppService()); + api.registerService(new PushService()); + api.registerService(new DeviceManagementService()); + api.registerService(new VideoConfService()); + api.registerService(new UploadService()); + api.registerService(new MessageService()); + api.registerService(new SettingsService()); + api.registerService(new StatusVisibilityService()); + api.registerService(new OmnichannelIntegrationService()); + api.registerService(new ImportService()); + api.registerService(new OmnichannelAnalyticsService()); + api.registerService(new UserService()); + api.registerService(new MediaCallService()); + api.registerService(new CallHistoryService()); + api.registerService(new AISearchService()); + + // if the process is running in micro services mode we don't need to register services that will run separately + if (!isRunningMs()) { + const { Presence } = await import('@rocket.chat/presence'); + + const { Authorization } = await import('./authorization/service'); + + api.registerService(new Presence()); + api.registerService(new Authorization()); + + // Run EE services defined outside of the main repo + // Otherwise, monolith would ignore them :( + // Always register the service and manage licensing inside the service (tbd) + api.registerService(new QueueWorker(db, Logger)); + api.registerService(new OmnichannelTranscript(Logger, i18n)); + } +}; diff --git a/apps/meteor/server/services/statusVisibility/service.spec.ts b/apps/meteor/server/services/statusVisibility/service.spec.ts new file mode 100644 index 0000000..7b950da --- /dev/null +++ b/apps/meteor/server/services/statusVisibility/service.spec.ts @@ -0,0 +1,156 @@ +import { StatusVisibilityService } from './service'; + +const getSetting = jest.fn(); +const broadcast = jest.fn(); +const findPresenceUsersByIds = jest.fn(); +const findWithStatusVisibilityConfig = jest.fn(); + +jest.mock('@rocket.chat/core-services', () => ({ + api: { broadcast: (...args: unknown[]) => broadcast(...args) }, + Settings: { get: (key: string) => getSetting(key) }, + ServiceClassInternal: class { + onSettingChanged() { + // no-op + } + }, +})); +jest.mock('@rocket.chat/models', () => ({ + Users: { + findPresenceUsersByIds: (...args: unknown[]) => findPresenceUsersByIds(...args), + findWithStatusVisibilityConfig: (...args: unknown[]) => findWithStatusVisibilityConfig(...args), + }, +})); + +const cursor = (users: object[]) => ({ toArray: async () => users }); +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const blocking = (id: string, blocked: string[]) => ({ _id: id, settings: { preferences: { statusVisibilityDenied: blocked } } }); + +describe('status visibility service', () => { + let service: StatusVisibilityService; + + beforeEach(async () => { + jest.resetAllMocks(); + getSetting.mockReturnValue(true); + broadcast.mockResolvedValue(undefined); + findPresenceUsersByIds.mockReturnValue(cursor([])); + findWithStatusVisibilityConfig.mockReturnValue(cursor([])); + service = new StatusVisibilityService(); + await service.refresh(); + }); + + it('hides a target from the viewers they blocked, and only from them', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(); + + expect(await service.getHiddenFrom('bruno')).toEqual(['ana']); + expect(await service.getHiddenFrom('carla')).toEqual([]); + }); + + it('never hides a target from themselves nor from an anonymous viewer', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['ana', 'bruno'])])); + await service.refresh(); + + expect(await service.getHiddenFrom('ana')).toEqual([]); + expect(await service.getHiddenFrom(null)).toEqual([]); + }); + + it('drops an entry when a targeted refresh finds the block list gone', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(['ana']); + expect(await service.getHiddenFrom('bruno')).toEqual(['ana']); + + findWithStatusVisibilityConfig.mockReturnValue(cursor([])); + findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }])); + const affected = await service.refresh(['ana']); + + expect(await service.getHiddenFrom('bruno')).toEqual([]); + expect(affected.map(({ _id }) => _id)).toEqual(['ana']); + }); + + it('flags users with an active block list, only while the feature is on', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(); + + expect(await service.hasRestrictions('ana')).toBe(true); + expect(await service.hasRestrictions('carla')).toBe(false); + + getSetting.mockReturnValue(false); + await service.refresh(); + + expect(await service.hasRestrictions('ana')).toBe(false); + }); + + it('reports the viewers on both sides of a block list change', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(); + + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['carla'])])); + await service.invalidate(['ana']); + + const [, payload] = broadcast.mock.calls.at(-1) as [string, { viewers?: string[] }]; + + expect(payload.viewers?.sort()).toEqual(['bruno', 'carla']); + }); + + it('leaves the viewers unscoped when the whole mirror is invalidated', async () => { + await service.invalidate(); + + const [, payload] = broadcast.mock.calls.at(-1) as [string, { viewers?: string[] }]; + + expect(payload.viewers).toBeUndefined(); + }); + + it('clears everything when the setting is off and reports who became visible', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(); + + getSetting.mockReturnValue(false); + findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }])); + const affected = await service.refresh(); + + expect(affected.map(({ _id }) => _id)).toEqual(['ana']); + expect(await service.getHiddenFrom('bruno')).toEqual([]); + }); + + it('reports every user whose visibility may have changed, configured before or after', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])])); + await service.refresh(); + + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('carla', ['bruno'])])); + findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }])); + const affected = await service.refresh(); + + expect(affected.map(({ _id }) => _id).sort()).toEqual(['ana', 'carla']); + }); + + it('applies overlapping refreshes in order, so an older read never wins', async () => { + const reads: Array<(users: object[]) => void> = []; + findWithStatusVisibilityConfig.mockImplementation(() => ({ + toArray: () => new Promise((resolve) => reads.push(resolve as (users: object[]) => void)), + })); + + const stale = service.refresh(); + const fresh = service.refresh(['ana']); + + await flush(); + expect(reads).toHaveLength(1); + reads[0]([]); + await stale; + + await flush(); + expect(reads).toHaveLength(2); + reads[1]([blocking('ana', ['bruno'])]); + await fresh; + + expect(await service.getHiddenFrom('bruno')).toEqual(['ana']); + }); + + it('lists who hid their status from a given viewer', async () => { + findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno']), blocking('carla', ['bruno', 'diego'])])); + await service.refresh(); + + expect((await service.getHiddenFrom('bruno')).sort()).toEqual(['ana', 'carla']); + expect(await service.getHiddenFrom('diego')).toEqual(['carla']); + expect(await service.getHiddenFrom('elena')).toEqual([]); + }); +}); diff --git a/apps/meteor/server/services/statusVisibility/service.ts b/apps/meteor/server/services/statusVisibility/service.ts new file mode 100644 index 0000000..c1e7fba --- /dev/null +++ b/apps/meteor/server/services/statusVisibility/service.ts @@ -0,0 +1,113 @@ +import type { IStatusVisibilityService } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal, Settings } from '@rocket.chat/core-services'; +import type { IUser, UserPresence } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; + +const logger = new Logger('StatusVisibility'); + +const PRESENCE_FIELDS = { username: 1, status: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1 } as const; + +export class StatusVisibilityService extends ServiceClassInternal implements IStatusVisibilityService { + protected name = 'status-visibility'; + + private enabled = false; + + private hiddenFromByUser = new Map>(); + + private lock: Promise = Promise.resolve(); + + constructor() { + super(); + + this.onSettingChanged('Accounts_StatusVisibility_Enabled', async () => { + await this.invalidate(); + }); + } + + override async started(): Promise { + await this.refresh(); + } + + async getHiddenFrom(viewerId: IUser['_id'] | null | undefined): Promise { + if (!this.enabled || !viewerId) { + return []; + } + + return [...this.hiddenFromByUser] + .filter(([targetId, viewers]) => targetId !== viewerId && viewers.has(viewerId)) + .map(([targetId]) => targetId); + } + + async hasRestrictions(targetId: IUser['_id']): Promise { + return this.enabled && this.hiddenFromByUser.has(targetId); + } + + async getRestrictedUsers(): Promise { + return [...this.hiddenFromByUser.keys()]; + } + + async refresh(targets?: IUser['_id'][]): Promise { + const result = this.lock.then(() => this.rebuildHiddenUsers(targets)); + this.lock = result.catch(() => undefined); + return result; + } + + private async rebuildHiddenUsers(targets?: IUser['_id'][]): Promise { + this.enabled = (await Settings.get('Accounts_StatusVisibility_Enabled')) === true; + + if (!this.enabled) { + const previous = [...this.hiddenFromByUser.keys()]; + this.hiddenFromByUser.clear(); + return previous.length ? Users.findPresenceUsersByIds(previous, { projection: PRESENCE_FIELDS }).toArray() : []; + } + + const previous = targets ?? [...this.hiddenFromByUser.keys()]; + + const users = await Users.findWithStatusVisibilityConfig(targets).toArray(); + + if (targets) { + targets.forEach((uid) => this.hiddenFromByUser.delete(uid)); + } else { + this.hiddenFromByUser.clear(); + } + + for (const { _id, settings: userSettings } of users) { + const viewers = userSettings?.preferences?.statusVisibilityDenied; + + if (viewers?.length) { + this.hiddenFromByUser.set(_id, new Set(viewers)); + } + } + + const dropped = previous.filter((uid) => !this.hiddenFromByUser.has(uid)); + + if (dropped.length) { + users.push(...(await Users.findPresenceUsersByIds(dropped, { projection: PRESENCE_FIELDS }).toArray())); + } + + return users; + } + + private viewersOf(targets: IUser['_id'][]): IUser['_id'][] { + const viewers = new Set(); + + for (const target of targets) { + this.hiddenFromByUser.get(target)?.forEach((viewer) => viewers.add(viewer)); + } + + return [...viewers]; + } + + async invalidate(targets?: IUser['_id'][]): Promise { + const previousViewers = targets && this.viewersOf(targets); + const affected = await this.refresh(targets); + const viewers = previousViewers && [...new Set([...previousViewers, ...this.viewersOf(targets)])]; + + void api + .broadcast('presence.invalidateVisibility', { targets, viewers }) + .catch((err) => logger.error({ msg: 'Status visibility invalidation failed', err, targets })); + + return affected; + } +} diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts new file mode 100644 index 0000000..df3e742 --- /dev/null +++ b/apps/meteor/server/services/team/service.ts @@ -0,0 +1,1110 @@ +import { Room, Authorization, Message, ServiceClassInternal, api } from '@rocket.chat/core-services'; +import type { + IListRoomsFilter, + ITeamAutocompleteResult, + ITeamCreateParams, + ITeamInfo, + ITeamMemberInfo, + ITeamMemberParams, + ITeamService, + ITeamUpdateData, +} from '@rocket.chat/core-services'; +import { TeamType } from '@rocket.chat/core-typings'; +import type { + IRoom, + IUser, + ISubscription, + IPaginationOptions, + IQueryOptions, + IRecordsWithTotal, + ITeam, + ITeamMember, + ITeamStats, + AtLeast, +} from '@rocket.chat/core-typings'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { Team, Rooms, Subscriptions, Users, TeamMember } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/tools'; +import type { Document, FindOptions, Filter } from 'mongodb'; + +import { notifyOnSubscriptionChangedByRoomIdAndUserId, notifyOnRoomChangedById } from '../../lib/notifyListener'; +import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; +import { getSubscribedRoomsForUserWithDetails } from '../../lib/rooms/getRoomsWithSingleOwner'; +import { removeUserFromRoom } from '../../lib/rooms/removeUserFromRoom'; +import { saveRoomName } from '../../lib/rooms/settings'; +import { saveRoomType } from '../../lib/rooms/settings/saveRoomType'; +import { omitStatusVisibilityConfig } from '../../lib/statusVisibility/redactStatus'; +import { checkUsernameAvailability } from '../../lib/users/checkUsernameAvailability'; +import { settings } from '../../settings'; + +export class TeamService extends ServiceClassInternal implements ITeamService { + protected name = 'team'; + + async create(uid: string, { team, room = { name: team.name, extraData: {} }, members, owner }: ITeamCreateParams): Promise { + if (!(await checkUsernameAvailability(team.name, 'room'))) { + throw new Error('team-name-already-exists'); + } + + const existingRoom = await Rooms.findOneByName(team.name, { projection: { _id: 1 } }); + if (existingRoom && existingRoom._id !== room.id) { + throw new Error('room-name-already-exists'); + } + + const createdBy = await Users.findOneById>(uid, { + projection: { username: 1 }, + }); + if (!createdBy) { + throw new Error('invalid-user'); + } + + // TODO add validations to `data` and `members` + + const membersResult = + !members || !Array.isArray(members) || members.length === 0 + ? [] + : await Users.findActiveByIdsOrUsernames(members, { + projection: { username: 1 }, + }).toArray(); + const memberUsernames = membersResult.map(({ username }) => username); + const memberIds = membersResult.map(({ _id }) => _id); + + const teamData = { + ...team, + createdAt: new Date(), + createdBy, + _updatedAt: new Date(), // TODO how to avoid having to do this? + roomId: '', // this will be populated at the end + }; + + try { + const roomId = + room.id || + ( + await Room.create(owner || uid, { + ...room, + type: team.type === TeamType.PRIVATE ? 'p' : 'c', + name: team.name, + members: memberUsernames as string[], + extraData: { + ...room.extraData, + }, + }) + )._id; + + const result = await Team.insertOne(teamData); + const teamId = result.insertedId; + // the same uid can be passed at 3 positions: owner, member list or via caller + // if the owner is present, remove it from the members list + // if the owner is not present, remove the caller from the members list + const excludeFromMembers = owner ? [owner] : [uid]; + + // filter empty strings and falsy values from members list + const membersList: Array> = + memberIds + ?.filter(Boolean) + .filter((memberId) => !excludeFromMembers.includes(memberId)) + .map((memberId) => ({ + teamId, + userId: memberId, + createdAt: new Date(), + createdBy, + })) || []; + + membersList.push({ + teamId, + userId: owner || uid, + roles: ['owner'], + createdAt: new Date(), + createdBy, + }); + + await TeamMember.insertMany(membersList); + + await Rooms.setTeamMainById(roomId, teamId); + await Team.updateMainRoomForTeam(teamId, roomId); + teamData.roomId = roomId; + + if (room.id) { + await Message.saveSystemMessage('user-converted-to-team', roomId, team.name, createdBy); + } + + void notifyOnRoomChangedById(roomId, 'inserted'); + + return { + _id: teamId, + ...teamData, + }; + } catch (e) { + throw new Error('error-team-creation'); + } + } + + async update(uid: string, teamId: string, updateData: ITeamUpdateData): Promise { + const team = await Team.findOneById>(teamId, { + projection: { roomId: 1 }, + }); + if (!team) { + return; + } + + const user = await Users.findOneById(uid); + if (!user) { + return; + } + + const { name, type, updateRoom = true } = updateData; + + if (updateRoom && name) { + await saveRoomName(team.roomId, name, user); + } + + if (updateRoom && typeof type !== 'undefined') { + await saveRoomType(team.roomId, type === TeamType.PRIVATE ? 'p' : 'c', user); + } + + await Team.updateNameAndType(teamId, updateData); + } + + async findBySubscribedUserIds(userId: string, callerId?: string): Promise { + const unfilteredTeams = await TeamMember.findByUserId>(userId, { + projection: { teamId: 1, roles: 1 }, + }).toArray(); + const unfilteredTeamIds = unfilteredTeams.map(({ teamId }) => teamId); + + let teamIds = unfilteredTeamIds; + + if (callerId) { + const publicTeams = await Team.findByIdsAndType>(unfilteredTeamIds, TeamType.PUBLIC, { + projection: { _id: 1 }, + }).toArray(); + const publicTeamIds = publicTeams.map(({ _id }) => _id); + const publicTeamIdsSet = new Set(publicTeamIds); + const privateTeamIds = unfilteredTeamIds.filter((teamId) => !publicTeamIdsSet.has(teamId)); + + const privateTeams = await TeamMember.findByUserIdAndTeamIds(callerId, privateTeamIds, { + projection: { teamId: 1 }, + }).toArray(); + const visibleTeamIds = new Set(privateTeams.map(({ teamId }) => teamId).concat(publicTeamIds)); + teamIds = unfilteredTeamIds.filter((teamId) => visibleTeamIds.has(teamId)); + } + + const ownedTeams = new Set(unfilteredTeams.filter(({ roles = [] }) => roles.includes('owner')).map(({ teamId }) => teamId)); + + const results = await Team.findByIds(teamIds).toArray(); + return results.map((team) => ({ + ...team, + isOwner: ownedTeams.has(team._id), + })); + } + + search(userId: string, term: string | RegExp): Promise; + + search(userId: string, term: string | RegExp, options: FindOptions): Promise; + + search

(userId: string, term: string | RegExp, options: FindOptions

): Promise; + + async search

( + userId: string, + term: string | RegExp, + options?: FindOptions | FindOptions

, + ): Promise { + if (typeof term === 'string') { + term = new RegExp(`^${escapeRegExp(term)}`, 'i'); + } + + const userTeams = await TeamMember.findByUserId>(userId, { + projection: { teamId: 1 }, + }).toArray(); + const teamIds = userTeams.map(({ teamId }) => teamId); + + return Team.findByNameAndTeamIds(term, teamIds, options || {}).toArray(); + } + + async list( + uid: string, + { offset, count }: IPaginationOptions = { offset: 0, count: 50 }, + { sort, query }: IQueryOptions = { sort: {} }, + ): Promise> { + const userTeams = await TeamMember.findByUserId>(uid, { + projection: { teamId: 1 }, + }).toArray(); + + const teamIds = userTeams.map(({ teamId }) => teamId); + if (teamIds.length === 0) { + return { + total: 0, + records: [], + }; + } + + const { cursor, totalCount } = Team.findByIdsPaginated( + teamIds, + { + ...(sort && { sort }), + limit: count, + skip: offset, + }, + query, + ); + + const [records, total] = await Promise.all([cursor.toArray(), totalCount]); + + const results: ITeamInfo[] = []; + for await (const record of records) { + results.push({ + ...record, + rooms: await Rooms.countByTeamId(record._id), + numberOfUsers: await TeamMember.countByTeamId(record._id), + }); + } + + return { + total, + records: results, + }; + } + + async listAll({ offset, count }: IPaginationOptions = { offset: 0, count: 50 }): Promise> { + const { cursor, totalCount } = Team.findPaginated( + {}, + { + limit: count, + skip: offset, + }, + ); + + const [records, total] = await Promise.all([cursor.toArray(), totalCount]); + + const results: ITeamInfo[] = []; + for await (const record of records) { + results.push({ + ...record, + rooms: await Rooms.countByTeamId(record._id), + numberOfUsers: await TeamMember.countByTeamId(record._id), + }); + } + + return { + total, + records: results, + }; + } + + listByNames(names: Array): Promise; + + listByNames(names: Array, options: FindOptions): Promise; + + listByNames

(names: Array, options: FindOptions

): Promise; + + async listByNames

( + names: Array, + options?: FindOptions | FindOptions

, + ): Promise { + if (options === undefined) { + return Team.findByNames(names).toArray(); + } + return Team.findByNames(names, options).toArray(); + } + + async listByIds(ids: Array, options?: FindOptions): Promise { + return Team.findByIds(ids, options).toArray(); + } + + async addRooms(uid: string, rooms: Array, teamId: string): Promise> { + if (!teamId) { + throw new Error('missing-teamId'); + } + if (!rooms) { + throw new Error('missing-rooms'); + } + if (!uid) { + throw new Error('missing-userId'); + } + + const team = await Team.findOneById>(teamId, { projection: { _id: 1, roomId: 1 } }); + if (!team) { + throw new Error('invalid-team'); + } + + // at this point, we already checked for the permission + // so we just need to check if the user can see the room + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('invalid-user'); + } + + const rids = rooms.filter((rid) => rid && typeof rid === 'string'); + const validRooms = await Rooms.findManyByRoomIds(rids).toArray(); + if (validRooms.length < rids.length) { + throw new Error('invalid-room'); + } + + // validate access for every room first + for await (const room of validRooms) { + const canSeeRoom = await Authorization.canAccessRoom(room, user); + if (!canSeeRoom) { + throw new Error('invalid-room'); + } + } + + for await (const room of validRooms) { + if (room.teamId) { + throw new Error('room-already-on-team'); + } + + if (!(await Subscriptions.isUserInRole(uid, 'owner', room._id))) { + throw new Error('error-no-owner-channel'); + } + + if (room.t === 'c') { + await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', user); + } + + room.teamId = teamId; + } + + await Rooms.setTeamByIds(rids, teamId); + void notifyOnRoomChangedById(rids, 'updated'); + return validRooms; + } + + async removeRoom(uid: string, rid: string, teamId: string, canRemoveAnyRoom = false): Promise { + if (!teamId) { + throw new Error('missing-teamId'); + } + if (!rid) { + throw new Error('missing-roomId'); + } + if (!uid) { + throw new Error('missing-userId'); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('invalid-room'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('invalid-user'); + } + if (!canRemoveAnyRoom) { + const canSeeRoom = await Authorization.canAccessRoom(room, user); + if (!canSeeRoom) { + throw new Error('invalid-room'); + } + } + + const team = await Team.findOneById>(teamId, { projection: { _id: 1, roomId: 1 } }); + if (!team) { + throw new Error('invalid-team'); + } + + if (room.teamId !== teamId) { + throw new Error('room-not-on-that-team'); + } + + delete room.teamId; + delete room.teamDefault; + await Rooms.unsetTeamById(room._id); + void notifyOnRoomChangedById(room._id, 'updated'); + + if (room.t === 'c') { + await Message.saveSystemMessage('user-removed-room-from-team', team.roomId, room.name || '', user); + } + + return { + ...room, + }; + } + + async unsetTeamIdOfRooms(user: AtLeast, team: AtLeast): Promise { + if (!team) { + throw new Error('invalid-team'); + } + + if (!user) { + throw new Error('invalid-user'); + } + + const room = await Rooms.findOneById>(team.roomId, { projection: { name: 1 } }); + if (room) { + await Message.saveSystemMessage('user-converted-to-channel', team.roomId, room.name || '', user); + } + + await Rooms.unsetTeamId(team._id); + } + + async updateRoom(uid: string, rid: string, isDefault: boolean, canUpdateAnyRoom = false): Promise { + if (!rid) { + throw new Error('missing-roomId'); + } + if (!uid) { + throw new Error('missing-userId'); + } + + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('invalid-room'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('invalid-user'); + } + if (!canUpdateAnyRoom) { + const canSeeRoom = await Authorization.canAccessRoom(room, user); + if (!canSeeRoom) { + throw new Error('invalid-room'); + } + } + + if (!room.teamId) { + throw new Error('room-not-on-team'); + } + room.teamDefault = isDefault; + await Rooms.setTeamDefaultById(rid, isDefault); + + if (isDefault) { + const maxNumberOfAutoJoinMembers = settings.get('API_User_Limit'); + const teamMembers = await this.members( + uid, + room.teamId, + true, + { offset: 0, count: maxNumberOfAutoJoinMembers }, + // We should not get the owner of the room, since he is already a member + { _id: { $ne: room.u._id } }, + ); + + for await (const m of teamMembers.records) { + if (await addUserToRoom(room._id, m.user, user)) { + room.usersCount++; + } + } + } + + return { + ...room, + }; + } + + listTeamsBySubscriberUserId(uid: string): Promise; + + listTeamsBySubscriberUserId(uid: string, options: FindOptions): Promise; + + listTeamsBySubscriberUserId

(uid: string, options: FindOptions

): Promise; + + listTeamsBySubscriberUserId

( + uid: string, + options?: FindOptions | FindOptions

, + ): Promise { + if (options) { + return TeamMember.findByUserId(uid, options).toArray(); + } + return TeamMember.findByUserId(uid).toArray(); + } + + async listRooms( + uid: string, + teamId: string, + filter: IListRoomsFilter, + { offset: skip, count: limit }: IPaginationOptions = { offset: 0, count: 50 }, + ): Promise> { + if (!teamId) { + throw new Error('missing-teamId'); + } + const team = await Team.findOneById>(teamId, { + projection: { _id: 1, type: 1 }, + }); + if (!team) { + throw new Error('invalid-team'); + } + + const { getAllRooms, allowPrivateTeam, name, isDefault } = filter; + + const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId); + if (team.type === TeamType.PRIVATE && !allowPrivateTeam && !isMember) { + throw new Error('user-not-on-private-team'); + } + + if (getAllRooms) { + const { cursor, totalCount } = Rooms.findPaginatedByTeamIdContainingNameAndDefault(teamId, name, isDefault, undefined, { + skip, + limit, + }); + const [records, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + total, + records, + }; + } + + const user = await Users.findOneById<{ __rooms: string[] }>(uid, { + projection: { __rooms: 1 }, + }); + const userRooms = user?.__rooms; + + const { cursor, totalCount } = Rooms.findPaginatedByTeamIdContainingNameAndDefault(teamId, name, isDefault, userRooms, { skip, limit }); + + const [records, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + total, + records, + }; + } + + async listRoomsOfUser( + uid: string, + teamId: string, + userId: string, + allowPrivateTeam: boolean, + showCanDeleteOnly: boolean, + { offset: skip, count: limit }: IPaginationOptions = { offset: 0, count: 50 }, + ): Promise> { + if (!teamId) { + throw new Error('missing-teamId'); + } + const team = await Team.findOneById(teamId, {}); + if (!team) { + throw new Error('invalid-team'); + } + const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId); + if (team.type === TeamType.PRIVATE && !allowPrivateTeam && !isMember) { + throw new Error('user-not-on-private-team'); + } + + const teamRooms: (Pick & { + userCanDelete?: boolean; + })[] = await Rooms.findByTeamId(teamId, { + projection: { _id: 1, t: 1 }, + }).toArray(); + + let teamRoomIds: string[]; + + if (showCanDeleteOnly) { + const canDeleteTeamChannel = await Authorization.hasPermission(userId, 'delete-team-channel', team.roomId); + const canDeleteTeamGroup = await Authorization.hasPermission(userId, 'delete-team-group', team.roomId); + for await (const room of teamRooms) { + const isPublicRoom = room.t === 'c'; + const canDeleteTeamRoom = isPublicRoom ? canDeleteTeamChannel : canDeleteTeamGroup; + const canDeleteRoom = + canDeleteTeamRoom && (await Authorization.hasPermission(userId, isPublicRoom ? 'delete-c' : 'delete-p', room._id)); + room.userCanDelete = canDeleteRoom; + } + + teamRoomIds = teamRooms.filter((room) => (room.t === 'c' || room.t === 'p') && room.userCanDelete).map((room) => room._id); + } else { + teamRoomIds = teamRooms.filter((room) => room.t === 'p' || room.t === 'c').map((room) => room._id); + } + + const subscriptionsCursor = Subscriptions.findByUserIdAndRoomIds(userId, teamRoomIds); + const subscriptionRoomIds = (await subscriptionsCursor.toArray()).map((subscription) => subscription.rid); + const { cursor, totalCount } = Rooms.findPaginatedByIds(subscriptionRoomIds, { + skip, + limit, + }); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + const roomData = await getSubscribedRoomsForUserWithDetails(userId, false, teamRoomIds); + const records = []; + + for (const room of rooms) { + const roomInfo = roomData.find((data) => data.rid === room._id); + if (!roomInfo) { + continue; + } + room.isLastOwner = roomInfo.userIsLastOwner; + records.push(room); + } + + return { + total, + records, + }; + } + + async getMatchingTeamRooms(teamId: string, rids: Array): Promise> { + if (!teamId) { + throw new Error('missing-teamId'); + } + + if (!rids) { + return []; + } + + if (!Array.isArray(rids)) { + throw new Error('invalid-list-of-rooms'); + } + + const rooms = await Rooms.findByTeamIdAndRoomsId(teamId, rids, { + projection: { _id: 1 }, + }).toArray(); + return rooms.map(({ _id }: { _id: string }) => _id); + } + + async getMembersByTeamIds(teamIds: Array, options: FindOptions): Promise> { + return TeamMember.findByTeamIds(teamIds, options).toArray(); + } + + async members( + uid: string, + teamId: string, + canSeeAll: boolean, + { offset, count }: IPaginationOptions = { offset: 0, count: 50 }, + query: Filter = {}, + ): Promise> { + const isMember = await TeamMember.findOneByUserIdAndTeamId(uid, teamId); + if (!isMember && !canSeeAll) { + return { + total: 0, + records: [], + }; + } + + const users = await Users.findActive({ ...query }).toArray(); + const userIds = users.map((m) => m._id); + const { cursor, totalCount } = TeamMember.findPaginatedMembersInfoByTeamId(teamId, count, offset, { + userId: { $in: userIds }, + }); + + const results: ITeamMemberInfo[] = []; + for await (const record of cursor) { + const user = users.find((u) => u._id === record.userId); + if (!user) { + continue; + } + + results.push({ + user: { + _id: user._id, + username: user.username, + name: user.name, + status: user.status, + settings: omitStatusVisibilityConfig(user.settings), + }, + roles: record.roles, + createdBy: { + _id: record.createdBy._id, + username: record.createdBy.username, + }, + createdAt: record.createdAt, + }); + } + + return { + total: await totalCount, + records: results, + }; + } + + async addMembers(uid: string, teamId: string, members: Array): Promise { + const createdBy = (await Users.findOneById(uid, { projection: { username: 1 } })) as Pick; + if (!createdBy) { + throw new Error('invalid-user'); + } + + const team = await Team.findOneById>(teamId, { + projection: { roomId: 1 }, + }); + if (!team) { + throw new Error('team-does-not-exist'); + } + + for await (const member of members) { + const user = (await Users.findOneById(member.userId, { projection: { username: 1 } })) as Pick; + await addUserToRoom(team.roomId, user, createdBy, { skipSystemMessage: false }); + + if (member.roles) { + const isRoleAddedToTeam = await this.addRolesToMember(teamId, member.userId, member.roles); + const isRoleAddedToSubscription = await this.addRolesToSubscription(team.roomId, member.userId, member.roles); + if (settings.get('UI_DisplayRoles') && isRoleAddedToTeam && isRoleAddedToSubscription) { + member.roles.forEach((role) => { + void api.broadcast('user.roleUpdate', { + type: 'added', + _id: role, + u: { + _id: user._id, + username: user.username, + }, + scope: team.roomId, + }); + }); + } + } + } + } + + async updateMember(teamId: string, member: ITeamMemberParams): Promise { + if (!member.userId) { + throw new Error('invalid-user'); + } + + const memberUpdate: Partial = { + roles: member.roles ? member.roles : [], + }; + + const team = await Team.findOneById(teamId); + + if (!team) { + throw new Error('invalid-team'); + } + + const responses = await Promise.all([ + TeamMember.updateOneByUserIdAndTeamId(member.userId, teamId, memberUpdate), + Subscriptions.updateOne( + { + 'rid': team?.roomId, + 'u._id': member.userId, + }, + { + $set: memberUpdate, + }, + ), + ]); + + if (responses[1].modifiedCount) { + void notifyOnSubscriptionChangedByRoomIdAndUserId(team.roomId, member.userId); + } + } + + async removeMember(teamId: string, userId: string): Promise { + await TeamMember.deleteByUserIdAndTeamId(userId, teamId); + } + + async removeMembers(uid: string, teamId: string, members: Array): Promise { + const team = await Team.findOneById>(teamId, { + projection: { _id: 1, roomId: 1 }, + }); + if (!team) { + throw new Error('team-does-not-exist'); + } + + const membersIds = members.map((m) => m.userId); + const usersToRemove = await Users.findByIds(membersIds).toArray(); + const byUser = await Users.findOneById(uid); + + for await (const member of members) { + if (!member.userId) { + throw new Error('invalid-user'); + } + + const existingMember = await TeamMember.findOneByUserIdAndTeamId(member.userId, team._id); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(team.roomId, member.userId); + + if (!existingMember && !subscription) { + throw new Error('member-does-not-exist'); + } + + if (existingMember) { + if (existingMember.roles?.includes('owner')) { + const totalOwners = await TeamMember.countByTeamIdAndRole(team._id, 'owner'); + if (totalOwners === 1) { + throw new Error('last-owner-can-not-be-removed'); + } + } + + await TeamMember.removeById(existingMember._id); + } + + const removedUser = usersToRemove.find((u) => u._id === (existingMember || member).userId); + if (removedUser) { + await removeUserFromRoom( + team.roomId, + removedUser, + uid !== member.userId && byUser + ? { + byUser, + } + : undefined, + ); + } + } + + return true; + } + + async insertMemberOnTeams(userId: string, teamIds: Array): Promise { + const inviter = { _id: 'rocket.cat', username: 'rocket.cat' }; + + await Promise.all( + teamIds.map(async (teamId) => { + const team = await Team.findOneById(teamId); + const user = await Users.findOneById(userId); + + if (!team || !user) { + return; + } + + await addUserToRoom(team.roomId, user, inviter, { skipSystemMessage: false }); + }), + ); + } + + async removeMemberFromTeams(userId: string, teamIds: Array): Promise { + await Promise.all( + teamIds.map(async (teamId) => { + const team = await Team.findOneById(teamId); + const user = await Users.findOneById(userId); + + if (!team || !user) { + return; + } + + await removeUserFromRoom(team.roomId, user); + }), + ); + } + + async removeAllMembersFromTeam(teamId: string): Promise { + if (!teamId) { + throw new Error('missing-teamId'); + } + + await TeamMember.deleteByTeamId(teamId); + } + + async addMember(inviter: Pick, userId: string, teamId: string): Promise { + const isAlreadyAMember = await TeamMember.findOneByUserIdAndTeamId(userId, teamId, { + projection: { _id: 1 }, + }); + + if (isAlreadyAMember) { + return false; + } + + let inviterData = {} as Pick; + if (inviter) { + inviterData = { _id: inviter._id, username: inviter.username }; + } + + await TeamMember.createOneByTeamIdAndUserId(teamId, userId, inviterData); + + await this.addMembersToDefaultRooms(inviter, teamId, [{ userId }]); + + return true; + } + + getAllPublicTeams(): Promise; + + getAllPublicTeams(options: FindOptions): Promise; + + async getAllPublicTeams(options?: FindOptions): Promise { + return options ? Team.findByType(TeamType.PUBLIC, options).toArray() : Team.findByType(TeamType.PUBLIC).toArray(); + } + + async getOneById(teamId: string, options?: FindOptions): Promise { + if (options === undefined) { + return Team.findOneById(teamId); + } + return Team.findOneById(teamId, options); + } + + async getOneByName(teamName: string | RegExp): Promise; + + async getOneByName(teamName: string | RegExp, options: FindOptions): Promise; + + async getOneByName(teamName: string | RegExp, options?: FindOptions): Promise { + if (!options) { + return Team.findOneByName(teamName); + } + return Team.findOneByName(teamName, options); + } + + async getOneByMainRoomId(roomId: string): Promise | null> { + return Team.findOneByMainRoomId>(roomId, { + projection: { _id: 1 }, + }); + } + + async getOneByRoomId(roomId: string, options?: FindOptions): Promise { + const room = await Rooms.findOneById(roomId, { projection: { teamId: 1 } }); + + if (!room) { + throw new Error('invalid-room'); + } + + if (!room.teamId) { + throw new Error('room-not-on-team'); + } + + return Team.findOneById(room.teamId, options); + } + + async addRolesToMember(teamId: string, userId: string, roles: Array): Promise { + const { matchedCount } = await TeamMember.updateRolesByTeamIdAndUserId(teamId, userId, roles); + + return matchedCount > 0; + } + + async addRolesToSubscription(roomId: string, userId: string, roles: Array): Promise { + const { matchedCount } = await Subscriptions.addRolesByUserId(userId, roles, roomId); + + return matchedCount > 0; + } + + async removeRolesFromMember(teamId: string, userId: string, roles: Array): Promise { + const { matchedCount } = await TeamMember.removeRolesByTeamIdAndUserId(teamId, userId, roles); + + return matchedCount > 0; + } + + async getInfoByName(teamName: string): Promise | null> { + return Team.findOne>( + { + name: teamName, + }, + { projection: { usernames: 0 } }, + ); + } + + async getInfoById(teamId: string): Promise | null> { + return Team.findOne>( + { + _id: teamId, + }, + { projection: { usernames: 0 } }, + ); + } + + async addMembersToDefaultRooms( + inviter: Pick, + teamId: string, + members: Array>, + ): Promise { + const defaultRooms = await Rooms.findDefaultRoomsForTeam(teamId).toArray(); + const users = await Users.findActiveByIds(members.map((member) => member.userId)).toArray(); + + for (const room of defaultRooms) { + // at this point, users are already part of the team so we won't check for membership + await Promise.all(users.map((user) => addUserToRoom(room._id, user, inviter, { skipSystemMessage: false }))); + } + } + + async deleteById(teamId: string): Promise { + return !!(await Team.deleteOneById(teamId)); + } + + async deleteByName(teamName: string): Promise { + return !!(await Team.deleteOneByName(teamName)); + } + + async getStatistics(): Promise { + return { + totalTeams: await Team.estimatedDocumentCount(), + totalRoomsInsideTeams: await Rooms.countRoomsInsideTeams(), + totalDefaultRoomsInsideTeams: await Rooms.countRoomsInsideTeams(true), + }; + } + + async autocomplete(uid: string, name: string): Promise { + const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); + + const subscriptions = await Subscriptions.find>({ 'u._id': uid }, { projection: { rid: 1 } }).toArray(); + const subscriptionIds = subscriptions.map(({ rid }) => rid); + + const rooms = await Rooms.find( + { + teamMain: true, + $and: [ + { + $or: [ + { + name: nameRegex, + }, + { + fname: nameRegex, + }, + ], + }, + { + $or: [ + { + t: 'c', + }, + { + _id: { $in: subscriptionIds }, + }, + ], + }, + ], + }, + { + projection: { + fname: 1, + teamId: 1, + name: 1, + t: 1, + avatarETag: 1, + }, + limit: 10, + sort: { + name: 1, + fname: 1, + }, + }, + ).toArray(); + + return rooms; + } + + private getParentRoom(team: AtLeast): Promise | null> { + return Rooms.findOneById>(team.roomId, { + projection: { name: 1, fname: 1, t: 1 }, + }); + } + + async getRoomInfo( + room: AtLeast, + ): Promise<{ team?: Pick; parentRoom?: Pick }> { + if (!room.teamId) { + return {}; + } + + const team = await Team.findOneById(room.teamId, { projection: { _id: 1, name: 1, roomId: 1, type: 1 } }); + if (!team) { + return {}; + } + + if (room.teamMain) { + return { team }; + } + + const parentRoom = await this.getParentRoom(team); + return { team, ...(parentRoom && { parentRoom }) }; + } + + // Returns the list of rooms and discussions a user has access to inside a team + // Rooms returned are a composition of the rooms the user is in + public rooms + discussions from the main room (if any) + async listChildren( + userId: string, + team: AtLeast, + filter?: string, + type?: 'channels' | 'discussions', + sort?: Record, + skip = 0, + limit = 10, + ): Promise<{ total: number; data: IRoom[] }> { + const mainRoom = await Rooms.findOneById(team.roomId, { projection: { _id: 1 } }); + if (!mainRoom) { + throw new Error('error-invalid-team-no-main-room'); + } + + const isMember = await TeamMember.findOneByUserIdAndTeamId(userId, team._id, { + projection: { _id: 1 }, + }); + + if (!isMember) { + throw new Error('error-invalid-team-not-a-member'); + } + + const [{ totalCount: [{ count: total }] = [], paginatedResults: data = [] }] = + (await Rooms.findChildrenOfTeam(team._id, mainRoom._id, userId, filter, type, { skip, limit, sort }).toArray()) || []; + + return { + total, + data, + }; + } +} diff --git a/apps/meteor/server/services/uikit-core-app/service.ts b/apps/meteor/server/services/uikit-core-app/service.ts new file mode 100644 index 0000000..7f07eb0 --- /dev/null +++ b/apps/meteor/server/services/uikit-core-app/service.ts @@ -0,0 +1,61 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { + IUiKitCoreApp, + IUiKitCoreAppService, + UiKitCoreAppBlockActionPayload, + UiKitCoreAppViewClosedPayload, + UiKitCoreAppViewSubmitPayload, +} from '@rocket.chat/core-services'; + +const registeredApps = new Map(); + +const getAppModule = (appId: string) => { + const module = registeredApps.get(appId); + + if (typeof module === 'undefined') { + throw new Error('invalid service name'); + } + + return module; +}; + +export const registerCoreApp = (module: IUiKitCoreApp): void => { + registeredApps.set(module.appId, module); +}; + +export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitCoreAppService { + protected name = 'uikit-core-app'; + + async isRegistered(appId: string): Promise { + return registeredApps.has(appId); + } + + async blockAction(payload: UiKitCoreAppBlockActionPayload) { + const { appId } = payload; + + const service = getAppModule(appId); + if (!service) return undefined; + + return service.blockAction?.(payload); + } + + async viewClosed(payload: UiKitCoreAppViewClosedPayload) { + const { appId } = payload; + + const service = getAppModule(appId); + if (!service) return undefined; + + return service.viewClosed?.(payload); + } + + async viewSubmit(payload: UiKitCoreAppViewSubmitPayload) { + const { appId } = payload; + + const service = getAppModule(appId); + if (!service) { + return; + } + + return service.viewSubmit?.(payload); + } +} diff --git a/apps/meteor/server/services/upload/service.ts b/apps/meteor/server/services/upload/service.ts new file mode 100644 index 0000000..c2dd29f --- /dev/null +++ b/apps/meteor/server/services/upload/service.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs'; +import type Stream from 'node:stream'; + +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; +import { api, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services'; +import type { IUpload, IUser, FilesAndAttachments, IMessage, AtLeast } from '@rocket.chat/core-typings'; +import { isFileAttachment } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Uploads, Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import sharp from 'sharp'; + +import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { canDeleteMessageAsync } from '../../lib/authorization/canDeleteMessage'; +import { i18n } from '../../lib/i18n'; +import { FileUpload } from '../../lib/media/file-upload'; +import { updateMessage } from '../../lib/messages/updateMessage'; +import { setUserAvatar } from '../../lib/users/setUserAvatar'; +import { parseFileIntoMessageAttachments, sendFileMessage } from '../../meteor-methods/messages/sendFileMessage'; +import { sendFileLivechatMessage } from '../../meteor-methods/omnichannel/sendFileLivechatMessage'; +import { UploadFS } from '../../ufs'; + +const logger = new Logger('UploadService'); + +export class UploadService extends ServiceClassInternal implements IUploadService { + protected name = 'upload'; + + async uploadFile({ buffer, details, federation }: IUploadFileParams): Promise { + const fileStore = FileUpload.getStore('Uploads'); + return fileStore.insert({ ...details, ...(federation && { federation }) }, buffer); + } + + async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise { + return sendFileMessage(userId, { roomId, file, msgData: message }); + } + + async sendFileLivechatMessage({ roomId, visitorToken, file, message }: ISendFileLivechatMessageParams): Promise { + return sendFileLivechatMessage({ roomId, visitorToken, file, msgData: message }); + } + + async getFileBuffer({ file }: { file: IUpload }): Promise { + const buffer = await FileUpload.getBuffer(file); + + if (!(buffer instanceof Buffer)) { + throw new Error('Unknown error'); + } + return buffer; + } + + async extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }> { + return FileUpload.extractMetadata(file); + } + + async parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise { + return parseFileIntoMessageAttachments(file, roomId, user); + } + + async canDeleteFile( + user: Pick, + file: Pick, + msg: IMessage | null, + ): Promise { + if (msg) { + return canDeleteMessageAsync(user, msg); + } + + if (!file.userId || !file.rid) { + return false; + } + + // If file is not confirmed and was sent by the same user + if (file.expiresAt && file.userId === user._id) { + return canAccessRoomIdAsync(file.rid, user._id); + } + + // It's a confirmed file but it has no message, so use data from the file to run message delete permission checks + const msgForValidation = { u: { _id: file.userId }, ts: file.uploadedAt, rid: file.rid }; + return canDeleteMessageAsync(user, msgForValidation); + } + + async deleteFile(user: IUser, fileId: IUpload['_id'], msg: IMessage | null): Promise<{ deletedFiles: IUpload['_id'][] }> { + // Find every file that is derived from the file that is being deleted (its thumbnails) + const additionalFiles = await Uploads.findAllByOriginalFileId(fileId, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(); + const allFiles = [fileId, ...additionalFiles]; + + if (msg) { + await this.updateMessageRemovingFiles(msg, allFiles, user); + } + + return this.removeFileAndDerivates(fileId, additionalFiles); + } + + private async removeFileAndDerivates( + fileId: IUpload['_id'], + additionalFiles: IUpload['_id'][], + ): Promise<{ deletedFiles: IUpload['_id'][] }> { + const store = FileUpload.getStore('Uploads'); + // Delete the main file first; + await store.deleteById(fileId); + + // The main file is already deleted; From here forward we'll return a success response even if some sub-process fails + const deletedFiles: IUpload['_id'][] = [fileId]; + // Delete them one by one as the store may include requests to external services + for await (const id of additionalFiles) { + try { + await store.deleteById(id); + deletedFiles.push(id); + } catch (err) { + logger.error({ msg: 'Failed to delete derived file', fileId: id, originalFileId: fileId, err }); + } + } + + return { deletedFiles }; + } + + private async updateMessageRemovingFiles(msg: IMessage, filesToRemove: IUpload['_id'][], user: IUser): Promise { + const text = `_${i18n.t('File_removed')}_`; + const color = NOTIFICATION_ATTACHMENT_COLOR; + + const newFiles = msg.files?.filter((file) => !filesToRemove.includes(file._id)); + const newAttachments = msg.attachments?.map((attachment) => { + if (!isFileAttachment(attachment)) { + return attachment; + } + + // If the attachment doesn't have a `fileId`, we assume it's an old message with only one file, in which case checking the id is not needed + if (attachment.fileId && !filesToRemove.includes(attachment.fileId)) { + return attachment; + } + + return { type: 'removed-file', color, text, ...(attachment.fileId && { fileId: attachment.fileId }) }; + }); + const newFile = msg.file?._id && !filesToRemove.includes(msg.file._id) ? msg.file : newFiles?.[0]; + + const editedMessage = { + ...msg, + files: newFiles, + attachments: newAttachments, + file: newFile, + }; + + await updateMessage(editedMessage, user, msg); + } + + async streamUploadedFile({ + file, + imageResizeOpts, + }: { + file: IUpload; + imageResizeOpts?: { width: number; height: number }; + }): Promise { + const stream = await FileUpload.getStore('Uploads')._store.getReadStream(file._id, file); + if (!stream) { + throw new Error('error-file-not-found'); + } + + if (file?.type?.includes('image') && imageResizeOpts) { + const { width, height } = imageResizeOpts; + + const transformer = sharp().resize({ width, height, fit: 'contain' }); + + stream.on('error', (err) => transformer.destroy(err)); + + return stream.pipe(transformer); + } + + return stream; + } + + async uploadFileFromStream({ + streamParam, + details, + }: { + streamParam: Stream.Readable; + details: Omit; + }): Promise { + const resolver = Promise.withResolvers(); + + const tempFilePath = UploadFS.getTempFilePath(Random.id()); + + const writeStream = fs.createWriteStream(tempFilePath); + streamParam.pipe(writeStream); + + const cleanup = (err: unknown) => { + fs.promises.unlink(tempFilePath).catch(() => undefined); + resolver.reject(err); + }; + + writeStream.on('finish', async () => { + FileUpload.getStore('Uploads') + .insert( + { + ...details, + size: writeStream.bytesWritten, + }, + tempFilePath, + ) + .then(resolver.resolve) + .catch(cleanup); + }); + + streamParam.on('error', async (err) => { + writeStream.destroy(); + cleanup(err); + }); + + writeStream.on('error', cleanup); + + return resolver.promise; + } + + async setUserAvatar(user: Pick, buffer: Buffer, contentType: string, service: 'rest'): Promise { + return setUserAvatar(user, buffer, contentType, service); + } + + async resetUserAvatar(user: AtLeast): Promise { + if (!user.username) { + throw new Error('Username is required to reset avatar'); + } + + await FileUpload.getStore('Avatars').deleteByName(user.username); + await Users.unsetAvatarData(user._id); + void api.broadcast('user.avatarUpdate', { username: user.username, avatarETag: undefined }); + } +} diff --git a/apps/meteor/server/services/user/lib/getNewUserRoles.ts b/apps/meteor/server/services/user/lib/getNewUserRoles.ts new file mode 100644 index 0000000..39117ac --- /dev/null +++ b/apps/meteor/server/services/user/lib/getNewUserRoles.ts @@ -0,0 +1,12 @@ +import type { IRole } from '@rocket.chat/core-typings'; + +import { parseCSV } from '../../../../lib/utils/parseCSV'; +import { settings } from '../../../settings'; + +export function getNewUserRoles(previousRoles?: IRole['_id'][]): IRole['_id'][] { + const currentRoles = previousRoles ?? []; + + const defaultUserRoles = parseCSV(settings.get('Accounts_Registration_Users_Default_Roles') || ''); + + return [...new Set([...currentRoles, ...defaultUserRoles])]; +} diff --git a/apps/meteor/server/services/user/service.ts b/apps/meteor/server/services/user/service.ts new file mode 100644 index 0000000..13371c1 --- /dev/null +++ b/apps/meteor/server/services/user/service.ts @@ -0,0 +1,20 @@ +import { ServiceClassInternal } from '@rocket.chat/core-services'; +import type { IUserService } from '@rocket.chat/core-services'; +import { Users } from '@rocket.chat/models'; + +import { getMaxLoginTokens } from '../../lib/getMaxLoginTokens'; + +// TODO merge this service with Account service +export class UserService extends ServiceClassInternal implements IUserService { + protected name = 'user'; + + async ensureLoginTokensLimit(uid: string): Promise { + const [{ tokens } = { tokens: [] }] = await Users.findAllResumeTokensByUserId(uid); + if (tokens.length < getMaxLoginTokens()) { + return; + } + + const oldestDate = tokens.reverse()[getMaxLoginTokens() - 1]; + await Users.removeOlderResumeTokensByUserId(uid, oldestDate.when); + } +} diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts new file mode 100644 index 0000000..eb71bc6 --- /dev/null +++ b/apps/meteor/server/services/video-conference/service.ts @@ -0,0 +1,1256 @@ +import { Apps } from '@rocket.chat/apps'; +import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager'; +import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services'; +import type { + IDirectVideoConference, + ILivechatVideoConference, + IRoom, + IUser, + VideoConferenceInstructions, + DirectCallInstructions, + ConferenceInstructions, + LivechatInstructions, + AtLeast, + IGroupVideoConference, + IVideoConferenceUser, + IMessage, + IStats, + VideoConference, + VideoConferenceCapabilities, + VideoConferenceCreateData, + Optional, + ExternalVideoConference, + IVoIPVideoConference, +} from '@rocket.chat/core-typings'; +import { + VideoConferenceStatus, + isDirectVideoConference, + isGroupVideoConference, + isLivechatVideoConference, +} from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { Users, VideoConference as VideoConferenceModel, Rooms, Messages, Subscriptions } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; +import type * as UiKit from '@rocket.chat/ui-kit'; +import { Meteor } from 'meteor/meteor'; +import { MongoInternals } from 'meteor/mongo'; + +import { availabilityErrors } from '../../../lib/videoConference/constants'; +import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; +import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { callbacks } from '../../lib/callbacks'; +import { i18n } from '../../lib/i18n'; +import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging'; +import { RocketChatAssets } from '../../lib/media/assets'; +import { sendMessage } from '../../lib/messages/sendMessage'; +import { metrics } from '../../lib/metrics/lib/metrics'; +import { Push } from '../../lib/notifications/push/push'; +import PushNotification from '../../lib/notifications/push-config/lib/PushNotification'; +import { notifyOnMessageChange } from '../../lib/notifyListener'; +import { createRoom } from '../../lib/rooms/createRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { updateCounter } from '../../lib/statistics/functions/updateStatsCounter'; +import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; +import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; +import { videoConfProviders } from '../../lib/videoConfProviders'; +import { videoConfTypes } from '../../lib/videoConfTypes'; +import { settings } from '../../settings'; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +const logger = new Logger('VideoConference'); + +export class VideoConfService extends ServiceClassInternal implements IVideoConfService { + protected name = 'video-conference'; + + // VideoConference.create: Start a video conference using the type and provider specified as arguments + public async create( + { type, rid, createdBy, providerName, ...data }: VideoConferenceCreateData, + useAppUser = true, + ): Promise { + return wrapExceptions(async () => { + const room = await Rooms.findOneById>(rid, { + projection: { t: 1, uids: 1, name: 1, fname: 1 }, + }); + + if (!room) { + throw new Error('invalid-room'); + } + + const user = await Users.findOneById(createdBy); + if (!user) { + throw new Error('failed-to-load-own-data'); + } + + if (type === 'direct') { + if (!isRoomCompatibleWithVideoConfRinging(room.t, room.uids)) { + throw new Error('type-and-room-not-compatible'); + } + + return this.startDirect(providerName, user, room, data); + } + + if (type === 'livechat') { + return this.startLivechat(providerName, user, rid); + } + + const title = (data as Partial).title || room.fname || room.name || ''; + return this.startGroup(providerName, user, room._id, title, data, useAppUser); + }).catch((err) => { + logger.error({ + name: 'Error on VideoConf.create', + err, + }); + throw err; + }); + } + + // VideoConference.start: Detect the desired type and provider then start a video conference using them + public async start( + caller: IUser['_id'], + rid: string, + { title, allowRinging }: { title?: string; allowRinging?: boolean }, + ): Promise { + return wrapExceptions(async () => { + const providerName = await this.getValidatedProvider(); + const initialData = await this.getTypeForNewVideoConference(rid, Boolean(allowRinging)); + + const data = { + ...initialData, + createdBy: caller, + rid, + providerName, + } as VideoConferenceCreateData; + + if (data.type === 'videoconference') { + data.title = title; + } + + return this.create(data, false); + }).catch((err) => { + logger.error({ + name: 'Error on VideoConf.start', + err, + }); + throw err; + }); + } + + public async join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise { + return wrapExceptions(async () => { + const call = await VideoConferenceModel.findOneById(callId); + if (!call || call.endedAt || !videoConfTypes.isCallManagedByApp(call)) { + throw new Error('invalid-call'); + } + + let user: Pick | null = null; + + if (uid) { + user = await Users.findOneById>(uid, { + projection: { name: 1, username: 1, avatarETag: 1 }, + }); + if (!user) { + throw new Error('failed-to-load-own-data'); + } + } + + if (call.providerName === 'jitsi') { + updateCounter({ settingsId: 'Jitsi_Click_To_Join_Count' }); + } + + return this.joinCall(call, user || undefined, options); + }).catch((err) => { + logger.error({ + name: 'Error on VideoConf.join', + err, + }); + throw err; + }); + } + + public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { + const call = await VideoConferenceModel.findOneById(callId); + if (!call) { + throw new Error('invalid-call'); + } + + if (!videoConfTypes.isCallManagedByApp(call)) { + return []; + } + + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + let user: Pick, '_id' | 'username' | 'name' | 'avatarETag'> | null = null; + + if (uid) { + user = await Users.findOneById, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, { + projection: { name: 1, username: 1, avatarETag: 1 }, + }); + if (!user) { + throw new Error('failed-to-load-own-data'); + } + } + + const blocks = await (await this.getProviderManager()).getVideoConferenceInfo(call.providerName, call, user || undefined).catch((e) => { + throw new Error(e); + }); + + if (blocks?.length) { + return blocks as UiKit.ModalSurfaceLayout; + } + + return [ + { + blockId: 'videoconf-info', + type: 'section', + text: { + type: 'mrkdwn', + text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`, + }, + }, + ]; + } + + public async cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId); + if (!call || !isDirectVideoConference(call)) { + throw new Error('invalid-call'); + } + + if (call.status !== VideoConferenceStatus.CALLING || call.endedBy || call.endedAt) { + throw new Error('invalid-call-status'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('failed-to-load-own-data'); + } + + await VideoConferenceModel.setDataById(callId, { + ringing: false, + status: VideoConferenceStatus.DECLINED, + endedAt: new Date(), + endedBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + }); + + await this.runVideoConferenceChangedEvent(callId); + this.notifyVideoConfUpdate(call.rid, call._id); + + await this.sendAllPushNotifications(call._id); + } + + public async get(callId: VideoConference['_id']): Promise | null> { + return VideoConferenceModel.findOneById>(callId, { projection: { providerData: 0 } }); + } + + public async getUnfiltered(callId: VideoConference['_id']): Promise { + return VideoConferenceModel.findOneById(callId); + } + + public async list( + roomId: IRoom['_id'], + pagination: { offset?: number; count?: number } = {}, + ): Promise> { + const { cursor, totalCount } = VideoConferenceModel.findPaginatedByRoomId(roomId, pagination); + + const [data, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + data, + offset: pagination.offset || 0, + count: data.length, + total, + }; + } + + public async setProviderData(callId: VideoConference['_id'], data: VideoConference['providerData'] | undefined): Promise { + await VideoConferenceModel.setProviderDataById(callId, data); + } + + public async setEndedBy(callId: VideoConference['_id'], endedBy: IUser['_id']): Promise { + const user = await Users.findOneById>>(endedBy, { + projection: { username: 1, name: 1 }, + }); + if (!user) { + throw new Error('Invalid User'); + } + + await VideoConferenceModel.setEndedById(callId, { + _id: user._id, + username: user.username, + name: user.name, + }); + } + + public async setEndedAt(callId: VideoConference['_id'], endedAt: Date): Promise { + await VideoConferenceModel.setEndedById(callId, undefined, endedAt); + } + + public async setStatus(callId: VideoConference['_id'], status: VideoConference['status']): Promise { + switch (status) { + case VideoConferenceStatus.ENDED: + return this.endCall(callId); + case VideoConferenceStatus.EXPIRED: + return this.expireCall(callId); + } + + await VideoConferenceModel.setStatusById(callId, status); + } + + public async addUser(callId: VideoConference['_id'], userId?: IUser['_id'], ts?: Date): Promise { + const call = await this.get(callId); + if (!call) { + throw new Error('Invalid video conference'); + } + + if (!userId) { + if (call.type === 'videoconference') { + return this.addAnonymousUser(call as Omit); + } + + throw new Error('Invalid User'); + } + + const user = await Users.findOneById>>(userId, { + projection: { username: 1, name: 1, avatarETag: 1 }, + }); + if (!user) { + throw new Error('Invalid User'); + } + + await this.addUserToCall(call, { + _id: user._id, + username: user.username, + name: user.name, + avatarETag: user.avatarETag, + ts: ts || new Date(), + }); + } + + public async listProviders(): Promise<{ key: string; label: string }[]> { + return videoConfProviders.getProviderList(); + } + + public async listProviderCapabilities(providerName: string): Promise { + return videoConfProviders.getProviderCapabilities(providerName) || {}; + } + + public async listCapabilities(): Promise<{ providerName: string; capabilities: VideoConferenceCapabilities }> { + const providerName = await this.getValidatedProvider(); + + return { + providerName, + capabilities: videoConfProviders.getProviderCapabilities(providerName) || {}, + }; + } + + public async declineLivechatCall(callId: VideoConference['_id']): Promise { + const call = await this.getUnfiltered(callId); + + if (!isLivechatVideoConference(call)) { + return false; + } + + if (call.messages.started) { + const name = + (settings.get('UI_Use_Real_Name') ? call.createdBy.name : call.createdBy.username) || call.createdBy.username || ''; + const text = i18n.t('video_livechat_missed', { username: name }); + await Messages.setBlocksById(call.messages.started, [this.buildMessageBlock(text)]); + + await notifyOnMessageChange({ + id: call.messages.started, + }); + } + + await VideoConferenceModel.setDataById(call._id, { + status: VideoConferenceStatus.DECLINED, + endedAt: new Date(), + }); + + return true; + } + + public async diagnoseProvider(uid: string, rid: string, providerName?: string): Promise { + try { + if (providerName) { + await this.validateProvider(providerName); + } else { + await this.getValidatedProvider(); + } + } catch (error: unknown) { + if (error instanceof Error) { + await this.createEphemeralMessage(uid, rid, error.message); + return error.message; + } + } + } + + public async getStatistics(): Promise { + const options = { + readPreference: readSecondaryPreferred(db), + }; + + const [videoConferenceStarted, videoConferenceEnded, directCalling, directStarted, directEnded, livechatStarted, livechatEnded] = + await Promise.all([ + VideoConferenceModel.countByTypeAndStatus('videoconference', VideoConferenceStatus.STARTED, options), + VideoConferenceModel.countByTypeAndStatus('videoconference', VideoConferenceStatus.ENDED, options), + VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.CALLING, options), + VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.STARTED, options), + VideoConferenceModel.countByTypeAndStatus('direct', VideoConferenceStatus.ENDED, options), + VideoConferenceModel.countByTypeAndStatus('livechat', VideoConferenceStatus.STARTED, options), + VideoConferenceModel.countByTypeAndStatus('livechat', VideoConferenceStatus.ENDED, options), + ]); + + return { + videoConference: { + started: videoConferenceStarted, + ended: videoConferenceEnded, + }, + direct: { + calling: directCalling, + started: directStarted, + ended: directEnded, + }, + livechat: { + started: livechatStarted, + ended: livechatEnded, + }, + settings: { + provider: settings.get('VideoConf_Default_Provider'), + dms: settings.get('VideoConf_Enable_DMs'), + channels: settings.get('VideoConf_Enable_Channels'), + groups: settings.get('VideoConf_Enable_Groups'), + teams: settings.get('VideoConf_Enable_Teams'), + }, + }; + } + + public async validateAction( + action: string, + caller: IUser['_id'], + { callId, uid, rid }: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] }, + ): Promise { + if (!callId || !uid || !rid) { + return false; + } + + if (!(await canAccessRoomIdAsync(rid, caller)) || (caller !== uid && !(await canAccessRoomIdAsync(rid, uid)))) { + return false; + } + + const call = await VideoConferenceModel.findOneById>(callId, { + projection: { status: 1, endedAt: 1, createdBy: 1 }, + }); + + if (!call) { + return false; + } + + if (action === 'end') { + return true; + } + + if (call.endedAt || call.status > VideoConferenceStatus.STARTED) { + // If the caller is still calling about a call that has already ended, notify it + if (action === 'call' && caller === call.createdBy._id) { + this.notifyUser(call.createdBy._id, 'end', { rid, uid, callId }); + } + + return false; + } + + return true; + } + + public async createVoIP(data: InsertionModel): Promise { + return wrapExceptions(async () => VideoConferenceModel.createVoIP(data)).catch((err) => { + logger.error({ + name: 'Error on VideoConf.createVoIP', + err, + }); + throw err; + }); + } + + private notifyUser( + userId: IUser['_id'], + action: string, + params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] }, + ): void { + void api.broadcast('user.video-conference', { userId, action, params }); + } + + private notifyVideoConfUpdate(rid: IRoom['_id'], callId: VideoConference['_id']): void { + void api.broadcast('room.video-conference', { rid, callId }); + } + + private async endCall(callId: VideoConference['_id']): Promise { + const call = await this.getUnfiltered(callId); + if (!call) { + return; + } + + await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.ENDED }); + await this.runVideoConferenceChangedEvent(call._id); + this.notifyVideoConfUpdate(call.rid, call._id); + + if (call.type === 'direct') { + return this.endDirectCall(call); + } + } + + private async expireCall(callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById>(callId, { projection: { messages: 1 } }); + if (!call) { + return; + } + + await VideoConferenceModel.setDataById(call._id, { endedAt: new Date(), status: VideoConferenceStatus.EXPIRED }); + } + + private async endDirectCall(call: IDirectVideoConference): Promise { + const params = { rid: call.rid, uid: call.createdBy._id, callId: call._id }; + + // Notify the caller that the call was ended by the server + this.notifyUser(call.createdBy._id, 'end', params); + + // If the callee hasn't joined the call yet, notify them that it has already ended + const subscriptions = await Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, { + projection: { 'u._id': 1, '_id': 0 }, + }).toArray(); + + for (const subscription of subscriptions) { + // Skip notifying users that already joined the call + if (call.users.find(({ _id }) => _id === subscription.u._id)) { + continue; + } + + this.notifyUser(subscription.u._id, 'end', params); + } + } + + private async getTypeForNewVideoConference( + rid: IRoom['_id'], + allowRinging: boolean, + ): Promise> { + const room = await Rooms.findOneById>(rid, { + projection: { t: 1 }, + }); + + if (!room) { + throw new Error('invalid-room'); + } + + return videoConfTypes.getTypeForRoom(room, allowRinging); + } + + private async createMessage(call: VideoConference, createdBy?: IUser, customBlocks?: IMessage['blocks']): Promise { + const record = { + t: 'videoconf', + msg: i18n.t('Video_Conference', { + lng: createdBy?.language || settings.get('Language') || 'en', + }), + groupable: false, + blocks: customBlocks || [this.buildVideoConfBlock(call._id)], + } satisfies Partial; + + const room = await Rooms.findOneById(call.rid); + const appId = videoConfProviders.getProviderAppId(call.providerName); + const user = createdBy || (appId && (await Users.findOneByAppId(appId))) || (await Users.findOneById('rocket.cat')); + + const message = await sendMessage(user, record, room); + + if (!message) { + throw new Error('failed-to-create-message'); + } + + return message._id; + } + + private async validateProvider(providerName: string): Promise { + const manager = await this.getProviderManager(); + const configured = await manager.isFullyConfigured(providerName).catch(() => false); + if (!configured) { + throw new Error(availabilityErrors.NOT_CONFIGURED); + } + } + + private async getValidatedProvider(): Promise { + if (!videoConfProviders.hasAnyProvider()) { + throw new Error(availabilityErrors.NO_APP); + } + + const providerName = videoConfProviders.getActiveProvider(); + if (!providerName) { + throw new Error(availabilityErrors.NOT_ACTIVE); + } + + await this.validateProvider(providerName); + + return providerName; + } + + private async createEphemeralMessage(uid: string, rid: string, i18nKey: string): Promise { + const user = await Users.findOneById>(uid, { projection: { language: 1, roles: 1 } }); + const language = user?.language || settings.get('Language') || 'en'; + const key = user?.roles.includes('admin') ? `admin-${i18nKey}` : i18nKey; + const msg = i18n.t(key, { + lng: language, + }); + + void api.broadcast('notify.ephemeralMessage', uid, rid, { + msg, + }); + } + + private async createLivechatMessage(call: ILivechatVideoConference, user: IUser, url: string): Promise { + const username = (settings.get('UI_Use_Real_Name') ? user.name : user.username) || user.username || ''; + const text = i18n.t('video_livechat_started', { + username, + }); + + return this.createMessage(call, user, [ + this.buildMessageBlock(text), + { + type: 'actions', + appId: 'videoconf-core', + blockId: call._id, + elements: [ + { + appId: 'videoconf-core', + blockId: call._id, + actionId: 'joinLivechat', + type: 'button', + text: { + type: 'plain_text', + text: i18n.t('Join_call'), + emoji: true, + }, + url, + }, + ], + }, + ]); + } + + private buildVideoConfBlock(callId: string): UiKit.MessageSurfaceLayout[number] { + return { + type: 'video_conf', + blockId: callId, + callId, + appId: 'videoconf-core', + }; + } + + private buildMessageBlock(text: string): UiKit.MessageSurfaceLayout[number] { + return { + type: 'section', + appId: 'videoconf-core', + text: { + type: 'mrkdwn', + text: `${text}`, + }, + }; + } + + private async sendPushNotification( + call: AtLeast, + calleeId: IUser['_id'], + ): Promise { + if ( + settings.get('Push_enable') !== true || + settings.get('VideoConf_Mobile_Ringing') !== true || + !(await getUserPreference(calleeId, 'enableMobileRinging')) + ) { + return; + } + + await Push.send({ + from: 'push', + badge: 0, + sound: 'ringtone.mp3', + priority: 10, + title: `@${call.createdBy.username}`, + text: i18n.t('Video_Conference'), + payload: { + host: Meteor.absoluteUrl(), + rid: call.rid, + notificationType: 'videoconf', + caller: call.createdBy, + avatar: getUserAvatarURL(call.createdBy.username), + status: call.status, + callId: call._id, + }, + userId: calleeId, + notId: PushNotification.getNotificationId(`${call.rid}|${call._id}`), + gcm: { + style: 'inbox', + image: RocketChatAssets.getURL('Assets_favicon_192'), + }, + apn: { + category: 'VIDEOCONF', + }, + }); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + metrics.notificationsSentTotal.inc({ notification_type: 'mobile' }); + } + + private async sendAllPushNotifications(callId: VideoConference['_id']): Promise { + if (settings.get('Push_enable') !== true || settings.get('VideoConf_Mobile_Ringing') !== true) { + return; + } + + const call = await VideoConferenceModel.findOneById>(callId, { + projection: { createdBy: 1, rid: 1, users: 1, status: 1 }, + }); + + if (!call) { + return; + } + + const subscriptions = Subscriptions.findByRoomIdAndNotUserId(call.rid, call.createdBy._id, { + projection: { 'u._id': 1, '_id': 0 }, + }); + + for await (const subscription of subscriptions) { + await this.sendPushNotification(call, subscription.u._id); + } + } + + private async startDirect( + providerName: string, + user: IUser, + { _id: rid, uids }: AtLeast, + extraData?: Partial, + ): Promise { + const calleeId = uids?.filter((uid) => uid !== user._id).pop(); + if (!calleeId) { + // Are you trying to call yourself? + throw new Error('invalid-call-target'); + } + + const callId = await VideoConferenceModel.createDirect({ + ...extraData, + rid, + createdBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + providerName, + }); + + await this.runNewVideoConferenceEvent(callId); + + await this.maybeCreateDiscussion(callId, user); + + const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null; + if (!call) { + throw new Error('failed-to-create-direct-call'); + } + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + + const messageId = await this.createMessage(call, user); + call.messages.started = messageId; + await VideoConferenceModel.setMessageById(callId, 'started', messageId); + + // After 40 seconds if the status is still "calling", we cancel the call automatically. + setTimeout(async () => { + try { + const call = await VideoConferenceModel.findOneById(callId); + + if (call) { + await this.endDirectCall(call); + if (call.status !== VideoConferenceStatus.CALLING) { + return; + } + + await this.cancel(user._id, callId); + } + } catch { + // Ignore errors on this timeout + } + }, 40000); + + await this.sendPushNotification(call, calleeId); + + return { + type: 'direct', + callId, + calleeId, + }; + } + + private async notifyUsersOfRoom( + rid: IRoom['_id'], + uid: IUser['_id'], + action: string, + params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] }, + ): Promise { + const subscriptions = Subscriptions.findByRoomIdAndNotUserId(rid, uid, { + projection: { 'u._id': 1, '_id': 0 }, + }); + + await subscriptions.forEach((subscription) => this.notifyUser(subscription.u._id, action, params)); + } + + private async startGroup( + providerName: string, + user: IUser, + rid: IRoom['_id'], + title: string, + extraData?: Partial, + useAppUser = true, + ): Promise { + const callId = await VideoConferenceModel.createGroup({ + ...extraData, + rid, + title, + createdBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + providerName, + }); + + await this.runNewVideoConferenceEvent(callId); + + await this.maybeCreateDiscussion(callId, user); + + const call = (await this.getUnfiltered(callId)) as IGroupVideoConference | null; + if (!call) { + throw new Error('failed-to-create-group-call'); + } + + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + + call.url = url; + + const messageId = await this.createMessage(call, useAppUser ? undefined : user); + call.messages.started = messageId; + await VideoConferenceModel.setMessageById(callId, 'started', messageId); + + if (call.ringing) { + await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id }); + } + + return { + type: 'videoconference', + callId, + rid, + }; + } + + private async startLivechat(providerName: string, user: IUser, rid: IRoom['_id']): Promise { + const callId = await VideoConferenceModel.createLivechat({ + rid, + createdBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + providerName, + }); + + const call = (await this.getUnfiltered(callId)) as ILivechatVideoConference | null; + if (!call) { + throw new Error('failed-to-create-livechat-call'); + } + + await this.runNewVideoConferenceEvent(callId); + + // Livechat conferences do not use discussions + + const joinUrl = await this.getUrl(call); + const messageId = await this.createLivechatMessage(call, user, joinUrl); + call.messages.started = messageId; + await VideoConferenceModel.setMessageById(callId, 'started', messageId); + + return { + type: 'livechat', + callId, + }; + } + + private async joinCall( + call: ExternalVideoConference, + user: AtLeast | undefined, + options: VideoConferenceJoinOptions, + ): Promise { + void callbacks.runAsync('onJoinVideoConference', call._id, user?._id); + + await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser); + + return this.getUrl(call, user, options); + } + + private async getProviderManager(): Promise { + if (!Apps.self?.isLoaded()) { + throw new Error('apps-engine-not-loaded'); + } + + const manager = Apps.self?.getManager()?.getVideoConfProviderManager(); + if (!manager) { + throw new Error(availabilityErrors.NO_APP); + } + + return manager; + } + + private async getRoomName(rid: string): Promise { + const room = await Rooms.findOneById>(rid, { projection: { name: 1, fname: 1 } }); + + return room?.fname || room?.name || rid; + } + + private async generateNewUrl(call: ExternalVideoConference): Promise { + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + const title = isGroupVideoConference(call) ? call.title || (await this.getRoomName(call.rid)) : ''; + const callData: VideoConfData = { + _id: call._id, + type: call.type, + rid: call.rid, + createdBy: call.createdBy as Required, + title, + providerData: call.providerData, + discussionRid: call.discussionRid, + }; + + return (await this.getProviderManager()).generateUrl(call.providerName, callData); + } + + private async getCallTitleForUser(call: VideoConference, userId?: IUser['_id']): Promise { + if (call.type === 'videoconference' && call.title) { + return call.title; + } + + if (userId) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(call.rid, userId, { projection: { fname: 1, name: 1 } }); + if (subscription) { + return subscription.fname || subscription.name; + } + } + + const room = await Rooms.findOneById(call.rid); + return room?.fname || room?.name || 'Rocket.Chat'; + } + + private async getCallTitle(call: VideoConference): Promise { + if (call.type === 'videoconference') { + if (call.title) { + return call.title; + } + } + + const room = await Rooms.findOneById(call.rid); + if (room) { + if (room.t === 'd') { + if (room.usernames?.length) { + return room.usernames.join(', '); + } + } else if (room.fname) { + return room.fname; + } else if (room.name) { + return room.name; + } + } + + return 'Rocket.Chat'; + } + + private async getUrl( + call: ExternalVideoConference, + user?: AtLeast, + options: VideoConferenceJoinOptions = {}, + ): Promise { + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + if (!call.url) { + call.url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(call._id, call.url); + } + + const callData: VideoConfDataExtended = { + _id: call._id, + type: call.type, + rid: call.rid, + url: call.url, + createdBy: call.createdBy as Required, + providerData: { + ...(call.providerData || {}), + ...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) }, + }, + title: await this.getCallTitle(call), + discussionRid: call.discussionRid, + }; + + const userData = user && { + _id: user._id, + username: user.username as string, + name: user.name as string, + }; + + return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options); + } + + private async runNewVideoConferenceEvent(callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId); + + if (!call) { + throw new Error('video-conf-data-not-found'); + } + + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + return (await this.getProviderManager()).onNewVideoConference(call.providerName, call); + } + + private async runVideoConferenceChangedEvent(callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId); + + if (!call) { + throw new Error('video-conf-data-not-found'); + } + + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call); + } + + private async runOnUserJoinEvent(callId: VideoConference['_id'], user?: IVideoConferenceUser): Promise { + const call = await VideoConferenceModel.findOneById(callId); + + if (!call) { + throw new Error('video-conf-data-not-found'); + } + + if (!videoConfTypes.isCallManagedByApp(call)) { + return; + } + + if (!videoConfProviders.isProviderAvailable(call.providerName)) { + throw new Error('video-conf-provider-unavailable'); + } + + return (await this.getProviderManager()).onUserJoin(call.providerName, call, user); + } + + private async addUserToCall( + call: Optional, + { _id, username, name, avatarETag, ts }: AtLeast, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date }, + ): Promise { + // If the call has a discussion, ensure the user is subscribed to it; + // This is done even if the user has already joined the call before, so they can be added back if they had left the discussion. + if (call.discussionRid) { + await this.addUserToDiscussion(call.discussionRid, _id); + } + + if (call.users.find((user) => user._id === _id)) { + return; + } + + await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts }); + + if (call.type === 'direct') { + return this.updateDirectCall(call as IDirectVideoConference, _id); + } + + this.notifyVideoConfUpdate(call.rid, call._id); + } + + private async addAnonymousUser(call: Optional): Promise { + await VideoConferenceModel.increaseAnonymousCount(call._id); + } + + private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise { + // If it's an user that hasn't joined yet + if (call.ringing && !call.users.find(({ _id }) => _id === newUserId)) { + this.notifyUser(call.createdBy._id, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); + if (newUserId !== call.createdBy._id) { + this.notifyUser(newUserId, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); + // If the callee joined the direct call, then we stopped ringing + await VideoConferenceModel.setRingingById(call._id, false); + } + } + + if (call.status !== VideoConferenceStatus.CALLING) { + return; + } + + await VideoConferenceModel.setStatusById(call._id, VideoConferenceStatus.STARTED); + this.notifyVideoConfUpdate(call.rid, call._id); + + await this.runVideoConferenceChangedEvent(call._id); + await this.sendAllPushNotifications(call._id); + } + + private isPersistentChatEnabled(): boolean { + // Persistent chat discussions are always created unencrypted, so persistent chat is treated as disabled + // while the workspace enforces encryption on private rooms. + const encryptionEnforced = settings.get('E2E_Enable') && settings.get('E2E_Force_Encryption_For_Private_Rooms'); + + return settings.get('VideoConf_Enable_Persistent_Chat') && settings.get('Discussion_enabled') && !encryptionEnforced; + } + + private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise { + if (!this.isPersistentChatEnabled()) { + return; + } + + const call = await VideoConferenceModel.findOneById(callId, { + projection: { rid: 1, createdBy: 1, discussionRid: 1, providerName: 1 }, + }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + // If there's already a discussion assigned to it, do not create a new one + if (call.discussionRid) { + return; + } + + // If the call provider does not explicitly support persistent chat, do not create discussions + if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + return; + } + + const name = settings.get('VideoConf_Persistent_Chat_Discussion_Name') || i18n.t('[date] Video Call Chat'); + let displayName; + const date = new Date().toISOString().substring(0, 10); + + if (name.includes('[date]')) { + displayName = name.replace('[date]', date); + } else { + displayName = `${date} ${name}`; + } + + await this.createDiscussionForConference(displayName, call, createdBy); + } + + private async getRoomForDiscussion( + baseRoom: IRoom['_id'], + childRoomIds: IRoom['_id'][] = [], + ): Promise> { + const room = await Rooms.findOneById>(baseRoom, { + projection: { t: 1, teamId: 1, prid: 1 }, + }); + if (!room) { + throw new Error('invalid-room'); + } + + if (room.prid) { + if (childRoomIds.includes(room.prid)) { + throw new Error('Room has circular reference.'); + } + + return this.getRoomForDiscussion(room.prid, [...childRoomIds, room._id]); + } + + return room; + } + + private async createDiscussionForConference( + name: string, + call: AtLeast, + createdBy?: IUser, + ): Promise { + const room = await this.getRoomForDiscussion(call.rid); + + const type = await roomCoordinator.getRoomDirectives(room.t).getDiscussionType(room); + const user = call.createdBy._id === createdBy?._id ? createdBy : await Users.findOneById(call.createdBy._id); + if (!user) { + throw new Error('invalid-user'); + } + + const discussion = await createRoom( + type, + Random.id(), + user, + [], + false, + false, + { + fname: name, + prid: room._id, + encrypted: false, + }, + { + creator: user._id, + subscriptionExtra: { + open: false, + }, + }, + ); + + return this.assignDiscussionToConference(call._id, discussion._id); + } + + public async assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise { + // Ensures the specified rid is a valid room + const room = rid ? await Rooms.findOneById>(rid, { projection: { prid: 1 } }) : null; + if (rid && !room) { + throw new Error('invalid-room-id'); + } + + const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, messages: 1 } }); + if (!call) { + return; + } + + if (rid === undefined) { + await VideoConferenceModel.unsetDiscussionRidById(callId); + } else { + await VideoConferenceModel.setDiscussionRidById(callId, rid); + } + + if (room) { + await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id))); + } + } + + private async addUserToDiscussion(rid: IRoom['_id'], uid: IUser['_id']): Promise { + try { + await Room.addUserToRoom(rid, { _id: uid }, undefined, { skipSystemMessage: true, createAsHidden: true }); + } catch (err) { + // Ignore any errors here so that the subscription doesn't block the user from participating in the conference. + logger.error({ + name: 'Error trying to subscribe user to discussion', + err, + rid, + uid, + }); + } + } +} diff --git a/apps/meteor/server/settings/CachedSettings.ts b/apps/meteor/server/settings/CachedSettings.ts new file mode 100644 index 0000000..a1644f4 --- /dev/null +++ b/apps/meteor/server/settings/CachedSettings.ts @@ -0,0 +1,392 @@ +import type { ISetting, SettingValue } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import _ from 'underscore'; + +import { SystemLogger } from '../lib/logger/system'; + +const warn = process.env.NODE_ENV === 'development' || process.env.TEST_MODE; + +type SettingsConfig = { + debounce: number; +}; + +type OverCustomSettingsConfig = Partial; + +export interface ICachedSettings { + /* + * @description: The settings object as ready + */ + initialized(): void; + + has(_id: ISetting['_id']): boolean; + + getSetting(_id: ISetting['_id']): ISetting | undefined; + + get(_id: ISetting['_id']): T; + + getByRegexp(_id: RegExp): [string, T][]; + + watchMultiple(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void; + + watch(_id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig): () => void; + + watchOnce( + _id: ISetting['_id'], + cb: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void; + + change( + _id: ISetting['_id'], + callback: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void; + + changeMultiple( + _ids: ISetting['_id'][], + callback: (settings: T[]) => void, + config?: OverCustomSettingsConfig, + ): () => void; + + changeOnce( + _id: ISetting['_id'], + callback: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void; + + set(record: ISetting): void; + + getConfig(config?: OverCustomSettingsConfig): SettingsConfig; + + watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void; + + changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void; + + onReady(cb: () => void): void; +} + +/** + * Class responsible for setting up the settings, cache and propagation changes + * Should be agnostic to the actual settings implementation, running on meteor or standalone + * + * You should not instantiate this class directly, only for testing purposes + * + * @extends Emitter + * @alpha + */ +export class CachedSettings + extends Emitter< + { + '*': [string, SettingValue]; + } & { + ready: undefined; + [k: string]: SettingValue; + } + > + implements ICachedSettings +{ + ready = false; + + store = new Map(); + + /** + * The settings object as ready + */ + initialized(): void { + if (this.ready) { + return; + } + this.ready = true; + this.emit('ready'); + SystemLogger.debug('Settings initialized'); + } + + /** + * returns if the setting is defined + * @param _id - The setting id + * @returns {boolean} + */ + public override has(_id: ISetting['_id']): boolean { + if (!this.ready && warn) { + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); + } + return this.store.has(_id); + } + + /** + * Gets the current Object of the setting + * @param _id - The setting id + * @returns {ISetting} - The current Object of the setting + */ + public getSetting(_id: ISetting['_id']): ISetting | undefined { + if (!this.ready && warn) { + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); + } + return this.store.get(_id); + } + + /** + * Gets the current value of the setting + * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that + * - The setting's value will be cached in memory so it won't call the DB every time you fetch a particular setting + * @param _id - The setting id + * @returns {SettingValue} - The current value of the setting + */ + public get(_id: ISetting['_id']): T { + if (!this.ready && warn) { + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); + } + return this.store.get(_id)?.value as T; + } + + /** + * Gets the current value of the setting + * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that + * @deprecated + * @param _id - The setting id + * @returns {SettingValue} - The current value of the setting + */ + public getByRegexp(_id: RegExp): [string, T][] { + if (!this.ready && warn) { + SystemLogger.warn({ msg: 'Settings not initialized yet. getting', _id }); + } + + return [...this.store.entries()].filter(([key]) => _id.test(key)).map(([key, setting]) => [key, setting.value]) as [string, T][]; + } + + /** + * Get the current value of the settings, and keep track of changes + * - This callback is debounced + * - The callback is not fire until the settings got initialized + * @param _ids - Array of setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public watchMultiple(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void { + if (!this.ready) { + const cancel = new Set<() => void>(); + + cancel.add( + this.once('ready', (): void => { + cancel.clear(); + cancel.add(this.watchMultiple(_id, callback)); + }), + ); + return (): void => { + cancel.forEach((fn) => fn()); + }; + } + + if (_id.every((id) => this.store.has(id))) { + const settings = _id.map((id) => this.store.get(id)?.value); + callback(settings as T[]); + } + + const mergeFunction = + process.env.TEST_MODE !== 'true' && process.env.TEST_MODE !== 'api' + ? _.debounce((): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }, 100) + : (): void => { + callback(_id.map((id) => this.store.get(id)?.value) as T[]); + }; + + const fns = _id.map((id) => this.on(id, mergeFunction)); + return (): void => { + fns.forEach((fn) => fn()); + }; + } + + /** + * Get the current value of the setting, and keep track of changes + * - This callback is debounced + * - The callback is not fire until the settings got initialized + * @param _id - The setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public watch( + _id: ISetting['_id'], + cb: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void { + if (!this.ready) { + const cancel = new Set<() => void>(); + cancel.add( + this.once('ready', (): void => { + cancel.clear(); + cancel.add(this.watch(_id, cb, config)); + }), + ); + return (): void => { + cancel.forEach((fn) => fn()); + }; + } + + this.store.has(_id) && cb(this.store.get(_id)?.value as T); + return this.change(_id, cb, config); + } + + /** + * Get the current value of the setting, or wait until the initialized + * - This is a one time run + * - This callback is debounced + * - The callback is not fire until the settings got initialized + * @param _id - The setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public watchOnce( + _id: ISetting['_id'], + cb: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void { + if (this.store.has(_id)) { + cb(this.store.get(_id)?.value as T); + return (): void => undefined; + } + return this.changeOnce(_id, cb, config); + } + + /** + * Observes the given setting by id and keep track of changes + * - This callback is debounced + * - The callback is not fire until the setting is changed + * - The callback is not fire until all the settings get initialized + * @param _id - The setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public change( + _id: ISetting['_id'], + callback: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void { + const { debounce } = this.getConfig(config); + return this.on(_id, _.debounce(callback, debounce) as any); + } + + /** + * Observes multiple settings and keep track of changes + * - This callback is debounced + * - The callback is not fire until the setting is changed + * - The callback is not fire until all the settings get initialized + * @param _ids - Array of setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public changeMultiple( + _ids: ISetting['_id'][], + callback: (settings: T[]) => void, + config?: OverCustomSettingsConfig, + ): () => void { + const fns = _ids.map((id) => + this.change( + id, + (): void => { + callback(_ids.map((id) => this.store.get(id)?.value) as T[]); + }, + config, + ), + ); + return (): void => { + fns.forEach((fn) => fn()); + }; + } + + /** + * Observes the setting and fires only if there is a change. Runs only once + * - This is a one time run + * - This callback is debounced + * - The callback is not fire until the setting is changed + * - The callback is not fire until all the settings get initialized + * @param _id - The setting id + * @param callback - The callback to run + * @returns {() => void} - A function that can be used to cancel the observe + */ + public changeOnce( + _id: ISetting['_id'], + callback: (args: T) => void, + config?: OverCustomSettingsConfig, + ): () => void { + const { debounce } = this.getConfig(config); + return this.once(_id, _.debounce(callback, debounce) as any); + } + + /** + * Sets the value of the setting + * - if the value set is the same as the current value, the change will not be fired + * - if the value is set before the initialization, the emit will be queued and will be fired after initialization + * @param _id - The setting id + * @param value - The value to set + * @returns {void} + */ + public set(record: ISetting): void { + if (this.store.has(record._id) && this.store.get(record._id)?.value === record.value) { + return; + } + + this.store.set(record._id, record); + if (!this.ready) { + this.once('ready', () => { + this.emit(record._id, this.store.get(record._id)?.value); + this.emit('*', [record._id, this.store.get(record._id)?.value]); + }); + return; + } + this.emit(record._id, this.store.get(record._id)?.value); + this.emit('*', [record._id, this.store.get(record._id)?.value]); + } + + public getConfig = (config?: OverCustomSettingsConfig): SettingsConfig => ({ + debounce: process.env.TEST_MODE ? 0 : 500, + ...config, + }); + + /** @deprecated */ + public watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void { + if (!this.ready) { + const cancel = new Set<() => void>(); + cancel.add( + this.once('ready', (): void => { + cancel.clear(); + cancel.add(this.watchByRegex(regex, cb, config)); + }), + ); + return (): void => { + cancel.forEach((fn) => fn()); + }; + } + [...this.store.entries()].forEach(([key, setting]) => { + if (regex.test(key)) { + cb(key, setting.value); + } + }); + + return this.changeByRegex(regex, cb, config); + } + + /** @deprecated */ + public changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void { + const store: Map void> = new Map(); + return this.on('*', ([_id, value]) => { + if (regex.test(_id)) { + const { debounce } = this.getConfig(config); + const cb = store.get(_id) || _.debounce(callback, debounce); + cb(_id, value); + store.set(_id, cb); + } + regex.lastIndex = 0; + }); + } + + /** + * Wait until the settings get ready then run the callback + */ + public onReady(cb: () => void): void { + if (this.ready) { + return cb(); + } + this.once('ready', cb); + } +} diff --git a/apps/meteor/server/settings/Middleware.ts b/apps/meteor/server/settings/Middleware.ts new file mode 100644 index 0000000..ce37a30 --- /dev/null +++ b/apps/meteor/server/settings/Middleware.ts @@ -0,0 +1,13 @@ +type Next any> = (...args: Parameters) => ReturnType; + +type Middleware any> = (context: Parameters, next: Next) => ReturnType; + +const pipe = + any>(fn: T) => + (...args: Parameters): ReturnType => + fn(...args); + +export const use = any>(fn: T, middleware: Middleware): T => + function (this: unknown, ...context: Parameters): ReturnType { + return middleware(context, pipe(fn.bind(this))); + } as T; diff --git a/apps/meteor/server/settings/README.md b/apps/meteor/server/settings/README.md new file mode 100644 index 0000000..5201c4c --- /dev/null +++ b/apps/meteor/server/settings/README.md @@ -0,0 +1,58 @@ +## Rocket.Chat main library + +This package contains the main libraries of Rocket.Chat. + +### APIs + +#### Settings + +This is an example to create settings: +```javascript +settingsRegistry.addGroup('Settings_Group', function() { + this.add('SettingInGroup', 'default_value', { type: 'boolean', public: true }); + + this.section('Group_Section', function() { + this.add('Setting_Inside_Section', 'default_value', { + type: 'boolean', + public: true, + enableQuery: { + _id: 'SettingInGroup', + value: true + } + }); + }); +}); +``` + +`settingsRegistry.add` type: + +* `string` - Stores a string value + * Additional options: + * `multiline`: boolean +* `int` - Stores an integer value +* `boolean` - Stores a boolean value +* `select` - Creates an `

Rocket.chat
', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Header', + }, + ); + await this.add( + 'Email_Footer', + '
© Rocket.Chat Technologies Corp.
Made with ❤️ in 🇧🇷 🇨🇦 🇩🇪 🇮🇳 🇬🇧 🇺🇸
', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Footer', + }, + ); + await this.add('Email_Footer_Direct_Reply', '

{Direct_Reply_Advice}

', { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Footer_Direct_Reply', + }); + }); + await this.section('Direct_Reply', async function () { + await this.add('Direct_Reply_Enable', false, { + type: 'boolean', + env: true, + i18nLabel: 'Direct_Reply_Enable', + }); + await this.add('Direct_Reply_Debug', false, { + type: 'boolean', + env: true, + i18nLabel: 'Direct_Reply_Debug', + i18nDescription: 'Direct_Reply_Debug_Description', + }); + await this.add('Direct_Reply_Protocol', 'IMAP', { + type: 'select', + values: [ + { + key: 'IMAP', + i18nLabel: 'IMAP', + }, + { + key: 'POP', + i18nLabel: 'POP', + }, + ], + env: true, + i18nLabel: 'Protocol', + }); + await this.add('Direct_Reply_Host', '', { + type: 'string', + env: true, + i18nLabel: 'Host', + }); + await this.add('Direct_Reply_Port', '', { + type: 'string', + env: true, + i18nLabel: 'Port', + }); + await this.add('Direct_Reply_IgnoreTLS', false, { + type: 'boolean', + env: true, + i18nLabel: 'IgnoreTLS', + }); + await this.add('Direct_Reply_Frequency', 5, { + type: 'int', + env: true, + i18nLabel: 'Direct_Reply_Frequency', + enableQuery: { + _id: 'Direct_Reply_Protocol', + value: 'POP', + }, + }); + await this.add('Direct_Reply_Delete', true, { + type: 'boolean', + env: true, + i18nLabel: 'Direct_Reply_Delete', + enableQuery: { + _id: 'Direct_Reply_Protocol', + value: 'IMAP', + }, + }); + await this.add('Direct_Reply_Separator', '+', { + type: 'select', + values: [ + { + key: '!', + i18nLabel: '!', + }, + { + key: '#', + i18nLabel: '#', + }, + { + key: '$', + i18nLabel: '$', + }, + { + key: '%', + i18nLabel: '%', + }, + { + key: '&', + i18nLabel: '&', + }, + { + key: "'", + i18nLabel: "'", + }, + { + key: '*', + i18nLabel: '*', + }, + { + key: '+', + i18nLabel: '+', + }, + { + key: '-', + i18nLabel: '-', + }, + { + key: '/', + i18nLabel: '/', + }, + { + key: '=', + i18nLabel: '=', + }, + { + key: '?', + i18nLabel: '?', + }, + { + key: '^', + i18nLabel: '^', + }, + { + key: '_', + i18nLabel: '_', + }, + { + key: '`', + i18nLabel: '`', + }, + { + key: '{', + i18nLabel: '{', + }, + { + key: '|', + i18nLabel: '|', + }, + { + key: '}', + i18nLabel: '}', + }, + { + key: '~', + i18nLabel: '~', + }, + ], + env: true, + i18nLabel: 'Direct_Reply_Separator', + }); + await this.add('Direct_Reply_Username', '', { + type: 'string', + env: true, + i18nLabel: 'Username', + placeholder: 'email@domain', + secret: true, + }); + await this.add('Direct_Reply_ReplyTo', '', { + type: 'string', + env: true, + i18nLabel: 'ReplyTo', + placeholder: 'email@domain', + }); + return this.add('Direct_Reply_Password', '', { + type: 'password', + env: true, + i18nLabel: 'Password', + secret: true, + }); + }); + + await this.section('SMTP', async function () { + await this.add('SMTP_Protocol', 'smtp', { + type: 'select', + values: [ + { + key: 'smtp', + i18nLabel: 'smtp', + }, + { + key: 'smtps', + i18nLabel: 'smtps', + }, + ], + env: true, + i18nLabel: 'Protocol', + }); + await this.add('SMTP_Host', '', { + type: 'string', + env: true, + i18nLabel: 'Host', + }); + await this.add('SMTP_Port', '', { + type: 'string', + env: true, + i18nLabel: 'Port', + }); + await this.add('SMTP_IgnoreTLS', true, { + type: 'boolean', + env: true, + i18nLabel: 'IgnoreTLS', + enableQuery: { + _id: 'SMTP_Protocol', + value: 'smtp', + }, + }); + await this.add('SMTP_Pool', true, { + type: 'boolean', + env: true, + i18nLabel: 'Pool', + }); + await this.add('SMTP_Username', '', { + type: 'string', + env: true, + i18nLabel: 'Username', + autocomplete: false, + secret: true, + }); + await this.add('SMTP_Password', '', { + type: 'password', + env: true, + i18nLabel: 'Password', + autocomplete: false, + secret: true, + }); + await this.add('From_Email', '', { + type: 'string', + placeholder: 'email@domain', + }); + return this.add('SMTP_Test_Button', 'sendSMTPTestEmail', { + type: 'action', + actionText: 'Send_a_test_mail_to_my_user', + enableQuery: { + _id: 'SMTP_Host', + value: { + $exists: true, + $ne: '', + }, + }, + }); + }); + + await this.section('Registration', async function () { + await this.add('Accounts_Enrollment_Email_Subject', '{Welcome_to}', { + type: 'string', + i18nLabel: 'Subject', + }); + await this.add( + 'Accounts_Enrollment_Email', + '

{Welcome_to}

{Visit_Site_Url_and_try_the_best_open_source_chat_solution_available_today}

{Login}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + }, + ); + }); + + await this.section('Registration_via_Admin', async function () { + await this.add('Accounts_UserAddedEmail_Subject', '{Welcome_to}', { + type: 'string', + i18nLabel: 'Subject', + }); + await this.add( + 'Accounts_UserAddedEmail_Email', + '

{Welcome_to}

{Visit_Site_Url_and_try_the_best_open_source_chat_solution_available_today}

{Login}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Accounts_UserAddedEmail_Description', + }, + ); + }); + + await this.section('Verification', async function () { + await this.add('Verification_Email_Subject', '{Verification_Email_Subject}', { + type: 'string', + i18nLabel: 'Subject', + }); + + await this.add( + 'Verification_Email', + '

{Hi_username}

{Verification_email_body}

{Verify_your_email}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Verification_Description', + }, + ); + }); + + await this.section('Offline_Message', async function () { + await this.add('Offline_Message_Use_DeepLink', true, { + type: 'boolean', + }); + }); + + await this.section('Invitation', async function () { + await this.add('Invitation_Subject', '{Invitation_Subject_Default}', { + type: 'string', + i18nLabel: 'Subject', + }); + await this.add( + 'Invitation_Email', + '

{Welcome_to}

{Visit_Site_Url_and_try_the_best_open_source_chat_solution_available_today}

{Join_Chat}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Invitation_Email_Description', + }, + ); + }); + + await this.add('Invitation_Email_Count', 0, { + type: 'int', + hidden: true, + }); + + await this.section('Forgot_password_section', async function () { + await this.add('Forgot_Password_Email_Subject', '{Forgot_Password_Email_Subject}', { + type: 'string', + i18nLabel: 'Subject', + }); + + await this.add( + 'Forgot_Password_Email', + '

{Forgot_password}

{Lets_get_you_new_one_}

{Reset}

{If_you_didnt_ask_for_reset_ignore_this_email}

', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Forgot_Password_Description', + }, + ); + }); + + await this.section('Email_changed_section', async function () { + await this.add('Email_Changed_Email_Subject', '{Email_Changed_Email_Subject}', { + type: 'string', + i18nLabel: 'Subject', + }); + + await this.add( + 'Email_Changed_Email', + '

{Hi},

{Your_email_address_has_changed}

{Your_new_email_is_email}

{Login}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Email_Changed_Description', + }, + ); + }); + + await this.section('Password_changed_section', async function () { + await this.add('Password_Changed_Email_Subject', '{Password_Changed_Email_Subject}', { + type: 'string', + i18nLabel: 'Subject', + }); + + await this.add( + 'Password_Changed_Email', + '

{Hi},

{Your_password_was_changed_by_an_admin}

{Your_temporary_password_is_password}

{Login}', + { + type: 'code', + code: 'text/html', + multiline: true, + i18nLabel: 'Body', + i18nDescription: 'Password_Changed_Description', + }, + ); + }); + + await this.section('Privacy', async function () { + await this.add('Email_notification_show_message', true, { + type: 'boolean', + public: true, + }); + await this.add('Add_Sender_To_ReplyTo', false, { + type: 'boolean', + }); + }); + }); diff --git a/apps/meteor/server/settings/federation-service.ts b/apps/meteor/server/settings/federation-service.ts new file mode 100644 index 0000000..ba0310d --- /dev/null +++ b/apps/meteor/server/settings/federation-service.ts @@ -0,0 +1,155 @@ +import { generateEd25519RandomSecretKey } from '@rocket.chat/federation-matrix'; + +import { settingsRegistry } from '.'; + +export const createFederationServiceSettings = async (): Promise => { + await settingsRegistry.addGroup('Federation', async function () { + await this.add('Federation_Service_Enabled', false, { + type: 'boolean', + public: true, + enterprise: true, + modules: ['federation'], + invalidValue: false, + }); + + await this.add('Federation_Service_Domain', '', { + type: 'string', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: '', + alert: 'Federation_Service_Domain_Alert', + }); + + await this.add('Federation_Service_Matrix_Signing_Algorithm', 'ed25519', { + type: 'select', + public: false, + values: [{ key: 'ed25519', i18nLabel: 'ed25519' }], + enterprise: true, + modules: ['federation'], + invalidValue: 'ed25519', + }); + + await this.add('Federation_Service_Matrix_Signing_Version', '0', { + type: 'string', + public: false, + readonly: true, + enterprise: true, + modules: ['federation'], + invalidValue: '0', + }); + + const randomKey = generateEd25519RandomSecretKey().toString('base64'); + + // https://spec.matrix.org/v1.16/appendices/#signing-details + await this.add('Federation_Service_Matrix_Signing_Key', randomKey, { + type: 'password', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: '', + }); + + await this.add('Federation_Service_max_allowed_size_of_public_rooms_to_join', 100, { + type: 'int', + public: false, + alert: 'Federation_Service_max_allowed_size_of_public_rooms_to_join_Alert', + enterprise: true, + modules: ['federation'], + invalidValue: false, + }); + + await this.add('Federation_Service_Allow_List', '', { + type: 'string', + i18nLabel: 'Federation_Service_Allow_List', + i18nDescription: 'Federation_Service_Allow_List_Description', + public: false, + }); + + await this.add('Federation_Service_EDU_Process_Typing', true, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + alert: 'Federation_Service_EDU_Process_Typing_Alert', + }); + + await this.add('Federation_Service_EDU_Process_Presence', false, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + alert: 'Federation_Service_EDU_Process_Presence_Alert', + }); + + await this.add('Federation_Service_EDU_Process_Receipt', false, { + type: 'boolean', + public: true, + enterprise: true, + modules: ['federation'], + invalidValue: false, + alert: 'Federation_Service_EDU_Process_Receipt_Alert', + }); + + await this.add('Federation_Service_Join_Encrypted_Rooms', false, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + }); + + await this.add('Federation_Service_Join_Non_Private_Rooms', false, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + }); + + await this.add('Federation_Service_Validate_User_Domain', false, { + type: 'boolean', + public: false, + enterprise: true, + modules: ['federation'], + invalidValue: false, + }); + + await this.section('XMPP', async function () { + await this.add('Federation_XMPP_Enabled', false, { + type: 'boolean', + enterprise: true, + modules: ['federation'], + i18nLabel: 'Enabled', + invalidValue: false, + enableQuery: { _id: 'Federation_Service_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_URL', '', { + type: 'string', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_HS_Token', '', { + type: 'password', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + + await this.add('Federation_XMPP_Bridge_AS_Token', '', { + type: 'password', + enterprise: true, + modules: ['federation'], + invalidValue: '', + enableQuery: { _id: 'Federation_XMPP_Enabled', value: true }, + }); + }); + }); +}; diff --git a/apps/meteor/server/settings/federation.ts b/apps/meteor/server/settings/federation.ts new file mode 100644 index 0000000..354024d --- /dev/null +++ b/apps/meteor/server/settings/federation.ts @@ -0,0 +1,70 @@ +import { FederationKeys } from '@rocket.chat/models'; + +import { settingsRegistry } from '.'; + +export const createFederationSettings = () => + settingsRegistry.addGroup('Federation', async function () { + await this.section('Rocket.Chat Federation', async function () { + await this.add('FEDERATION_Enabled', false, { + type: 'boolean', + readonly: true, + i18nLabel: 'Enabled', + i18nDescription: 'FEDERATION_Enabled', + alert: 'Old_Federation_Alert', + public: true, + }); + + await this.add('FEDERATION_Status', 'Disabled', { + readonly: true, + hidden: true, + type: 'string', + i18nLabel: 'FEDERATION_Status', + }); + + await this.add('FEDERATION_Domain', '', { + type: 'string', + readonly: true, + hidden: true, + i18nLabel: 'FEDERATION_Domain', + i18nDescription: 'FEDERATION_Domain_Description', + alert: 'FEDERATION_Domain_Alert', + // disableReset: true, + }); + + const federationPublicKey = await FederationKeys.getPublicKeyString(); + + await this.add('FEDERATION_Public_Key', federationPublicKey || '', { + readonly: true, + hidden: true, + type: 'string', + multiline: true, + i18nLabel: 'FEDERATION_Public_Key', + i18nDescription: 'FEDERATION_Public_Key_Description', + }); + + await this.add('FEDERATION_Discovery_Method', 'dns', { + type: 'select', + readonly: true, + hidden: true, + values: [ + { + key: 'dns', + i18nLabel: 'DNS', + }, + { + key: 'hub', + i18nLabel: 'Hub', + }, + ], + i18nLabel: 'FEDERATION_Discovery_Method', + i18nDescription: 'FEDERATION_Discovery_Method_Description', + public: true, + }); + + await this.add('FEDERATION_Test_Setup', 'FEDERATION_Test_Setup', { + type: 'action', + hidden: true, + actionText: 'FEDERATION_Test_Setup', + }); + }); + }); diff --git a/apps/meteor/server/settings/file-upload.ts b/apps/meteor/server/settings/file-upload.ts new file mode 100644 index 0000000..2f6f6ad --- /dev/null +++ b/apps/meteor/server/settings/file-upload.ts @@ -0,0 +1,356 @@ +import { settingsRegistry } from '.'; + +export const createFileUploadSettings = () => + settingsRegistry.addGroup('FileUpload', async function () { + await this.add('FileUpload_Enabled', true, { + type: 'boolean', + public: true, + }); + + await this.add('FileUpload_MaxFileSize', 104857600, { + type: 'int', + public: true, + i18nDescription: 'FileUpload_MaxFileSizeDescription', + }); + + await this.add('FileUpload_MediaTypeWhiteList', '', { + type: 'string', + public: true, + i18nDescription: 'FileUpload_MediaTypeWhiteListDescription', + }); + + await this.add('FileUpload_MediaTypeBlackList', 'image/svg+xml', { + type: 'string', + public: true, + i18nDescription: 'FileUpload_MediaTypeBlackListDescription', + alert: 'FileUpload_MediaTypeBlackList_Alert', + }); + + await this.add('FileUpload_ProtectFiles', true, { + type: 'boolean', + public: true, + i18nDescription: 'FileUpload_ProtectFilesDescription', + }); + + await this.add('FileUpload_Restrict_to_room_members', true, { + type: 'boolean', + enableQuery: [ + { + _id: 'FileUpload_ProtectFiles', + value: true, + }, + { + _id: 'FileUpload_Restrict_to_users_who_can_access_room', + value: false, + }, + ], + }); + + await this.add('FileUpload_Restrict_to_users_who_can_access_room', false, { + type: 'boolean', + enableQuery: [ + { + _id: 'FileUpload_ProtectFiles', + value: true, + }, + { + _id: 'FileUpload_Restrict_to_room_members', + value: false, + }, + ], + }); + + await this.add('FileUpload_RotateImages', true, { + type: 'boolean', + public: true, + }); + + await this.add('FileUpload_Enable_json_web_token_for_files', true, { + type: 'boolean', + i18nLabel: 'FileUpload_Enable_json_web_token_for_files', + i18nDescription: 'FileUpload_Enable_json_web_token_for_files_description', + alert: 'FileUpload_Enable_json_web_token_for_files_Alert', + enableQuery: { + _id: 'FileUpload_ProtectFiles', + value: true, + }, + }); + + await this.add('FileUpload_json_web_token_secret_for_files', '', { + type: 'string', + i18nLabel: 'FileUpload_json_web_token_secret_for_files', + i18nDescription: 'FileUpload_json_web_token_secret_for_files_description', + enableQuery: { + _id: 'FileUpload_Enable_json_web_token_for_files', + value: true, + }, + }); + + await this.add('FileUpload_Storage_Type', 'GridFS', { + type: 'select', + values: [ + { + key: 'GridFS', + i18nLabel: 'GridFS', + }, + { + key: 'AmazonS3', + i18nLabel: 'AmazonS3', + }, + { + key: 'GoogleCloudStorage', + i18nLabel: 'GoogleCloudStorage', + }, + { + key: 'Webdav', + i18nLabel: 'WebDAV', + }, + { + key: 'FileSystem', + i18nLabel: 'FileSystem', + }, + ], + public: true, + }); + + await this.section('Amazon S3', async function () { + await this.add('FileUpload_S3_Bucket', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_Acl', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_AWSAccessKeyId', '', { + type: 'password', + autocomplete: false, + secret: true, + i18nLabel: 'FileUpload_S3_AWSAccessKeyId', + i18nDescription: 'FileUpload_S3_AWSAccessKeyId_desc', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_AWSSecretAccessKey', '', { + type: 'password', + autocomplete: false, + secret: true, + i18nLabel: 'FileUpload_S3_AWSSecretAccessKey', + i18nDescription: 'FileUpload_S3_AWSSecretAccessKey_desc', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_CDN', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_Region', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_BucketURL', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + i18nDescription: 'Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given.', + secret: true, + }); + await this.add('FileUpload_S3_ForcePathStyle', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_URLExpiryTimeSpan', 120, { + type: 'int', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + i18nDescription: 'FileUpload_S3_URLExpiryTimeSpan_Description', + }); + await this.add('FileUpload_S3_Proxy_Avatars', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_Proxy_Uploads', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + await this.add('FileUpload_S3_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); + }); + + await this.section('Google Cloud Storage', async function () { + await this.add('FileUpload_GoogleStorage_Bucket', '', { + type: 'string', + private: true, + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + secret: true, + }); + await this.add('FileUpload_GoogleStorage_AccessId', '', { + type: 'string', + private: true, + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + secret: true, + }); + await this.add('FileUpload_GoogleStorage_Secret', '', { + type: 'string', + multiline: true, + private: true, + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + secret: true, + }); + + await this.add('FileUpload_GoogleStorage_ProjectId', '', { + type: 'string', + multiline: false, + private: true, + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + secret: true, + }); + + await this.add('FileUpload_GoogleStorage_URLExpiryTimeSpan', 120, { + type: 'int', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + i18nDescription: 'FileUpload_GoogleStorage_URLExpiryTimeSpan_Description', + }); + + await this.add('FileUpload_GoogleStorage_Proxy_Avatars', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + }); + await this.add('FileUpload_GoogleStorage_Proxy_Uploads', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + }); + await this.add('FileUpload_GoogleStorage_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + }); + }); + + await this.section('File System', async function () { + await this.add('FileUpload_FileSystemPath', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'FileSystem', + }, + }); + }); + + await this.section('WebDAV', async function () { + await this.add('FileUpload_Webdav_Upload_Folder_Path', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); + await this.add('FileUpload_Webdav_Server_URL', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); + await this.add('FileUpload_Webdav_Username', '', { + type: 'string', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + secret: true, + }); + await this.add('FileUpload_Webdav_Password', '', { + type: 'password', + private: true, + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + secret: true, + }); + await this.add('FileUpload_Webdav_Proxy_Avatars', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); + await this.add('FileUpload_Webdav_Proxy_Uploads', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); + await this.add('FileUpload_Webdav_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); + }); + + await this.add('FileUpload_Enabled_Direct', true, { + type: 'boolean', + public: true, + }); + }); diff --git a/apps/meteor/server/settings/functions/convertValue.ts b/apps/meteor/server/settings/functions/convertValue.ts new file mode 100644 index 0000000..7ba6758 --- /dev/null +++ b/apps/meteor/server/settings/functions/convertValue.ts @@ -0,0 +1,17 @@ +import type { ISetting, SettingValue } from '@rocket.chat/core-typings'; + +export const convertValue = (value: 'true' | 'false' | string, type: ISetting['type']): SettingValue => { + if (value.toLowerCase() === 'true') { + return true; + } + if (value.toLowerCase() === 'false') { + return false; + } + if (type === 'int') { + return parseInt(value); + } + if (type === 'multiSelect') { + return JSON.parse(value); + } + return value; +}; diff --git a/apps/meteor/server/settings/functions/getSettingDefaults.ts b/apps/meteor/server/settings/functions/getSettingDefaults.ts new file mode 100644 index 0000000..e865fcf --- /dev/null +++ b/apps/meteor/server/settings/functions/getSettingDefaults.ts @@ -0,0 +1,46 @@ +import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import { isSettingColor, isSettingRange } from '@rocket.chat/core-typings'; + +export const getSettingDefaults = ( + setting: Partial & Pick, + blockedSettings: Set = new Set(), + hiddenSettings: Set = new Set(), + wizardRequiredSettings: Set = new Set(), +): ISetting => { + const { _id, value, sorter, ...data } = setting; + + const options = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)) as Partial; + + return { + _id, + value, + packageValue: value, + valueSource: 'packageValue', + secret: false, + enterprise: false, + i18nDescription: `${_id}_Description`, + autocomplete: true, + sorter: sorter || 0, + ts: new Date(), + createdAt: new Date(), + _updatedAt: options._updatedAt ?? new Date(), + ...options, + ...(options.enableQuery ? { enableQuery: JSON.stringify(options.enableQuery) } : undefined), + ...(options.validation ? { validation: JSON.stringify(options.validation) } : undefined), + i18nLabel: options.i18nLabel || _id, + hidden: options.hidden || hiddenSettings.has(_id), + blocked: options.blocked || blockedSettings.has(_id), + requiredOnWizard: options.requiredOnWizard || wizardRequiredSettings.has(_id), + type: options.type || 'string', + env: options.env || false, + public: options.public || false, + ...(options.displayQuery ? { displayQuery: JSON.stringify(options.displayQuery) } : undefined), + ...(isSettingColor(setting as ISetting) && { + packageEditor: (setting as ISettingColor).editor, + }), + ...(isSettingRange(setting as ISetting) && { + minValue: 0, + maxValue: 100, + }), + }; +}; diff --git a/apps/meteor/server/settings/functions/overrideGenerator.ts b/apps/meteor/server/settings/functions/overrideGenerator.ts new file mode 100644 index 0000000..7e62385 --- /dev/null +++ b/apps/meteor/server/settings/functions/overrideGenerator.ts @@ -0,0 +1,57 @@ +import type { ISetting, SettingValueMultiSelect, SettingValueRoomPick } from '@rocket.chat/core-typings'; + +import { convertValue } from './convertValue'; + +const compareSettingsValue = (a: ISetting['value'], b: ISetting['value'], type?: ISetting['type']): boolean => { + if (Array.isArray(a) && Array.isArray(b)) { + if (type === 'multiSelect') { + a = a as SettingValueMultiSelect; + b = b as SettingValueMultiSelect; + return a.length === b.length && a.every((value, index) => compareSettingsValue(value, (b as SettingValueMultiSelect)[index])); + } + + if (type === 'roomPick') { + a = a as SettingValueRoomPick; + b = b as SettingValueRoomPick; + return ( + a.length === b.length && + a.every((value, index) => { + return Object.keys(value).every((key) => + compareSettingsValue( + value[key as keyof typeof value], + (b as SettingValueRoomPick)[index as keyof typeof b][key as keyof typeof b], + ), + ); + }) + ); + } + } + return a === b; +}; + +export const overrideGenerator = + (fn: (key: string) => string | undefined) => + (setting: ISetting): ISetting => { + const overwriteValue = fn(setting._id); + if (overwriteValue === null || overwriteValue === undefined) { + return setting; + } + + try { + const value = convertValue(overwriteValue, setting.type); + + if (compareSettingsValue(value, setting.value, setting.type) && compareSettingsValue(value, setting.processEnvValue, setting.type)) { + return setting; + } + + return { + ...setting, + value, + processEnvValue: value, + valueSource: 'processEnvValue', + }; + } catch (error) { + console.error(`Error converting value for setting ${setting._id} expected "${setting.type}" type`); + return setting; + } + }; diff --git a/apps/meteor/server/settings/functions/overrideSetting.ts b/apps/meteor/server/settings/functions/overrideSetting.ts new file mode 100644 index 0000000..d51b74e --- /dev/null +++ b/apps/meteor/server/settings/functions/overrideSetting.ts @@ -0,0 +1,3 @@ +import { overrideGenerator } from './overrideGenerator'; + +export const overrideSetting = overrideGenerator((key: string) => process.env[key]); diff --git a/apps/meteor/server/settings/functions/overwriteSetting.ts b/apps/meteor/server/settings/functions/overwriteSetting.ts new file mode 100644 index 0000000..5eba462 --- /dev/null +++ b/apps/meteor/server/settings/functions/overwriteSetting.ts @@ -0,0 +1,3 @@ +import { overrideGenerator } from './overrideGenerator'; + +export const overwriteSetting = overrideGenerator((key: string) => process.env[`OVERWRITE_SETTING_${key}`]); diff --git a/apps/meteor/server/settings/functions/settingSchemas.ts b/apps/meteor/server/settings/functions/settingSchemas.ts new file mode 100644 index 0000000..0160a2b --- /dev/null +++ b/apps/meteor/server/settings/functions/settingSchemas.ts @@ -0,0 +1,10 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { ajv } from '@rocket.chat/rest-typings'; + +const schemas = new Map>(); + +export const registerSettingSchema = (_id: ISetting['_id'], schema: Record): void => { + schemas.set(_id, ajv.compile(schema)); +}; + +export const getSettingSchemaValidator = (_id: ISetting['_id']): ReturnType | undefined => schemas.get(_id); diff --git a/apps/meteor/server/settings/functions/settings.mocks.ts b/apps/meteor/server/settings/functions/settings.mocks.ts new file mode 100644 index 0000000..758f96a --- /dev/null +++ b/apps/meteor/server/settings/functions/settings.mocks.ts @@ -0,0 +1,112 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +import type { ICachedSettings } from '../CachedSettings'; + +type Dictionary = { + [index: string]: any; +}; + +class SettingsClass { + settings: ICachedSettings; + + private delay = 0; + + setDelay(delay: number): void { + this.delay = delay; + } + + find(): any[] { + return []; + } + + public data = new Map(); + + public upsertCalls = 0; + + public insertCalls = 0; + + private checkQueryMatch(key: string, data: Dictionary, queryValue: any): boolean { + if (typeof queryValue === 'object') { + if (queryValue.$exists !== undefined) { + return (data.hasOwnProperty(key) && data[key] !== undefined) === queryValue.$exists; + } + } + + return queryValue === data[key]; + } + + findOne(query: Dictionary): any { + return [...this.data.values()].find((data) => Object.entries(query).every(([key, value]) => this.checkQueryMatch(key, data, value))); + } + + insertOne(doc: any): void { + this.data.set(doc._id, doc); + + this.settings.set(doc); + this.insertCalls++; + } + + updateOne(query: any, update: any, options?: any): void { + const existent = this.findOne(query); + + const data = { ...existent, ...query, ...update, ...update.$set }; + + if (!existent) { + Object.assign(data, update.$setOnInsert); + } + + if (update.$unset) { + Object.keys(update.$unset).forEach((key) => { + delete data[key]; + }); + } + + const modifiers = ['$set', '$setOnInsert', '$unset']; + + modifiers.forEach((key) => { + delete data[key]; + }); + + if (options?.upsert === true && !modifiers.some((key) => Object.keys(update).includes(key))) { + throw new Error('Invalid upsert'); + } + + if (this.delay) { + setTimeout(() => { + // console.log(query, data); + this.data.set(query._id, data); + + // Can't import before the mock command on end of this file! + + this.settings.set(data); + }, this.delay); + } else { + this.data.set(query._id, data); + // Can't import before the mock command on end of this file! + + this.settings.set(data); + } + + this.upsertCalls++; + } + + findOneAndUpdate({ _id }: { _id: string }, value: any, options?: any) { + this.updateOne({ _id }, value, options); + return { value: this.findOne({ _id }) }; + } + + updateValueById(id: string, value: any): void { + this.data.set(id, { ...this.data.get(id), value }); + // Can't import before the mock command on end of this file! + + if (this.delay) { + setTimeout(() => { + this.settings.set(this.data.get(id) as ISetting); + }, this.delay); + } else { + this.settings.set(this.data.get(id) as ISetting); + } + } +} + +export const Settings = new SettingsClass(); diff --git a/apps/meteor/server/settings/functions/validateSetting.ts b/apps/meteor/server/settings/functions/validateSetting.ts new file mode 100644 index 0000000..d0ba5bb --- /dev/null +++ b/apps/meteor/server/settings/functions/validateSetting.ts @@ -0,0 +1,59 @@ +import { isActionSettingWithEndpoint, type ISetting } from '@rocket.chat/core-typings'; + +export const validateSetting = (_id: T['_id'], type: T['type'], value: T['value'] | unknown): boolean => { + switch (type) { + case 'asset': + if (typeof value !== 'object') { + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); + } + break; + case 'action': + if (typeof value !== 'string' && !isActionSettingWithEndpoint(value as never)) { + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); + } + break; + case 'string': + case 'relativeUrl': + case 'password': + case 'language': + case 'color': + case 'font': + case 'code': + case 'roomPick': + case 'group': + if (typeof value !== 'string') { + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + throw new Error(`Setting ${_id} is of type boolean but got ${typeof value}`); + } + break; + case 'int': + if (typeof value !== 'number') { + throw new Error(`Setting ${_id} is of type int but got ${typeof value}`); + } + break; + case 'multiSelect': + if (!Array.isArray(value)) { + throw new Error(`Setting ${_id} is of type array but got ${typeof value}`); + } + break; + case 'select': + case 'lookup': + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); + } + break; + case 'date': + if (!(value instanceof Date)) { + throw new Error(`Setting ${_id} is of type date but got ${typeof value}`); + } + break; + default: + return true; + } + + return true; +}; diff --git a/apps/meteor/server/settings/functions/validationRuleBuilders.ts b/apps/meteor/server/settings/functions/validationRuleBuilders.ts new file mode 100644 index 0000000..cfa738e --- /dev/null +++ b/apps/meteor/server/settings/functions/validationRuleBuilders.ts @@ -0,0 +1,14 @@ +import type { ISetting, SettingValidationRule } from '@rocket.chat/core-typings'; + +export const positiveOrDisabled = (): SettingValidationRule => ({ + query: { $or: [{ value: -1 }, { value: { $gte: 1 } }] }, +}); + +export const notGreaterThanSetting = (otherId: ISetting['_id']): SettingValidationRule => ({ + query: { $or: [{ value: { $lt: 1 } }, { value: { $lte: { $setting: otherId } } }] }, + appliesWhen: { _id: otherId, value: { $gte: 1 } }, +}); + +export const notLowerThanSetting = (otherId: ISetting['_id']): SettingValidationRule => ({ + query: { $or: [{ value: { $lt: 1 } }, { value: { $gte: { $setting: otherId } } }] }, +}); diff --git a/apps/meteor/server/settings/general.ts b/apps/meteor/server/settings/general.ts new file mode 100644 index 0000000..8dfa4eb --- /dev/null +++ b/apps/meteor/server/settings/general.ts @@ -0,0 +1,401 @@ +import { settingsRegistry } from '.'; + +export const createGeneralSettings = async () => { + await settingsRegistry.addGroup('General', async function () { + await this.section('REST API', async function () { + await this.add('API_Upper_Count_Limit', 100, { type: 'int', public: false }); + await this.add('API_Default_Count', 50, { type: 'int', public: false }); + await this.add('API_Allow_Infinite_Count', true, { type: 'boolean', public: false }); + await this.add('API_Enable_Direct_Message_History_EndPoint', false, { + type: 'boolean', + public: false, + }); + await this.add('API_Enable_Shields', true, { type: 'boolean', public: false }); + await this.add('API_Shield_Types', '*', { + type: 'string', + public: false, + enableQuery: { _id: 'API_Enable_Shields', value: true }, + }); + await this.add('API_Shield_user_require_auth', false, { + type: 'boolean', + public: false, + enableQuery: { _id: 'API_Enable_Shields', value: true }, + }); + await this.add('API_Enable_CORS', false, { type: 'boolean', public: false }); + await this.add('API_CORS_Origin', '*', { + type: 'string', + public: false, + enableQuery: { _id: 'API_Enable_CORS', value: true }, + }); + + // Should enforce the permission on next Major and remove this setting + await this.add('API_Apply_permission_view-outside-room_on_users-list', false, { + type: 'boolean', + public: true, + alert: 'This_is_a_deprecated_feature_alert', + }); + }); + await this.add('Show_Setup_Wizard', 'pending', { + type: 'select', + public: true, + readonly: true, + values: [ + { + key: 'pending', + i18nLabel: 'Pending', + }, + { + key: 'in_progress', + i18nLabel: 'In_progress', + }, + { + key: 'completed', + i18nLabel: 'Completed', + }, + ], + }); + + await this.add( + 'Site_Url', + typeof (global as any).__meteor_runtime_config__ !== 'undefined' && (global as any).__meteor_runtime_config__ !== null + ? (global as any).__meteor_runtime_config__.ROOT_URL + : null, + { + type: 'string', + i18nDescription: 'Site_Url_Description', + public: true, + }, + ); + await this.add('Site_Name', 'Rocket.Chat', { + type: 'string', + public: true, + wizard: { + step: 3, + order: 0, + }, + }); + await this.add('Document_Domain', '', { + type: 'string', + public: true, + }); + await this.add('Language', '', { + type: 'language', + public: true, + wizard: { + step: 3, + order: 1, + }, + alert: 'Language_setting_warning', + }); + await this.add('Allow_Invalid_SelfSigned_Certs', false, { + type: 'boolean', + secret: true, + }); + + await this.add('Enable_CSP', true, { + type: 'boolean', + }); + + await this.add('Use_RC_SDK', false, { + type: 'boolean', + public: true, + alert: 'Use_RC_SDK_Alert', + i18nDescription: 'Use_RC_SDK_Description', + }); + + await this.add('Extra_CSP_Domains', '', { + type: 'string', + multiline: true, + }); + + await this.add('Iframe_Restrict_Access', true, { + type: 'boolean', + secret: true, + }); + await this.add('Iframe_X_Frame_Options', 'sameorigin', { + type: 'string', + secret: true, + enableQuery: { + _id: 'Iframe_Restrict_Access', + value: true, + }, + }); + await this.add('Favorite_Rooms', true, { + type: 'boolean', + public: true, + }); + await this.add('First_Channel_After_Login', '', { + type: 'string', + public: true, + }); + await this.add('Unread_Count', 'user_and_group_mentions_only', { + type: 'select', + values: [ + { + key: 'all_messages', + i18nLabel: 'All_messages', + }, + { + key: 'user_mentions_only', + i18nLabel: 'User_mentions_only', + }, + { + key: 'group_mentions_only', + i18nLabel: 'Group_mentions_only', + }, + { + key: 'user_and_group_mentions_only', + i18nLabel: 'User_and_group_mentions_only', + }, + ], + public: true, + }); + await this.add('Unread_Count_DM', 'all_messages', { + type: 'select', + values: [ + { + key: 'all_messages', + i18nLabel: 'All_messages', + }, + { + key: 'mentions_only', + i18nLabel: 'Mentions_only', + }, + ], + public: true, + }); + await this.add('Unread_Count_Omni', 'all_messages', { + type: 'select', + values: [ + { + key: 'all_messages', + i18nLabel: 'All_messages', + }, + { + key: 'mentions_only', + i18nLabel: 'Mentions_only', + }, + ], + public: true, + }); + + await this.add('DeepLink_Url', 'https://go.rocket.chat', { + type: 'string', + public: true, + }); + + await this.add('CDN_PREFIX', '', { + type: 'string', + public: true, + }); + await this.add('CDN_PREFIX_ALL', true, { + type: 'boolean', + public: true, + }); + await this.add('CDN_JSCSS_PREFIX', '', { + type: 'string', + public: true, + enableQuery: { + _id: 'CDN_PREFIX_ALL', + value: false, + }, + }); + await this.add('Force_SSL', false, { + type: 'boolean', + public: true, + }); + + await this.add('GoogleTagManager_id', '', { + type: 'string', + public: true, + secret: true, + }); + await this.add('Bugsnag_api_key', '', { + type: 'string', + public: false, + secret: true, + }); + await this.add('Restart', 'restart_server', { + type: 'action', + actionText: 'Restart_the_server', + }); + await this.add('Store_Last_Message', true, { + type: 'boolean', + public: true, + i18nDescription: 'Store_Last_Message_Sent_per_Room', + }); + await this.add('Robot_Instructions_File_Content', 'User-agent: *\nDisallow: /', { + type: 'string', + public: true, + multiline: true, + }); + await this.add('Default_Referrer_Policy', 'same-origin', { + type: 'select', + values: [ + { + key: 'no-referrer', + i18nLabel: 'No_Referrer', + }, + { + key: 'no-referrer-when-downgrade', + i18nLabel: 'No_Referrer_When_Downgrade', + }, + { + key: 'origin', + i18nLabel: 'Origin', + }, + { + key: 'origin-when-cross-origin', + i18nLabel: 'Origin_When_Cross_Origin', + }, + { + key: 'same-origin', + i18nLabel: 'Same_Origin', + }, + { + key: 'strict-origin', + i18nLabel: 'Strict_Origin', + }, + { + key: 'strict-origin-when-cross-origin', + i18nLabel: 'Strict_Origin_When_Cross_Origin', + }, + { + key: 'unsafe-url', + i18nLabel: 'Unsafe_Url', + }, + ], + public: true, + }); + await this.section('UTF8', async function () { + await this.add('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+', { + type: 'string', + public: true, + i18nDescription: 'UTF8_User_Names_Validation_Description', + }); + await this.add('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+', { + type: 'string', + public: true, + i18nDescription: 'UTF8_Channel_Names_Validation_Description', + }); + return this.add('UTF8_Names_Slugify', true, { + type: 'boolean', + public: true, + }); + }); + await this.section('Reporting', async function () { + return this.add('Statistics_reporting', true, { + type: 'boolean', + hidden: true, + }); + }); + await this.section('Notifications', async function () { + await this.add('Notifications_Max_Room_Members', 100, { + type: 'int', + public: true, + i18nDescription: 'Notifications_Max_Room_Members_Description', + }); + }); + await this.section('REST API', async function () { + return this.add('API_User_Limit', 500, { + type: 'int', + public: true, + i18nDescription: 'API_User_Limit', + }); + }); + await this.section('Iframe_Integration', async function () { + await this.add('Iframe_Integration_send_enable', false, { + type: 'boolean', + public: true, + }); + await this.add('Iframe_Integration_send_target_origin', '*', { + type: 'string', + public: true, + enableQuery: { + _id: 'Iframe_Integration_send_enable', + value: true, + }, + }); + await this.add('Iframe_Integration_receive_enable', false, { + type: 'boolean', + public: true, + }); + return this.add('Iframe_Integration_receive_origin', '*', { + type: 'string', + public: true, + enableQuery: { + _id: 'Iframe_Integration_receive_enable', + value: true, + }, + }); + }); + await this.section('Translations', async function () { + return this.add('Custom_Translations', '', { + type: 'code', + code: 'application/json', + public: true, + }); + }); + await this.section('Stream_Cast', async function () { + return this.add('Stream_Cast_Address', '', { + type: 'string', + }); + }); + await this.section('NPS', async function () { + await this.add('NPS_survey_enabled', true, { + type: 'boolean', + }); + }); + await this.section('Timezone', async function () { + await this.add('Default_Timezone_For_Reporting', 'server', { + type: 'select', + values: [ + { + key: 'server', + i18nLabel: 'Default_Server_Timezone', + }, + { + key: 'custom', + i18nLabel: 'Default_Custom_Timezone', + }, + { + key: 'user', + i18nLabel: 'Default_User_Timezone', + }, + ], + }); + await this.add('Default_Custom_Timezone', '', { + type: 'timezone', + enableQuery: { + _id: 'Default_Timezone_For_Reporting', + value: 'custom', + }, + }); + }); + await this.section('Update', async function () { + await this.add('Update_LatestAvailableVersion', '0.0.0', { + type: 'string', + readonly: true, + }); + + await this.add('Update_EnableChecker', true, { + type: 'boolean', + enableQuery: { + _id: 'Register_Server', + value: true, + }, + i18nDescription: 'Update_EnableChecker_Description', + }); + }); + + await this.section('SSRF_Protection', async function () { + await this.add('SSRF_Allowlist', '', { + type: 'string', + multiline: true, + public: false, + i18nLabel: 'SSRF_Allowlist', + i18nDescription: 'SSRF_Allowlist_Description', + }); + }); + }); +}; diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts new file mode 100644 index 0000000..30a8c9f --- /dev/null +++ b/apps/meteor/server/settings/index.ts @@ -0,0 +1,24 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { Settings } from '@rocket.chat/models'; + +import { use } from './Middleware'; +import { SettingsRegistry } from './SettingsRegistry'; +import { settings } from './cached'; +import { initializeSettings } from './startup'; +import './applyMiddlewares'; + +export { SettingsEvents } from './SettingsRegistry'; + +export { settings }; + +export const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings }); + +settingsRegistry.add = use(settingsRegistry.add, async (context, next) => { + return next(...context) as any; +}); + +settingsRegistry.addGroup = use(settingsRegistry.addGroup, async (context, next) => { + return next(...context) as any; +}); + +await initializeSettings({ model: Settings, settings }); diff --git a/apps/meteor/server/settings/irc.ts b/apps/meteor/server/settings/irc.ts new file mode 100644 index 0000000..d835a65 --- /dev/null +++ b/apps/meteor/server/settings/irc.ts @@ -0,0 +1,65 @@ +import { settingsRegistry } from '.'; + +export const createIRCSettings = () => + settingsRegistry.addGroup('IRC_Federation', async function () { + await this.add('IRC_Enabled', false, { + type: 'boolean', + i18nLabel: 'Enabled', + i18nDescription: 'IRC_Enabled', + alert: 'IRC_Enabled_Alert', + }); + + await this.add('IRC_Protocol', 'RFC2813', { + type: 'select', + i18nLabel: 'Protocol', + i18nDescription: 'IRC_Protocol', + values: [ + { + key: 'RFC2813', + i18nLabel: 'RFC2813', + }, + ], + }); + + await this.add('IRC_Host', 'localhost', { + type: 'string', + i18nLabel: 'Host', + i18nDescription: 'IRC_Host', + }); + + await this.add('IRC_Port', 6667, { + type: 'int', + i18nLabel: 'Port', + i18nDescription: 'IRC_Port', + }); + + await this.add('IRC_Name', 'irc.rocket.chat', { + type: 'string', + i18nLabel: 'Name', + i18nDescription: 'IRC_Name', + }); + + await this.add('IRC_Description', 'Rocket.Chat IRC Bridge', { + type: 'string', + i18nLabel: 'Description', + i18nDescription: 'IRC_Description', + }); + + await this.add('IRC_Local_Password', 'password', { + type: 'string', + i18nLabel: 'Local_Password', + i18nDescription: 'IRC_Local_Password', + }); + + await this.add('IRC_Peer_Password', 'password', { + type: 'string', + i18nLabel: 'Peer_Password', + i18nDescription: 'IRC_Peer_Password', + }); + + await this.add('IRC_Reset_Connection', 'resetIrcConnection', { + type: 'action', + actionText: 'Reset_Connection', + i18nLabel: 'Reset_Connection', + }); + }); diff --git a/apps/meteor/server/settings/layout.ts b/apps/meteor/server/settings/layout.ts new file mode 100644 index 0000000..e8e9ad0 --- /dev/null +++ b/apps/meteor/server/settings/layout.ts @@ -0,0 +1,231 @@ +import { settingsRegistry } from '.'; +import roomToolboxLayoutSchema from '../../lib/roomToolboxLayout/room-toolbox-layout.schema.json'; + +export const createLayoutSettings = () => + settingsRegistry.addGroup('Layout', async function () { + await this.section('Login', async function () { + await this.add('Layout_Login_Hide_Logo', false, { + type: 'boolean', + public: true, + enterprise: true, + invalidValue: false, + modules: ['hide-watermark'], + }); + await this.add('Layout_Login_Hide_Title', false, { + type: 'boolean', + public: true, + enterprise: true, + invalidValue: false, + modules: ['hide-watermark'], + }); + await this.add('Layout_Login_Hide_Powered_By', false, { + type: 'boolean', + public: true, + enterprise: true, + invalidValue: false, + modules: ['hide-watermark'], + }); + await this.add('Layout_Login_Template', 'horizontal-template', { + type: 'select', + values: [ + { + key: 'vertical-template', + i18nLabel: 'Layout_Login_Template_Vertical', + }, + { + key: 'horizontal-template', + i18nLabel: 'Layout_Login_Template_Horizontal', + }, + ], + public: true, + enterprise: true, + invalidValue: 'horizontal-template', + modules: ['hide-watermark'], + }); + await this.add('Accounts_ShowFormLogin', true, { + type: 'boolean', + public: true, + }); + }); + await this.section('Layout_Home_Page_Content_Title', async function () { + await this.add('Layout_Home_Title', 'Home', { + type: 'string', + public: true, + }); + await this.add('Layout_Show_Home_Button', true, { + type: 'boolean', + public: true, + }); + await this.add('Layout_Home_Body', '', { + i18nDescription: 'Layout_Custom_Content_Description', + type: 'code', + code: 'text/html', + multiline: true, + public: true, + }); + await this.add('Layout_Home_Custom_Block_Visible', false, { + type: 'boolean', + invalidValue: false, + public: true, + enableQuery: [ + { + _id: 'Layout_Home_Body', + value: { + $exists: true, + $ne: '', + }, + }, + { + _id: 'Layout_Custom_Body_Only', + value: { + $exists: true, + $ne: true, + }, + }, + ], + }); + await this.add('Layout_Custom_Body_Only', false, { + i18nDescription: 'Layout_Custom_Body_Only_Description', + type: 'boolean', + invalidValue: false, + enterprise: true, + public: true, + modules: ['hide-watermark'], + enableQuery: [ + { + _id: 'Layout_Home_Body', + value: { + $exists: true, + $ne: '', + }, + }, + { + _id: 'Layout_Home_Custom_Block_Visible', + value: { + $exists: true, + $ne: false, + }, + }, + ], + }); + await this.add('Layout_Terms_of_Service', 'Terms of Service
Go to APP SETTINGS → Layout to customize this page.', { + type: 'code', + code: 'text/html', + multiline: true, + public: true, + }); + await this.add('Layout_Login_Terms', '', { + type: 'code', + code: 'text/html', + multiline: true, + public: true, + }); + await this.add('Layout_Privacy_Policy', 'Privacy Policy
Go to APP SETTINGS → Layout to customize this page.', { + type: 'code', + code: 'text/html', + multiline: true, + public: true, + }); + await this.add('Layout_Legal_Notice', 'Legal Notice
Go to APP SETTINGS -> Layout to customize this page.', { + type: 'code', + code: 'text/html', + multiline: true, + public: true, + }); + await this.add('Layout_Sidenav_Footer_Dark', 'Home', { + type: 'code', + code: 'text/html', + public: true, + i18nDescription: 'Layout_Sidenav_Footer_description', + }); + return this.add('Layout_Sidenav_Footer', 'Home', { + type: 'code', + code: 'text/html', + public: true, + i18nDescription: 'Layout_Sidenav_Footer_description', + }); + }); + await this.section('Custom_Scripts', async function () { + await this.add('Custom_Script_On_Logout', '//Add your script', { + type: 'code', + multiline: true, + public: true, + }); + await this.add('Custom_Script_Logged_Out', '//Add your script', { + type: 'code', + multiline: true, + public: true, + }); + return this.add('Custom_Script_Logged_In', '//Add your script', { + type: 'code', + multiline: true, + public: true, + }); + }); + await this.section('User_Interface', async function () { + await this.add('UI_DisplayRoles', true, { + type: 'boolean', + public: true, + }); + await this.add('UI_Group_Channels_By_Type', true, { + type: 'boolean', + public: false, + }); + await this.add('UI_Use_Name_Avatar', false, { + type: 'boolean', + public: true, + }); + await this.add('UI_Use_Real_Name', false, { + type: 'boolean', + public: true, + }); + + await this.add('Number_of_users_autocomplete_suggestions', 5, { + type: 'int', + public: true, + }); + + await this.add('UI_Unread_Counter_Style', 'Different_Style_For_User_Mentions', { + type: 'select', + values: [ + { + key: 'Same_Style_For_Mentions', + i18nLabel: 'Same_Style_For_Mentions', + }, + { + key: 'Different_Style_For_User_Mentions', + i18nLabel: 'Different_Style_For_User_Mentions', + }, + ], + public: true, + }); + await this.add('UI_Allow_room_names_with_special_chars', false, { + type: 'boolean', + public: true, + }); + return this.add('UI_Show_top_navbar_embedded_layout', false, { + type: 'boolean', + public: true, + }); + }); + await this.section('Custom CSS', async function () { + await this.add('theme-custom-css', '', { + type: 'code', + code: 'text/css', + multiline: true, + public: true, + }); + }); + await this.section('Room_Header', async function () { + await this.add('Room_Toolbox_Layout', '', { + type: 'code', + code: 'application/json', + multiline: true, + public: true, + enableQuery: [{ _id: 'Accounts_AllowFeaturePreview', value: true }], + i18nLabel: 'Room_Toolbox_Layout', + i18nDescription: 'Room_Toolbox_Layout_Setting_description', + schema: roomToolboxLayoutSchema, + }); + }); + }); diff --git a/apps/meteor/server/settings/ldap.ts b/apps/meteor/server/settings/ldap.ts new file mode 100644 index 0000000..812ef1e --- /dev/null +++ b/apps/meteor/server/settings/ldap.ts @@ -0,0 +1,250 @@ +import { settingsRegistry } from '.'; + +export const createLdapSettings = () => + settingsRegistry.addGroup('LDAP', async function () { + const enableQuery = { _id: 'LDAP_Enable', value: true }; + const adOnly = { _id: 'LDAP_Server_Type', value: 'ad' }; + const ldapOnly = { _id: 'LDAP_Server_Type', value: '' }; + + await this.with({ tab: 'LDAP_Connection' }, async function () { + await this.add('LDAP_Enable', false, { type: 'boolean', public: true, alert: 'Premium_required_from_9_0_0_alert' }); + + await this.add('LDAP_Server_Type', 'ad', { + type: 'select', + public: true, + enableQuery, + values: [ + { key: 'ad', i18nLabel: 'LDAP_Server_Type_AD' }, + { key: '', i18nLabel: 'LDAP_Server_Type_Other' }, + ], + }); + + await this.add('LDAP_Host', '', { type: 'string', enableQuery }); + await this.add('LDAP_Port', 389, { type: 'int', enableQuery }); + await this.add('LDAP_Reconnect', false, { type: 'boolean', enableQuery }); + + await this.add('LDAP_Login_Fallback', false, { type: 'boolean', enableQuery }); + + await this.section('LDAP_Connection_Authentication', async function () { + const enableAuthentication = [enableQuery, { _id: 'LDAP_Authentication', value: true }]; + + await this.add('LDAP_Authentication', false, { type: 'boolean', enableQuery, invalidValue: false }); + await this.add('LDAP_Authentication_UserDN', '', { + type: 'string', + enableQuery: enableAuthentication, + secret: true, + invalidValue: '', + }); + await this.add('LDAP_Authentication_Password', '', { + type: 'password', + enableQuery: enableAuthentication, + secret: true, + invalidValue: '', + }); + }); + + await this.section('LDAP_Connection_Encryption', async function () { + await this.add('LDAP_Encryption', 'plain', { + type: 'select', + values: [ + { key: 'plain', i18nLabel: 'No_Encryption' }, + { key: 'tls', i18nLabel: 'StartTLS' }, + { key: 'ssl', i18nLabel: 'SSL/LDAPS' }, + ], + enableQuery, + }); + + const enableTLSQuery = [enableQuery, { _id: 'LDAP_Encryption', value: { $in: ['tls', 'ssl'] } }]; + + await this.add('LDAP_CA_Cert', '', { + type: 'string', + multiline: true, + enableQuery: enableTLSQuery, + secret: true, + }); + await this.add('LDAP_Reject_Unauthorized', true, { type: 'boolean', enableQuery: enableTLSQuery }); + }); + + await this.section('LDAP_Connection_Timeouts', async function () { + await this.add('LDAP_Timeout', 60000, { type: 'int', enableQuery }); + await this.add('LDAP_Connect_Timeout', 1000, { type: 'int', enableQuery }); + await this.add('LDAP_Idle_Timeout', 1000, { type: 'int', enableQuery }); + }); + }); + + await this.with({ tab: 'LDAP_UserSearch' }, async function () { + await this.add('LDAP_Find_User_After_Login', true, { type: 'boolean', enableQuery }); + + await this.section('LDAP_UserSearch_Filter', async function () { + await this.add('LDAP_BaseDN', '', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_User_Search_Filter', '(objectclass=*)', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_User_Search_Scope', 'sub', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_AD_User_Search_Field', 'sAMAccountName', { + i18nLabel: 'LDAP_User_Search_Field', + i18nDescription: 'LDAP_User_Search_Field_Description', + type: 'string', + enableQuery, + displayQuery: adOnly, + }); + await this.add('LDAP_User_Search_Field', 'uid', { + type: 'string', + enableQuery, + displayQuery: ldapOnly, + }); + await this.add('LDAP_Search_Page_Size', 250, { + type: 'int', + enableQuery, + }); + + await this.add('LDAP_Search_Size_Limit', 1000, { + type: 'int', + enableQuery, + }); + }); + + await this.section('LDAP_UserSearch_GroupFilter', async function () { + const groupFilterQuery = [enableQuery, { _id: 'LDAP_Group_Filter_Enable', value: true }]; + + await this.add('LDAP_Group_Filter_Enable', false, { type: 'boolean', enableQuery }); + await this.add('LDAP_Group_Filter_ObjectClass', 'groupOfUniqueNames', { + type: 'string', + enableQuery: groupFilterQuery, + }); + await this.add('LDAP_Group_Filter_Group_Id_Attribute', 'cn', { + type: 'string', + enableQuery: groupFilterQuery, + }); + await this.add('LDAP_Group_Filter_Group_Member_Attribute', 'uniqueMember', { + type: 'string', + enableQuery: groupFilterQuery, + }); + await this.add('LDAP_Group_Filter_Group_Member_Format', '', { + type: 'string', + enableQuery: groupFilterQuery, + }); + await this.add('LDAP_Group_Filter_Group_Name', 'ROCKET_CHAT', { + type: 'string', + enableQuery: groupFilterQuery, + }); + }); + }); + + await this.with({ tab: 'LDAP_DataSync' }, async function () { + await this.add('LDAP_Unique_Identifier_Field', 'objectGUID,ibm-entryUUID,GUID,dominoUNID,nsuniqueId,uidNumber,uid', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_Merge_Existing_Users', false, { + type: 'boolean', + enableQuery, + }); + + await this.add('LDAP_Update_Data_On_Login', true, { + type: 'boolean', + enableQuery, + }); + + await this.add('LDAP_Update_Data_On_OAuth_Login', false, { + type: 'boolean', + enableQuery: [enableQuery, { _id: 'LDAP_Update_Data_On_Login', value: true }], + }); + + await this.add('LDAP_Default_Domain', '', { + type: 'string', + enableQuery, + }); + + await this.section('LDAP_DataSync_DataMap', async function () { + await this.add('LDAP_AD_Username_Field', 'sAMAccountName', { + type: 'string', + enableQuery, + i18nLabel: 'LDAP_Username_Field', + i18nDescription: 'LDAP_Username_Field_Description', + displayQuery: adOnly, + }); + + await this.add('LDAP_Username_Field', 'uid', { + type: 'string', + enableQuery, + displayQuery: ldapOnly, + }); + + await this.add('LDAP_AD_Email_Field', 'mail', { + i18nLabel: 'LDAP_Email_Field', + i18nDescription: 'LDAP_Email_Field_Description', + type: 'string', + enableQuery, + displayQuery: adOnly, + }); + + await this.add('LDAP_Email_Field', 'mail', { + type: 'string', + enableQuery, + displayQuery: ldapOnly, + }); + + await this.add('LDAP_AD_Name_Field', 'cn', { + i18nLabel: 'LDAP_Name_Field', + i18nDescription: 'LDAP_Name_Field_Description', + type: 'string', + enableQuery, + displayQuery: adOnly, + }); + + await this.add('LDAP_Name_Field', 'cn', { + type: 'string', + enableQuery, + displayQuery: ldapOnly, + }); + + await this.add('LDAP_Extension_Field', '', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_FederationHomeServer_Field', '', { + type: 'string', + enableQuery, + }); + + await this.add('LDAP_DataSync_UseVariables', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + await this.add('LDAP_DataSync_VariableMap', '{}', { + type: 'code', + code: 'application/json', + multiline: true, + enableQuery: [enableQuery, { _id: 'LDAP_DataSync_UseVariables', value: true }], + invalidValue: '{}', + }); + }); + + await this.section('LDAP_DataSync_Avatar', async function () { + await this.add('LDAP_Sync_User_Avatar', true, { + type: 'boolean', + enableQuery, + }); + + await this.add('LDAP_Avatar_Field', '', { + type: 'string', + enableQuery, + }); + }); + }); + }); diff --git a/apps/meteor/server/settings/lib/auditedSettingUpdates.spec.ts b/apps/meteor/server/settings/lib/auditedSettingUpdates.spec.ts new file mode 100644 index 0000000..a438a1c --- /dev/null +++ b/apps/meteor/server/settings/lib/auditedSettingUpdates.spec.ts @@ -0,0 +1,307 @@ +import type { ISetting, SettingValue } from '@rocket.chat/core-typings'; + +import { updateAuditedByUser, updateAuditedBySystem, updateAuditedByApp, resetAuditedSettingByUser } from './auditedSettingUpdates'; + +const mockCreateAuditServerEvent = jest.fn(); +jest.mock('@rocket.chat/models', () => ({ + ServerEvents: { + createAuditServerEvent: (...args: any[]) => mockCreateAuditServerEvent(...args), + }, +})); + +const mockSettings = new Map(); + +jest.mock('../cached', () => { + const mockGetSetting = jest.fn((key: string) => mockSettings.get(key)); + return { + settings: { + getSetting: mockGetSetting, + }, + }; +}); + +describe('auditedSettingUpdates', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSettings.clear(); + }); + + describe('Masking sensitive settings', () => { + it('should mask password type settings with more than 8 characters', () => { + const settingId = 'SMTP_Password'; + const originalValue = 'secretpassword123'; + const newValue = 'newpassword456'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: originalValue, + } as ISetting); + + const actor = { + _id: 'user1234567', + username: 'testuser133532', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByUser(actor)(mockFn, settingId, newValue); + + expect(auditedFn).toBe(newValue); + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: 'sec**************', + current: 'new***********', + }, + { + type: 'user', + ...actor, + }, + ); + }); + + it('should mask password type settings with 8 or fewer characters completely', () => { + const settingId = 'Short_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 'abc', + } as ISetting); + + const actor = { + _id: 'user123', + username: 'testuser', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByUser(actor)(mockFn, settingId, 'xy'); + + expect(auditedFn).toBe('xy'); + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: '***', + current: '**', + }, + { + type: 'user', + ...actor, + }, + ); + }); + + it('should mask secret type settings', () => { + const settingId = 'SMTP_Username'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'string', + secret: true, + value: 'myusername', + } as ISetting); + + const actor = { + _id: 'user123', + username: 'testuser', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByUser(actor)(mockFn, settingId, 'newusername'); + + expect(auditedFn).toBe('newusername'); + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: 'myu*******', + current: 'new********', + }, + { + type: 'user', + ...actor, + }, + ); + }); + }); + + describe('updateAuditedBySystem', () => { + it('should mask sensitive settings for system actor', () => { + const settingId = 'SMTP_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 'oldpassword', + } as ISetting); + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedBySystem({}); + + auditedFn(mockFn, settingId, 'newpassword123'); + + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: 'old********', + current: 'new***********', + }, + { + type: 'system', + }, + ); + }); + }); + + describe('updateAuditedByApp', () => { + it('should mask sensitive settings for app actor', () => { + const settingId = 'LDAP_Authentication_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 'oldpass', + } as ISetting); + + const actor = { + type: 'app' as const, + _id: 'app123', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByApp(actor); + + auditedFn(mockFn, settingId, 'ldappass'); + + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: '*******', + current: '********', + }, + { + type: 'app', + _id: 'app123', + }, + ); + }); + }); + + describe('resetAuditedSettingByUser', () => { + it('should mask sensitive settings when resetting', () => { + const settingId = 'SMTP_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 'currentpassword123', + packageValue: 'packagepassword456', + } as ISetting); + + const actor = { + _id: 'user123', + username: 'testuser', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((key: string) => key); + const auditedFn = resetAuditedSettingByUser(actor); + + auditedFn(mockFn, settingId); + + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: 'cur***************', + current: 'pac***************', + }, + { + type: 'user', + ...actor, + }, + ); + }); + }); + + describe('Edge cases', () => { + it('should handle exactly 8 character values', () => { + const settingId = 'Three_Char_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 'abc', + } as ISetting); + + const actor = { + _id: 'user1234', + username: 'testuser', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByUser(actor)(mockFn, settingId, 'xyz'); + + expect(auditedFn).toBe('xyz'); + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: '***', + current: '***', + }, + { + type: 'user', + ...actor, + }, + ); + }); + + it('should handle non-string values (numbers)', () => { + const settingId = 'Numeric_Password'; + + mockSettings.set(settingId, { + _id: settingId, + type: 'password', + value: 12345, + } as ISetting); + + const actor = { + _id: 'user123', + username: 'testuser', + ip: '127.0.0.1', + useragent: 'test-agent', + }; + + const mockFn = jest.fn((_key: string, value: SettingValue) => value); + const auditedFn = updateAuditedByUser(actor)(mockFn, settingId, 67890); + + expect(auditedFn).toBe(67890); + expect(mockCreateAuditServerEvent).toHaveBeenCalledWith( + 'settings.changed', + { + id: settingId, + previous: '*****', + current: '*****', + }, + { + type: 'user', + ...actor, + }, + ); + }); + }); +}); diff --git a/apps/meteor/server/settings/lib/auditedSettingUpdates.ts b/apps/meteor/server/settings/lib/auditedSettingUpdates.ts new file mode 100644 index 0000000..4734e6a --- /dev/null +++ b/apps/meteor/server/settings/lib/auditedSettingUpdates.ts @@ -0,0 +1,134 @@ +import type { + IAuditServerAppActor, + IAuditServerSystemActor, + IAuditServerUserActor, + ISetting, + SettingValue, +} from '@rocket.chat/core-typings'; +import { ServerEvents } from '@rocket.chat/models'; + +import { settings } from '../cached'; + +const shouldMaskSettingInAudit = (settingId: ISetting['_id']): boolean => { + const setting = settings.getSetting(settingId); + return Boolean(setting && (setting.type === 'password' || (setting.type === 'string' && setting.secret === true))); +}; + +const maskIfNeeded = (settingId: ISetting['_id'], value: SettingValue): SettingValue => { + if (!shouldMaskSettingInAudit(settingId)) { + return value; + } + + if (value === undefined || value === null || value === '') { + return value; + } + + const valueString = String(value); + const valueLength = valueString.length; + + let maskedValue: string; + + if (valueLength <= 8) { + maskedValue = '*'.repeat(valueLength); + } else { + maskedValue = valueString.substring(0, 3) + '*'.repeat(valueLength - 3); + } + + return maskedValue; +}; + +export const resetAuditedSettingByUser = + (actor: Omit) => + any>(fn: F, key: ISetting['_id']): ReturnType => { + const { value, packageValue } = settings.getSetting(key) ?? {}; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous: maskIfNeeded(key, value), + current: maskIfNeeded(key, packageValue), + }, + { + type: 'user', + ...actor, + }, + ); + return fn(key); + }; + +export const updateAuditedByUser = + (actor: Omit) => + any>( + fn: F, + ...args: Parameters + ): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous: maskIfNeeded(key, previous), + current: maskIfNeeded(key, value), + }, + { + type: 'user', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; + +export const updateAuditedBySystem = + (actor: Omit) => + any>( + fn: F, + ...args: Parameters + ): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous: maskIfNeeded(key, previous), + current: maskIfNeeded(key, value), + }, + { + type: 'system', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; + +export const updateAuditedByApp = + any>( + actor: Omit, + ) => + (fn: F, ...args: Parameters): ReturnType => { + const [key, value, ...rest] = args; + const setting = settings.getSetting(key); + + const previous = setting?.value; + void ServerEvents.createAuditServerEvent( + 'settings.changed', + { + id: key, + previous: maskIfNeeded(key, previous), + current: maskIfNeeded(key, value), + }, + { + type: 'app', + ...actor, + }, + ); + return fn(key, value, ...rest); + }; diff --git a/apps/meteor/server/settings/lib/saveSettingsBulk.ts b/apps/meteor/server/settings/lib/saveSettingsBulk.ts new file mode 100644 index 0000000..2cc2239 --- /dev/null +++ b/apps/meteor/server/settings/lib/saveSettingsBulk.ts @@ -0,0 +1,146 @@ +import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import { isSettingCode, isSettingColor } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { settings } from '..'; +import { updateAuditedByUser } from './auditedSettingUpdates'; +import { getSettingPermissionId } from '../../../app/authorization/lib'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { validateSettingRules } from '../../lib/settingValidationRules'; +import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; +import { checkSettingValueBounds } from '../checkSettingValueBonds'; + +const validJSON = Match.Where((value: string) => { + try { + value === '' || JSON.parse(value); + return true; + } catch (_) { + throw new Meteor.Error('Invalid JSON provided'); + } +}); + +const checkInteger = (value: ISetting['value']) => { + if (!Number.isInteger(value)) { + throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { + method: 'saveSettings', + }); + } +}; + +export type SaveSettingsAudit = { + username: string; + ip: string; + useragent: string; +}; + +export const saveSettingsBulk = async ( + uid: string, + params: { _id: ISetting['_id']; value: ISetting['value']; editor?: ISettingColor['editor'] }[], + audit: SaveSettingsAudit, +): Promise => { + const settingsNotAllowed: ISetting['_id'][] = []; + + const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); + const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); + + // if the id contains Organization_Name then change the Site_Name + const orgName = params.find(({ _id }) => _id === 'Organization_Name'); + + if (orgName) { + // check if the site name is still the default value or ifs the same as organization name + const siteName = await Settings.findOneById('Site_Name'); + + if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { + params.push({ + _id: 'Site_Name', + value: orgName.value, + }); + } + } + + await Promise.all( + params.map(async ({ _id, value }) => { + // Verify the _id passed in is a string. + check(_id, String); + if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { + return settingsNotAllowed.push(_id); + } + + // Disable custom scripts in cloud trials to prevent phishing campaigns + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return settingsNotAllowed.push(_id); + } + + const setting = await Settings.findOneById(_id); + // Verify the value is what it should be + switch (setting?.type) { + case 'roomPick': + check(value, Match.OneOf([Object], '')); + break; + case 'boolean': + check(value, Boolean); + break; + case 'timespan': + case 'int': + case 'range': + check(value, Number); + checkInteger(value); + checkSettingValueBounds(setting, value); + break; + case 'multiSelect': + check(value, Array); + break; + case 'code': + check(value, String); + if (isSettingCode(setting) && setting.code === 'application/json') { + check(value, validJSON); + } + break; + default: + check(value, String); + break; + } + }), + ); + + if (settingsNotAllowed.length) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSettings', + settingIds: settingsNotAllowed, + }); + } + + validateSettingRules(params); + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: audit.username, + ip: audit.ip, + useragent: audit.useragent, + }); + + const promises = params.map(async ({ _id, value, editor }) => { + const valueResult = await auditSettingOperation(Settings.updateValueById, _id, value); + + if (!editor) { + return Boolean(valueResult?.modifiedCount); + } + + const setting = await Settings.findOneById(_id, { projection: { type: 1 } }); + if (!setting || !isSettingColor(setting)) { + return Boolean(valueResult?.modifiedCount); + } + + const { modifiedCount } = await Settings.updateOptionsById(_id, { editor }); + return Boolean(valueResult?.modifiedCount) || Boolean(modifiedCount); + }); + + (await Promise.all(promises)).forEach((changed, index) => { + if (changed) { + void notifyOnSettingChangedById(params[index]._id); + } + }); +}; diff --git a/apps/meteor/server/settings/logs.ts b/apps/meteor/server/settings/logs.ts new file mode 100644 index 0000000..ad5557a --- /dev/null +++ b/apps/meteor/server/settings/logs.ts @@ -0,0 +1,76 @@ +import { settingsRegistry } from '.'; + +export const createLogSettings = () => + settingsRegistry.addGroup('Logs', async function () { + await this.add('Log_Level', '0', { + type: 'select', + values: [ + { + key: '0', + i18nLabel: '0_Errors_Only', + }, + { + key: '1', + i18nLabel: '1_Errors_and_Information', + }, + { + key: '2', + i18nLabel: '2_Erros_Information_and_Debug', + }, + ], + public: true, + }); + + await this.add('Log_Trace_Methods', false, { + type: 'boolean', + }); + + await this.add('Log_Trace_Methods_Filter', '', { + type: 'string', + enableQuery: { + _id: 'Log_Trace_Methods', + value: true, + }, + }); + + await this.add('Log_Trace_Subscriptions', false, { + type: 'boolean', + }); + + await this.add('Log_Trace_Subscriptions_Filter', '', { + type: 'string', + enableQuery: { + _id: 'Log_Trace_Subscriptions', + value: true, + }, + }); + + await this.add('Uncaught_Exceptions_Count', 0, { + hidden: true, + type: 'int', + }); + + await this.section('Prometheus', async function () { + await this.add('Prometheus_Enabled', false, { + type: 'boolean', + i18nLabel: 'Enabled', + }); + // See the default port allocation at https://github.com/prometheus/prometheus/wiki/Default-port-allocations + await this.add('Prometheus_Port', 9458, { + type: 'int', + i18nLabel: 'Port', + }); + await this.add('Prometheus_Reset_Interval', 0, { + type: 'int', + }); + await this.add('Prometheus_Garbage_Collector', false, { + type: 'boolean', + alert: 'Prometheus_Garbage_Collector_Alert', + }); + await this.add('Prometheus_API_User_Agent', false, { + type: 'boolean', + }); + }); + + await this.add('Log_Exceptions_to_Channel', '', { type: 'string' }); + }); diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts new file mode 100644 index 0000000..9243512 --- /dev/null +++ b/apps/meteor/server/settings/message.ts @@ -0,0 +1,507 @@ +import { settingsRegistry } from '.'; +import { MessageTypesValues } from '../../app/lib/lib/MessageTypes'; + +export const createMessageSettings = () => + settingsRegistry.addGroup('Message', async function () { + await this.section('Message_Attachments', async function () { + await this.add('Message_Attachments_Thumbnails_Enabled', true, { + type: 'boolean', + public: true, + i18nDescription: 'Message_Attachments_Thumbnails_EnabledDesc', + }); + + await this.add('Message_Attachments_Thumbnails_Width', 480, { + type: 'int', + public: true, + enableQuery: [ + { + _id: 'Message_Attachments_Thumbnails_Enabled', + value: true, + }, + ], + }); + + await this.add('Message_Attachments_Thumbnails_Height', 360, { + type: 'int', + public: true, + enableQuery: [ + { + _id: 'Message_Attachments_Thumbnails_Enabled', + value: true, + }, + ], + }); + + await this.add('Message_Attachments_Strip_Exif', true, { + type: 'boolean', + public: true, + i18nDescription: 'Message_Attachments_Strip_ExifDescription', + }); + }); + await this.section('Message_Audio', async function () { + await this.add('Message_AudioRecorderEnabled', true, { + type: 'boolean', + public: true, + i18nDescription: 'Message_AudioRecorderEnabledDescription', + }); + await this.add('Message_Audio_bitRate', 32, { + type: 'int', + public: true, + }); + }); + await this.section('Read_Receipts', async function () { + await this.add('Message_Read_Receipt_Enabled', false, { + type: 'boolean', + enterprise: true, + invalidValue: false, + modules: ['message-read-receipt'], + public: true, + }); + await this.add('Message_Read_Receipt_Store_Users', false, { + type: 'boolean', + enterprise: true, + invalidValue: false, + modules: ['message-read-receipt'], + public: true, + enableQuery: { _id: 'Message_Read_Receipt_Enabled', value: true }, + }); + await this.add('Message_Read_Receipt_Archive_Enabled', false, { + type: 'boolean', + enterprise: true, + invalidValue: false, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Enabled_Description', + alert: 'Message_Read_Receipt_Archive_Enabled_Alert', + enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + }); + await this.add('Message_Read_Receipt_Archive_Retention_Days', 30, { + type: 'int', + enterprise: true, + invalidValue: 30, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Retention_Days_Description', + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], + }); + await this.add('Message_Read_Receipt_Archive_Cron', '0 2 * * *', { + type: 'string', + enterprise: true, + invalidValue: '0 2 * * *', + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Cron_Description', + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], + }); + await this.add('Message_Read_Receipt_Archive_Batch_Size', 10000, { + type: 'int', + enterprise: true, + invalidValue: 10000, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Batch_Size_Description', + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], + }); + }); + await this.add('Message_CustomDomain_AutoLink', '', { + type: 'string', + public: true, + }); + await this.add('Message_AllowEditing', true, { + type: 'boolean', + public: true, + }); + await this.add('Message_AllowEditing_BlockEditInMinutes', 0, { + type: 'int', + public: true, + i18nDescription: 'Message_AllowEditing_BlockEditInMinutesDescription', + }); + await this.add('Message_AllowDeleting', true, { + type: 'boolean', + public: true, + }); + await this.add('Message_AllowDeleting_BlockDeleteInMinutes', 0, { + type: 'int', + public: true, + i18nDescription: 'Message_AllowDeleting_BlockDeleteInMinutes', + }); + await this.add('Message_AllowUnrecognizedSlashCommand', false, { + type: 'boolean', + public: true, + }); + await this.add('Message_AllowDirectMessagesToYourself', true, { + type: 'boolean', + public: true, + }); + await this.add('Message_AlwaysSearchRegExp', false, { + type: 'boolean', + }); + await this.add('Message_ShowDeletedStatus', false, { + type: 'boolean', + public: true, + }); + await this.add('Message_AllowBadWordsFilter', false, { + type: 'boolean', + public: true, + }); + await this.add('Message_BadWordsFilterList', '', { + type: 'string', + public: true, + }); + await this.add('Message_BadWordsWhitelist', '', { + type: 'string', + public: true, + }); + await this.add('Message_KeepHistory', false, { + type: 'boolean', + public: true, + }); + await this.add('Message_MaxAll', 0, { + type: 'int', + public: true, + }); + await this.add('Message_MaxAllowedSize', 5000, { + type: 'int', + public: true, + }); + await this.add('Message_AllowConvertLongMessagesToAttachment', true, { + type: 'boolean', + public: true, + }); + await this.add('Message_GroupingPeriod', 300, { + type: 'int', + public: true, + i18nDescription: 'Message_GroupingPeriodDescription', + }); + await this.add('API_Embed', true, { + type: 'boolean', + public: true, + }); + await this.add( + 'API_Embed_UserAgent', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', + { + type: 'string', + public: true, + }, + ); + await this.add('API_EmbedCacheExpirationDays', 30, { + type: 'int', + public: false, + }); + await this.add('API_Embed_clear_cache_now', 'OEmbedCacheCleanup', { + type: 'action', + actionText: 'clear', + i18nLabel: 'clear_cache_now', + }); + // TODO: deprecate this setting in favor of App + await this.add('API_EmbedIgnoredHosts', 'localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16', { + type: 'string', + i18nDescription: 'API_EmbedIgnoredHosts_Description', + }); + // TODO: deprecate this setting in favor of App + await this.add('API_EmbedSafePorts', '80, 443', { + type: 'string', + }); + await this.add('API_EmbedTimeout', 10, { + type: 'int', + enableQuery: { _id: 'API_Embed', value: true }, + }); + await this.add('Message_TimeFormat', 'LT', { + type: 'string', + public: true, + i18nDescription: 'Message_TimeFormat_Description', + }); + await this.add('Message_DateFormat', 'LL', { + type: 'string', + public: true, + i18nDescription: 'Message_DateFormat_Description', + }); + await this.add('Message_TimeAndDateFormat', 'LLL', { + type: 'string', + public: true, + i18nDescription: 'Message_TimeAndDateFormat_Description', + }); + await this.add('Message_QuoteChainLimit', 2, { + type: 'int', + public: true, + }); + + await this.add('Hide_System_Messages', [], { + type: 'multiSelect', + public: true, + values: MessageTypesValues, + }); + + await this.add('DirectMesssage_maxUsers', 8, { + type: 'int', + public: true, + }); + + await this.add('Message_ErasureType', 'Delete', { + type: 'select', + public: true, + i18nDescription: 'Message_ErasureType_Description', + values: [ + { + key: 'Keep', + i18nLabel: 'Message_ErasureType_Keep', + }, + { + key: 'Delete', + i18nLabel: 'Message_ErasureType_Delete', + }, + { + key: 'Unlink', + i18nLabel: 'Message_ErasureType_Unlink', + }, + ], + }); + + await this.add( + 'Message_Code_highlight', + 'javascript,css,markdown,dockerfile,json,go,rust,clean,bash,plaintext,powershell,scss,shell,yaml,vim', + { + type: 'string', + public: true, + }, + ); + await this.add('Message_Auditing_Panel_Load_Count', 0, { + type: 'int', + hidden: true, + }); + await this.add('Message_Auditing_Apply_Count', 0, { + type: 'int', + hidden: true, + }); + await this.add('Message_VideoRecorderEnabled', true, { + type: 'boolean', + public: true, + i18nDescription: 'Message_VideoRecorderEnabledDescription', + }); + await this.add('AutoTranslate_Enabled', false, { + type: 'boolean', + group: 'Message', + section: 'AutoTranslate', + public: true, + }); + + await this.add('AutoTranslate_AutoEnableOnJoinRoom', false, { + type: 'boolean', + group: 'Message', + section: 'AutoTranslate', + public: true, + enableQuery: [{ _id: 'AutoTranslate_Enabled', value: true }], + }); + + await this.add('AutoTranslate_ServiceProvider', 'google-translate', { + type: 'select', + group: 'Message', + section: 'AutoTranslate', + values: [ + { + key: 'google-translate', + i18nLabel: 'AutoTranslate_Google', + }, + { + key: 'deepl-translate', + i18nLabel: 'AutoTranslate_DeepL', + }, + { + key: 'microsoft-translate', + i18nLabel: 'AutoTranslate_Microsoft', + }, + { + key: 'libre-translate', + i18nLabel: 'AutoTranslate_LibreTranslate', + }, + ], + enableQuery: [{ _id: 'AutoTranslate_Enabled', value: true }], + i18nLabel: 'AutoTranslate_ServiceProvider', + public: true, + }); + + await this.add('AutoTranslate_GoogleAPIKey', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_Google', + public: false, + i18nLabel: 'AutoTranslate_APIKey', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', + value: true, + }, + { + _id: 'AutoTranslate_ServiceProvider', + value: 'google-translate', + }, + ], + }); + + await this.add('AutoTranslate_DeepLAPIKey', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_DeepL', + public: false, + i18nLabel: 'AutoTranslate_APIKey', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', + value: true, + }, + { + _id: 'AutoTranslate_ServiceProvider', + value: 'deepl-translate', + }, + ], + }); + + await this.add('AutoTranslate_MicrosoftAPIKey', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_Microsoft', + public: false, + i18nLabel: 'AutoTranslate_Microsoft_API_Key', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', + value: true, + }, + { + _id: 'AutoTranslate_ServiceProvider', + value: 'microsoft-translate', + }, + ], + }); + + await this.add('AutoTranslate_LibreTranslateAPIURL', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_LibreTranslate', + public: false, + i18nLabel: 'AutoTranslate_LibreTranslate_API_URL', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', + value: true, + }, + { + _id: 'AutoTranslate_ServiceProvider', + value: 'libre-translate', + }, + ], + }); + + await this.add('AutoTranslate_LibreTranslateAPIKey', '', { + type: 'string', + group: 'Message', + section: 'AutoTranslate_LibreTranslate', + public: false, + secret: true, + i18nLabel: 'AutoTranslate_APIKey', + enableQuery: [ + { + _id: 'AutoTranslate_Enabled', + value: true, + }, + { + _id: 'AutoTranslate_ServiceProvider', + value: 'libre-translate', + }, + ], + }); + + await this.add('HexColorPreview_Enabled', true, { + type: 'boolean', + i18nLabel: 'Enabled', + group: 'Message', + section: 'Hex_Color_Preview', + public: true, + }); + + await this.section('Katex', async function () { + const enableQuery = { + _id: 'Katex_Enabled', + value: true, + }; + await this.add('Katex_Enabled', true, { + type: 'boolean', + public: true, + i18nDescription: 'Katex_Enabled_Description', + }); + await this.add('Katex_Parenthesis_Syntax', true, { + type: 'boolean', + public: true, + enableQuery, + i18nDescription: 'Katex_Parenthesis_Syntax_Description', + }); + await this.add('Katex_Dollar_Syntax', false, { + type: 'boolean', + public: true, + enableQuery, + i18nDescription: 'Katex_Dollar_Syntax_Description', + }); + }); + + await this.section('Google Maps', async function () { + await this.add('MapView_Enabled', false, { + type: 'boolean', + public: true, + i18nLabel: 'MapView_Enabled', + i18nDescription: 'MapView_Enabled_Description', + }); + await this.add('MapView_GMapsAPIKey', '', { + type: 'string', + public: true, + i18nLabel: 'MapView_GMapsAPIKey', + i18nDescription: 'MapView_GMapsAPIKey_Description', + secret: true, + }); + }); + + await this.add('Message_AllowPinning', true, { + type: 'boolean', + public: true, + }); + await this.add('Message_AllowStarring', true, { + type: 'boolean', + public: true, + }); + + await this.add('Message_CustomFields_Enabled', false, { + type: 'boolean', + }); + await this.add( + 'Message_CustomFields', + ` +{ + "properties": { + "priority": { + "type": "string", + "nullable": false, + "enum": ["low", "medium", "high"] + } + }, + "required": ["priority"] +} + `, + { + type: 'code', + code: 'application/json', + invalidValue: '', + multiline: true, + enableQuery: [ + { + _id: 'Message_CustomFields_Enabled', + value: true, + }, + ], + }, + ); + }); diff --git a/apps/meteor/server/settings/meta.ts b/apps/meteor/server/settings/meta.ts new file mode 100644 index 0000000..cb0abcf --- /dev/null +++ b/apps/meteor/server/settings/meta.ts @@ -0,0 +1,28 @@ +import { settingsRegistry } from '.'; + +export const createMetaSettings = () => + settingsRegistry.addGroup('Meta', async function () { + await this.add('Meta_language', '', { + type: 'string', + }); + await this.add('Meta_fb_app_id', '', { + type: 'string', + secret: true, + }); + await this.add('Meta_robots', 'INDEX,FOLLOW', { + type: 'string', + }); + await this.add('Meta_google-site-verification', '', { + type: 'string', + secret: true, + }); + await this.add('Meta_msvalidate01', '', { + type: 'string', + secret: true, + }); + return this.add('Meta_custom', '', { + type: 'code', + code: 'text/html', + multiline: true, + }); + }); diff --git a/apps/meteor/server/settings/misc.ts b/apps/meteor/server/settings/misc.ts new file mode 100644 index 0000000..d74267e --- /dev/null +++ b/apps/meteor/server/settings/misc.ts @@ -0,0 +1,84 @@ +import crypto from 'node:crypto'; + +import { Logger } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; + +import { settingsRegistry, settings } from '.'; +import { updateAuditedBySystem } from './lib/auditedSettingUpdates'; +import { notifyOnSettingChangedById } from '../lib/notifyListener'; + +const logger = new Logger('FingerPrint'); + +const generateFingerprint = function () { + const siteUrl = settings.get('Site_Url'); + const dbConnectionString = process.env.MONGO_URL; + + const fingerprint = `${siteUrl}${dbConnectionString}`; + return crypto.createHash('sha256').update(fingerprint).digest('base64'); +}; + +const updateFingerprint = async function (fingerprint: string, verified: boolean, emit = true) { + const auditedSettingBySystem = updateAuditedBySystem({ + reason: 'updateFingerprint', + }); + // No need to call ws listener because current function is called on startup + await Promise.all([ + auditedSettingBySystem(Settings.updateValueById, 'Deployment_FingerPrint_Hash', fingerprint), + auditedSettingBySystem(Settings.updateValueById, 'Deployment_FingerPrint_Verified', verified), + ]); + + if (emit) { + void notifyOnSettingChangedById('Deployment_FingerPrint_Hash'); + void notifyOnSettingChangedById('Deployment_FingerPrint_Verified'); + } +}; + +export const verifyFingerPrint = async function (emit = true) { + const DeploymentFingerPrintRecordHash = await Settings.getValueById('Deployment_FingerPrint_Hash'); + + const fingerprint = generateFingerprint(); + + if (!DeploymentFingerPrintRecordHash) { + logger.info({ msg: 'Generating fingerprint for the first time', fingerprint }); + await updateFingerprint(fingerprint, true, emit); + return; + } + + if (DeploymentFingerPrintRecordHash === fingerprint) { + return; + } + + if (process.env.AUTO_ACCEPT_FINGERPRINT === 'true') { + logger.info({ msg: 'Updating fingerprint as AUTO_ACCEPT_FINGERPRINT is true', fingerprint }); + await updateFingerprint(fingerprint, true, emit); + return; + } + + logger.warn({ msg: 'Updating fingerprint as pending for admin verification', fingerprint }); + await updateFingerprint(fingerprint, false, emit); +}; + +// Insert server unique id if it doesn't exist +export const createMiscSettings = async () => { + await settingsRegistry.add('uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID(), { + public: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Hash', '', { + public: false, + readonly: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Verified', true, { + type: 'boolean', + public: true, + readonly: true, + }); + + await verifyFingerPrint(false); + + await settingsRegistry.add('Initial_Channel_Created', false, { + type: 'boolean', + hidden: true, + }); +}; diff --git a/apps/meteor/server/settings/mobile.ts b/apps/meteor/server/settings/mobile.ts new file mode 100644 index 0000000..e23ac11 --- /dev/null +++ b/apps/meteor/server/settings/mobile.ts @@ -0,0 +1,22 @@ +import { settingsRegistry } from '.'; + +export const createMobileSettings = () => + settingsRegistry.addGroup('Mobile', async function () { + await this.add('Allow_Save_Media_to_Gallery', true, { + type: 'boolean', + public: true, + }); + await this.section('Screen_Lock', async function () { + await this.add('Force_Screen_Lock', false, { + type: 'boolean', + i18nDescription: 'Force_Screen_Lock_description', + public: true, + }); + await this.add('Force_Screen_Lock_After', 1800, { + type: 'int', + i18nDescription: 'Force_Screen_Lock_After_description', + enableQuery: { _id: 'Force_Screen_Lock', value: true }, + public: true, + }); + }); + }); diff --git a/apps/meteor/server/settings/oauth.ts b/apps/meteor/server/settings/oauth.ts new file mode 100644 index 0000000..54cfdc7 --- /dev/null +++ b/apps/meteor/server/settings/oauth.ts @@ -0,0 +1,432 @@ +import { Random } from '@rocket.chat/random'; + +import { settingsRegistry } from '.'; + +export const createOauthSettings = () => + settingsRegistry.addGroup('OAuth', async function () { + await this.add('Accounts_OAuth_Use_Modern_Flow', false, { + type: 'boolean', + public: true, + i18nLabel: 'Accounts_OAuth_Use_Modern_Flow_Label', + i18nDescription: 'Accounts_OAuth_Use_Modern_Flow_Description', + }); + + await this.section('Drupal', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Drupal', + value: true, + }; + + await this.add('Accounts_OAuth_Drupal', false, { type: 'boolean' }); + await this.add('API_Drupal_URL', '', { + type: 'string', + public: true, + enableQuery, + i18nDescription: 'API_Drupal_URL_Description', + }); + await this.add('Accounts_OAuth_Drupal_id', '', { type: 'string', enableQuery }); + await this.add('Accounts_OAuth_Drupal_secret', '', { type: 'string', enableQuery, secret: true }); + await this.add('Accounts_OAuth_Drupal_callback_url', '_oauth/drupal', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + + await this.section('Apple', async function () { + await this.add('Accounts_OAuth_Apple', false, { type: 'boolean', public: true }); + + await this.add('Accounts_OAuth_Apple_id', '', { type: 'string', public: true }); + await this.add('Accounts_OAuth_Apple_secretKey', '', { type: 'string', multiline: true }); + + await this.add('Accounts_OAuth_Apple_iss', '', { type: 'string' }); + await this.add('Accounts_OAuth_Apple_kid', '', { type: 'string' }); + }); + + await this.section('GitHub Enterprise', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_GitHub_Enterprise', + value: true, + }; + + await this.add('Accounts_OAuth_GitHub_Enterprise', false, { type: 'boolean' }); + await this.add('API_GitHub_Enterprise_URL', '', { + type: 'string', + public: true, + enableQuery, + i18nDescription: 'API_GitHub_Enterprise_URL_Description', + }); + await this.add('Accounts_OAuth_GitHub_Enterprise_id', '', { + type: 'string', + enableQuery, + secret: true, + }); + await this.add('Accounts_OAuth_GitHub_Enterprise_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + await this.add('Accounts_OAuth_GitHub_Enterprise_callback_url', '_oauth/github_enterprise', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('GitLab', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Gitlab', + value: true, + }; + + await this.add('Accounts_OAuth_Gitlab', false, { type: 'boolean', public: true }); + await this.add('API_Gitlab_URL', '', { type: 'string', enableQuery, public: true, secret: true }); + await this.add('Accounts_OAuth_Gitlab_id', '', { type: 'string', enableQuery }); + await this.add('Accounts_OAuth_Gitlab_secret', '', { type: 'string', enableQuery, secret: true }); + await this.add('Accounts_OAuth_Gitlab_identity_path', '/api/v4/user', { + type: 'string', + public: true, + enableQuery, + }); + await this.add('Accounts_OAuth_Gitlab_merge_users', false, { + type: 'boolean', + public: true, + enableQuery, + }); + await this.add('Accounts_OAuth_Gitlab_callback_url', '_oauth/gitlab', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('Nextcloud', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Nextcloud', + value: true, + }; + + await this.add('Accounts_OAuth_Nextcloud', false, { type: 'boolean', public: true }); + await this.add('Accounts_OAuth_Nextcloud_URL', '', { type: 'string', enableQuery, public: true }); + await this.add('Accounts_OAuth_Nextcloud_id', '', { type: 'string', enableQuery }); + await this.add('Accounts_OAuth_Nextcloud_secret', '', { type: 'string', enableQuery }); + await this.add('Accounts_OAuth_Nextcloud_callback_url', '_oauth/nextcloud', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + await this.add('Accounts_OAuth_Nextcloud_button_label_text', 'Nextcloud', { + type: 'string', + public: true, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + persistent: true, + }); + await this.add('Accounts_OAuth_Nextcloud_button_label_color', '#ffffff', { + type: 'string', + public: true, + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + await this.add('Accounts_OAuth_Nextcloud_button_color', '#0082c9', { + type: 'string', + public: true, + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + }); + + await this.section('WordPress', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Wordpress', + value: true, + }; + await this.add('Accounts_OAuth_Wordpress', false, { + type: 'boolean', + public: true, + }); + await this.add('API_Wordpress_URL', '', { + type: 'string', + enableQuery, + public: true, + secret: true, + }); + await this.add('Accounts_OAuth_Wordpress_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Wordpress_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + await this.add('Accounts_OAuth_Wordpress_server_type', '', { + type: 'select', + enableQuery, + public: true, + values: [ + { + key: 'wordpress-com', + i18nLabel: 'Accounts_OAuth_Wordpress_server_type_wordpress_com', + }, + { + key: 'wp-oauth-server', + i18nLabel: 'Accounts_OAuth_Wordpress_server_type_wp_oauth_server', + }, + { + key: 'custom', + i18nLabel: 'Accounts_OAuth_Wordpress_server_type_custom', + }, + ], + i18nLabel: 'Server_Type', + }); + + const customOAuthQuery = [ + { + _id: 'Accounts_OAuth_Wordpress', + value: true, + }, + { + _id: 'Accounts_OAuth_Wordpress_server_type', + value: 'custom', + }, + ]; + + await this.add('Accounts_OAuth_Wordpress_identity_path', '', { + type: 'string', + enableQuery: customOAuthQuery, + public: true, + }); + await this.add('Accounts_OAuth_Wordpress_identity_token_sent_via', '', { + type: 'string', + enableQuery: customOAuthQuery, + public: true, + }); + await this.add('Accounts_OAuth_Wordpress_token_path', '', { + type: 'string', + enableQuery: customOAuthQuery, + public: true, + }); + await this.add('Accounts_OAuth_Wordpress_authorize_path', '', { + type: 'string', + enableQuery: customOAuthQuery, + public: true, + }); + await this.add('Accounts_OAuth_Wordpress_scope', '', { + type: 'string', + enableQuery: customOAuthQuery, + public: true, + }); + await this.add('Accounts_OAuth_Wordpress_callback_url', '_oauth/wordpress', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + + await this.section('Dolphin', async function () { + await this.add('Accounts_OAuth_Dolphin_URL', '', { + type: 'string', + public: true, + i18nLabel: 'URL', + }); + await this.add('Accounts_OAuth_Dolphin', false, { + type: 'boolean', + i18nLabel: 'Accounts_OAuth_Custom_Enable', + }); + await this.add('Accounts_OAuth_Dolphin_id', '', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_id', + }); + await this.add('Accounts_OAuth_Dolphin_secret', '', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Secret', + secret: true, + }); + await this.add('Accounts_OAuth_Dolphin_login_style', 'redirect', { + type: 'select', + i18nLabel: 'Accounts_OAuth_Custom_Login_Style', + persistent: true, + values: [ + { key: 'redirect', i18nLabel: 'Redirect' }, + { key: 'popup', i18nLabel: 'Popup' }, + { key: '', i18nLabel: 'Default' }, + ], + }); + await this.add('Accounts_OAuth_Dolphin_button_label_text', '', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Text', + persistent: true, + }); + await this.add('Accounts_OAuth_Dolphin_button_label_color', '#FFFFFF', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Label_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + await this.add('Accounts_OAuth_Dolphin_button_color', '#1d74f5', { + type: 'string', + i18nLabel: 'Accounts_OAuth_Custom_Button_Color', + persistent: true, + alert: 'OAuth_button_colors_alert', + }); + }); + await this.section('Facebook', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Facebook', + value: true, + }; + await this.add('Accounts_OAuth_Facebook', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Facebook_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Facebook_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Facebook_callback_url', '_oauth/facebook', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('Google', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Google', + value: true, + }; + await this.add('Accounts_OAuth_Google', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Google_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Google_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Google_callback_url', '_oauth/google', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('GitHub', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Github', + value: true, + }; + await this.add('Accounts_OAuth_Github', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Github_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Github_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Github_callback_url', '_oauth/github', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('Linkedin', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Linkedin', + value: true, + }; + await this.add('Accounts_OAuth_Linkedin', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Linkedin_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Linkedin_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Linkedin_callback_url', '_oauth/linkedin', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('Meteor', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Meteor', + value: true, + }; + await this.add('Accounts_OAuth_Meteor', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Meteor_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Meteor_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Meteor_callback_url', '_oauth/meteor', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.section('Twitter', async function () { + const enableQuery = { + _id: 'Accounts_OAuth_Twitter', + value: true, + }; + await this.add('Accounts_OAuth_Twitter', false, { + type: 'boolean', + public: true, + }); + await this.add('Accounts_OAuth_Twitter_id', '', { + type: 'string', + enableQuery, + }); + await this.add('Accounts_OAuth_Twitter_secret', '', { + type: 'string', + enableQuery, + secret: true, + }); + return this.add('Accounts_OAuth_Twitter_callback_url', '_oauth/twitter', { + type: 'relativeUrl', + readonly: true, + enableQuery, + }); + }); + await this.add('Accounts_OAuth_Session_Secret', Random.secret(), { + type: 'string', + secret: true, + hidden: true, + }); + return this.section('Proxy', async function () { + await this.add('Accounts_OAuth_Proxy_host', 'https://oauth-proxy.rocket.chat', { + type: 'string', + public: true, + }); + return this.add('Accounts_OAuth_Proxy_services', '', { + type: 'string', + public: true, + }); + }); + }); diff --git a/apps/meteor/server/settings/omnichannel.ts b/apps/meteor/server/settings/omnichannel.ts new file mode 100644 index 0000000..c765edc --- /dev/null +++ b/apps/meteor/server/settings/omnichannel.ts @@ -0,0 +1,764 @@ +import { SettingEditor } from '@rocket.chat/core-typings'; + +import { settingsRegistry } from '.'; + +const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true }; + +export const createOmniSettings = () => + settingsRegistry.addGroup('Omnichannel', async function () { + await this.add('Livechat_enabled', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + }); + + await this.add('Livechat_title', 'Rocket.Chat', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_title_color', '#C1272D', { + type: 'color', + editor: SettingEditor.COLOR, + // allowedTypes: ['color', 'expression'], + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_enable_message_character_limit', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_message_character_limit', 0, { + type: 'int', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_display_offline_form', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Display_offline_form', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_clear_local_storage_when_chat_ended', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Clear_livechat_session_when_chat_ended', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_validate_offline_email', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Validate_email_address', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_offline_form_unavailable', '', { + type: 'string', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Offline_form_unavailable_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_offline_title', 'Leave a message', { + type: 'string', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Title', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_offline_title_color', '#666666', { + type: 'color', + editor: SettingEditor.COLOR, + // allowedTypes: ['color', 'expression'], + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Color', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_offline_message', '', { + type: 'string', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Instructions', + i18nDescription: 'Instructions_to_your_visitor_fill_the_form_to_send_a_message', + enableQuery: omnichannelEnabledQuery, + multiline: true, + }); + + await this.add('Livechat_offline_email', '', { + type: 'string', + group: 'Omnichannel', + i18nLabel: 'Email_address_to_send_offline_messages', + section: 'Livechat', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_offline_success_message', '', { + type: 'string', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Offline_success_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_allow_switching_departments', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Livechat', + i18nLabel: 'Allow_switching_departments', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_show_agent_info', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Show_agent_info', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_show_agent_email', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: [{ _id: 'Livechat_show_agent_info', value: true }, omnichannelEnabledQuery], + i18nLabel: 'Show_agent_email', + }); + + await this.add('Omnichannel_allow_visitors_to_close_conversation', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_request_comment_when_closing_conversation', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Request_comment_when_closing_conversation', + i18nDescription: 'Request_comment_when_closing_conversation_description', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Omnichannel_allow_force_close_conversations', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'API', + public: true, + enableQuery: omnichannelEnabledQuery, + alert: 'Omnichannel_allow_force_close_conversations_alert', + }); + + await this.add('Livechat_conversation_finished_message', '', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Conversation_finished_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_conversation_finished_text', '', { + type: 'string', + multiline: true, + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Conversation_finished_text', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_registration_form', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Show_preregistration_form', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_name_field_registration_form', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Show_name_field', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_email_field_registration_form', true, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Show_email_field', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_guest_count', 1, { type: 'int', group: 'Omnichannel', hidden: true }); + + await this.add('Livechat_enabled_when_agent_idle', true, { + type: 'boolean', + group: 'Omnichannel', + i18nLabel: 'Accept_new_livechats_when_agent_is_idle', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhookUrl', '', { + type: 'string', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Webhook_URL', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_secret_token', '', { + type: 'string', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Secret_token', + secret: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_start', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_chat_start', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_close', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_chat_close', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_chat_taken', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_chat_taken', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_chat_queued', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_chat_queued', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_forward', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_forwarding', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_offline_msg', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_offline_messages', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_visitor_message', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_visitor_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_webhook_on_agent_message', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_agent_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Send_visitor_navigation_history_livechat_webhook_request', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_visitor_navigation_history_on_request', + i18nDescription: 'Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled', + enableQuery: [{ _id: 'Livechat_Visitor_navigation_as_a_message', value: true }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_webhook_on_capture', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Send_request_on_lead_capture', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b', { + type: 'string', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Lead_capture_email_regex', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add( + 'Livechat_lead_phone_regex', + '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', + { + type: 'string', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Lead_capture_phone_regex', + enableQuery: omnichannelEnabledQuery, + }, + ); + + await this.add('Livechat_history_monitor_type', 'url', { + type: 'select', + group: 'Omnichannel', + section: 'Livechat', + i18nLabel: 'Monitor_history_for_changes_on', + values: [ + { key: 'url', i18nLabel: 'Page_URL' }, + { key: 'title', i18nLabel: 'Page_title' }, + ], + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_http_timeout', 5000, { + type: 'int', + group: 'Omnichannel', + section: 'CRM_Integration', + i18nLabel: 'Livechat_http_timeout', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_Visitor_navigation_as_a_message', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Send_Visitor_navigation_history_as_a_message', + enableQuery: omnichannelEnabledQuery, + }); + + await settingsRegistry.addGroup('Omnichannel', async function () { + await this.section('Business_Hours', async function () { + await this.add('Livechat_enable_business_hours', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Business_hours_enabled', + enableQuery: omnichannelEnabledQuery, + }); + }); + }); + + await this.add('Livechat_continuous_sound_notification_new_livechat_room', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Continuous_sound_notifications_for_new_livechat_room', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_fileupload_enabled', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'FileUpload_Enabled', + enableQuery: [{ _id: 'FileUpload_Enabled', value: true }, omnichannelEnabledQuery], + }); + + // Making these 2 settings "depend" on each other + // Prevents us from having both as true and then asking visitor if it wants a Transcript + // But send it anyways because of send_always being enabled. So one can only be turned on + // if the other is off. + await this.add('Livechat_enable_transcript', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Transcript_Enabled', + enableQuery: [{ _id: 'Livechat_transcript_send_always', value: false }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_transcript_send_always', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + enableQuery: [{ _id: 'Livechat_enable_transcript', value: false }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_transcript_show_system_messages', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_transcript_message', '', { + type: 'string', + group: 'Omnichannel', + public: true, + i18nLabel: 'Transcript_message', + enableQuery: [{ _id: 'Livechat_enable_transcript', value: true }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_transcript_email_subject', '', { + type: 'string', + group: 'Omnichannel', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Omnichannel_enable_department_removal', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + i18nLabel: 'Omnichannel_enable_department_removal', + alert: 'Omnichannel_enable_department_removal_alert', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_registration_form_message', '', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Livechat_registration_form_message', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_AllowedDomainsList', '', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + i18nLabel: 'Livechat_AllowedDomainsList', + i18nDescription: 'Domains_allowed_to_embed_the_livechat_widget', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_OfflineMessageToChannel_enabled', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_OfflineMessageToChannel_channel_name', '', { + type: 'string', + group: 'Omnichannel', + section: 'Livechat', + public: true, + enableQuery: [{ _id: 'Livechat_OfflineMessageToChannel_enabled', value: true }, omnichannelEnabledQuery], + i18nLabel: 'Channel_name', + }); + + await this.add('Livechat_Routing_Method', 'Auto_Selection', { + type: 'select', + group: 'Omnichannel', + public: true, + section: 'Routing', + values: [ + { key: 'External', i18nLabel: 'External_Service' }, + { key: 'Auto_Selection', i18nLabel: 'Auto_Selection' }, + { key: 'Manual_Selection', i18nLabel: 'Manual_Selection' }, + ], + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_accept_chats_with_no_agents', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Routing', + i18nLabel: 'Accept_with_no_online_agents', + i18nDescription: 'Accept_incoming_livechat_requests_even_if_there_are_no_online_agents', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_assign_new_conversation_to_bot', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Routing', + i18nLabel: 'Assign_new_conversations_to_bot_agent', + i18nDescription: 'Assign_new_conversations_to_bot_agent_description', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_guest_pool_max_number_incoming_livechats_displayed', 0, { + type: 'int', + group: 'Omnichannel', + section: 'Routing', + public: true, + i18nLabel: 'Max_number_incoming_livechats_displayed', + i18nDescription: 'Max_number_incoming_livechats_displayed_description', + enableQuery: [{ _id: 'Livechat_Routing_Method', value: 'Manual_Selection' }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_show_queue_list_link', false, { + type: 'boolean', + group: 'Omnichannel', + public: true, + section: 'Routing', + i18nLabel: 'Show_queue_list_to_all_agents', + enableQuery: [{ _id: 'Livechat_Routing_Method', value: { $ne: 'External' } }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_External_Queue_URL', '', { + type: 'string', + group: 'Omnichannel', + public: false, + section: 'Routing', + i18nLabel: 'External_Queue_Service_URL', + i18nDescription: 'For_more_details_please_check_our_docs', + enableQuery: [{ _id: 'Livechat_Routing_Method', value: 'External' }, omnichannelEnabledQuery], + }); + + await this.add('Livechat_External_Queue_Token', '', { + type: 'string', + group: 'Omnichannel', + public: false, + section: 'Routing', + i18nLabel: 'Secret_token', + enableQuery: [{ _id: 'Livechat_Routing_Method', value: 'External' }, omnichannelEnabledQuery], + }); + + await this.add('Omnichannel_queue_delay_timeout', 5, { + type: 'int', + group: 'Omnichannel', + section: 'Queue_management', + i18nLabel: 'Queue_delay_timeout', + i18nDescription: 'Time_in_seconds', + enableQuery: [{ _id: 'Livechat_Routing_Method', value: { $ne: 'Manual_Selection' } }, omnichannelEnabledQuery], + invalidValue: 5, + }); + + await this.add('Livechat_Allow_collect_and_store_HTTP_header_informations', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'GDPR', + public: true, + i18nLabel: 'Allow_collect_and_store_HTTP_header_informations', + i18nDescription: 'Allow_collect_and_store_HTTP_header_informations_description', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_force_accept_data_processing_consent', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'GDPR', + public: true, + alert: 'Force_visitor_to_accept_data_processing_consent_enabled_alert', + i18nLabel: 'Force_visitor_to_accept_data_processing_consent', + i18nDescription: 'Force_visitor_to_accept_data_processing_consent_description', + enableQuery: [omnichannelEnabledQuery, { _id: 'Livechat_Allow_collect_and_store_HTTP_header_informations', value: true }], + }); + + await this.add('Livechat_data_processing_consent_text', '', { + type: 'string', + multiline: true, + group: 'Omnichannel', + section: 'GDPR', + public: true, + i18nLabel: 'Data_processing_consent_text', + i18nDescription: 'Data_processing_consent_text_description', + enableQuery: [ + { _id: 'Livechat_force_accept_data_processing_consent', value: true }, + { _id: 'Livechat_Allow_collect_and_store_HTTP_header_informations', value: true }, + omnichannelEnabledQuery, + ], + }); + + await this.add('Livechat_agent_leave_action', 'none', { + type: 'select', + group: 'Omnichannel', + section: 'Sessions', + values: [ + { key: 'none', i18nLabel: 'None' }, + { key: 'forward', i18nLabel: 'Forward' }, + { key: 'close', i18nLabel: 'Close' }, + ], + i18nLabel: 'How_to_handle_open_sessions_when_agent_goes_offline', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Livechat_agent_leave_action_timeout', 60, { + type: 'int', + group: 'Omnichannel', + section: 'Sessions', + enableQuery: [{ _id: 'Livechat_agent_leave_action', value: { $ne: 'none' } }, omnichannelEnabledQuery], + i18nLabel: 'How_long_to_wait_after_agent_goes_offline', + i18nDescription: 'Time_in_seconds', + }); + + await this.add('Livechat_agent_leave_comment', '', { + type: 'string', + group: 'Omnichannel', + section: 'Sessions', + enableQuery: [{ _id: 'Livechat_agent_leave_action', value: 'close' }, omnichannelEnabledQuery], + i18nLabel: 'Comment_to_leave_on_closing_session', + }); + + await this.add('Livechat_visitor_inactivity_timeout', 3600, { + type: 'int', + group: 'Omnichannel', + section: 'Sessions', + i18nLabel: 'How_long_to_wait_to_consider_visitor_abandonment', + i18nDescription: 'Time_in_seconds', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Omnichannel_call_provider', 'none', { + type: 'select', + public: true, + group: 'Omnichannel', + section: 'Video_and_Audio_Call', + values: [ + { key: 'none', i18nLabel: 'None' }, + { key: 'default-provider', i18nLabel: 'Default Provider' }, + ], + i18nDescription: 'Feature_depends_on_selected_call_provider_to_be_enabled_from_administration_settings', + i18nLabel: 'Call_provider', + enableQuery: omnichannelEnabledQuery, + }); + + await this.add('Omnichannel_Metrics_Ignore_Automatic_Messages', false, { + type: 'boolean', + public: true, + group: 'Omnichannel', + section: 'Analytics', + i18nLabel: 'Omnichannel_Ignore_automatic_responses_for_performance_metrics', + }); + }); +await settingsRegistry.addGroup('SMS', async function () { + await this.add('SMS_Enabled', false, { + type: 'boolean', + i18nLabel: 'Enabled', + }); + + await this.add('SMS_Service', 'twilio', { + type: 'select', + values: [ + { + key: 'twilio', + i18nLabel: 'Twilio', + }, + ], + i18nLabel: 'Service', + }); + + await this.add('SMS_Default_Omnichannel_Department', '', { + type: 'string', + }); + + await this.section('Twilio', async function () { + await this.add('SMS_Twilio_Account_SID', '', { + type: 'string', + enableQuery: { + _id: 'SMS_Service', + value: 'twilio', + }, + i18nLabel: 'Account_SID', + secret: true, + }); + await this.add('SMS_Twilio_authToken', '', { + type: 'string', + enableQuery: { + _id: 'SMS_Service', + value: 'twilio', + }, + i18nLabel: 'Auth_Token', + secret: true, + }); + await this.add('SMS_Twilio_FileUpload_Enabled', true, { + type: 'boolean', + enableQuery: { + _id: 'SMS_Service', + value: 'twilio', + }, + i18nLabel: 'FileUpload_Enabled', + secret: true, + }); + await this.add('SMS_Twilio_FileUpload_MediaTypeWhiteList', 'image/*,audio/*,video/*,text/*,application/pdf', { + type: 'string', + enableQuery: { + _id: 'SMS_Service', + value: 'twilio', + }, + i18nLabel: 'FileUpload_MediaTypeWhiteList', + i18nDescription: 'FileUpload_MediaTypeWhiteListDescription', + secret: true, + }); + }); + + await this.section('External Frame', async function () { + await this.add('Omnichannel_External_Frame_Enabled', false, { + type: 'boolean', + public: true, + alert: 'Experimental_Feature_Alert', + }); + + await this.add('Omnichannel_External_Frame_URL', '', { + type: 'string', + public: true, + enableQuery: { + _id: 'Omnichannel_External_Frame_Enabled', + value: true, + }, + }); + + await this.add('Omnichannel_External_Frame_Encryption_JWK', '', { + type: 'string', + public: true, + enableQuery: { + _id: 'Omnichannel_External_Frame_Enabled', + value: true, + }, + }); + }); +}); diff --git a/apps/meteor/server/settings/push.ts b/apps/meteor/server/settings/push.ts new file mode 100644 index 0000000..2a7a052 --- /dev/null +++ b/apps/meteor/server/settings/push.ts @@ -0,0 +1,168 @@ +import { settingsRegistry } from '.'; + +const pushEnabledWithoutGateway = [ + { + _id: 'Push_enable', + value: true, + }, + { + _id: 'Push_enable_gateway', + value: false, + }, +]; + +export const createPushSettings = () => + settingsRegistry.addGroup('Push', async function () { + await this.add('Push_enable', true, { + type: 'boolean', + public: true, + alert: 'Push_Setting_Requires_Restart_Alert', + }); + + // TODO: Push_UseLegacy should be removed in 8.0.0, as well as Push_gcm_project_number and Push_gcm_api_key + await this.add('Push_UseLegacy', false, { + type: 'boolean', + hidden: true, + alert: 'Push_Setting_Legacy_Warning', + }); + + await this.add('Push_enable_gateway', true, { + type: 'boolean', + alert: 'Push_Setting_Requires_Restart_Alert', + enableQuery: [ + { + _id: 'Push_enable', + value: true, + }, + { + _id: 'Register_Server', + value: true, + }, + { + _id: 'Cloud_Service_Agree_PrivacyTerms', + value: true, + }, + ], + }); + await this.add('Push_gateway', 'https://gateway.rocket.chat', { + type: 'string', + i18nDescription: 'Push_gateway_description', + alert: 'Push_Setting_Requires_Restart_Alert', + multiline: true, + enableQuery: [ + { + _id: 'Push_enable', + value: true, + }, + { + _id: 'Push_enable_gateway', + value: true, + }, + ], + }); + await this.add('Push_production', true, { + type: 'boolean', + public: true, + alert: 'Push_Setting_Requires_Restart_Alert', + enableQuery: pushEnabledWithoutGateway, + }); + await this.add( + 'Push_test_push', + { method: 'POST', path: '/v1/push.test' }, + { + type: 'action', + actionText: 'Send_a_test_push_to_my_user', + enableQuery: { + _id: 'Push_enable', + value: true, + }, + }, + ); + await this.section('Certificates_and_Keys', async function () { + await this.add('Push_apn_passphrase', '', { + type: 'string', + enableQuery: [], + secret: true, + }); + await this.add('Push_apn_key', '', { + type: 'string', + multiline: true, + enableQuery: [], + secret: true, + }); + await this.add('Push_apn_cert', '', { + type: 'string', + multiline: true, + enableQuery: [], + secret: true, + }); + await this.add('Push_apn_dev_passphrase', '', { + type: 'string', + enableQuery: [], + secret: true, + }); + await this.add('Push_apn_dev_key', '', { + type: 'string', + multiline: true, + enableQuery: [], + secret: true, + }); + await this.add('Push_apn_dev_cert', '', { + type: 'string', + multiline: true, + enableQuery: [], + secret: true, + }); + await this.add('Push_gcm_api_key', '', { + type: 'string', + hidden: true, + enableQuery: [ + { + _id: 'Push_UseLegacy', + value: true, + }, + ], + secret: true, + }); + + await this.add('Push_google_api_credentials', '', { + type: 'code', + multiline: true, + enableQuery: [ + { + _id: 'Push_UseLegacy', + value: false, + }, + ], + secret: true, + }); + + return this.add('Push_gcm_project_number', '', { + type: 'string', + hidden: true, + enableQuery: [ + { + _id: 'Push_UseLegacy', + value: true, + }, + ], + secret: true, + }); + }); + return this.section('Privacy', async function () { + await this.add('Push_show_username_room', true, { + type: 'boolean', + public: true, + }); + await this.add('Push_show_message', true, { + type: 'boolean', + public: true, + }); + await this.add('Push_request_content_from_server', true, { + type: 'boolean', + enterprise: true, + invalidValue: false, + modules: ['push-privacy'], + }); + }); + }); diff --git a/apps/meteor/server/settings/rate.ts b/apps/meteor/server/settings/rate.ts new file mode 100644 index 0000000..e045fc9 --- /dev/null +++ b/apps/meteor/server/settings/rate.ts @@ -0,0 +1,79 @@ +import { settingsRegistry } from '.'; + +export const createRateLimitSettings = () => + settingsRegistry.addGroup('Rate Limiter', async function () { + await this.section('DDP_Rate_Limiter', async function () { + await this.add('DDP_Rate_Limit_IP_Enabled', true, { type: 'boolean' }); + await this.add('DDP_Rate_Limit_IP_Requests_Allowed', 120000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_IP_Enabled', value: true }, + }); + await this.add('DDP_Rate_Limit_IP_Interval_Time', 60000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_IP_Enabled', value: true }, + }); + + await this.add('DDP_Rate_Limit_User_Enabled', true, { type: 'boolean' }); + await this.add('DDP_Rate_Limit_User_Requests_Allowed', 1200, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_User_Enabled', value: true }, + }); + await this.add('DDP_Rate_Limit_User_Interval_Time', 60000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_User_Enabled', value: true }, + }); + + await this.add('DDP_Rate_Limit_Connection_Enabled', true, { type: 'boolean' }); + await this.add('DDP_Rate_Limit_Connection_Requests_Allowed', 600, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_Connection_Enabled', value: true }, + }); + await this.add('DDP_Rate_Limit_Connection_Interval_Time', 60000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_Connection_Enabled', value: true }, + }); + + await this.add('DDP_Rate_Limit_User_By_Method_Enabled', true, { type: 'boolean' }); + await this.add('DDP_Rate_Limit_User_By_Method_Requests_Allowed', 20, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_User_By_Method_Enabled', value: true }, + }); + await this.add('DDP_Rate_Limit_User_By_Method_Interval_Time', 10000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_User_By_Method_Enabled', value: true }, + }); + + await this.add('DDP_Rate_Limit_Connection_By_Method_Enabled', true, { type: 'boolean' }); + await this.add('DDP_Rate_Limit_Connection_By_Method_Requests_Allowed', 10, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_Connection_By_Method_Enabled', value: true }, + }); + await this.add('DDP_Rate_Limit_Connection_By_Method_Interval_Time', 10000, { + type: 'int', + enableQuery: { _id: 'DDP_Rate_Limit_Connection_By_Method_Enabled', value: true }, + }); + }); + + await this.section('API_Rate_Limiter', async function () { + await this.add('API_Enable_Rate_Limiter', true, { type: 'boolean' }); + await this.add('API_Enable_Rate_Limiter_Dev', true, { + type: 'boolean', + enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true }, + }); + await this.add('API_Enable_Rate_Limiter_Limit_Calls_Default', 10, { + type: 'int', + enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true }, + }); + await this.add('API_Enable_Rate_Limiter_Limit_Time_Default', 60000, { + type: 'int', + enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true }, + }); + }); + + await this.section('Feature_Limiting', async function () { + await this.add('Rate_Limiter_Limit_RegisterUser', 1, { + type: 'int', + enableQuery: { _id: 'API_Enable_Rate_Limiter', value: true }, + }); + }); + }); diff --git a/apps/meteor/server/settings/raw.ts b/apps/meteor/server/settings/raw.ts new file mode 100644 index 0000000..31d6f2d --- /dev/null +++ b/apps/meteor/server/settings/raw.ts @@ -0,0 +1,32 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +const cache = new Map(); + +export const setValue = (_id: string, value: SettingValue) => cache.set(_id, value); + +const setFromDB = async (_id: string) => { + const setting = await Settings.findOneById(_id, { projection: { value: 1 } }); + if (!setting) { + return; + } + + setValue(_id, setting.value); + + return setting.value; +}; + +export const getValue = async (_id: string) => { + if (!cache.has(_id)) { + return setFromDB(_id); + } + + return cache.get(_id); +}; + +export const updateValue = (id: string, fields: T) => { + if (typeof fields.value === 'undefined') { + return; + } + setValue(id, fields.value); +}; diff --git a/apps/meteor/server/settings/retention-policy.ts b/apps/meteor/server/settings/retention-policy.ts new file mode 100644 index 0000000..17cbf56 --- /dev/null +++ b/apps/meteor/server/settings/retention-policy.ts @@ -0,0 +1,202 @@ +import { settingsRegistry } from '.'; + +const THIRTY_DAYS = 2592000000; + +export const createRetentionSettings = () => + settingsRegistry.addGroup('RetentionPolicy', async function () { + const globalQuery = { + _id: 'RetentionPolicy_Enabled', + value: true, + }; + + await this.add('RetentionPolicy_Enabled', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_Enabled', + alert: + 'Watch out! Tweaking these settings without utmost care can destroy all message history. Please read here the documentation before turning the feature ON.', + }); + + await this.add('RetentionPolicy_Precision', '0', { + type: 'select', + values: [ + { + key: '0', + i18nLabel: 'every_30_minutes', + }, + { + key: '1', + i18nLabel: 'every_hour', + }, + { + key: '2', + i18nLabel: 'every_six_hours', + }, + { + key: '3', + i18nLabel: 'every_day', + }, + ], + public: true, + i18nLabel: 'RetentionPolicy_Precision', + i18nDescription: 'RetentionPolicy_Precision_Description', + enableQuery: [ + globalQuery, + { + _id: 'RetentionPolicy_Advanced_Precision', + value: false, + }, + ], + }); + + await this.add('RetentionPolicy_Advanced_Precision', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_Advanced_Precision', + i18nDescription: 'RetentionPolicy_Advanced_Precision_Description', + enableQuery: globalQuery, + }); + + await this.add('RetentionPolicy_Advanced_Precision_Cron', '*/30 * * * *', { + type: 'string', + public: true, + i18nLabel: 'RetentionPolicy_Advanced_Precision_Cron', + i18nDescription: 'RetentionPolicy_Advanced_Precision_Cron_Description', + enableQuery: [globalQuery, { _id: 'RetentionPolicy_Advanced_Precision', value: true }], + }); + + await this.section('Global Policy', async function () { + await this.add('RetentionPolicy_AppliesToChannels', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_AppliesToChannels', + i18nDescription: 'RetentionPolicy_AppliesToChannels_Description', + enableQuery: globalQuery, + }); + await this.add('RetentionPolicy_MaxAge_Channels', 30, { + type: 'int', + public: true, + hidden: true, + i18nLabel: 'RetentionPolicy_MaxAge_Channels', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToChannels', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_TTL_Channels', THIRTY_DAYS, { + type: 'timespan', + public: true, + i18nLabel: 'RetentionPolicy_TTL_Channels', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToChannels', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_AppliesToGroups', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_AppliesToGroups', + i18nDescription: 'RetentionPolicy_AppliesToGroups_Description', + enableQuery: globalQuery, + }); + await this.add('RetentionPolicy_MaxAge_Groups', 30, { + type: 'int', + public: true, + hidden: true, + i18nLabel: 'RetentionPolicy_MaxAge_Groups', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToGroups', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_TTL_Groups', THIRTY_DAYS, { + type: 'timespan', + public: true, + i18nLabel: 'RetentionPolicy_TTL_Groups', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToGroups', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_AppliesToDMs', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_AppliesToDMs', + enableQuery: globalQuery, + }); + + await this.add('RetentionPolicy_MaxAge_DMs', 30, { + type: 'int', + public: true, + hidden: true, + i18nLabel: 'RetentionPolicy_MaxAge_DMs', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToDMs', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_TTL_DMs', THIRTY_DAYS, { + type: 'timespan', + public: true, + i18nLabel: 'RetentionPolicy_TTL_DMs', + enableQuery: [ + { + _id: 'RetentionPolicy_AppliesToDMs', + value: true, + }, + globalQuery, + ], + }); + + await this.add('RetentionPolicy_DoNotPrunePinned', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_DoNotPrunePinned', + enableQuery: globalQuery, + }); + + await this.add('RetentionPolicy_FilesOnly', false, { + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_FilesOnly', + i18nDescription: 'RetentionPolicy_FilesOnly_Description', + enableQuery: globalQuery, + }); + await this.add('RetentionPolicy_DoNotPruneDiscussion', true, { + group: 'RetentionPolicy', + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_DoNotPruneDiscussion', + i18nDescription: 'RetentionPolicy_DoNotPruneDiscussion_Description', + enableQuery: globalQuery, + }); + await this.add('RetentionPolicy_DoNotPruneThreads', true, { + group: 'RetentionPolicy', + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_DoNotPruneThreads', + i18nDescription: 'RetentionPolicy_DoNotPruneThreads_Description', + enableQuery: globalQuery, + }); + }); + }); diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts new file mode 100644 index 0000000..b46150d --- /dev/null +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -0,0 +1,1358 @@ +import { settingsRegistry } from '.'; + +export const createSetupWSettings = () => + settingsRegistry.addGroup('Setup_Wizard', async function () { + await this.section('Organization_Info', async function () { + await this.add('Organization_Type', '', { + type: 'select', + values: [ + { + key: 'community', + i18nLabel: 'Community', + }, + { + key: 'enterprise', + i18nLabel: 'Premium', + }, + { + key: 'government', + i18nLabel: 'Government', + }, + { + key: 'nonprofit', + i18nLabel: 'Nonprofit', + }, + ], + wizard: { + step: 2, + order: 0, + }, + }); + await this.add('Organization_Name', '', { + type: 'string', + wizard: { + step: 2, + order: 1, + }, + public: true, + }); + await this.add('Industry', '', { + type: 'select', + values: [ + { + key: 'aerospaceDefense', + i18nLabel: 'Aerospace_and_Defense', + }, + { + key: 'consulting', + i18nLabel: 'Consulting', + }, + { + key: 'consumerGoods', + i18nLabel: 'Consumer_Packaged_Goods', + }, + { + key: 'contactCenter', + i18nLabel: 'Contact_Center', + }, + { + key: 'education', + i18nLabel: 'Education', + }, + { + key: 'entertainment', + i18nLabel: 'Entertainment', + }, + { + key: 'financialServices', + i18nLabel: 'Financial_Services', + }, + { + key: 'gaming', + i18nLabel: 'Gaming', + }, + { + key: 'healthcare', + i18nLabel: 'Healthcare', + }, + { + key: 'hospitalityBusinness', + i18nLabel: 'Hospitality_Businness', + }, + { + key: 'insurance', + i18nLabel: 'Insurance', + }, + { + key: 'itSecurity', + i18nLabel: 'It_Security', + }, + { + key: 'logistics', + i18nLabel: 'Logistics', + }, + { + key: 'manufacturing', + i18nLabel: 'Manufacturing', + }, + { + key: 'media', + i18nLabel: 'Media', + }, + { + key: 'pharmaceutical', + i18nLabel: 'Pharmaceutical', + }, + { + key: 'realEstate', + i18nLabel: 'Real_Estate', + }, + { + key: 'religious', + i18nLabel: 'Religious', + }, + { + key: 'retail', + i18nLabel: 'Retail', + }, + { + key: 'socialNetwork', + i18nLabel: 'Social_Network', + }, + { + key: 'technologyProvider', + i18nLabel: 'Technology_Provider', + }, + { + key: 'technologyServices', + i18nLabel: 'Technology_Services', + }, + { + key: 'telecom', + i18nLabel: 'Telecom', + }, + { + key: 'utilities', + i18nLabel: 'Utilities', + }, + { + key: 'other', + i18nLabel: 'Other', + }, + ], + wizard: { + step: 2, + order: 2, + }, + }); + await this.add('Size', '', { + type: 'select', + values: [ + { + key: '0', + i18nLabel: '1-10 people', + }, + { + key: '1', + i18nLabel: '11-50 people', + }, + { + key: '2', + i18nLabel: '51-100 people', + }, + { + key: '3', + i18nLabel: '101-250 people', + }, + { + key: '4', + i18nLabel: '251-500 people', + }, + { + key: '5', + i18nLabel: '501-1000 people', + }, + { + key: '6', + i18nLabel: '1001-4000 people', + }, + { + key: '7', + i18nLabel: '4000 or more people', + }, + ], + wizard: { + step: 2, + order: 3, + }, + }); + await this.add('Country', '', { + type: 'select', + values: [ + { + key: 'afghanistan', + i18nLabel: 'Country_Afghanistan', + }, + { + key: 'albania', + i18nLabel: 'Country_Albania', + }, + { + key: 'algeria', + i18nLabel: 'Country_Algeria', + }, + { + key: 'americanSamoa', + i18nLabel: 'Country_American_Samoa', + }, + { + key: 'andorra', + i18nLabel: 'Country_Andorra', + }, + { + key: 'angola', + i18nLabel: 'Country_Angola', + }, + { + key: 'anguilla', + i18nLabel: 'Country_Anguilla', + }, + { + key: 'antarctica', + i18nLabel: 'Country_Antarctica', + }, + { + key: 'antiguaAndBarbuda', + i18nLabel: 'Country_Antigua_and_Barbuda', + }, + { + key: 'argentina', + i18nLabel: 'Country_Argentina', + }, + { + key: 'armenia', + i18nLabel: 'Country_Armenia', + }, + { + key: 'aruba', + i18nLabel: 'Country_Aruba', + }, + { + key: 'australia', + i18nLabel: 'Country_Australia', + }, + { + key: 'austria', + i18nLabel: 'Country_Austria', + }, + { + key: 'azerbaijan', + i18nLabel: 'Country_Azerbaijan', + }, + { + key: 'bahamas', + i18nLabel: 'Country_Bahamas', + }, + { + key: 'bahrain', + i18nLabel: 'Country_Bahrain', + }, + { + key: 'bangladesh', + i18nLabel: 'Country_Bangladesh', + }, + { + key: 'barbados', + i18nLabel: 'Country_Barbados', + }, + { + key: 'belarus', + i18nLabel: 'Country_Belarus', + }, + { + key: 'belgium', + i18nLabel: 'Country_Belgium', + }, + { + key: 'belize', + i18nLabel: 'Country_Belize', + }, + { + key: 'benin', + i18nLabel: 'Country_Benin', + }, + { + key: 'bermuda', + i18nLabel: 'Country_Bermuda', + }, + { + key: 'bhutan', + i18nLabel: 'Country_Bhutan', + }, + { + key: 'bolivia', + i18nLabel: 'Country_Bolivia', + }, + { + key: 'bosniaAndHerzegovina', + i18nLabel: 'Country_Bosnia_and_Herzegovina', + }, + { + key: 'botswana', + i18nLabel: 'Country_Botswana', + }, + { + key: 'bouvetIsland', + i18nLabel: 'Country_Bouvet_Island', + }, + { + key: 'brazil', + i18nLabel: 'Country_Brazil', + }, + { + key: 'britishIndianOceanTerritory', + i18nLabel: 'Country_British_Indian_Ocean_Territory', + }, + { + key: 'bruneiDarussalam', + i18nLabel: 'Country_Brunei_Darussalam', + }, + { + key: 'bulgaria', + i18nLabel: 'Country_Bulgaria', + }, + { + key: 'burkinaFaso', + i18nLabel: 'Country_Burkina_Faso', + }, + { + key: 'burundi', + i18nLabel: 'Country_Burundi', + }, + { + key: 'cambodia', + i18nLabel: 'Country_Cambodia', + }, + { + key: 'cameroon', + i18nLabel: 'Country_Cameroon', + }, + { + key: 'canada', + i18nLabel: 'Country_Canada', + }, + { + key: 'capeVerde', + i18nLabel: 'Country_Cape_Verde', + }, + { + key: 'caymanIslands', + i18nLabel: 'Country_Cayman_Islands', + }, + { + key: 'centralAfricanRepublic', + i18nLabel: 'Country_Central_African_Republic', + }, + { + key: 'chad', + i18nLabel: 'Country_Chad', + }, + { + key: 'chile', + i18nLabel: 'Country_Chile', + }, + { + key: 'china', + i18nLabel: 'Country_China', + }, + { + key: 'christmasIsland', + i18nLabel: 'Country_Christmas_Island', + }, + { + key: 'cocosKeelingIslands', + i18nLabel: 'Country_Cocos_Keeling_Islands', + }, + { + key: 'colombia', + i18nLabel: 'Country_Colombia', + }, + { + key: 'comoros', + i18nLabel: 'Country_Comoros', + }, + { + key: 'congo', + i18nLabel: 'Country_Congo', + }, + { + key: 'congoTheDemocraticRepublicOfThe', + i18nLabel: 'Country_Congo_The_Democratic_Republic_of_The', + }, + { + key: 'cookIslands', + i18nLabel: 'Country_Cook_Islands', + }, + { + key: 'costaRica', + i18nLabel: 'Country_Costa_Rica', + }, + { + key: 'coteDivoire', + i18nLabel: 'Country_Cote_Divoire', + }, + { + key: 'croatia', + i18nLabel: 'Country_Croatia', + }, + { + key: 'cuba', + i18nLabel: 'Country_Cuba', + }, + { + key: 'cyprus', + i18nLabel: 'Country_Cyprus', + }, + { + key: 'czechRepublic', + i18nLabel: 'Country_Czech_Republic', + }, + { + key: 'denmark', + i18nLabel: 'Country_Denmark', + }, + { + key: 'djibouti', + i18nLabel: 'Country_Djibouti', + }, + { + key: 'dominica', + i18nLabel: 'Country_Dominica', + }, + { + key: 'dominicanRepublic', + i18nLabel: 'Country_Dominican_Republic', + }, + { + key: 'ecuador', + i18nLabel: 'Country_Ecuador', + }, + { + key: 'egypt', + i18nLabel: 'Country_Egypt', + }, + { + key: 'elSalvador', + i18nLabel: 'Country_El_Salvador', + }, + { + key: 'equatorialGuinea', + i18nLabel: 'Country_Equatorial_Guinea', + }, + { + key: 'eritrea', + i18nLabel: 'Country_Eritrea', + }, + { + key: 'estonia', + i18nLabel: 'Country_Estonia', + }, + { + key: 'ethiopia', + i18nLabel: 'Country_Ethiopia', + }, + { + key: 'falklandIslandsMalvinas', + i18nLabel: 'Country_Falkland_Islands_Malvinas', + }, + { + key: 'faroeIslands', + i18nLabel: 'Country_Faroe_Islands', + }, + { + key: 'fiji', + i18nLabel: 'Country_Fiji', + }, + { + key: 'finland', + i18nLabel: 'Country_Finland', + }, + { + key: 'france', + i18nLabel: 'Country_France', + }, + { + key: 'frenchGuiana', + i18nLabel: 'Country_French_Guiana', + }, + { + key: 'frenchPolynesia', + i18nLabel: 'Country_French_Polynesia', + }, + { + key: 'frenchSouthernTerritories', + i18nLabel: 'Country_French_Southern_Territories', + }, + { + key: 'gabon', + i18nLabel: 'Country_Gabon', + }, + { + key: 'gambia', + i18nLabel: 'Country_Gambia', + }, + { + key: 'georgia', + i18nLabel: 'Country_Georgia', + }, + { + key: 'germany', + i18nLabel: 'Country_Germany', + }, + { + key: 'ghana', + i18nLabel: 'Country_Ghana', + }, + { + key: 'gibraltar', + i18nLabel: 'Country_Gibraltar', + }, + { + key: 'greece', + i18nLabel: 'Country_Greece', + }, + { + key: 'greenland', + i18nLabel: 'Country_Greenland', + }, + { + key: 'grenada', + i18nLabel: 'Country_Grenada', + }, + { + key: 'guadeloupe', + i18nLabel: 'Country_Guadeloupe', + }, + { + key: 'guam', + i18nLabel: 'Country_Guam', + }, + { + key: 'guatemala', + i18nLabel: 'Country_Guatemala', + }, + { + key: 'guinea', + i18nLabel: 'Country_Guinea', + }, + { + key: 'guineaBissau', + i18nLabel: 'Country_Guinea_bissau', + }, + { + key: 'guyana', + i18nLabel: 'Country_Guyana', + }, + { + key: 'haiti', + i18nLabel: 'Country_Haiti', + }, + { + key: 'heardIslandAndMcdonaldIslands', + i18nLabel: 'Country_Heard_Island_and_Mcdonald_Islands', + }, + { + key: 'holySeeVaticanCityState', + i18nLabel: 'Country_Holy_See_Vatican_City_State', + }, + { + key: 'honduras', + i18nLabel: 'Country_Honduras', + }, + { + key: 'hongKong', + i18nLabel: 'Country_Hong_Kong', + }, + { + key: 'hungary', + i18nLabel: 'Country_Hungary', + }, + { + key: 'iceland', + i18nLabel: 'Country_Iceland', + }, + { + key: 'india', + i18nLabel: 'Country_India', + }, + { + key: 'indonesia', + i18nLabel: 'Country_Indonesia', + }, + { + key: 'iranIslamicRepublicOf', + i18nLabel: 'Country_Iran_Islamic_Republic_of', + }, + { + key: 'iraq', + i18nLabel: 'Country_Iraq', + }, + { + key: 'ireland', + i18nLabel: 'Country_Ireland', + }, + { + key: 'israel', + i18nLabel: 'Country_Israel', + }, + { + key: 'italy', + i18nLabel: 'Country_Italy', + }, + { + key: 'jamaica', + i18nLabel: 'Country_Jamaica', + }, + { + key: 'japan', + i18nLabel: 'Country_Japan', + }, + { + key: 'jordan', + i18nLabel: 'Country_Jordan', + }, + { + key: 'kazakhstan', + i18nLabel: 'Country_Kazakhstan', + }, + { + key: 'kenya', + i18nLabel: 'Country_Kenya', + }, + { + key: 'kiribati', + i18nLabel: 'Country_Kiribati', + }, + { + key: 'koreaDemocraticPeoplesRepublicOf', + i18nLabel: 'Country_Korea_Democratic_Peoples_Republic_of', + }, + { + key: 'koreaRepublicOf', + i18nLabel: 'Country_Korea_Republic_of', + }, + { + key: 'kuwait', + i18nLabel: 'Country_Kuwait', + }, + { + key: 'kyrgyzstan', + i18nLabel: 'Country_Kyrgyzstan', + }, + { + key: 'laoPeoplesDemocraticRepublic', + i18nLabel: 'Country_Lao_Peoples_Democratic_Republic', + }, + { + key: 'latvia', + i18nLabel: 'Country_Latvia', + }, + { + key: 'lebanon', + i18nLabel: 'Country_Lebanon', + }, + { + key: 'lesotho', + i18nLabel: 'Country_Lesotho', + }, + { + key: 'liberia', + i18nLabel: 'Country_Liberia', + }, + { + key: 'libyanArabJamahiriya', + i18nLabel: 'Country_Libyan_Arab_Jamahiriya', + }, + { + key: 'liechtenstein', + i18nLabel: 'Country_Liechtenstein', + }, + { + key: 'lithuania', + i18nLabel: 'Country_Lithuania', + }, + { + key: 'luxembourg', + i18nLabel: 'Country_Luxembourg', + }, + { + key: 'macao', + i18nLabel: 'Country_Macao', + }, + { + key: 'macedoniaTheFormerYugoslavRepublicOf', + i18nLabel: 'Country_Macedonia_The_Former_Yugoslav_Republic_of', + }, + { + key: 'madagascar', + i18nLabel: 'Country_Madagascar', + }, + { + key: 'malawi', + i18nLabel: 'Country_Malawi', + }, + { + key: 'malaysia', + i18nLabel: 'Country_Malaysia', + }, + { + key: 'maldives', + i18nLabel: 'Country_Maldives', + }, + { + key: 'mali', + i18nLabel: 'Country_Mali', + }, + { + key: 'malta', + i18nLabel: 'Country_Malta', + }, + { + key: 'marshallIslands', + i18nLabel: 'Country_Marshall_Islands', + }, + { + key: 'martinique', + i18nLabel: 'Country_Martinique', + }, + { + key: 'mauritania', + i18nLabel: 'Country_Mauritania', + }, + { + key: 'mauritius', + i18nLabel: 'Country_Mauritius', + }, + { + key: 'mayotte', + i18nLabel: 'Country_Mayotte', + }, + { + key: 'mexico', + i18nLabel: 'Country_Mexico', + }, + { + key: 'micronesiaFederatedStatesOf', + i18nLabel: 'Country_Micronesia_Federated_States_of', + }, + { + key: 'moldovaRepublicOf', + i18nLabel: 'Country_Moldova_Republic_of', + }, + { + key: 'monaco', + i18nLabel: 'Country_Monaco', + }, + { + key: 'mongolia', + i18nLabel: 'Country_Mongolia', + }, + { + key: 'montserrat', + i18nLabel: 'Country_Montserrat', + }, + { + key: 'morocco', + i18nLabel: 'Country_Morocco', + }, + { + key: 'mozambique', + i18nLabel: 'Country_Mozambique', + }, + { + key: 'myanmar', + i18nLabel: 'Country_Myanmar', + }, + { + key: 'namibia', + i18nLabel: 'Country_Namibia', + }, + { + key: 'nauru', + i18nLabel: 'Country_Nauru', + }, + { + key: 'nepal', + i18nLabel: 'Country_Nepal', + }, + { + key: 'netherlands', + i18nLabel: 'Country_Netherlands', + }, + { + key: 'netherlandsAntilles', + i18nLabel: 'Country_Netherlands_Antilles', + }, + { + key: 'newCaledonia', + i18nLabel: 'Country_New_Caledonia', + }, + { + key: 'newZealand', + i18nLabel: 'Country_New_Zealand', + }, + { + key: 'nicaragua', + i18nLabel: 'Country_Nicaragua', + }, + { + key: 'niger', + i18nLabel: 'Country_Niger', + }, + { + key: 'nigeria', + i18nLabel: 'Country_Nigeria', + }, + { + key: 'niue', + i18nLabel: 'Country_Niue', + }, + { + key: 'norfolkIsland', + i18nLabel: 'Country_Norfolk_Island', + }, + { + key: 'northernMarianaIslands', + i18nLabel: 'Country_Northern_Mariana_Islands', + }, + { + key: 'norway', + i18nLabel: 'Country_Norway', + }, + { + key: 'oman', + i18nLabel: 'Country_Oman', + }, + { + key: 'pakistan', + i18nLabel: 'Country_Pakistan', + }, + { + key: 'palau', + i18nLabel: 'Country_Palau', + }, + { + key: 'palestinianTerritoryOccupied', + i18nLabel: 'Country_Palestinian_Territory_Occupied', + }, + { + key: 'panama', + i18nLabel: 'Country_Panama', + }, + { + key: 'papuaNewGuinea', + i18nLabel: 'Country_Papua_New_Guinea', + }, + { + key: 'paraguay', + i18nLabel: 'Country_Paraguay', + }, + { + key: 'peru', + i18nLabel: 'Country_Peru', + }, + { + key: 'philippines', + i18nLabel: 'Country_Philippines', + }, + { + key: 'pitcairn', + i18nLabel: 'Country_Pitcairn', + }, + { + key: 'poland', + i18nLabel: 'Country_Poland', + }, + { + key: 'portugal', + i18nLabel: 'Country_Portugal', + }, + { + key: 'puertoRico', + i18nLabel: 'Country_Puerto_Rico', + }, + { + key: 'qatar', + i18nLabel: 'Country_Qatar', + }, + { + key: 'reunion', + i18nLabel: 'Country_Reunion', + }, + { + key: 'romania', + i18nLabel: 'Country_Romania', + }, + { + key: 'russianFederation', + i18nLabel: 'Country_Russian_Federation', + }, + { + key: 'rwanda', + i18nLabel: 'Country_Rwanda', + }, + { + key: 'saintHelena', + i18nLabel: 'Country_Saint_Helena', + }, + { + key: 'saintKittsAndNevis', + i18nLabel: 'Country_Saint_Kitts_and_Nevis', + }, + { + key: 'saintLucia', + i18nLabel: 'Country_Saint_Lucia', + }, + { + key: 'saintPierreAndMiquelon', + i18nLabel: 'Country_Saint_Pierre_and_Miquelon', + }, + { + key: 'saintVincentAndTheGrenadines', + i18nLabel: 'Country_Saint_Vincent_and_The_Grenadines', + }, + { + key: 'samoa', + i18nLabel: 'Country_Samoa', + }, + { + key: 'sanMarino', + i18nLabel: 'Country_San_Marino', + }, + { + key: 'saoTomeAndPrincipe', + i18nLabel: 'Country_Sao_Tome_and_Principe', + }, + { + key: 'saudiArabia', + i18nLabel: 'Country_Saudi_Arabia', + }, + { + key: 'senegal', + i18nLabel: 'Country_Senegal', + }, + { + key: 'serbiaAndMontenegro', + i18nLabel: 'Country_Serbia_and_Montenegro', + }, + { + key: 'seychelles', + i18nLabel: 'Country_Seychelles', + }, + { + key: 'sierraLeone', + i18nLabel: 'Country_Sierra_Leone', + }, + { + key: 'singapore', + i18nLabel: 'Country_Singapore', + }, + { + key: 'slovakia', + i18nLabel: 'Country_Slovakia', + }, + { + key: 'slovenia', + i18nLabel: 'Country_Slovenia', + }, + { + key: 'solomonIslands', + i18nLabel: 'Country_Solomon_Islands', + }, + { + key: 'somalia', + i18nLabel: 'Country_Somalia', + }, + { + key: 'southAfrica', + i18nLabel: 'Country_South_Africa', + }, + { + key: 'southGeorgiaAndTheSouthSandwichIslands', + i18nLabel: 'Country_South_Georgia_and_The_South_Sandwich_Islands', + }, + { + key: 'spain', + i18nLabel: 'Country_Spain', + }, + { + key: 'sriLanka', + i18nLabel: 'Country_Sri_Lanka', + }, + { + key: 'sudan', + i18nLabel: 'Country_Sudan', + }, + { + key: 'suriname', + i18nLabel: 'Country_Suriname', + }, + { + key: 'svalbardAndJanMayen', + i18nLabel: 'Country_Svalbard_and_Jan_Mayen', + }, + { + key: 'swaziland', + i18nLabel: 'Country_Swaziland', + }, + { + key: 'sweden', + i18nLabel: 'Country_Sweden', + }, + { + key: 'switzerland', + i18nLabel: 'Country_Switzerland', + }, + { + key: 'syrianArabRepublic', + i18nLabel: 'Country_Syrian_Arab_Republic', + }, + { + key: 'taiwanProvinceOfChina', + i18nLabel: 'Country_Taiwan_Province_of_China', + }, + { + key: 'tajikistan', + i18nLabel: 'Country_Tajikistan', + }, + { + key: 'tanzaniaUnitedRepublicOf', + i18nLabel: 'Country_Tanzania_United_Republic_of', + }, + { + key: 'thailand', + i18nLabel: 'Country_Thailand', + }, + { + key: 'timorLeste', + i18nLabel: 'Country_Timor_leste', + }, + { + key: 'togo', + i18nLabel: 'Country_Togo', + }, + { + key: 'tokelau', + i18nLabel: 'Country_Tokelau', + }, + { + key: 'tonga', + i18nLabel: 'Country_Tonga', + }, + { + key: 'trinidadAndTobago', + i18nLabel: 'Country_Trinidad_and_Tobago', + }, + { + key: 'tunisia', + i18nLabel: 'Country_Tunisia', + }, + { + key: 'turkey', + i18nLabel: 'Country_Turkey', + }, + { + key: 'turkmenistan', + i18nLabel: 'Country_Turkmenistan', + }, + { + key: 'turksAndCaicosIslands', + i18nLabel: 'Country_Turks_and_Caicos_Islands', + }, + { + key: 'tuvalu', + i18nLabel: 'Country_Tuvalu', + }, + { + key: 'uganda', + i18nLabel: 'Country_Uganda', + }, + { + key: 'ukraine', + i18nLabel: 'Country_Ukraine', + }, + { + key: 'unitedArabEmirates', + i18nLabel: 'Country_United_Arab_Emirates', + }, + { + key: 'unitedKingdom', + i18nLabel: 'Country_United_Kingdom', + }, + { + key: 'unitedStates', + i18nLabel: 'Country_United_States', + }, + { + key: 'unitedStatesMinorOutlyingIslands', + i18nLabel: 'Country_United_States_Minor_Outlying_Islands', + }, + { + key: 'uruguay', + i18nLabel: 'Country_Uruguay', + }, + { + key: 'uzbekistan', + i18nLabel: 'Country_Uzbekistan', + }, + { + key: 'vanuatu', + i18nLabel: 'Country_Vanuatu', + }, + { + key: 'venezuela', + i18nLabel: 'Country_Venezuela', + }, + { + key: 'vietNam', + i18nLabel: 'Country_Viet_Nam', + }, + { + key: 'virginIslandsBritish', + i18nLabel: 'Country_Virgin_Islands_British', + }, + { + key: 'virginIslandsUS', + i18nLabel: 'Country_Virgin_Islands_US', + }, + { + key: 'wallisAndFutuna', + i18nLabel: 'Country_Wallis_and_Futuna', + }, + { + key: 'westernSahara', + i18nLabel: 'Country_Western_Sahara', + }, + { + key: 'yemen', + i18nLabel: 'Country_Yemen', + }, + { + key: 'zambia', + i18nLabel: 'Country_Zambia', + }, + { + key: 'zimbabwe', + i18nLabel: 'Country_Zimbabwe', + }, + { + key: 'worldwide', + i18nLabel: 'Worldwide', + }, + ], + wizard: { + step: 2, + order: 4, + }, + }); + await this.add('Website', '', { + type: 'string', + wizard: { + step: 2, + order: 5, + }, + }); + await this.add('Server_Type', '', { + type: 'select', + values: [ + { + key: 'privateTeam', + i18nLabel: 'Private_Team', + }, + { + key: 'publicCommunity', + i18nLabel: 'Public_Community', + }, + ], + wizard: { + step: 3, + order: 2, + }, + }); + await this.add('Allow_Marketing_Emails', true, { + type: 'boolean', + }); + await this.add('Register_Server', false, { + type: 'boolean', + }); + await this.add('Organization_Email', '', { + type: 'string', + }); + await this.add('Triggered_Emails_Count', 0, { + type: 'int', + hidden: true, + }); + }); + await this.section('Cloud_Info', async function () { + await this.add('Nps_Url', 'https://nps.rocket.chat', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Supported_Versions_Token', '', { + type: 'string', + hidden: true, + readonly: true, + secret: true, + }); + + await this.add('Cloud_Url', 'https://cloud.rocket.chat', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Omnigateway_Url', 'https://omni-gateway.rocket.chat', { + type: 'string', + hidden: true, + secret: true, + readonly: true, + }); + + await this.add('Cloud_Service_Agree_PrivacyTerms', false, { + type: 'boolean', + }); + + await this.add('Cloud_Workspace_Id', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Name', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Client_Id', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Client_Secret', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Client_Secret_Expires_At', 0, { + type: 'int', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Registration_Client_Uri', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_PublicKey', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_License', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + + await this.add('Cloud_Workspace_Had_Trial', false, { + type: 'boolean', + hidden: true, + readonly: true, + secret: true, + }); + + await this.add('Cloud_Workspace_Registration_State', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + await this.add('Cloud_Billing_Url', 'https://billing.rocket.chat', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + await this.add('Cloud_Sync_Announcement_Payload', 'null', { + type: 'string', // TODO: replace setting type string for object once is implemented. + hidden: true, + secret: true, + }); + }); + }); diff --git a/apps/meteor/server/settings/slackbridge.ts b/apps/meteor/server/settings/slackbridge.ts new file mode 100644 index 0000000..507b9f5 --- /dev/null +++ b/apps/meteor/server/settings/slackbridge.ts @@ -0,0 +1,182 @@ +import { settingsRegistry } from '.'; + +export const createSlackBridgeSettings = () => + settingsRegistry.addGroup('SlackBridge', async function () { + await this.add('SlackBridge_Enabled', false, { + type: 'boolean', + i18nLabel: 'Enabled', + public: true, + }); + + await this.add('SlackBridge_UseLegacy', true, { + type: 'boolean', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + i18nLabel: 'SlackBridge_UseLegacy', + i18nDescription: 'SlackBridge_UseLegacy_Description', + public: true, + packageValue: true, + }); + + await this.add('SlackBridge_APIToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: true, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_APIToken', + i18nDescription: 'SlackBridge_APIToken_Description', + secret: true, + }); + + await this.add('SlackBridge_BotToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_BotToken', + i18nDescription: 'SlackBridge_BotToken_Description', + secret: true, + }); + + await this.add('SlackBridge_SigningSecret', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_SigningSecret', + i18nDescription: 'SlackBridge_SigningSecret_Description', + secret: true, + }); + + await this.add('SlackBridge_AppToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_AppToken', + i18nDescription: 'SlackBridge_AppToken_Description', + secret: true, + }); + + await this.add('SlackBridge_FileUpload_Enabled', true, { + type: 'boolean', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + i18nLabel: 'FileUpload', + }); + + await this.add('SlackBridge_Out_Enabled', false, { + type: 'boolean', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + }); + + await this.add('SlackBridge_Out_All', false, { + type: 'boolean', + enableQuery: [ + { + _id: 'SlackBridge_Enabled', + value: true, + }, + { + _id: 'SlackBridge_Out_Enabled', + value: true, + }, + ], + }); + + await this.add('SlackBridge_Out_Channels', '', { + type: 'roomPick', + enableQuery: [ + { + _id: 'SlackBridge_Enabled', + value: true, + }, + { + _id: 'SlackBridge_Out_Enabled', + value: true, + }, + { + _id: 'SlackBridge_Out_All', + value: false, + }, + ], + }); + + await this.add('SlackBridge_AliasFormat', '', { + type: 'string', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + i18nLabel: 'Alias_Format', + i18nDescription: 'Alias_Format_Description', + }); + + await this.add('SlackBridge_ExcludeBotnames', '', { + type: 'string', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + i18nLabel: 'Exclude_Botnames', + i18nDescription: 'Exclude_Botnames_Description', + }); + + await this.add('SlackBridge_Reactions_Enabled', true, { + type: 'boolean', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + i18nLabel: 'Reactions', + }); + + await this.add('SlackBridge_Remove_Channel_Links', 'removeSlackBridgeChannelLinks', { + type: 'action', + actionText: 'Remove_Channel_Links', + i18nDescription: 'SlackBridge_Remove_Channel_Links_Description', + enableQuery: { + _id: 'SlackBridge_Enabled', + value: true, + }, + }); + }); diff --git a/apps/meteor/server/settings/smarsh.ts b/apps/meteor/server/settings/smarsh.ts new file mode 100644 index 0000000..b3cb98a --- /dev/null +++ b/apps/meteor/server/settings/smarsh.ts @@ -0,0 +1,76 @@ +import moment from 'moment-timezone'; + +import { settingsRegistry } from '.'; + +export const smarshIntervalValuesToCronMap: Record = { + every_30_seconds: '*/30 * * * * *', + every_30_minutes: '*/30 * * * *', + every_1_hours: '0 * * * *', + every_6_hours: '0 */6 * * *', +}; + +export const createSmarshSettings = () => + settingsRegistry.addGroup('Smarsh', async function addSettings() { + await this.add('Smarsh_Enabled', false, { + type: 'boolean', + i18nLabel: 'Smarsh_Enabled', + enableQuery: { + _id: 'From_Email', + value: { + $exists: 1, + $ne: '', + }, + }, + }); + await this.add('Smarsh_Email', '', { + type: 'string', + i18nLabel: 'Smarsh_Email', + placeholder: 'email@domain.com', + secret: true, + }); + await this.add('Smarsh_MissingEmail_Email', 'no-email@example.com', { + type: 'string', + i18nLabel: 'Smarsh_MissingEmail_Email', + placeholder: 'no-email@example.com', + }); + + const zoneValues = moment.tz.names().map(function _timeZonesToSettings(name) { + return { + key: name, + i18nLabel: name, + }; + }); + await this.add('Smarsh_Timezone', 'America/Los_Angeles', { + type: 'select', + values: zoneValues, + }); + + await this.add('Smarsh_Interval', 'every_30_minutes', { + type: 'select', + values: [ + { + key: 'every_30_seconds', + i18nLabel: 'every_30_seconds', + }, + { + key: 'every_30_minutes', + i18nLabel: 'every_30_minutes', + }, + { + key: 'every_1_hours', + i18nLabel: 'every_hour', + }, + { + key: 'every_6_hours', + i18nLabel: 'every_six_hours', + }, + ], + enableQuery: { + _id: 'From_Email', + value: { + $exists: 1, + $ne: '', + }, + }, + }); + }); diff --git a/apps/meteor/server/settings/startup.ts b/apps/meteor/server/settings/startup.ts new file mode 100644 index 0000000..450d46c --- /dev/null +++ b/apps/meteor/server/settings/startup.ts @@ -0,0 +1,12 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import type { Settings } from '@rocket.chat/models'; + +import type { ICachedSettings } from './CachedSettings'; + +export async function initializeSettings({ model, settings }: { model: typeof Settings; settings: ICachedSettings }): Promise { + await model.find().forEach((record: ISetting) => { + settings.set(record); + }); + + settings.initialized(); +} diff --git a/apps/meteor/server/settings/theme/index.ts b/apps/meteor/server/settings/theme/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/settings/theme/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/settings/theme/server.ts b/apps/meteor/server/settings/theme/server.ts new file mode 100644 index 0000000..d6aef6f --- /dev/null +++ b/apps/meteor/server/settings/theme/server.ts @@ -0,0 +1,43 @@ +import crypto from 'node:crypto'; + +import { Settings } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '..'; +import { addStyle } from '../../lib/ui-master/inject'; + +settings.watch('theme-custom-css', (value) => { + if (!value || typeof value !== 'string') { + addStyle('css-theme', ''); + return; + } + addStyle('css-theme', value); +}); + +// TODO: Add a migration to remove this setting from the database +Meteor.startup(async () => { + await Settings.deleteMany({ _id: /theme-color/ }); + await Settings.deleteOne({ _id: /theme-font/ }); + await Settings.deleteOne({ _id: 'css' }); +}); + +WebApp.rawConnectHandlers.use((req, res, next) => { + const path = req.url?.split('?')[0]; + const prefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''; + + if (path !== `${prefix}/theme.css`) { + next(); + return; + } + + const style = settings.get('theme-custom-css'); + if (typeof style !== 'string') { + throw new Error('Invalid theme-custom-css setting'); + } + + res.setHeader('Content-Type', 'text/css; charset=UTF-8'); + res.setHeader('Content-Length', style.length); + res.setHeader('ETag', `"${crypto.createHash('sha1').update(style).digest('hex')}"`); + res.end(style, 'utf-8'); +}); diff --git a/apps/meteor/server/settings/threads.ts b/apps/meteor/server/settings/threads.ts new file mode 100644 index 0000000..5855b64 --- /dev/null +++ b/apps/meteor/server/settings/threads.ts @@ -0,0 +1,11 @@ +import { settingsRegistry } from '.'; + +export const createThreadSettings = () => + settingsRegistry.addGroup('Threads', async function () { + await this.add('Threads_enabled', true, { + group: 'Threads', + i18nLabel: 'Enable', + type: 'boolean', + public: true, + }); + }); diff --git a/apps/meteor/server/settings/troubleshoot.ts b/apps/meteor/server/settings/troubleshoot.ts new file mode 100644 index 0000000..c860978 --- /dev/null +++ b/apps/meteor/server/settings/troubleshoot.ts @@ -0,0 +1,66 @@ +import { settingsRegistry } from '.'; + +export const createTroubleshootSettings = () => + settingsRegistry.addGroup('Troubleshoot', async function () { + await this.add('Troubleshoot_Disable_Notifications', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Notifications_Alert', + }); + + // this settings will let clients know in case presence has been disabled + await this.add('Presence_broadcast_disabled', false, { + type: 'boolean', + public: true, + readonly: true, + }); + + await this.add('Troubleshoot_Disable_Presence_Broadcast', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Presence_Broadcast_Alert', + enableQuery: { _id: 'Presence_broadcast_disabled', value: false }, + }); + + await this.add('Troubleshoot_Disable_Instance_Broadcast', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Instance_Broadcast_Alert', + }); + await this.add('Troubleshoot_Disable_Sessions_Monitor', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Sessions_Monitor_Alert', + }); + await this.add('Troubleshoot_Disable_Livechat_Activity_Monitor', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Livechat_Activity_Monitor_Alert', + }); + + await this.add('Troubleshoot_Disable_Data_Exporter_Processor', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Data_Exporter_Processor_Alert', + }); + await this.add('Troubleshoot_Disable_Teams_Mention', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Teams_Mention_Alert', + }); + + // TODO: remove this setting at next major (7.0.0) + await this.add('Troubleshoot_Disable_Statistics_Generator', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Statistics_Generator_Alert', + private: true, + hidden: true, + readonly: true, + }); + // TODO: remove this setting at next major (7.0.0) + await this.add('Troubleshoot_Disable_Workspace_Sync', false, { + type: 'boolean', + i18nDescription: 'Troubleshoot_Disable_Workspace_Sync_Alert', + private: true, + hidden: true, + readonly: true, + }); + + await this.add('Troubleshoot_Force_Caching_Version', '', { + type: 'string', + i18nDescription: 'Troubleshoot_Force_Caching_Version_Alert', + }); + }); diff --git a/apps/meteor/server/settings/userDataDownload.ts b/apps/meteor/server/settings/userDataDownload.ts new file mode 100644 index 0000000..2f7b7ef --- /dev/null +++ b/apps/meteor/server/settings/userDataDownload.ts @@ -0,0 +1,34 @@ +import { settingsRegistry } from '.'; + +export const createUserDataSettings = () => + settingsRegistry.addGroup('UserDataDownload', async function () { + await this.add('UserData_EnableDownload', true, { + type: 'boolean', + public: true, + i18nLabel: 'UserData_EnableDownload', + }); + + await this.add('UserData_FileSystemPath', '', { + type: 'string', + public: true, + i18nLabel: 'UserData_FileSystemPath', + }); + + await this.add('UserData_FileSystemZipPath', '', { + type: 'string', + public: true, + i18nLabel: 'UserData_FileSystemZipPath', + }); + + await this.add('UserData_ProcessingFrequency', 2, { + type: 'int', + public: true, + i18nLabel: 'UserData_ProcessingFrequency', + }); + + await this.add('UserData_MessageLimitPerRequest', 1000, { + type: 'int', + public: true, + i18nLabel: 'UserData_MessageLimitPerRequest', + }); + }); diff --git a/apps/meteor/server/settings/video-conference.ts b/apps/meteor/server/settings/video-conference.ts new file mode 100644 index 0000000..097472c --- /dev/null +++ b/apps/meteor/server/settings/video-conference.ts @@ -0,0 +1,29 @@ +import { settingsRegistry } from '.'; + +export const createVConfSettings = () => + settingsRegistry.addGroup('Video_Conference', async function () { + await this.add('VideoConf_Default_Provider', '', { + type: 'lookup', + lookupEndpoint: 'v1/video-conference.providers', + public: true, + }); + + await this.add('VideoConf_Mobile_Ringing', false, { + type: 'boolean', + public: true, + enterprise: true, + modules: ['videoconference-enterprise'], + invalidValue: false, + alert: 'VideoConf_Mobile_Ringing_Alert', + }); + + // #ToDo: Those should probably be handled by the apps themselves + await this.add('Jitsi_Click_To_Join_Count', 0, { + type: 'int', + hidden: true, + }); + await this.add('Jitsi_Start_SlashCommands_Count', 0, { + type: 'int', + hidden: true, + }); + }); diff --git a/apps/meteor/server/settings/webdav.ts b/apps/meteor/server/settings/webdav.ts new file mode 100644 index 0000000..ee59637 --- /dev/null +++ b/apps/meteor/server/settings/webdav.ts @@ -0,0 +1,9 @@ +import { settingsRegistry } from '.'; + +export const createWebDavSettings = () => + settingsRegistry.addGroup('Webdav Integration', async function () { + await this.add('Webdav_Integration_Enabled', false, { + type: 'boolean', + public: true, + }); + }); diff --git a/apps/meteor/server/slashcommands/archiveroom/index.ts b/apps/meteor/server/slashcommands/archiveroom/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/archiveroom/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/archiveroom/server.ts b/apps/meteor/server/slashcommands/archiveroom/server.ts new file mode 100644 index 0000000..cc3b227 --- /dev/null +++ b/apps/meteor/server/slashcommands/archiveroom/server.ts @@ -0,0 +1,86 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { isRegisterUser } from '@rocket.chat/core-typings'; +import { Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { i18n } from '../../lib/i18n'; +import { archiveRoom } from '../../lib/rooms/archiveRoom'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'archive', + callback: async function Archive({ params, message, userId }: SlashCommandCallbackParams<'archive'>): Promise { + let channel = params.trim(); + + let room; + + if (channel === '') { + room = await Rooms.findOneById(message.rid); + if (room?.name) { + channel = room.name; + } + } else { + channel = channel.replace('#', ''); + room = await Rooms.findOneByName(channel); + } + + if (!userId) { + return; + } + + const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!user || !isRegisterUser(user)) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + if (!room) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_doesnt_exist', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) { + throw new Meteor.Error('error-room-type-not-archivable', `Room type: ${room.t} can not be archived`); + } + + if (!(await hasPermissionAsync(userId, 'archive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized'); + } + + if (room.archived) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Duplicate_archived_channel_name', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await archiveRoom(room._id, user); + + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_Archived', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + }, + options: { + description: 'Archive', + params: '#channel', + permission: 'archive-room', + }, +}); diff --git a/apps/meteor/server/slashcommands/asciiarts/gimme.ts b/apps/meteor/server/slashcommands/asciiarts/gimme.ts new file mode 100644 index 0000000..557386d --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/gimme.ts @@ -0,0 +1,24 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +/* + * Gimme is a named function that will replace /gimme commands + * @param {Object} message - The message object + */ + +async function Gimme({ message, params, userId }: SlashCommandCallbackParams<'gimme'>): Promise { + const msg = message; + msg.msg = `༼ つ ◕_◕ ༽つ ${params}`; + await executeSendMessage(userId, msg); +} + +slashCommands.add({ + command: 'gimme', + callback: Gimme, + options: { + description: 'Slash_Gimme_Description', + params: 'your_message_optional', + clientOnly: true, + }, +}); diff --git a/apps/meteor/server/slashcommands/asciiarts/index.ts b/apps/meteor/server/slashcommands/asciiarts/index.ts new file mode 100644 index 0000000..421d44e --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/index.ts @@ -0,0 +1,5 @@ +import './gimme'; +import './lenny'; +import './shrug'; +import './tableflip'; +import './unflip'; diff --git a/apps/meteor/server/slashcommands/asciiarts/lenny.ts b/apps/meteor/server/slashcommands/asciiarts/lenny.ts new file mode 100644 index 0000000..a0b5169 --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/lenny.ts @@ -0,0 +1,24 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +/* + * Lenny is a named function that will replace /lenny commands + * @param {Object} message - The message object + */ + +async function LennyFace({ message, params, userId }: SlashCommandCallbackParams<'lenny'>): Promise { + const msg = message; + msg.msg = `${params} ( ͡° ͜ʖ ͡°)`; + await executeSendMessage(userId, msg); +} + +slashCommands.add({ + command: 'lennyface', + callback: LennyFace, + options: { + description: 'Slash_LennyFace_Description', + params: 'your_message_optional', + clientOnly: true, + }, +}); diff --git a/apps/meteor/server/slashcommands/asciiarts/shrug.ts b/apps/meteor/server/slashcommands/asciiarts/shrug.ts new file mode 100644 index 0000000..875738d --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/shrug.ts @@ -0,0 +1,22 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +/* + * Shrug is a named function that will replace /shrug commands + * @param {Object} message - The message object + */ + +slashCommands.add({ + command: 'shrug', + callback: async ({ message, params, userId }: SlashCommandCallbackParams<'shrug'>): Promise => { + const msg = message; + msg.msg = `${params} ¯\\\\_(ツ)_/¯`; + await executeSendMessage(userId, msg); + }, + options: { + description: 'Slash_Shrug_Description', + params: 'your_message_optional', + clientOnly: true, + }, +}); diff --git a/apps/meteor/server/slashcommands/asciiarts/tableflip.ts b/apps/meteor/server/slashcommands/asciiarts/tableflip.ts new file mode 100644 index 0000000..f63f642 --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/tableflip.ts @@ -0,0 +1,22 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +/* + * Tableflip is a named function that will replace /Tableflip commands + * @param {Object} message - The message object + */ + +slashCommands.add({ + command: 'tableflip', + callback: async ({ message, params, userId }: SlashCommandCallbackParams<'tableflip'>): Promise => { + const msg = message; + msg.msg = `${params} (╯°□°)╯︵ ┻━┻`; + await executeSendMessage(userId, msg); + }, + options: { + description: 'Slash_Tableflip_Description', + params: 'your_message_optional', + clientOnly: true, + }, +}); diff --git a/apps/meteor/server/slashcommands/asciiarts/unflip.ts b/apps/meteor/server/slashcommands/asciiarts/unflip.ts new file mode 100644 index 0000000..428bfd6 --- /dev/null +++ b/apps/meteor/server/slashcommands/asciiarts/unflip.ts @@ -0,0 +1,22 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +/* + * Unflip is a named function that will replace /unflip commands + * @param {Object} message - The message object + */ + +slashCommands.add({ + command: 'unflip', + callback: async ({ message, params, userId }: SlashCommandCallbackParams<'unflip'>): Promise => { + const msg = message; + msg.msg = `${params} ┬─┬ ノ( ゜-゜ノ)`; + await executeSendMessage(userId, msg); + }, + options: { + description: 'Slash_TableUnflip_Description', + params: 'your_message_optional', + clientOnly: true, + }, +}); diff --git a/apps/meteor/server/slashcommands/ban/ban.ts b/apps/meteor/server/slashcommands/ban/ban.ts new file mode 100644 index 0000000..21c20e7 --- /dev/null +++ b/apps/meteor/server/slashcommands/ban/ban.ts @@ -0,0 +1,39 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { banUserFromRoomMethod } from '../../lib/banUserFromRoom'; +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { sanitizeUsername } from '../../meteor-methods/rooms/addUsersToRoom'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'ban', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'ban'>): Promise => { + const username = sanitizeUsername(params.trim()); + + if (!username) { + return; + } + + const user = await Users.findOneByUsernameIgnoringCase(username); + if (!user) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await banUserFromRoomMethod(userId, { rid: message.rid, username }); + }, + options: { + description: 'Ban_user_from_room', + params: '@username', + permission: 'ban-user', + }, +}); diff --git a/apps/meteor/server/slashcommands/ban/index.ts b/apps/meteor/server/slashcommands/ban/index.ts new file mode 100644 index 0000000..f04cdbe --- /dev/null +++ b/apps/meteor/server/slashcommands/ban/index.ts @@ -0,0 +1,2 @@ +import './ban'; +import './unban'; diff --git a/apps/meteor/server/slashcommands/ban/unban.ts b/apps/meteor/server/slashcommands/ban/unban.ts new file mode 100644 index 0000000..b3fa65f --- /dev/null +++ b/apps/meteor/server/slashcommands/ban/unban.ts @@ -0,0 +1,39 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { unbanUserFromRoom } from '../../lib/unbanUserFromRoom'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { sanitizeUsername } from '../../meteor-methods/rooms/addUsersToRoom'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'unban', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'unban'>): Promise => { + const username = sanitizeUsername(params.trim()); + + if (!username) { + return; + } + + const user = await Users.findOneByUsernameIgnoringCase(username); + if (!user) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await unbanUserFromRoom(userId, { rid: message.rid, username }); + }, + options: { + description: 'Unban_user_from_room', + params: '@username', + permission: 'ban-user', + }, +}); diff --git a/apps/meteor/server/slashcommands/create/index.ts b/apps/meteor/server/slashcommands/create/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/create/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/create/server.ts b/apps/meteor/server/slashcommands/create/server.ts new file mode 100644 index 0000000..6fa2777 --- /dev/null +++ b/apps/meteor/server/slashcommands/create/server.ts @@ -0,0 +1,68 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { createChannelMethod } from '../../meteor-methods/rooms/createChannel'; +import { createPrivateGroupMethod } from '../../meteor-methods/rooms/createPrivateGroup'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'create', + callback: async function Create({ params, message, userId }: SlashCommandCallbackParams<'create'>): Promise { + function getParams(str: string): string[] { + const regex = /(--(\w+))+/g; + const result = []; + let m; + while ((m = regex.exec(str)) !== null) { + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + result.push(m[2]); + } + return result; + } + + const regexp = new RegExp(settings.get('UTF8_Channel_Names_Validation') as string); + + const channel = regexp.exec(params.trim()); + + if (!channel) { + return; + } + + const channelStr: string = channel ? channel[0] : ''; + if (channelStr === '') { + return; + } + + const room = await Rooms.findOneByName(channelStr); + if (room != null) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_already_exist', { + interpolation: { escapeValue: false }, + channelName: channelStr, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + if (getParams(params).indexOf('private') > -1) { + const user = await Users.findOneById(userId); + if (!user) { + return; + } + await createPrivateGroupMethod(user, channelStr, []); + return; + } + + await createChannelMethod(userId, channelStr, []); + }, + options: { + description: 'Create_A_New_Channel', + params: '#channel', + permission: ['create-c', 'create-p'], + }, +}); diff --git a/apps/meteor/server/slashcommands/help/index.ts b/apps/meteor/server/slashcommands/help/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/help/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/help/server.ts b/apps/meteor/server/slashcommands/help/server.ts new file mode 100644 index 0000000..fdaccbc --- /dev/null +++ b/apps/meteor/server/slashcommands/help/server.ts @@ -0,0 +1,74 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { settings } from '../../settings'; + +/* + * Help is a named function that will replace /help commands + * @param {Object} message - The message object + */ + +interface IHelpCommand { + key: string; + command: string; +} + +slashCommands.add({ + command: 'help', + callback: async function Help({ message, userId }: SlashCommandCallbackParams<'help'>): Promise { + const user = await Users.findOneById(userId); + + const keys: IHelpCommand[] = [ + { + key: 'Open_channel_user_search', + command: 'Command (or Ctrl) + p OR Command (or Ctrl) + k', + }, + { + key: 'Mark_all_as_read', + command: 'Shift (or Ctrl) + ESC', + }, + { + key: 'Edit_previous_message', + command: 'Up Arrow', + }, + { + key: 'Move_beginning_message', + command: 'Command (or Alt) + Left Arrow', + }, + { + key: 'Move_beginning_message', + command: 'Command (or Alt) + Up Arrow', + }, + { + key: 'Move_end_message', + command: 'Command (or Alt) + Right Arrow', + }, + { + key: 'Move_end_message', + command: 'Command (or Alt) + Down Arrow', + }, + { + key: 'New_line_message_compose_input', + command: 'Shift + Enter', + }, + ]; + let msg = ''; + keys.forEach((key) => { + msg = `${msg}\n${i18n.t(key.key, { + interpolation: { escapeValue: false }, + shortcut: key.command, + lng: user?.language || settings.get('language') || 'en', + })}`; + }); + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg, + ...(message.tmid && { tmid: message.tmid }), + }); + }, + options: { + description: 'Show_the_keyboard_shortcut_list', + }, +}); diff --git a/apps/meteor/server/slashcommands/hide/hide.ts b/apps/meteor/server/slashcommands/hide/hide.ts new file mode 100644 index 0000000..431aff8 --- /dev/null +++ b/apps/meteor/server/slashcommands/hide/hide.ts @@ -0,0 +1,75 @@ +import { api } from '@rocket.chat/core-services'; +import type { IRoom, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { hideRoomMethod } from '../../meteor-methods/rooms/hideRoom'; +import { settings } from '../../settings'; + +/* + * Hide is a named function that will replace /hide commands + * @param {Object} message - The message object + */ + +slashCommands.add({ + command: 'hide', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'hide'>): Promise => { + const room = params.trim(); + if (!userId) { + return; + } + + const user = await Users.findOneById(userId); + + if (!user) { + return; + } + + const lng = user.language || settings.get('Language') || 'en'; + + // if there is not a param, hide the current room + let { rid } = message; + if (room !== '') { + const [strippedRoom] = room.replace(/#|@/, '').split(' '); + + const [type] = room; + + const roomObject: IRoom | null = + type === '#' + ? await Rooms.findOneByName(strippedRoom) + : await Rooms.findOne({ + t: 'd', + usernames: { $all: [user.username, strippedRoom] }, + }); + if (!roomObject) { + void api.broadcast('notify.ephemeralMessage', user._id, message.rid, { + msg: i18n.t('Channel_doesnt_exist', { + interpolation: { escapeValue: false }, + channelName: strippedRoom, + lng, + }), + }); + } + if (!(await Subscriptions.findOneByRoomIdAndUserId(roomObject ? roomObject._id : '', user._id, { projection: { _id: 1 } }))) { + void api.broadcast('notify.ephemeralMessage', user._id, message.rid, { + msg: i18n.t('error-logged-user-not-in-room', { + interpolation: { escapeValue: false }, + roomName: room, + lng, + }), + }); + return; + } + rid = roomObject?._id || message.rid; + } + try { + await hideRoomMethod(userId, rid); + } catch (error: any) { + await api.broadcast('notify.ephemeralMessage', user._id, message.rid, { + msg: i18n.t(error, { lng }), + }); + } + }, + options: { description: 'Hide_room', params: '#room' }, +}); diff --git a/apps/meteor/server/slashcommands/hide/index.ts b/apps/meteor/server/slashcommands/hide/index.ts new file mode 100644 index 0000000..7456d5f --- /dev/null +++ b/apps/meteor/server/slashcommands/hide/index.ts @@ -0,0 +1 @@ +import './hide'; diff --git a/apps/meteor/server/slashcommands/invite/index.ts b/apps/meteor/server/slashcommands/invite/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/invite/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/invite/server.ts b/apps/meteor/server/slashcommands/invite/server.ts new file mode 100644 index 0000000..27fb8f8 --- /dev/null +++ b/apps/meteor/server/slashcommands/invite/server.ts @@ -0,0 +1,153 @@ +import { api, FederationMatrix, isMeteorError } from '@rocket.chat/core-services'; +import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { isBannedSubscription } from '@rocket.chat/core-typings'; +import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { addUsersToRoomMethod, sanitizeUsername } from '../../meteor-methods/rooms/addUsersToRoom'; +import { FederationActions } from '../../services/room/hooks/BeforeFederationActions'; +import { settings } from '../../settings'; + +// Type guards for the error +function isStringError(error: unknown): error is { error: string } { + return typeof (error as any)?.error === 'string'; +} + +/* + * Invite is a named function that will replace /invite commands + * @param {Object} message - The message object + */ +slashCommands.add({ + command: 'invite', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'invite'>): Promise => { + let usernames = params + .split(/[\s,]/) + .map((username) => sanitizeUsername(username)) + .filter((a) => a !== ''); + if (usernames.length === 0) { + return; + } + + // Get room information for federation check + const room = await Rooms.findOneById(message.rid); + if (!room) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('error-invalid-room', { lng: settings.get('Language') || 'en' }), + }); + return; + } + + // Ensure federated users exist locally before looking them up + const federatedUsernames = usernames.filter((u) => validateFederatedUsername(u)) as string[]; + if (federatedUsernames.length > 0) { + if (FederationActions.shouldPerformFederationAction(room)) { + await FederationMatrix.ensureFederatedUsersExistLocally(federatedUsernames); + } else { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }), + }); + // These federated users shouldn't be invited and we already broadcasted the error message + usernames = usernames.filter((username) => { + return !federatedUsernames.includes(username); + }); + if (usernames.length === 0) { + return; + } + } + } + + const users = await Users.findByUsernames(usernames).toArray(); + if (users.length === 0) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('User_doesnt_exist', { + interpolation: { escapeValue: false }, + username: usernames.join(' @'), + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + const usersFiltered: IUser[] = []; + + for await (const user of users) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(message.rid, user._id, { + projection: { _id: 1, status: 1 }, + }); + if (subscription == null || isBannedSubscription(subscription)) { + usersFiltered.push(user); + continue; + } + const usernameStr = user.username as string; + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_is_already_in_here', { + interpolation: { escapeValue: false }, + username: usernameStr, + lng: settings.get('Language') || 'en', + }), + }); + } + + const inviter = await Users.findOneById(userId); + + if (!inviter) { + throw new Meteor.Error('error-user-not-found', 'Inviter not found', { + method: 'slashcommand-invite', + }); + } + + await Promise.all( + usersFiltered.map(async (user) => { + try { + // TODO: Refactor this to return an error if some user fails to be added + return await addUsersToRoomMethod( + userId, + { + rid: message.rid, + users: [user.username || ''], + }, + inviter, + ); + } catch (e: unknown) { + if (isMeteorError(e)) { + if (e.error === 'error-only-compliant-users-can-be-added-to-abac-rooms') { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t(e.error, { lng: settings.get('Language') || 'en' }), + }); + } else { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t(e.message, { lng: settings.get('Language') || 'en' }), + }); + } + return; + } + + if (isStringError(e)) { + const { error } = e; + if (error === 'error-federated-users-in-non-federated-rooms') { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }), + }); + } else if (error === 'cant-invite-for-direct-room') { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Cannot_invite_users_to_direct_rooms', { lng: settings.get('Language') || 'en' }), + }); + } else { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t(error, { lng: settings.get('Language') || 'en' }), + }); + } + } + } + }), + ); + }, + options: { + description: 'Invite_user_to_join_channel', + params: '@username', + permission: 'add-user-to-joined-room', + }, +}); diff --git a/apps/meteor/server/slashcommands/inviteall/index.ts b/apps/meteor/server/slashcommands/inviteall/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/inviteall/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/inviteall/server.ts b/apps/meteor/server/slashcommands/inviteall/server.ts new file mode 100644 index 0000000..bbc988f --- /dev/null +++ b/apps/meteor/server/slashcommands/inviteall/server.ts @@ -0,0 +1,128 @@ +/* + * Invite is a named function that will replace /invite commands + * @param {Object} message - The message object + */ + +import { api } from '@rocket.chat/core-services'; +import type { ISubscription, SlashCommand, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomAsync } from '../../lib/authorization'; +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; +import { createChannelMethod } from '../../meteor-methods/rooms/createChannel'; +import { createPrivateGroupMethod } from '../../meteor-methods/rooms/createPrivateGroup'; +import { settings } from '../../settings'; + +function inviteAll(type: T): SlashCommand['callback'] { + return async function inviteAll({ command, params, message, userId }: SlashCommandCallbackParams): Promise { + if (!/invite\-all-(to|from)/.test(command)) { + return; + } + + let channel = params.trim(); + if (channel === '') { + return; + } + + channel = channel.replace('#', ''); + + if (!channel) { + return; + } + if (!userId) { + return; + } + + const user = await Users.findOneById(userId); + if (!user) { + return; + } + const lng = user?.language || settings.get('Language') || 'en'; + + const baseChannel = type === 'to' ? await Rooms.findOneById(message.rid) : await Rooms.findOneByName(channel); + const targetChannel = type === 'from' ? await Rooms.findOneById(message.rid) : await Rooms.findOneByName(channel); + + if (!baseChannel) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_doesnt_exist', { + interpolation: { escapeValue: false }, + channelName: channel, + lng, + }), + }); + return; + } + + if (!(await canAccessRoomAsync(baseChannel, user))) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Room_not_exist_or_not_permission', { lng }), + }); + return; + } + + try { + const APIsettings = settings.get('API_User_Limit'); + if (!APIsettings) { + return; + } + if ((await Subscriptions.countByRoomIdWhenUsernameExists(baseChannel._id)) > APIsettings) { + throw new Meteor.Error('error-user-limit-exceeded', 'User Limit Exceeded', { + method: 'addAllToRoom', + }); + } + + const cursor = Subscriptions.findByRoomIdWhenUsernameExists(baseChannel._id, { + projection: { 'u.username': 1 }, + }); + const users = (await cursor.toArray()).map((s: ISubscription) => s.u.username).filter(isTruthy); + + if (!targetChannel && ['c', 'p'].indexOf(baseChannel.t) > -1) { + baseChannel.t === 'c' ? await createChannelMethod(userId, channel, users) : await createPrivateGroupMethod(user, channel, users); + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_created', { + interpolation: { escapeValue: false }, + channelName: channel, + lng, + }), + }); + } else { + await addUsersToRoomMethod(userId, { + rid: targetChannel?._id ?? '', + users, + }); + } + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Users_added', { lng }), + }); + } catch (e: any) { + const msg = e.error === 'cant-invite-for-direct-room' ? 'Cannot_invite_users_to_direct_rooms' : e.error; + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t(msg, { lng }), + }); + } + }; +} + +slashCommands.add({ + command: 'invite-all-to', + callback: inviteAll('to'), + options: { + description: 'Invite_user_to_join_channel_all_to', + params: '#room', + permission: ['add-user-to-joined-room', 'add-user-to-any-c-room', 'add-user-to-any-p-room'], + }, +}); +slashCommands.add({ + command: 'invite-all-from', + callback: inviteAll('from'), + options: { + description: 'Invite_user_to_join_channel_all_from', + params: '#room', + permission: 'add-user-to-joined-room', + }, +}); +module.exports = inviteAll; diff --git a/apps/meteor/server/slashcommands/join/index.ts b/apps/meteor/server/slashcommands/join/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/join/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/join/server.ts b/apps/meteor/server/slashcommands/join/server.ts new file mode 100644 index 0000000..cbc1834 --- /dev/null +++ b/apps/meteor/server/slashcommands/join/server.ts @@ -0,0 +1,59 @@ +import { api, Room } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'join', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'join'>): Promise => { + let channel = params.trim(); + if (channel === '') { + return; + } + + if (!userId) { + return; + } + + channel = channel.replace('#', ''); + + const room = await Rooms.findOneByNameAndType(channel, 'c'); + if (!room) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_doesnt_exist', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, userId, { + projection: { _id: 1 }, + }); + + if (subscription) { + throw new Meteor.Error('error-user-already-in-room', 'You are already in the channel', { + method: 'slashCommands', + }); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'slashCommands', + }); + } + await Room.join({ room, user }); + }, + options: { + description: 'Join_the_given_channel', + params: '#channel', + permission: 'view-c-room', + }, +}); diff --git a/apps/meteor/server/slashcommands/kick/index.ts b/apps/meteor/server/slashcommands/kick/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/kick/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/kick/server.ts b/apps/meteor/server/slashcommands/kick/server.ts new file mode 100644 index 0000000..a362f1a --- /dev/null +++ b/apps/meteor/server/slashcommands/kick/server.ts @@ -0,0 +1,44 @@ +// Kick is a named function that will replace /kick commands +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { sanitizeUsername } from '../../meteor-methods/rooms/addUsersToRoom'; +import { removeUserFromRoomMethod } from '../../meteor-methods/rooms/removeUserFromRoom'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'kick', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'kick'>): Promise => { + const username = sanitizeUsername(params.trim()); + if (username === '') { + return; + } + const user = await Users.findOneById(userId); + const lng = user?.language || settings.get('Language') || 'en'; + + const kickedUser = await Users.findOneByUsernameIgnoringCase(username); + + if (kickedUser == null) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username, + lng, + }), + }); + return; + } + + const { rid } = message; + + await removeUserFromRoomMethod(userId, { rid, username }); + }, + options: { + description: 'Remove_someone_from_room', + params: '@username', + permission: 'remove-user', + }, +}); diff --git a/apps/meteor/server/slashcommands/leave/index.ts b/apps/meteor/server/slashcommands/leave/index.ts new file mode 100644 index 0000000..3264a6d --- /dev/null +++ b/apps/meteor/server/slashcommands/leave/index.ts @@ -0,0 +1 @@ +import './leave'; diff --git a/apps/meteor/server/slashcommands/leave/leave.ts b/apps/meteor/server/slashcommands/leave/leave.ts new file mode 100644 index 0000000..3c3280f --- /dev/null +++ b/apps/meteor/server/slashcommands/leave/leave.ts @@ -0,0 +1,49 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { leaveRoomMethod } from '../../meteor-methods/rooms/leaveRoom'; +import { settings } from '../../settings'; + +/* + * Leave is a named function that will replace /leave commands + * @param {Object} message - The message object + */ +const Leave = async function Leave({ message, userId }: SlashCommandCallbackParams<'leave'>): Promise { + try { + const user = await Users.findOneById(userId); + if (!user) { + return; + } + await leaveRoomMethod(user, message.rid); + } catch (error: any) { + if (typeof error.error !== 'string') { + throw error; + } + const user = await Users.findOneById(userId); + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t(error, { lng: user?.language || settings.get('Language') || 'en' }), + }); + throw new Meteor.Error(error.error, error.message); + } +}; + +slashCommands.add({ + command: 'leave', + callback: Leave, + options: { + description: 'Leave_the_current_channel', + permission: ['leave-c', 'leave-p'], + }, +}); +slashCommands.add({ + command: 'part', + callback: Leave, + options: { + description: 'Leave_the_current_channel', + permission: ['leave-c', 'leave-p'], + }, +}); diff --git a/apps/meteor/server/slashcommands/me/index.ts b/apps/meteor/server/slashcommands/me/index.ts new file mode 100644 index 0000000..d300e88 --- /dev/null +++ b/apps/meteor/server/slashcommands/me/index.ts @@ -0,0 +1 @@ +import './me'; diff --git a/apps/meteor/server/slashcommands/me/me.ts b/apps/meteor/server/slashcommands/me/me.ts new file mode 100644 index 0000000..3fbe36e --- /dev/null +++ b/apps/meteor/server/slashcommands/me/me.ts @@ -0,0 +1,23 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { slashCommands } from '../../lib/utils/slashCommand'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; + +/* + * Me is a named function that will replace /me commands + * @param {Object} message - The message object + */ +slashCommands.add({ + command: 'me', + callback: async function Me({ params, message, userId }: SlashCommandCallbackParams<'me'>): Promise { + if (params.trim()) { + const msg = message; + msg.msg = `_${params}_`; + await executeSendMessage(userId, msg); + } + }, + options: { + description: 'Displays_action_text', + params: 'your_message', + }, +}); diff --git a/apps/meteor/server/slashcommands/msg/index.ts b/apps/meteor/server/slashcommands/msg/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/msg/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/msg/server.ts b/apps/meteor/server/slashcommands/msg/server.ts new file mode 100644 index 0000000..9222451 --- /dev/null +++ b/apps/meteor/server/slashcommands/msg/server.ts @@ -0,0 +1,55 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage'; +import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; +import { settings } from '../../settings'; + +/* + * Msg is a named function that will replace /msg commands + */ + +slashCommands.add({ + command: 'msg', + callback: async function Msg({ params, message: item, userId }: SlashCommandCallbackParams<'msg'>): Promise { + const trimmedParams = params.trim(); + const separator = trimmedParams.indexOf(' '); + if (separator === -1) { + void api.broadcast('notify.ephemeralMessage', userId, item.rid, { + msg: i18n.t('Username_and_message_must_not_be_empty', { lng: settings.get('Language') || 'en' }), + }); + return; + } + const message = trimmedParams.slice(separator + 1); + const targetUsernameOrig = trimmedParams.slice(0, separator); + const targetUsername = targetUsernameOrig.replace('@', ''); + const targetUser = await Users.findOneByUsernameIgnoringCase(targetUsername); + if (targetUser == null) { + const user = await Users.findOneById(userId, { projection: { language: 1 } }); + void api.broadcast('notify.ephemeralMessage', userId, item.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username: targetUsernameOrig, + lng: user?.language || settings.get('Language') || 'en', + }), + }); + return; + } + const { rid } = await createDirectMessage([targetUsername], userId); + const msgObject = { + _id: Random.id(), + rid, + msg: message, + }; + await executeSendMessage(userId, msgObject); + }, + options: { + description: 'Direct_message_someone', + params: '@username ', + permission: 'create-d', + }, +}); diff --git a/apps/meteor/server/slashcommands/mute/index.ts b/apps/meteor/server/slashcommands/mute/index.ts new file mode 100644 index 0000000..aa1e813 --- /dev/null +++ b/apps/meteor/server/slashcommands/mute/index.ts @@ -0,0 +1,2 @@ +import './mute'; +import './unmute'; diff --git a/apps/meteor/server/slashcommands/mute/mute.ts b/apps/meteor/server/slashcommands/mute/mute.ts new file mode 100644 index 0000000..aa222d4 --- /dev/null +++ b/apps/meteor/server/slashcommands/mute/mute.ts @@ -0,0 +1,43 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { muteUserInRoom } from '../../meteor-methods/rooms/muteUserInRoom'; +import { settings } from '../../settings'; + +/* + * Mute is a named function that will replace /mute commands + */ + +slashCommands.add({ + command: 'mute', + callback: async function Mute({ params, message, userId }: SlashCommandCallbackParams<'mute'>): Promise { + const username = params.trim().replace('@', ''); + if (username === '') { + return; + } + + const mutedUser = await Users.findOneByUsernameIgnoringCase(username); + if (mutedUser == null) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username, + lng: settings.get('Language') || 'en', + }), + }); + } + + await muteUserInRoom(userId, { + rid: message.rid, + username, + }); + }, + options: { + description: 'Mute_someone_in_room', + params: '@username', + permission: 'mute-user', + }, +}); diff --git a/apps/meteor/server/slashcommands/mute/unmute.ts b/apps/meteor/server/slashcommands/mute/unmute.ts new file mode 100644 index 0000000..cc3b1f8 --- /dev/null +++ b/apps/meteor/server/slashcommands/mute/unmute.ts @@ -0,0 +1,43 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { unmuteUserInRoom } from '../../meteor-methods/rooms/unmuteUserInRoom'; +import { settings } from '../../settings'; + +/* + * Unmute is a named function that will replace /unmute commands + */ + +slashCommands.add({ + command: 'unmute', + callback: async function Unmute({ params, message, userId }: SlashCommandCallbackParams<'unmute'>): Promise { + const username = params.trim().replace('@', ''); + if (username === '') { + return; + } + const unmutedUser = await Users.findOneByUsernameIgnoringCase(username); + if (unmutedUser == null) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Username_doesnt_exist', { + interpolation: { escapeValue: false }, + username, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await unmuteUserInRoom(userId, { + rid: message.rid, + username, + }); + }, + options: { + description: 'Unmute_someone_in_room', + params: '@username', + permission: 'mute-user', + }, +}); diff --git a/apps/meteor/server/slashcommands/status/index.ts b/apps/meteor/server/slashcommands/status/index.ts new file mode 100644 index 0000000..57ce4c9 --- /dev/null +++ b/apps/meteor/server/slashcommands/status/index.ts @@ -0,0 +1 @@ +import './status'; diff --git a/apps/meteor/server/slashcommands/status/status.ts b/apps/meteor/server/slashcommands/status/status.ts new file mode 100644 index 0000000..3775cfc --- /dev/null +++ b/apps/meteor/server/slashcommands/status/status.ts @@ -0,0 +1,47 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { i18n } from '../../lib/i18n'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { setUserStatusMethod } from '../../meteor-methods/users/setUserStatus'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'status', + callback: async function Status({ params, message, userId }: SlashCommandCallbackParams<'status'>): Promise { + if (!userId) { + return; + } + + const user = await Users.findOneById< + Pick + >(userId, { + projection: { language: 1, username: 1, name: 1, status: 1, statusDefault: 1, roles: 1, statusText: 1 }, + }); + const lng = user?.language || settings.get('Language') || 'en'; + + if (!user) { + return; + } + try { + await setUserStatusMethod(user, undefined, params); + + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('StatusMessage_Changed_Successfully', { lng }), + }); + } catch (err: any) { + if (err.error === 'error-not-allowed') { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('StatusMessage_Change_Disabled', { lng }), + }); + } + + throw err; + } + }, + options: { + description: 'Slash_Status_Description', + params: 'Slash_Status_Params', + }, +}); diff --git a/apps/meteor/server/slashcommands/topic/index.ts b/apps/meteor/server/slashcommands/topic/index.ts new file mode 100644 index 0000000..2ad19c1 --- /dev/null +++ b/apps/meteor/server/slashcommands/topic/index.ts @@ -0,0 +1 @@ +import './topic'; diff --git a/apps/meteor/server/slashcommands/topic/topic.ts b/apps/meteor/server/slashcommands/topic/topic.ts new file mode 100644 index 0000000..56da1d6 --- /dev/null +++ b/apps/meteor/server/slashcommands/topic/topic.ts @@ -0,0 +1,19 @@ +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; + +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { saveRoomSettings } from '../../meteor-methods/rooms/saveRoomSettings'; + +slashCommands.add({ + command: 'topic', + callback: async ({ params, message, userId }: SlashCommandCallbackParams<'topic'>): Promise => { + if (userId && (await hasPermissionAsync(userId, 'edit-room', message.rid))) { + await saveRoomSettings(userId, message.rid, 'roomTopic', params); + } + }, + options: { + description: 'Slash_Topic_Description', + params: 'Slash_Topic_Params', + permission: 'edit-room', + }, +}); diff --git a/apps/meteor/server/slashcommands/unarchiveroom/index.ts b/apps/meteor/server/slashcommands/unarchiveroom/index.ts new file mode 100644 index 0000000..1199af1 --- /dev/null +++ b/apps/meteor/server/slashcommands/unarchiveroom/index.ts @@ -0,0 +1 @@ +import './server'; diff --git a/apps/meteor/server/slashcommands/unarchiveroom/server.ts b/apps/meteor/server/slashcommands/unarchiveroom/server.ts new file mode 100644 index 0000000..f701487 --- /dev/null +++ b/apps/meteor/server/slashcommands/unarchiveroom/server.ts @@ -0,0 +1,85 @@ +import { api } from '@rocket.chat/core-services'; +import { isRegisterUser } from '@rocket.chat/core-typings'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { i18n } from '../../lib/i18n'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { unarchiveRoom } from '../../lib/rooms/unarchiveRoom'; +import { slashCommands } from '../../lib/utils/slashCommand'; +import { settings } from '../../settings'; + +slashCommands.add({ + command: 'unarchive', + callback: async function Unarchive({ params, message, userId }: SlashCommandCallbackParams<'unarchive'>): Promise { + let channel = params.trim(); + let room; + + if (channel === '') { + room = await Rooms.findOneById(message.rid); + if (room?.name) { + channel = room.name; + } + } else { + channel = channel.replace('#', ''); + room = await Rooms.findOneByName(channel); + } + + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + const user = await Users.findOneById(userId, { projection: { username: 1, name: 1 } }); + if (!user || !isRegisterUser(user)) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); + } + + if (!room) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_doesnt_exist', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.ARCHIVE, userId))) { + throw new Meteor.Error('error-room-type-not-unarchivable', `Room type: ${room.t} can not be unarchived`); + } + + if (!(await hasPermissionAsync(userId, 'unarchive-room', room._id))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized'); + } + + if (!room.archived) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_already_Unarchived', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + return; + } + + await unarchiveRoom(room._id, user); + + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('Channel_Unarchived', { + interpolation: { escapeValue: false }, + channelName: channel, + lng: settings.get('Language') || 'en', + }), + }); + }, + options: { + description: 'Unarchive', + params: '#channel', + permission: 'unarchive-room', + }, +}); diff --git a/apps/meteor/server/startup/appcache.ts b/apps/meteor/server/startup/appcache.ts new file mode 100644 index 0000000..bead534 --- /dev/null +++ b/apps/meteor/server/startup/appcache.ts @@ -0,0 +1,7 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.AppCache) { + Meteor.AppCache.config({ + onlineOnly: ['/elements/', '/landing/', '/moment-locales/', '/scripts/'], + }); +} diff --git a/apps/meteor/server/startup/callHistoryTestData.ts b/apps/meteor/server/startup/callHistoryTestData.ts new file mode 100644 index 0000000..3c6e8a0 --- /dev/null +++ b/apps/meteor/server/startup/callHistoryTestData.ts @@ -0,0 +1,240 @@ +import { CallHistory, MediaCalls } from '@rocket.chat/models'; + +export async function addCallHistoryTestData(uid: string, extraUid: string): Promise { + const callId1 = 'rocketchat.internal.call.test'; + const callId2 = 'rocketchat.internal.call.test.2'; + const callId3 = 'rocketchat.external.call.test.outbound'; + const callId4 = 'rocketchat.external.call.test.inbound'; + + const extraCallId1 = 'rocketchat.extra.call.test.1'; + const extraCallId2 = 'rocketchat.extra.call.test.2'; + + await CallHistory.deleteMany({ uid }); + await MediaCalls.deleteMany({ _id: { $in: [callId1, callId2, callId3, callId4] } }); + + await CallHistory.insertMany([ + { + _id: 'rocketchat.internal.history.test.outbound', + ts: new Date(), + callId: callId1, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'outbound', + contactName: 'Pineapple', // random words used for searching + contactUsername: 'fruit-001', + }, + { + _id: 'rocketchat.internal.history.test.inbound', + ts: new Date(), + callId: callId2, + state: 'not-answered', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'inbound', + contactName: 'Apple', + contactUsername: 'fruit-002', + }, + { + _id: 'rocketchat.internal.history.test.outbound.2', + ts: new Date(), + callId: extraCallId1, + state: 'transferred', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'outbound', + contactName: 'Grapefruit 002', + contactUsername: 'username-001', + }, + { + _id: 'rocketchat.internal.history.test.inbound.2', + ts: new Date(), + callId: extraCallId2, + state: 'transferred', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'inbound', + contactName: 'Pasta 1', + contactUsername: 'meal', + }, + { + _id: 'rocketchat.external.history.test.outbound', + ts: new Date(), + callId: callId3, + state: 'failed', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: true, + uid, + direction: 'outbound', + contactExtension: '1001', + }, + { + _id: 'rocketchat.external.history.test.inbound', + ts: new Date(), + callId: callId4, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: true, + uid, + direction: 'inbound', + contactExtension: '1002', + }, + ]); + + await MediaCalls.insertMany([ + { + _id: callId1, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: uid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: uid, + contractId: 'contract1', + }, + callee: { + type: 'user', + id: extraUid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid, extraUid], + features: ['audio'], + }, + { + _id: callId2, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: extraUid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: extraUid, + contractId: 'contract1', + }, + callee: { + type: 'user', + id: uid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid, extraUid], + features: ['audio'], + }, + { + _id: callId3, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: uid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: uid, + contractId: 'contract1', + }, + callee: { + type: 'sip', + id: '1001', + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid], + features: ['audio'], + sipCallId: 'sipCallId3', + }, + { + _id: callId4, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'sip', + id: '1001', + }, + createdAt: new Date(), + caller: { + type: 'sip', + id: '1001', + contractId: 'contract1', + }, + callee: { + type: 'user', + id: uid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid], + features: ['audio'], + sipCallId: 'sipCallId4', + }, + ]); +} diff --git a/apps/meteor/server/startup/callbacks.ts b/apps/meteor/server/startup/callbacks.ts new file mode 100644 index 0000000..e2e2676 --- /dev/null +++ b/apps/meteor/server/startup/callbacks.ts @@ -0,0 +1,38 @@ +import { Logger } from '@rocket.chat/logger'; +import { performance } from 'universal-perf-hooks'; + +import { callbacks } from '../lib/callbacks'; +import { metrics, StatsTracker } from '../lib/metrics'; + +callbacks.setLogger(new Logger('Callbacks')); + +callbacks.setMetricsTrackers({ + trackCallback: ({ hook, id }) => { + const start = performance.now(); + + const stopTimer = metrics.rocketchatCallbacks.startTimer({ hook, callback: id }); + const stopHistogram = metrics.rocketchatCallbacksSeconds.startTimer({ hook, callback: id }); + + return (): void => { + const end = performance.now(); + StatsTracker.timing('callbacks.time', end - start, [`hook:${hook}`, `callback:${id}`]); + + stopTimer(); + stopHistogram(); + }; + }, + trackHook: ({ hook, length }) => { + const stopTimer = metrics.rocketchatHooks.startTimer({ + hook, + callbacks_length: length, + }); + const stopHistogram = metrics.rocketchatHooksSeconds.startTimer({ + hook, + callbacks_length: length, + }); + return (): void => { + stopTimer(); + stopHistogram(); + }; + }, +}); diff --git a/apps/meteor/server/startup/cloudRegistration.ts b/apps/meteor/server/startup/cloudRegistration.ts new file mode 100644 index 0000000..9acc72e --- /dev/null +++ b/apps/meteor/server/startup/cloudRegistration.ts @@ -0,0 +1,26 @@ +import { Settings } from '@rocket.chat/models'; + +export async function ensureCloudWorkspaceRegistered(): Promise { + const cloudWorkspaceClientId = await Settings.getValueById('Cloud_Workspace_Client_Id'); + const cloudWorkspaceClientSecret = await Settings.getValueById('Cloud_Workspace_Client_Secret'); + const showSetupWizard = await Settings.findOneById('Show_Setup_Wizard', { + projection: { value: 1, valueSource: 1, processEnvValue: 1 }, + }); + + // skip if both fields are already set, which means the workspace is already registered + if (!!cloudWorkspaceClientId && !!cloudWorkspaceClientSecret) { + return; + } + + // skip if the setup wizard still not completed + if (showSetupWizard?.value !== 'completed') { + return; + } + + if (showSetupWizard.valueSource === 'processEnvValue' && showSetupWizard.value === showSetupWizard.processEnvValue) { + return; + } + + // otherwise, set the setup wizard to in_progress forcing admins to complete the registration + await Settings.updateValueById('Show_Setup_Wizard', 'in_progress'); +} diff --git a/apps/meteor/server/startup/coreApps.ts b/apps/meteor/server/startup/coreApps.ts new file mode 100644 index 0000000..38f95da --- /dev/null +++ b/apps/meteor/server/startup/coreApps.ts @@ -0,0 +1,14 @@ +import { BannerModule } from '../modules/core-apps/banner.module'; +import { CloudAnnouncementsModule } from '../modules/core-apps/cloudAnnouncements.module'; +import { CloudSubscriptionCommunication } from '../modules/core-apps/cloudSubscriptionCommunication.module'; +import { MentionModule } from '../modules/core-apps/mention.module'; +import { Nps } from '../modules/core-apps/nps.module'; +import { VideoConfModule } from '../modules/core-apps/videoconf.module'; +import { registerCoreApp } from '../services/uikit-core-app/service'; + +registerCoreApp(new CloudAnnouncementsModule()); +registerCoreApp(new CloudSubscriptionCommunication()); +registerCoreApp(new Nps()); +registerCoreApp(new BannerModule()); +registerCoreApp(new VideoConfModule()); +registerCoreApp(new MentionModule()); diff --git a/apps/meteor/server/startup/cron.ts b/apps/meteor/server/startup/cron.ts new file mode 100644 index 0000000..bed6b62 --- /dev/null +++ b/apps/meteor/server/startup/cron.ts @@ -0,0 +1,16 @@ +import { Logger } from '@rocket.chat/logger'; + +import { npsCron } from '../cron/nps'; +import { oembedCron } from '../cron/oembed'; +import { startCron } from '../cron/start'; +import { temporaryUploadCleanupCron } from '../cron/temporaryUploadsCleanup'; +import { usageReportCron } from '../cron/usageReport'; +import { userDataDownloadsCron } from '../cron/userDataDownloads'; +import { videoConferencesCron } from '../cron/videoConferences'; + +const logger = new Logger('SyncedCron'); + +export const startCronJobs = async (): Promise => { + await Promise.all([startCron(), oembedCron(), usageReportCron(logger), npsCron(), temporaryUploadCleanupCron(), videoConferencesCron()]); + userDataDownloadsCron(); +}; diff --git a/apps/meteor/server/startup/ensureMessagesTextIndex.ts b/apps/meteor/server/startup/ensureMessagesTextIndex.ts new file mode 100644 index 0000000..2ac96f9 --- /dev/null +++ b/apps/meteor/server/startup/ensureMessagesTextIndex.ts @@ -0,0 +1,87 @@ +import { Messages } from '@rocket.chat/models'; + +import { SystemLogger } from '../lib/logger/system'; + +const { USE_ROOM_SEARCH_INDEX = 'false' } = process.env; + +// MongoDB stores a text index's key with `_fts: 'text'` / `_ftsx: 1` placeholders +// and tracks the original text fields in `weights`. Classify by looking at the +// non-placeholder prefix fields plus weights. +const classifyTextIndex = (idx: { key: Record; weights?: Record }) => { + const { weights, key } = idx; + if (weights?.msg !== 1 || Object.keys(weights).length !== 1) { + return 'other'; + } + + const prefix = Object.keys(key).filter((k) => k !== '_fts' && k !== '_ftsx'); + if (prefix.length === 0) { + return 'default'; + } + + if (prefix.length === 1 && prefix[0] === 'rid' && key.rid === 1) { + return 'room-scoped'; + } + + return 'other'; +}; + +export const ensureMessagesTextIndex = async (): Promise => { + const desiredShape = USE_ROOM_SEARCH_INDEX === 'true' ? 'room-scoped' : 'default'; + + SystemLogger.debug({ + msg: 'checking messages text index', + USE_ROOM_SEARCH_INDEX, + desired: desiredShape, + }); + + let existing; + try { + existing = await Messages.col.indexes(); + } catch (err) { + SystemLogger.error({ msg: 'failed to list messages indexes; skipping text index check', err }); + return; + } + + const textIndexes = existing.filter((idx) => Object.values(idx.key).includes('text')); + const matching = textIndexes.find((idx) => classifyTextIndex(idx) === desiredShape); + const stale = textIndexes.filter((idx) => idx !== matching); + + if (matching && stale.length === 0) { + SystemLogger.debug({ msg: 'messages text index already matches desired shape', name: matching.name }); + return; + } + + for (const idx of stale) { + if (!idx.name) { + continue; + } + SystemLogger.startup({ + msg: 'dropping messages text index; may take several minutes on large databases', + name: idx.name, + shape: classifyTextIndex(idx), + }); + try { + await Messages.col.dropIndex(idx.name); + SystemLogger.startup({ msg: 'dropped messages text index', name: idx.name }); + } catch (err) { + SystemLogger.error({ msg: 'failed to drop messages text index; aborting', name: idx.name, err }); + return; + } + } + + if (matching) { + SystemLogger.debug({ msg: 'messages text index already matches desired shape', name: matching.name }); + return; + } + + SystemLogger.startup({ + msg: 'creating messages text index; may take several minutes on large databases', + shape: desiredShape, + }); + try { + const name = await Messages.col.createIndex(desiredShape === 'room-scoped' ? { rid: 1, msg: 'text' } : { msg: 'text' }); + SystemLogger.startup({ msg: 'created messages text index', name, shape: desiredShape }); + } catch (err) { + SystemLogger.error({ msg: 'failed to create messages text index', shape: desiredShape, err }); + } +}; diff --git a/apps/meteor/server/startup/generateKeys.ts b/apps/meteor/server/startup/generateKeys.ts new file mode 100644 index 0000000..dbe8a5b --- /dev/null +++ b/apps/meteor/server/startup/generateKeys.ts @@ -0,0 +1,8 @@ +import { FederationKeys } from '@rocket.chat/models'; + +// Create key pair if needed +export async function generateFederationKeys() { + if (!(await FederationKeys.getPublicKey())) { + await FederationKeys.generateKeys(); + } +} diff --git a/apps/meteor/server/startup/httpSocketTimeout.ts b/apps/meteor/server/startup/httpSocketTimeout.ts new file mode 100644 index 0000000..275f9bc --- /dev/null +++ b/apps/meteor/server/startup/httpSocketTimeout.ts @@ -0,0 +1,17 @@ +import { WebApp } from 'meteor/webapp'; + +// meteor's webapp arms a 5s idle-socket timeout (and re-arms it after every +// response), which kills keep-alive conns a fronting proxy may be about to +// reuse — the root of the CI dynamic-import truncation flake (PR #41399). +// Overriding it is opt-in: only CI sets HTTP_SOCKET_TIMEOUT_MS, pointing it +// above traefik's idleConnTimeout so the proxy always closes conns first. +const timeout = parseInt(process.env.HTTP_SOCKET_TIMEOUT_MS ?? '', 10); +if (timeout > 0) { + const { httpServer, _timeoutAdjustmentRequestCallback } = WebApp as typeof WebApp & { + _timeoutAdjustmentRequestCallback: (req: unknown, res: unknown) => void; + }; + httpServer.removeListener('request', _timeoutAdjustmentRequestCallback); + httpServer.setTimeout(timeout); + httpServer.keepAliveTimeout = timeout; + httpServer.headersTimeout = timeout + 1000; +} diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts new file mode 100644 index 0000000..a307336 --- /dev/null +++ b/apps/meteor/server/startup/index.ts @@ -0,0 +1,29 @@ +import './appcache'; +import './callbacks'; +import { ensureMessagesTextIndex } from './ensureMessagesTextIndex'; +import './initialData'; +import './serverRunning'; +import './coreApps'; +import { generateFederationKeys } from './generateKeys'; +import './presenceTroubleshoot'; +import './httpSocketTimeout'; +import '../hooks'; +import '../lib/rooms/roomTypes'; +import '../lib/settingsRegenerator'; +import { performMigrationProcedure } from './migrations'; +import { isRunningMs } from '../lib/isRunningMs'; + +export const startup = async () => { + await performMigrationProcedure(); + + await generateFederationKeys(); + + // NOTE: cron jobs are started from main.ts after startRocketChat(), so jobs + // that contact Rocket.Chat Cloud on boot (e.g. the usage report) run only + // after the license — including the offline flag — has been applied. + setImmediate(() => ensureMessagesTextIndex()); + // only starts network broker if running in micro services mode + if (!isRunningMs()) { + require('./localServices'); + } +}; diff --git a/apps/meteor/server/startup/initialData.ts b/apps/meteor/server/startup/initialData.ts new file mode 100644 index 0000000..c51c415 --- /dev/null +++ b/apps/meteor/server/startup/initialData.ts @@ -0,0 +1,257 @@ +import { UserStatus, type IUser } from '@rocket.chat/core-typings'; +import { Settings, Rooms, Users, Roles } from '@rocket.chat/models'; +import { validateEmail } from '@rocket.chat/tools'; +import colors from 'colors/safe'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { addCallHistoryTestData } from './callHistoryTestData'; +import { FileUpload } from '../lib/media/file-upload'; +import { notifyOnSettingChangedById } from '../lib/notifyListener'; +import { addUserRolesAsync } from '../lib/roles/addUserRoles'; +import { addUserToDefaultChannels } from '../lib/rooms/addUserToDefaultChannels'; +import { checkUsernameAvailability } from '../lib/users/checkUsernameAvailability'; +import { settings } from '../settings'; + +export async function insertAdminUserFromEnv() { + if (process.env.ADMIN_PASS) { + if ((await Roles.countUsersInRole('admin')) === 0) { + const adminUser: Partial = { + name: process.env.ADMIN_NAME || 'Administrator', + username: 'admin', + status: UserStatus.OFFLINE, + statusDefault: UserStatus.ONLINE, + utcOffset: 0, + active: true, + type: 'user', + }; + + console.log(colors.green(`Name: ${adminUser.name}`)); + + if (process.env.ADMIN_EMAIL) { + if (validateEmail(process.env.ADMIN_EMAIL)) { + if (!(await Users.findOneByEmailAddress(process.env.ADMIN_EMAIL))) { + adminUser.emails = [ + { + address: process.env.ADMIN_EMAIL, + verified: process.env.ADMIN_EMAIL_VERIFIED === 'true', + }, + ]; + + console.log(colors.green(`Email: ${process.env.ADMIN_EMAIL}`)); + } else { + console.log(colors.red('Email provided already exists; Ignoring environment variables ADMIN_EMAIL')); + } + } else { + console.log(colors.red('Email provided is invalid; Ignoring environment variables ADMIN_EMAIL')); + } + } + + if (process.env.ADMIN_USERNAME) { + let nameValidation; + + try { + nameValidation = new RegExp(`^${settings.get('UTF8_User_Names_Validation')}$`); + } catch (error) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (nameValidation.test(process.env.ADMIN_USERNAME)) { + try { + await checkUsernameAvailability(process.env.ADMIN_USERNAME); + adminUser.username = process.env.ADMIN_USERNAME; + } catch (error) { + console.log( + colors.red('Username provided already exists or is blocked from usage; Ignoring environment variables ADMIN_USERNAME'), + ); + } + } else { + console.log(colors.red('Username provided is invalid; Ignoring environment variables ADMIN_USERNAME')); + } + } + + console.log(colors.green(`Username: ${adminUser.username}`)); + + const { insertedId: userId } = await Users.create(adminUser); + + await Accounts.setPasswordAsync(userId, process.env.ADMIN_PASS); + + await addUserRolesAsync(userId, ['admin']); + } else { + console.log(colors.red('Users with admin role already exist; Ignoring environment variables ADMIN_PASS')); + } + } +} + +Meteor.startup(async () => { + const dynamicImport = { + 'dynamic-import': { + useLocationOrigin: true, + }, + }; + + if (!Meteor.settings) { + Meteor.settings = { + public: { + packages: { + 'dynamic-import': dynamicImport, + }, + }, + }; + } + + if (!Meteor.settings.public) { + Meteor.settings.public = { + packages: { + 'dynamic-import': dynamicImport, + }, + }; + } + + if (!Meteor.settings.public.packages) { + Meteor.settings.public.packages = dynamicImport; + } + + Meteor.settings.public.packages['dynamic-import'] = dynamicImport['dynamic-import']; + + if (!settings.get('Initial_Channel_Created')) { + const exists = await Rooms.findOneById('GENERAL', { projection: { _id: 1 } }); + if (!exists) { + await Rooms.createWithIdTypeAndName('GENERAL', 'c', 'general', { + default: true, + }); + } + + (await Settings.updateValueById('Initial_Channel_Created', true)).modifiedCount && + void notifyOnSettingChangedById('Initial_Channel_Created'); + } + + try { + if (!(await Users.findOneById('rocket.cat', { projection: { _id: 1 } }))) { + await Users.create({ + _id: 'rocket.cat', + name: 'Rocket.Cat', + username: 'rocket.cat', + status: UserStatus.ONLINE, + statusDefault: UserStatus.ONLINE, + utcOffset: 0, + active: true, + type: 'bot', + }); + + await addUserRolesAsync('rocket.cat', ['bot']); + + const asset = await Assets.getBinaryAsync('avatars/rocketcat.png'); + if (asset) { + const buffer = Buffer.from(asset); + + const fileStore = FileUpload.getStore('Avatars'); + await fileStore.deleteByName('rocket.cat'); + + const file = { + userId: 'rocket.cat', + type: 'image/png', + size: buffer.length, + }; + + const upload = await fileStore.insert(file, buffer); + await Users.setAvatarData('rocket.cat', 'local', upload.etag); + } + } + } catch (error) { + console.log( + 'Error creating default `rocket.cat` user, if you created a user with this username please remove it and restart the server', + ); + throw error; + } + + await insertAdminUserFromEnv(); + + if (typeof process.env.INITIAL_USER === 'string' && process.env.INITIAL_USER.length > 0) { + try { + const initialUser = JSON.parse(process.env.INITIAL_USER); + + if (!initialUser._id) { + console.log(colors.red('No _id provided; Ignoring environment variable INITIAL_USER')); + } else if (!(await Users.findOneById(initialUser._id))) { + console.log(colors.green('Inserting initial user:')); + console.log(colors.green(JSON.stringify(initialUser, null, 2))); + await Users.create(initialUser); + + await addUserToDefaultChannels(initialUser, true); + } + } catch (e) { + console.log(colors.red('Error processing environment variable INITIAL_USER'), e); + } + } + + if ((await Roles.countUsersInRole('admin')) === 0) { + const oldestUser = await Users.getOldest({ projection: { _id: 1, username: 1, name: 1 } }); + + if (oldestUser) { + await addUserRolesAsync(oldestUser._id, ['admin']); + console.log(`No admins are found. Set ${oldestUser.username || oldestUser.name} as admin for being the oldest user`); + } + } + + if ((await Roles.countUsersInRole('admin')) !== 0) { + if (settings.get('Show_Setup_Wizard') === 'pending') { + console.log('Setting Setup Wizard to "in_progress" because, at least, one admin was found'); + + (await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount && + void notifyOnSettingChangedById('Show_Setup_Wizard'); + } + } + + await Users.removeById('rocketchat.internal.admin.test'); + + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { + console.log(colors.green('Inserting admin test user:')); + + const adminUser: Omit = { + _id: 'rocketchat.internal.admin.test', + name: 'RocketChat Internal Admin Test', + username: 'rocketchat.internal.admin.test', + emails: [ + { + address: 'rocketchat.internal.admin.test@rocket.chat', + verified: true, + }, + ], + status: UserStatus.OFFLINE, + statusDefault: UserStatus.ONLINE, + utcOffset: 0, + active: true, + type: 'user', + }; + + console.log(colors.green(`Name: ${adminUser.name}`)); + console.log(colors.green(`Email: ${adminUser.emails![0].address}`)); + console.log(colors.green(`Username: ${adminUser.username}`)); + console.log(colors.green(`Password: ${adminUser._id}`)); + + if (await Users.findOneByEmailAddress(adminUser.emails![0].address)) { + throw new Meteor.Error(`Email ${adminUser.emails![0].address} already exists`, "Rocket.Chat can't run in test mode"); + } + + if (!(await checkUsernameAvailability(adminUser.username!))) { + throw new Meteor.Error(`Username ${adminUser.username} already exists`, "Rocket.Chat can't run in test mode"); + } + + await Users.create(adminUser); + + await Accounts.setPasswordAsync(adminUser._id, adminUser._id); + + await addUserRolesAsync(adminUser._id, ['admin']); + + if (settings.get('Show_Setup_Wizard') === 'pending') { + (await Settings.updateValueById('Show_Setup_Wizard', 'in_progress')).modifiedCount && + void notifyOnSettingChangedById('Show_Setup_Wizard'); + } + + await addUserToDefaultChannels(adminUser as IUser, true); + + // Create sample call history for API tests + return addCallHistoryTestData('rocketchat.internal.admin.test', 'rocket.cat'); + } +}); diff --git a/apps/meteor/server/startup/localServices.ts b/apps/meteor/server/startup/localServices.ts new file mode 100644 index 0000000..ee09e0f --- /dev/null +++ b/apps/meteor/server/startup/localServices.ts @@ -0,0 +1,12 @@ +import { api, LocalBroker } from '@rocket.chat/core-services'; + +import { StreamerCentral } from '../modules/streamer/streamer.module'; + +const broker = new LocalBroker(); + +broker.onBroadcast((eventName: string, args: unknown[]) => { + StreamerCentral.emit('broadcast', 'local', 'broadcast', [{ eventName, args }]); +}); + +api.setBroker(broker); +void api.start(); diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts new file mode 100644 index 0000000..d10693f --- /dev/null +++ b/apps/meteor/server/startup/migrations/index.ts @@ -0,0 +1,45 @@ +import './minimumVersion'; +import './v293'; +import './v294'; +import './v295'; +import './v296'; +import './v297'; +import './v298'; +import './v299'; +import './v300'; +import './v301'; +import './v303'; +import './v304'; +import './v305'; +import './v306'; +import './v307'; +import './v308'; +import './v309'; +import './v310'; +import './v311'; +import './v312'; +import './v313'; +import './v314'; +import './v315'; +import './v316'; +import './v317'; +import './v318'; +import './v319'; +import './v320'; +import './v321'; +import './v322'; +import './v323'; +import './v324'; +import './v325'; +import './v326'; +import './v327'; +import './v328'; +import './v329'; +import './v330'; +import './v331'; +import './v332'; +import './v333'; +import './v334'; +import './v335'; + +export * from './xrun'; diff --git a/apps/meteor/server/startup/migrations/minimumVersion.ts b/apps/meteor/server/startup/migrations/minimumVersion.ts new file mode 100644 index 0000000..fa84a9a --- /dev/null +++ b/apps/meteor/server/startup/migrations/minimumVersion.ts @@ -0,0 +1,20 @@ +import { addMigration } from '../../lib/migrations'; + +// this was the first migration added on version 6.0, so if this needs to run, +// it means the server was never updated to 6.x, which is not supported. +addMigration({ + version: 292, + up() { + throw new Error( + [ + 'UPGRADE NOT SUPPORTED!', + '', + `It seems you're trying to upgrade from an unsupported version!`, + '', + 'To be able to update to version 7.x.y you need to update to version 6.x first.', + '', + 'Read more: https://go.rocket.chat/i/how-to-upgrade', + ].join('\n'), + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v293.ts b/apps/meteor/server/startup/migrations/v293.ts new file mode 100644 index 0000000..ad7f3c0 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v293.ts @@ -0,0 +1,9 @@ +import { upsertPermissions } from '../../lib/authorization/upsertPermissions'; +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 293, + async up() { + await upsertPermissions(); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v294.ts b/apps/meteor/server/startup/migrations/v294.ts new file mode 100644 index 0000000..0cc7b55 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v294.ts @@ -0,0 +1,37 @@ +import { Apps } from '@rocket.chat/apps'; +import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 294, + async up() { + if (!Apps.self) { + throw new Error('Apps Orchestrator not registered.'); + } + + Apps.initialize(); + + const sigMan = Apps.getManager().getSignatureManager(); + const appsStorage = Apps.getStorage(); + + const apps = await appsStorage.retrieveAll(); + + for (const app of apps.values()) { + if (app.installationSource && app.signature) { + continue; + } + + const updatedApp = { + ...app, + migrated: true, + installationSource: 'marketplaceInfo' in app ? 'marketplace' : 'private', + } as IAppStorageItem; + + await appsStorage.updatePartialAndReturnDocument({ + ...updatedApp, + signature: await sigMan.signApp(updatedApp), + }); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v295.ts b/apps/meteor/server/startup/migrations/v295.ts new file mode 100644 index 0000000..7e09a95 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v295.ts @@ -0,0 +1,45 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +import { SystemLogger } from '../../lib/logger/system'; +import { addMigration } from '../../lib/migrations'; +import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; + +addMigration({ + version: 295, + name: 'Change old "LDAP_Background_Sync_Interval" and "CROWD_Sync_Interval" to a pre-defined values instead of accept any input from the user', + async up() { + const oldLdapDefault = 'Every 24 hours'; + const oldCrowdDefault = 'Every 60 mins'; + + const newLdapDefault = 'every_24_hours'; + const newCrowdDefault = 'every_1_hours'; + + const ldapSyncInterval = await Settings.findOneById>('LDAP_Background_Sync_Interval', { + projection: { value: 1 }, + }); + const crowdSyncInterval = await Settings.findOneById>('CROWD_Sync_Interval', { projection: { value: 1 } }); + + // update setting values + await updateAuditedBySystem({ + reason: 'Migration 295', + })(Settings.updateValueById, 'LDAP_Background_Sync_Interval', newLdapDefault); + await updateAuditedBySystem({ + reason: 'Migration 295', + })(Settings.updateValueById, 'CROWD_Sync_Interval', newCrowdDefault); + + // notify user about the changes if the value was different from the default + + if (ldapSyncInterval && ldapSyncInterval.value !== oldLdapDefault) { + SystemLogger.warn( + `The value of the setting 'LDAP background synchronization interval' has changed from "${ldapSyncInterval.value}" to "${newLdapDefault}". Please review your settings.`, + ); + } + + if (crowdSyncInterval && crowdSyncInterval.value !== oldCrowdDefault) { + SystemLogger.warn( + `The value of the setting 'CROWD background synchronization interval' has changed from "${crowdSyncInterval.value}" to "${newCrowdDefault}". Please review your settings.`, + ); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v296.ts b/apps/meteor/server/startup/migrations/v296.ts new file mode 100644 index 0000000..5e26d60 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v296.ts @@ -0,0 +1,22 @@ +import { Settings } from '@rocket.chat/models'; + +import { SystemLogger } from '../../lib/logger/system'; +import { addMigration } from '../../lib/migrations'; +import { settings } from '../../settings'; + +addMigration({ + version: 296, + name: 'Reset the default value of Login Terms setting and replace by empty string', + async up() { + const oldLoginTermsValue = + 'By proceeding you are agreeing to our Terms of Service, Privacy Policy and Legal Notice.'; + + const loginTermsValue = settings.get('Layout_Login_Terms'); + + if (loginTermsValue === oldLoginTermsValue) { + // TODO: audit + await Settings.updateOne({ _id: 'Layout_Login_Terms' }, { $set: { value: '', packageValue: '' } }); + SystemLogger.warn(`The default value of the setting 'Login Terms' has changed to an empty string. Please review your settings.`); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v297.ts b/apps/meteor/server/startup/migrations/v297.ts new file mode 100644 index 0000000..5d38f41 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v297.ts @@ -0,0 +1,9 @@ +import { upsertPermissions } from '../../lib/authorization/upsertPermissions'; +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 297, + async up() { + await upsertPermissions(); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v298.ts b/apps/meteor/server/startup/migrations/v298.ts new file mode 100644 index 0000000..0146dc1 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v298.ts @@ -0,0 +1,20 @@ +import { Integrations } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 298, + name: 'Set overrideDestinationChannelEnabled for all incoming webhook integrations', + async up() { + await Integrations.updateMany( + { + type: 'webhook-incoming', + }, + { + $set: { + overrideDestinationChannelEnabled: true, + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v299.ts b/apps/meteor/server/startup/migrations/v299.ts new file mode 100644 index 0000000..b351f9f --- /dev/null +++ b/apps/meteor/server/startup/migrations/v299.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 299, + name: 'Add public field to existing custom OAuth settings', + async up() { + await Settings.updateMany({ _id: /^Accounts_OAuth_Custom.+/, group: 'OAuth' }, { $set: { public: false } }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v300.ts b/apps/meteor/server/startup/migrations/v300.ts new file mode 100644 index 0000000..8c46ef3 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v300.ts @@ -0,0 +1,27 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; +import { settingsRegistry } from '../../settings'; + +addMigration({ + version: 300, + async up() { + const customOauthServices = await Settings.find({ _id: /Accounts_OAuth_Custom-[^-]+$/ }, { projection: { _id: 1 } }).toArray(); + const serviceNames = customOauthServices.map(({ _id }) => _id.replace('Accounts_OAuth_Custom-', '')); + + for await (const serviceName of serviceNames) { + await settingsRegistry.add(`Accounts_OAuth_Custom-${serviceName}-merge_users_distinct_services`, false, { + type: 'boolean', + group: 'OAuth', + section: `Custom OAuth: ${serviceName}`, + i18nLabel: 'Accounts_OAuth_Custom_Merge_Users_Distinct_Services', + i18nDescription: 'Accounts_OAuth_Custom_Merge_Users_Distinct_Services_Description', + enableQuery: { + _id: `Accounts_OAuth_Custom-${serviceName}-merge_users`, + value: true, + }, + persistent: true, + }); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v301.ts b/apps/meteor/server/startup/migrations/v301.ts new file mode 100644 index 0000000..7cf1bb0 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v301.ts @@ -0,0 +1,10 @@ +import { Permissions } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 301, + async up() { + await Permissions.updateOne({ _id: 'call-management', roles: { $ne: 'user' } }, { $addToSet: { roles: 'user' } }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v303.ts b/apps/meteor/server/startup/migrations/v303.ts new file mode 100644 index 0000000..6dfa133 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v303.ts @@ -0,0 +1,16 @@ +import { Users } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 303, + name: 'Add roles field and set empty for users without it', + async up() { + await Users.updateMany( + { + roles: { $exists: false }, + }, + { $set: { roles: [] } }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v304.ts b/apps/meteor/server/startup/migrations/v304.ts new file mode 100644 index 0000000..48cb217 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v304.ts @@ -0,0 +1,22 @@ +import { Analytics } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 304, + name: 'Drop wrong index from analytics collection', + async up() { + const indexes = await Analytics.col.indexes(); + + if ( + indexes.find( + (index) => index.name === 'room._id_1_date_1' && index.partialFilterExpression && index.partialFilterExpression.type === 'rooms', + ) + ) { + return; + } + + await Analytics.col.dropIndex('room._id_1_date_1'); + await Analytics.createIndexes(); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v305.ts b/apps/meteor/server/startup/migrations/v305.ts new file mode 100644 index 0000000..b840965 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v305.ts @@ -0,0 +1,11 @@ +import { Permissions } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 305, + name: 'Remove unused view-history permission', + async up() { + await Permissions.deleteOne({ _id: 'view-history' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v306.ts b/apps/meteor/server/startup/migrations/v306.ts new file mode 100644 index 0000000..fcd6ae9 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v306.ts @@ -0,0 +1,22 @@ +import { Rooms } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 306, + name: 'Adds missing ignoreThreads parameter for old rooms with retention policy overridden', + async up() { + await Rooms.updateMany( + { + 'retention.enabled': true, + 'retention.overrideGlobal': true, + 'retention.ignoreThreads': { $exists: false }, + }, + { + $set: { + 'retention.ignoreThreads': false, + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v307.ts b/apps/meteor/server/startup/migrations/v307.ts new file mode 100644 index 0000000..d10bb62 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v307.ts @@ -0,0 +1,38 @@ +import { Apps } from '@rocket.chat/apps'; +import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem'; +import { License } from '@rocket.chat/license'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 307, + name: "Mark all installed private apps as 'migrated'", + async up() { + const isEE = License.hasValidLicense(); + if (isEE) { + return; + } + + if (!Apps.self) { + throw new Error('Apps Orchestrator not registered.'); + } + + Apps.initialize(); + + const sigMan = Apps.getManager().getSignatureManager(); + const appsStorage = Apps.getStorage(); + const apps = await appsStorage.retrieveAllPrivate(); + + for (const app of apps.values()) { + const updatedApp = { + ...app, + migrated: true, + } as IAppStorageItem; + + await appsStorage.updatePartialAndReturnDocument({ + ...updatedApp, + signature: await sigMan.signApp(updatedApp), + }); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v308.ts b/apps/meteor/server/startup/migrations/v308.ts new file mode 100644 index 0000000..d1c97c5 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v308.ts @@ -0,0 +1,32 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { isValidCron } from 'cron-validator'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 308, + name: 'Update packageValue from LDAP interval settings', + async up() { + const newAvatarSyncPackageValue = '0 0 * * *'; + const newAutoLogoutPackageValue = '*/5 * * * *'; + const ldapAvatarSyncInterval = await Settings.findOneById>('LDAP_Background_Sync_Avatars_Interval', { + projection: { value: 1 }, + }); + const ldapAutoLogoutInterval = await Settings.findOneById>('LDAP_Sync_AutoLogout_Interval', { + projection: { value: 1 }, + }); + const isValidAvatarSyncInterval = ldapAvatarSyncInterval && isValidCron(ldapAvatarSyncInterval.value as string); + const isValidAutoLogoutInterval = ldapAutoLogoutInterval && isValidCron(ldapAutoLogoutInterval.value as string); + + // TODO audit + await Settings.updateOne( + { _id: 'LDAP_Background_Sync_Avatars_Interval' }, + { $set: { packageValue: newAvatarSyncPackageValue, ...(!isValidAvatarSyncInterval && { value: newAvatarSyncPackageValue }) } }, + ); + await Settings.updateOne( + { _id: 'LDAP_Sync_AutoLogout_Interval' }, + { $set: { packageValue: newAutoLogoutPackageValue, ...(!isValidAutoLogoutInterval && { value: newAutoLogoutPackageValue }) } }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v309.ts b/apps/meteor/server/startup/migrations/v309.ts new file mode 100644 index 0000000..4df0e0a --- /dev/null +++ b/apps/meteor/server/startup/migrations/v309.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 309, + name: 'Remove unused UI_Click_Direct_Message setting', + async up() { + await Settings.removeById('UI_Click_Direct_Message'); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v310.ts b/apps/meteor/server/startup/migrations/v310.ts new file mode 100644 index 0000000..75a7738 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v310.ts @@ -0,0 +1,29 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 310, + name: 'Update translation key on "Forgot password" e-mail body setting', + async up() { + const forgotPasswordEmail = await Settings.findOneById>('Forgot_Password_Email', { projection: { value: 1 } }); + if (!forgotPasswordEmail) { + return; + } + const newPackageValue = + '

{Forgot_password}

{Lets_get_you_new_one_}

{Reset}

{If_you_didnt_ask_for_reset_ignore_this_email}

'; + + // TODO: audit + + await Settings.updateOne( + { _id: 'Forgot_Password_Email' }, + { + $set: { + packageValue: newPackageValue, + value: (forgotPasswordEmail.value as string).replace(/{Lets_get_you_new_one}/g, '{Lets_get_you_new_one_}'), + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v311.ts b/apps/meteor/server/startup/migrations/v311.ts new file mode 100644 index 0000000..ca77630 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v311.ts @@ -0,0 +1,22 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 311, + name: 'Update default behavior of E2E_Enabled_Mentions setting, to allow mentions in encrypted messages by default.', + async up() { + // TODO: audit + await Settings.updateOne( + { + _id: 'E2E_Enabled_Mentions', + }, + { + $set: { + value: true, + packageValue: true, + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v312.ts b/apps/meteor/server/startup/migrations/v312.ts new file mode 100644 index 0000000..aa0a96f --- /dev/null +++ b/apps/meteor/server/startup/migrations/v312.ts @@ -0,0 +1,20 @@ +import { LivechatRooms, Rooms, Subscriptions, Users } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 312, + async up() { + try { + await Promise.allSettled([ + LivechatRooms.col.dropIndex('v.token_1'), + Rooms.col.dropIndex('t_1'), + Subscriptions.col.dropIndex('rid_1'), + Users.col.dropIndex('active_1'), + ]); + } catch (error: unknown) { + console.warn('Error dropping redundant indexes, continuing...'); + console.warn(error); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v313.ts b/apps/meteor/server/startup/migrations/v313.ts new file mode 100644 index 0000000..88c8616 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v313.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 313, + name: 'Remove "Call_Center" setting group', + async up() { + await Settings.deleteOne({ _id: 'Call_Center', type: 'group' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v314.ts b/apps/meteor/server/startup/migrations/v314.ts new file mode 100644 index 0000000..e7b4ef9 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v314.ts @@ -0,0 +1,22 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 314, + name: 'Update default behavior of E2E_Allow_Unencrypted_Messages setting, to not allow un-encrypted messages by default.', + async up() { + // TODO: audit + await Settings.updateOne( + { + _id: 'E2E_Allow_Unencrypted_Messages', + }, + { + $set: { + value: false, + packageValue: false, + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v315.ts b/apps/meteor/server/startup/migrations/v315.ts new file mode 100644 index 0000000..2646f8f --- /dev/null +++ b/apps/meteor/server/startup/migrations/v315.ts @@ -0,0 +1,27 @@ +import type { IPermission } from '@rocket.chat/core-typings'; +import { Permissions } from '@rocket.chat/models'; + +import { upsertPermissions } from '../../lib/authorization/upsertPermissions'; +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 315, + name: 'Copy roles from add-team-channel permission to new create-team-channel, create-team-group and move-room-to-team permissions', + async up() { + // Calling upsertPermissions on purpose so that the new permissions are added before the migration runs + await upsertPermissions(); + + const addTeamChannelPermission = await Permissions.findOneById>('add-team-channel', { + projection: { roles: 1 }, + }); + + if (addTeamChannelPermission) { + await Permissions.updateMany( + { _id: { $in: ['create-team-channel', 'create-team-group', 'move-room-to-team'] } }, + { $set: { roles: addTeamChannelPermission.roles } }, + ); + + await Permissions.deleteOne({ _id: 'add-team-channel' }); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v316.ts b/apps/meteor/server/startup/migrations/v316.ts new file mode 100644 index 0000000..210dafc --- /dev/null +++ b/apps/meteor/server/startup/migrations/v316.ts @@ -0,0 +1,20 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 316, + name: 'Remove Cloud_Workspace_Access_Token and Cloud_Workspace_Access_Token_Expires_At from the settings collection', + async up() { + const accessToken = ((await Settings.getValueById('Cloud_Workspace_Access_Token')) as string) || ''; + const expirationDate = ((await Settings.getValueById('Cloud_Workspace_Access_Token_Expires_At')) as Date) || new Date(0); + + if (accessToken) { + await Settings.removeById('Cloud_Workspace_Access_Token'); + } + + if (expirationDate) { + await Settings.removeById('Cloud_Workspace_Access_Token_Expires_At'); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v317.ts b/apps/meteor/server/startup/migrations/v317.ts new file mode 100644 index 0000000..383f2a4 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v317.ts @@ -0,0 +1,124 @@ +import type { ILoginServiceConfiguration, OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Settings, LoginServiceConfiguration } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; + +import { SystemLogger } from '../../lib/logger/system'; +import { addMigration } from '../../lib/migrations'; + +const newDefaultButtonColor = '#e4e7ea'; +const newDefaultButtonLabelColor = '#1f2329'; + +const settingsToUpdate = [ + // button background colors + { key: 'SAML_Custom_Default_button_color', defaultValue: '#1d74f5', newValue: newDefaultButtonColor }, + { key: 'CAS_button_color', defaultValue: '#1d74f5', newValue: newDefaultButtonColor }, + { key: 'Accounts_OAuth_Nextcloud_button_color', defaultValue: '#0082c9', newValue: newDefaultButtonColor }, + { key: 'Accounts_OAuth_Dolphin_button_color', defaultValue: '#1d74f5', newValue: newDefaultButtonColor }, + // button label colors + { key: 'SAML_Custom_Default_button_label_color', defaultValue: '#1d74f5', newValue: newDefaultButtonLabelColor }, + { key: 'CAS_button_label_color', defaultValue: '#1d74f5', newValue: newDefaultButtonLabelColor }, + { key: 'Accounts_OAuth_Nextcloud_button_label_color', defaultValue: '#1d74f5', newValue: newDefaultButtonLabelColor }, + { key: 'Accounts_OAuth_Dolphin_button_label_color', defaultValue: '#1d74f5', newValue: newDefaultButtonLabelColor }, +]; + +const getSettingValue = async (key: string) => Settings.getValueById(key); + +async function updateOAuthServices(): Promise { + const services = await Settings.find({ _id: { $regex: /^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i } }).toArray(); + const filteredServices = services.filter(({ value }) => typeof value === 'boolean'); + for await (const { _id: key, value } of filteredServices) { + if (value !== true) { + continue; + } + + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + if (/Accounts_OAuth_Custom-/.test(key)) { + serviceName = key.replace('Accounts_OAuth_Custom-', ''); + } + + const serviceKey = serviceName.toLowerCase(); + + const data: Partial> = {}; + + if (/Accounts_OAuth_Custom-/.test(key)) { + data.buttonLabelColor = (await getSettingValue(`${key}-button_label_color`)) as string; + data.buttonColor = (await getSettingValue(`${key}-button_color`)) as string; + } + + if (serviceName === 'Nextcloud') { + data.buttonLabelColor = (await getSettingValue('Accounts_OAuth_Nextcloud_button_label_color')) as string; + data.buttonColor = (await getSettingValue('Accounts_OAuth_Nextcloud_button_color')) as string; + } + + await LoginServiceConfiguration.createOrUpdateService(serviceKey, data); + } +} + +addMigration({ + version: 317, + name: 'Change default color of OAuth login services buttons', + async up() { + // TODO: audit + const promises = settingsToUpdate + .map(async ({ key, defaultValue, newValue }) => { + const oldSettingValue = await getSettingValue(key); + + if (!oldSettingValue || oldSettingValue !== defaultValue) { + return; + } + + SystemLogger.warn({ msg: 'The default value of the setting has changed. Please review your settings.', key, newValue }); + + return Settings.updateOne({ _id: key }, { $set: { value: newValue } }); + }) + .filter(isTruthy); + + await Promise.all(promises); + + const customOAuthButtonColors = await Settings.find({ + _id: { $regex: /^Accounts_OAuth_Custom-[a-zA-Z0-9_-]+-button_color$/ }, + }).toArray(); + const customOAuthButtonLabelColors = await Settings.find({ + _id: { $regex: /^Accounts_OAuth_Custom-[a-zA-Z0-9_-]+-button_label_color$/ }, + }).toArray(); + + const buttonColorPromises = customOAuthButtonColors + .map(({ _id, value, packageValue }) => { + if (packageValue !== value) { + return; + } + + SystemLogger.warn({ + msg: 'The default value of the custom setting has changed. Please review your settings.', + _id, + newValue: newDefaultButtonColor, + }); + + return Settings.updateOne({ _id }, { $set: { value: newDefaultButtonColor } }); + }) + .filter(isTruthy); + + const buttonLabelColorPromises = customOAuthButtonLabelColors + .map(({ _id, value, packageValue }) => { + if (packageValue !== value) { + return; + } + + SystemLogger.warn({ + msg: 'The default value of the custom setting has changed. Please review your settings.', + _id, + newValue: newDefaultButtonLabelColor, + }); + + return Settings.updateOne({ _id }, { $set: { value: newDefaultButtonLabelColor } }); + }) + .filter(isTruthy); + + await Promise.all([...buttonColorPromises, ...buttonLabelColorPromises]); + // update login service configurations + await updateOAuthServices(); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v318.ts b/apps/meteor/server/startup/migrations/v318.ts new file mode 100644 index 0000000..be22cd7 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v318.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 318, + name: 'Remove "E2E Encryption" setting group', + async up() { + await Settings.deleteOne({ _id: 'E2E Encryption', type: 'group' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v319.ts b/apps/meteor/server/startup/migrations/v319.ts new file mode 100644 index 0000000..7be3b0f --- /dev/null +++ b/apps/meteor/server/startup/migrations/v319.ts @@ -0,0 +1,15 @@ +import { LivechatRooms } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 319, + name: 'Remove "pdfTranscriptRequested" index from LivechatRooms collection', + async up() { + try { + await LivechatRooms.col.dropIndex('pdfTranscriptRequested_1'); + } catch { + console.error('Error dropping index'); + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v320.ts b/apps/meteor/server/startup/migrations/v320.ts new file mode 100644 index 0000000..e213d80 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v320.ts @@ -0,0 +1,10 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 320, + async up() { + await Settings.deleteOne({ _id: 'API_Use_REST_For_DDP_Calls' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v321.ts b/apps/meteor/server/startup/migrations/v321.ts new file mode 100644 index 0000000..b1f32ab --- /dev/null +++ b/apps/meteor/server/startup/migrations/v321.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 321, + name: 'Remove voxtelesys & mobex related settings', + async up() { + await Promise.all([Settings.deleteMany({ section: { $in: ['Voxtelesys', 'Mobex'] } })]); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v322.ts b/apps/meteor/server/startup/migrations/v322.ts new file mode 100644 index 0000000..7c1f60a --- /dev/null +++ b/apps/meteor/server/startup/migrations/v322.ts @@ -0,0 +1,21 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 322, + name: 'Remove Tokenpass settings', + async up() { + await Settings.deleteMany({ + _id: { + $in: [ + 'Accounts_OAuth_Tokenpass', + 'API_Tokenpass_URL', + 'Accounts_OAuth_Tokenpass_id', + 'Accounts_OAuth_Tokenpass_secret', + 'Accounts_OAuth_Tokenpass_callback_url', + ], + }, + }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v323.ts b/apps/meteor/server/startup/migrations/v323.ts new file mode 100644 index 0000000..8961b4b --- /dev/null +++ b/apps/meteor/server/startup/migrations/v323.ts @@ -0,0 +1,27 @@ +import { AppLogs } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +// Remove outdated indexes on rocketchat_apps_logs +addMigration({ + version: 323, + name: 'Remove outdated indexes on rocketchat_apps_logs', + async up() { + // Idempotent drops: ignore "IndexNotFound" (code 27) + try { + await AppLogs.col.dropIndex('appId_indexed_query'); + } catch (e: any) { + if (e?.code !== 27 && e?.codeName !== 'IndexNotFound') { + throw e; + } + } + + try { + await AppLogs.col.dropIndex('general_logs_index'); + } catch (e: any) { + if (e?.code !== 27 && e?.codeName !== 'IndexNotFound') { + throw e; + } + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v324.ts b/apps/meteor/server/startup/migrations/v324.ts new file mode 100644 index 0000000..4437fb7 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v324.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 324, + name: 'Remove Log_View_Limit setting', + async up() { + await Settings.deleteOne({ _id: 'Log_View_Limit' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v325.ts b/apps/meteor/server/startup/migrations/v325.ts new file mode 100644 index 0000000..1f54f94 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v325.ts @@ -0,0 +1,43 @@ +import { Settings, Permissions } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 325, + name: 'Remove VoIP_Omnichannel Settings and Permissions', + async up() { + await Settings.deleteMany({ + _id: { + $in: [ + 'VoIP_Omnichannel', + 'VoIP_Enabled', + 'VoIP_JWT_Secret', + 'Voip_Server_Configuration', + 'VoIP_Server_Name', + 'VoIP_Server_Websocket_Path', + 'VoIP_Retry_Count', + 'VoIP_Enable_Keep_Alive_For_Unstable_Networks', + 'VoIP_Management_Server_Host', + 'VoIP_Management_Server_Port', + 'VoIP_Management_Server_Name', + 'VoIP_Management_Server_Username', + 'VoIP_Management_Server_Password', + ], + }, + }); + + await Permissions.deleteMany({ + _id: { + $in: [ + 'manage-voip-call-settings', + 'manage-voip-contact-center-settings', + 'manage-agent-extension-association', + 'inbound-voip-calls', + 'spy-voip-calls', + 'outbound-voip-calls', + 'view-agent-extension-association', + ], + }, + }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v326.ts b/apps/meteor/server/startup/migrations/v326.ts new file mode 100644 index 0000000..e05da1e --- /dev/null +++ b/apps/meteor/server/startup/migrations/v326.ts @@ -0,0 +1,23 @@ +import { Rooms } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 326, + name: 'Close Omnichannel VoIP Rooms', + async up() { + await Rooms.updateMany( + { + t: 'v' as any, + open: { + $exists: true, + }, + }, + { + $unset: { + open: 1, + }, + }, + ); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v327.ts b/apps/meteor/server/startup/migrations/v327.ts new file mode 100644 index 0000000..0de9f58 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v327.ts @@ -0,0 +1,22 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 327, + name: 'Remove FreeSwitch Settings', + async up() { + await Settings.deleteMany({ + _id: { + $in: [ + 'VoIP_TeamCollab_FreeSwitch', + 'VoIP_TeamCollab_FreeSwitch_Host', + 'VoIP_TeamCollab_FreeSwitch_Port', + 'VoIP_TeamCollab_FreeSwitch_Password', + 'VoIP_TeamCollab_FreeSwitch_Timeout', + 'VoIP_TeamCollab_FreeSwitch_WebSocket_Path', + ], + }, + }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v328.ts b/apps/meteor/server/startup/migrations/v328.ts new file mode 100644 index 0000000..d5bd45a --- /dev/null +++ b/apps/meteor/server/startup/migrations/v328.ts @@ -0,0 +1,15 @@ +import { Permissions } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 328, + name: 'Remove Old Voip Permissions', + async up() { + await Permissions.deleteMany({ + _id: { + $in: ['manage-voip-extensions', 'view-user-voip-extension', 'view-voip-extension-details'], + }, + }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v329.ts b/apps/meteor/server/startup/migrations/v329.ts new file mode 100644 index 0000000..9288647 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v329.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 329, + name: 'Remove VoIP Toggle Setting', + async up() { + await Settings.deleteOne({ _id: 'VoIP_TeamCollab_Enabled' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v330.ts b/apps/meteor/server/startup/migrations/v330.ts new file mode 100644 index 0000000..4956ad4 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v330.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 330, + name: 'Remove ECDH setting', + async up() { + await Settings.deleteOne({ _id: 'ECDH_Enabled' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v331.ts b/apps/meteor/server/startup/migrations/v331.ts new file mode 100644 index 0000000..83f761c --- /dev/null +++ b/apps/meteor/server/startup/migrations/v331.ts @@ -0,0 +1,11 @@ +import { Permissions } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 331, + name: 'Remove view-livechat-current-chats permission', + async up() { + await Permissions.deleteOne({ _id: 'view-livechat-current-chats' }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v332.ts b/apps/meteor/server/startup/migrations/v332.ts new file mode 100644 index 0000000..c92d3f7 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v332.ts @@ -0,0 +1,53 @@ +import { CallHistory, Users } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 332, + name: 'Fill contact information on older call history entries', + async up() { + const cursor = CallHistory.col.aggregate([ + { + $match: { + external: false, + contactId: { $exists: true }, + contactName: { $exists: false }, + contactUsername: { $exists: false }, + }, + }, + + { + $lookup: { + from: Users.col.collectionName, + localField: 'contactId', + foreignField: '_id', + as: 'contactDetails', + }, + }, + { + $addFields: { + contactName: { $first: '$contactDetails.name' }, + contactUsername: { $first: '$contactDetails.username' }, + }, + }, + { + $project: { + contactName: 1, + contactUsername: 1, + }, + }, + { + $merge: { + into: CallHistory.col.collectionName, + on: '_id', + whenMatched: 'merge', + }, + }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _item of cursor) { + // + } + }, +}); diff --git a/apps/meteor/server/startup/migrations/v333.ts b/apps/meteor/server/startup/migrations/v333.ts new file mode 100644 index 0000000..216340d --- /dev/null +++ b/apps/meteor/server/startup/migrations/v333.ts @@ -0,0 +1,35 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 333, + name: 'Remove Voxtelesys & Mobex SMS settings by ID', + async up() { + const ids = [ + // Voxtelesys settings + 'SMS_Voxtelesys_authToken', + 'SMS_Voxtelesys_URL', + 'SMS_Voxtelesys_FileUpload_Enabled', + 'SMS_Voxtelesys_FileUpload_MediaTypeWhiteList', + + // Mobex settings + 'SMS_Mobex_gateway_address', + 'SMS_Mobex_gateway_address_desc', + 'SMS_Mobex_from_number', + 'SMS_Mobex_from_number_desc', + 'SMS_Mobex_from_numbers_list', + 'SMS_Mobex_from_numbers_list_desc', + 'SMS_Mobex_password', + 'SMS_Mobex_restful_address', + 'SMS_Mobex_restful_address_desc', + 'SMS_Mobex_username', + ]; + + await Settings.deleteMany({ + _id: { $in: ids }, + }); + + await Settings.findOneAndUpdate({ _id: 'SMS_Service', value: { $ne: 'twilio' } }, { $set: { value: 'twilio' } }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v334.ts b/apps/meteor/server/startup/migrations/v334.ts new file mode 100644 index 0000000..318496c --- /dev/null +++ b/apps/meteor/server/startup/migrations/v334.ts @@ -0,0 +1,22 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 334, + name: 'Remove webRTC settings', + async up() { + await Settings.deleteMany({ + _id: { + $in: [ + 'WebRTC_Enabled', + 'WebRTC_Enable_Channel', + 'WebRTC_Enable_Private', + 'WebRTC_Enable_Direct', + 'WebRTC_Calls_Count', + 'WebRTC_Servers', + ], + }, + }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/v335.ts b/apps/meteor/server/startup/migrations/v335.ts new file mode 100644 index 0000000..49b56b2 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v335.ts @@ -0,0 +1,11 @@ +import { Settings } from '@rocket.chat/models'; + +import { addMigration } from '../../lib/migrations'; + +addMigration({ + version: 335, + name: 'Remove OTR_Enable and OTR_Count settings', + async up() { + await Settings.deleteMany({ _id: { $in: ['OTR_Enable', 'OTR_Count'] } }); + }, +}); diff --git a/apps/meteor/server/startup/migrations/xrun.ts b/apps/meteor/server/startup/migrations/xrun.ts new file mode 100644 index 0000000..9fdaea2 --- /dev/null +++ b/apps/meteor/server/startup/migrations/xrun.ts @@ -0,0 +1,102 @@ +import { Permissions, Roles, Settings, Users } from '@rocket.chat/models'; +import type { UpdateResult } from 'mongodb'; + +import { upsertPermissions } from '../../lib/authorization/upsertPermissions'; +import { migrateDatabase, onServerVersionChange } from '../../lib/migrations'; +import { settings } from '../../settings'; +import { ensureCloudWorkspaceRegistered } from '../cloudRegistration'; + +const { MIGRATION_VERSION = 'latest' } = process.env; + +const [version, ...subcommands] = MIGRATION_VERSION.split(','); + +const maxAgeSettingMap = new Map([ + ['RetentionPolicy_MaxAge_Channels', 'RetentionPolicy_TTL_Channels'], + ['RetentionPolicy_MaxAge_Groups', 'RetentionPolicy_TTL_Groups'], + ['RetentionPolicy_MaxAge_DMs', 'RetentionPolicy_TTL_DMs'], +]); + +const moveRetentionSetting = async () => { + const convertDaysToMs = (days: number) => days * 24 * 60 * 60 * 1000; + + const promises: Array> = []; + await Settings.find( + { _id: { $in: Array.from(maxAgeSettingMap.keys()) }, value: { $ne: -1 } }, + { projection: { _id: 1, value: 1 } }, + ).forEach(({ _id, value }) => { + const newSettingId = maxAgeSettingMap.get(_id); + if (!newSettingId) { + throw new Error(`moveRetentionSetting - Setting ${_id} equivalent does not exist`); + } + + const newValue = convertDaysToMs(Number(value)); + + promises.push( + Settings.updateOne( + { + _id: maxAgeSettingMap.get(_id), + }, + { + $set: { + value: newValue, + }, + }, + ), + ); + + const currentCache = settings.getSetting(newSettingId); + if (!currentCache) { + return; + } + settings.set({ ...currentCache, value: newValue }); + }); + + await Promise.all(promises); + await Settings.updateMany({ _id: { $in: Array.from(maxAgeSettingMap.keys()) } }, { $set: { value: -1 } }); +}; + +async function setPermissionsToNewRole() { + const role = await Roles.findOneById('federated-external'); + + // if role was found it means it was already created with the permissions, so we can skip it + if (role) { + return; + } + + // if federation was used before the new role was created, we need to update their roles with the new role + await Users.updateMany({ federated: true }, { $addToSet: { roles: 'federated-external' } }); + + const rolePermissions = [ + 'create-c', + 'create-d', + 'create-p', + 'delete-own-message', + 'leave-c', + 'leave-p', + 'mention-all', + 'mention-here', + 'start-discussion', + 'start-discussion-other-user', + 'view-c-room', + 'view-d-room', + 'view-p-room', + 'preview-c-room', + 'view-outside-room', + 'mobile-upload-file', + 'access-federation', + ]; + + // since this is a one time query, no need to create a method in model class for it + await Permissions.updateMany({ _id: { $in: rolePermissions } }, { $addToSet: { roles: 'federated-external' } }); +} + +export const performMigrationProcedure = async (): Promise => { + await migrateDatabase(version === 'latest' ? version : parseInt(version), subcommands); + // perform operations when the server is starting with a different version + await onServerVersionChange(async () => { + await setPermissionsToNewRole(); + await upsertPermissions(); + await ensureCloudWorkspaceRegistered(); + await moveRetentionSetting(); + }); +}; diff --git a/apps/meteor/server/startup/presenceTroubleshoot.ts b/apps/meteor/server/startup/presenceTroubleshoot.ts new file mode 100644 index 0000000..4fad112 --- /dev/null +++ b/apps/meteor/server/startup/presenceTroubleshoot.ts @@ -0,0 +1,12 @@ +import { Presence } from '@rocket.chat/core-services'; + +import { settings } from '../settings'; + +// maybe this setting should disable the listener to 'presence.status' event on listerners.module.ts +settings.watch('Troubleshoot_Disable_Presence_Broadcast', async (value) => { + try { + await Presence.toggleBroadcast(!value); + } catch (e) { + // do nothing + } +}); diff --git a/apps/meteor/server/startup/rateLimiter.js b/apps/meteor/server/startup/rateLimiter.js new file mode 100644 index 0000000..5b5c0fe --- /dev/null +++ b/apps/meteor/server/startup/rateLimiter.js @@ -0,0 +1,239 @@ +import { Logger } from '@rocket.chat/logger'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; +import { Meteor } from 'meteor/meteor'; +import { RateLimiter } from 'meteor/rate-limit'; +import _ from 'underscore'; + +import { sleep } from '../../lib/utils/sleep'; +import { metrics } from '../lib/metrics'; +import { settings } from '../settings'; + +const logger = new Logger('RateLimiter'); + +const slowDownRate = parseInt(process.env.RATE_LIMITER_SLOWDOWN_RATE); + +const rateLimiterConsoleLog = ({ msg, reply, input }) => { + console.warn('DDP RATE LIMIT:', msg); + console.warn(JSON.stringify({ reply, input }, null, 2)); +}; + +const rateLimiterLogger = ({ msg, reply, input }) => logger.info({ msg, reply, input }); + +const rateLimiterLog = String(process.env.RATE_LIMITER_LOGGER) === 'console' ? rateLimiterConsoleLog : rateLimiterLogger; + +// Get initial set of names already registered for rules +const names = new Set( + Object.values(DDPRateLimiter.printRules()) + .map((rule) => rule._matchers) + .filter((match) => typeof match.name === 'string') + .map((match) => match.name), +); + +// Override the addRule to save new names added after this point +const { addRule } = DDPRateLimiter; +DDPRateLimiter.addRule = (matcher, calls, time, callback) => { + if (matcher && typeof matcher.name === 'string') { + names.add(matcher.name); + } + return addRule.call(DDPRateLimiter, matcher, calls, time, callback); +}; + +const { _increment } = DDPRateLimiter; +DDPRateLimiter._increment = function (input) { + const session = Meteor.server.sessions.get(input.connectionId); + input.broadcastAuth = (session && session.connectionHandle && session.connectionHandle.broadcastAuth) === true; + + return _increment.call(DDPRateLimiter, input); +}; + +// Need to override the meteor's code duo to a problem with the callback reply +// being shared among all matchs +RateLimiter.prototype.check = function (input) { + // ==== BEGIN OVERRIDE ==== + const session = Meteor.server.sessions.get(input.connectionId); + input.broadcastAuth = (session && session.connectionHandle && session.connectionHandle.broadcastAuth) === true; + // ==== END OVERRIDE ==== + + const self = this; + const reply = { + allowed: true, + timeToReset: 0, + numInvocationsLeft: Infinity, + }; + + const matchedRules = self._findAllMatchingRules(input); + _.each(matchedRules, (rule) => { + // ==== BEGIN OVERRIDE ==== + const callbackReply = { + allowed: true, + timeToReset: 0, + numInvocationsLeft: Infinity, + }; + // ==== END OVERRIDE ==== + + const ruleResult = rule.apply(input); + let numInvocations = rule.counters[ruleResult.key]; + + if (ruleResult.timeToNextReset < 0) { + // Reset all the counters since the rule has reset + rule.resetCounter(); + ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + ruleResult.timeToNextReset = rule.options.intervalTime; + numInvocations = 0; + } + + if (numInvocations > rule.options.numRequestsAllowed) { + // Only update timeToReset if the new time would be longer than the + // previously set time. This is to ensure that if this input triggers + // multiple rules, we return the longest period of time until they can + // successfully make another call + if (reply.timeToReset < ruleResult.timeToNextReset) { + reply.timeToReset = ruleResult.timeToNextReset; + } + reply.allowed = false; + reply.numInvocationsLeft = 0; + + // ==== BEGIN OVERRIDE ==== + callbackReply.timeToReset = ruleResult.timeToNextReset; + callbackReply.allowed = false; + callbackReply.numInvocationsLeft = 0; + callbackReply.numInvocationsExceeded = numInvocations - rule.options.numRequestsAllowed; + rule._executeCallback(callbackReply, input); + // ==== END OVERRIDE ==== + } else { + // If this is an allowed attempt and we haven't failed on any of the + // other rules that match, update the reply field. + if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { + reply.timeToReset = ruleResult.timeToNextReset; + reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; + } + + // ==== BEGIN OVERRIDE ==== + callbackReply.timeToReset = ruleResult.timeToNextReset; + callbackReply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; + rule._executeCallback(callbackReply, input); + // ==== END OVERRIDE ==== + } + }); + return reply; +}; + +const checkNameNonStream = (name) => name && !names.has(name) && !name.startsWith('stream-'); +const checkNameForStream = (name) => name && !names.has(name) && name.startsWith('stream-'); + +const ruleIds = {}; + +const callback = (msg, name) => async (reply, input) => { + if (reply.allowed === false) { + rateLimiterLog({ msg, reply, input }); + const rateLimitLabels = { + limit_name: name, + user_id: input.userId, + client_address: input.clientAddress, + type: input.type, + name: input.name, + connection_id: input.connectionId, + }; + metrics.ddpRateLimitExceeded.inc(rateLimitLabels); + metrics.ddpRateLimitExceededTotal.inc(rateLimitLabels); + // sleep before sending the error to slow down next requests + if (slowDownRate > 0 && reply.numInvocationsExceeded) { + await sleep(slowDownRate * reply.numInvocationsExceeded); + } + // } else { + // console.log('DDP RATE LIMIT:', message); + // console.log(JSON.stringify({ ...reply, ...input }, null, 2)); + } +}; + +const messages = { + IP: 'address', + User: 'userId', + Connection: 'connectionId', + User_By_Method: 'userId per method', + Connection_By_Method: 'connectionId per method', +}; + +const reconfigureLimit = Meteor.bindEnvironment((name, rules, factor = 1) => { + if (ruleIds[name + factor]) { + DDPRateLimiter.removeRule(ruleIds[name + factor]); + } + + if (!settings.get(`DDP_Rate_Limit_${name}_Enabled`)) { + return; + } + + ruleIds[name + factor] = addRule( + rules, + settings.get(`DDP_Rate_Limit_${name}_Requests_Allowed`) * factor, + settings.get(`DDP_Rate_Limit_${name}_Interval_Time`) * factor, + callback(`limit by ${messages[name]}`, name), + ); +}); + +const configIP = _.debounce(() => { + reconfigureLimit('IP', { + broadcastAuth: false, + clientAddress: (clientAddress) => clientAddress !== '127.0.0.1', + }); +}, 1000); + +const configUser = _.debounce(() => { + reconfigureLimit('User', { + broadcastAuth: false, + userId: (userId) => userId != null, + }); +}, 1000); + +const configConnection = _.debounce(() => { + reconfigureLimit('Connection', { + broadcastAuth: false, + connectionId: () => true, + }); +}, 1000); + +const configUserByMethod = _.debounce(() => { + reconfigureLimit('User_By_Method', { + broadcastAuth: false, + type: () => true, + name: checkNameNonStream, + userId: (userId) => userId != null, + }); + reconfigureLimit( + 'User_By_Method', + { + broadcastAuth: false, + type: () => true, + name: checkNameForStream, + userId: (userId) => userId != null, + }, + 4, + ); +}, 1000); + +const configConnectionByMethod = _.debounce(() => { + reconfigureLimit('Connection_By_Method', { + broadcastAuth: false, + type: () => true, + name: checkNameNonStream, + connectionId: () => true, + }); + reconfigureLimit( + 'Connection_By_Method', + { + broadcastAuth: false, + type: () => true, + name: checkNameForStream, + connectionId: () => true, + }, + 4, + ); +}, 1000); + +if (!process.env.TEST_MODE) { + settings.watchByRegex(/^DDP_Rate_Limit_IP_.+/, configIP); + settings.watchByRegex(/^DDP_Rate_Limit_User_[^B].+/, configUser); + settings.watchByRegex(/^DDP_Rate_Limit_Connection_[^B].+/, configConnection); + settings.watchByRegex(/^DDP_Rate_Limit_User_By_Method_.+/, configUserByMethod); + settings.watchByRegex(/^DDP_Rate_Limit_Connection_By_Method_.+/, configConnectionByMethod); +} diff --git a/apps/meteor/server/startup/robots.js b/apps/meteor/server/startup/robots.js new file mode 100644 index 0000000..1ddbe35 --- /dev/null +++ b/apps/meteor/server/startup/robots.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '../settings'; + +Meteor.startup(() => { + return WebApp.connectHandlers.use('/robots.txt', (req, res /* , next*/) => { + res.writeHead(200); + res.end(settings.get('Robot_Instructions_File_Content')); + }); +}); diff --git a/apps/meteor/server/startup/serverRunning.ts b/apps/meteor/server/startup/serverRunning.ts new file mode 100644 index 0000000..8114f8b --- /dev/null +++ b/apps/meteor/server/startup/serverRunning.ts @@ -0,0 +1,131 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { License } from '@rocket.chat/license'; +// import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; +import semver from 'semver'; + +import { Info } from '../../app/utils/rocketchat.info'; +import { showErrorBox, showSuccessBox, showWarningBox } from '../lib/logger/showBox'; +import { getMongoInfo } from '../lib/utils/functions/getMongoInfo'; +// import { i18n } from '../lib/i18n'; +// import { isRunningMs } from '../lib/isRunningMs'; +// import { sendMessagesToAdmins } from '../lib/sendMessagesToAdmins'; +import { settings } from '../settings'; + +const exitIfNotBypassed = (ignore: string | undefined, errorCode = 1) => { + if (typeof ignore === 'string' && ['yes', 'true'].includes(ignore.toLowerCase())) { + return; + } + + process.exit(errorCode); +}; + +const skipMongoDbDeprecationCheck = + ['yes', 'true'].includes(String(process.env.SKIP_MONGODEPRECATION_CHECK).toLowerCase()) || + process.env.TEST_MODE === 'true' || + process.env.TEST_MODE === 'api' || + process.env.NODE_ENV === 'development'; +// const skipMongoDbDeprecationBanner = ['yes', 'true'].includes(String(process.env.SKIP_MONGODEPRECATION_BANNER).toLowerCase()); + +Meteor.startup(async () => { + const { mongoVersion, mongoStorageEngine } = await getMongoInfo(); + + const desiredNodeVersion = semver.clean(fs.readFileSync(path.join(process.cwd(), '../../.node_version.txt')).toString()); + const parsedSemVer = semver.parse(desiredNodeVersion); + if (!parsedSemVer) { + console.error('Failed to parse desired Node.js version from .node_version.txt'); + process.exit(1); + } + + const desiredNodeVersionMajor = String(parsedSemVer.major); + + return setTimeout(async () => { + let msg: string | string[] = [ + `Rocket.Chat Version: ${Info.version}`, + ` NodeJS Version: ${process.versions.node} - ${process.arch}`, + ` MongoDB Version: ${mongoVersion}`, + ` MongoDB Engine: ${mongoStorageEngine}`, + ` Platform: ${process.platform}`, + ` Process Port: ${process.env.PORT}`, + ` Site URL: ${settings.get('Site_Url')}`, + ` Hashed Site URL: ${License.getHashedWorkspaceUrl()}`, + ` OpenSSL Version: ${process.versions.openssl}`, + ` FIPS Provider: ${crypto.getFips() ? 'Enabled' : 'Disabled'}`, + ]; + + if (Info.commit?.hash) { + msg.push(` Commit Hash: ${Info.commit.hash.substr(0, 10)}`); + } + + if (Info.commit?.branch) { + msg.push(` Commit Branch: ${Info.commit.branch}`); + } + + msg = msg.join('\n'); + + if (!semver.satisfies(process.versions.node, desiredNodeVersionMajor)) { + msg += [ + '', + '', + 'YOUR CURRENT NODEJS VERSION IS NOT SUPPORTED,', + `PLEASE UPGRADE / DOWNGRADE TO VERSION ${desiredNodeVersionMajor}.X.X`, + ].join('\n'); + showErrorBox('SERVER ERROR', msg); + + exitIfNotBypassed(process.env.BYPASS_NODEJS_VALIDATION); + } + + const mongoSemver = semver.coerce(mongoVersion); + + if (!mongoSemver || semver.satisfies(mongoSemver, '<7.0.0')) { + msg += ['', '', 'YOUR CURRENT MONGODB VERSION IS NOT SUPPORTED BY ROCKET.CHAT,', 'PLEASE UPGRADE TO VERSION 8.0 OR LATER'].join('\n'); + showErrorBox('SERVER ERROR', msg); + + exitIfNotBypassed(process.env.BYPASS_MONGO_VALIDATION); + } + + showSuccessBox('SERVER RUNNING', msg); + + // Deprecation + if (!skipMongoDbDeprecationCheck && semver.satisfies(semver.coerce(mongoVersion)!, '<8.0.0')) { + msg = [ + `DEPRECATED: MONGODB VERSION ${mongoVersion}.`, + 'SUPPORT FOR MONGODB <8.0 WILL BE REMOVED IN ROCKET.CHAT 9.0.0', + 'PLEASE UPGRADE MONGODB TO VERSION 8.0 OR GREATER', + ].join('\n'); + showWarningBox('DEPRECATION', msg); + + // const id = `mongodbDeprecation_${mongoVersion.replace(/[^0-9]/g, '_')}`; + // const title = 'MongoDB_Deprecated'; + // const text = 'MongoDB_version_s_is_deprecated_please_upgrade_your_installation'; + // const link = 'https://go.rocket.chat/i/mongodb-deprecated'; + + // if (!(await Users.bannerExistsById(id))) { + // if (skipMongoDbDeprecationBanner || process.env.TEST_MODE) { + // return; + // } + // sendMessagesToAdmins({ + // msgs: async ({ adminUser }) => [ + // { + // msg: `*${i18n.t(title, adminUser.language)}*\n${i18n.t(text, { postProcess: 'sprintf', sprintf: [mongoVersion] }, adminUser.language)}\n${link}`, + // }, + // ], + // banners: [ + // { + // id, + // priority: 100, + // title, + // text, + // textArguments: [mongoVersion], + // modifiers: ['danger'], + // link, + // }, + // ], + // }); + // } + } + }, 100); +}); diff --git a/apps/meteor/server/tracing.ts b/apps/meteor/server/tracing.ts new file mode 100644 index 0000000..8496ce1 --- /dev/null +++ b/apps/meteor/server/tracing.ts @@ -0,0 +1,5 @@ +import { startTracing } from '@rocket.chat/tracing'; + +import { client } from './database/utils'; + +startTracing({ service: 'core', db: client }); diff --git a/apps/meteor/server/ufs/index.ts b/apps/meteor/server/ufs/index.ts new file mode 100644 index 0000000..34f6daa --- /dev/null +++ b/apps/meteor/server/ufs/index.ts @@ -0,0 +1,7 @@ +import { UploadFS as UFS } from './ufs'; +import './ufs-methods'; +import './ufs-server'; +import './ufs-gridfs'; +import './ufs-local'; + +export const UploadFS = UFS; diff --git a/apps/meteor/server/ufs/ufs-config.ts b/apps/meteor/server/ufs/ufs-config.ts new file mode 100644 index 0000000..cce931b --- /dev/null +++ b/apps/meteor/server/ufs/ufs-config.ts @@ -0,0 +1,56 @@ +type ConfigOptions = { + https?: boolean; + simulateUploadSpeed?: number; + storesPath?: string; + tmpDir?: string; + tmpDirPermissions?: string; +}; + +type RequiredConfigOptions = Required; + +export class Config { + public https: RequiredConfigOptions['https']; + + public simulateUploadSpeed: RequiredConfigOptions['simulateUploadSpeed']; + + public storesPath: RequiredConfigOptions['storesPath']; + + public tmpDir: RequiredConfigOptions['tmpDir']; + + public tmpDirPermissions: RequiredConfigOptions['tmpDirPermissions']; + + constructor(options: ConfigOptions = {}) { + // Default options + options = { + https: false, + simulateUploadSpeed: 0, + storesPath: 'ufs', + tmpDir: '/tmp/ufs', + tmpDirPermissions: '0700', + ...options, + }; + + // Check options + if (typeof options.https !== 'boolean') { + throw new TypeError('Config: https is not a function'); + } + if (typeof options.simulateUploadSpeed !== 'number') { + throw new TypeError('Config: simulateUploadSpeed is not a number'); + } + if (typeof options.storesPath !== 'string') { + throw new TypeError('Config: storesPath is not a string'); + } + if (typeof options.tmpDir !== 'string') { + throw new TypeError('Config: tmpDir is not a string'); + } + if (typeof options.tmpDirPermissions !== 'string') { + throw new TypeError('Config: tmpDirPermissions is not a string'); + } + + this.https = options.https; + this.simulateUploadSpeed = options.simulateUploadSpeed; + this.storesPath = options.storesPath; + this.tmpDir = options.tmpDir; + this.tmpDirPermissions = options.tmpDirPermissions; + } +} diff --git a/apps/meteor/server/ufs/ufs-filter.ts b/apps/meteor/server/ufs/ufs-filter.ts new file mode 100644 index 0000000..5cc85cf --- /dev/null +++ b/apps/meteor/server/ufs/ufs-filter.ts @@ -0,0 +1,143 @@ +import type { IUpload } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; +import type { OptionalId } from 'mongodb'; + +type IFilterOptions = { + contentTypes?: string[]; + extensions?: string[]; + minSize?: number; + maxSize?: number; + onCheck?: (file: IUpload, content?: Buffer | string) => Promise; + invalidFileError?: () => Meteor.Error; + fileTooSmallError?: (fileSize: number, minFileSize: number) => Meteor.Error; + fileTooLargeError?: (fileSize: number, maxFileSize: number) => Meteor.Error; + invalidFileExtension?: (fileExtension: string, allowedExtensions: string[]) => Meteor.Error; + invalidFileType?: (fileType: string | undefined, allowedContentTypes: string[]) => Meteor.Error; +}; + +export class Filter { + private options: Required; + + constructor(options: IFilterOptions) { + this.options = { + contentTypes: [], + extensions: [], + minSize: 1, + maxSize: 0, + invalidFileError: () => new Meteor.Error('invalid-file', 'File is not valid'), + fileTooSmallError: (fileSize, minFileSize) => + new Meteor.Error('file-too-small', `File size (size = ${fileSize}) is too small (min = ${minFileSize})`), + fileTooLargeError: (fileSize, maxFileSize) => + new Meteor.Error('file-too-large', `File size (size = ${fileSize}) is too large (max = ${maxFileSize})`), + invalidFileExtension: (fileExtension, allowedExtensions) => + new Meteor.Error('invalid-file-extension', `File extension "${fileExtension}" is not accepted (${allowedExtensions})`), + invalidFileType: (fileType, allowedContentTypes) => + new Meteor.Error('invalid-file-type', `File type "${fileType}" is not accepted (${allowedContentTypes})`), + onCheck: this.onCheck, + ...options, + }; + + // Check options + if (this.options.contentTypes && !(this.options.contentTypes instanceof Array)) { + throw new TypeError('Filter: contentTypes is not an Array'); + } + if (this.options.extensions && !(this.options.extensions instanceof Array)) { + throw new TypeError('Filter: extensions is not an Array'); + } + if (typeof this.options.minSize !== 'number') { + throw new TypeError('Filter: minSize is not a number'); + } + if (typeof this.options.maxSize !== 'number') { + throw new TypeError('Filter: maxSize is not a number'); + } + if (this.options.onCheck && typeof this.options.onCheck !== 'function') { + throw new TypeError('Filter: onCheck is not a function'); + } + + if (typeof this.options.onCheck === 'function') { + this.onCheck = this.options.onCheck; + } + } + + async check(file: Omit, '_updatedAt'>, content?: Buffer | string) { + let error = null; + if (typeof file !== 'object' || !file) { + error = this.options.invalidFileError(); + } + // Check size + const fileSize = file.size || 0; + const minSize = this.getMinSize(); + if (fileSize <= 0 || fileSize < minSize) { + error = this.options.fileTooSmallError(fileSize, minSize); + } + const maxSize = this.getMaxSize(); + if (maxSize > 0 && fileSize > maxSize) { + error = this.options.fileTooLargeError(fileSize, maxSize); + } + // Check extension + const allowedExtensions = this.getExtensions(); + const fileExtension = file.extension || ''; + if (allowedExtensions.length && !allowedExtensions.includes(fileExtension)) { + error = this.options.invalidFileExtension(fileExtension, allowedExtensions); + } + // Check content type + const allowedContentTypes = this.getContentTypes(); + const fileTypes = file.type; + if (allowedContentTypes.length && !this.isContentTypeInList(fileTypes, allowedContentTypes)) { + error = this.options.invalidFileType(fileTypes, allowedContentTypes); + } + // Apply custom check + if (typeof this.onCheck === 'function' && !(await this.onCheck(file, content))) { + error = new Meteor.Error('invalid-file', 'File does not match filter'); + } + + if (error) { + throw error; + } + } + + getContentTypes() { + return this.options.contentTypes; + } + + getExtensions() { + return this.options.extensions; + } + + getMaxSize() { + return this.options.maxSize; + } + + getMinSize() { + return this.options.minSize; + } + + isContentTypeInList(type: string | undefined, list: string[]) { + if (typeof type === 'string' && list instanceof Array) { + if (list.includes(type)) { + return true; + } + const wildCardGlob = '/*'; + const wildcards = list.filter((item) => item.indexOf(wildCardGlob) > 0); + + if (wildcards.includes(type.replace(/(\/.*)$/, wildCardGlob))) { + return true; + } + } + return false; + } + + async isValid(file: IUpload) { + let result = true; + try { + await this.check(file); + } catch (err) { + result = false; + } + return result; + } + + async onCheck(_file: Omit, '_updatedAt'>, _content?: Buffer | string) { + return true; + } +} diff --git a/apps/meteor/server/ufs/ufs-gridfs.ts b/apps/meteor/server/ufs/ufs-gridfs.ts new file mode 100644 index 0000000..f93f116 --- /dev/null +++ b/apps/meteor/server/ufs/ufs-gridfs.ts @@ -0,0 +1,92 @@ +import type { IUpload } from '@rocket.chat/core-typings'; +import { MongoInternals } from 'meteor/mongo'; +import { NpmModuleMongodb } from 'meteor/npm-mongo'; +import type { ObjectId } from 'mongodb'; + +import { UploadFS } from './ufs'; +import type { StoreOptions } from './ufs-store'; + +type GridFSStoreOptions = StoreOptions & { + chunkSize: number; + collectionName: string; +}; + +export class GridFSStore extends UploadFS.Store { + protected chunkSize: number; + + protected collectionName: string; + + constructor(options: GridFSStoreOptions) { + // Default options + options = Object.assign( + { + chunkSize: 1024 * 255, + collectionName: 'uploadfs', + }, + options, + ); + + // Check options + if (typeof options.chunkSize !== 'number') { + throw new TypeError('GridFSStore: chunkSize is not a number'); + } + if (typeof options.collectionName !== 'string') { + throw new TypeError('GridFSStore: collectionName is not a string'); + } + + super(options); + + this.chunkSize = options.chunkSize; + this.collectionName = options.collectionName; + + // const mongo = MongoInternals.NpmModule; + const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + const mongoStore = new NpmModuleMongodb.GridFSBucket(db as any, { + bucketName: options.collectionName, + chunkSizeBytes: this.chunkSize, + }); + + this.delete = async function (fileId) { + const collectionName = `${options.collectionName}.files`; + const file = await db.collection(collectionName).findOne({ _id: fileId as any }); + + if (file) { + await mongoStore.delete(fileId as unknown as ObjectId); + } + }; + + this.getReadStream = async function (fileId: string, _file: IUpload, options?: { end?: number }) { + options = Object.assign({}, options); + // https://mongodb.github.io/node-mongodb-native/4.4/interfaces/GridFSBucketReadStreamOptionsWithRevision.html#end + // according to the mongodb doc, the end is 0-based offset in bytes to stop streaming before -<< BEFORE + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range + // an integer in the given unit indicating the end position (zero-indexed & inclusive) of the requested range. -<< inclusive + // so before there always one byte miss, then browser will send a start=end request, with case fail to get the DB's last byte + // this will leads to audio's duration Infinite and keep waiting... + if (options?.end) { + options.end += 1; + } + return mongoStore.openDownloadStream(fileId as unknown as ObjectId, options); + }; + + this.getWriteStream = async function (fileId: string, file: IUpload, _options?: any) { + const writeStream = mongoStore.openUploadStreamWithId(fileId as unknown as ObjectId, fileId, { + chunkSizeBytes: this.chunkSize, + contentType: file.type, + }); + let finished = false; + writeStream.on('finish', () => { + finished = true; + }); + writeStream.on('close', () => { + if (!finished) { + writeStream.emit('finish'); + } + }); + return writeStream; + }; + } +} + +// Add store to UFS namespace +UploadFS.store.GridFS = GridFSStore; diff --git a/apps/meteor/server/ufs/ufs-local.spec.ts b/apps/meteor/server/ufs/ufs-local.spec.ts new file mode 100644 index 0000000..3b95174 --- /dev/null +++ b/apps/meteor/server/ufs/ufs-local.spec.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; + +import { expect } from 'chai'; +import { before, beforeEach, afterEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; + +// import { Config } from './ufs-config'; +// import { UploadFS } from './ufs'; +import type { LocalStore as LocalStoreClass } from './ufs-local'; + +const fakeCollection = { + removeById: sinon.stub(), + findOne: sinon.stub(), +}; + +// Proxyquire chain to mock all Meteor dependencies +const ufsFilterMock = proxyquire.noCallThru().load('./ufs-filter', { + 'meteor/meteor': {}, + 'meteor/check': {}, + 'meteor/mongo': {}, + 'meteor/npm-mongo': {}, +}); + +const { UploadFS } = proxyquire.noCallThru().load('./ufs', { + 'meteor/meteor': {}, + 'meteor/check': {}, + 'meteor/mongo': {}, + 'meteor/npm-mongo': {}, + './ufs-filter': ufsFilterMock, + './ufs-store': { + Store: Object, + }, +}); + +// Create the UploadFS mock object as a callable function with properties +// function UploadFS() { +// return UploadFS; +// } +// Object.assign(UploadFS, { +// store: {}, +// config: new Config(), +// addStore: sinon.stub(), +// getStore: sinon.stub().returns(undefined), +// getTempFilePath: sinon.stub().returns('/tmp/mockfile'), +// }); +// The module must export { UploadFS: ... } +const ufsMock = { UploadFS }; +const ufsMockModule = { UploadFS }; +const ufsIndexMock = { UploadFS }; + +const ufsStoreMockRaw = proxyquire.noCallThru().load('./ufs-store', { + 'meteor/meteor': {}, + 'meteor/check': {}, + 'meteor/mongo': {}, + 'meteor/npm-mongo': {}, + './ufs': ufsMockModule, + './ufs-filter': ufsFilterMock, + './index': ufsIndexMock, +}); +// Patch ufsStoreMock to export all possible ways (default, named, etc) +const ufsStoreMock = Object.assign({}, ufsStoreMockRaw, { default: ufsStoreMockRaw, ufsStoreMock: ufsStoreMockRaw }); +const localStoreProxy = proxyquire.noCallThru().load('./ufs-local', { + // 'mkdirp': sinon.stub(), + 'meteor/meteor': {}, + 'meteor/check': {}, + 'meteor/mongo': {}, + 'meteor/npm-mongo': {}, + './ufs': ufsMock, + './ufs-store': ufsStoreMock, + './index': ufsIndexMock, +}); + +const { LocalStore } = localStoreProxy as { + LocalStore: typeof LocalStoreClass; +}; + +describe('LocalStore', () => { + let store: LocalStoreClass; + let unlinkStub: SinonStub; + + before(() => { + fakeCollection.removeById.resolves(); + fakeCollection.findOne.resolves({ _id: 'test', name: 'file.txt' }); + store = new LocalStore({ name: 'test', collection: fakeCollection as any, path: '/tmp/ufs-local' }); + }); + + beforeEach(() => { + unlinkStub = sinon.stub(fs.promises, 'unlink').resolves(); + fakeCollection.removeById.resetHistory(); + fakeCollection.findOne.resetHistory(); + }); + + afterEach(() => { + unlinkStub.restore(); + }); + + it('should not throw if file does not exist (ENOENT)', async () => { + unlinkStub.rejects(Object.assign(new Error('not found'), { code: 'ENOENT' })); + await expect(store.delete('test')).to.be.fulfilled; + // unlink is called twice: once for the temp file, once for the actual file + expect(unlinkStub.calledTwice).to.be.true; + }); + + it('should throw if unlink fails with non-ENOENT error', async () => { + unlinkStub.rejects(Object.assign(new Error('fail'), { code: 'EACCES' })); + await expect(store.delete('test')).to.be.rejectedWith('fail'); + }); + + it('should call findOne to get file info', async () => { + await store.delete('test'); + expect(fakeCollection.findOne.calledWith('test')).to.be.true; + }); +}); diff --git a/apps/meteor/server/ufs/ufs-local.ts b/apps/meteor/server/ufs/ufs-local.ts new file mode 100644 index 0000000..c18f4f4 --- /dev/null +++ b/apps/meteor/server/ufs/ufs-local.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { isNativeError } from 'node:util/types'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import mkdirp from 'mkdirp'; + +import { UploadFS } from './ufs'; +import type { StoreOptions } from './ufs-store'; +import { Store } from './ufs-store'; + +type LocalStoreOptions = StoreOptions & { + mode?: string; + path?: string; + writeMode?: number; +}; + +export class LocalStore extends Store { + protected getPath: (file: string) => string; + + constructor(options: LocalStoreOptions) { + // Default options + options = { + mode: '0744', + path: 'ufs/uploads', + writeMode: 0o744, + ...options, + }; + + // Check options + if (typeof options.mode !== 'string') { + throw new TypeError('LocalStore: mode is not a string'); + } + if (typeof options.path !== 'string') { + throw new TypeError('LocalStore: path is not a string'); + } + if (typeof options.writeMode !== 'number') { + throw new TypeError('LocalStore: writeMode is not a string'); + } + + super(options); + + // Private attributes + const { mode } = options; + const { path } = options; + const { writeMode } = options; + + fs.stat(path, (err) => { + if (err) { + // Create the directory + mkdirp(path, { mode }) + .then(() => { + console.info(`LocalStore: store created at ${path}`); + }) + .catch((err) => { + console.error(`LocalStore: cannot create store at ${path} (${err.message})`); + }); + } else { + // Set directory permissions + fs.chmod(path, mode, (err) => { + err && console.error(`LocalStore: cannot set store permissions ${mode} (${err.message})`); + }); + } + }); + + this.getPath = function (file) { + return path + (file ? `/${file}` : ''); + }; + + this.delete = async (fileId, options) => { + const path = await this.getFilePath(fileId); + try { + await unlink(path); + } catch (error) { + if (!isNativeError(error) || !('code' in error) || !(error.code === 'ENOENT')) { + throw error; + } + } + await this.removeById(fileId, { session: options?.session }, true); + }; + + this.getReadStream = async (fileId: string, file: IUpload, options?: { start?: number; end?: number }) => { + options = Object.assign({}, options); + return fs.createReadStream(await this.getFilePath(fileId, file), { + flags: 'r', + encoding: undefined, + autoClose: true, + start: options.start, + end: options.end, + }); + }; + + this.getWriteStream = async (fileId: string, file: IUpload, options?: { start?: number }) => { + options = Object.assign({}, options); + return fs.createWriteStream(await this.getFilePath(fileId, file), { + flags: 'a', + encoding: undefined, + mode: writeMode, + start: options.start, + }); + }; + } + + override async getFilePath(fileId: string, fileParam?: IUpload): Promise { + const file = fileParam || (await this.getCollection().findOne(fileId, { projection: { extension: 1 } })); + return (file && this.getPath(fileId + (file.extension ? `.${file.extension}` : ''))) || ''; + } +} + +// Add store to UFS namespace +UploadFS.store.Local = LocalStore; diff --git a/apps/meteor/server/ufs/ufs-methods.ts b/apps/meteor/server/ufs/ufs-methods.ts new file mode 100644 index 0000000..ae68a01 --- /dev/null +++ b/apps/meteor/server/ufs/ufs-methods.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession } from 'mongodb'; + +import { UploadFS } from './ufs'; + +export async function ufsComplete(fileId: string, storeName: string, options?: { session?: ClientSession }): Promise { + check(fileId, String); + check(storeName, String); + + // Get store + const store = UploadFS.getStore(storeName); + if (!store) { + throw new Meteor.Error('invalid-store', 'Store not found'); + } + + const tmpFile = UploadFS.getTempFilePath(fileId); + + const removeTempFile = () => + fs.promises.unlink(tmpFile).catch(() => { + console.warn(`[ufsComplete] Failed to remove temp file: ${tmpFile}`); + }); + + return new Promise(async (resolve, reject) => { + try { + // todo check if temp file exists + + // Get file + const file = await store.getCollection().findOne({ _id: fileId }, { session: options?.session }); + + if (!file) { + throw new Meteor.Error('invalid-file', 'File is not valid'); + } + + // Validate file before moving to the store + await store.validate(file, { session: options?.session }); + + // Get the temp file + const rs = fs.createReadStream(tmpFile, { + flags: 'r', + encoding: undefined, + autoClose: true, + }); + + // Clean upload if error occurs + rs.on('error', (err) => { + console.error(err); + void store.removeById(fileId, { session: options?.session }); + reject(err); + }); + + // Save file in the store + await store.write( + rs, + fileId, + (err, file) => { + void removeTempFile(); + + if (err) { + return reject(err); + } + if (!file) { + return reject(new Error('Unknown error writing file')); + } + resolve(file); + }, + { session: options?.session }, + ); + } catch (err: any) { + // If write failed, remove the file + await store.removeById(fileId, { session: options?.session }); + reject(new Meteor.Error('ufs: cannot upload file', err)); + } + }); +} diff --git a/apps/meteor/server/ufs/ufs-mime.ts b/apps/meteor/server/ufs/ufs-mime.ts new file mode 100644 index 0000000..de42d6f --- /dev/null +++ b/apps/meteor/server/ufs/ufs-mime.ts @@ -0,0 +1,127 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2017 Karl STEIN + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +/** + * MIME types and extensions + */ +export const MIME: Record = { + // application + '7z': 'application/x-7z-compressed', + 'arc': 'application/octet-stream', + 'ai': 'application/postscript', + 'bin': 'application/octet-stream', + 'bz': 'application/x-bzip', + 'bz2': 'application/x-bzip2', + 'eps': 'application/postscript', + 'exe': 'application/octet-stream', + 'gz': 'application/x-gzip', + 'gzip': 'application/x-gzip', + 'js': 'application/javascript', + 'json': 'application/json', + 'ogx': 'application/ogg', + 'pdf': 'application/pdf', + 'ps': 'application/postscript', + 'psd': 'application/octet-stream', + 'rar': 'application/x-rar-compressed', + 'rev': 'application/x-rar-compressed', + 'swf': 'application/x-shockwave-flash', + 'tar': 'application/x-tar', + 'xhtml': 'application/xhtml+xml', + 'xml': 'application/xml', + 'zip': 'application/zip', + + // audio + 'aif': 'audio/aiff', + 'aifc': 'audio/aiff', + 'aiff': 'audio/aiff', + 'au': 'audio/basic', + 'flac': 'audio/flac', + 'midi': 'audio/midi', + 'mp2': 'audio/mpeg', + 'mp3': 'audio/mpeg', + 'mpa': 'audio/mpeg', + 'oga': 'audio/ogg', + 'ogg': 'audio/ogg', + 'opus': 'audio/ogg', + 'ra': 'audio/vnd.rn-realaudio', + 'spx': 'audio/ogg', + 'wav': 'audio/x-wav', + 'weba': 'audio/webm', + 'wma': 'audio/x-ms-wma', + + // image + 'avs': 'image/avs-video', + 'bmp': 'image/x-windows-bmp', + 'gif': 'image/gif', + 'ico': 'image/vnd.microsoft.icon', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpg', + 'mjpg': 'image/x-motion-jpeg', + 'pic': 'image/pic', + 'png': 'image/png', + 'svg': 'image/svg+xml', + 'tif': 'image/tiff', + 'tiff': 'image/tiff', + + // text + 'css': 'text/css', + 'csv': 'text/csv', + 'html': 'text/html', + 'txt': 'text/plain', + + // video + 'avi': 'video/avi', + 'dv': 'video/x-dv', + 'flv': 'video/x-flv', + 'mov': 'video/quicktime', + 'mp4': 'video/mp4', + 'mpeg': 'video/mpeg', + 'mpg': 'video/mpg', + 'ogv': 'video/ogg', + 'vdo': 'video/vdo', + 'webm': 'video/webm', + 'wmv': 'video/x-ms-wmv', + + // specific to vendors + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'odb': 'application/vnd.oasis.opendocument.database', + 'odc': 'application/vnd.oasis.opendocument.chart', + 'odf': 'application/vnd.oasis.opendocument.formula', + 'odg': 'application/vnd.oasis.opendocument.graphics', + 'odi': 'application/vnd.oasis.opendocument.image', + 'odm': 'application/vnd.oasis.opendocument.text-master', + 'odp': 'application/vnd.oasis.opendocument.presentation', + 'ods': 'application/vnd.oasis.opendocument.spreadsheet', + 'odt': 'application/vnd.oasis.opendocument.text', + 'otg': 'application/vnd.oasis.opendocument.graphics-template', + 'otp': 'application/vnd.oasis.opendocument.presentation-template', + 'ots': 'application/vnd.oasis.opendocument.spreadsheet-template', + 'ott': 'application/vnd.oasis.opendocument.text-template', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; diff --git a/apps/meteor/server/ufs/ufs-server.ts b/apps/meteor/server/ufs/ufs-server.ts new file mode 100644 index 0000000..665ea76 --- /dev/null +++ b/apps/meteor/server/ufs/ufs-server.ts @@ -0,0 +1,295 @@ +import fs from 'node:fs'; +import stream from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import URL from 'node:url'; +import zlib from 'node:zlib'; + +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import mkdirp from 'mkdirp'; + +import { UploadFS } from './ufs'; + +Meteor.startup(() => { + const path = UploadFS.config.tmpDir; + const mode = UploadFS.config.tmpDirPermissions; + + fs.stat(path, (err) => { + if (err) { + // Create the temp directory + mkdirp(path, { mode }) + .then(() => { + console.log(`ufs: temp directory created at "${path}"`); + }) + .catch((err) => { + console.error(`ufs: cannot create temp directory at "${path}" (${err.message})`); + }); + } else { + // Set directory permissions + fs.chmod(path, mode, (err) => { + err && console.error(`ufs: cannot set temp directory permissions ${mode} (${err.message})`); + }); + } + }); +}); + +// Listen HTTP requests to serve files +WebApp.connectHandlers.use(async (req, res, next) => { + // Quick check to see if request should be caught + if (!req.url?.includes(`/${UploadFS.config.storesPath}/`)) { + next(); + return; + } + + // Remove store path + const parsedUrl = URL.parse(req.url, true); + const path = parsedUrl.pathname?.substr(UploadFS.config.storesPath.length + 1); + + if (!path) { + next(); + return; + } + + const allowCORS = () => { + // res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + res.setHeader('Access-Control-Allow-Methods', 'POST'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + }; + + if (req.method === 'OPTIONS') { + const regExp = new RegExp('^/([^/?]+)/([^/?]+)$'); + const match = regExp.exec(path); + + // Request is not valid + if (match === null) { + res.writeHead(400); + res.end(); + return; + } + + // Get store + const store = UploadFS.getStore(match[1]); + if (!store) { + res.writeHead(404); + res.end(); + return; + } + + // If a store is found, go ahead and allow the origin + allowCORS(); + + next(); + } else if (req.method === 'POST') { + res.writeHead(404); + res.end(); + } else if (req.method === 'GET') { + // Get store, file Id and file name + const regExp = new RegExp('^/([^/?]+)/([^/?]+)(?:/([^/?]+))?$'); + const match = regExp.exec(path); + + // Avoid 504 Gateway timeout error + // if file is not handled by UploadFS. + if (match === null) { + next(); + return; + } + + // Get store + const storeName = match[1]; + const store = UploadFS.getStore(storeName); + + if (!store) { + res.writeHead(404); + res.end(); + return; + } + + if (store.onRead !== null && store.onRead !== undefined && typeof store.onRead !== 'function') { + console.error(`ufs: Store.onRead is not a function in store "${storeName}"`); + res.writeHead(500); + res.end(); + return; + } + + // Remove file extension from file Id + const index = match[2].indexOf('.'); + const fileId = index !== -1 ? match[2].substr(0, index) : match[2]; + + // Get file from database + const file = await store.getCollection().findOne({ _id: fileId }); + if (!file) { + res.writeHead(404); + res.end(); + return; + } + + try { + // Check if the file can be accessed + if ((await store.onRead.call(store, fileId, file, req, res)) !== false) { + const options: { + start?: number; + end?: number; + } = {}; + let status = 200; + + // Prepare response headers + const headers: Record = { + 'Content-Type': file.type, + 'Content-Length': file.size, + }; + + // Add ETag header + if (typeof file.etag === 'string') { + headers.ETag = file.etag; + } + + // Add Last-Modified header + if (file.modifiedAt instanceof Date) { + headers['Last-Modified'] = file.modifiedAt.toUTCString(); + } else if (file.uploadedAt instanceof Date) { + headers['Last-Modified'] = file.uploadedAt.toUTCString(); + } + + // Parse request headers + if (typeof req.headers === 'object') { + // Compare ETag + if (req.headers['if-none-match']) { + if (file.etag === req.headers['if-none-match']) { + res.writeHead(304); // Not Modified + res.end(); + return; + } + } + + // Compare file modification date + if (req.headers['if-modified-since']) { + const modifiedSince = new Date(req.headers['if-modified-since']); + + if ( + (file.modifiedAt instanceof Date && file.modifiedAt > modifiedSince) || + (file.uploadedAt instanceof Date && file.uploadedAt > modifiedSince) + ) { + res.writeHead(304); // Not Modified + res.end(); + return; + } + } + + // Support range request + if (typeof req.headers.range === 'string') { + const { range } = req.headers; + + // Range is not valid + if (!range) { + res.writeHead(416); + res.end(); + return; + } + + const total = file.size || 0; + const unit = range.substr(0, range.indexOf('=')); + + if (unit !== 'bytes') { + res.writeHead(416); + res.end(); + return; + } + + const ranges = range + .substr(unit.length) + .replace(/[^0-9\-,]/, '') + .split(','); + + if (ranges.length > 1) { + // todo: support multipart ranges: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests + } else { + const r = ranges[0].split('-'); + const start = parseInt(r[0], 10); + const end = r[1] ? parseInt(r[1], 10) : total - 1; + + // Range is not valid + if (start < 0 || end >= total || start > end) { + res.writeHead(416); + res.end(); + return; + } + + // Update headers + headers['Content-Range'] = `bytes ${start}-${end}/${total}`; + headers['Content-Length'] = end - start + 1; + options.start = start; + options.end = end; + } + status = 206; // partial content + } + } else { + headers['Accept-Ranges'] = 'bytes'; + } + + // Open the file stream + const rs = await store.getReadStream(fileId, file, options); + const ws = new stream.PassThrough(); + + rs.on('error', (err) => { + store.onReadError.call(store, err, fileId, file); + res.end(); + }); + ws.on('error', (err) => { + store.onReadError.call(store, err, fileId, file); + res.end(); + }); + ws.on('close', () => { + // Close output stream at the end + ws.emit('end'); + }); + + // Transform stream + store.transformRead(rs, ws, fileId, file, req, headers); + + // Parse request headers + if (typeof req.headers === 'object') { + // Compress data using if needed (ignore audio/video as they are already compressed) + if (typeof req.headers['accept-encoding'] === 'string' && (!file.type || !/^(audio|video)/.test(file.type))) { + const accept = req.headers['accept-encoding']; + + // Compress with gzip + if (accept.match(/\bgzip\b/)) { + headers['Content-Encoding'] = 'gzip'; + delete headers['Content-Length']; + res.writeHead(status, headers); + await pipeline(ws, zlib.createGzip(), res); + return; + } + // Compress with deflate + if (accept.match(/\bdeflate\b/)) { + headers['Content-Encoding'] = 'deflate'; + delete headers['Content-Length']; + res.writeHead(status, headers); + await pipeline(ws, zlib.createDeflate(), res); + return; + } + } + } + + // Send raw data + if (!headers['Content-Encoding']) { + res.writeHead(status, headers); + await pipeline(ws, res); + } + } else { + res.end(); + } + } catch (err) { + console.error(`ufs: ${err instanceof Error ? err.message : String(err)}`); + if (!res.headersSent) { + res.writeHead(500); + } + if (!res.writableEnded) { + res.end(); + } + } + } else { + next(); + } +}); diff --git a/apps/meteor/server/ufs/ufs-store.ts b/apps/meteor/server/ufs/ufs-store.ts new file mode 100644 index 0000000..05a094b --- /dev/null +++ b/apps/meteor/server/ufs/ufs-store.ts @@ -0,0 +1,360 @@ +import fs from 'node:fs'; +import type * as http from 'node:http'; +import type stream from 'node:stream'; + +import type { IUpload } from '@rocket.chat/core-typings'; +import type { IBaseUploadsModel } from '@rocket.chat/model-typings'; +import type createServer from 'connect'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; +import type { ClientSession, OptionalId } from 'mongodb'; + +import { UploadFS } from '.'; +import { Filter } from './ufs-filter'; + +export type StoreOptions = { + collection?: IBaseUploadsModel; + filter?: Filter; + name: string; + onCopyError?: (err: any, fileId: string, file: IUpload) => void; + onFinishUpload?: (file: IUpload) => Promise; + onRead?: (fileId: string, file: IUpload, request: any, response: any) => Promise; + onReadError?: (err: any, fileId: string, file: IUpload) => void; + onValidate?: (file: IUpload, options?: { session?: ClientSession }) => Promise; + onWriteError?: (err: any, fileId: string, file: IUpload) => void; + transformRead?: ( + readStream: stream.Readable, + writeStream: stream.Writable, + fileId: string, + file: IUpload, + request: createServer.IncomingMessage, + headers?: Record, + ) => void; + transformWrite?: (readStream: stream.Readable, writeStream: stream.Writable, fileId: string, file: IUpload) => void; +}; + +export class Store { + protected options: StoreOptions; + + public copy: ( + fileId: string, + store: Store, + callback?: (err?: Error, copyId?: string, copy?: OptionalId, store?: Store) => void, + ) => Promise; + + public create: (file: Omit, '_updatedAt'>, options?: { session?: ClientSession }) => Promise; + + public write: ( + rs: stream.Readable, + fileId: string, + callback: (err?: Error, file?: IUpload) => void, + options?: { session?: ClientSession }, + ) => Promise; + + constructor(options: StoreOptions) { + options = { + onCopyError: this.onCopyError, + onFinishUpload: this.onFinishUpload, + onRead: this.onRead, + onReadError: this.onReadError, + onValidate: this.onValidate, + onWriteError: this.onWriteError, + ...options, + }; + + if (UploadFS.getStore(options.name)) { + throw new TypeError('Store: name already exists'); + } + if (options.onCopyError && typeof options.onCopyError !== 'function') { + throw new TypeError('Store: onCopyError is not a function'); + } + if (options.onFinishUpload && typeof options.onFinishUpload !== 'function') { + throw new TypeError('Store: onFinishUpload is not a function'); + } + if (options.onRead && typeof options.onRead !== 'function') { + throw new TypeError('Store: onRead is not a function'); + } + if (options.onReadError && typeof options.onReadError !== 'function') { + throw new TypeError('Store: onReadError is not a function'); + } + if (options.onWriteError && typeof options.onWriteError !== 'function') { + throw new TypeError('Store: onWriteError is not a function'); + } + if (options.transformRead && typeof options.transformRead !== 'function') { + throw new TypeError('Store: transformRead is not a function'); + } + if (options.transformWrite && typeof options.transformWrite !== 'function') { + throw new TypeError('Store: transformWrite is not a function'); + } + if (options.onValidate && typeof options.onValidate !== 'function') { + throw new TypeError('Store: onValidate is not a function'); + } + + // Public attributes + this.options = options; + + if (options.onCopyError) this.onCopyError = options.onCopyError; + if (options.onFinishUpload) this.onFinishUpload = options.onFinishUpload; + if (options.onRead) this.onRead = options.onRead; + if (options.onReadError) this.onReadError = options.onReadError; + if (options.onWriteError) this.onWriteError = options.onWriteError; + if (options.onValidate) this.onValidate = options.onValidate; + + // Add the store to the list + UploadFS.addStore(this); + + this.copy = async (fileId, store, callback) => { + check(fileId, String); + + if (!(store instanceof Store)) { + throw new TypeError('store is not an instance of UploadFS.Store'); + } + // Get original file + const file = await this.getCollection().findOne({ _id: fileId }); + if (!file) { + throw new Meteor.Error('file-not-found', 'File not found'); + } + // Silently ignore the file if it does not match filter + const filter = store.getFilter(); + if (filter instanceof Filter && !(await filter.isValid(file))) { + return; + } + + // Prepare copy + const { _id, url, ...copy } = file; + copy.originalStore = this.getName(); + copy.originalId = fileId; + + // Create the copy + const copyId = await store.create(copy); + + // Get original stream + const rs = await this.getReadStream(fileId, file); + + // Catch errors to avoid app crashing + rs.on('error', (err: Error) => { + callback?.call(this, err); + }); + + // Copy file data + await store.write(rs, copyId, (err) => { + if (err) { + void this.removeById(copyId); + this.onCopyError.call(this, err, fileId, file); + } + if (typeof callback === 'function') { + callback.call(this, err, copyId, copy, store); + } + }); + }; + + this.create = async (file, options) => { + check(file, Object); + file.store = this.options.name; // assign store to file + return (await this.getCollection().insertOne(file, { session: options?.session })).insertedId; + }; + + this.write = async (rs, fileId, callback, options) => { + const file = await this.getCollection().findOne({ _id: fileId }, { session: options?.session }); + if (!file) { + return callback(new Error('File not found')); + } + + const errorHandler = (err: Error) => { + this.onWriteError.call(this, err, fileId, file); + callback.call(this, err); + }; + + const finishHandler = async () => { + try { + // Set file attribute + file.complete = true; + file.etag = UploadFS.generateEtag(); + file.path = await this.getFileRelativeURL(fileId); + file.progress = 1; + file.token = this.generateToken(); + file.uploading = false; + file.uploadedAt = new Date(); + file.url = await this.getFileURL(fileId); + + // Execute callback + if (typeof this.onFinishUpload === 'function') { + await this.onFinishUpload.call(this, file); + } + + // Sets the file URL when file transfer is complete, + // this way, the image will loads entirely. + await this.getCollection().updateOne( + { _id: fileId }, + { + $set: { + complete: file.complete, + etag: file.etag, + path: file.path, + progress: file.progress, + size: file.size, + token: file.token, + uploading: file.uploading, + uploadedAt: file.uploadedAt, + url: file.url, + }, + }, + { session: options?.session }, + ); + + // Return file info + callback.call(this, undefined, file); + } catch (error) { + callback.call(this, error as Error); + } + }; + + const ws = await this.getWriteStream(fileId, file); + ws.on('error', errorHandler); + ws.once('finish', finishHandler); + + // Execute transformation + this.transformWrite(rs, ws, fileId, file); + }; + } + + async removeById(fileId: string, options?: { session?: ClientSession }, isDeleted = false): Promise { + // Delete the physical file in the store + if (!isDeleted) { + await this.delete(fileId); + } + + const tmpFile = UploadFS.getTempFilePath(fileId); + + // Delete the temp file + await fs.promises.unlink(tmpFile).catch((err) => { + err?.code !== 'ENOENT' && console.error(`ufs: cannot delete temp file at ${tmpFile} (${err.message})`); + }); + + await this.getCollection().removeById(fileId, { session: options?.session }); + } + + async delete(_fileId: string, _options?: { session?: ClientSession }): Promise { + throw new Error('delete is not implemented'); + } + + generateToken(pattern?: string) { + return (pattern || 'xyxyxyxyxy').replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + const s = v.toString(16); + return Math.round(Math.random()) ? s.toUpperCase() : s; + }); + } + + getCollection() { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.options.collection!; + } + + async getFilePath(_fileId: string, _file?: IUpload): Promise { + throw new Error('Store.getFilePath is not implemented'); + } + + async getFileRelativeURL(fileId: string) { + const file = await this.getCollection().findOne(fileId, { projection: { name: 1 } }); + return file ? this.getRelativeURL(`${fileId}/${file.name}`) : undefined; + } + + async getFileURL(fileId: string) { + const file = await this.getCollection().findOne(fileId, { projection: { name: 1 } }); + return file ? this.getURL(`${fileId}/${file.name}`) : undefined; + } + + getFilter() { + return this.options.filter; + } + + getName() { + return this.options.name; + } + + async getReadStream(_fileId: string, _file: IUpload, _options?: { start?: number; end?: number }): Promise { + throw new Error('Store.getReadStream is not implemented'); + } + + getRelativeURL(path: string) { + const rootUrl = Meteor.absoluteUrl().replace(/\/+$/, ''); + const rootPath = rootUrl.replace(/^[a-z]+:\/\/[^/]+\/*/gi, ''); + const storeName = this.getName(); + path = String(path).replace(/\/$/, '').trim(); + return encodeURI(`${rootPath}/${UploadFS.config.storesPath}/${storeName}/${path}`); + } + + getURL(path: string) { + const rootUrl = Meteor.absoluteUrl('', { secure: UploadFS.config.https }).replace(/\/+$/, ''); + const storeName = this.getName(); + path = String(path).replace(/\/$/, '').trim(); + return encodeURI(`${rootUrl}/${UploadFS.config.storesPath}/${storeName}/${path}`); + } + + async getRedirectURL(_file: IUpload, _forceDownload = false): Promise { + throw new Error('getRedirectURL is not implemented'); + } + + async getWriteStream(_fileId: string, _file: IUpload): Promise { + throw new Error('getWriteStream is not implemented'); + } + + onCopyError(err: Error, fileId: string, _file: IUpload) { + console.error(`ufs: cannot copy file "${fileId}" (${err.message})`, err); + } + + async onFinishUpload(_file: IUpload) { + // + } + + async onRead(_fileId: string, _file: IUpload, _request: createServer.IncomingMessage, _response: http.ServerResponse) { + return true; + } + + onReadError(err: Error, fileId: string, _file: IUpload) { + console.error(`ufs: cannot read file "${fileId}" (${err.message})`, err); + } + + async onValidate(_file: IUpload, _options?: { session?: ClientSession }) { + // + } + + onWriteError(err: Error, fileId: string, _file: IUpload) { + console.error(`ufs: cannot write file "${fileId}" (${err.message})`, err); + } + + transformRead( + readStream: stream.Readable, + writeStream: stream.Writable, + fileId: string, + file: IUpload, + request: createServer.IncomingMessage, + headers?: Record, + ) { + if (typeof this.options.transformRead === 'function') { + this.options.transformRead.call(this, readStream, writeStream, fileId, file, request, headers); + } else { + readStream.pipe(writeStream); + } + } + + transformWrite(readStream: stream.Readable, writeStream: stream.Writable, fileId: string, file: IUpload) { + if (typeof this.options.transformWrite === 'function') { + this.options.transformWrite.call(this, readStream, writeStream, fileId, file); + } else { + readStream.pipe(writeStream); + } + } + + async validate(file: IUpload, options?: { session?: ClientSession }) { + if (typeof this.onValidate === 'function') { + await this.onValidate(file, options); + } + } + + async getUrlExpiryTimeSpan(): Promise { + throw new Error('getUrlExpiryTimeSpan is not implemented'); + } +} diff --git a/apps/meteor/server/ufs/ufs.ts b/apps/meteor/server/ufs/ufs.ts new file mode 100644 index 0000000..5829980 --- /dev/null +++ b/apps/meteor/server/ufs/ufs.ts @@ -0,0 +1,51 @@ +import { Random } from '@rocket.chat/random'; + +import { Config } from './ufs-config'; +import { Filter } from './ufs-filter'; +import { MIME } from './ufs-mime'; +import { Store } from './ufs-store'; + +const stores: Record = {}; +const store: Record = {}; + +export const UploadFS = { + config: new Config(), + + store, + + addStore(store: Store) { + if (!(store instanceof Store)) { + throw new TypeError('ufs: store is not an instance of UploadFS.Store.'); + } + stores[store.getName()] = store; + }, + + generateEtag() { + return Random.id(); + }, + + getMimeType(extension: string) { + extension = extension.toLowerCase(); + return MIME[extension]; + }, + + getMimeTypes() { + return MIME; + }, + + getStore(name: string) { + return stores[name]; + }, + + getStores() { + return stores; + }, + + getTempFilePath(fileId: string) { + return `${this.config.tmpDir}/${fileId}`; + }, + + Config, + Filter, + Store, +}; diff --git a/apps/meteor/startRocketChat.ts b/apps/meteor/startRocketChat.ts new file mode 100644 index 0000000..196c37d --- /dev/null +++ b/apps/meteor/startRocketChat.ts @@ -0,0 +1,22 @@ +import { registerEEBroker } from './ee/server'; +import { enforceFipsLicense } from './ee/server/lib/license/enforceFipsLicense'; +import { startLicense } from './ee/server/lib/license/startup'; +import { startFederationService as startFederationMatrixService } from './ee/server/startup/federation'; + +const loadBeforeLicense = async () => { + await registerEEBroker(); +}; + +const loadAfterLicense = async () => { + await startFederationMatrixService(); +}; + +export const startRocketChat = async () => { + await loadBeforeLicense(); + + await startLicense(); + + enforceFipsLicense(); + + await loadAfterLicense(); +}; diff --git a/apps/meteor/startRocketChatFOSS.ts b/apps/meteor/startRocketChatFOSS.ts new file mode 100644 index 0000000..568f173 --- /dev/null +++ b/apps/meteor/startRocketChatFOSS.ts @@ -0,0 +1,4 @@ +// This file is used in FOSS builds of Rocket.Chat to replace the default startRocketChat.ts file, removing references to licensed code. +export const startRocketChat = async () => { + // Nothing to do here +}; diff --git a/apps/meteor/tests/data/api-data.ts b/apps/meteor/tests/data/api-data.ts new file mode 100644 index 0000000..6bfb977 --- /dev/null +++ b/apps/meteor/tests/data/api-data.ts @@ -0,0 +1,87 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { Path } from '@rocket.chat/rest-typings'; +import type { CallbackHandler, Response } from 'supertest'; +import supertest from 'supertest'; + +import { adminUsername, adminPassword } from './user'; + +export const apiUrl = process.env.TEST_API_URL || 'http://localhost:3000'; + +export const request = supertest(apiUrl); +const prefix = '/api/v1/'; + +export function wait(cb: () => void, time: number) { + return () => setTimeout(cb, time); +} + +const privateChannelName = `private-channel-test-${Date.now()}` as const; + +const username = 'user.test'; +const email = `${username}@rocket.chat`; + +export const apiUsername = `api${username}-${Date.now()}` as const; +export const apiEmail = `api${email}-${Date.now()}` as const; +export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}` as const; + +const roleNameUsers = `role-name-test-users-${Date.now()}` as const; +const roleNameSubscriptions = `role-name-test-subscriptions-${Date.now()}` as const; +const roleScopeUsers = 'Users' as const; +const roleScopeSubscriptions = 'Subscriptions' as const; +const roleDescription = `role-description-test-${Date.now()}` as const; + +export const apiRoleNameUsers = `api${roleNameUsers}` as const; +export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}` as const; +export const apiRoleScopeUsers = `${roleScopeUsers}` as const; +export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}` as const; +export const apiRoleDescription = `api${roleDescription}` as const; +export const reservedWords = ['admin', 'administrator', 'system', 'user'] as const; + +export const credentials: Credentials = { + 'X-Auth-Token': undefined, + 'X-User-Id': undefined, +} as unknown as Credentials; // FIXME + +export type PathWithoutPrefix = TPath extends `/v1/${infer U}` ? U : never; + +export function api>(path: TPath) { + return `${prefix}${path}` as const; +} + +export function methodCall(methodName: TMethodName) { + return api(`method.call/${methodName}`); +} + +export function methodCallAnon(methodName: TMethodName) { + return api(`method.callAnon/${methodName}`); +} + +export function log(res: Response) { + console.log((res as { req?: any }).req.path); // FIXME + console.log({ + body: res.body, + headers: res.headers, + }); +} + +let instanceId: string | undefined; + +export function getCredentials(done?: CallbackHandler) { + void request + .post(api('login')) + .send({ + user: adminUsername, + password: adminPassword, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + credentials['X-Auth-Token'] = res.body.data.authToken; + credentials['X-User-Id'] = res.body.data.userId; + instanceId = res.headers['x-instance-id']; + }) + .end(done); +} + +export function getInstanceId() { + return instanceId; +} diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md new file mode 100644 index 0000000..092b049 --- /dev/null +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -0,0 +1,669 @@ +# Test App Packages + +Includes pre-built app packages that are designed to test specific APIs exposed by the Apps-engine. + +DISCLAIMER: App related source code shown here are for testing purposes only, and don't reflect best practices or recommended implementation. + +## How to use + +In your tests, add a `before` step and call the `installLocalTestPackage` function, passing the path of your desired package. For instance: + +```javascript +import { appImplementsIPreFileUpload } from '../../data/apps/app-packages'; +import { installLocalTestPackage } from '../../data/apps/helper'; + +describe('My tests', () => { + before(async () => { + await installLocalTestPackage(appImplementsIPreFileUpload); + }); +}); +``` + +### Available apps + +#### IPreFileUpload handler + +File name: `file-upload-test_0.0.1.zip` + +An app that handles the `IPreFileUpload` event. If the file name starts with `"test-should-reject"`, the app will prevent the upload from happening. The error message will contain the contents of the uploaded file as evidence that the app could successfully read them. + +
+App source code + +```typescript +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { FileUploadNotAllowedException } from '@rocket.chat/apps-engine/definition/exceptions'; +import { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext'; + +export class TestIPreFileUpload extends App implements IPreFileUpload { + public async executePreFileUpload( + context: IFileUploadContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify: IModify, + ): Promise { + if (context.file.name.startsWith('test-should-reject')) { + console.log('[executePreFileUpload] Rejecting file which name starts with "test-should-reject"'); + throw new FileUploadNotAllowedException(`Test file rejected ${context.content.toString()}`); + } + console.log('[executePreFileUpload] Did not reject file'); + } +} +``` + +
+ +#### API Parameter Test + +File name: `api-parameter-test_0.0.1.zip` + +An app that provides a public API endpoint with URL parameters. The endpoint path is `/api/:param1/:param2/test` and returns the values of both parameters in the response. + +**Response format:** + +- Content-Type: `text/plain` +- Body: `Param1: , Param2: ` +- Status: 200 OK + +
+App source code + +**APIParameterTestApp.ts** + +```typescript +import { IAppAccessors, IConfigurationExtend, IEnvironmentRead, ILogger } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { TestEndpoint } from './TestEndpoint'; + +export class APIParameterTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new TestEndpoint(this)], + }); + } +} +``` + +**TestEndpoint.ts** + +```typescript +import { HttpStatusCode, IModify, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +export class TestEndpoint extends ApiEndpoint { + public path = 'api/:param1/:param2/test'; + + public async get(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify): Promise { + return { + content: `Param1: ${request.params.param1}, Param2: ${request.params.param2}`, + status: HttpStatusCode.OK, + headers: { + 'Content-Type': 'text/plain', + }, + }; + } +} +``` + +
+ +#### External ID Test (resolveVisitor API) + +File name: `external-id-test_0.0.1.zip` + +An app that tests the `ILivechatCreator.resolveVisitor()` and `ILivechatUpdater.updateVisitorExternalId()` APIs for resolving and updating livechat visitors by external identifiers. This is used to test the WhatsApp BSUID (Business Scoped User ID) support and progressive visitor enrichment. + +**Endpoints:** + +1. `POST /api/apps/public/:appId/resolve-visitor` - Resolve visitor by externalId with phone/email fallback +2. `POST /api/apps/public/:appId/update-external-id` - Update visitor's externalId for this app + +**Request body (resolve-visitor):** +```json +{ + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } }, + "phone": "+1234567890" +} +``` + +**Request body (update-external-id):** +```json +{ + "visitorId": "visitor-123", + "externalId": { "entityId": "bsuid-123", "metadata": { "username": "@johndoe" } } +} +``` + +**Response:** +- Returns the visitor if found/updated +- Returns `{ visitor: null }` if not found + +
+App source code + +**ExternalIdTestApp.ts** +```typescript +import { + IAppAccessors, + IConfigurationExtend, + ILogger, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { ResolveVisitorEndpoint } from './ResolveVisitorEndpoint'; +import { UpdateExternalIdEndpoint } from './UpdateExternalIdEndpoint'; + +export class ExternalIdTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + public override async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [new ResolveVisitorEndpoint(this), new UpdateExternalIdEndpoint(this)], + }); + } +} +``` + +**ResolveVisitorEndpoint.ts** +```typescript +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +export class ResolveVisitorEndpoint extends ApiEndpoint { + public override path = 'resolve-visitor'; + + public async post( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + modify: IModify, + _http: IHttp, + _persistence: IPersistence, + ): Promise { + const { externalId, phone, email } = request.content; + + let contactData: { phone: string } | { email: string } | undefined; + + if (phone) { + contactData = { phone }; + } else if (email) { + contactData = { email }; + } + + const visitor = await modify.getCreator().getLivechatCreator().resolveVisitor(externalId, contactData); + + return { + status: HttpStatusCode.OK, + content: { visitor: visitor || null }, + }; + } +} +``` + +**UpdateExternalIdEndpoint.ts** +```typescript +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +export class UpdateExternalIdEndpoint extends ApiEndpoint { + public override path = 'update-external-id'; + + public async post( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + modify: IModify, + _http: IHttp, + _persistence: IPersistence, + ): Promise { + const { visitorId, externalId } = request.content; + + if (!visitorId || !externalId) { + return { + status: HttpStatusCode.BAD_REQUEST, + content: { error: 'visitorId and externalId are required' }, + }; + } + + const visitor = await modify.getUpdater().getLivechatUpdater().updateVisitorExternalId(visitorId, externalId); + + return { + status: HttpStatusCode.OK, + content: { visitor: visitor || null }, + }; + } +} +``` + +
+ +#### Nested Requests simulation + +File name: `nested-requests_0.0.1.zip` + +An app that simulates a "nested request" scenario. It listens for `IPostMessageSent` events, and provides a slashcommand that sends a message. Executing the slashcommand will create a scenario where the event handler for `IPostMessageSent` will be triggered by the slashcommand executor handler. + +This situation used to cause logs for the originating handler (the slashcommand executor, in this case) to disappear, and only the logs for the nested request (`IPostMessageSent` handler, in this case) would be persisted to the database. + +
+App source code + +```typescript +export class NestedRequestsApp extends App implements IPostMessageSent { + public async executePostMessageSent( + message: IMessage, + _read: IRead, + _http: IHttp, + _persistence: IPersistence, + _modify: IModify, + ): Promise { + this.getLogger().debug('executed_post_message_sent', message.id); + } + + protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise { + configuration.slashCommands.provideSlashCommand( + new (class implements ISlashCommand { + public command = 'nest'; + public i18nParamsExample = 'nest'; + public i18nDescription = 'nest'; + public providesPreview = false; + + constructor(private readonly app: IApp) {} + + public async executor( + context: SlashCommandContext, + _read: IRead, + modify: IModify, + _http: IHttp, + _persis: IPersistence, + ): Promise { + const [execId] = context.getArguments(); + + this.app.getLogger().debug('slashcommand_triggered', execId); + + const mb = modify.getCreator().startMessage().setText(`nested_test_message ${execId}`).setRoom(context.getRoom()); + + const id = await modify.getCreator().finish(mb); + + this.app.getLogger().debug('slashcommand_message_sent', id); + } + })(this), + ); + } +} +``` + +
+ +#### Update Status Test + +File name: `update-status-test_0.0.1.zip` + +An app that provides two public API endpoints to test the `updateStatus` and `updateStatusText` bridge methods. A `username` parameter is required to specify the target user. + +**Endpoints:** + +- `POST /update-status` — Calls `updateStatus(user, statusText, status)`. Expects `{ username: string, status: string, statusText?: string }`. +- `POST /update-status-text` — Calls `updateStatusText(user, statusText)`. Expects `{ username: string, statusText: string }`. + +
+App source code + +**UpdateStatusTestApp.ts** +```typescript +import { + IAppAccessors, + IConfigurationExtend, + ILogger, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { UpdateStatusEndpoint } from './endpoints/UpdateStatusEndpoint'; +import { UpdateStatusTextEndpoint } from './endpoints/UpdateStatusTextEndpoint'; + +export class UpdateStatusTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + protected async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [ + new UpdateStatusEndpoint(this), + new UpdateStatusTextEndpoint(this), + ], + }); + } +} +``` + +**endpoints/UpdateStatusEndpoint.ts** +```typescript +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export class UpdateStatusEndpoint extends ApiEndpoint { + public path = 'update-status'; + + public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise { + const { status, statusText = '', username } = request.content || {}; + + if (!status) { + return { status: 400, content: 'status is required' }; + } + + if (!username) { + return { status: 400, content: 'username is required' }; + } + + const user = await read.getUserReader().getByUsername(username) as IUser; + + if (!user) { + return { status: 404, content: 'User not found' }; + } + + await modify.getUpdater().getUserUpdater().updateStatus(user, statusText, status); + + return this.success(JSON.stringify({ status, statusText })); + } +} +``` + +**endpoints/UpdateStatusTextEndpoint.ts** +```typescript +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export class UpdateStatusTextEndpoint extends ApiEndpoint { + public path = 'update-status-text'; + + public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise { + const { statusText, username } = request.content || {}; + + if (typeof statusText !== 'string') { + return { status: 400, content: 'statusText is required' }; + } + + if (!username) { + return { status: 400, content: 'username is required' }; + } + + const user = await read.getUserReader().getByUsername(username) as IUser; + + if (!user) { + return { status: 404, content: 'User not found' }; + } + + await modify.getUpdater().getUserUpdater().updateStatusText(user, statusText); + + return this.success(JSON.stringify({ statusText })); + } +} +``` + +
+ +#### Message Reaction Test + +File name: `message-updater-test_0.0.1.zip` + +An app used to test the message reaction updates. Provides a `/msg-update` slashcommand that takes an action of `'add' | 'remove'` and a message id, then adds or removes a reaction in the target message accordingly using the App's bot user. + +
+App source code + +```typescript +export class MessageUpdaterTestApp extends App { + protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise { + await configuration.slashCommands.provideSlashCommand(new class UpdateCommand implements ISlashCommand { + command = 'msg-update'; + i18nDescription = 'msg-update'; + i18nParamsExample = 'msg-update'; + providesPreview = false; + + constructor(private readonly app: App) { } + + public async executor(context: SlashCommandContext, read: IRead, modify: IModify, _http: IHttp, _persis: IPersistence) { + const [action, msgId] = context.getArguments() as ['add' | 'remove', string]; + + const user = await read.getUserReader().getAppUser(); + + if (!user) { + this.app.getLogger().error(`Couldn't find app user`); + return; + } + + if (action === 'add') { + await modify.getUpdater().getMessageUpdater().addReaction(msgId, user.id, ':+1:'); + this.app.getLogger().debug(`Added reaction 👍 to message ${msgId}`); + } else { + await modify.getUpdater().getMessageUpdater().removeReaction(msgId, user.id, ':+1:'); + this.app.getLogger().debug(`Removed reaction 👍 from message ${msgId}`); + } + } + }(this)); + } +} +``` + +#### UIKit Room Context Test + +File name: `uikit-room-test_0.0.1.zip` + +An app that validates room context propagation and interaction data in UIKit interactions across all surfaces (message, modal, contextual bar). It provides a slash command (`open-uikit-room-test-modal`) with three actions: + +- `modal` - Opens a modal with a button and submit action +- `ctx` - Opens a contextual bar with a button and submit action +- `message` - Sends a message with a button + +It also takes a second argument with a random "seed" used to identify the current execution context. + +Each interaction handler (`executeBlockActionHandler`, `executeViewSubmitHandler`, `executeViewClosedHandler`) logs detailed data including room ID, user name, view id, block value, triggerId, actionId, and container type. These logs are used by Playwright e2e tests to verify that interaction data is correctly propagated. + +
+App source code + +```typescript +import { + IAppAccessors, + IConfigurationExtend, + IEnvironmentRead, + IHttp, + ILogger, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { + IUIKitInteractionHandler, + IUIKitResponse, + UIKitBlockInteractionContext, + UIKitViewCloseInteractionContext, + UIKitViewSubmitInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import { UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit'; + +export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + async [AppMethod.UIKIT_BLOCK_ACTION]( + context: UIKitBlockInteractionContext, + _read: IRead, + _http: IHttp, + _persistence: IPersistence, + _modify: IModify, + ): Promise { + const data = context.getInteractionData(); + this.getLogger().debug('block_action_room', data.room ? data.room.id : 'no-room'); + this.getLogger().debug('block_action_user', data.user ? data.user.username : 'no-user'); + this.getLogger().debug('block_action_triggerId', data.triggerId || 'no-triggerId'); + this.getLogger().debug('block_action_actionId', data.actionId || 'no-actionId'); + this.getLogger().debug('block_action_value', data.value); + this.getLogger().debug('block_action_container', data.container ? data.container.type : 'no-container'); + return context.getInteractionResponder().successResponse(); + } + + async [AppMethod.UIKIT_VIEW_SUBMIT]( + context: UIKitViewSubmitInteractionContext, + _read: IRead, + _http: IHttp, + _persistence: IPersistence, + _modify: IModify, + ): Promise { + const data = context.getInteractionData(); + this.getLogger().debug('view_submit_room', data.room ? data.room.id : 'no-room'); + this.getLogger().debug('view_submit_user', data.user ? data.user.username : 'no-user'); + this.getLogger().debug('view_submit_id', data.view.id); + this.getLogger().debug('view_submit_triggerId', data.triggerId || 'no-triggerId'); + this.getLogger().debug('view_submit_actionId', data.actionId || 'no-actionId'); + return context.getInteractionResponder().successResponse(); + } + + async [AppMethod.UIKIT_VIEW_CLOSE]( + context: UIKitViewCloseInteractionContext, + _read: IRead, + _http: IHttp, + _persistence: IPersistence, + _modify: IModify, + ): Promise { + const data = context.getInteractionData(); + this.getLogger().debug('view_closed_room', data.room ? data.room.id : 'no-room'); + this.getLogger().debug('view_closed_user', data.user ? data.user.username : 'no-user'); + this.getLogger().debug('view_closed_id', data.view.id); + this.getLogger().debug('view_closed_triggerId', data.triggerId || 'no-triggerId'); + this.getLogger().debug('view_closed_actionId', data.actionId || 'no-actionId'); + return context.getInteractionResponder().successResponse(); + } + + protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise { + await configuration.slashCommands.provideSlashCommand({ + command: 'open-uikit-room-test-modal', + i18nDescription: '', + i18nParamsExample: '', + providesPreview: false, + executor: async (context, _read, modify, _http, _persis) => { + const [action, seed] = context.getArguments(); + const triggerId = context.getTriggerId(); + + if (!triggerId) { + this.getLogger().error('No triggerId provided to slash command'); + return; + } + + if (action === 'modal') { + await modify.getUiController().openSurfaceView( + { + id: `modal-${seed}`, + title: { type: 'plain_text', text: 'UIKit Room Test Modal' }, + type: UIKitSurfaceType.MODAL, + blocks: [ + { + type: 'section', + text: { type: 'plain_text', text: 'This modal tests room context in UIKit interactions.' }, + }, + { + type: 'actions', + elements: [{ type: 'button', value: seed, actionId: 'modal-button', text: { type: 'plain_text', text: 'Click!' } }], + }, + ], + submit: { + type: 'button', + actionId: `modal-submit-${seed}`, + text: { type: 'plain_text', text: 'Submit' }, + }, + }, + { triggerId }, + context.getSender(), + ); + } + + if (action === 'ctx') { + await modify.getUiController().openSurfaceView( + { + id: `ctx-${seed}`, + title: { type: 'plain_text', text: 'UIKit Room Test Contextual Bar' }, + type: UIKitSurfaceType.CONTEXTUAL_BAR, + blocks: [ + { + type: 'section', + text: { type: 'plain_text', text: 'This contextual bar tests room context.' }, + }, + { + type: 'actions', + elements: [{ type: 'button', value: seed, actionId: 'ctx-button', text: { type: 'plain_text', text: 'Click!' } }], + }, + ], + submit: { + type: 'button', + actionId: `ctxbar-submit-${seed}`, + text: { type: 'plain_text', text: 'Submit' }, + }, + }, + { triggerId }, + context.getSender(), + ); + } + + if (action === 'message') { + const msg = modify + .getCreator() + .startMessage() + .setRoom(context.getRoom()) + .setBlocks([ + { + type: 'section', + text: { type: 'plain_text', text: 'This message tests room context.' }, + }, + { + type: 'actions', + elements: [{ type: 'button', value: seed, actionId: 'msg-button', text: { type: 'plain_text', text: 'Click!' } }], + }, + ]); + + await modify.getCreator().finish(msg); + } + }, + }); + } +} +``` + +
diff --git a/apps/meteor/tests/data/apps/app-packages/api-parameter-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/api-parameter-test_0.0.1.zip new file mode 100644 index 0000000..d0992ff Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/api-parameter-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/app-update-test-broken_0.0.3.zip b/apps/meteor/tests/data/apps/app-packages/app-update-test-broken_0.0.3.zip new file mode 100644 index 0000000..dcd983e Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/app-update-test-broken_0.0.3.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/app-update-test-faulty_0.0.2.zip b/apps/meteor/tests/data/apps/app-packages/app-update-test-faulty_0.0.2.zip new file mode 100644 index 0000000..75a1138 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/app-update-test-faulty_0.0.2.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/app-update-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/app-update-test_0.0.1.zip new file mode 100644 index 0000000..6bd9879 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/app-update-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip new file mode 100644 index 0000000..870c14e Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/external-id-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/file-upload-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/file-upload-test_0.0.1.zip new file mode 100644 index 0000000..05183f3 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/file-upload-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts new file mode 100644 index 0000000..af73089 --- /dev/null +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -0,0 +1,25 @@ +import * as path from 'path'; + +export const appImplementsIPreFileUpload = path.resolve(__dirname, './file-upload-test_0.0.1.zip'); + +export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test_0.0.1.zip'); + +export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip'); + +export const appUiKitRoomTest = path.resolve(__dirname, './uikit-room-test_0.0.1.zip'); + +export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip'); + +export const appUpdateTest = path.resolve(__dirname, './app-update-test_0.0.1.zip'); + +// Same app id as appUpdateTest, but its `onEnable()` throws so the update applies yet the app ends up disabled. +export const appUpdateTestFaulty = path.resolve(__dirname, './app-update-test-faulty_0.0.2.zip'); + +// Same app id as appUpdateTest, but the package is missing its main class file, so the update fails to apply. +export const appUpdateTestBroken = path.resolve(__dirname, './app-update-test-broken_0.0.3.zip'); + +export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip'); + +export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip'); + +export const appPresenceStateTest = path.resolve(__dirname, './presence-state-test_0.0.1.zip'); diff --git a/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip new file mode 100644 index 0000000..cf9bd96 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/nested-requests_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/nested-requests_0.0.1.zip new file mode 100644 index 0000000..358122c Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/nested-requests_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/presence-state-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/presence-state-test_0.0.1.zip new file mode 100644 index 0000000..69d5896 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/presence-state-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/uikit-room-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/uikit-room-test_0.0.1.zip new file mode 100644 index 0000000..316e39b Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/uikit-room-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip new file mode 100644 index 0000000..9eacbf9 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip differ diff --git a/apps/meteor/tests/data/apps/apps-data.ts b/apps/meteor/tests/data/apps/apps-data.ts new file mode 100644 index 0000000..d1058d5 --- /dev/null +++ b/apps/meteor/tests/data/apps/apps-data.ts @@ -0,0 +1,16 @@ +import type { Path } from '@rocket.chat/rest-typings'; + +export const APP_URL = 'https://github.com/RocketChat/Apps.RocketChat.Tester/raw/master/dist/appsrocketchattester_0.3.0.zip?raw=true'; +export const APP_NAME = 'Apps.RocketChat.Tester'; + +type PathWithoutPrefix = TPath extends `/apps${infer U}` ? U : never; + +export function apps(path?: ''): `/api/apps`; +export function apps>(path: TPath): `/api/apps${TPath}`; +export function apps(path = '') { + return `/api/apps${path}` as const; +} + +export function installedApps() { + return `/api/apps/installed` as const; +} diff --git a/apps/meteor/tests/data/apps/helper.ts b/apps/meteor/tests/data/apps/helper.ts new file mode 100644 index 0000000..7871b51 --- /dev/null +++ b/apps/meteor/tests/data/apps/helper.ts @@ -0,0 +1,54 @@ +import type { App } from '@rocket.chat/core-typings'; + +import { request, credentials } from '../api-data'; +import { apps, APP_URL, installedApps } from './apps-data'; + +const getApps = () => + new Promise((resolve) => { + void request + .get(installedApps()) + .set(credentials) + .end((_err, res) => { + resolve(res.body.apps); + }); + }); + +const removeAppById = (id: App['id']) => + new Promise((resolve) => { + void request + .delete(apps(`/${id}`)) + .set(credentials) + .end(resolve); + }); + +export const cleanupApps = async () => { + const apps = await getApps(); + await Promise.all(apps.map((testApp) => removeAppById(testApp.id))); +}; + +export const installTestApp = () => + new Promise((resolve) => { + void request + .post(apps()) + .set(credentials) + .send({ + url: APP_URL, + }) + .end((_err, res) => { + resolve(res.body.app); + }); + }); + +export const installLocalTestPackage = (path: string) => + new Promise((resolve, reject) => { + void request + .post(apps()) + .set(credentials) + .attach('app', path) + .end((err, res) => { + if (err) { + return reject(err); + } + return resolve(res.body.app); + }); + }); diff --git a/apps/meteor/tests/data/chat.helper.ts b/apps/meteor/tests/data/chat.helper.ts new file mode 100644 index 0000000..7bcab19 --- /dev/null +++ b/apps/meteor/tests/data/chat.helper.ts @@ -0,0 +1,123 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IMessage } from '@rocket.chat/core-typings'; + +import { api, credentials, request } from './api-data'; + +export const sendSimpleMessage = ({ + roomId, + text = 'test message', + tmid, + userCredentials = credentials, +}: { + roomId: IRoom['_id']; + text?: string; + tmid?: IMessage['_id']; + userCredentials?: Credentials; +}) => { + if (!roomId) { + throw new Error('"roomId" is required in "sendSimpleMessage" test helper'); + } + const message: { + rid: IRoom['_id']; + text: string; + tmid?: IMessage['_id']; + } = { + rid: roomId, + text, + }; + + if (tmid) { + message.tmid = tmid; + } + + return request.post(api('chat.sendMessage')).set(userCredentials).send({ message }); +}; + +export const sendMessage = ({ + message, + requestCredentials, +}: { + message: { rid: IRoom['_id']; msg: string } & Partial>; + requestCredentials?: Credentials; +}) => { + return request + .post(api('chat.sendMessage')) + .set(requestCredentials ?? credentials) + .send({ message }); +}; + +export const starMessage = ({ messageId, requestCredentials }: { messageId: IMessage['_id']; requestCredentials?: Credentials }) => { + return request + .post(api('chat.starMessage')) + .set(requestCredentials ?? credentials) + .send({ messageId }); +}; + +export const pinMessage = ({ + messageId, + requestCredentials, + unpin = false, +}: { + messageId: IMessage['_id']; + requestCredentials?: Credentials; + unpin?: boolean; +}) => { + return request + .post(api(unpin ? 'chat.unPinMessage' : 'chat.pinMessage')) + .set(requestCredentials ?? credentials) + .send({ messageId }); +}; + +export const deleteMessage = ({ roomId, msgId }: { roomId: IRoom['_id']; msgId: IMessage['_id'] }) => { + if (!roomId) { + throw new Error('"roomId" is required in "deleteMessage" test helper'); + } + if (!msgId) { + throw new Error('"msgId" is required in "deleteMessage" test helper'); + } + + return request.post(api('chat.delete')).set(credentials).send({ + roomId, + msgId, + }); +}; + +export const getMessageById = ({ msgId }: { msgId: IMessage['_id'] }) => { + if (!msgId) { + throw new Error('"msgId" is required in "getMessageById" test helper'); + } + + return new Promise((resolve) => { + void request + .get(api(`chat.getMessage`)) + .query({ msgId }) + .set(credentials) + .end((_err, res) => { + resolve(res.body.message); + }); + }); +}; + +export const followMessage = ({ msgId, requestCredentials }: { msgId: IMessage['_id']; requestCredentials?: Credentials }) => { + return request + .post(api('chat.followMessage')) + .set(requestCredentials ?? credentials) + .send({ mid: msgId }); +}; + +export const updateMessage = ({ + msgId, + requestCredentials, + updatedMessage, + roomId, +}: { + msgId: IMessage['_id']; + requestCredentials?: Credentials; + updatedMessage: string; + roomId?: IRoom['_id']; +}) => { + return request + .post(api('chat.update')) + .set(requestCredentials ?? credentials) + .send({ msgId, text: updatedMessage, roomId }); +}; diff --git a/apps/meteor/tests/data/constants.ts b/apps/meteor/tests/data/constants.ts new file mode 100644 index 0000000..a7ebc76 --- /dev/null +++ b/apps/meteor/tests/data/constants.ts @@ -0,0 +1 @@ +export const CI_MAX_ROOMS_PER_GUEST = 10; diff --git a/apps/meteor/tests/data/file.helper.ts b/apps/meteor/tests/data/file.helper.ts new file mode 100644 index 0000000..23230a8 --- /dev/null +++ b/apps/meteor/tests/data/file.helper.ts @@ -0,0 +1,170 @@ +import * as fs from 'fs'; + +import type { IMessage } from '@rocket.chat/core-typings'; + +import { api } from './api-data'; +import type { IRequestConfig } from './users.helper'; + +/** + * Uploads a file to Rocket.Chat using the two-step process (rooms.media then rooms.mediaConfirm). + * + * @param roomId - The room ID where the file will be uploaded + * @param filePath - Path to the file to upload + * @param description - Description for the file + * @param config - Request configuration with credentials and request instance + * @param message - Optional message text to include with the file + * @returns Promise resolving to the message response + */ +export async function uploadFileToRC( + roomId: string, + filePath: string, + description: string, + config: IRequestConfig, + message = '', +): Promise<{ message: IMessage }> { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + // Step 1: Upload file to rooms.media/:rid + const mediaResponse = await requestInstance + .post(api(`rooms.media/${roomId}`)) + .set(credentialsInstance) + .attach('file', filePath) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!mediaResponse.body.success || !mediaResponse.body.file?._id) { + throw new Error(`File upload failed: ${JSON.stringify(mediaResponse.body)}`); + } + + const fileId = mediaResponse.body.file._id; + + // Step 2: Confirm and send message with rooms.mediaConfirm/:rid/:fileId + const confirmResponse = await requestInstance + .post(api(`rooms.mediaConfirm/${roomId}/${fileId}`)) + .set(credentialsInstance) + .send({ + msg: message, + description, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!confirmResponse.body.success || !confirmResponse.body.message) { + throw new Error(`File confirmation failed: ${JSON.stringify(confirmResponse.body)}`); + } + + return confirmResponse.body; +} + +/** + * Gets the list of files for a room. + * + * @param roomId - The room ID + * @param config - Request configuration + * @param options - Optional query parameters (name for filtering, count, offset) + * @returns Promise resolving to the files list response + */ +export async function getFilesList( + roomId: string, + config: IRequestConfig, + options: { name?: string; count?: number; offset?: number } = {}, +): Promise<{ + files: Array<{ + _id: string; + name: string; + size: number; + type: string; + rid: string; + userId: string; + path?: string; + url?: string; + uploadedAt?: string; + federation?: { + mrid?: string; + mxcUri?: string; + serverName?: string; + mediaId?: string; + }; + }>; + count: number; + offset: number; + total: number; + success: boolean; +}> { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + const queryParams: Record = { + roomId, + count: String(options.count || 10), + offset: String(options.offset || 0), + sort: JSON.stringify({ uploadedAt: -1 }), + }; + + if (options.name) { + queryParams.name = options.name; + } + + const response = await requestInstance + .get(api('groups.files')) + .set(credentialsInstance) + .query(queryParams) + .expect('Content-Type', 'application/json') + .expect(200); + + if (!response.body.success) { + throw new Error(`Failed to get files list: ${JSON.stringify(response.body)}`); + } + + return response.body; +} + +/** + * Downloads a file and verifies it matches the original file using binary comparison. + * + * @param fileUrl - The URL to download the file from (relative path like /file-upload/...) + * @param originalFilePath - Path to the original file to compare against + * @param config - Request configuration + * @returns Promise resolving to true if files match byte-by-byte + */ +export async function downloadFileAndVerifyBinary(fileUrl: string, originalFilePath: string, config: IRequestConfig): Promise { + const requestInstance = config.request; + const credentialsInstance = config.credentials; + + const response = await requestInstance.get(fileUrl).set(credentialsInstance).expect(200); + + // Handle different response types: + // - For text/plain, supertest parses as JSON (resulting in {}), so use response.text + // - For binary files, response.body might be a Buffer + // - For other text types, response.text contains the content + let downloadedBuffer: Buffer; + if (Buffer.isBuffer(response.body)) { + // Binary file - response.body is already a Buffer + downloadedBuffer = response.body; + } else if (response.text !== undefined) { + // Text response (including text/plain) - use response.text to avoid JSON parsing + // Convert to Buffer using binary encoding to preserve exact bytes + downloadedBuffer = Buffer.from(response.text, 'binary'); + } else if (typeof response.body === 'string') { + // Fallback: if body is a string, convert to buffer + downloadedBuffer = Buffer.from(response.body, 'binary'); + } else { + // If body is an object (like {} from JSON parsing), this is an error + throw new Error( + `Failed to get file content. Response body type: ${typeof response.body}. ` + + `This usually means supertest parsed a text/plain response as JSON. ` + + `Response text available: ${response.text !== undefined ? 'yes' : 'no'}`, + ); + } + + // Read the original file + const originalBuffer = fs.readFileSync(originalFilePath); + + // Compare buffers byte-by-byte + if (downloadedBuffer.length !== originalBuffer.length) { + return false; + } + + return downloadedBuffer.equals(originalBuffer); +} diff --git a/apps/meteor/tests/data/groups.helper.ts b/apps/meteor/tests/data/groups.helper.ts new file mode 100644 index 0000000..b3f612c --- /dev/null +++ b/apps/meteor/tests/data/groups.helper.ts @@ -0,0 +1,22 @@ +import { api, credentials, request } from './api-data'; + +export const createGroup = ({ name }: { name: string }) => { + if (!name) { + throw new Error('"name" is required in "createGroup" test helper'); + } + return request.post(api('groups.create')).set(credentials).send({ name }); +}; + +export const deleteGroup = ({ groupId, roomName }: { groupId?: string; roomName?: string }) => { + if (!groupId && !roomName) { + throw new Error('"groupId" or "roomName" is required in "deleteGroup" test helper'); + } + + return request + .post(api('groups.delete')) + .set(credentials) + .send({ + ...(groupId && { groupId }), + ...(roomName && { roomName }), + }); +}; diff --git a/apps/meteor/tests/data/integration.helper.ts b/apps/meteor/tests/data/integration.helper.ts new file mode 100644 index 0000000..211de64 --- /dev/null +++ b/apps/meteor/tests/data/integration.helper.ts @@ -0,0 +1,48 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IIntegration } from '@rocket.chat/core-typings'; +import type { IntegrationsCreateProps } from '@rocket.chat/rest-typings'; + +import { api, credentials, request } from './api-data'; + +export const createIntegration = (integration: IntegrationsCreateProps, userCredentials: Credentials) => + new Promise((resolve, reject) => { + void request + .post(api('integrations.create')) + .set(userCredentials) + .send(integration) + .end((err, res) => { + if (err) { + reject(err); + return; + } + + if (!res.body.success) { + reject(res.body); + return; + } + + resolve(res.body.integration); + }); + }); + +export const removeIntegration = (integrationId: IIntegration['_id'], type: 'incoming' | 'outgoing') => + new Promise((resolve) => { + void request + .post(api('integrations.remove')) + .set(credentials) + .send({ + type: `webhook-${type}`, + integrationId, + }) + .end((err, res) => { + if (err) { + console.warn(err); + } + + if (!res.body.success) { + console.warn(res.body); + } + + resolve(); + }); + }); diff --git a/apps/meteor/tests/data/interactions.ts b/apps/meteor/tests/data/interactions.ts new file mode 100644 index 0000000..bbf4768 --- /dev/null +++ b/apps/meteor/tests/data/interactions.ts @@ -0,0 +1,3 @@ +export const imgURL = './public/images/logo/1024x1024.png'; +export const soundURL = './public/sounds/beep.mp3'; +export const tiffURL = './tests/mocks/files/sample-tiff.tiff'; diff --git a/apps/meteor/tests/data/livechat/businessHours.ts b/apps/meteor/tests/data/livechat/businessHours.ts new file mode 100644 index 0000000..003877d --- /dev/null +++ b/apps/meteor/tests/data/livechat/businessHours.ts @@ -0,0 +1,209 @@ +import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import type { POSTLivechatBusinessHoursSaveParams } from '@rocket.chat/rest-typings'; +import moment from 'moment'; + +import { api, credentials, request } from '../api-data'; +import { updateEESetting, updateSetting } from '../permissions.helper'; + +type ISaveBhApiWorkHour = Omit & { + workHours: { day: string; start: string; finish: string; open: boolean }[]; +} & { departmentsToApplyBusinessHour?: string } & { timezoneName: string }; + +export const saveBusinessHour = async (businessHour: POSTLivechatBusinessHoursSaveParams) => { + const { body } = await request.post(api('livechat/business-hours.save')).set(credentials).send(businessHour); + + return body; +}; + +export const createCustomBusinessHour = async (departments: string[], open = true): Promise => { + const name = `business-hour-${Date.now()}`; + const businessHour: POSTLivechatBusinessHoursSaveParams = { + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: getWorkHours(open), + timezoneName: 'Asia/Calcutta', + timezone: 'Asia/Calcutta', + departmentsToApplyBusinessHour: '', + daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + daysTime: [ + { day: 'Monday', start: { time: '08:00' }, finish: { time: '18:00' }, open }, + { day: 'Tuesday', start: { time: '08:00' }, finish: { time: '18:00' }, open }, + { day: 'Wednesday', start: { time: '08:00' }, finish: { time: '18:00' }, open }, + { day: 'Thursday', start: { time: '08:00' }, finish: { time: '18:00' }, open }, + { day: 'Friday', start: { time: '08:00' }, finish: { time: '18:00' }, open }, + ], + }; + + if (departments.length) { + businessHour.departmentsToApplyBusinessHour = departments.join(','); + } + + await saveBusinessHour(businessHour); + + const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours(); + const createdBusinessHour = existingBusinessHours.find((bh) => bh.name === name); + if (!createdBusinessHour) { + throw new Error('Could not create business hour'); + } + + return createdBusinessHour; +}; + +export const makeDefaultBusinessHourActiveAndClosed = async () => { + // enable settings + await updateSetting('Livechat_enable_business_hours', true); + await updateEESetting('Livechat_business_hour_type', 'Single'); + + // create business hours + const { + body: { businessHour }, + } = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send(); + + const { workHours } = businessHour; + + // Remove properties not accepted by the endpoint schema + const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour; + + const enabledBusinessHour = { + ...cleanedBusinessHour, + timezoneName: 'America/Sao_Paulo', + timezone: 'America/Sao_Paulo', + departmentsToApplyBusinessHour: '', + daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => { + return { + open: true, + start: { time: '00:00' }, + finish: { time: '00:01' }, + day: workHour.day, + }; + }), + workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string; code?: number }) => { + workHour.open = true; + workHour.start = '00:00'; + workHour.finish = '00:01'; // if a job runs between 00:00 and 00:01, then this test will fail :P + delete workHour.code; + return workHour; + }), + }; + + return request.post(api('livechat/business-hours.save')).set(credentials).send(enabledBusinessHour).expect(200); +}; + +export const disableDefaultBusinessHour = async () => { + // disable settings + await updateSetting('Livechat_enable_business_hours', false); + await updateEESetting('Livechat_business_hour_type', 'Single'); + + // create business hours + const { + body: { businessHour }, + } = await request.get(api('livechat/business-hour')).query({ type: 'default' }).set(credentials).send(); + + const { workHours } = businessHour; + + // Remove properties not accepted by the endpoint schema + const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour; + const disabledBusinessHour = { + ...cleanedBusinessHour, + timezoneName: 'America/Sao_Paulo', + timezone: 'America/Sao_Paulo', + departmentsToApplyBusinessHour: '', + daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + daysTime: workHours.map((workHour: { open: boolean; start: { time: string }; finish: { time: string }; day: string }) => { + return { + open: false, + start: { time: '00:00' }, + finish: { time: '23:59' }, + day: workHour.day, + }; + }), + workHours: workHours.map((workHour: { open: boolean; start: string; finish: string; day: string }) => { + workHour.open = false; + workHour.start = '00:00'; + workHour.finish = '23:59'; + return workHour; + }), + }; + + return request.post(api('livechat/business-hours.save')).set(credentials).send(disabledBusinessHour).expect(200); +}; + +const removeCustomBusinessHour = async (businessHourId: string) => { + return request + .post(api('livechat/business-hours.remove')) + .set(credentials) + .send({ _id: businessHourId, type: LivechatBusinessHourTypes.CUSTOM }) + .expect(200); +}; + +const getAllCustomBusinessHours = async (): Promise => { + const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200); + return (response.body.businessHours || []).filter( + (businessHour: ILivechatBusinessHour) => businessHour.type === LivechatBusinessHourTypes.CUSTOM, + ); +}; + +export const removeAllCustomBusinessHours = async () => { + const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours(); + + const promises = existingBusinessHours.map((businessHour) => removeCustomBusinessHour(businessHour._id)); + await Promise.all(promises); +}; + +export const getDefaultBusinessHour = async (): Promise => { + const response = await request + .get(api('livechat/business-hour')) + .set(credentials) + .query({ type: LivechatBusinessHourTypes.DEFAULT }) + .expect(200); + return response.body.businessHour; +}; + +export const getCustomBusinessHourById = async (businessHourId: string): Promise => { + const response = await request + .get(api('livechat/business-hour')) + .set(credentials) + .query({ type: LivechatBusinessHourTypes.CUSTOM, _id: businessHourId }) + .expect(200); + return response.body.businessHour; +}; + +// TODO: Refactor logic so object passed is of the correct type for POST /livechat/business-hours.save. See: CORE-1552 +export const openOrCloseBusinessHour = async (businessHour: ILivechatBusinessHour, open: boolean) => { + const { _updatedAt, ts, ...cleanedBusinessHour } = businessHour; + const timezoneName = businessHour.timezone.name; + + const enabledBusinessHour = { + ...cleanedBusinessHour, + timezoneName, + timezone: timezoneName, + workHours: getWorkHours().map((workHour) => { + return { + ...workHour, + open, + }; + }), + departmentsToApplyBusinessHour: businessHour.departments?.map((department) => department._id).join(',') || '', + }; + + await saveBusinessHour(enabledBusinessHour as any); +}; + +export const getWorkHours = (open = true): ISaveBhApiWorkHour['workHours'] => { + const workHours: ISaveBhApiWorkHour['workHours'] = []; + + for (let i = 0; i < 7; i++) { + workHours.push({ + day: moment().day(i).format('dddd'), + start: '00:00', + finish: '23:59', + + open, + }); + } + + return workHours; +}; diff --git a/apps/meteor/tests/data/livechat/canned-responses.ts b/apps/meteor/tests/data/livechat/canned-responses.ts new file mode 100644 index 0000000..55a6db0 --- /dev/null +++ b/apps/meteor/tests/data/livechat/canned-responses.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker'; +import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; + +import { api, credentials, request } from '../api-data'; +import type { DummyResponse } from './utils'; + +export const createCannedResponse = (): Promise> => + new Promise((resolve, reject) => { + const response = { + shortcut: `${faker.lorem.word()}-${Date.now()}`, + scope: 'user', + tags: [faker.string.uuid()], + text: faker.lorem.sentence(), + }; + return request + .post(api(`canned-responses`)) + .set(credentials) + .send(response) + .end((_err: Error, _res: DummyResponse) => { + if (_err) { + return reject(_err); + } + resolve(response); + }); + }); diff --git a/apps/meteor/tests/data/livechat/custom-fields.ts b/apps/meteor/tests/data/livechat/custom-fields.ts new file mode 100644 index 0000000..d85ea04 --- /dev/null +++ b/apps/meteor/tests/data/livechat/custom-fields.ts @@ -0,0 +1,49 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import type { Response } from 'supertest'; + +import { credentials, request, api } from '../api-data'; + +type ExtendedCustomField = Omit & { field: string }; + +export const createCustomField = (customField: ExtendedCustomField): Promise => + new Promise((resolve, reject) => { + void request + .get(api(`livechat/custom-fields/${customField.label}`)) + .set(credentials) + .send() + .end((err: Error, res: Response) => { + if (err) { + return reject(err); + } + if (res.body.customField !== null && res.body.customField !== undefined) { + resolve(res.body.customField); + } else { + void request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ customFieldId: null, customFieldData: customField }) + .end((err: Error, res: Response): void => { + if (err) { + return reject(err); + } + resolve(res.body.customField); + }); + } + }); + }); + +export const deleteCustomField = (customFieldID: string) => + new Promise((resolve, reject) => { + void request + .post(api('livechat/custom-fields.delete')) + .set(credentials) + .send({ + customFieldId: customFieldID, + }) + .end((err: Error, res: Response): void => { + if (err) { + return reject(err); + } + resolve(res.body); + }); + }); diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts new file mode 100644 index 0000000..5d58873 --- /dev/null +++ b/apps/meteor/tests/data/livechat/department.ts @@ -0,0 +1,225 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; + +import { api, credentials, request } from '../api-data'; +import { makeAgentAvailable } from './rooms'; +import { createAgentAndReLogin, createAnAwayAgent, createAnOfflineAgent } from './users'; +import type { WithRequiredProperty } from './utils'; + +const NewDepartmentData = ((): Partial => ({ + enabled: true, + name: `new department ${Random.id()}`, + description: 'created from api', + showOnRegistration: true, + email: faker.internet.email(), + showOnOfflineForm: true, +}))(); + +export const createDepartment = async ( + departmentData?: Partial, + agents?: { agentId: string; count?: string; order?: string }[], +): Promise => { + const department = { ...NewDepartmentData, ...departmentData }; + const response = await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department, + ...(agents && { agents }), + }) + .expect(200); + return response.body.department; +}; + +const updateDepartment = async (departmentId: string, departmentData: Partial): Promise => { + const response = await request + .put(api(`livechat/department/${departmentId}`)) + .set(credentials) + .send({ + department: departmentData, + }) + .expect(200); + return response.body.department; +}; + +export type OnlineAgent = { + user: WithRequiredProperty; + credentials: Credentials; +}; + +export const createDepartmentWithAnOnlineAgent = async (): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => { + const { user, credentials: agentCredentials } = await createAgentAndReLogin(); + + const department = await createDepartment(); + + await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); + + await makeAgentAvailable(agentCredentials); + + return { + department, + agent: { + credentials: agentCredentials, + user, + }, + }; +}; + +export const createDepartmentWith2OnlineAgents = async (): Promise<{ + department: ILivechatDepartment; + agent1: OnlineAgent; + agent2: OnlineAgent; +}> => { + const [agent1, agent2] = await Promise.all([createAgentAndReLogin(), createAgentAndReLogin()]); + + const department = await createDepartment( + { + enabled: true, + name: Random.id(), + showOnRegistration: true, + email: `${Random.id()}@example.com`, + showOnOfflineForm: true, + }, + [{ agentId: agent1.user._id }, { agentId: agent2.user._id }], + ); + + await Promise.all([makeAgentAvailable(agent1.credentials), makeAgentAvailable(agent2.credentials)]); + + return { + department, + agent1, + agent2, + }; +}; + +export const createDepartmentWithAgent = async (agent: OnlineAgent): Promise<{ department: ILivechatDepartment; agent: OnlineAgent }> => { + const { user, credentials } = agent; + const department = await createDepartment(); + + await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); + + return { + department, + agent: { + credentials, + user, + }, + }; +}; + +export const addOrRemoveAgentFromDepartment = async ( + departmentId: string, + agent: { agentId: string; username: string; count?: number; order?: number }, + add: boolean, +) => { + const response = await request + .post(api(`livechat/department/${departmentId}/agents`)) + .set(credentials) + .send({ + ...(add ? { upsert: [agent], remove: [] } : { remove: [agent], upsert: [] }), + }); + + if (response.status !== 200) { + throw new Error(`Failed to add or remove agent from department. Status code: ${response.status}\n${response.body}`); + } +}; + +export const createDepartmentWithAnOfflineAgent = async ({ + allowReceiveForwardOffline = false, + fallbackForwardDepartment, + departmentsAllowedToForward, +}: { + allowReceiveForwardOffline?: boolean; + fallbackForwardDepartment?: string; + departmentsAllowedToForward?: string[]; +}): Promise<{ + department: ILivechatDepartment; + agent: { + credentials: Credentials; + user: WithRequiredProperty; + }; +}> => { + const { user, credentials } = await createAnOfflineAgent(); + + const department = (await createDepartment({ + allowReceiveForwardOffline, + fallbackForwardDepartment, + departmentsAllowedToForward, + })) as ILivechatDepartment; + + await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); + + return { + department, + agent: { + credentials, + user, + }, + }; +}; + +export const createDepartmentWithAnAwayAgent = async ({ + allowReceiveForwardOffline = false, + fallbackForwardDepartment, + departmentsAllowedToForward, +}: { + allowReceiveForwardOffline?: boolean; + fallbackForwardDepartment?: string; + departmentsAllowedToForward?: string[]; +}): Promise<{ + department: ILivechatDepartment; + agent: { + credentials: Credentials; + user: WithRequiredProperty; + }; +}> => { + const { user, credentials } = await createAnAwayAgent(); + + const department = (await createDepartment({ + allowReceiveForwardOffline, + fallbackForwardDepartment, + departmentsAllowedToForward, + })) as ILivechatDepartment; + + await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true); + + return { + department, + agent: { + credentials, + user, + }, + }; +}; + +export const archiveDepartment = async (departmentId: string): Promise => { + await request + .post(api(`livechat/department/${departmentId}/archive`)) + .set(credentials) + .expect(200); +}; + +export const disableDepartment = async (department: Omit): Promise => { + department.enabled = false; + delete department._updatedAt; + const updatedDepartment = await updateDepartment(department._id, department); + expect(updatedDepartment.enabled).to.be.false; +}; + +export const deleteDepartment = async (departmentId: string): Promise => { + await request + .delete(api(`livechat/department/${departmentId}`)) + .set(credentials) + .expect(200); +}; + +export const getDepartmentById = async (departmentId: string): Promise => { + const response = await request + .get(api(`livechat/department/${departmentId}`)) + .set(credentials) + .expect(200); + return response.body.department; +}; diff --git a/apps/meteor/tests/data/livechat/inboxes.ts b/apps/meteor/tests/data/livechat/inboxes.ts new file mode 100644 index 0000000..7def060 --- /dev/null +++ b/apps/meteor/tests/data/livechat/inboxes.ts @@ -0,0 +1,31 @@ +import { getCredentials, api, request, credentials } from '../api-data'; + +export const createEmailInbox = async (): Promise<{ _id: string }> => { + await new Promise((resolve) => getCredentials(resolve)); + const { body } = await request + .post(api('email-inbox')) + .set(credentials) + .send({ + name: 'test', + active: false, + email: `test${new Date().getTime()}@test.com`, + description: 'test', + senderInfo: 'test', + smtp: { + server: 'smtp.example.com', + port: 587, + username: 'xxxx', + password: 'xxxx', + secure: true, + }, + imap: { + server: 'imap.example.com', + port: 993, + username: 'xxxx', + password: 'xxxx', + secure: true, + maxRetries: 10, + }, + }); + return body; +}; diff --git a/apps/meteor/tests/data/livechat/inquiries.ts b/apps/meteor/tests/data/livechat/inquiries.ts new file mode 100644 index 0000000..93676c8 --- /dev/null +++ b/apps/meteor/tests/data/livechat/inquiries.ts @@ -0,0 +1,31 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; + +import { api, request } from '../api-data'; + +export const fetchAllInquiries = async (credentials: Credentials, department?: string): Promise => { + const inquiries: ILivechatInquiryRecord[] = []; + + let hasMore = true; + let offset = 0; + + while (hasMore) { + const { body } = (await request + .get(api('livechat/inquiries.queuedForUser')) + .set(credentials) + .query({ + ...(department && { department }), + offset, + }) + .expect('Content-Type', 'application/json') + .expect(200)) as { body: PaginatedResult<{ inquiries: Array }> }; + + inquiries.push(...body.inquiries); + + hasMore = body.total > inquiries.length; + offset += body.count; + } + + return inquiries; +}; diff --git a/apps/meteor/tests/data/livechat/priorities.ts b/apps/meteor/tests/data/livechat/priorities.ts new file mode 100644 index 0000000..e77845f --- /dev/null +++ b/apps/meteor/tests/data/livechat/priorities.ts @@ -0,0 +1,71 @@ +import type { ILivechatPriority, IOmnichannelServiceLevelAgreements } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { generateRandomSLAData } from '../../e2e/utils/omnichannel/sla'; +import { api, credentials, request } from '../api-data'; +import type { DummyResponse } from './utils'; + +export const createSLA = (): Promise> => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/sla')) + .set(credentials) + .send(generateRandomSLAData()) + .end((err: Error, res: DummyResponse<{ sla: Omit }, 'unwrapped'>) => { + if (err) { + return reject(err); + } + resolve(res.body.sla); + }); + }); +}; + +export const deleteSLA = (id: string): Promise => { + return new Promise((resolve, reject) => { + void request + .delete(api(`livechat/sla/${id}`)) + .set(credentials) + .send() + .end((err: Error, _res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(); + }); + }); +}; + +export const bulkCreateSLA = (amount: number): Promise[]> => { + const promises = []; + for (let i = 0; i < amount; i++) { + promises.push(createSLA()); + } + return Promise.all(promises); +}; + +export const deleteAllSLA = async (): Promise => { + const response = await request.get(api('livechat/sla')).set(credentials).expect('Content-Type', 'application/json').expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.sla).to.be.an('array'); + const { + body: { sla }, + } = response as { body: { sla: IOmnichannelServiceLevelAgreements[] } }; + const promises = sla.map((slaObj) => + request + .delete(api(`livechat/sla/${slaObj._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200), + ); + await Promise.all(promises); +}; + +export const getRandomPriority = async (): Promise => { + const response = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.priorities).to.be.an('array'); + const { + body: { priorities }, + } = response as { body: { priorities: ILivechatPriority[] } }; + return priorities[Math.floor(Math.random() * priorities.length)]; +}; diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts new file mode 100644 index 0000000..2ccdd6c --- /dev/null +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -0,0 +1,494 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { + ILivechatInquiryRecord, + ILivechatAgent, + ILivechatDepartment, + ILivechatVisitor, + IMessage, + IOmnichannelRoom, +} from '@rocket.chat/core-typings'; +import type { Response } from 'supertest'; + +import { api, credentials, methodCall, request } from '../api-data'; +import { imgURL } from '../interactions'; +import { getSettingValueById, restorePermissionToRoles, updateSetting } from '../permissions.helper'; +import { adminUsername } from '../user'; +import { getRandomVisitorToken } from './users'; +import type { DummyResponse } from './utils'; +import { sleep } from './utils'; + +export const createLivechatRoom = async (visitorToken: string, extraRoomParams?: Record): Promise => { + const urlParams = new URLSearchParams(); + urlParams.append('token', visitorToken); + if (extraRoomParams) { + for (const [key, value] of Object.entries(extraRoomParams)) { + urlParams.append(key, value); + } + } + + const response = await request.get(api('livechat/room')).query(urlParams.toString()).set(credentials).expect(200); + + return response.body.room; +}; + +export const createLivechatRoomWidget = async ( + visitorToken: string, + extraRoomParams?: Record, +): Promise => { + const urlParams = new URLSearchParams(); + urlParams.append('token', visitorToken); + if (extraRoomParams) { + for (const [key, value] of Object.entries(extraRoomParams)) { + urlParams.append(key, value); + } + } + + const response = await request + .get(api('livechat/room')) + .set('Cookie', [`rc_room_type=l`, `rc_is_widget=t`]) + .query(urlParams.toString()) + .set(credentials) + .expect(200); + + return response.body.room; +}; + +export const createVisitorWithCustomData = async ({ + department, + visitorName, + customPhone, + customFields, + customToken, + customEmail, + ignoreEmail = false, + ignorePhone = false, +}: { + department?: string; + customPhone?: string; + visitorName?: string; + customEmail?: string; + customFields?: { key: string; value: string; overwrite: boolean }[]; + customToken?: string; + ignoreEmail?: boolean; + ignorePhone?: boolean; +}): Promise => { + const token = customToken || getRandomVisitorToken(); + const email = customEmail || `${token}@${token}.com`; + const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`; + + try { + const res = await request.get(api(`livechat/visitor/${token}`)); + if (res?.body?.visitor) { + return res.body.visitor as ILivechatVisitor; + } + } catch { + // Ignore errors from GET; we will create the visitor below. + } + + const res = await request + .post(api('livechat/visitor')) + .set(credentials) + .send({ + visitor: { + name: visitorName || `Visitor ${Date.now()}`, + token, + customFields: customFields || [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }], + ...(department ? { department } : {}), + ...(!ignoreEmail ? { email } : {}), + ...(!ignorePhone ? { phone } : {}), + }, + }); + + return res.body.visitor as ILivechatVisitor; +}; + +export const createVisitor = ( + department?: string, + visitorName?: string, + customEmail?: string, + customPhone?: string, +): Promise => + new Promise((resolve, reject) => { + const token = getRandomVisitorToken(); + const email = customEmail || `${token}@${token}.com`; + const phone = customPhone || `${Math.floor(Math.random() * 10000000000)}`; + void request.get(api(`livechat/visitor/${token}`)).end((err: Error, res: DummyResponse) => { + if (!err && res && res.body && res.body.visitor) { + return resolve(res.body.visitor); + } + void request + .post(api('livechat/visitor')) + .set(credentials) + .send({ + visitor: { + name: visitorName || `Visitor ${Date.now()}`, + email, + token, + phone, + customFields: [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }], + ...(department ? { department } : {}), + }, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.visitor); + }); + }); + }); + +export const deleteVisitor = async (token: string): Promise => { + await request.delete(api(`livechat/visitor/${token}`)); +}; + +export const takeInquiry = async (inquiryId: string, agentCredentials?: Credentials): Promise => { + const userId = agentCredentials ? agentCredentials['X-User-Id'] : credentials['X-User-Id']; + + await request + .post(api('livechat/inquiries.take')) + .set(agentCredentials || credentials) + .send({ userId, inquiryId }) + .expect(200); +}; + +export const fetchInquiry = (roomId: string): Promise => { + return new Promise((resolve, reject) => { + void request + .get(api('livechat/inquiries.getOne')) + .query({ roomId }) + .set(credentials) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.inquiry); + }); + }); +}; + +export const createDepartment = ( + agents?: { agentId: string; count?: number }[], + name?: string, + enabled = true, + opts: Record = {}, + departmentUnit?: { _id?: string }, + userCredentials: Credentials = credentials, +): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/department')) + .set(userCredentials) + .send({ + department: { + name: name || `Department ${Date.now()}`, + enabled, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'a@b.com', + ...opts, + }, + agents, + departmentUnit, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.department); + }); + }); +}; + +export const updateDepartment = ({ + departmentId, + userCredentials, + agents, + name, + enabled = true, + opts = {}, + departmentUnit, +}: { + departmentId: string; + userCredentials: Credentials; + agents?: { agentId: string }[]; + name?: string; + enabled?: boolean; + opts?: Record; + departmentUnit?: { _id?: string }; +}): Promise => { + return new Promise((resolve, reject) => { + void request + .put(api(`livechat/department/${departmentId}`)) + .set(userCredentials) + .send({ + department: { + name: name || `Department ${Date.now()}`, + enabled, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'a@b.com', + ...opts, + }, + agents, + departmentUnit, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.department); + }); + }); +}; + +export const deleteAgent = async (agentId: string = credentials['X-User-Id']): Promise => { + await request + .delete(api(`livechat/users/agent/${agentId}`)) + .set(credentials) + .expect(200); +}; + +export const createAgent = (overrideUsername?: string): Promise => + new Promise((resolve, reject) => { + void request + .post(api('livechat/users/agent')) + .set(credentials) + .send({ + username: overrideUsername || adminUsername, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.user); + }); + }); + +export const createManager = (overrideUsername?: string): Promise => + new Promise((resolve, reject) => { + void request + .post(api('livechat/users/manager')) + .set(credentials) + .send({ + username: overrideUsername || adminUsername, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.user); + }); + }); + +export const switchLivechatStatus = async (status: 'available' | 'not-available', overrideCredentials?: Credentials): Promise => { + await request + .post(api('livechat/agent.status')) + .set(overrideCredentials || credentials) + .send({ status }) + .expect(200); +}; + +export const makeAgentAvailable = async (overrideCredentials?: Credentials): Promise => { + await restorePermissionToRoles('view-l-room'); + await request + .post(api('users.setStatus')) + .set(overrideCredentials || credentials) + .send({ + message: '', + status: 'online', + }); + + return request + .post(api('livechat/agent.status')) + .set(overrideCredentials || credentials) + .send({ + status: 'available', + }); +}; + +export const makeAgentUnavailable = async (overrideCredentials?: Credentials): Promise => { + await request + .post(api('users.setStatus')) + .set(overrideCredentials || credentials) + .send({ message: '', status: 'offline' }) + .expect(200); + await request + .post(api('livechat/agent.status')) + .set(overrideCredentials || credentials) + .send({ + status: 'not-available', + }) + .expect(200); +}; + +export const getLivechatRoomInfo = (roomId: string): Promise => { + return new Promise((resolve /* , reject*/) => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId, + }) + .end((_err: Error, res: DummyResponse) => { + resolve(res.body.channel); + }); + }); +}; + +/** + * @summary Sends message as visitor + */ +export const sendMessage = (roomId: string, message: string, visitorToken: string): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/message')) + .set(credentials) + .send({ + rid: roomId, + msg: message, + token: visitorToken, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.message); + }); + }); +}; + +export const uploadFile = (roomId: string, visitorToken: string): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api(`livechat/upload/${roomId}`)) + .set({ 'x-visitor-token': visitorToken, ...credentials }) + .attach('file', imgURL) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body as unknown as IMessage); + }); + }); +}; + +// Sends a message using sendMessage method from agent +export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise => { + return new Promise((resolve, reject) => { + void request + .post(methodCall('sendMessage')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ rid: roomId, msg: msg || faker.lorem.sentence() }], + id: 'id', + msg: 'method', + }), + }) + .end((err: Error, res: any) => { + if (err) { + return reject(err); + } + resolve(JSON.parse(res.body.message).result); + }); + }); +}; + +export const fetchMessages = (roomId: string, visitorToken: string): Promise => { + return new Promise((resolve, reject) => { + void request + .get(api(`livechat/messages.history/${roomId}`)) + .set(credentials) + .query({ + token: visitorToken, + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + + if (!res.body.success) { + reject(res.body); + return; + } + + resolve(res.body.messages); + }); + }); +}; + +export const closeOmnichannelRoom = async (roomId: string, tags?: string[]): Promise => { + await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: roomId, ...(tags && { tags }), comment: faker.lorem.sentence() }) + .expect(200); +}; + +export const bulkCreateLivechatRooms = async ( + amount: number, + department?: string, + resolveRoomExtraParams?: (index: number) => Record | undefined, +): Promise => { + const rooms: IOmnichannelRoom[] = []; + + for (let i = 0; i < amount; i++) { + const visitor = await createVisitor(department); + const extraRoomParams = resolveRoomExtraParams ? resolveRoomExtraParams(i) : {}; + + const room = await createLivechatRoom(visitor.token, extraRoomParams); + + rooms.push(room); + } + + return rooms; +}; + +export const startANewLivechatRoomAndTakeIt = async ({ + departmentId, + agent, +}: { + departmentId?: string; + agent?: Credentials; +} = {}): Promise<{ room: IOmnichannelRoom; visitor: ILivechatVisitor }> => { + const currentRoutingMethod = await getSettingValueById('Livechat_Routing_Method'); + const routingMethodChanged = false; + if (currentRoutingMethod !== 'Manual_Selection') { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + + // wait for routing algorithm to stop + await sleep(1000); + } + + const visitor = await createVisitor(departmentId); + const room = await createLivechatRoom(visitor.token); + const { _id: roomId } = room; + const inq = await fetchInquiry(roomId); + await takeInquiry(inq._id, agent); + await sendMessage(roomId, 'test message', visitor.token); + + if (routingMethodChanged) { + await updateSetting('Livechat_Routing_Method', currentRoutingMethod); + + // wait for routing algorithm to start + await sleep(1000); + } + + return { room, visitor }; +}; + +export const placeRoomOnHold = async (roomId: string): Promise => { + await request.post(api('livechat/room.onHold')).set(credentials).send({ roomId }).expect(200); +}; + +export const moveBackToQueue = async (roomId: string, overrideCredentials?: Credentials): Promise => { + await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(overrideCredentials || credentials) + .send({ roomId }) + .expect('Content-Type', 'application/json') + .expect(200); +}; diff --git a/apps/meteor/tests/data/livechat/sample.png b/apps/meteor/tests/data/livechat/sample.png new file mode 100644 index 0000000..80f92af Binary files /dev/null and b/apps/meteor/tests/data/livechat/sample.png differ diff --git a/apps/meteor/tests/data/livechat/tags.ts b/apps/meteor/tests/data/livechat/tags.ts new file mode 100644 index 0000000..de25e66 --- /dev/null +++ b/apps/meteor/tests/data/livechat/tags.ts @@ -0,0 +1,50 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatTag, FindTagsResult } from '@rocket.chat/core-typings'; + +import { credentials, request, api } from '../api-data'; +import type { DummyResponse } from './utils'; + +export const listTags = async (): Promise => { + const { body } = await request.get(api('livechat/tags')).set(credentials).query({ viewAll: 'true' }); + + return body; +}; + +export const saveTags = async (departments: string[] = []): Promise => { + const { body } = await request + .post(api('livechat/tags.save')) + .set(credentials) + .send({ + tagData: { + name: faker.string.uuid(), + description: faker.lorem.sentence(), + }, + ...(departments.length > 0 && { tagDepartments: departments }), + }); + + return body; +}; + +export const removeAllTags = async (): Promise => { + const tagsList = await listTags(); + await Promise.all(tagsList.tags.map((tag) => removeTag(tag._id))); + + const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + return response.body.tags.length === 0; +}; + +export const removeTag = (id: string): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/tags.delete')) + .set(credentials) + .send({ id }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(JSON.parse(res.body.success).result); + }); + }); +}; diff --git a/apps/meteor/tests/data/livechat/triggers.ts b/apps/meteor/tests/data/livechat/triggers.ts new file mode 100644 index 0000000..6c1798b --- /dev/null +++ b/apps/meteor/tests/data/livechat/triggers.ts @@ -0,0 +1,55 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatTrigger } from '@rocket.chat/core-typings'; + +import { api, credentials, request } from '../api-data'; +import type { DummyResponse } from './utils'; + +export const createTrigger = (name: string): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name, + description: faker.lorem.sentence(), + enabled: true, + runOnce: faker.datatype.boolean(), + actions: [ + { + name: 'send-message', + params: { + msg: faker.lorem.sentence(), + name: faker.person.firstName(), + sender: faker.helpers.arrayElement(['queue', 'custom']), + }, + }, + ], + conditions: [ + { + name: faker.helpers.arrayElement(['time-on-site', 'page-url', 'chat-opened-by-visitor', 'after-guest-registration']), + value: faker.string.alpha(), + }, + ], + }) + .end((err: Error, _res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(true); + }); + }); +}; + +export const fetchTriggers = (): Promise => { + return new Promise((resolve, reject) => { + void request + .get(api('livechat/triggers')) + .set(credentials) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body.triggers); + }); + }); +}; diff --git a/apps/meteor/tests/data/livechat/units.ts b/apps/meteor/tests/data/livechat/units.ts new file mode 100644 index 0000000..92a5e9e --- /dev/null +++ b/apps/meteor/tests/data/livechat/units.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import type { IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; + +import { methodCall, credentials, request, api } from '../api-data'; +import type { DummyResponse } from './utils'; + +export const createMonitor = async (username: string): Promise<{ _id: string; username: string; role: string[] }> => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/monitors.create')) + .set(credentials) + .send({ username }) + .end((err: Error, res: DummyResponse<{ _id: string; username: string; role: string[] }, 'not-wrapped'>) => { + if (err) { + return reject(err); + } + + resolve(res.body); + }); + }); +}; + +export const createUnit = async ( + monitorId: string, + username: string, + departmentIds: string[], + name?: string, + extraMonitor: { monitorId: string; username: string }[] = [], +): Promise => { + return new Promise((resolve, reject) => { + void request + .post(api('livechat/units')) + .set(credentials) + .send({ + unitData: { + name: name || `${faker.person.firstName()} ${faker.string.uuid()}`, + visibility: faker.helpers.arrayElement(['public', 'private']), + }, + unitMonitors: [{ monitorId, username }, ...extraMonitor], + unitDepartments: departmentIds.map((departmentId) => ({ departmentId })), + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body); + }); + }); +}; + +export const deleteUnit = async (unit: IOmnichannelBusinessUnit): Promise => { + return new Promise((resolve, reject) => { + void request + .post(methodCall(`livechat:removeUnit`)) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'livechat:removeUnit', + params: [unit._id], + id: '101', + msg: 'method', + }), + }) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(JSON.parse(res.body.message).result); + }); + }); +}; + +export const getUnit = (unitId: string): Promise => { + return new Promise((resolve, reject) => { + void request + .get(api(`livechat/units/${unitId}`)) + .set(credentials) + .end((err: Error, res: DummyResponse) => { + if (err) { + return reject(err); + } + resolve(res.body); + }); + }); +}; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts new file mode 100644 index 0000000..8e7b421 --- /dev/null +++ b/apps/meteor/tests/data/livechat/users.ts @@ -0,0 +1,128 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import { UserStatus, type ILivechatAgent, type IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; + +import { api, credentials, request } from '../api-data'; +import { password } from '../user'; +import { createUser, login, setUserAway, setUserStatus } from '../users.helper'; +import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; + +export const createBotAgent = async (): Promise<{ + credentials: Credentials; + user: IUser; +}> => { + const agent = await createUser({ + roles: ['bot'], + }); + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentAvailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; + +export const getRandomVisitorToken = (): string => faker.string.alphanumeric(17); + +export const getAgent = async (userId: string): Promise => { + const { body } = await request + .get(api(`livechat/users/agent/${userId}`)) + .set(credentials) + .expect(200); + return body.user; +}; + +export const removeAgent = async (userId: string): Promise => { + await request + .delete(api(`livechat/users/agent/${userId}`)) + .set(credentials) + .expect(200); +}; + +export const createAgentAndReLogin = async (): Promise<{ + credentials: Credentials; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Random.id()}`; + const email = `${username}@rocket.chat`; + const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; + +export const createAnOnlineAgent = async (): Promise<{ + credentials: Credentials; + user: IUser & { username: string }; +}> => { + const { credentials: createdUserCredentials, user: agent } = await createAgentAndReLogin(); + await makeAgentAvailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; + +export const createAnOfflineAgent = async (): Promise<{ + credentials: Credentials; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Date.now()}.offline`; + const email = `${username}.offline@rocket.chat`; + const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentUnavailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; + +export const createAnAwayAgent = async (): Promise<{ + credentials: Credentials; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Date.now()}.away`; + const email = `${username}.offline@rocket.chat`; + const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentAvailable(createdUserCredentials); + await setUserStatus(createdUserCredentials, UserStatus.AWAY); + await setUserAway(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +}; + +export const updateLivechatSettingsForUser = async ( + agentId: string, + livechatSettings: Record, + agentDepartments: string[] = [], +): Promise => { + await request + .post(api('livechat/agents.saveInfo')) + .set(credentials) + .send({ + agentId, + agentData: livechatSettings, + agentDepartments, + }) + .expect('Content-Type', 'application/json') + .expect(200); +}; diff --git a/apps/meteor/tests/data/livechat/utils.ts b/apps/meteor/tests/data/livechat/utils.ts new file mode 100644 index 0000000..cf4b906 --- /dev/null +++ b/apps/meteor/tests/data/livechat/utils.ts @@ -0,0 +1,17 @@ +export type DummyResponse = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; + +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; + +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export const parseMethodResponse = (response: any) => { + if (response.message) { + return JSON.parse(response.message); + } + + return {}; +}; diff --git a/apps/meteor/tests/data/livechat/visitor.ts b/apps/meteor/tests/data/livechat/visitor.ts new file mode 100644 index 0000000..ed29296 --- /dev/null +++ b/apps/meteor/tests/data/livechat/visitor.ts @@ -0,0 +1,13 @@ +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { api, credentials, request } from '../api-data'; + +export const getLivechatVisitorByToken = async (token: string): Promise => { + const response = await request + .get(api(`livechat/visitor/${token}`)) + .set(credentials) + .expect(200); + expect(response.body).to.have.property('visitor'); + return response.body.visitor; +}; diff --git a/apps/meteor/tests/data/messages.helper.ts b/apps/meteor/tests/data/messages.helper.ts new file mode 100644 index 0000000..9a8160e --- /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/mock-server.helper.ts b/apps/meteor/tests/data/mock-server.helper.ts new file mode 100644 index 0000000..e1f2959 --- /dev/null +++ b/apps/meteor/tests/data/mock-server.helper.ts @@ -0,0 +1,93 @@ +import { MOCK_SERVER_URL } from '../e2e/config/constants'; + +type Decision = 'DECISION_PERMIT' | 'DECISION_DENY'; + +export const mockServerSet = async (method: string, path: string, body: unknown, statusCode = 200, times = 0): Promise => { + const res = await fetch(`${MOCK_SERVER_URL}/__mock/set`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method, + path, + response: { status_code: statusCode, body, times }, + }), + }); + if (!res.ok) { + throw new Error(`Failed to program mock-server: ${res.status} ${await res.text()}`); + } +}; + +export const mockServerSetMany = async ( + mocks: Array<{ method: string; path: string; body: unknown; statusCode?: number; times?: number }>, +): Promise => { + const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-many`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + mocks.map((m) => ({ + method: m.method, + path: m.path, + response: { status_code: m.statusCode ?? 200, body: m.body, times: m.times ?? 0 }, + })), + ), + }); + if (!res.ok) { + throw new Error(`Failed to program mock-server (set-many): ${res.status} ${await res.text()}`); + } +}; + +export const mockServerReset = async (): Promise => { + await fetch(`${MOCK_SERVER_URL}/__mock/reset`, { method: 'DELETE' }); +}; + +export const mockServerHealthy = async (): Promise => { + try { + const res = await fetch(`${MOCK_SERVER_URL}/__mock/health`); + return res.ok; + } catch { + return false; + } +}; + +export const seedDefaultMocks = async () => { + await mockServerSetMany([ + { + method: 'GET', + path: '/healthz', + body: { status: 'SERVING' }, + }, + { + method: 'POST', + path: '/auth/realms/mock/protocol/openid-connect/token', + body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 }, + }, + ]); +}; + +export const seedGetDecisionBulk = async ( + responses: Array<{ resourceDecisions: Array<{ decision: Decision; ephemeralResourceId?: string }> }>, + times = 0, +) => { + await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionResponses: responses }, 200, times); +}; + +export const seedGetEntitlements = async (fqnMap: Record, times = 0) => { + await mockServerSet( + 'POST', + '/authorization.v2.AuthorizationService/GetEntitlements', + { entitlements: [{ actionsPerAttributeValueFqn: fqnMap }] }, + 200, + times, + ); +}; + +export const seedBulkDecisionByEntity = async (permitValues: string[], defaultDecision: Decision = 'DECISION_DENY') => { + const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-bulk-decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ permit_values: permitValues, default_decision: defaultDecision }), + }); + if (!res.ok) { + throw new Error(`Failed to program mock-server (set-bulk-decision): ${res.status} ${await res.text()}`); + } +}; diff --git a/apps/meteor/tests/data/permissions.helper.ts b/apps/meteor/tests/data/permissions.helper.ts new file mode 100644 index 0000000..fe83450 --- /dev/null +++ b/apps/meteor/tests/data/permissions.helper.ts @@ -0,0 +1,122 @@ +import type { ISetting } from '@rocket.chat/core-typings'; + +import { api, credentials, request } from './api-data'; +import { omnichannelEEPermissions } from '../../ee/server/lib/omnichannel/permissions'; +import { permissions } from '../../server/lib/authorization/constant/permissions'; +import { IS_EE } from '../e2e/config/constants'; + +export const updatePermission = (permission: string, roles: string[]): Promise => + new Promise((resolve, reject) => { + void request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions: [{ _id: permission, roles }] }) + .expect('Content-Type', 'application/json') + .expect(200) + .end((err?: Error) => { + if (err) { + reject(err); + return; + } + + resolve(); + }); + }); + +export const updateEEPermission = (permission: string, roles: string[]): Promise => + IS_EE ? updatePermission(permission, roles) : Promise.resolve(); + +const updateManyPermissions = (permissions: { [key: string]: string[] }): Promise => + new Promise((resolve, reject) => { + void request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions: Object.keys(permissions).map((k) => ({ _id: k, roles: permissions[k] })) }) + .expect('Content-Type', 'application/json') + .expect(200) + .end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100)); + }); + +export const updateSetting = (setting: string, value: ISetting['value'], debounce = true): Promise => + new Promise((resolve, reject) => { + void request + .post(`/api/v1/settings/${setting}`) + .set(credentials) + .send({ value }) + .expect('Content-Type', 'application/json') + .expect(200) + .end((err?: Error) => { + if (err) { + return reject(err); + } + + if (debounce) { + setTimeout(resolve, 100); + return; + } + + resolve(); + }); + }); + +export const getSettingValueById = async (setting: string): Promise => { + const response = await request.get(`/api/v1/settings/${setting}`).set(credentials).expect('Content-Type', 'application/json').expect(200); + + return response.body.value; +}; + +export const updateEESetting = (setting: string, value: ISetting['value']): Promise => + IS_EE + ? new Promise((resolve, reject) => { + void request + .post(`/api/v1/settings/${setting}`) + .set(credentials) + .send({ value }) + .expect('Content-Type', 'application/json') + .expect(200) + .end((err?: Error) => setTimeout(() => (!err && resolve()) || reject(err), 100)); + }) + : Promise.resolve(); + +export const removePermissions = async (perms: string[]) => { + await updateManyPermissions(Object.fromEntries(perms.map((name) => [name, []]))); +}; + +export const addPermissions = async (perms: { [key: string]: string[] }) => { + await updateManyPermissions(perms); +}; + +type Permission = (typeof permissions)[number]['_id']; + +export const removePermissionFromAllRoles = async (permission: Permission) => { + await updatePermission(permission, []); +}; + +const getPermissions = () => { + if (!IS_EE) { + return permissions; + } + + return [...permissions, ...omnichannelEEPermissions]; +}; + +export const restorePermissionToRoles = async (permission: Permission) => { + const defaultPermission = getPermissions().find((p) => p._id === permission); + if (!defaultPermission) { + throw new Error(`No default roles found for permission ${permission}`); + } + + const mutableDefaultRoles: string[] = defaultPermission.roles.map((r) => r); + + if (!IS_EE) { + const eeOnlyRoles = ['livechat-monitor']; + eeOnlyRoles.forEach((role) => { + const index = mutableDefaultRoles.indexOf(role); + if (index !== -1) { + mutableDefaultRoles.splice(index, 1); + } + }); + } + + await updatePermission(permission, mutableDefaultRoles); +}; diff --git a/apps/meteor/tests/data/roles.helper.ts b/apps/meteor/tests/data/roles.helper.ts new file mode 100644 index 0000000..d499677 --- /dev/null +++ b/apps/meteor/tests/data/roles.helper.ts @@ -0,0 +1,41 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRole } from '@rocket.chat/core-typings'; + +import { api, credentials, request } from './api-data'; + +export const createCustomRole = async ({ + name, + scope, + description, + credentials: customCredentials, +}: Pick & { credentials?: Credentials }) => { + const response = await request + .post(api('roles.create')) + .set(customCredentials || credentials) + .send({ name, scope, description }); + return response.body.role as IRole; +}; + +export const deleteCustomRole = async ({ roleId, credentials: customCredentials }: { roleId: IRole['_id']; credentials?: Credentials }) => { + const response = await request + .post(api('roles.delete')) + .set(customCredentials || credentials) + .send({ roleId }); + return response.body.success as boolean; +}; + +export const assignRoleToUser = async ({ + username, + roleId, + credentials: customCredentials, +}: { + username: string; + roleId: string; + credentials?: Credentials; +}) => { + const response = await request + .post(api('roles.addUserToRole')) + .set(customCredentials || credentials) + .send({ username, roleId }); + return response.body.success as boolean; +}; diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts new file mode 100644 index 0000000..0d55ce4 --- /dev/null +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -0,0 +1,539 @@ +import type { Credentials } from '@rocket.chat/api-client'; +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'; + +type CreateRoomParams = { + name?: IRoom['name']; + type: IRoom['t']; + username?: string; + members?: string[]; + credentials?: Credentials; + readOnly?: boolean; + extraData?: Record; + config?: IRequestConfig; +}; + +export const createRoom = ({ + name, + type, + username, + members, + credentials: customCredentials, + extraData, + readOnly, + config, +}: CreateRoomParams) => { + if (!type) { + throw new Error('"type" is required in "createRoom.ts" test helper'); + } + + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || customCredentials || credentials; + + if (type === 'd' && !username) { + throw new Error('To be able to create DM Room, you must provide the username'); + } + + const endpoints = { + c: 'channels.create', + p: 'groups.create', + d: 'im.create', + } as const; + const params = type === 'd' ? { username } : { name }; + + const roomType = endpoints[type as keyof typeof endpoints]; + + return requestInstance + .post(api(roomType)) + .set(credentialsInstance) + .send({ + ...params, + ...(members && { members }), + ...(readOnly && { readOnly }), + ...(extraData && { extraData }), + }); +}; + +type ActionType = 'delete' | 'close' | 'addOwner' | 'removeOwner'; +export type ActionRoomParams = { + action: ActionType; + type: Exclude; + roomId: IRoom['_id']; + overrideCredentials?: Credentials; + extraData?: Record; +}; + +export function actionRoom({ action, type, roomId, overrideCredentials = credentials, extraData = {} }: ActionRoomParams) { + if (!type) { + throw new Error(`"type" is required in "${action}Room" test helper`); + } + if (!roomId) { + throw new Error(`"roomId" is required in "${action}Room" test helper`); + } + const endpoints = { + c: 'channels', + p: 'groups', + d: 'im', + } as const; + + const path = `${endpoints[type]}.${action}` as const; + + if (path === 'im.addOwner' || path === 'im.removeOwner') throw new Error(`invalid path ("${path}")`); + + return new Promise((resolve) => { + void request + .post(api(path)) + .set(overrideCredentials) + .send({ + roomId, + ...extraData, + }) + .end(resolve); + }); +} + +export const deleteRoom = ({ type, roomId }: { type: ActionRoomParams['type']; roomId: IRoom['_id'] }) => + actionRoom({ action: 'delete', type, roomId, overrideCredentials: credentials }); + +export const getSubscriptionByRoomId = (roomId: IRoom['_id'], userCredentials = credentials, req = request): Promise => + new Promise((resolve, reject) => { + void req + .get(api('subscriptions.getOne')) + .set(userCredentials) + .query({ roomId }) + .end((err, res) => { + if (err) { + return reject(err); + } + if (!res.body?.subscription) { + return reject(new Error('Subscription not found')); + } + + resolve(res.body.subscription); + }); + }); + +/** + * Adds users to a room using the REST invite endpoints (channels.invite / groups.invite). + * + * This is the entrypoint the "Add users" UI uses. Supports both local and federated users. + * The REST endpoints accept a single invitee per call, so this issues one request per + * username (mirroring how the UI fans out the invites) and resolves to the array of responses. + * + * @param usernames - Array of usernames to add to the room + * @param rid - The unique identifier of the room + * @param type - Room type, selects the endpoint: 'c' -> channels.invite, 'p' -> groups.invite + * @param userCredentials - Optional credentials for the request (deprecated, use config instead) + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the array of REST invite responses (one per username) + */ +export const addUserToRoom = ({ + usernames, + rid, + type = 'c', + userCredentials, + config, +}: { + usernames: string[]; + rid: IRoom['_id']; + type?: 'c' | 'p'; + userCredentials?: Credentials; + config?: IRequestConfig; +}) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || userCredentials || credentials; + const endpoint = type === 'p' ? 'groups.invite' : 'channels.invite'; + + // The REST invite endpoints accept a single invitee per call, so we issue one + // request per username (mirroring how the UI fans out the invites). + return Promise.all( + usernames.map((username) => requestInstance.post(api(endpoint)).set(credentialsInstance).send({ roomId: rid, username })), + ); +}; + +/** + * Adds users to a room using the deprecated `addUsersToRoom` DDP method. + * + * The method is deprecated in favour of the REST invite endpoints (see {@link addUserToRoom}), + * but it is still a supported entrypoint. Prefer the REST helper for general test setup — this + * helper exists to keep dedicated coverage of the DDP-method entrypoint (e.g. federation invites). + * + * @param usernames - Array of usernames to add to the room + * @param rid - The unique identifier of the room + * @param userCredentials - Optional credentials for the request (deprecated, use config instead) + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the method call response + */ +export const addUserToRoomViaMethod = ({ + usernames, + rid, + userCredentials, + config, +}: { + usernames: string[]; + rid: IRoom['_id']; + userCredentials?: Credentials; + config?: IRequestConfig; +}) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || userCredentials || credentials; + + return requestInstance + .post(methodCall('addUsersToRoom')) + .set(credentialsInstance) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid, users: usernames }], + id: 'id', + msg: 'method', + }), + }); +}; + +/** + * 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 } + */ +// TODO(ddp-removal): swap /api/v1/method.call/slashCommand for +// POST /v1/commands.run. Same caveat as addUserToRoom: federation specs +// inspect the DDP-style `message` payload and need to be ported to the +// REST envelope first. +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)); + } + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const membersResponse = await getRoomMembers(roomId, config); + const member = membersResponse.members.find((member: IUser) => member.username === username); + + if (member) { + return member; + } + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + console.warn(`Attempt ${attempt} to find room member failed:`, error); + + if (attempt < maxRetries) { + 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; + }; +}; + +/** + * Accepts a room invite for the authenticated user. + * + * Processes a room invitation by accepting it, which grants the user + * access to the room. This is essential for federated room workflows + * where users receive invitations rather than auto-joining. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the acceptance response + */ +export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'accept', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Retrieves the subscriptions for the authenticated user. + * + * Fetches the complete list of subscriptions for the authenticated user, which is essential + * for verifying federation subscription synchronization and member synchronization. + * + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the subscriptions response + */ + +export const getSubscriptions = (config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise>((resolve) => { + void requestInstance + .get(api('subscriptions.get')) + .set(credentialsInstance) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; + +/** + * Rejects a room invite for the authenticated user. + * + * Processes a room invitation by rejecting it, which prevents the user + * from joining the room and removes them from the invited members list. + * This is essential for federated room workflows where users can decline invitations. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the rejection response + */ +export const rejectRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'reject', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; diff --git a/apps/meteor/tests/data/slashcommands.helpers.ts b/apps/meteor/tests/data/slashcommands.helpers.ts new file mode 100644 index 0000000..51db95d --- /dev/null +++ b/apps/meteor/tests/data/slashcommands.helpers.ts @@ -0,0 +1,35 @@ +import { credentials, methodCall, request } from './api-data'; + +/** + * Executes an app slash command via a POST request to the slashCommand method. + * + * @param cmd - The slashcommand name to execute + * @param rid - The room ID where the command will be executed + * @param params - Optional parameters to pass to the command (default: '') + * @param triggerId - Optional trigger ID for the command invocation (default: 'triggerId') + * + * @returns Promise resolving to the API response + */ +export const executeAppSlashCommand = (cmd: string, rid: string, params = '', triggerId = 'triggerId') => + request + .post(methodCall('slashCommand')) + .set(credentials) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'slashCommand', + params: [ + { + cmd, + params, + triggerId, + msg: { + rid, + _id: 'message_id', + msg: `/${cmd} ${params}`.trim(), + }, + }, + ], + }), + }); diff --git a/apps/meteor/tests/data/teams.helper.ts b/apps/meteor/tests/data/teams.helper.ts new file mode 100644 index 0000000..4f4d47d --- /dev/null +++ b/apps/meteor/tests/data/teams.helper.ts @@ -0,0 +1,27 @@ +import type { ITeam, TeamType } from '@rocket.chat/core-typings'; + +import { api, request } from './api-data'; + +export const createTeam = async ( + credentials: Record, + teamName: string, + type: TeamType, + members?: string[], +): Promise => { + const response = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type, + ...(members && { members }), + }); + + return response.body.team; +}; + +export const deleteTeam = async (credentials: Record, teamName: string): Promise => { + await request.post(api('teams.delete')).set(credentials).send({ + teamName, + }); +}; diff --git a/apps/meteor/tests/data/uploads.helper.ts b/apps/meteor/tests/data/uploads.helper.ts new file mode 100644 index 0000000..f89f5c2 --- /dev/null +++ b/apps/meteor/tests/data/uploads.helper.ts @@ -0,0 +1,380 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { api, request, credentials } from './api-data'; +import { imgURL, soundURL } from './interactions'; +import { updateSetting } from './permissions.helper'; +import { addUserToRoom, createRoom, deleteRoom } from './rooms.helper'; +import { password } from './user'; +import { createUser, deleteUser, login } from './users.helper'; + +export async function testFileUploads( + filesEndpoint: 'channels.files' | 'groups.files' | 'im.files', + roomType: 'c' | 'd' | 'p', + invalidRoomError = 'error-room-not-found', +) { + let testRoom: IRoom; + const propertyMap = { + c: 'channel', + p: 'group', + d: 'room', + }; + let user: any; + + before(async () => { + await Promise.all([updateSetting('Message_KeepHistory', true)]); + user = await createUser(); + + testRoom = ( + await createRoom({ + type: roomType, + ...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }), + } as any) + ).body[propertyMap[roomType]]; + }); + + after(() => + Promise.all([deleteRoom({ type: 'c' as const, roomId: testRoom._id }), updateSetting('Message_KeepHistory', false), deleteUser(user)]), + ); + + it('should fail if invalid channel', (done) => { + void request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', invalidRoomError); + }) + .end(done); + }); + + it('should succeed when searching by roomId', (done) => { + void request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + }) + .end(done); + }); + + it('should succeed when searching by roomId even requested with count and offset params', (done) => { + void request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + }) + .end(done); + }); + + it('should succeed when searching by roomName', function (done) { + if (!testRoom.name) { + this.skip(); + } + void request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomName: testRoom.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + }) + .end(done); + }); + + it('should succeed when searching by roomName even requested with count and offset params', function (done) { + if (!testRoom.name) { + this.skip(); + } + void request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomName: testRoom.name, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + }) + .end(done); + }); + + let fileId: string; + it('should not return thumbnails', async () => { + await request + .post(api(`rooms.media/${testRoom._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1); + + const { files } = res.body; + + files.forEach((file: unknown) => { + expect(file).to.not.have.property('originalFileId'); + }); + }); + }); + + it('should not return hidden files', async () => { + let msgId; + + await request + .post(api(`rooms.media/${testRoom._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + msgId = res.body.message._id; + }); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: testRoom._id, + msgId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1); + + const { files } = res.body; + files.forEach((file: unknown) => { + expect(file).to.have.property('_id').to.not.be.equal(fileId); + }); + }); + }); + + it('should properly filter files by name or typeGroup', async () => { + const fileOneName = 'image-zyxwv.png'; + const fileTwoName = 'sound-abcde.png'; + const fileIdsToConfirm: string[] = []; + + // Post 2 files, one image and one audio + await Promise.all([ + request + .post(api(`rooms.media/${testRoom._id}`)) + .set(credentials) + .attach('file', imgURL, { filename: fileOneName }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(typeof res.body?.file?._id).to.equal('string'); + fileIdsToConfirm.push(res.body.file._id); + }), + request + .post(api(`rooms.media/${testRoom._id}`)) + .set(credentials) + .attach('file', soundURL, { filename: fileTwoName }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(typeof res.body?.file?._id).to.equal('string'); + fileIdsToConfirm.push(res.body.file._id); + }), + ]); + + // Confirm the files + await Promise.all( + fileIdsToConfirm.map((fileId) => + request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200), + ), + ); + + // test filtering by name + const nameFilterTest = request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + name: fileOneName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1); + + const { files } = res.body; + + expect(files[0].name).to.equal(fileOneName); + }); + + // test filtering by typeGroup + const typeGroupFilterTest = request + .get(api(filesEndpoint)) + .set(credentials) + .query({ + roomId: testRoom._id, + typeGroup: 'audio', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').with.lengthOf(1); + + const { files } = res.body; + + expect(files[0].name).to.equal(fileTwoName); + }); + + await Promise.all([nameFilterTest, typeGroupFilterTest]); + }); + + describe('with another user', () => { + let anotherUserCreds: Credentials; + let anotherUser: IUser; + let extraRoom: IRoom; + + before(async () => { + anotherUser = await createUser(); + anotherUserCreds = await login(anotherUser.username, password); + + extraRoom = ( + await createRoom({ + type: roomType, + ...(roomType === 'd' ? { username: user.username } : { name: `channel-files-${Date.now()}` }), + credentials: anotherUserCreds, + } as any) + ).body[propertyMap[roomType]]; + + if (roomType === 'p') { + await addUserToRoom({ + rid: testRoom._id, + usernames: [anotherUser.username!], + type: 'p', + }); + } + }); + + after(() => Promise.all([deleteUser(anotherUser), deleteRoom({ type: roomType, roomId: extraRoom._id })])); + + it('should not allow to confirm a file from another user', async function () { + if (roomType === 'd') { + this.skip(); + } + + let fileId: string; + await request + .post(api(`rooms.media/${testRoom._id}`)) + .set(anotherUserCreds) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should not allow to confirm a file that was not uploaded to the same room', async () => { + let fileId: string; + + await request + .post(api(`rooms.media/${extraRoom._id}`)) + .set(anotherUserCreds) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testRoom._id}/${fileId!}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); +} diff --git a/apps/meteor/tests/data/user.ts b/apps/meteor/tests/data/user.ts new file mode 100644 index 0000000..2c6658a --- /dev/null +++ b/apps/meteor/tests/data/user.ts @@ -0,0 +1,12 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; + +export const password = 'R0ck3t.ch@tP@ssw0rd1234.!'; +export const adminUsername = 'rocketchat.internal.admin.test'; +export const adminEmail = `${adminUsername}@rocket.chat`; +export const adminPassword = adminUsername; + +export type IUserWithCredentials = { + user: IUser; + credentials: Credentials; +}; diff --git a/apps/meteor/tests/data/users.helper.ts b/apps/meteor/tests/data/users.helper.ts new file mode 100644 index 0000000..b8835cc --- /dev/null +++ b/apps/meteor/tests/data/users.helper.ts @@ -0,0 +1,201 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import supertest from 'supertest'; +import type { Response } from 'supertest'; + +import { api, credentials, methodCall, request } from './api-data'; +import { password } from './user'; + +export type TestUser = TUser & { username: string; emails: string[] }; + +/** + * Configuration interface for custom request handling. + * + * Provides a way to override the default request instance and credentials + * for testing scenarios that require custom domains or authentication. + */ +export interface IRequestConfig { + credentials: Credentials; + request: ReturnType; +} + +/** + * Creates a request configuration for a specific domain. + * + * Sets up a new request instance and authenticates with the specified + * domain, user, and password. This is essential for federation testing + * where multiple Rocket.Chat instances need to be accessed. + * + * @param domain - The base URL of the Rocket.Chat instance + * @param user - The username for authentication + * @param password - The password for authentication + * @returns Promise resolving to request configuration with credentials + */ +export async function getRequestConfig(domain: string, user: string, password: string): Promise { + const request = supertest(domain); + const credentials = await login(user, password, { request, credentials: {} as Credentials }); + + return { + credentials, + request, + }; +} + +export const createUser = ( + userData: { + username?: string; + email?: string; + roles?: string[]; + active?: boolean; + joinDefaultChannels?: boolean; + verified?: boolean; + requirePasswordChange?: boolean; + name?: string; + password?: string; + freeSwitchExtension?: string; + bio?: string; + nickname?: string; + } = {}, + config?: IRequestConfig, +) => + new Promise>((resolve, reject) => { + const username = userData.username || `user.test.${Date.now()}.${Math.random()}`; + const email = userData.email || `${username}@rocket.chat`; + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + void requestInstance + .post(api('users.create')) + .set(credentialsInstance) + .send({ email, name: username, username, password, ...userData }) + .end((err: unknown, res: Response) => { + if (err) { + return reject(err); + } + resolve(res.body.user); + }); + }); + +export const login = (username: string | undefined, password: string, config?: IRequestConfig): Promise => + new Promise((resolve) => { + const requestInstance = config?.request || request; + void requestInstance + .post(api('login')) + .send({ + user: username, + password, + }) + .end((_err: unknown, res: Response) => { + resolve({ + 'X-Auth-Token': res.body.data.authToken, + 'X-User-Id': res.body.data.userId, + }); + }); + }); + +export const deleteUser = async (user: Pick, extraData = {}, config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + return requestInstance + .post(api('users.delete')) + .set(credentialsInstance) + .send({ + userId: user._id, + ...extraData, + }); +}; + +export const getUserByUsername = (username: string, config?: IRequestConfig) => + new Promise>((resolve) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + void requestInstance + .get(api('users.info')) + .query({ username }) + .set(credentialsInstance) + .end((_err: unknown, res: Response) => { + resolve(res.body.user); + }); + }); + +export const getMe = (overrideCredential = credentials, config?: IRequestConfig) => + new Promise>((resolve) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || overrideCredential; + void requestInstance + .get(api('me')) + .set(credentialsInstance) + .expect('Content-Type', 'application/json') + .expect(200) + .end((_end: unknown, res: Response) => { + resolve(res.body); + }); + }); + +export const setUserActiveStatus = (userId: IUser['_id'], activeStatus = true, config?: IRequestConfig) => + new Promise((resolve) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + void requestInstance + .post(api('users.setActiveStatus')) + .set(credentialsInstance) + .send({ + userId, + activeStatus, + }) + .end(resolve); + }); + +export const setUserStatus = (overrideCredentials = credentials, status = UserStatus.ONLINE, config?: IRequestConfig) => { + const requestInstance = config?.request || request; + return requestInstance.post(api('users.setStatus')).set(overrideCredentials).send({ + message: '', + status, + }); +}; + +export const setUserAway = (overrideCredentials = credentials, config?: IRequestConfig) => { + const requestInstance = config?.request || request; + return requestInstance + .post(methodCall('UserPresence:away')) + .set(overrideCredentials) + .send({ + message: JSON.stringify({ + method: 'UserPresence:away', + params: [], + id: 'id', + msg: 'method', + }), + }); +}; + +export const setUserOnline = (overrideCredentials = credentials, config?: IRequestConfig) => { + const requestInstance = config?.request || request; + return requestInstance + .post(methodCall('UserPresence:online')) + .set(overrideCredentials) + .send({ + message: JSON.stringify({ + method: 'UserPresence:online', + params: [], + id: 'id', + msg: 'method', + }), + }); +}; + +export const removeRoleFromUser = (username: string, roleId: string, overrideCredentials = credentials, config?: IRequestConfig) => + getUserByUsername(username, config).then((user) => { + const requestInstance = config?.request || request; + return requestInstance + .post(api('users.update')) + .set(overrideCredentials) + .send({ + userId: user._id, + data: { roles: user.roles.filter((role) => role !== roleId) }, + }) + .expect(200); + }); diff --git a/apps/meteor/tests/data/utils.ts b/apps/meteor/tests/data/utils.ts new file mode 100644 index 0000000..fe0c942 --- /dev/null +++ b/apps/meteor/tests/data/utils.ts @@ -0,0 +1,40 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { Path } from '@rocket.chat/rest-typings'; +import { expect } from 'chai'; + +import { api, request, type PathWithoutPrefix } from './api-data'; + +export function withTimeout(fn: (signal: AbortSignal) => Promise, ms: number): Promise { + const controller = new AbortController(); + + const timeoutId = setTimeout(() => { + controller.abort(); + }, ms); + + return fn(controller.signal).finally(() => { + clearTimeout(timeoutId); + }); +} + +export const pagination = >( + apiEndpoint: TPath, + credentials: Credentials, + extraQueryParams: Record = {}, +) => { + return request + .get(api(apiEndpoint)) + .set(credentials) + .query({ + ...extraQueryParams, + count: 10, + offset: 1, + sort: JSON.stringify({ _updatedAt: -1 }), + }) + .expect('content-type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('count').that.is.a('number'); + expect(res.body).to.have.property('offset').that.is.a('number'); + expect(res.body).to.have.property('total').that.is.a('number'); + }); +}; diff --git a/apps/meteor/tests/data/validation.helper.ts b/apps/meteor/tests/data/validation.helper.ts new file mode 100644 index 0000000..08aa539 --- /dev/null +++ b/apps/meteor/tests/data/validation.helper.ts @@ -0,0 +1,11 @@ +import { expect } from 'chai'; + +import type { request } from './api-data'; + +export function expectInvalidParams(res: Awaited>, expectedErrors: string[]): void { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.a('string'); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect((res.body.error as string).split('\n').map((line) => line.trim())).to.be.deep.equal(expectedErrors); +} diff --git a/apps/meteor/tests/e2e/README.md b/apps/meteor/tests/e2e/README.md new file mode 100644 index 0000000..07f6c55 --- /dev/null +++ b/apps/meteor/tests/e2e/README.md @@ -0,0 +1,410 @@ +# E2E Testing with playwright + +## Running tests +The application must be started with `TEST_MODE=true` + +```sh +$ TEST_MODE=true yarn dev +``` + +Then we can run a single suite with + +```sh +$ yarn test:e2e ./tests/e2e/administration.spec.ts +``` + +Or all the tests with + +```sh +$ yarn test:e2e +``` + +We can also provide some env vars to `test:e2e` script: +- `BASE_URL=` Run the tests to the given url +- `PWDEBUG=1` Control the test execution + +## Page Objects +- Any locator name must start with of one the following prefixes: `btn`, `link`, `input`, `select`, `checkbox`, `text` + +## Important links +- [playwright docs](https://playwright.dev/docs/intro) + +## Assertions and waiting + +- Prefer Playwright web-first assertions such as `toBeVisible()` and `toHaveText()`. They retry until the expected condition is met. +- Use Playwright's `expect` matchers instead of Node-style `assert` statements. +- Rely on Playwright's built-in auto-waiting whenever possible. +- When an explicit wait is necessary, wait for a specific condition with APIs such as `locator.waitFor()`, `page.waitForURL()`, or `page.waitForResponse()`. +- Prefer waiting for observable conditions over fixed delays. If a fixed delay is unavoidable, document why no observable condition can be awaited. + +```ts + await expect(anyElement).toBeVisible(); + ``` + +# Playwright Locator Best Practices + +## Preferred locator types: + +### 1. By role `.getByRole()`: +This is the most recommended locator type. +- It ensures our element is accessible to screen readers and other assistive technologies. +- It ensures that the element is uniquely identifiable. +- It's recommended to use the `exact` option to ensure that the locator doesn't match any other element with the same role + + +```ts +await page.getByRole('button', { name: 'Save', exact: true }); + +// Without the `exact` option, Playwright would match buttons with 'Save' and 'Save changes' labels. +``` + + +### 2. By text `.getByText()` or `.getByLabel()`: +- Use this when locating by role is not possible or not sufficient. +- Be sure to restrict the scope of the locator to the element you are looking for. + +### 3. Locator with `has`: +Our input elements hide the native input and render a custom component. + +To target the input and trigger changes, you should locate the label that wraps the input and use the `has` locator. +```ts +page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Private' }) }); +``` + +## Locator types to avoid (at all costs): +If you are not able to find the element by role, label or text, it's a sign that the element is not accessible to the user. +The component should be refactored to allow a more accessible locator. +### 1. By `data-qa-id` or testId `.getByTestId()`: +```ts +// DON'T ❌ +page.locator('[data-qa-id="menu-more-actions"]'); + +// DO ✅ +page.getByRole('menu', {name: 'More actions', exact: true }); +``` + +### 2. By elements and class names `.locator('div.class-name')`: +The HTML structure and/or class names can easily change over time, so it's better to use the locator by role and name. + +```ts +// DON'T ❌ +page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary'); + +// #modal-root was used to locate the injected portal element for the modal. +// This can be better located by role 'dialog' and making sure the modal has the proper name attribute. + +// DO ✅ +page.getByRole('dialog', name: 'Modal name example').getByRole('button', { name: 'Confirm', exact: true }); +``` + +### 3. By position: +Using `nth-child` or similar selectors is not recommended as it can easily change. +It's a fragile locator and it's hard to maintain. + +### 4. Parent/Child relationships: +A change in the DOM structure can break the test. + +## Use our page-objects: +> apps/meteor/tests/e2e/page-objects +- page-objects are a great way to reuse locators across tests using `getters` and `methods`. +- They make it easier to write tests that are more readable and maintainable. +- Always make sure to use the most restricted scope possible - not the whole page to avoid multiple matches. +- Page objects own navigation - see [Navigation](#navigation-goto-owns-the-wait). + +If you are writing a new test, make sure to look at the existing page-objects to see if there is a suitable one for your use case. + +If not, evaluate if creating a reusable getter/method is worth it and create the ones you need in the respective context. + +E.g.: +Writing a new test for a sidebar behavior. +- check `apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts` if the fragments you need are already there. +- If not, create new getters and/or methods in this file. + +Usage example: + +```ts +// apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts +// ... + + get sidebar(): Locator { + return this.page.getByRole('navigation', { name: 'sidebar' }); + } + + get channelsList(): Locator { + return this.sidebar.getByRole('list', { name: 'Channels' }); + } + + // Restricted scope: inside navigation > sidebar > list named Channels > link with name + getSearchRoomByName(name: string) { + return this.channelsList.getByRole('link', { name }); + } +``` + +```ts +// test.spec.ts + + test('should display sidebar items', async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + const targetChannel = 'channel-test'; + + await expect(poHomeChannel.sidebar.getSearchRoomByName(targetChannel)).toBeVisible(); + }); +``` + +## Navigation: `goto()` owns the wait + +`page.goto()` resolves as soon as the document loads, long before the SPA has rendered - the next +locator then races the app. So page objects own navigation: `goto()` navigates *and* waits. + +```ts +poAdminUsers = new AdminUsers(page); +await poAdminUsers.goto(); // the next line can act on the page +``` + +Never call `page.goto()` from a test. If the page you need has no page object, add one - under +`Admin`, `Account` or `OmnichannelAdmin` it is usually two lines: + +```ts +export class AdminThirdPartyLogin extends Admin { + protected readonly route = AdminSectionsHref.thirdPartyLogin; + + protected readonly title = 'Third-party login'; +} +``` + +`title` is the page's heading, matched with `exact: true` - copy it from +`packages/i18n/src/locales/en.i18n.json` rather than guessing the casing. It also scopes +`pageContent`, the `main` region to hang the page's other locators off. + +Anything else extends `RoutedPage` directly and implements `waitForReady()`. Override it when the +heading is not enough - `AdminUsers` waits for its search box, `OmnichannelAdmin` for its table. + +Two more hooks: + +- `gotoExpecting(until)` - the route settles on something else, e.g. an upsell dialog on CE. +- `navigateTo(route, until?)` - build extra `gotoSomething()` methods when a page object owns more + than one route (`gotoGeneral()`, `gotoChannel(name)`). `until` takes a `Locator` or an async waiter. + +Auth screens (`Login`, `Registration`, `Authenticated`) take the url to navigate to, since an +anonymous visitor asking for `/home` lands somewhere else. + +A raw `page.goto()` is still right for external urls, urls derived from `page.url()`, and tests that +must not wait for the route to settle. + +## Cleanup after tests +- Remember to delete all users, channels, rooms, etc, created during the tests. +- Reset settings to their default values after the tests, if changed. +- Close all new pages opened during the tests. +- You can use the `test.afterAll()` or `test.afterEach()` methods to clean up after the tests. + + +```ts +// test.spec.ts + +test.describe.serial('feature example', ({ api}) => { + let targetChannel; + let targetDiscussion; + + test.beforeAll(async ({ api }) => { + // change setting value + await setSettingValueById(api, 'Accounts_AllowFeaturePreview', true); + + // create channel + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + + // create discussion + targetDiscussion = await createTargetDiscussion(api); + }); + test.afterAll(async ({ api }) => { + // reset setting value + await setSettingValueById(api, 'Accounts_AllowFeaturePreview', false); + + // delete channel + await deleteChannel(api, targetChannel); + + // delete discussion + await deleteRoom(api, targetDiscussion._id); + }); +}) +``` + +## General recommendations + +### Use descriptive test names + +Test names should clearly describe the behavior and expected outcome being verified. + +### Use `test.describe` for grouping tests +It can be used to group related tests into a test suite. It provides a way to organize tests logically and improve the readability and maintainability of your test code. +```ts +test.describe('Feature Test', () => { + test('should display the feature heading after navigation', async ({ api}) => { + // do some tests + }); +}); +``` + +### Use `test.step` for grouping steps +Enhances test readability and provides more detailed information in test reports +```ts +test.describe('Feature Test', () => { + test('should display the feature heading after navigation', async ({ page }) => { + await test.step('open the feature page', async () => { + const poFeature = new FeaturePage(page); + await poFeature.goto(); + await expect(page.getByRole('heading', { name: 'Feature' })).toBeVisible(); + }); + }); +}); +``` + +### Big test files should not be `.serial`: +- Ok for tests with very few steps, when steps have dependencies on each other, you can simulate the user flow sequentially. +- Avoid chaining big tests with `test.serial` - dependant steps make it harder to debug and/or make small changes +> If you are changing something on the 34th step, you would have to run the whole test suite multiple times during development, instead of olny running the test step in question. + +## Performance patterns + +E2E tests are the most expensive tests we run. Two patterns, applied together, typically cut a suite's runtime by ~80% and remove the most common sources of flake. They are the standard for new suites and the target for migrating existing ones. + +### Pattern 1 — seed state through the API, not the UI + +Use the browser **only for what the test actually verifies**. Everything else — creating rooms, DMs, discussions, threads, sending setup messages — goes through REST. + +Why: +- A REST call costs 30–150ms; the equivalent UI flow (menu → modal → input → submit → wait for nav) costs 2–8s per step. +- API errors are deterministic and synchronous. UI errors manifest as timeouts on overloaded CI, which is a slow and ambiguous signal. +- The test fails only when the feature under test breaks, not when an unrelated setup UI regresses. A broken "create discussion" modal should not take down every test that needs a discussion as fixture. +- Removes the most flake-prone interactions: hover-revealed menus, modal animations, search debounces. + +Do **not** apply when the setup UI is the actual subject of the test (`create-direct.spec.ts`, `create-discussion.spec.ts`, `create-channel.spec.ts`, etc.). There, keep one test per creation flow going through the UI; unrelated tests rely on the API path. + +### Pattern 2 — share the browser context across a serial suite + +For suites already using `test.describe.serial`, move `browser.newContext(), pageObject.goto()` and the initial room navigation from `beforeEach` to `beforeAll`. + +Why: bootstrapping a Playwright context plus hydrating the Meteor app costs ~1.5–2.5s per test. In a serial suite the isolation cost is already paid — collecting the speed benefit is free. + +Preconditions: +- Suite is `test.describe.serial`. +- All tests share the same user / storage state. +- Each test leaves the page in a known "home position" (base room open, no modals, threads, or side panels lingering). + +Do **not** apply when: +- Tests need different users or storage states. +- Suite covers auth, 2FA, or session lifecycle — the browser state is the subject. +- Suite has more than ~15 tests: the debug cost of shared state outgrows the speed win (see "Big test files should not be `.serial`" above). + +### Pattern 3 — navigate to a known room by URL, not by search + +When a test already knows the room it needs (a channel created via API, `general`, etc.), open it directly with `poHomeChannel.gotoChannel(name)` instead of going home and searching for it. + +```ts +// ❌ fragile — three independent things must align before the combobox `fill` resolves +await page.goto('/home'); +await poHomeChannel.navbar.openChat(targetChannel); + +// ✅ deterministic — navigate straight to the room, then wait for it to be ready +await poHomeChannel.gotoChannel(targetChannel); +``` + +Why: searching the navbar couples the navigation to (1) the app shell hydrating, (2) the **search index** having picked up the room — a real lag for a channel created moments earlier via API — and (3) the listbox rendering and the option becoming clickable. Any one stalling makes `searchInput.fill` hang until the test timeout, surfacing as the misleading `Target page, context or browser has been closed`. `gotoChannel` loads `/channel/` and then `waitForChannel()` (main region, heading, message list, `aria-busy` cleared), so it has a proper readiness wait without the search dependency. + +Do **not** apply when: +- The navbar search is the actual subject of the test. +- Navigating to a **DM** (`/direct/`) or a **discussion** — `gotoChannel` builds a `/channel/` URL only. +- Reaching a channel the user is **not a member of** — the direct URL hits a different preview/join flow; verify the behavior before switching. + +## API helpers for state seeding + +Prefer these helpers in `beforeAll` / `beforeEach` and in setup `test.step`s. All live under `apps/meteor/tests/e2e/utils/`. + +| Intent | Helper | REST endpoint | +| ------------------------------------ | --------------------------------------------------------- | ------------------------- | +| Create public channel | `createTargetChannel(api)` | `/channels.create` | +| Create public channel (full room) | `createTargetChannelAndReturnFullRoom(api)` | `/channels.create` | +| Create private channel | `createTargetPrivateChannel(api)` | `/groups.create` | +| Create private group (full room) | `createTargetGroupAndReturnFullRoom(api)` | `/groups.create` | +| Create team | `createTargetTeam(api)` | `/teams.create` | +| Create discussion (fresh parent) | `createTargetDiscussion(api)` | `/rooms.createDiscussion` | +| Create discussion on existing msg | `createDiscussion(api, parentRoomId, parentMsgId, name)` | `/rooms.createDiscussion` | +| Create DM room (get id back) | `createDirectMessageRoom(api, username)` | `/im.create` | +| Send message to a room | `sendMessage(api, roomId, msg)` | `/chat.sendMessage` | +| Send message inside a thread | `sendMessage(api, roomId, msg, parentMsgId)` | `/chat.sendMessage` | +| Send message as a specific user | `sendMessageFromUser(request, user, rid, msg)` | `/chat.postMessage` | +| Delete channel (by name) | `deleteChannel(api, roomName)` | `/channels.delete` | +| Delete room (by id) | `deleteRoom(api, roomId)` | `/rooms.delete` | +| Delete team | `deleteTeam(api, teamName)` | `/teams.delete` | + +If the helper you need is missing, add it under `utils/` and re-export it from `utils/index.ts` rather than inlining the REST call in the spec. + +## Template: optimized `.serial` suite + +Copy-paste starting point for a new suite that applies both patterns. + +```ts +import type { Page, BrowserContext } from 'playwright-core'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, sendMessage } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Feature X', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetChannelId: string; + let page: Page; + let context: BrowserContext; + + test.beforeAll(async ({ browser, api }) => { + targetChannel = await createTargetChannel(api); + const info = await (await api.get(`/channels.info?roomName=${targetChannel}`)).json(); + targetChannelId = info.channel._id; + + context = await browser.newContext(); + page = await context.newPage(); + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + await page.close(); + await context.close(); + }); + + test('exercises the feature', async ({ api }) => { + const messageText = 'seed'; + await sendMessage(api, targetChannelId, messageText); + await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText); + // assertions for the feature under test + }); +}); +``` + +## Migrating an existing suite + +Recipe for a single spec. Keep PRs to at most 5 files so reviews stay tractable. + +1. **Baseline**. Run the spec three times locally and record the median per-test time. Paste it in the PR body. +2. **Map setup**. For every test, mark each `test.step` and every `beforeEach` call that only prepares state (creates rooms, messages, threads, opens menus). These are candidates for API seeding. +3. **Apply Pattern 1**. Replace the marked UI setup with helpers from the table above. If a helper is missing, add it under `utils/` and export it from `utils/index.ts`. +4. **Apply Pattern 2** — only if the suite is `.serial` and the preconditions hold. Move context, page, navigation to `beforeAll`. Close page and context in `afterAll`. Ensure every test returns the page to the home position. +5. **Check coverage when consolidating**. If you merge tests (e.g. four formatting tests into one), confirm the merged test still asserts every behavior the originals covered. List the merges in the PR body. +6. **Compare**. Run three times again, record the new median. If the improvement is under 30%, revisit — the coupling may not be worth it. +7. **PR body must include**: before/after timings, the list of UI setups moved to API, and an explicit reason if you chose not to apply one of the patterns. + +## Anti-patterns to flag in review + +- `poHomeChannel.content.sendMessage(...)` used inside `beforeEach` or `beforeAll` — that is setup, should be `sendMessage(api, ...)`. +- Opening meatball menus or modals purely to create a discussion, thread, or DM as a setup step. +- `test.describe.serial` that builds a fresh context and navigates in `beforeEach` — both should be shared in `beforeAll`. +- Any raw `page.goto(...)` in a spec — use the page object's `goto()` (see [Navigation](#navigation-goto-owns-the-wait)). +- `goto()` + `navbar.openChat(name)` to reach a room the test already knows — use `poHomeChannel.gotoChannel(name)` (see Pattern 3). Searching a just-created channel races the search index and hangs until timeout. +- New non-serial suites whose tests still each carry >3s of UI setup. +- Inline `api.post('/im.create', …)` / `api.post('/chat.sendMessage', …)` in a spec instead of extending the helpers in `utils/`. diff --git a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts new file mode 100644 index 0000000..7f0bce4 --- /dev/null +++ b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts @@ -0,0 +1,120 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { MongoClient } from 'mongodb'; + +import { IS_EE, URL_MONGODB } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetGroupAndReturnFullRoom, deleteRoom, setSettingValueById } from './utils'; +import { convertHexToRGB } from './utils/convertHexToRGB'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const attrKey = `clearance_${Date.now()}`; + +const bannersConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'clearance', + source: attrKey, + label: 'Clearance', + showInBanner: true, + showLabel: true, + bannerLabel: 'CLEARANCE', + labelSeparator: '-', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: 'MULTIPLE', + drivesColor: true, + values: [ + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + ], +}; + +test.describe.serial('abac-classification-banner', () => { + let poHomeChannel: HomeChannel; + let connection: MongoClient; + let room: IRoom; + let attributeId: string; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + connection = await MongoClient.connect(URL_MONGODB); + + await Promise.all([ + setSettingValueById(api, 'ABAC_Enabled', true), + setSettingValueById(api, 'ABAC_PDP_Type', 'local'), + setSettingValueById(api, 'ABAC_Attribute_Store', 'local'), + setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', true), + setSettingValueById(api, 'ABAC_Classification_Banners_Config', JSON.stringify(bannersConfig)), + ]); + + expect((await api.post('/abac/attributes', { key: attrKey, values: ['TS', 'U'] })).status()).toBe(200); + const { attributes } = await (await api.get('/abac/attributes', { key: attrKey })).json(); + attributeId = attributes.find((attribute: { key: string }) => attribute.key === attrKey)._id; + + // the local PDP only keeps members whose own attributes match the room's, + // so the admin gets them straight in the database before the room is tagged + await connection + .db() + .collection('users') + .updateOne({ username: Users.admin.data.username }, { $push: { abacAttributes: { key: attrKey, values: ['TS', 'U'] } } }); + + ({ group: room } = await createTargetGroupAndReturnFullRoom(api)); + expect((await api.put(`/abac/rooms/${room._id}/attributes/${attrKey}`, { values: ['TS'] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, room._id); + await api.delete(`/abac/attributes/${attributeId}`); + await connection + .db() + .collection('users') + .updateOne({ username: Users.admin.data.username }, { $pull: { abacAttributes: { key: attrKey } } }); + await connection.close(); + + await Promise.all([ + setSettingValueById(api, 'ABAC_Classification_Banners_Config', ''), + setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false), + setSettingValueById(api, 'ABAC_Enabled', false), + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test('should show the classification banner built from the config in an ABAC room', async ({ page }) => { + await poHomeChannel.navbar.openChat(room.name as string); + + const banner = page.getByRole('region', { name: 'Room Attributes' }); + await expect(banner).toBeVisible(); + await expect(banner).toHaveText('CLEARANCE-TOP SECRET'); + await expect(banner).toHaveCSS('background-color', convertHexToRGB('#ff8c00')); + }); + + test('should not show the banner when classification banners are disabled', async ({ page, api }) => { + await setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false); + + await poHomeChannel.navbar.openChat(room.name as string); + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(page.getByRole('region', { name: 'Room Attributes' })).toHaveCount(0); + }); +}); diff --git a/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts b/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts new file mode 100644 index 0000000..9d402d2 --- /dev/null +++ b/apps/meteor/tests/e2e/account-forgetSessionOnWindowClose.spec.ts @@ -0,0 +1,110 @@ +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { AccountSecurity, HomeChannel, Login } from './page-objects'; +import { test, expect } from './utils/test'; +import { createTestUser, type ITestUser } from './utils/user-helpers'; + +test.describe.serial('Forget session on window close setting', () => { + let poLogin: Login; + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + + await poLogin.goto(); + }); + + test.describe('Setting off', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false }); + }); + + test('Login using credentials and reload to stay logged in', async ({ page, context }) => { + await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + const newPage = await context.newPage(); + await new HomeChannel(newPage).goto(); + + await expect(newPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + }); + }); + + test.describe('Setting on', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_ForgetUserSessionOnWindowClose', { value: false }); + }); + + test('Login using credentials and reload to stay logged in', async ({ page }) => { + await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + await page.reload(); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + }); + + test('Login using credentials in a new tab after first tab logged in', async ({ page, context }) => { + await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + const newPage = await context.newPage(); + const newPoLogin = new Login(newPage); + await newPoLogin.goto(); + + await newPoLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await expect(newPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + }); + + test.describe('E2EE save password flow', () => { + // Dedicated throwaway user: resetE2EEPassword() calls Users.unsetLoginTokens(), + // which would invalidate the shared admin login token the `api` fixture + // authenticates with — breaking every subsequent admin-authenticated request + // in the worker (e.g. the next spec's beforeAll settings changes silently 401). + let e2eeUser: ITestUser; + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + + e2eeUser = await createTestUser(api); + }); + + test.afterAll(async ({ api }) => { + await e2eeUser?.delete(); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enable', { value: false }); + }); + + test('E2EE save password button opens modal in SAVE_PASSWORD state', async ({ page }) => { + const poHomeChannel = new HomeChannel(page); + const poAccountSecurity = new AccountSecurity(page); + + await poLogin.login(e2eeUser.data.username, DEFAULT_USER_CREDENTIALS.password); + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + + await poAccountSecurity.goto(); + await poAccountSecurity.resetE2EEPassword(); + + await page.locator('role=button[name="Login"]').waitFor(); + + await poLogin.login(e2eeUser.data.username, DEFAULT_USER_CREDENTIALS.password); + await expect(page.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + await expect(poHomeChannel.bannerSaveEncryptionPassword).toBeVisible(); + await poHomeChannel.bannerSaveEncryptionPassword.click(); + + const randomPassword = await page.evaluate(() => window.sessionStorage.getItem('e2e.randomPassword') ?? ''); + expect(randomPassword).toBeTruthy(); + + await expect(poHomeChannel.dialogSaveE2EEPassword).toBeVisible(); + await expect(poHomeChannel.dialogSaveE2EEPassword).toContainText(randomPassword); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/account-login.spec.ts b/apps/meteor/tests/e2e/account-login.spec.ts new file mode 100644 index 0000000..a881945 --- /dev/null +++ b/apps/meteor/tests/e2e/account-login.spec.ts @@ -0,0 +1,44 @@ +import { Users, restoreState } from './fixtures/userStates'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.userE2EE.state }); + +test.describe('Account Login', () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: false }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/home'); + await restoreState(page, Users.userE2EE); + }); + + test('should remove private_key and public_key from localStorage when token expires', async ({ page }) => { + expect(await page.evaluate(() => localStorage.getItem('Meteor.userId'))).not.toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('Meteor.loginToken'))).not.toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires'))).not.toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('private_key'))).not.toBeNull(); + // public key acquisition takes time and now happens only after login cycle is complete + await page.waitForFunction(() => localStorage.getItem('public_key')); + + await page.evaluate(() => { + localStorage.setItem('Meteor.loginTokenExpires', new Date().toString()); + }); + + await page.reload(); + + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible(); + + expect(await page.evaluate(() => localStorage.getItem('Meteor.userId'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('Meteor.loginToken'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('private_key'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('public_key'))).toBeNull(); + }); +}); diff --git a/apps/meteor/tests/e2e/account-manage-devices.spec.ts b/apps/meteor/tests/e2e/account-manage-devices.spec.ts new file mode 100644 index 0000000..dcaa914 --- /dev/null +++ b/apps/meteor/tests/e2e/account-manage-devices.spec.ts @@ -0,0 +1,66 @@ +import type { Page } from 'playwright-core'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import injectInitialData from './fixtures/inject-initial-data'; +import { Users } from './fixtures/userStates'; +import { Login } from './page-objects'; +import { AccountSectionsHref } from './page-objects/account'; +import { AccountManageDevices } from './page-objects/account.manage-devices'; +import { test, expect } from './utils/test'; + +test.describe('Account Manage Devices Page', () => { + test.skip(!IS_EE); + test.use({ storageState: Users.user1.state }); + + let page: Page; + let accountDevices: AccountManageDevices; + let poLogin: Login; + + test.beforeEach(async ({ browser }) => { + ({ page } = await createAuxContext(browser, Users.user1)); + accountDevices = new AccountManageDevices(page); + poLogin = new Login(page); + await accountDevices.goto(); + }); + + test.afterEach(async () => { + await page.close(); + await injectInitialData(); + }); + + test('should show Manage Devices page', async () => { + await expect(accountDevices.pageContent).toBeVisible(); + }); + + test('should logout current device and redirect to login page', async () => { + await accountDevices.table.orderByLastLogin(); + + const deviceId = await accountDevices.getNthDeviceId(1); + await accountDevices.logoutDeviceById(deviceId); + await poLogin.waitForDisplay(); + }); + + test('should logout other device successfully', async ({ browser }) => { + const context2 = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page2 = await context2.newPage(); + const loginPage2 = new Login(page2); + const accountDevices2 = new AccountManageDevices(page2); + + await test.step('should login same user in another session', async () => { + await loginPage2.goto(AccountSectionsHref.manageDevices); + await loginPage2.login('user1', 'password'); + await expect(accountDevices2.pageContent).toBeVisible(); + }); + + await test.step('should logout device 1 from session 2', async () => { + await accountDevices2.table.orderByLastLogin(); + const device1Id = await accountDevices2.getNthDeviceId(2); + await accountDevices2.logoutDeviceById(device1Id); + await poLogin.waitForDisplay(); + await expect(accountDevices2.table.getDeviceRowById(device1Id)).not.toBeVisible(); + }); + + await context2.close(); + }); +}); diff --git a/apps/meteor/tests/e2e/account-profile.spec.ts b/apps/meteor/tests/e2e/account-profile.spec.ts new file mode 100644 index 0000000..3b69ac1 --- /dev/null +++ b/apps/meteor/tests/e2e/account-profile.spec.ts @@ -0,0 +1,161 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { + AccountAccessibility, + AccountFeaturePreview, + AccountOmnichannel, + AccountProfile, + AccountTokens, + HomeChannel, +} from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.user3.state }); + +test.describe.serial('settings-account-profile', () => { + let poHomeChannel: HomeChannel; + let poAccountProfile: AccountProfile; + + const token = faker.string.alpha(10); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + poAccountProfile = new AccountProfile(page); + }); + + test.describe('Profile', () => { + test.beforeEach(async () => { + await poAccountProfile.goto(); + }); + + // FIXME: solve test intermitencies + test.skip('expect update profile with new name/username', async () => { + const newName = faker.person.fullName(); + const newUsername = faker.internet.userName({ firstName: newName }); + + await poAccountProfile.inputName.fill(newName); + await poAccountProfile.inputUsername.fill(newUsername); + await poAccountProfile.btnSubmit.click(); + await poAccountProfile.btnClose.click(); + await poHomeChannel.navbar.openChat('general'); + await poHomeChannel.content.sendMessage('any_message'); + + await expect(poHomeChannel.content.lastUserMessageNotSequential).toContainText(newUsername); + + await poHomeChannel.content.lastUserMessageNotSequential.locator('figure').click(); + await poHomeChannel.userCard.openUserInfo(); + + await expect(poHomeChannel.tabs.userInfo.username).toHaveText(newUsername); + }); + + test.describe('Avatar', () => { + test('should change avatar image by uploading file', async () => { + await poAccountProfile.inputImageFile.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + await poAccountProfile.btnSubmit.click(); + + await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src'); + }); + + test('should change avatar image from url', async () => { + await poAccountProfile.inputAvatarLink.fill('https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50'); + await poAccountProfile.btnSetAvatarLink.click(); + + await poAccountProfile.btnSubmit.click(); + await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src'); + }); + + test('should show inline error if the image url is not valid', async () => { + await poAccountProfile.inputAvatarLink.fill('https://invalidUrl'); + await poAccountProfile.btnSetAvatarLink.click(); + + await expect(poAccountProfile.errorInvalidUrl).toBeVisible(); + }); + + test('should show inline error if url does not point to an image', async () => { + await poAccountProfile.inputAvatarLink.fill('https://google.com'); + await poAccountProfile.btnSetAvatarLink.click(); + + await expect(poAccountProfile.errorInvalidUrl).toBeVisible(); + }); + + test('should not allow avatar URL change when avatar changes are disabled', async ({ api }) => { + await setSettingValueById(api, 'Accounts_AllowUserAvatarChange', false); + await expect(poAccountProfile.btnSetAvatarLink).toBeDisabled(); + await expect(poAccountProfile.inputAvatarLink).toBeDisabled(); + await setSettingValueById(api, 'Accounts_AllowUserAvatarChange', true); + }); + }); + }); + + test('Personal Access Tokens', async ({ page }) => { + const poAccountTokens = new AccountTokens(page); + const response = page.waitForResponse('**/api/v1/users.getPersonalAccessTokens'); + await poAccountTokens.goto(); + await response; + + await test.step('should show empty personal access tokens table', async () => { + await expect(poAccountTokens.tokensTableEmpty).toBeVisible(); + await expect(poAccountTokens.inputToken).toBeVisible(); + }); + + await test.step('should show new personal token', async () => { + await poAccountTokens.inputToken.fill(token); + await poAccountTokens.btnTokensAdd.click(); + await expect(poAccountTokens.tokenAddedModal).toBeVisible(); + await poAccountTokens.btnTokenAddedOk.click(); + }); + + await test.step('should not allow add new personal with no name', async () => { + await poAccountTokens.btnTokensAdd.click(); + await expect(page.getByRole('alert').filter({ hasText: 'Please provide a name for your token' })).toBeVisible(); + }); + + await test.step('should not allow add new personal token with same name', async () => { + await poAccountTokens.inputToken.fill(token); + await poAccountTokens.btnTokensAdd.click(); + await expect(poAccountTokens.tokensRows).toHaveCount(1); + }); + + await test.step('should regenerate personal token', async () => { + await poAccountTokens.tokenInTable(token).locator('button >> nth=0').click(); + await poAccountTokens.btnRegenerateTokenModal.click(); + await expect(poAccountTokens.tokenAddedModal).toBeVisible(); + await poAccountTokens.btnTokenAddedOk.click(); + }); + + await test.step('should delete personal token', async () => { + await poAccountTokens.tokenInTable(token).locator('button >> nth=1').click(); + await poAccountTokens.btnRemoveTokenModal.click(); + await expect(poAccountTokens.tokensTableEmpty).toBeVisible(); + }); + }); + + test.describe('Omnichannel', () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + await new AccountOmnichannel(page).goto(); + + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + }); + + test.describe('Feature Preview', () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + await new AccountFeaturePreview(page).goto(); + + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + }); + + test.describe('Accessibility & Appearance', () => { + test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => { + await new AccountAccessibility(page).goto(); + + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/account-security.spec.ts b/apps/meteor/tests/e2e/account-security.spec.ts new file mode 100644 index 0000000..4710eae --- /dev/null +++ b/apps/meteor/tests/e2e/account-security.spec.ts @@ -0,0 +1,114 @@ +import { faker } from '@faker-js/faker'; + +import { ADMIN_CREDENTIALS } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { AccountSecurity } from './page-objects'; +import { setSettingValueById, updateOwnUserPassword } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const RANDOM_PASSWORD = faker.helpers + .shuffle([ + faker.string.alpha({ casing: 'upper' }), + faker.string.alpha({ casing: 'lower' }), + faker.string.numeric(), + faker.string.symbol(), + faker.string.alphanumeric(10), + ]) + .join(''); + +test.describe.serial('account-security', () => { + let poAccountSecurity: AccountSecurity; + + test.beforeEach(async ({ page, api }) => { + poAccountSecurity = new AccountSecurity(page); + await poAccountSecurity.gotoExpecting(poAccountSecurity.securityHeader.or(poAccountSecurity.notAuthorizedMessage)); + await setSettingValueById(api, 'Accounts_Password_Policy_Enabled', false); + }); + + test.afterAll(async ({ api }) => + Promise.all([ + setSettingValueById(api, 'Accounts_AllowPasswordChange', true), + setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', true), + setSettingValueById(api, 'E2E_Enable', false), + setSettingValueById(api, 'Accounts_Password_Policy_Enabled', true), + ]), + ); + + test('should disable and enable email 2FA', async () => { + await poAccountSecurity.security2FASection.click(); + await expect(poAccountSecurity.email2FASwitch).toBeVisible(); + await poAccountSecurity.email2FASwitch.click(); + await poAccountSecurity.toastMessage.waitForDisplay(); + await poAccountSecurity.toastMessage.dismissToast(); + + await poAccountSecurity.email2FASwitch.click(); + await poAccountSecurity.toastMessage.waitForDisplay(); + }); + + test('should be able to change password', async ({ api }) => { + await test.step('change password', async () => { + await poAccountSecurity.changePassword(RANDOM_PASSWORD, RANDOM_PASSWORD, ADMIN_CREDENTIALS.password); + await expect(poAccountSecurity.inputNewPassword).toHaveValue(''); + }); + + await test.step('change back to the original password', async () => { + expect( + (await updateOwnUserPassword(api, { newPassword: ADMIN_CREDENTIALS.password, currentPassword: RANDOM_PASSWORD })).status(), + ).toBe(200); + }); + }); + + test.describe('settings disabled', () => { + test.beforeAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowPasswordChange', false), + setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false), + setSettingValueById(api, 'E2E_Enable', false), + ]); + }); + + test('security tab is invisible when password change, 2FA and E2E are disabled', async () => { + await expect(poAccountSecurity.sidebar.linkSecurity).not.toBeVisible(); + await expect(poAccountSecurity.notAuthorizedMessage).toBeVisible(); + }); + }); + + test.describe('account security sections', () => { + test.beforeAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowPasswordChange', true), + setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false), + setSettingValueById(api, 'E2E_Enable', false), + ]); + }); + + test.beforeEach(async () => { + await poAccountSecurity.securityHeader.waitFor({ state: 'visible' }); + }); + + test('should display security tab and section when password change is enabled but 2FA and E2E are disabled', async () => { + await expect(poAccountSecurity.sidebar.linkSecurity).toBeVisible(); + await expect(poAccountSecurity.securityPasswordSection).toBeVisible(); + }); + + test('can access 2FA setting when enabled but password change and E2E are disabled', async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowPasswordChange', false), + setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', true), + setSettingValueById(api, 'E2E_Enable', false), + ]); + await expect(poAccountSecurity.security2FASection).toBeVisible(); + }); + + test('can access E2E setting when enabled but password change and 2FA are disabled', async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowPasswordChange', false), + setSettingValueById(api, 'Accounts_TwoFactorAuthentication_Enabled', false), + setSettingValueById(api, 'E2E_Enable', true), + ]); + await expect(poAccountSecurity.securityE2EEncryptionSection).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/account-totp.spec.ts b/apps/meteor/tests/e2e/account-totp.spec.ts new file mode 100644 index 0000000..db88586 --- /dev/null +++ b/apps/meteor/tests/e2e/account-totp.spec.ts @@ -0,0 +1,93 @@ +import { Users } from './fixtures/userStates'; +import { Authenticated } from './page-objects/auth'; +import { test, expect } from './utils/test'; + +// We mock the REST `method.call` response instead of registering a real +// Meteor method that requires 2FA: there isn't one in the codebase right now +// (every twoFactorRequired/`requireSecondFactor: true` user-facing surface is +// a typed REST endpoint), and the bug we're guarding against lives inside +// `ddpOverREST`'s `.catch` — a unit-style server fixture exercises it more +// reliably than wiring up TOTP for a real account. +test.use({ storageState: Users.admin.state }); + +const METHOD_NAME = 'rc-test-totp-flow'; + +test.describe('account-totp ddpOverREST preserves the totp-required error', () => { + test('opens the TOTP modal when the REST envelope carries a `totp-required` DDP frame, and resolves on retry', async ({ page }) => { + await new Authenticated(page).goto(); + + let calls = 0; + await page.route(`**/api/v1/method.call/${METHOD_NAME}`, async (route) => { + calls += 1; + const body = JSON.parse(route.request().postData() ?? '{}'); + const message = JSON.parse(body.message); + const lastParam = message.params[message.params.length - 1]; + const hasTotp = lastParam && typeof lastParam === 'object' && lastParam.twoFactorCode && lastParam.twoFactorMethod === 'totp'; + + if (!hasTotp) { + // First leg: server demands a TOTP code. The body is shaped exactly + // like `mountResult({ id, error })` in server/api/v1/misc.ts — + // `message` is a DDP-encoded `result` frame whose `error.error` + // carries the original `totp-required` code. The bug under test + // rebuilt this envelope as `error: 'unknown'` and the 2FA modal + // never opened. + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + success: false, + message: JSON.stringify({ + msg: 'result', + id: message.id, + error: { + isClientSafe: true, + error: 'totp-required', + reason: 'TOTP Required', + message: 'TOTP Required [totp-required]', + errorType: 'Meteor.Error', + details: { method: 'totp', availableMethods: ['totp'] }, + }, + }), + }), + }); + return; + } + + // Second leg: client retried with the code; respond success. + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + message: JSON.stringify({ + msg: 'result', + id: message.id, + result: { ok: true, code: lastParam.twoFactorCode }, + }), + }), + }); + }); + + // Drive the call from the page so it goes through Meteor.callAsync → + // withAsyncTOTP → ddpOverREST → process2faAsyncReturn — the exact path + // where the catch handler used to drop `error: 'totp-required'` and the + // TOTP modal therefore never opened. + await page.evaluate((method) => { + (window as any).__totpCallResult = (window as any).Meteor.callAsync(method) + .then((value: unknown) => ({ ok: true, value })) + .catch((err: unknown) => ({ ok: false, error: String((err as Error)?.message ?? err) })); + }, METHOD_NAME); + + const totpModal = page.getByRole('dialog', { name: 'Enter TOTP password' }); + await expect(totpModal).toBeVisible({ timeout: 10_000 }); + + await totpModal.getByPlaceholder('Enter code here').fill('123456'); + await totpModal.getByRole('button', { name: 'Verify' }).click(); + + await expect(totpModal).toBeHidden(); + + const result = await page.evaluate(() => (window as any).__totpCallResult); + expect(result).toEqual({ ok: true, value: { ok: true, code: '123456' } }); + expect(calls).toBe(2); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-device-management.spec.ts b/apps/meteor/tests/e2e/admin-device-management.spec.ts new file mode 100644 index 0000000..2744713 --- /dev/null +++ b/apps/meteor/tests/e2e/admin-device-management.spec.ts @@ -0,0 +1,118 @@ +import type { Page } from 'playwright-core'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import injectInitialData from './fixtures/inject-initial-data'; +import { Users } from './fixtures/userStates'; +import { Authenticated, HomeChannel, Login, Registration } from './page-objects'; +import { AdminDeviceManagement } from './page-objects/admin-device-management'; +import { test, expect } from './utils/test'; + +test.describe('Admin Device Management Page', () => { + test.skip(!IS_EE); + test.use({ storageState: Users.admin.state }); + + let page: Page; + let adminDeviceManagement: AdminDeviceManagement; + let poLogin: Login; + + test.beforeEach(async ({ browser }) => { + ({ page } = await createAuxContext(browser, Users.admin)); + adminDeviceManagement = new AdminDeviceManagement(page); + poLogin = new Login(page); + await adminDeviceManagement.goto(); + }); + + test.afterEach(async () => { + await page.close(); + await injectInitialData(); + }); + + test('should show Device management page', async () => { + await expect(adminDeviceManagement.pageContent).toBeVisible(); + }); + + test('should logout current device and redirect to login page', async () => { + const deviceId = await adminDeviceManagement.getUsersDeviceId('rocketchat.internal.admin.test'); + await adminDeviceManagement.logoutDeviceById(deviceId); + await poLogin.waitForDisplay(); + }); + + test('should logout current device from device info tab and redirect to login page', async () => { + const deviceId = await adminDeviceManagement.getUsersDeviceId('rocketchat.internal.admin.test'); + await adminDeviceManagement.searchUserDevice('rocketchat.internal.admin.test'); + await adminDeviceManagement.table.getDeviceRowById(deviceId).click(); + + await expect(adminDeviceManagement.deviceInfo.getDeviceInfoId(deviceId)).toBeVisible(); + await adminDeviceManagement.deviceInfo.btnLogoutDevice.click(); + await adminDeviceManagement.logoutModal.confirmLogout(); + await poLogin.waitForDisplay(); + }); + + test('should logout other device successfully', async ({ browser }) => { + const user2Page = await browser.newPage({ storageState: Users.user2.state }); + const loginPage2 = new Registration(user2Page); + await new Authenticated(user2Page).goto(); + + const user2DeviceId = await adminDeviceManagement.getUsersDeviceId('user2'); + + await test.step('should logout user2 and redirect to login page', async () => { + await adminDeviceManagement.logoutDeviceById(user2DeviceId); + await loginPage2.waitForDisplay(); + }); + + await test.step('should no longer show user2 device in admin device management page', async () => { + await adminDeviceManagement.searchUserDevice('user2'); + await expect(adminDeviceManagement.table.getDeviceRowById(user2DeviceId)).not.toBeVisible(); + }); + + await user2Page.close(); + }); + + test('should remove user2 from device list when user2 logs out from their own session', async ({ browser }) => { + const user2Page = await browser.newPage({ storageState: Users.user2.state }); + const loginPage2 = new Registration(user2Page); + const poUser2Home = new HomeChannel(user2Page); + + await poUser2Home.goto(); + + await test.step('should list user2 device while user2 is logged in', async () => { + await expect(adminDeviceManagement.pageContent).toBeVisible(); + await adminDeviceManagement.searchUserDevice('user2'); + await expect(adminDeviceManagement.table.getRowsForUsername('user2')).toHaveCount(1); + }); + + await test.step('should log user2 out from the app and redirect to login page', async () => { + await poUser2Home.navbar.logout(); + await loginPage2.waitForDisplay(); + }); + + await test.step('should no longer show user2 device in admin device management page', async () => { + await page.reload(); + await expect(adminDeviceManagement.pageContent).toBeVisible(); + await adminDeviceManagement.searchUserDevice('user2'); + await expect(adminDeviceManagement.table.getRowsForUsername('user2')).toHaveCount(0); + }); + + await user2Page.close(); + }); + + test('Should show empty state when searching for user without result', async () => { + const noResultQuery = 'nonexistentuser'; + await adminDeviceManagement.searchUserDevice('noResultQuery'); + await expect(adminDeviceManagement.table.getRowsForUsername(noResultQuery)).toHaveCount(0); + await expect(adminDeviceManagement.emptyState).toBeVisible(); + }); +}); + +test.describe('Admin Device Management Page - unauthorized access', () => { + test.skip(!IS_EE); + test.use({ storageState: Users.user2.state }); + + test('should not access device-management when user has no view-device-management permission', async ({ page }) => { + const user2DeviceManagement = new AdminDeviceManagement(page); + await user2DeviceManagement.gotoExpecting(user2DeviceManagement.notAuthorizedMessage); + await expect(user2DeviceManagement.notAuthorizedMessage).toBeVisible(); + await expect(user2DeviceManagement.pageContent).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-room.spec.ts b/apps/meteor/tests/e2e/admin-room.spec.ts new file mode 100644 index 0000000..d0c1ab9 --- /dev/null +++ b/apps/meteor/tests/e2e/admin-room.spec.ts @@ -0,0 +1,94 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { AdminInfo, AdminRooms, AdminSectionsHref } from './page-objects'; +import { createTargetChannel, createTargetPrivateChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('admin-rooms', () => { + let channel: string; + let privateRoom: string; + let adminRooms: AdminRooms; + let adminInfo: AdminInfo; + + test.beforeEach(async ({ page }) => { + adminRooms = new AdminRooms(page); + await adminRooms.goto(); + }); + + test.beforeAll(async ({ api }) => { + [channel, privateRoom] = await Promise.all([createTargetChannel(api), createTargetPrivateChannel(api)]); + }); + + test('should display the Rooms Table', async ({ page }) => { + await expect(page.getByRole('main').getByRole('heading', { level: 1, name: 'Rooms', exact: true })).toBeVisible(); + await expect(page.getByRole('main').getByRole('table')).toBeVisible(); + }); + + test('should filter room by name', async ({ page }) => { + await adminRooms.inputSearchRooms.fill(channel); + + await expect(page.locator(`[qa-room-name="${channel}"]`)).toBeVisible(); + }); + + test('should filter rooms by type', async ({ page }) => { + const dropdown = await adminRooms.dropdownFilterRoomType(); + await dropdown.click(); + + const privateOption = page.locator('text=Private channels'); + + await privateOption.waitFor(); + await privateOption.click(); + + const selectedDropdown = await adminRooms.dropdownFilterRoomType('Rooms (1)'); + await expect(selectedDropdown).toBeVisible(); + + await expect(page.locator('text=Private Channel').first()).toBeVisible(); + }); + + test('should filter rooms by type and name', async ({ page }) => { + await adminRooms.inputSearchRooms.fill(privateRoom); + + const dropdown = await adminRooms.dropdownFilterRoomType(); + await dropdown.click(); + + await page.locator('text=Private channels').click(); + + await expect(page.locator(`[qa-room-name="${privateRoom}"]`)).toBeVisible(); + }); + + test('should be empty in case of the search does not find any room', async ({ page }) => { + const nonExistingChannel = faker.string.alpha(10); + + await adminRooms.inputSearchRooms.fill(nonExistingChannel); + + const dropdown = await adminRooms.dropdownFilterRoomType(); + await dropdown.click(); + + await page.locator('text=Private channels').click(); + + await expect(page.locator('text=No results found')).toBeVisible(); + }); + + test('should filter rooms by type and name and clean the filter after changing section', async ({ page }) => { + adminInfo = new AdminInfo(page); + + await adminRooms.inputSearchRooms.fill(privateRoom); + const dropdown = adminRooms.dropdownFilterRoomType(); + await dropdown.click(); + + await page.locator('text=Private channels').click(); + + const workspaceButton = await adminRooms.adminSectionButton(AdminSectionsHref.workspace); + await workspaceButton.click(); + await expect(adminInfo.pageContent).toBeVisible(); + + const roomsButton = await adminRooms.adminSectionButton(AdminSectionsHref.rooms); + await roomsButton.click(); + + const selectDropdown = await adminRooms.dropdownFilterRoomType('All rooms'); + await expect(selectDropdown).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-users-custom-fields.spec.ts b/apps/meteor/tests/e2e/admin-users-custom-fields.spec.ts new file mode 100644 index 0000000..04ec00c --- /dev/null +++ b/apps/meteor/tests/e2e/admin-users-custom-fields.spec.ts @@ -0,0 +1,157 @@ +import { Users } from './fixtures/userStates'; +import { AdminUsers, HomeChannel } from './page-objects'; +import { test, expect } from './utils/test'; +import { createTestUser, type ITestUser } from './utils/user-helpers'; + +const customFieldInitial1 = 'initial1'; +const adminCustomFieldValue1 = 'admin_value1'; +const adminCustomFieldValue2 = 'admin_value2'; +const adminCustomFieldUpdated1 = 'updated_admin1'; + +test.describe('Admin users custom fields', () => { + let poHomeChannel: HomeChannel; + let poAdmin: AdminUsers; + let addTestUser: ITestUser; + let updateTestUser: ITestUser; + + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_CustomFields', { + value: JSON.stringify({ + customFieldText1: { + type: 'text', + required: false, + }, + customFieldText2: { + type: 'text', + required: false, + }, + }), + }); + + [addTestUser, updateTestUser] = await Promise.all([ + createTestUser(api), + createTestUser(api, { + data: { + customFields: { + customFieldText1: customFieldInitial1, + customFieldText2: adminCustomFieldValue2, + }, + }, + }), + ]); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Accounts_CustomFields', { + value: '', + }), + addTestUser.delete(), + updateTestUser.delete(), + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + poAdmin = new AdminUsers(page); + await poAdmin.goto(); + }); + + test('should allow admin to add user custom fields', async () => { + await test.step('should find and click on add test user', async () => { + await poAdmin.searchUser(addTestUser.data.username); + await poAdmin.getUserRowByUsername(addTestUser.data.username).click(); + }); + + await test.step('should navigate to edit user form', async () => { + await poAdmin.userInfo.btnEdit.click(); + }); + + await test.step('should fill custom fields for user', async () => { + await poAdmin.editUser.getCustomField('customFieldText1').fill(adminCustomFieldValue1); + await poAdmin.editUser.getCustomField('customFieldText2').fill(adminCustomFieldValue2); + }); + + await test.step('should save user custom fields', async () => { + await poAdmin.editUser.btnSaveUser.click(); + await poHomeChannel.toastMessage.dismissToast(); + await poAdmin.editUser.close(); + }); + + await test.step('should verify custom fields were saved', async () => { + await poAdmin.getUserRowByUsername(addTestUser.data.username).click(); + await poAdmin.userInfo.btnEdit.click(); + + await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(adminCustomFieldValue1); + await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2); + }); + }); + + test('should allow admin to update existing user custom fields', async () => { + await test.step('should find and click on update test user', async () => { + await poAdmin.searchUser(updateTestUser.data.username); + await poAdmin.getUserRowByUsername(updateTestUser.data.username).click(); + }); + + await test.step('should navigate to edit user form', async () => { + await poAdmin.userInfo.btnEdit.click(); + }); + + await test.step('should verify existing values and update one custom field', async () => { + await poAdmin.editUser.inputName.waitFor(); + + await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(customFieldInitial1); + await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2); + + await poAdmin.editUser.getCustomField('customFieldText1').clear(); + await poAdmin.editUser.getCustomField('customFieldText1').fill(adminCustomFieldUpdated1); + }); + + await test.step('should save and verify partial update', async () => { + await poAdmin.editUser.btnSaveUser.click(); + await poHomeChannel.toastMessage.dismissToast(); + + await poAdmin.editUser.close(); + await poAdmin.getUserRowByUsername(updateTestUser.data.username).click(); + await poAdmin.userInfo.btnEdit.click(); + + await expect(poAdmin.editUser.getCustomField('customFieldText1')).toHaveValue(adminCustomFieldUpdated1); + await expect(poAdmin.editUser.getCustomField('customFieldText2')).toHaveValue(adminCustomFieldValue2); + }); + }); + + test.describe('with invalid custom field type', () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_CustomFields', { + value: JSON.stringify({ + customFieldText1: { + type: 'invalid_type', + required: false, + }, + customFieldText2: { + type: 'text', + required: false, + }, + }), + }); + }); + + test('should not render fields with invalid custom field type', async () => { + await test.step('should find and click on add test user', async () => { + await poAdmin.searchUser(addTestUser.data.username); + await poAdmin.getUserRowByUsername(addTestUser.data.username).click(); + }); + + await test.step('should navigate to edit user form', async () => { + await poAdmin.userInfo.btnEdit.click(); + }); + + await test.step('should verify custom field is not rendered', async () => { + await expect(poAdmin.editUser.getCustomField('customFieldText1')).not.toBeVisible(); + await expect(poAdmin.editUser.getCustomField('customFieldText2')).toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-users-role-management.spec.ts b/apps/meteor/tests/e2e/admin-users-role-management.spec.ts new file mode 100644 index 0000000..f500e94 --- /dev/null +++ b/apps/meteor/tests/e2e/admin-users-role-management.spec.ts @@ -0,0 +1,66 @@ +import { Users } from './fixtures/userStates'; +import { AdminUsers } from './page-objects'; +import { test, expect } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser } from './utils/user-helpers'; + +let userWithoutAdminAccess: ITestUser; +let userWithAdminAccess: ITestUser; +let admin: AdminUsers; + +test.describe('Admin > Users Role Management', () => { + test.beforeAll('Create test users', async ({ api }) => { + userWithoutAdminAccess = await createTestUser(api); + userWithAdminAccess = await createTestUser(api, { roles: ['admin'] }); + }); + + test.afterAll('Delete test users', async () => { + await userWithoutAdminAccess.delete(); + await userWithAdminAccess.delete(); + }); + + test.describe('Admin Role Management', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeEach('Go to /admin/users', async ({ page }) => { + admin = new AdminUsers(page); + await admin.goto(); + }); + + test('Make a newly created user as admin', async () => { + await test.step('should be visible in the All tab', async () => { + await admin.getTabByName().click(); + await admin.searchUser(userWithoutAdminAccess.data.username); + }); + + await test.step('make a user admin', async () => { + await admin.dispatchUserAction(userWithoutAdminAccess.data.username, 'Make Admin'); + await admin.toastMessage.waitForDisplay({ type: 'success', message: 'User is now an admin' }); + }); + + await test.step('verify user is admin', async () => { + await admin.getUserRowByUsername(userWithoutAdminAccess.data.username).click(); + await expect(admin.userInfo.root).toBeVisible(); + await expect(admin.userInfo.root).toContainText('Admin'); + }); + }); + + test('Remove role as admin', async () => { + await test.step('User should be visible in the All tab', async () => { + await admin.getTabByName().click(); + await admin.searchUser(userWithAdminAccess.data.username); + }); + + await test.step('remove admin role', async () => { + await admin.dispatchUserAction(userWithAdminAccess.data.username, 'Remove Admin'); + await admin.toastMessage.waitForDisplay({ type: 'success', message: 'User is no longer an admin' }); + }); + + await test.step('verify user role as admin is removed', async () => { + await admin.getUserRowByUsername(userWithAdminAccess.data.username).click(); + await expect(admin.userInfo.root).toBeVisible(); + await expect(admin.userInfo.root).not.toHaveText('Admin'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-users-status-management.spec.ts b/apps/meteor/tests/e2e/admin-users-status-management.spec.ts new file mode 100644 index 0000000..6b87b90 --- /dev/null +++ b/apps/meteor/tests/e2e/admin-users-status-management.spec.ts @@ -0,0 +1,80 @@ +import type { BrowserContext, Page } from '@playwright/test'; + +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { AdminUsers, Authenticated, Login } from './page-objects'; +import { test, expect } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser } from './utils/user-helpers'; + +let user: ITestUser; +let adminUsers: AdminUsers; + +test.describe.serial('Admin > Users', () => { + test.beforeAll('Create a new user', async ({ api }) => { + user = await createTestUser(api); + }); + + test.afterAll('Delete the new user', async () => { + await user.delete(); + }); + test.describe('Login', () => { + let context: BrowserContext; + let page: Page; + let poLogin: Login; + let poAuth: Authenticated; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + poLogin = new Login(page); + poAuth = new Authenticated(page); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test('Login as a newly created user and verify its status is active', async () => { + await poLogin.goto('/login'); + await test.step('should log in as the newly created user', async () => { + await poLogin.login(user.data.username, DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('Assert user is logged in', async () => { + await poAuth.waitForDisplay(); + }); + }); + }); + + test.describe('Admin > Users Status Management', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeEach('Go to /admin/users', async ({ page }) => { + adminUsers = new AdminUsers(page); + await adminUsers.goto(); + }); + + test('After the first login, the user gets listed under the Active tab', async () => { + await test.step('should be visible in the All tab', async () => { + await adminUsers.getTabByName().click(); + await adminUsers.searchUser(user.data.username); + }); + + await test.step('should not be visible in the Pending tab', async () => { + await adminUsers.getTabByName('Pending').click(); + await expect(adminUsers.getUserRowByUsername(user.data.username)).not.toBeVisible(); + }); + + await test.step('should be visible in the Active tab', async () => { + await adminUsers.getTabByName('Active').click(); + await expect(adminUsers.getUserRowByUsername(user.data.username)).toBeVisible(); + }); + + await test.step('should not be visible in the Deactivated tab', async () => { + await adminUsers.getTabByName('Deactivated').click(); + await expect(adminUsers.getUserRowByUsername(user.data.username)).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/admin-users.spec.ts b/apps/meteor/tests/e2e/admin-users.spec.ts new file mode 100644 index 0000000..7bdb350 --- /dev/null +++ b/apps/meteor/tests/e2e/admin-users.spec.ts @@ -0,0 +1,66 @@ +import { Users } from './fixtures/userStates'; +import { AdminUsers } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser } from './utils/user-helpers'; + +let user: ITestUser; +let admin: AdminUsers; + +test.use({ storageState: Users.admin.state }); + +test.describe('Admin > Users', () => { + test.beforeAll('Create a new user', async ({ api }) => { + await setSettingValueById(api, 'Accounts_ManuallyApproveNewUsers', true); + user = await createTestUser(api); + }); + + test.afterAll('Delete the new user', async ({ api }) => { + await user.delete(); + await setSettingValueById(api, 'Accounts_ManuallyApproveNewUsers', false); + }); + + test.beforeEach('Go to /admin/users', async ({ page }) => { + admin = new AdminUsers(page); + await admin.goto(); + }); + + test('New user shows in correct tabs when deactivated', async () => { + await test.step('should be visible in the All tab', async () => { + await admin.getTabByName().click(); + await admin.searchUser(user.data.username); + }); + + await test.step('should be visible in the Pending tab', async () => { + await admin.getTabByName('Pending').click(); + await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible(); + }); + + await test.step('should not be visible in the Active tab', async () => { + await admin.getTabByName('Active').click(); + await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible(); + }); + + await test.step('should not be visible in the Deactivated tab', async () => { + await admin.getTabByName('Deactivated').click(); + await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible(); + }); + + await test.step('should move from Pending to Active tab', async () => { + await admin.getTabByName('Pending').click(); + await admin.activatePendingUser(user.data.username); + await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible(); + await admin.getTabByName('Active').click(); + await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible(); + }); + + await test.step('should move from Active to Deactivated tab', async () => { + await admin.getTabByName('Active').click(); + await admin.dispatchUserAction(user.data.username, 'Deactivate'); + await expect(admin.getUserRowByUsername(user.data.username)).not.toBeVisible(); + await admin.getTabByName('Deactivated').click(); + await expect(admin.getUserRowByUsername(user.data.username)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/administration-settings.spec.ts b/apps/meteor/tests/e2e/administration-settings.spec.ts new file mode 100644 index 0000000..f8ca46b --- /dev/null +++ b/apps/meteor/tests/e2e/administration-settings.spec.ts @@ -0,0 +1,101 @@ +import { Users } from './fixtures/userStates'; +import { AdminSettings, AdminSectionsHref } from './page-objects'; +import { Navbar } from './page-objects/fragments'; +import { LoginPage } from './page-objects/login'; +import { getSettingValueById, setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.parallel('administration-settings', () => { + let poAdminSettings: AdminSettings; + let navbar: Navbar; + let poLoginPage: LoginPage; + + test.beforeEach(async ({ page }) => { + poAdminSettings = new AdminSettings(page); + navbar = new Navbar(page); + poLoginPage = new LoginPage(page); + }); + + test.describe('Settings Page', () => { + test.beforeEach(async () => { + await poAdminSettings.goto(); + }); + + test('should display settings list', async ({ page }) => { + await test.step('should list settings on load', async () => { + await expect(page.getByText('No results found')).not.toBeVisible(); + }); + + await test.step('should list settings after logout and login', async () => { + await poAdminSettings.sidebar.close(); + await navbar.logout(); + + await poLoginPage.loginByUserState(Users.admin); + + await navbar.openAdminPanel(); + const settingsButton = await poAdminSettings.adminSectionButton(AdminSectionsHref.settings); + await settingsButton.click(); + + await expect(page.getByText('No results found')).not.toBeVisible(); + }); + }); + }); + + test.describe('General', () => { + let inputSiteURLSetting: string; + + test.beforeAll(async ({ api }) => { + inputSiteURLSetting = (await getSettingValueById(api, 'Site_Url')) as string; + }); + + test.beforeEach(async () => { + await poAdminSettings.gotoGeneral(); + }); + + test('should be able to reset a setting after a change', async () => { + await poAdminSettings.inputSiteURL.fill('any_text'); + await poAdminSettings.btnResetSiteURL.click(); + + await expect(poAdminSettings.inputSiteURL).toHaveValue(inputSiteURLSetting); + }); + + test('should be able to go back to the settings page', async ({ page }) => { + await poAdminSettings.btnBack.click(); + + await expect(page).toHaveURL('/admin/settings'); + }); + }); + + test.describe('Layout', () => { + test.beforeEach(async () => { + await poAdminSettings.gotoLayout(); + }); + + test.afterAll(async ({ api }) => setSettingValueById(api, 'theme-custom-css', '')); + + test('should display the code mirror correctly', async ({ page, api }) => { + await poAdminSettings.getAccordionBtnByName('Custom CSS').click(); + + await test.step('should render only one code mirror element', async () => { + const codeMirrorParent = page.getByRole('code'); + await expect(codeMirrorParent.locator('.CodeMirror')).toHaveCount(1); + }); + + await test.step('should display full screen properly', async () => { + await poAdminSettings.btnFullScreen.click(); + await expect(page.getByRole('code')).toHaveCSS('width', '920px'); + await poAdminSettings.btnExitFullScreen.click(); + }); + + await test.step('should reflect updated value when valueProp changes after server update', async () => { + const codeValue = `.test-class-${Date.now()} { background-color: red; }`; + await setSettingValueById(api, 'theme-custom-css', codeValue); + + const codeMirrorParent = page.getByRole('code'); + await expect(codeMirrorParent.locator('.CodeMirror-line')).toHaveText(codeValue); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts new file mode 100644 index 0000000..9e9ce88 --- /dev/null +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -0,0 +1,469 @@ +import { faker } from '@faker-js/faker'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { + AdminUsers, + AdminPermissions, + AdminRooms, + AdminThirdPartyLogin, + AdminIntegrations, + AdminInfo, + AdminEngagement, + AdminMailer, +} from './page-objects'; +import { AdminDeviceManagement } from './page-objects/admin-device-management'; +import { ToastMessages } from './page-objects/fragments'; +import { createTargetChannel, setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.parallel('administration', () => { + let targetChannel: string; + + test.describe('Workspace', () => { + test.beforeEach(async ({ page }) => { + await new AdminInfo(page).goto(); + }); + + test('expect download info as JSON', async ({ page }) => { + const [download] = await Promise.all([page.waitForEvent('download'), page.locator('button:has-text("Download info")').click()]); + + await expect(download.suggestedFilename()).toBe('statistics.json'); + }); + }); + + test.describe('Engagement dashboard', () => { + test('Should show upsell modal', async ({ page }) => { + test.skip(IS_EE); + const poAdminEngagement = new AdminEngagement(page); + await poAdminEngagement.gotoExpecting(poAdminEngagement.upsellModal); + + await expect(poAdminEngagement.upsellModal).toBeVisible(); + }); + + test('Should show engagement dashboard', async ({ page }) => { + test.skip(!IS_EE); + await new AdminEngagement(page).goto(); + + await expect(page.locator('h1 >> text="Engagement"')).toBeVisible(); + }); + }); + + test.describe('Device management', () => { + test('Should show upsell modal', async ({ page }) => { + test.skip(IS_EE); + const poAdminDeviceManagement = new AdminDeviceManagement(page); + await poAdminDeviceManagement.gotoExpecting(poAdminDeviceManagement.upsellModal); + + await expect(poAdminDeviceManagement.upsellModal).toBeVisible(); + }); + + test('Should show device management page', async ({ page }) => { + test.skip(!IS_EE); + await new AdminDeviceManagement(page).goto(); + + await expect(page.locator('h1 >> text="Device management"')).toBeVisible(); + }); + }); + + test.describe('Users', () => { + let poAdminUsers: AdminUsers; + test.beforeEach(async ({ page }) => { + poAdminUsers = new AdminUsers(page); + + await poAdminUsers.goto(); + }); + + test('expect find "user1" user', async () => { + await poAdminUsers.searchUser('user1'); + }); + + test('expect create a user', async () => { + await poAdminUsers.btnNewUser.click(); + await poAdminUsers.editUser.inputEmail.fill(faker.internet.email()); + await poAdminUsers.editUser.inputName.fill(faker.person.firstName()); + await poAdminUsers.editUser.inputUserName.fill(faker.internet.userName()); + await poAdminUsers.editUser.inputSetManually.click(); + await poAdminUsers.editUser.inputPassword.fill('P@ssw0rd1234.!'); + await poAdminUsers.editUser.inputConfirmPassword.fill('P@ssw0rd1234.!'); + await expect(poAdminUsers.editUser.chipUserRole).toBeVisible(); + await poAdminUsers.editUser.btnAddUser.click(); + }); + + test('expect SMTP setup warning and routing to email settings', async ({ page }) => { + await poAdminUsers.btnInvite.click(); + await poAdminUsers.editUser.setupSmtpLink.click(); + await expect(page).toHaveURL('/admin/settings/Email'); + }); + + test('expect to show join default channels option only when creating new users, not when editing users', async () => { + const username = faker.internet.userName(); + + await poAdminUsers.btnNewUser.click(); + await poAdminUsers.editUser.inputName.fill(faker.person.firstName()); + await poAdminUsers.editUser.inputUserName.fill(username); + await poAdminUsers.editUser.inputEmail.fill(faker.internet.email()); + await poAdminUsers.editUser.inputSetManually.click(); + await poAdminUsers.editUser.inputPassword.fill('P@ssw0rd1234.!'); + await poAdminUsers.editUser.inputConfirmPassword.fill('P@ssw0rd1234.!'); + await expect(poAdminUsers.editUser.chipUserRole).toBeVisible(); + await expect(poAdminUsers.editUser.joinDefaultChannels).toBeVisible(); + await poAdminUsers.editUser.btnAddUser.click(); + + await poAdminUsers.searchUser(username); + await poAdminUsers.getUserRowByUsername(username).click(); + await poAdminUsers.userInfo.btnEdit.click(); + await expect(poAdminUsers.editUser.inputUserName).toHaveValue(username); + await expect(poAdminUsers.editUser.joinDefaultChannels).not.toBeVisible(); + }); + + test.describe('Delete user', () => { + const nonEmptyChannelName = faker.string.uuid(); + const emptyChannelName = faker.string.uuid(); + let ownerUser: IUser; + let user: IUser; + let poToastMessage: ToastMessages; + + test.beforeEach(({ page }) => { + poToastMessage = new ToastMessages(page); + }); + + test.beforeAll(async ({ api }) => { + const createUserResponse = await api.post('/users.create', { + email: faker.internet.email(), + name: faker.person.fullName(), + password: faker.internet.password(), + username: faker.internet.userName(), + }); + + user = (await createUserResponse.json()).user; + + const createOwnerUserResponse = await api.post('/users.create', { + email: faker.internet.email(), + name: faker.person.fullName(), + password: faker.internet.password(), + username: faker.internet.userName(), + }); + + ownerUser = (await createOwnerUserResponse.json()).user; + + // TODO: refactor createChannel utility in order to get channel data when creating + const response = await api.post('/channels.create', { name: nonEmptyChannelName, members: [ownerUser.username] }); + const { channel: nonEmptyChannel } = await response.json(); + + await api.post('/channels.addOwner', { roomId: nonEmptyChannel._id, username: ownerUser.username }); + await api.post('/channels.removeOwner', { roomId: nonEmptyChannel._id, userId: Users.admin.data._id }); + + // TODO: refactor createChannel utility in order to get channel data when creating + const res = await api.post('/groups.create', { name: emptyChannelName, members: [ownerUser.username] }); + const { group: emptyRoom } = await res.json(); + + await api.post('/groups.addOwner', { roomId: emptyRoom._id, username: ownerUser.username }); + await api.post('/groups.leave', { roomId: emptyRoom._id }); + }); + + test('expect to show owner change modal, when deleting last owner of any room', async ({ page }) => { + await poAdminUsers.deleteUser(ownerUser.username); + await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toContainText( + `A new owner will be assigned automatically to the ${nonEmptyChannelName} room.`, + ); + await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toContainText( + `The empty room ${emptyChannelName} will be removed automatically.`, + ); + await expect(page.getByRole('dialog').getByRole('button', { name: 'Delete' })).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + await poToastMessage.waitForDisplay(); + await expect(page.getByRole('heading', { name: 'No users' })).toBeVisible(); + }); + + test('expect to delete user', async ({ page }) => { + await poAdminUsers.deleteUser(user.username); + await expect(page.getByRole('dialog', { name: 'Are you sure?' })).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await poToastMessage.waitForDisplay(); + await expect(page.getByRole('heading', { name: 'No users' })).toBeVisible(); + }); + }); + }); + + // TODO: Move this suit to admin-rooms.spec.ts + test.describe('Rooms', () => { + let poAdminRooms: AdminRooms; + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + test.beforeEach(async ({ page }) => { + poAdminRooms = new AdminRooms(page); + await poAdminRooms.goto(); + }); + + test('should find "general" channel', async ({ page }) => { + await poAdminRooms.inputSearchRooms.type('general'); + await page.waitForSelector('[qa-room-id="GENERAL"]'); + }); + + test('should edit target channel name', async () => { + await poAdminRooms.inputSearchRooms.fill(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.roomNameInput.fill(`${targetChannel}-edited`); + await poAdminRooms.editRoom.btnSave.click(); + + await expect(poAdminRooms.getRoomRow(targetChannel)).toContainText(`${targetChannel}-edited`); + + targetChannel = `${targetChannel}-edited`; + }); + + test('should edit target channel type', async () => { + await poAdminRooms.inputSearchRooms.type(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.privateLabel.click(); + await poAdminRooms.editRoom.btnSave.click(); + await expect(poAdminRooms.getRoomRow(targetChannel)).toContainText('Private Channel'); + }); + + test('should archive target channel', async () => { + await poAdminRooms.inputSearchRooms.type(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.archivedLabel.click(); + await poAdminRooms.editRoom.btnSave.click(); + await poAdminRooms.editRoom.waitForDismissal(); + + await poAdminRooms.getRoomRow(targetChannel).click(); + await expect(poAdminRooms.editRoom.archivedInput).toBeChecked(); + }); + + test.describe.serial('Default rooms', () => { + test('expect target channel to be default', async () => { + await poAdminRooms.inputSearchRooms.type(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.defaultLabel.click(); + + await test.step('should close contextualbar after saving', async () => { + await poAdminRooms.editRoom.btnSave.click(); + await poAdminRooms.editRoom.waitForDismissal(); + }); + + await poAdminRooms.getRoomRow(targetChannel).click(); + await expect(poAdminRooms.editRoom.defaultInput).toBeChecked(); + }); + + test('should mark target default channel as "favorite by default"', async () => { + await poAdminRooms.inputSearchRooms.fill(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.favoriteLabel.click(); + await poAdminRooms.editRoom.btnSave.click(); + await expect(poAdminRooms.editRoom.btnSave).not.toBeVisible(); + + await poAdminRooms.getRoomRow(targetChannel).click(); + await expect(poAdminRooms.editRoom.favoriteInput).toBeChecked(); + }); + + test('should see favorite switch disabled when default is not true', async () => { + await poAdminRooms.inputSearchRooms.fill(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + await poAdminRooms.editRoom.defaultLabel.click(); + + await expect(poAdminRooms.editRoom.favoriteInput).toBeDisabled(); + }); + + test('should see favorite switch enabled when default is true', async () => { + await poAdminRooms.inputSearchRooms.type(targetChannel); + await poAdminRooms.getRoomRow(targetChannel).click(); + + await expect(poAdminRooms.editRoom.favoriteInput).toBeEnabled(); + }); + }); + }); + + test.describe('Permissions', () => { + let poAdminPermissions: AdminPermissions; + test.beforeEach(async ({ page }) => { + poAdminPermissions = new AdminPermissions(page); + await poAdminPermissions.goto(); + }); + + test('expect open upsell modal if not enterprise', async ({ page }) => { + test.skip(IS_EE); + await poAdminPermissions.btnCreateRole.click(); + await expect(page.getByRole('dialog', { name: 'Custom roles' })).toBeVisible(); + }); + + test.describe('Users in role', () => { + const channelName = faker.string.uuid(); + test.beforeAll(async ({ api }) => { + // TODO: refactor createChannel utility in order to get channel data when creating + const response = await api.post('/channels.create', { name: channelName, members: ['user1'] }); + const { channel } = await response.json(); + + await api.post('/channels.addOwner', { roomId: channel._id, userId: Users.user1.data._id }); + await api.post('/channels.removeOwner', { roomId: channel._id, userId: Users.admin.data._id }); + }); + + test('admin should be able to get the owners of a room that wasnt created by him', async ({ page }) => { + await poAdminPermissions.openRoleByName('Owner').click(); + await poAdminPermissions.btnUsersInRole.click(); + await poAdminPermissions.inputRoom.fill(channelName); + await page.getByRole('option', { name: channelName }).click(); + + await expect(poAdminPermissions.getUserInRoleRowByUsername('user1')).toBeVisible(); + }); + + test('should add user1 as moderator of target channel', async ({ page }) => { + await poAdminPermissions.openRoleByName('Moderator').click(); + await poAdminPermissions.btnUsersInRole.click(); + + await poAdminPermissions.inputRoom.fill(channelName); + await page.getByRole('option', { name: channelName }).click(); + + await poAdminPermissions.inputUsers.pressSequentially('user1'); + await page.getByRole('option', { name: 'user1' }).click(); + await poAdminPermissions.btnAdd.click(); + + await expect(poAdminPermissions.getUserInRoleRowByUsername('user1')).toBeVisible(); + }); + + test('should remove user1 as moderator of target channel', async ({ page }) => { + await poAdminPermissions.openRoleByName('Moderator').click(); + await poAdminPermissions.btnUsersInRole.click(); + + await poAdminPermissions.inputRoom.fill(channelName); + await page.getByRole('option', { name: channelName }).click(); + + await poAdminPermissions.removeUserFromRoleByUsername('user1'); + await expect(page.locator('h3 >> text="No results found"')).toBeVisible(); + }); + + test('should back to the permissions page', async ({ page }) => { + await poAdminPermissions.openRoleByName('Moderator').click(); + await poAdminPermissions.btnUsersInRole.click(); + await poAdminPermissions.btnBack.click(); + + await expect(page.locator('h1 >> text="Permissions"')).toBeVisible(); + }); + }); + }); + + test.describe('Mailer', () => { + test.beforeEach(async ({ page }) => { + await new AdminMailer(page).goto(); + }); + + test('should not have any accessibility violations', async ({ makeAxeBuilder }) => { + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + }); + + test.describe.serial('Third party login', () => { + let poAdminThirdPartyLogin: AdminThirdPartyLogin; + const appName = faker.string.uuid(); + const appRedirectURI = faker.internet.url(); + + test.beforeEach(async ({ page }) => { + poAdminThirdPartyLogin = new AdminThirdPartyLogin(page); + await poAdminThirdPartyLogin.goto(); + }); + + test('should show Third-party login page', async ({ page }) => { + await expect(page.locator('h1 >> text="Third-party login"')).toBeVisible(); + }); + + test('should not be able to create a new application without application name', async ({ page }) => { + await poAdminThirdPartyLogin.btnNewApplication.click(); + await poAdminThirdPartyLogin.inputRedirectURI.fill(appRedirectURI); + await poAdminThirdPartyLogin.btnSave.click(); + + await expect(page.getByText('Name required')).toBeVisible(); + }); + + test('should not be able to create a new application without redirect URI', async ({ page }) => { + await poAdminThirdPartyLogin.btnNewApplication.click(); + await poAdminThirdPartyLogin.inputApplicationName.fill(appName); + await poAdminThirdPartyLogin.btnSave.click(); + + await expect(page.getByText('Redirect URI required')).toBeVisible(); + }); + + test('should be able to create a new application', async ({ page }) => { + await poAdminThirdPartyLogin.btnNewApplication.click(); + await poAdminThirdPartyLogin.inputApplicationName.fill(appName); + await poAdminThirdPartyLogin.inputRedirectURI.fill(appRedirectURI); + await poAdminThirdPartyLogin.btnSave.click(); + + await expect(poAdminThirdPartyLogin.getThirdPartyAppByName(appName)).toBeVisible(); + await expect(page.getByText('Application added')).toBeVisible(); + }); + + test('should be able see aplication fields', async () => { + await poAdminThirdPartyLogin.getThirdPartyAppByName(appName).click(); + await expect(poAdminThirdPartyLogin.inputApplicationName).toBeVisible(); + await expect(poAdminThirdPartyLogin.inputRedirectURI).toBeVisible(); + await expect(poAdminThirdPartyLogin.inputClientId).toBeVisible(); + await expect(poAdminThirdPartyLogin.inputClientSecret).toBeVisible(); + await expect(poAdminThirdPartyLogin.inputAuthUrl).toBeVisible(); + await expect(poAdminThirdPartyLogin.inputTokenUrl).toBeVisible(); + }); + + test('should be able to delete an application', async ({ page }) => { + await poAdminThirdPartyLogin.deleteThirdPartyAppByName(appName); + + await expect(poAdminThirdPartyLogin.getThirdPartyAppByName(appName)).not.toBeVisible(); + await expect(page.getByText('Your entry has been deleted.')).toBeVisible(); + }); + }); + + test.describe('Integrations', () => { + let poAdminIntegrations: AdminIntegrations; + + const messageCodeHighlightDefault = + 'javascript,css,markdown,dockerfile,json,go,rust,clean,bash,plaintext,powershell,scss,shell,yaml,vim'; + const incomingIntegrationName = faker.string.uuid(); + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Code_highlight', ''); + }); + + test.beforeEach(async ({ page }) => { + poAdminIntegrations = new AdminIntegrations(page); + await poAdminIntegrations.goto(); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Code_highlight', messageCodeHighlightDefault); + }); + + test('should display the example payload correctly', async () => { + await poAdminIntegrations.btnNew.click(); + await poAdminIntegrations.btnInstructions.click(); + + await expect(poAdminIntegrations.codeExamplePayload('Loading')).not.toBeVisible(); + }); + + test('should be able to create new incoming integration', async () => { + await poAdminIntegrations.btnNew.click(); + await poAdminIntegrations.inputName.fill(incomingIntegrationName); + await poAdminIntegrations.inputPostToChannel.fill('#general'); + await poAdminIntegrations.inputPostAs.fill('rocket.cat'); + await poAdminIntegrations.btnSave.click(); + + await expect(poAdminIntegrations.inputWebhookUrl).not.toHaveValue('Will be available here after saving.'); + + await poAdminIntegrations.btnBack.click(); + await expect(poAdminIntegrations.getIntegrationByName(incomingIntegrationName)).toBeVisible(); + }); + + test('should be able to delete an incoming integration', async () => { + await poAdminIntegrations.deleteIntegrationByName(incomingIntegrationName); + await expect(poAdminIntegrations.getIntegrationByName(incomingIntegrationName)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/anonymous-user.spec.ts b/apps/meteor/tests/e2e/anonymous-user.spec.ts new file mode 100644 index 0000000..ffa583d --- /dev/null +++ b/apps/meteor/tests/e2e/anonymous-user.spec.ts @@ -0,0 +1,46 @@ +import { faker } from '@faker-js/faker'; + +import { HomeChannel, Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { expect, test } from './utils/test'; + +test.describe('anonymous-user', () => { + let poHomeChannel: HomeChannel; + + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousRead', true)).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousWrite', true)).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousRead', false)).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_AllowAnonymousWrite', false)).status()).toBe(200); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + await poHomeChannel.navbar.openChat('general'); + }); + + test('expect to go to the login page as anonymous user', async ({ page }) => { + await poHomeChannel.content.btnAnonymousSignIn.click(); + + await expect(page.locator('role=form')).toBeVisible(); + }); + + test('expect to chat as anonymous user', async ({ page }) => { + poHomeChannel = new HomeChannel(page); + const poRegistration = new Registration(page); + + await poHomeChannel.content.btnAnonymousTalk.click(); + + await expect(poRegistration.username).toBeVisible(); + await poRegistration.username.type(faker.internet.userName()); + + await poRegistration.btnRegisterConfirmUsername.click(); + + await poHomeChannel.content.sendMessage('hello world'); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts b/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts new file mode 100644 index 0000000..ec3b67b --- /dev/null +++ b/apps/meteor/tests/e2e/apps/app-modal-interaction.spec.ts @@ -0,0 +1,72 @@ +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { createTargetChannel } from '../utils/create-target-channel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +// TODO: Remove the skip on this test once a fix in the apps engine is done +// For some reason the app doesn't work properly when installed via insertApp method +test.describe.skip('app-surfaces-interaction', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test('expect to submit an success modal', async ({ page }) => { + await page.locator('role=button[name="Options"]').click(); + await page.locator('[data-key="success"]').click(); + await page.locator('role=button[name="success"]').click(); + + const updatedButton = page.locator('role=button[name="success"]'); + await expect(updatedButton).not.toBeVisible(); + }); + + test('expect to not close the modal and there is an error in the modal', async ({ page }) => { + await page.locator('role=button[name="Options"]').click(); + await page.locator('[data-key="error"]').click(); + await page.locator('role=button[name="error"]').click(); + + const updatedTitle = page.locator('role=button[name="error"]'); + expect(updatedTitle).toBeDefined(); + + const input = page.locator('input[type="text"]'); + + await input.click(); + await input.fill('fixed'); + + await page.locator('role=button[name="error"]').click(); + await expect(input).not.toBeVisible(); + }); + + test('expect to show the toaster error for modal that timeout the execution', async ({ page }) => { + await page.locator('role=button[name="Options"]').click(); + await page.locator('[data-key="timeout"]').click(); + await page.locator('role=button[name="timeout"]').click(); + await page.locator('role=alert').waitFor(); + + const toaster = page.locator('role=alert'); + + await expect(toaster).toBeVisible(); + }); + + test('expect change the modal and then submit the updated modal', async ({ page }) => { + await page.locator('role=button[name="Options"]').click(); + await page.locator('[data-key="update"]').click(); + await page.locator('role=button[name="update"]').click(); + + const updatedTitle = page.locator('role=button[name="title updated"]'); + expect(updatedTitle).toBeDefined(); + + const updatedButton = page.locator('role=button[name="updated"]'); + expect(updatedButton).toBeDefined(); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/apps-contextualbar.spec.ts b/apps/meteor/tests/e2e/apps/apps-contextualbar.spec.ts new file mode 100644 index 0000000..5648613 --- /dev/null +++ b/apps/meteor/tests/e2e/apps/apps-contextualbar.spec.ts @@ -0,0 +1,36 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { expect, test } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('Apps > ContextualBar', () => { + test.skip(!IS_EE, 'Premium Only'); + let poHomeChannel: HomeChannel; + + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel('general'); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('expect allow user open app contextualbar', async () => { + await poHomeChannel.content.dispatchSlashCommand('/contextualbar'); + await expect(poHomeChannel.btnContextualbarClose).toBeVisible(); + }); + + test('expect app contextualbar to be closed', async () => { + await poHomeChannel.btnContextualbarClose.click(); + await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/apps-modal.spec.ts b/apps/meteor/tests/e2e/apps/apps-modal.spec.ts new file mode 100644 index 0000000..1e5eece --- /dev/null +++ b/apps/meteor/tests/e2e/apps/apps-modal.spec.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { AppsModal } from '../page-objects/fragments/modals'; +import { expect, test } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('Apps > Modal', () => { + test.skip(!IS_EE, 'Premium Only'); + let poHomeChannel: HomeChannel; + let poModal: AppsModal; + + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + + poHomeChannel = new HomeChannel(page); + poModal = new AppsModal(page); + + await poHomeChannel.gotoChannel('general'); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('should allow user open app modal', async () => { + await poHomeChannel.content.dispatchSlashCommand('/modal'); + await poModal.waitForDisplay(); + }); + + test('should display validation error message in app modal', async () => { + await poModal.btnSubmit.click(); + await expect(poModal.textInputErrorMessage).toBeVisible(); + }); + + test('should not display validation error message in app modal', async () => { + await poModal.submit('something'); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/private-apps-upload.spec.ts b/apps/meteor/tests/e2e/apps/private-apps-upload.spec.ts new file mode 100644 index 0000000..09b8e4d --- /dev/null +++ b/apps/meteor/tests/e2e/apps/private-apps-upload.spec.ts @@ -0,0 +1,116 @@ +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { Marketplace } from '../page-objects'; +import { expect, test } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Private apps upload', () => { + let poMarketplace: Marketplace; + + test.beforeEach(async ({ page }) => { + poMarketplace = new Marketplace(page); + + await poMarketplace.goto(); + }); + + test.describe('Premium', () => { + test.skip(!IS_EE, 'Premium Only'); + + test('expect to allow admin to upload a private app in EE, which should be enabled by default', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + + await poMarketplace.btnUploadPrivateApp.click(); + await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled(); + + await poMarketplace.btnUploadPrivateAppFile.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip'); + + await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled(); + await poMarketplace.btnInstallPrivateApp.click(); + await page.getByRole('button', { name: 'Agree' }).click(); + await expect(poMarketplace.appStatusTag).toHaveText('Enabled'); + }); + + test('expect to allow admin to update a enabled private app in EE, which should remain enabled', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + + await poMarketplace.btnUploadPrivateApp.click(); + await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled(); + + await poMarketplace.btnUploadPrivateAppFile.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip'); + + await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled(); + await poMarketplace.btnInstallPrivateApp.click(); + await poMarketplace.btnConfirmAppUpdate.click(); + await page.getByRole('button', { name: 'Agree' }).click(); + + await poMarketplace.goto(); + await poMarketplace.lastAppRow.click(); + await expect(poMarketplace.appStatusTag).toHaveText('Enabled'); + }); + + test('expect to allow disabling a recently installed private app in EE', async () => { + await poMarketplace.lastAppRow.click(); + await expect(poMarketplace.appStatusTag).toHaveText('Enabled'); + await poMarketplace.appMenu.click(); + await expect(poMarketplace.btnDisableApp).toBeEnabled(); + await poMarketplace.btnDisableApp.click(); + await poMarketplace.btnConfirmAppUpdate.click(); + await expect(poMarketplace.appStatusTag).toHaveText('Disabled'); + }); + + test('expect to allow admin to update a disabled private app in EE, which should remain disabled', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + + await poMarketplace.btnUploadPrivateApp.click(); + await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled(); + + await poMarketplace.btnUploadPrivateAppFile.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip'); + + await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled(); + await poMarketplace.btnInstallPrivateApp.click(); + await poMarketplace.btnConfirmAppUpdate.click(); + await page.getByRole('button', { name: 'Agree' }).click(); + + await poMarketplace.goto(); + await poMarketplace.lastAppRow.click(); + await expect(poMarketplace.appStatusTag).toHaveText('Disabled'); + }); + }); + + test.describe('Community Edition', () => { + test.skip(IS_EE, 'CE Only'); + + test('expect to allow admin to upload a private app in CE, but it should be disabled by default', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + + await poMarketplace.btnUploadPrivateApp.click(); + await expect(poMarketplace.btnConfirmAppUploadModal).toBeEnabled(); + await poMarketplace.btnConfirmAppUploadModal.click(); + + await expect(poMarketplace.btnInstallPrivateApp).toBeDisabled(); + await poMarketplace.btnUploadPrivateAppFile.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./tests/e2e/fixtures/files/test-app_0.0.1.zip'); + + await expect(poMarketplace.btnInstallPrivateApp).toBeEnabled(); + await poMarketplace.btnInstallPrivateApp.click(); + + await page.getByRole('button', { name: 'Agree' }).click(); + await expect(poMarketplace.appStatusTag).toHaveText('Disabled'); + }); + + test('expect not to allow enabling a recently installed private app in CE', async () => { + await poMarketplace.lastAppRow.click(); + await expect(poMarketplace.appStatusTag).toHaveText('Disabled'); + await poMarketplace.appMenu.click(); + await expect(poMarketplace.btnEnableApp).toBeDisabled(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts b/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts new file mode 100644 index 0000000..63df9ad --- /dev/null +++ b/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts @@ -0,0 +1,288 @@ +import { appUiKitRoomTest } from '../../data/apps/app-packages'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { getAppLogs, installLocalTestPackage, uninstallApp } from '../utils/apps'; +import { expect, test } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('Apps > UIKit interactions data', () => { + test.skip(!IS_EE, 'Premium Only'); + let poHomeChannel: HomeChannel; + let appId: string; + + test.beforeAll(async () => { + const result = await installLocalTestPackage(appUiKitRoomTest); + appId = result.app.id; + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel('general'); + }); + + test.afterAll(async () => { + await uninstallApp(appId); + }); + + /** + * Finds a log entry matching a handler method and a specific debug label. + * The app logs using `this.getLogger().debug(label, value)`, creating entries with args = [label, value]. + * Each handler invocation creates a log group with `method` like `app:executeBlockActionHandler`. + */ + function findLogItem( + logs: Awaited>['logs'], + methodFragment: string, + [arg0, arg1]: [arg0: string, arg1?: string], + ) { + return logs.find( + (log) => + log.method.includes(methodFragment) && log.entries.some((entry) => arg0 === entry.args[0] && (!arg1 || arg1 === entry.args[1])), + ); + } + + test('should include correct data in executeBlockActionHandler when triggered in a message', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Send a message with a button via the slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal message ${seed}`); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the message with the button to appear and click it + await page.getByRole('button', { name: 'Click!' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + expect(blockActionLog, 'Block action handler log not found for message').toBeTruthy(); + + // Verify room is present (GENERAL room) + const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room'); + expect(roomEntry?.args[1], 'Room id should be present for message block action').toBe('GENERAL'); + + // Verify user is present + const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user'); + expect(userEntry?.args[1], 'User should be present for message block action').toBe('user1'); + + // Verify triggerId is present + const triggerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_triggerId'); + expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId'); + + // Verify actionId is correct + const actionIdEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_actionId'); + expect(actionIdEntry?.args[1], 'ActionId should be msg-button').toBe('msg-button'); + + // Verify container type + const containerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_container'); + expect(containerEntry?.args[1], 'Container type should be message').toBe('message'); + }); + + test('should include correct data in executeBlockActionHandler when triggered in a contextual bar surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a contextual bar via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`); + + // Opening a contextual bar via the Apps Engine causes a client-side URL navigation + // (e.g. /channel/general/app/{viewId}). Wait for that navigation to complete before + // using any locators, otherwise Playwright throws "Target page, context or browser + // has been closed" while the navigation is still in progress. + await page.waitForURL(/\/app\//); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the contextual bar to appear and click the button + const surface = page.getByRole('dialog', { name: 'UIKit Room Test Contextual Bar' }); + await surface.getByRole('button', { name: 'Click!' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + // Find the most recent block action log with ctx-button actionId + const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + expect(blockActionLog, 'Block action handler log not found for contextual bar').toBeTruthy(); + + // Verify room is present + const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room'); + expect(roomEntry?.args[1], 'Room id should be present for contextual bar block action').toBe('GENERAL'); + + // Verify user is present + const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user'); + expect(userEntry?.args[1], 'User should be present for contextual bar block action').toBe('user1'); + + // Verify triggerId is present + const triggerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_triggerId'); + expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId'); + + // Close the contextual bar + await surface.getByRole('button', { name: 'Close' }).click(); + }); + + test('should include correct data in executeBlockActionHandler when triggered in a modal surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a modal via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the modal to appear and click the button + const surface = page.getByRole('dialog', { name: 'UIKit Room Test Modal' }); + await surface.getByRole('button', { name: 'Click!' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + // Find the most recent block action log with modal-button actionId + const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + expect(blockActionLog, 'Block action handler log not found for modal').toBeTruthy(); + + // Verify user is present + const userEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_user'); + expect(userEntry?.args[1], 'User should be present for modal block action').toBe('user1'); + + // Verify container type is view (modal) + const containerEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_container'); + expect(containerEntry?.args[1], 'Container type should be view for modal').toBe('view'); + + await test.step('room is intentionally absent for modal block actions (modals have no room context)', async () => { + const roomEntry = blockActionLog?.entries.find((e) => e.args[0] === 'block_action_room'); + expect(roomEntry?.args[1], 'Room entry should not be present for modal block action').toBe('no-room'); + }); + + // Close the modal for the next test + await surface.getByRole('button', { name: 'Close' }).click(); + }); + + test('should include correct data in executeViewSubmitHandler when triggered in a modal surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a modal via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the modal and submit it + await page.getByLabel('UIKit Room Test Modal').getByRole('button', { name: 'Submit' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `modal-${seed}`]); + expect(viewSubmitLog, 'View submit handler log not found for modal').toBeTruthy(); + + // Verify user is present + const userEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_user'); + expect(userEntry?.args[1], 'User should be present for modal view submit').toBe('user1'); + + // Verify triggerId is present + const triggerEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_triggerId'); + expect(triggerEntry?.args[1], 'TriggerId should be present').not.toBe('no-triggerId'); + }); + + test('should include correct data in executeViewSubmitHandler when triggered in a contextual bar surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a contextual bar via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`); + + // Wait for the client-side navigation to the contextual bar URL to complete + await page.waitForURL(/\/app\//); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the contextual bar and submit it + await page.getByLabel('UIKit Room Test Contextual Bar').getByRole('button', { name: 'Submit' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + // Find the most recent view submit log + const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `ctx-${seed}`]); + expect(viewSubmitLog, 'View submit handler log not found for contextual bar').toBeTruthy(); + + // Verify room is present + const roomEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_room'); + expect(roomEntry?.args[1], 'Room id should be present for contextual bar view submit').toBe('GENERAL'); + + // Verify user is present + const userEntry = viewSubmitLog?.entries.find((e) => e.args[0] === 'view_submit_user'); + expect(userEntry?.args[1], 'User should be present for contextual bar view submit').toBe('user1'); + }); + + test('should include correct data in executeViewClosedHandler when triggered in a modal surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a modal via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal modal ${seed}`); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the modal and close it (via X button, not submit) + await page.getByLabel('UIKit Room Test Modal').getByRole('button', { name: 'Close' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `modal-${seed}`]); + expect(viewClosedLog, 'View closed handler log not found for modal').toBeTruthy(); + + // Verify user is present + const userEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_user'); + expect(userEntry?.args[1], 'User should be present for modal view closed').toBe('user1'); + }); + + test('should include correct data in executeViewClosedHandler when triggered in a contextual bar surface', async ({ api, page }) => { + const seed = Date.now().toString(); + + // Open a contextual bar via slash command + await poHomeChannel.content.dispatchSlashCommand(`/open-uikit-room-test-modal ctx ${seed}`); + + // Wait for the client-side navigation to the contextual bar URL to complete + await page.waitForURL(/\/app\//); + + const interactionResponse = page.waitForResponse('**/ui.interaction/*'); + + // Wait for the contextual bar to appear and close it + await page.getByLabel('UIKit Room Test Contextual Bar').getByRole('button', { name: 'Close' }).click(); + + await interactionResponse; + + // Fetch app logs and validate + const logsResult = await getAppLogs(api, appId); + expect(logsResult.logs).toBeDefined(); + + // Find the most recent view closed log + const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `ctx-${seed}`]); + expect(viewClosedLog, 'View closed handler log not found for contextual bar').toBeTruthy(); + + // Verify room is present + const roomEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_room'); + expect(roomEntry?.args[1], 'Room id should be present for contextual bar view closed').toBe('GENERAL'); + + // Verify user is present + const userEntry = viewClosedLog?.entries.find((e) => e.args[0] === 'view_closed_user'); + expect(userEntry?.args[1], 'User should be present for contextual bar view closed').toBe('user1'); + }); +}); diff --git a/apps/meteor/tests/e2e/auto-translate.spec.ts b/apps/meteor/tests/e2e/auto-translate.spec.ts new file mode 100644 index 0000000..4a39a5d --- /dev/null +++ b/apps/meteor/tests/e2e/auto-translate.spec.ts @@ -0,0 +1,60 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannelAndReturnFullRoom, deleteChannel, sendTargetChannelMessage } from './utils'; +import { preserveSettings } from './utils/preserveSettings'; +import { seedTranslatedMessage } from './utils/seedTranslatedMessage'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const ORIGINAL_MESSAGE = 'Good morning everyone'; +const TRANSLATED_MESSAGE = 'Guten Morgen zusammen'; + +test.describe.serial('auto-translate', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + preserveSettings(['AutoTranslate_Enabled']); + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'AutoTranslate_Enabled', true); + + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = channel.name as string; + + await sendTargetChannelMessage(api, targetChannel, { msg: ORIGINAL_MESSAGE }); + + await seedTranslatedMessage({ + rid: channel._id, + msg: ORIGINAL_MESSAGE, + author: Users.user1, + translations: { de: TRANSLATED_MESSAGE }, + }); + + await api.post('/autotranslate.saveSettings', { roomId: channel._id, field: 'autoTranslateLanguage', value: 'de' }); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test('should render the translated message once the user enables automatic translation', async () => { + await poHomeChannel.gotoChannel(targetChannel); + + await expect(poHomeChannel.content.lastUserMessage).toContainText(ORIGINAL_MESSAGE); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemAutoTranslate.click(); + await poHomeChannel.tabs.autoTranslate.waitForDisplay(); + await poHomeChannel.tabs.autoTranslate.setAutomaticTranslation(true); + await poHomeChannel.toastMessage.waitForDisplay({ type: 'success' }); + + await expect(poHomeChannel.content.lastUserMessage).toContainText(TRANSLATED_MESSAGE); + await expect(poHomeChannel.content.lastUserMessage).not.toContainText(ORIGINAL_MESSAGE); + }); +}); diff --git a/apps/meteor/tests/e2e/avatar-settings.ts b/apps/meteor/tests/e2e/avatar-settings.ts new file mode 100644 index 0000000..a4f47f2 --- /dev/null +++ b/apps/meteor/tests/e2e/avatar-settings.ts @@ -0,0 +1,98 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, createTargetPrivateChannel, createDirectMessage } from './utils'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const testAvatars = (homeChannel: HomeChannel, channel: string, url: string) => { + test('expect sidebar avatar to have provider prefix', async () => { + expect(homeChannel.sidebar.getSidebarItemByName(channel).locator('img').getAttribute('src')).toBe(url); + }); + + test('expect channel header avatar to have provider prefix', async () => { + await homeChannel.navbar.openChat(channel); + expect(homeChannel.content.channelHeader.locator('img').getAttribute('src')).toBe(url); + }); + + test('expect channel info avatar to have provider prefix', async () => { + await homeChannel.navbar.openChat(channel); + expect(homeChannel.content.channelHeader.locator('img').getAttribute('src')).toBe(url); + }); +}; + +test.describe('avatar-settings', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.describe('external avatar provider', () => { + const providerUrlPrefix = 'https://example.com/avatar/'; + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_RoomAvatarExternalUrl', `${providerUrlPrefix}{username}`); + await setSettingValueById(api, 'Accounts_AvatarExternalUrl', `${providerUrlPrefix}{username}`); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_RoomAvatarExternalUrl', ''); + await setSettingValueById(api, 'Accounts_AvatarExternalUrl', ''); + }); + + test.describe('public channels', () => { + let channelName = ''; + let avatarUrl = ''; + + test.beforeAll(async ({ api }) => { + channelName = await createTargetChannel(api); + avatarUrl = `${providerUrlPrefix}${channelName}`; + }); + + testAvatars(poHomeChannel, channelName, avatarUrl); + }); + + test.describe('private channels', () => { + let channelName = ''; + let avatarUrl = ''; + + test.beforeAll(async ({ api }) => { + channelName = await createTargetPrivateChannel(api); + avatarUrl = `${providerUrlPrefix}${channelName}`; + }); + + testAvatars(poHomeChannel, channelName, avatarUrl); + }); + + test.describe('direct messages', () => { + const channelName = Users.user2.data.username; + const avatarUrl = `${providerUrlPrefix}${channelName}`; + + test.beforeAll(async ({ api }) => { + await createDirectMessage(api); + + // send a message as user 2 + test.use({ storageState: Users.user2.state }); + await poHomeChannel.navbar.openChat(Users.user1.data.username); + await poHomeChannel.content.sendMessage('hello world'); + + test.use({ storageState: Users.user1.state }); + }); + + testAvatars(poHomeChannel, channelName, avatarUrl); + + test('expect message avatar to have provider prefix', async () => { + await expect(poHomeChannel.content.lastUserMessage.locator('img')).toHaveAttribute('src', avatarUrl); + }); + + test('expect user card avatar to have provider prefix', async () => { + await poHomeChannel.content.lastUserMessage.locator('.rcx-message-header__name-container').click(); + await expect(poHomeChannel.userCard.imgUserCard).toHaveAttribute('src', avatarUrl); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/calendar.spec.ts b/apps/meteor/tests/e2e/calendar.spec.ts new file mode 100644 index 0000000..610d4d9 --- /dev/null +++ b/apps/meteor/tests/e2e/calendar.spec.ts @@ -0,0 +1,71 @@ +import type { CalendarEventImportProps } from '@rocket.chat/rest-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { test, expect, type BaseTest } from './utils/test'; +import { links } from '../../client/lib/links'; + +test.use({ storageState: Users.admin.state }); + +// TODO: Make this test run on CI - it's slow and requires an open browser, run manually for now. +test.describe.skip('Calendar', () => { + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: true })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Calendar_BusyStatus_Enabled', { value: false })).status()).toBe(200); + }); + + test.describe('Status changes', () => { + test.beforeEach(async ({ page, api }) => { + expect((await api.post('/users.setStatus', { status: 'away', message: '' })).status()).toBe(200); + await new HomeChannel(page).goto(); + }); + + test('Should change user status back to away when there are overlapping imported events', async ({ page, api }) => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away'); + + const event1 = await importCalendarEvent(api, { start: 1, end: 2 }); + await importCalendarEvent(api, { start: 1.5, end: 2.5 }); + const event3 = await importCalendarEvent(api, { start: 2.5, end: 3 }); + + await test.step('Should change status to busy', async () => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('busy', { + timeout: event1.endTime.getTime() - Date.now(), + }); + }); + + await test.step('Should change status to away again', async () => { + await expect(page.getByRole('button', { name: 'User menu' }).getByRole('status')).toHaveAccessibleName('away', { + timeout: event3.endTime.getTime() - Date.now() + 1000, + }); + }); + }); + }); +}); + +async function importCalendarEvent(api: BaseTest['api'], { now = Date.now(), start = 2, end = 4 } = {}) { + const startTime = new Date(now + 1000 * 60 * start); + const endTime = new Date(now + 1000 * 60 * end); + + const apiResponse = await api.post('/calendar-events.import', { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + subject: 'Test appointment', + description: 'Test appointment description', + meetingUrl: links.rocketChat, + busy: true, + externalId: `test-${start}-${end}-${now}`, + } satisfies CalendarEventImportProps); + + expect(apiResponse.status()).toBe(200); + + const { id } = await apiResponse.json(); + + if (typeof id !== 'string') { + throw new Error(`Expected id to be a string, but got ${typeof id}`); + } + + return { startTime, endTime, now, id }; +} diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts new file mode 100644 index 0000000..8736cec --- /dev/null +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -0,0 +1,355 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { CreateNewDiscussionModal } from './page-objects/fragments'; +import { createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('channel-management', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let discussionName: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + // TODO: this should be replaced by a unit test + test('should navigate on toolbar using arrow keys', async ({ page }) => { + const roomHeaderFavoriteBtn = poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE); + + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + await roomHeaderFavoriteBtn.focus(); + await expect(roomHeaderFavoriteBtn).toBeFocused(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + + await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Threads', exact: true })).toBeFocused(); + }); + + // TODO: this should be replaced by a unit test + test('should move the focus away from toolbar using tab key', async ({ page }) => { + const roomHeaderFavoriteBtn = poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE); + + await poHomeChannel.navbar.openChat(targetChannel); + await roomHeaderFavoriteBtn.focus(); + await expect(roomHeaderFavoriteBtn).toBeFocused(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Call' })).not.toBeFocused(); + }); + + test('should add user1 to targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.addUser('user1'); + + await expect(poHomeChannel.tabs.members.memberOption('user1')).toBeVisible(); + await expect(poHomeChannel.content.getSystemMessageByText('added user1')).toBeVisible(); + }); + + test('should edit topic of targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.inputTopic.fill('hello-topic-edited'); + await poHomeChannel.tabs.editRoom.btnSave.click(); + + await poHomeChannel.toastMessage.dismissToast(); + await poHomeChannel.roomToolbar.openRoomInfo(); + + await expect(poHomeChannel.tabs.room.root).toContainText('hello-topic-edited'); + await expect(poHomeChannel.content.getSystemMessageByText('changed room topic to hello-topic-edited')).toBeVisible(); + }); + + test('should edit announcement of targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.inputAnnouncement.fill('hello-announcement-edited'); + await poHomeChannel.tabs.editRoom.btnSave.click(); + + await poHomeChannel.toastMessage.dismissToast(); + await poHomeChannel.roomToolbar.openRoomInfo(); + await expect(poHomeChannel.tabs.room.root).toContainText('hello-announcement-edited'); + await expect(poHomeChannel.content.getSystemMessageByText('changed room announcement to: hello-announcement-edited')).toBeVisible(); + }); + + test('should edit description of targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.inputDescription.fill('hello-description-edited'); + await poHomeChannel.tabs.editRoom.btnSave.click(); + + await poHomeChannel.toastMessage.dismissToast(); + await poHomeChannel.roomToolbar.openRoomInfo(); + await expect(poHomeChannel.tabs.room.root).toContainText('hello-description-edited'); + await expect(poHomeChannel.content.getSystemMessageByText('changed room description to: hello-description-edited')).toBeVisible(); + }); + + test('should edit name of targetChannel', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.inputName.fill(`NAME-EDITED-${targetChannel}`); + await poHomeChannel.tabs.editRoom.btnSave.click(); + + targetChannel = `NAME-EDITED-${targetChannel}`; + await expect(page.getByRole('main').getByRole('heading', { name: targetChannel })).toBeVisible(); + await poHomeChannel.navbar.openChat(targetChannel); + + await expect(page).toHaveURL(`/channel/${targetChannel}`); + }); + + test.skip('should truncate the room name for small screens', async ({ page }) => { + const hugeName = faker.string.alpha(200); + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.inputName.fill(hugeName); + await poHomeChannel.tabs.editRoom.btnSave.click(); + targetChannel = hugeName; + + await page.setViewportSize({ width: 640, height: 460 }); + await expect(page.getByRole('heading', { name: hugeName })).toHaveCSS('width', '407px'); + }); + + test('should open sidebar clicking on sidebar toggler', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + + await page.setViewportSize({ width: 640, height: 460 }); + await page.getByRole('button', { name: 'Open sidebar' }).click(); + + await expect(page.getByRole('navigation', { name: 'Sidebar' })).toBeVisible(); + }); + + test('should open room info when clicking on roomName', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.getBtnOpenRoomInfo(targetChannel).focus(); + await expect(poHomeChannel.getBtnOpenRoomInfo(targetChannel)).toBeFocused(); + await page.keyboard.press('Space'); + await page.getByRole('dialog').waitFor(); + + await expect(page.getByRole('dialog')).toBeVisible(); + }); + + test('should create a discussion using the message composer', async ({ page }) => { + discussionName = faker.string.uuid(); + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.composer.btnMenuMoreActions.click(); + await page.getByRole('menuitem', { name: 'Discussion' }).click(); + const createDiscussionModal = new CreateNewDiscussionModal(page); + await createDiscussionModal.inputName.fill(discussionName); + await createDiscussionModal.create(); + + await expect(page.getByRole('heading', { name: discussionName })).toBeVisible(); + }); + + test('should access targetTeam through discussion header', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await page.getByRole('listitem', { name: discussionName }).getByRole('button', { name: 'Reply' }).click(); + + await page.getByRole('button', { name: `Back to ${targetChannel} channel`, exact: true }).focus(); + await expect(page.getByRole('button', { name: `Back to ${targetChannel} channel`, exact: true })).toBeFocused(); + await page.keyboard.press('Space'); + + await expect(page).toHaveURL(`/channel/${targetChannel}`); + }); + + test('should edit notification preferences of targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemNotificationsPreferences.click(); + await poHomeChannel.tabs.notificationPreferences.updateAllNotificationPreferences(); + await poHomeChannel.tabs.notificationPreferences.btnSave.click(); + + await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Desktop')).toContainText('Mentions'); + await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Mobile')).toContainText('Mentions'); + await expect(poHomeChannel.tabs.notificationPreferences.getPreferenceByDevice('Email')).toContainText('Mentions'); + }); + + test.describe.serial('cross user tests', () => { + let user1Page: Page; + test.beforeEach(async ({ browser }) => { + user1Page = await browser.newPage({ storageState: Users.user1.state }); + }); + + test.afterEach(async () => { + await user1Page.close(); + }); + + test('should mute user1', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.muteUser('user1'); + + await expect(poHomeChannel.content.getSystemMessageByText('muted user1')).toBeVisible(); + + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await expect(user1Channel.composer.readOnlyFooter).toBeVisible(); + }); + + test('should unmuteUser user1', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.unmuteUser('user1'); + + await expect(poHomeChannel.content.getSystemMessageByText('unmuted user1')).toBeVisible(); + + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await expect(user1Channel.composer.inputMessage).toBeVisible(); + }); + + test('should set user1 as moderator', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.setUserAsModerator('user1'); + + await expect(poHomeChannel.content.getSystemMessageByText('set user1 as moderator')).toBeVisible(); + + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await user1Channel.roomToolbar.openRoomInfo(); + await expect(user1Channel.tabs.room.btnEdit).toBeVisible(); + }); + + test('should set user1 as owner', async ({ browser }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.setUserAsOwner('user1'); + + await expect(poHomeChannel.content.getSystemMessageByText('set user1 as owner')).toBeVisible(); + + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await user1Channel.roomToolbar.openRoomInfo(); + + await user1Channel.tabs.room.btnMore.click(); + + await expect(user1Channel.tabs.room.optionDelete).toBeVisible(); + + await user1Page.close(); + }); + + test('should ignore user1 messages', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.ignoreUser('user1'); + await poHomeChannel.toastMessage.waitForDisplay({ type: 'success', message: 'User has been ignored' }); + + await poHomeChannel.tabs.members.userInfo.openMoreActions(); + await expect(poHomeChannel.tabs.members.userInfo.menu.getMenuItem('Unignore')).toBeVisible(); + await page.keyboard.press('Escape'); + + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.sendMessage('message to check ignore'); + + await expect(poHomeChannel.content.lastUserMessageBody.getByRole('button', { name: 'This message was ignored' })).toBeVisible(); + + await user1Channel.content.openReplyInThread(); + await user1Channel.content.waitForThread(); + await user1Channel.content.toggleAlsoSendThreadToChannel(false); + await user1Channel.content.sendMessageInThread('thread reply from an ignored user'); + + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.waitForThread(); + + await expect(poHomeChannel.content.threadMessageListItems).toHaveCount(2); + await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(2); + await expect(poHomeChannel.content.threadMessageList).not.toContainText('thread reply from an ignored user'); + }); + + test('should unignore single user1 message', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.sendMessage('only message to be unignored'); + + await poHomeChannel.navbar.openChat(targetChannel); + + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('This message was ignored'); + await poHomeChannel.content.lastIgnoredUserMessage.click(); + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('only message to be unignored'); + + await user1Channel.content.openReplyInThread(); + await user1Channel.content.waitForThread(); + await user1Channel.content.toggleAlsoSendThreadToChannel(false); + await user1Channel.content.sendMessageInThread('thread reply that stays ignored'); + await user1Channel.content.sendMessageInThread('thread reply to be revealed'); + + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.waitForThread(); + + await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(3); + + await poHomeChannel.content.lastIgnoredThreadMessage.click(); + + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('thread reply to be revealed'); + await expect(poHomeChannel.content.ignoredThreadMessages).toHaveCount(2); + }); + + test('should unignore user1 messages', async ({ page }) => { + const user1Channel = new HomeChannel(user1Page); + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.sendMessage('message before being unignored'); + + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('This message was ignored'); + + await poHomeChannel.roomToolbar.openMembersTab(); + await poHomeChannel.tabs.members.showAllUsers(); + await poHomeChannel.tabs.members.unignoreUser('user1'); + + await poHomeChannel.tabs.members.userInfo.openMoreActions(); + await expect(poHomeChannel.tabs.members.userInfo.menu.getMenuItem('Ignore')).toBeVisible(); + await page.keyboard.press('Escape'); + + await user1Channel.content.sendMessage('message after being unignored'); + + await expect(poHomeChannel.content.nthMessage(-2)).toContainText('message before being unignored'); + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('message after being unignored'); + }); + + test('should readOnlyChannel show join button', async () => { + const channelName = faker.string.uuid(); + await poHomeChannel.navbar.createNew('Channel', channelName, { private: false, readOnly: true }); + + const channel = new HomeChannel(user1Page); + + await channel.gotoChannel(channelName); + await expect(user1Page.getByRole('button', { name: 'Join', exact: true })).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/config/constants.ts b/apps/meteor/tests/e2e/config/constants.ts new file mode 100644 index 0000000..8cd9088 --- /dev/null +++ b/apps/meteor/tests/e2e/config/constants.ts @@ -0,0 +1,24 @@ +export const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000'; + +export const API_PREFIX = '/api/v1'; + +export const BASE_API_URL = BASE_URL + API_PREFIX; + +export const IS_LOCALHOST = BASE_URL.startsWith('http://localhost'); + +export const IS_EE = process.env.IS_EE ? !!JSON.parse(process.env.IS_EE) : false; + +export const URL_MONGODB = process.env.MONGO_URL || 'mongodb://localhost:3001/meteor?retryWrites=false'; + +export const MOCK_SERVER_URL = process.env.MOCK_SERVER_URL || 'http://localhost:4000'; + +export const ADMIN_CREDENTIALS = { + email: 'rocketchat.internal.admin.test@rocket.chat', + password: 'rocketchat.internal.admin.test', + username: 'rocketchat.internal.admin.test', +} as const; + +export const DEFAULT_USER_CREDENTIALS = { + password: 'password', + bcrypt: '$2b$10$LNYaqDreDE7tt9EVEeaS9uw.C3hic9hcqFfIocMBPTMxJaDCC6QWW', +} as const; diff --git a/apps/meteor/tests/e2e/config/global-setup.ts b/apps/meteor/tests/e2e/config/global-setup.ts new file mode 100644 index 0000000..cbcfd26 --- /dev/null +++ b/apps/meteor/tests/e2e/config/global-setup.ts @@ -0,0 +1,11 @@ +import addCustomOAuth from '../fixtures/addCustomOAuth'; +import injectInitialData from '../fixtures/inject-initial-data'; +import { insertDefaultTestApp } from '../utils/apps'; + +export default async function (): Promise { + await injectInitialData(); + + await insertDefaultTestApp(); + + await addCustomOAuth(); +} diff --git a/apps/meteor/tests/e2e/config/global.d.ts b/apps/meteor/tests/e2e/config/global.d.ts new file mode 100644 index 0000000..64ab771 --- /dev/null +++ b/apps/meteor/tests/e2e/config/global.d.ts @@ -0,0 +1,7 @@ +declare namespace PlaywrightTest { + interface Matchers { + hasAttribute(a: string): Promise; + toBeInvalid(): Promise; + toBeBusy(): Promise; + } +} diff --git a/apps/meteor/tests/e2e/containers/saml/Dockerfile b/apps/meteor/tests/e2e/containers/saml/Dockerfile new file mode 100644 index 0000000..01a393a --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/Dockerfile @@ -0,0 +1,36 @@ +FROM php:8.2-apache-bookworm + +# Utilities +RUN apt-get update && \ + apt-get -y install apt-transport-https git curl vim --no-install-recommends && \ + rm -r /var/lib/apt/lists/* + +# SimpleSAMLphp +ARG SIMPLESAMLPHP_VERSION=1.19.6 +RUN curl -s -L -o /tmp/simplesamlphp.tar.gz https://github.com/simplesamlphp/simplesamlphp/releases/download/v$SIMPLESAMLPHP_VERSION/simplesamlphp-$SIMPLESAMLPHP_VERSION.tar.gz && \ + tar xzf /tmp/simplesamlphp.tar.gz -C /tmp && \ + rm -f /tmp/simplesamlphp.tar.gz && \ + mv /tmp/simplesamlphp-* /var/www/simplesamlphp && \ + touch /var/www/simplesamlphp/modules/exampleauth/enable +COPY config/simplesamlphp/config.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/authsources.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/saml20-idp-hosted.php /var/www/simplesamlphp/metadata +COPY config/simplesamlphp/saml20-sp-remote.php /var/www/simplesamlphp/metadata +COPY config/simplesamlphp/server_crt /var/www/simplesamlphp/cert/server.crt +COPY config/simplesamlphp/server_pem /var/www/simplesamlphp/cert/server.pem + +# Apache +COPY config/apache/ports.conf /etc/apache2 +COPY config/apache/simplesamlphp.conf /etc/apache2/sites-available +COPY config/apache/cert_crt /etc/ssl/cert/cert.crt +COPY config/apache/private_key /etc/ssl/private/private.key +RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf && \ + a2enmod ssl && \ + a2dissite 000-default.conf default-ssl.conf && \ + a2ensite simplesamlphp.conf + +# Set work dir +WORKDIR /var/www/simplesamlphp + +# General setup +EXPOSE 8080 8443 diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt new file mode 100644 index 0000000..4104eaf --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJANdMEUvsTntJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzMjIxWhcNNDgwNjI1MTQzMjIxWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAyXRKD3KzV/hOqwThFRtA+eoitJpIIEmWbugPMC1G+7bFqxHmtxiuQhOw +yHrij35biiD8VpboY69Zep7n1QCfmIodh9uxdaNxFtzxjryRLzfP3MpPFkpBHCdV +HZWDP2TzIvOxWcnlLmikSnBrBM1nvhKSWjaFsjDAXMLXT0mceiDpQ0QQkDA6RAyx +JWWRJILjudBh56ukqvdz4eFWAAViZX5MUwCDxiBxtP3NIXVmODM7kDLqZ9+QcfpM +N5QvfcUjHP9584yrYiJ9N64Fy5vU2OH1RX5EsMHTtdqR4H5K6zNfVlgRSG170Mcj +ksTSbo1kcDCSuzTO82NrVkU+R78W/QIDAQABo1AwTjAdBgNVHQ4EFgQUkYMQMPqY +leTTtBHS1f7yNFmY86QwHwYDVR0jBBgwFoAUkYMQMPqYleTTtBHS1f7yNFmY86Qw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGZpbEWzYLoa5keg9rQDa +S2cf9rQFMNflwR7hQ6OtSXeP0JsQ6yFhRq3TgpBdbkmdealDJbyG8pWQxqwOBD/j +45jr+NsHap0HvQAg9pfq/QIqjH5osCGAHmNdHfP638FOpi0s6hAX11+nCW6ClHMO +ofn0fYXCBtM18qZvtoeB8swi6MlRXFLvvRL4tWzGugdQj+0f+ukk/GZAEitdpkuj +qFCHqfKwg9FJ4My5M8lIyB/P4+SK/ail/BoCJ/qGBXky2bob0MQuLysd35zrTA62 +j5IvPp1XZj/2KnuPtqMuFhNtE5wCnOEC1WG02ZVIfs4DAxX78z59VSEaFlnstT9k +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf new file mode 100644 index 0000000..286af7f --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf @@ -0,0 +1,9 @@ +Listen 8080 + + + Listen 8443 + + + + Listen 8443 + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/private_key b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key new file mode 100644 index 0000000..f511efc --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJdEoPcrNX+E6r +BOEVG0D56iK0mkggSZZu6A8wLUb7tsWrEea3GK5CE7DIeuKPfluKIPxWluhjr1l6 +nufVAJ+Yih2H27F1o3EW3PGOvJEvN8/cyk8WSkEcJ1UdlYM/ZPMi87FZyeUuaKRK +cGsEzWe+EpJaNoWyMMBcwtdPSZx6IOlDRBCQMDpEDLElZZEkguO50GHnq6Sq93Ph +4VYABWJlfkxTAIPGIHG0/c0hdWY4MzuQMupn35Bx+kw3lC99xSMc/3nzjKtiIn03 +rgXLm9TY4fVFfkSwwdO12pHgfkrrM19WWBFIbXvQxyOSxNJujWRwMJK7NM7zY2tW +RT5Hvxb9AgMBAAECggEAVT4KtHyxXJDqIL1gzICKvvUOmGMMD/VzXRx+iMEv3wTY +oWliuakM21LfpAUzZspty4XnoHAch0nET/l7WYr4/R+8HSed8IwnJyh4YhByUouI +PgGw81qaMGKIRotkTOfXZbu+GKMwgbGvivwEnLSZqDjNirS1X8/3JYkgeCFKv/X6 +K4Z1SKoebtA4oGDGZtxNpeGJ6TibaSO3PRW+oH3dD5j5ez1k+dlRasMnuKG76jBy +naz052t52UZxAnGkbZUU84VljO5R8x1jip+M6+qILC/PsI1hb45Dx5VRXNUBGP2s +/PRVFptNGmsIlXBYMSdOo9RJAIsZ1kTty34nIqeuYQKBgQD4Nv+QoeRXjG7iAmzj +JabcA+2FiHftvjdaYjahXQ/Ma6ns/vv//tVfxJaNyCxMObjIKhidsrf/AAn03FgT +LkL0Q2tTBNL9jejzHstnCKSSu/pSgqatsABm1cokC9qsuMjweMVy+tGjSsF6YgjI +2K0heQpF0bMBSYmyQU4aTLbytQKBgQDPxdWVn7ReCscDwpchx+zffKh5JOshW44z +GYBfVbugBljj9w6fDjKyQzk0jMGmuG9rvqfZzu9MpQhjBFKxL4aXPBN7bQOY3DKc +FuGSbIbV7ldPmZhOxqLy6NLyiry6eSQGNf1Q8YCF6bsUR/rvdKDtBLrbJObgRVbu +ChT2PXRYKQKBgFSEDZMGvMReqebE4qSZTm592+Nq60MFUL2y0V0yXc3CHxL2Y4Hw +GGFKg+T08rhlsxhc1RLlJqdqMPmyCT9Gsj+PsTyMWPdC2b3mj2We2MKpxPtRR0W+ +tvRM+U46xxOmu6y9wqV65+TM8IImXU1eEd1i5G+Pjn7ytjL+74Qe+PA9AoGAEasM +F5YmG10lQU+Z1HiQzwxlsy+Ngx+q/vNrNDAxLVF8253Vs3bcnsYSpkJV8Vx7tRjY +YzAyrzzVcr4aXhDhjBjCu1sw1B3de+KCOhZafPSwngc8qW5AyxE7Zv6fP+gvRQvw +R6LRwBF5JCde0mADk0Q0s4/2xhl/Y+ydjbb6HskCgYEAysuIUrDslGGsK57loxMO +FVz9SmLTZIJqkSW+l3dDHMG+BvnJFP+yf0Kr2zGbXRzOvNVWAFP2aU39RFDxbUIM +Nz7obLWrVKbQUDaU7fCbP4OBVuo9p4UM6j/PZy+3Cyps+GMTrC9wi4HbbNEWHyCW +xaNL9LNQ3B9hJG77htK1oRw= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf new file mode 100644 index 0000000..a81c39d --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf @@ -0,0 +1,23 @@ + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + SSLEngine on + SSLCertificateFile /etc/ssl/cert/cert.crt + SSLCertificateKeyFile /etc/ssl/private/private.key + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php new file mode 100644 index 0000000..850bd8d --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php @@ -0,0 +1,74 @@ + array( + 'core:AdminPassword', + ), + + 'example-userpass' => array( + 'exampleauth:UserPass', + 'samluser1:password' => array( + 'uid' => array('1'), + 'username' => 'samluser1', + 'cn' => 'Saml User 1', + 'eduPersonAffiliation' => array('group1'), + 'email' => 'samluser1@example.com', + ), + 'samluser2:password' => array( + 'uid' => array('2'), + 'username' => 'samluser2', + 'cn' => 'Saml User 2', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'user_for_saml_merge@email.com', + ), + 'samluser3:password' => array( + 'uid' => array('3'), + 'username' => 'user_for_saml_merge2', + 'cn' => 'Saml User 3', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'samluser3@example.com', + ), + 'samluser4:password' => array( + 'uid' => array('4'), + 'username' => 'samluser4', + 'cn' => 'Saml User 4', + 'eduPersonAffiliation' => array('group4'), + 'email' => 'samluser4@example.com', + 'role' => 'saml-role', + ), + 'samluser5:password' => array( + 'uid' => array('5'), + 'username' => 'samluser5', + 'eduPersonAffiliation' => array('group5'), + 'email' => 'samluser5@example.com', + ), + 'samluser6:password' => array( + 'uid' => array('6'), + 'username' => 'samluser6', + 'displayName' => 'Saml User 6 Display Name', + 'eduPersonAffiliation' => array('group6'), + 'email' => 'samluser6@example.com', + ), + 'samluser7:password' => array( + 'uid' => array('7'), + 'username' => 'samluser7', + 'cn' => 'Saml User 7', + 'eduPersonAffiliation' => array('group7'), + 'email' => 'samluser7@example.com', + 'channels' => array('saml-channel-1', 'saml-channel-2'), + ), + 'samlusernoname:password' => array( + 'uid' => array('5'), + 'cn' => 'Saml User No Username', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'samlusernoname@example.com', + ), + 'samlusernoname2:password' => array( + 'uid' => array('6'), + 'cn' => 'Saml User No Username 2', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'samlusernoname2@example.com', + ), + ), +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php new file mode 100644 index 0000000..71b6721 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php @@ -0,0 +1,112 @@ + 'simplesaml/', + 'certdir' => 'cert/', + 'loggingdir' => 'log/', + 'datadir' => 'data/', + 'tempdir' => '/tmp/simplesaml', + 'debug' => true, + 'showerrors' => true, + 'errorreporting' => true, + 'debug.validatexml' => false, + 'auth.adminpassword' => 'secret', + 'admin.protectindexpage' => false, + 'admin.protectmetadata' => false, + 'secretsalt' => 'defaultsecretsalt', + 'technicalcontact_name' => 'Administrator', + 'technicalcontact_email' => 'na@example.org', + 'timezone' => null, + 'logging.level' => SimpleSAML_Logger::DEBUG, + 'logging.handler' => 'errorlog', + //'logging.format' => '%date{%b %d %H:%M:%S} %process %level %stat[%trackid] %msg', + 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, + 'logging.processname' => 'simplesamlphp', + 'logging.logfile' => 'simplesamlphp.log', + 'statistics.out' => array( + ), + 'database.dsn' => 'mysql:host=localhost;dbname=saml', + 'database.username' => 'simplesamlphp', + 'database.password' => 'secret', + 'database.prefix' => '', + 'database.persistent' => false, + 'database.slaves' => array( + ), + 'enable.saml20-idp' => true, + 'enable.shib13-idp' => true, + 'enable.adfs-idp' => false, + 'enable.wsfed-sp' => false, + 'enable.authmemcookie' => false, + 'session.duration' => 8 * (60 * 60), // 8 hours. + 'session.datastore.timeout' => (4 * 60 * 60), // 4 hours + 'session.state.timeout' => (60 * 60), // 1 hour + 'session.cookie.name' => 'SimpleSAMLSessionIDIdp', + 'session.cookie.lifetime' => 0, + 'session.cookie.path' => '/', + 'session.cookie.domain' => null, + 'session.cookie.secure' => false, + 'session.cookie.samesite' => 'Lax', + 'enable.http_post' => false, + 'session.phpsession.cookiename' => 'PHPSESSIDIDP', + 'session.phpsession.savepath' => null, + 'session.phpsession.httponly' => true, + 'session.authtoken.cookiename' => 'SimpleSAMLAuthTokenIdp', + 'session.rememberme.enable' => false, + 'session.rememberme.checked' => false, + 'session.rememberme.lifetime' => (14 * 86400), + 'language.available' => array( + 'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'fr', 'it', 'nl', 'lb', 'cs', + 'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', 'ru', 'et', + 'he', 'id', 'sr', 'lv', 'ro', 'eu' + ), + 'language.rtl' => array('ar', 'dv', 'fa', 'ur', 'he'), + 'language.default' => 'en', + 'language.parameter.name' => 'language', + 'language.parameter.setcookie' => true, + 'language.cookie.name' => 'language', + 'language.cookie.domain' => null, + 'language.cookie.path' => '/', + 'language.cookie.lifetime' => (60 * 60 * 24 * 900), + 'attributes.extradictionary' => null, + 'theme.use' => 'default', + 'default-wsfed-idp' => 'urn:federation:pingfederate:localhost', + 'idpdisco.enableremember' => true, + 'idpdisco.rememberchecked' => true, + 'idpdisco.validate' => true, + 'idpdisco.extDiscoveryStorage' => null, + 'idpdisco.layout' => 'dropdown', + 'shib13.signresponse' => true, + 'authproc.idp' => array( + 30 => 'core:LanguageAdaptor', + 45 => array( + 'class' => 'core:StatisticsWithAttribute', + 'attributename' => 'realm', + 'type' => 'saml20-idp-SSO', + ), + 50 => 'core:AttributeLimit', + 99 => 'core:LanguageAdaptor', + ), + 'authproc.sp' => array( + 90 => 'core:LanguageAdaptor', + ), + 'metadata.sources' => array( + array('type' => 'flatfile'), + ), + 'store.type' => 'phpsession', + 'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3', + 'store.sql.username' => null, + 'store.sql.password' => null, + 'store.sql.prefix' => 'SimpleSAMLphp', + 'memcache_store.servers' => array( + array( + array('hostname' => 'localhost'), + ), + ), + 'memcache_store.prefix' => null, + 'memcache_store.expires' => 36 * (60 * 60), // 36 hours. + 'metadata.sign.enable' => false, + 'metadata.sign.privatekey' => null, + 'metadata.sign.privatekey_pass' => null, + 'metadata.sign.certificate' => null, + 'proxy' => null, + 'trusted.url.domains' => array('localhost:3000'), +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-idp-hosted.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-idp-hosted.php new file mode 100644 index 0000000..c72fa97 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-idp-hosted.php @@ -0,0 +1,23 @@ + 'http://localhost:3000/_saml/metadata/test-sp', + 'host' => '__DEFAULT__', + 'privatekey' => 'server.pem', + 'certificate' => 'server.crt', + 'auth' => 'example-userpass', + 'redirect.sign' => true, + + /* + * Force the NameID to be the raw email attribute value in emailAddress + * format, bypassing SimpleSAMLphp's default persistent (hashed) nameID + * generation. + */ + 'authproc' => [ + 100 => [ + 'class' => 'saml:AttributeNameID', + 'attribute' => 'email', + 'Format' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + ], + ], +]; diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php new file mode 100644 index 0000000..4e306b2 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php @@ -0,0 +1,31 @@ + 'http://localhost:3000/_saml/metadata/test-sp', + 'contacts' => array ( + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'http://localhost:3000/_saml/validate/test-sp', + 'index' => 1, + 'isDefault' => true, + ), + ), + 'SingleLogoutService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'http://localhost:3000/_saml/logout/test-sp/', + 'ResponseLocation' => 'http://localhost:3000/_saml/logout/test-sp/', + ), + ), + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + // Other settings that may be useful for testing, but are not compatible with the existing e2e tests + 'privatekey' => 'server.pem', + 'certificate' => 'server.crt', + 'redirect.sign' => true, + // 'redirect.validate' => true, + // 'validate.logout' => true, + // 'assertion.encryption' => true, +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt new file mode 100644 index 0000000..f0623b9 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem new file mode 100644 index 0000000..ba2bac5 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNQIWjOA1vWHUz +SPM1FIKOE4GdH65VtWlpZ9dghH4CFYN0R7mvJj4KBq86Dxt8vJvLMV16GVh0NGCR +50QH8aMbxonDTqXSoXiMM4DDSQTKBYK7aZwftc7FG35gAfdNUdr8e7VbdaPOShuq +qotDyCQpZYzbt86ABnoaJ5okE3pUFIwxw97LcdYsGZz5Ngma/V1to7aMeEqHyl8r +DRbXZUzw/U8g7yC/g+G7+64liJ4FYqLEETLLSUePKLFgUJHXbF2HgIDjur3nxlEa +ecNQYVUTVCGBFpwkI5n1t3m32avwotpUFhMImjkRETyPKZpvl0+p7mop8mwJmKpa +CVuNSj23AgMBAAECggEABn4I/B20xxXcNzASiVZJvua9DdRHtmxTlkLznBj0x2oY +y1/Nbs3d3oFRn5uEuhBZOTcphsgwdRSHDXZsP3gUObew+d2N/zieUIj8hLDVlvJP +rU/s4U/l53Q0LiNByE9ThvL+zJLPCKJtd5uHZjB5fFm69+Q7gu8xg4xHIub+0pP5 +PHanmHCDrbgNN/oqlar4FZ2MXTgekW6Amyc/koE9hIn4Baa2Ke/B/AUGY4pMRLqp +TArt+GTVeWeoFY9QACUpaHpJhGb/Piou6tlU57e42cLoki1f0+SARsBBKyXA7BB1 +1fMH10KQYFA68dTYWlKzQau/K4xaqg4FKmtwF66GQQKBgQD9OpNUS7oRxMHVJaBR +TNWW+V1FXycqojekFpDijPb2X5CWV16oeWgaXp0nOHFdy9EWs3GtGpfZasaRVHsX +SHtPh4Nb8JqHdGE0/CD6t0+4Dns8Bn9cSqtdQB7R3Jn7IMXi9X/U8LDKo+A18/Jq +V8VgUngMny9YjMkQIbK8TRWkYQKBgQDPf4nxO6ju+tOHHORQty3bYDD0+OV3I0+L +0yz0uPreryBVi9nY43KakH52D7UZEwwsBjjGXD+WH8xEsmBWsGNXJu025PvzIJoz +lAEiXvMp/NmYp+tY4rDmO8RhyVocBqWHzh38m0IFOd4ByFD5nLEDrA3pDVo0aNgY +n0GwRysZFwKBgQDkCj3m6ZMUsUWEty+aR0EJhmKyODBDOnY09IVhH2S/FexVFzUN +LtfK9206hp/Awez3Ln2uT4Zzqq5K7fMzUniJdBWdVB004l8voeXpIe9OZuwfcBJ9 +gFi1zypx/uFDv421BzQpBN+QfOdKbvbdQVFjnqCxbSDr80yVlGMrI5fbwQKBgG09 +oRrepO7EIO8GN/GCruLK/ptKGkyhy3Q6xnVEmdb47hX7ncJA5IoZPmrblCVSUNsw +n11XHabksL8OBgg9rt8oQEThQv/aDzTOW9aDlJNragejiBTwq99aYeZ1gjo1CZq4 +2jKubpCfyZC4rGDtrIfZYi1q+S2UcQhtd8DdhwQbAoGAAM4EpDA4yHB5yiek1p/o +CbqRCta/Dx6Eyo0KlNAyPuFPAshupG4NBx7mT2ASfL+2VBHoi6mHSri+BDX5ryYF +fMYvp7URYoq7w7qivRlvvEg5yoYrK13F2+Gj6xJ4jEN9m0KdM/g3mJGq0HBTIQrp +Sm75WXsflOxuTn08LbgGc4s= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/docker-compose.yml b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml new file mode 100644 index 0000000..6d8a00f --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' +services: + testsamlidp_idp: + build: . + ports: + - "8080:8080" + - "8443:8443" diff --git a/apps/meteor/tests/e2e/create-channel.spec.ts b/apps/meteor/tests/e2e/create-channel.spec.ts new file mode 100644 index 0000000..889e475 --- /dev/null +++ b/apps/meteor/tests/e2e/create-channel.spec.ts @@ -0,0 +1,33 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('create-channel', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test('expect create a public channel', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', channelName, { private: false }); + + await expect(page).toHaveURL(`/channel/${channelName}`); + }); + + test('expect create a private channel', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', channelName, { private: true }); + + await expect(page).toHaveURL(`/group/${channelName}`); + }); +}); diff --git a/apps/meteor/tests/e2e/create-direct.spec.ts b/apps/meteor/tests/e2e/create-direct.spec.ts new file mode 100644 index 0000000..a762923 --- /dev/null +++ b/apps/meteor/tests/e2e/create-direct.spec.ts @@ -0,0 +1,20 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('channel-direct-message', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test('expect create a direct room', async ({ page }) => { + await poHomeChannel.navbar.createNewDM('rocket.cat'); + await expect(page).toHaveURL(/direct\/.*/); + }); +}); diff --git a/apps/meteor/tests/e2e/create-discussion.spec.ts b/apps/meteor/tests/e2e/create-discussion.spec.ts new file mode 100644 index 0000000..a4de90f --- /dev/null +++ b/apps/meteor/tests/e2e/create-discussion.spec.ts @@ -0,0 +1,26 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeDiscussion } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('create-discussion', () => { + let poHomeDiscussion: HomeDiscussion; + + test.beforeEach(async ({ page }) => { + poHomeDiscussion = new HomeDiscussion(page); + + await poHomeDiscussion.goto(); + }); + + test('expect create discussion', async ({ page }) => { + const discussionName = faker.string.uuid(); + const discussionMessage = faker.animal.type(); + + await poHomeDiscussion.navbar.createNewDiscussion('general', discussionName, discussionMessage); + + await expect(page).toHaveURL(/\/channel\/[a-z0-9]{0,17}$/i); + }); +}); diff --git a/apps/meteor/tests/e2e/delete-account.spec.ts b/apps/meteor/tests/e2e/delete-account.spec.ts new file mode 100644 index 0000000..090d306 --- /dev/null +++ b/apps/meteor/tests/e2e/delete-account.spec.ts @@ -0,0 +1,104 @@ +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { AccountProfile, Authenticated, Login } from './page-objects'; +import { setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; +import { createTestUser, type ITestUser } from './utils/user-helpers'; + +test.describe('Delete Own Account', () => { + let poAccountProfile: AccountProfile; + let poLogin: Login; + let poAuth: Authenticated; + let userToDelete: ITestUser; + let userWithInvalidPassword: ITestUser; + let userWithoutPermissions: ITestUser; + + test.beforeAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Accounts_AllowDeleteOwnAccount', true)).status()).toBe(200); + userToDelete = await createTestUser(api, { username: 'user-to-delete' }); + userWithInvalidPassword = await createTestUser(api, { username: 'user-with-invalid-password' }); + userWithoutPermissions = await createTestUser(api, { username: 'user-without-permissions' }); + }); + + test.beforeEach(async ({ page }) => { + poAccountProfile = new AccountProfile(page); + poLogin = new Login(page); + poAuth = new Authenticated(page); + await poLogin.goto(); + }); + + test.afterAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Accounts_AllowDeleteOwnAccount', false)).status()).toBe(200); + await userWithInvalidPassword.delete(); + await userWithoutPermissions.delete(); + }); + + test('should not delete account when invalid password is provided', async () => { + await test.step('login with the user to delete', async () => { + await poLogin.login(userWithInvalidPassword.data.username, DEFAULT_USER_CREDENTIALS.password); + await poAuth.waitForDisplay(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await poAccountProfile.goto(); + await poAccountProfile.btnDeleteMyAccount.click(); + await poAccountProfile.deleteAccountModal.waitForDisplay(); + }); + + await test.step('enter invalid password in the confirmation field and click delete account', async () => { + await poAccountProfile.deleteAccountModal.inputPassword.fill('invalid-password'); + await expect(poAccountProfile.deleteAccountModal.inputPassword).toHaveValue('invalid-password'); + await poAccountProfile.deleteAccountModal.confirmDelete({ waitForDismissal: false }); + }); + + await test.step('verify error message appears', async () => { + await expect(poAccountProfile.deleteAccountModal.inputErrorMessage).toBeVisible(); + }); + + await test.step('verify user is still on the profile page', async () => { + await expect(poAccountProfile.profileHeading).toBeVisible(); + }); + }); + + test('should delete account when valid password is provided and permission is enabled', async () => { + await test.step('login with the user to delete', async () => { + await poLogin.login(userToDelete.data.username, DEFAULT_USER_CREDENTIALS.password); + await poAuth.waitForDisplay(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await poAccountProfile.goto(); + await poAccountProfile.btnDeleteMyAccount.click(); + await poAccountProfile.deleteAccountModal.waitForDisplay(); + }); + + await test.step('enter password in the confirmation field and click delete account', async () => { + await poAccountProfile.deleteAccountModal.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password); + await expect(poAccountProfile.deleteAccountModal.inputPassword).toHaveValue(DEFAULT_USER_CREDENTIALS.password); + await poAccountProfile.deleteAccountModal.confirmDelete({ waitForDismissal: false }); + }); + + await test.step('verify user is redirected to login page', async () => { + await poLogin.waitForDisplay(); + userToDelete.markAsDeleted(); + }); + }); + + test.describe('Delete Own Account - Permission Disabled', () => { + test.beforeAll(async ({ api }) => { + const response = await api.post('/settings/Accounts_AllowDeleteOwnAccount', { value: false }); + expect(response.status()).toBe(200); + }); + + test('should not show delete account button when permission is disabled', async () => { + await test.step('login with the user to delete', async () => { + await poLogin.login(userWithoutPermissions.data.username, DEFAULT_USER_CREDENTIALS.password); + await poAuth.waitForDisplay(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await poAccountProfile.goto(); + await expect(poAccountProfile.btnDeleteMyAccount).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts new file mode 100644 index 0000000..6d5d24b --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts @@ -0,0 +1,407 @@ +import { faker } from '@faker-js/faker'; + +import { DEFAULT_USER_CREDENTIALS } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { ToastMessages } from '../page-objects/fragments'; +import { CreateE2EEChannel } from '../page-objects/fragments/e2ee'; +import { deletePrivateRoomsByName } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +preserveSettings(settingsList); + +test.describe('E2EE Encrypted Channels', () => { + const createdChannels: string[] = []; + let poHomeChannel: HomeChannel; + let toastMessages: ToastMessages; + let createE2EEChannel: CreateE2EEChannel; + + test.use({ storageState: Users.userE2EE.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { username: 'user2' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + createE2EEChannel = new CreateE2EEChannel(page); + await poHomeChannel.goto(); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName( + { username: Users.userE2EE.data.username, password: DEFAULT_USER_CREDENTIALS.password }, + createdChannels, + ); + }); + + test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { + toastMessages = new ToastMessages(page); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await toastMessages.dismissToast(); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemDisableE2EEncryption.click(); + await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Disable encryption' }).click(); + await poHomeChannel.toastMessage.dismissToast(); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world not encrypted'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await expect(poHomeChannel.roomToolbar.menuItemEnableE2EEncryption).toBeVisible(); + await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click(); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await poHomeChannel.toastMessage.dismissToast(); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world encrypted again'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is the thread main message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await poHomeChannel.content.openReplyInThread(); + + await expect(page).toHaveURL(/.*thread/); + + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); + await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.lastUserThreadMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessagePreview).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.content.lastUserMessage.hover(); + await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); + + await poHomeChannel.content.openLastMessageMenu(); + + await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/); + await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + }); + + test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', channelName); + createE2EEChannel.store(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.toastMessage.waitForDisplay(); + await poHomeChannel.toastMessage.dismissToast(); + + await poHomeChannel.roomToolbar.openMoreOptions(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.isVisible())) { + await poHomeChannel.roomToolbar.openMoreOptions(); + } + await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click(); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect create a encrypted private channel and mention user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello @user1'); + + const userMention = page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + }); + + test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + + const channelMention = page.getByRole('button', { + name: 'general', + }); + + await expect(channelMention).toBeVisible(); + + await channelMention.click(); + + await expect(page).toHaveURL(`/channel/general`); + }); + + test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); + + const channelMention = page.getByRole('button', { + name: 'general', + }); + + const userMention = page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + await expect(channelMention).toBeVisible(); + }); + + test('expect create a private channel, send unecrypted messages, encrypt the channel and delete the last message and check the last message in the sidebar', async ({ + page, + }) => { + const channelName = faker.string.uuid(); + + // Enable Sidebar Extended display mode + await poHomeChannel.navbar.setDisplayMode('Extended'); + + // Create private channel + await poHomeChannel.navbar.createNew('Channel', channelName, { private: true }); + createE2EEChannel.store(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + await poHomeChannel.toastMessage.waitForDisplay(); + await poHomeChannel.toastMessage.dismissToast(); + + // Send Unencrypted Messages + await poHomeChannel.content.sendMessage('first unencrypted message'); + await poHomeChannel.content.sendMessage('second unencrypted message'); + + // Encrypt channel + await poHomeChannel.roomToolbar.openMoreOptions(); + await expect(poHomeChannel.roomToolbar.menuItemEnableE2EEncryption).toBeVisible(); + await poHomeChannel.roomToolbar.menuItemEnableE2EEncryption.click(); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + // Send Encrypted Messages + const encriptedMessage1 = 'first ENCRYPTED message'; + const encriptedMessage2 = 'second ENCRYPTED message'; + await poHomeChannel.content.sendMessage(encriptedMessage1); + await poHomeChannel.content.sendMessage(encriptedMessage2); + + // Delete last message + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(encriptedMessage2); + await poHomeChannel.content.openLastMessageMenu(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await page.locator('role=menuitem[name="Delete"]').isVisible())) { + await poHomeChannel.content.openLastMessageMenu(); + } + await page.locator('role=menuitem[name="Delete"]').click(); + await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); + + // Check last message in the sidebar + const sidebarChannel = poHomeChannel.sidebar.getSidebarItemByName(channelName); + await expect(sidebarChannel).toBeVisible(); + await expect(sidebarChannel.getByText(`You: ${encriptedMessage2}`, { exact: true })).not.toBeVisible(); + await expect(sidebarChannel.getByText(`You: ${encriptedMessage1}`, { exact: true })).toBeVisible(); + }); + + test('expect create a private encrypted channel and pin/star an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This message should be pinned and stared.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This message should be pinned and stared.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Star"]').click(); + + await poHomeChannel.toastMessage.waitForDisplay(); + await poHomeChannel.toastMessage.dismissToast(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Pin"]').click(); + await page.locator('#modal-root >> button:has-text("Yes, pin message")').click(); + + await poHomeChannel.toastMessage.waitForDisplay(); + await poHomeChannel.toastMessage.dismissToast(); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemPinnedMessages.click(); + + await expect(page.getByRole('dialog', { name: 'Pinned Messages' })).toBeVisible(); + + const lastPinnedMessage = page + .getByRole('dialog', { name: 'Pinned Messages' }) + .locator('[role="listitem"][aria-roledescription="message"]') + .last(); + await expect(lastPinnedMessage).toContainText('This message should be pinned and stared.'); + await lastPinnedMessage.hover(); + await lastPinnedMessage.locator('role=button[name="More"]').waitFor(); + await lastPinnedMessage.locator('role=button[name="More"]').click(); + await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + + await poHomeChannel.btnContextualbarClose.click(); + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemStarredMessages.click(); + + const lastStarredMessage = page + .getByRole('dialog', { name: 'Starred Messages' }) + .locator('[role="listitem"][aria-roledescription="message"]') + .last(); + await expect(page.getByRole('dialog', { name: 'Starred Messages' })).toBeVisible(); + await expect(lastStarredMessage).toContainText('This message should be pinned and stared.'); + await lastStarredMessage.hover(); + await lastStarredMessage.locator('role=button[name="More"]').waitFor(); + await lastStarredMessage.locator('role=button[name="More"]').click(); + await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + }); + + test('expect to edit encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + const originalMessage = 'This is the original encrypted message'; + const editedMessage = 'This is the edited encrypted message'; + + await createE2EEChannel.createAndStore(channelName, createdChannels); + await expect(page).toHaveURL(`/group/${channelName}`); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage(originalMessage); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(originalMessage); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await poHomeChannel.composer.inputMessage.fill(editedMessage); + + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(editedMessage); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect to edit encrypted message to include mention', async ({ page }) => { + const channelName = faker.string.uuid(); + const originalMessage = 'This is the original encrypted message'; + const editedMessage = 'This is the edited encrypted message with a mention to @user1 and #general'; + const displayedMessage = 'This is the edited encrypted message with a mention to user1 and general'; + + await createE2EEChannel.createAndStore(channelName, createdChannels); + await expect(page).toHaveURL(`/group/${channelName}`); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage(originalMessage); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(originalMessage); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await poHomeChannel.composer.inputMessage.fill(editedMessage); + + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(displayedMessage); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + const userMention = page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + + const channelMention = page.getByRole('button', { + name: 'general', + }); + + await expect(channelMention).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts new file mode 100644 index 0000000..3d805b6 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts @@ -0,0 +1,217 @@ +import { faker } from '@faker-js/faker'; + +import { resetOwnE2EKey } from './resetOwnE2EKey'; +import { setupE2EEPassword } from './setupE2EEPassword'; +import { ADMIN_CREDENTIALS, BASE_URL } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { EncryptedRoomPage } from '../page-objects/encrypted-room'; +import { Navbar } from '../page-objects/fragments'; +import { CreateE2EEChannel } from '../page-objects/fragments/e2ee'; +import { FileUploadModal } from '../page-objects/fragments/modals'; +import { LoginPage } from '../page-objects/login'; +import { createTargetGroupAndReturnFullRoom, deletePrivateRoomsByName, deleteRoom } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { sendMessageFromUser } from '../utils/sendMessage'; +import { test, expect } from '../utils/test'; + +const settingsList = ['E2E_Enable', 'E2E_Allow_Unencrypted_Messages']; + +preserveSettings(settingsList); + +test.describe('E2EE Encryption and Decryption - Basic Features', () => { + const createdChannels: string[] = []; + let loginPage: LoginPage; + let navbar: Navbar; + let encryptedRoomPage: EncryptedRoomPage; + let createE2EEChannel: CreateE2EEChannel; + + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + navbar = new Navbar(page); + encryptedRoomPage = new EncryptedRoomPage(page); + createE2EEChannel = new CreateE2EEChannel(page); + + await expect(await resetOwnE2EKey(ADMIN_CREDENTIALS)).toBeOK(); + + await loginPage.goto(); + await loginPage.loginByUserState(Users.admin); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName(ADMIN_CREDENTIALS, createdChannels); + }); + + test('expect placeholder text in place of encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + const messageText = 'This is an encrypted message.'; + + await setupE2EEPassword(page); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + await encryptedRoomPage.waitForChannel(); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible(); + + await encryptedRoomPage.sendMessage(messageText); + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.body).toHaveText(messageText); + + // Log out + await navbar.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Navigate to the encrypted channel WITHOUT entering the password + + await navbar.openChat(channelName); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + await expect(encryptedRoomPage.encryptionNotReadyIndicator).toBeVisible(); + + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.body).toHaveText( + 'This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.', + ); + }); + + test('expect placeholder text in place of encrypted file upload description', async ({ page }) => { + const fileUploadModal = new FileUploadModal(page); + + const channelName = faker.string.uuid(); + const fileName = faker.system.commonFileName('txt'); + const fileDescription = faker.lorem.sentence(); + + await setupE2EEPassword(page); + + // Create an encrypted channel + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible(); + + await test.step('upload the file with encryption', async () => { + // Upload a file + await encryptedRoomPage.dragAndDropTxtFile(); + + // Update file name and send + await encryptedRoomPage.composer.getFileByName('any_file.txt').click(); + await fileUploadModal.setName(fileName); + await fileUploadModal.update(); + await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible(); + + await encryptedRoomPage.composer.inputMessage.fill(fileDescription); + await encryptedRoomPage.composer.btnSend.click(); + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); + }); + + await test.step('disable encryption in the room', async () => { + await encryptedRoomPage.disableEncryption(); + await expect(encryptedRoomPage.encryptedTitle).not.toBeVisible(); + }); + + await test.step('upload the file without encryption', async () => { + await encryptedRoomPage.dragAndDropTxtFile(); + + // Update file name and send + await encryptedRoomPage.composer.getFileByName('any_file.txt').click(); + await fileUploadModal.setName(fileName); + await fileUploadModal.update(); + await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible(); + + await encryptedRoomPage.composer.inputMessage.fill(fileDescription); + await encryptedRoomPage.composer.btnSend.click(); + await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible(); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); + }); + + await test.step('enable encryption in the room', async () => { + await encryptedRoomPage.enableEncryption(); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + }); + + // Log out + await navbar.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + await expect(navbar.btnCreateNew).toBeVisible(); + + await navbar.openChat(channelName); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + + await expect(encryptedRoomPage.lastNthMessage(1).body).toHaveText( + 'This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.', + ); + await expect(encryptedRoomPage.lastNthMessage(1).encryptedIcon).toBeVisible(); + + await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible(); + await expect(encryptedRoomPage.lastMessage.getFileUploadByName(fileName)).toContainText(fileName); + await expect(encryptedRoomPage.lastMessage.body).toHaveText(fileDescription); + }); + + test.describe('E2EE Quotes', () => { + let targetRoomId: string; + let targetChannelName: string; + + test.afterAll(async ({ api }) => { + await deleteRoom(api, targetRoomId); + }); + + test('expect to not crash and not show quote message for a message_link which is not accessible to the user', async ({ + page, + request, + api, + }) => { + const encryptedRoomPage = new EncryptedRoomPage(page); + targetChannelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(targetChannelName, createdChannels); + + await expect(page).toHaveURL(`/group/${targetChannelName}`); + await expect(encryptedRoomPage.encryptedTitle).toBeVisible(); + await expect(encryptedRoomPage.encryptionNotReadyIndicator).not.toBeVisible(); + + await encryptedRoomPage.sendMessage('First encrypted message.'); + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.body).toHaveText('First encrypted message.'); + + // create a private group for user2 + const { group: user1Channel } = await createTargetGroupAndReturnFullRoom(api, { + excludeSelf: true, + members: [Users.user2.data._id], + }); + targetRoomId = user1Channel._id; + + // send a message to the private group, which is not accessible to the main user + const sentMessage = (await sendMessageFromUser(request, Users.user2, targetRoomId, 'This is a test message.')).message; + + const messageLink = `${BASE_URL}/group/${user1Channel.name}?msg=${sentMessage._id}`; + + await encryptedRoomPage.sendMessage(`This is a message with message link - ${messageLink}`); + + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.body).toContainText(`This is a message with message link - ${messageLink}`); + await expect(encryptedRoomPage.lastNthMessage(1).body).toContainText('First encrypted message.'); + + await page.reload(); + + await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); + await expect(encryptedRoomPage.lastMessage.body).toContainText(`This is a message with message link - ${messageLink}`); + await expect(encryptedRoomPage.lastNthMessage(1).body).toContainText('First encrypted message.'); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts new file mode 100644 index 0000000..3868914 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts @@ -0,0 +1,216 @@ +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { createTargetGroupAndReturnFullRoom } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enable_Encrypt_Files', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +const originalSettings = preserveSettings(settingsList); + +const TEST_FILE_TXT = 'any_file.txt'; + +test.use({ storageState: Users.userE2EE.state }); + +test.describe('E2EE File Encryption', () => { + let poHomeChannel: HomeChannel; + let encryptedRoomId: string; + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { username: 'user2' }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); + }); + + test.beforeEach(async ({ api, page }) => { + poHomeChannel = new HomeChannel(page); + const { group } = await createTargetGroupAndReturnFullRoom(api, { + extraData: { + broadcast: false, + encrypted: true, + }, + members: [Users.userE2EE.data._id], + }); + + encryptedRoomId = group._id; + + await poHomeChannel.gotoGroup(group.name as string); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect + .poll( + () => + page.evaluate(async (rid) => { + // eslint-disable-next-line import-x/no-absolute-path + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e'); + const room = await e2e.getInstanceByRoomId(rid); + return room?.getState(); + }, group._id), + { message: 'expect room encryption key to be ready before sending messages' }, + ) + .toBe('READY'); + }); + + test.afterEach(async ({ api }) => { + expect((await api.post('/groups.delete', { roomId: encryptedRoomId })).status()).toBe(200); + }); + + test('should edit encrypted message with file', async ({ page }) => { + const updatedFileName = `edited_${TEST_FILE_TXT}`; + + await test.step('send a file in channel and edit it', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await expect(poHomeChannel.composer.getFileByName(updatedFileName)).toBeVisible(); + + await poHomeChannel.composer.inputMessage.fill('any_description'); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.getLastMessageByFileName(updatedFileName)).toContainText(updatedFileName); + }); + + await test.step('edit the description', async () => { + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + + await expect(poHomeChannel.composer.inputMessage).toHaveValue('any_description'); + + await poHomeChannel.composer.inputMessage.fill('edited any_description'); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.getFileDescription).toHaveText('edited any_description'); + await expect(poHomeChannel.content.lastUserMessage.getByRole('link').getByText(updatedFileName)).toBeVisible(); + }); + + await test.step('delete the file from files list', async () => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemFiles.click(); + await poHomeChannel.tabs.files.deleteFile(updatedFileName); + + await expect(poHomeChannel.tabs.files.getFileByName(updatedFileName)).toHaveCount(0); + await expect(poHomeChannel.content.lastUserMessage.getByRole('link').getByText(updatedFileName)).not.toBeVisible(); + }); + }); + + test('File with Unicode filename uploads and downloads correctly', async ({ page }) => { + const UNICODE_FILE_NAME = 'Новый текстовый документ.txt'; + + await test.step('upload file with Unicode filename', async () => { + await poHomeChannel.content.sendFileMessage(TEST_FILE_TXT); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill(UNICODE_FILE_NAME); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getLastMessageByFileName(UNICODE_FILE_NAME)).toBeVisible(); + }); + + await test.step('download the file and verify the Unicode filename is preserved', async () => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemFiles.click(); + + await expect(poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME)).toBeVisible(); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + poHomeChannel.tabs.files.getFileByName(UNICODE_FILE_NAME).click(), + ]); + + expect(download.suggestedFilename()).toBe(UNICODE_FILE_NAME); + }); + }); + + test('File encryption with whitelisted and blacklisted media types', async ({ api }) => { + await test.step('send a text file in channel', async () => { + const updatedFileName = `edited_${TEST_FILE_TXT}`; + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.getLastMessageByFileName(updatedFileName)).toContainText(updatedFileName); + }); + + await test.step('set whitelisted media type setting', async () => { + await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); + }); + + await test.step('send text file again with whitelist setting set', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.inputMessage.fill('message 2'); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill('any_file2.txt'); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file2.txt')).toBeVisible(); + }); + + await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); + + await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + const composerFiles = await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).all(); + + await Promise.all(composerFiles.map((file) => expect(file).toHaveAttribute('readonly'))); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + }); + + test.describe('File encryption setting disabled', () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: originalSettings.E2E_Enable_Encrypt_Files }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); + }); + + test('Upload file without encryption in e2ee room', async () => { + await test.step('send a test encrypted message to check e2ee is working', async () => { + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + await test.step('send a text file in channel, file should not be encrypted', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.inputMessage.fill('any_description'); + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill('any_file1.txt'); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.getLastMessageByFileName('any_file1.txt')).toContainText('any_file1.txt'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-key-reset.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-key-reset.spec.ts new file mode 100644 index 0000000..c7b254c --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-key-reset.spec.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; + +import { createAuxContext } from '../fixtures/createAuxContext'; +import injectInitialData from '../fixtures/inject-initial-data'; +import { Users } from '../fixtures/userStates'; +import { AccountSecurity, HomeChannel } from '../page-objects'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +preserveSettings(settingsList); + +test.describe('E2EE Key Reset', () => { + let anotherClientPage: Page; + + test.use({ storageState: Users.userE2EE.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { username: 'user2' }); + }); + + test.beforeEach(async ({ browser, page }) => { + anotherClientPage = (await createAuxContext(browser, Users.userE2EE)).page; + const poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test.afterEach(async () => { + await anotherClientPage.close(); + await injectInitialData(); + }); + + test('expect force logout on e2e keys reset', async ({ page }) => { + const poAccountSecurity = new AccountSecurity(page); + + await poAccountSecurity.goto(); + await poAccountSecurity.securityE2EEncryptionSection.click(); + await poAccountSecurity.securityE2EEncryptionResetKeyButton.click(); + + await expect(page.locator('role=button[name="Login"]')).toBeVisible(); + await expect(anotherClientPage.locator('role=button[name="Login"]')).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts new file mode 100644 index 0000000..6731705 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts @@ -0,0 +1,100 @@ +import { faker } from '@faker-js/faker'; +import type { APIRequestContext } from '@playwright/test'; + +import { BASE_API_URL, DEFAULT_USER_CREDENTIALS } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { CreateE2EEChannel } from '../page-objects/fragments/e2ee'; +import { deletePrivateRoomsByName } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +preserveSettings(settingsList); + +const sendEncryptedMessage = async (request: APIRequestContext, rid: string, encryptedMsg: string) => { + return request.post(`${BASE_API_URL}/chat.sendMessage`, { + headers: { + 'X-Auth-Token': Users.userE2EE.data.loginToken, + 'X-User-Id': Users.userE2EE.data._id, + }, + data: { + message: { + rid, + msg: encryptedMsg, + t: 'e2e', + }, + }, + }); +}; + +test.describe('E2EE Legacy Format', () => { + const createdChannels: string[] = []; + let poHomeChannel: HomeChannel; + let createE2EEChannel: CreateE2EEChannel; + + test.use({ storageState: Users.userE2EE.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { username: 'user2' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + createE2EEChannel = new CreateE2EEChannel(page); + await poHomeChannel.goto(); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName( + { username: Users.userE2EE.data.username, password: DEFAULT_USER_CREDENTIALS.password }, + createdChannels, + ); + }); + + test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(page.getByTitle('Encrypted')).toBeVisible(); + // TODO: Fix this flakiness + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + + const rid = (await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room')) || ''; + expect(rid).toBeTruthy(); + + const kid = '32c9e7917b78'; + const encryptedKey = + 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; + const encryptedMessage = + '3JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s='; + + await page.evaluate( + async ({ rid, kid, encryptedKey }) => { + // eslint-disable-next-line import-x/no-absolute-path + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e'); + const room = await e2e.getInstanceByRoomId(rid); + await room?.importGroupKey(kid + encryptedKey); + }, + { rid, kid, encryptedKey }, + ); + + await sendEncryptedMessage(request, rid, kid + encryptedMessage); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts new file mode 100644 index 0000000..b3cac09 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -0,0 +1,350 @@ +import { faker } from '@faker-js/faker'; + +import { resetOwnE2EKey } from './resetOwnE2EKey'; +import { ADMIN_CREDENTIALS } from '../config/constants'; +import injectInitialData from '../fixtures/inject-initial-data'; +import { Users, storeState, restoreState } from '../fixtures/userStates'; +import { AccountSecurity, HomeChannel } from '../page-objects'; +import { setupE2EEPassword } from './setupE2EEPassword'; +import { Authenticated } from '../page-objects/auth'; +import { Navbar } from '../page-objects/fragments'; +import { CreateE2EEChannel, E2EEKeyDecodeFailureBanner, EnterE2EEPasswordBanner } from '../page-objects/fragments/e2ee'; +import { EnterE2EEPasswordModal, ResetE2EEPasswordModal } from '../page-objects/fragments/modals'; +import { LoginPage } from '../page-objects/login'; +import { deletePrivateRoomsByName } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +const originalSettings = preserveSettings(settingsList); + +test.describe('E2EE Passphrase Management - Initial Setup', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: originalSettings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: originalSettings.E2E_Allow_Unencrypted_Messages }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: originalSettings.E2E_Enabled_Default_DirectRooms }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms }); + }); + + test.describe('Generate', () => { + test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + + await expect(await resetOwnE2EKey(ADMIN_CREDENTIALS)).toBeOK(); + + await loginPage.goto(); + await loginPage.loginByUserState(Users.admin); + }); + + test('expect the randomly generated password to work', async ({ page }) => { + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const navbar = new Navbar(page); + + const password = await setupE2EEPassword(page); + + // Log out + await navbar.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(password); + + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + + test('expect to manually reset the password', async ({ page }) => { + const accountSecurityPage = new AccountSecurity(page); + const loginPage = new LoginPage(page); + + // Reset the E2EE key to start the flow from the beginning + await accountSecurityPage.goto(); + await accountSecurityPage.resetE2EEPassword(); + + await loginPage.loginByUserState(Users.admin); + }); + + test('should reset e2e password from the modal', async ({ page }) => { + const navbar = new Navbar(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); + + await setupE2EEPassword(page); + + // Logout + await navbar.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Reset E2EE password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.forgotPassword(); + await resetE2EEPasswordModal.confirmReset(); + + // restore login + await loginPage.loginByUserState(Users.admin); + }); + + test('expect to manually set a new password', async ({ page }) => { + const accountSecurityPage = new AccountSecurity(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const navbar = new Navbar(page); + + const newPassword = faker.internet.password({ + length: 30, + prefix: + faker.string.alpha({ casing: 'lower' }) + + faker.string.alpha({ casing: 'upper' }) + + faker.string.numeric() + + faker.string.symbol(), + }); + + await setupE2EEPassword(page); + + // Set a new password + await accountSecurityPage.goto(); + await accountSecurityPage.setE2EEPassword(newPassword); + + // Log out + await navbar.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(newPassword); + + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + }); + + test.describe('Recovery', () => { + test.use({ storageState: Users.userE2EE.state }); + + test('expect to recover the keys using the recovery key', async ({ page }) => { + await test.step('Recover the keys', async () => { + await page.goto('/home'); + await injectInitialData(); + await restoreState(page, Users.userE2EE); + const navbar = new Navbar(page); + + await navbar.logout(); + + const loginPage = new LoginPage(page); + await loginPage.loginByUserState(Users.userE2EE, { except: ['private_key', 'public_key'] }); + + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword('minus mobile dexter forest elvis'); + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + }); + }); +}); + +test.use({ storageState: Users.admin.state }); + +const roomSetupSettingsList = ['E2E_Enable', 'E2E_Allow_Unencrypted_Messages']; + +test.describe.serial('E2EE Passphrase Management - Room Setup States', () => { + const createdChannels: string[] = []; + let poAccountSecurity: AccountSecurity; + let poHomeChannel: HomeChannel; + let createE2EEChannel: CreateE2EEChannel; + let e2eePassword: string; + + preserveSettings(roomSetupSettingsList); + + test.beforeEach(async ({ page }) => { + poAccountSecurity = new AccountSecurity(page); + poHomeChannel = new HomeChannel(page); + createE2EEChannel = new CreateE2EEChannel(page); + }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + + test.afterEach(async ({ api }) => { + await api.recreateContext(); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName(ADMIN_CREDENTIALS, createdChannels); + }); + + test('expect save password state on encrypted room', async ({ page }) => { + await poAccountSecurity.goto(); + await poAccountSecurity.securityE2EEncryptionSection.click(); + await poAccountSecurity.securityE2EEncryptionResetKeyButton.click(); + + await page.locator('role=button[name="Login"]').waitFor(); + + await injectInitialData(); + await restoreState(page, Users.admin); + + await new Authenticated(page).goto(); + + await expect(poHomeChannel.bannerSaveEncryptionPassword).toBeVisible(); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); + await expect(poHomeChannel.btnRoomSaveE2EEPassword).toBeVisible(); + + await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor(); + await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible(); + + await expect(poHomeChannel.composer.inputMessage).not.toBeVisible(); + + await poHomeChannel.btnRoomSaveE2EEPassword.click(); + + e2eePassword = (await page.evaluate(() => localStorage.getItem('e2e.randomPassword'))) || 'undefined'; + + await expect(poHomeChannel.dialogSaveE2EEPassword).toBeVisible(); + await expect(poHomeChannel.dialogSaveE2EEPassword).toContainText(e2eePassword); + + await poHomeChannel.btnSavedMyPassword.click(); + + await poHomeChannel.composer.inputMessage.waitFor(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect enter password state on encrypted room', async ({ page }) => { + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + await poHomeChannel.goto(); + + // Logout to remove e2ee keys + await poHomeChannel.navbar.logout(); + + await injectInitialData(); + await restoreState(page, Users.admin, { except: ['private_key', 'public_key'] }); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); + + await poHomeChannel.btnRoomEnterE2EEPassword.waitFor(); + + await expect(poHomeChannel.btnRoomEnterE2EEPassword).toBeVisible(); + + await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor(); + await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible(); + + await expect(poHomeChannel.composer.inputMessage).not.toBeVisible(); + + await poHomeChannel.btnRoomEnterE2EEPassword.click(); + + await enterE2EEPasswordModal.enterPassword(e2eePassword); + + await expect(poHomeChannel.bannerEnterE2EEPassword).not.toBeVisible(); + + await poHomeChannel.composer.inputMessage.waitFor(); + // For E2EE to complete init setup + await page.waitForTimeout(300); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await storeState(page, Users.admin); + }); + + test('expect waiting for room keys state', async ({ page }) => { + await poHomeChannel.goto(); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.navbar.btnUserMenu.click(); + await poHomeChannel.navbar.getUserProfileMenuOption('Profile').click(); + + await poAccountSecurity.sidebar.linkSecurity.click(); + + await poAccountSecurity.securityE2EEncryptionSection.click(); + await poAccountSecurity.securityE2EEncryptionResetKeyButton.click(); + + await page.locator('role=button[name="Login"]').waitFor(); + + await page.reload(); + + await page.locator('role=button[name="Login"]').waitFor(); + + await injectInitialData(); + await restoreState(page, Users.admin); + + await poHomeChannel.navbar.typeSearch(channelName); + await poHomeChannel.navbar.getSearchRoomByName(channelName).click(); + + await poHomeChannel.btnRoomSaveE2EEPassword.click(); + await poHomeChannel.btnSavedMyPassword.click(); + + await expect(poHomeChannel.composer.inputMessage).not.toBeVisible(); + await expect(page.locator('.rcx-states__title')).toContainText('Check back later'); + + await poHomeChannel.roomToolbar.btnDisableE2EEncryption.waitFor(); + await expect(poHomeChannel.roomToolbar.btnDisableE2EEncryption).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnMembers).toBeVisible(); + await expect(poHomeChannel.roomToolbar.btnRoomInfo).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-pdf-export.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-pdf-export.spec.ts new file mode 100644 index 0000000..79d3b00 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-pdf-export.spec.ts @@ -0,0 +1,90 @@ +import { faker } from '@faker-js/faker'; + +import { resetOwnE2EKey } from './resetOwnE2EKey'; +import { ADMIN_CREDENTIALS } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { EncryptedRoomPage } from '../page-objects/encrypted-room'; +import { CreateE2EEChannel } from '../page-objects/fragments/e2ee'; +import { ExportMessagesFlexTab } from '../page-objects/fragments/flextabs'; +import { LoginPage } from '../page-objects/login'; +import { deletePrivateRoomsByName } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +preserveSettings(settingsList); + +test.describe('E2EE PDF Export', () => { + const createdChannels: string[] = []; + let createE2EEChannel: CreateE2EEChannel; + + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + // Note: Using admin user, so no need for userE2EE cleanup + }); + + test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + + await expect(await resetOwnE2EKey(ADMIN_CREDENTIALS)).toBeOK(); + + await loginPage.goto(); + await loginPage.loginByUserState(Users.admin); + createE2EEChannel = new CreateE2EEChannel(page); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName(ADMIN_CREDENTIALS, createdChannels); + }); + + test('should display only the download file method when exporting messages in an e2ee room', async ({ page }) => { + const encryptedRoomPage = new EncryptedRoomPage(page); + const exportMessagesTab = new ExportMessagesFlexTab(page); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + await expect(page).toHaveURL(`/group/${channelName}`); + await expect(encryptedRoomPage.encryptedRoomHeaderIcon).toBeVisible(); + + await encryptedRoomPage.showExportMessagesTab(); + await expect(exportMessagesTab.method).toContainClass('disabled'); // FIXME: looks like the component have an a11y issue + await expect(exportMessagesTab.method).toHaveAccessibleName('Download file Method'); + }); + + test('should allow exporting messages as PDF in an encrypted room', async ({ page }) => { + const encryptedRoomPage = new EncryptedRoomPage(page); + const exportMessagesTab = new ExportMessagesFlexTab(page); + + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + await expect(page).toHaveURL(`/group/${channelName}`); + await expect(encryptedRoomPage.encryptedRoomHeaderIcon).toBeVisible(); + + await encryptedRoomPage.sendMessage('This is a message to export as PDF.'); + await encryptedRoomPage.showExportMessagesTab(); + await expect(exportMessagesTab.method).toHaveAccessibleName('Download file Method'); + + // Select Output format as PDF + await exportMessagesTab.setOutputFormat('PDF'); + + // select messages to be exported + await exportMessagesTab.selectAllMessages(); + + // Wait for download event and match format + const download = await exportMessagesTab.downloadMessages(); + expect(download.suggestedFilename()).toMatch(/\.pdf$/); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-server-settings.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-server-settings.spec.ts new file mode 100644 index 0000000..ab218a1 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-server-settings.spec.ts @@ -0,0 +1,111 @@ +import { faker } from '@faker-js/faker'; + +import { DEFAULT_USER_CREDENTIALS, IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { CreateE2EEChannel } from '../page-objects/fragments/e2ee'; +import { deletePrivateRoomsByName } from '../utils'; +import { preserveSettings } from '../utils/preserveSettings'; +import { test, expect } from '../utils/test'; + +const settingsList = [ + 'E2E_Enable', + 'E2E_Allow_Unencrypted_Messages', + 'E2E_Enabled_Default_DirectRooms', + 'E2E_Enabled_Default_PrivateRooms', +]; + +preserveSettings(settingsList); + +test.describe('E2EE Server Settings', () => { + const createdChannels: string[] = []; + let poHomeChannel: HomeChannel; + let createE2EEChannel: CreateE2EEChannel; + + test.use({ storageState: Users.userE2EE.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { username: 'user2' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + createE2EEChannel = new CreateE2EEChannel(page); + await poHomeChannel.goto(); + }); + + test.afterAll(async () => { + await deletePrivateRoomsByName( + { username: Users.userE2EE.data.username, password: DEFAULT_USER_CREDENTIALS.password }, + createdChannels, + ); + }); + + test('expect slash commands to be enabled in an e2ee room', async ({ page }) => { + test.skip(!IS_EE, 'Premium Only'); + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await page.locator('[name="msg"]').type('/'); + await expect(page.locator('#popup-item-contextualbar')).not.toHaveClass(/disabled/); + await page.locator('[name="msg"]').clear(); + + await poHomeChannel.content.dispatchSlashCommand('/contextualbar'); + await expect(poHomeChannel.btnContextualbarClose).toBeVisible(); + + await poHomeChannel.btnContextualbarClose.click(); + await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); + }); + + test.describe('un-encrypted messages not allowed in e2ee rooms', () => { + test.skip(!IS_EE, 'Premium Only'); + let poHomeChannel: HomeChannel; + let createE2EEChannel: CreateE2EEChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + createE2EEChannel = new CreateE2EEChannel(page); + await poHomeChannel.goto(); + }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test('expect slash commands to be disabled in an e2ee room', async ({ page }) => { + const channelName = faker.string.uuid(); + + await createE2EEChannel.createAndStore(channelName, createdChannels); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await page.locator('[name="msg"]').pressSequentially('/'); + await expect(page.locator('#popup-item-contextualbar')).toHaveClass(/disabled/); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts b/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts new file mode 100644 index 0000000..39edb8c --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts @@ -0,0 +1,28 @@ +import { request as baseRequest } from '@playwright/test'; + +import { BASE_API_URL } from '../config/constants'; + +type Credentials = { + password: string; + username?: string; + email?: string; +}; + +export const resetOwnE2EKey = async (credentials: Credentials) => { + const request = await baseRequest.newContext(); + + try { + const loginResponse = await request.post(`${BASE_API_URL}/login`, { data: credentials }); + const { data } = await loginResponse.json(); + + return await request.post(`${BASE_API_URL}/users.resetE2EKey`, { + data: { userId: data.userId }, + headers: { + 'X-Auth-Token': data.authToken, + 'X-User-Id': data.userId, + }, + }); + } finally { + await request.dispose(); + } +}; diff --git a/apps/meteor/tests/e2e/e2e-encryption/setupE2EEPassword.ts b/apps/meteor/tests/e2e/e2e-encryption/setupE2EEPassword.ts new file mode 100644 index 0000000..ebd09e5 --- /dev/null +++ b/apps/meteor/tests/e2e/e2e-encryption/setupE2EEPassword.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test'; + +import { SaveE2EEPasswordBanner } from '../page-objects/fragments/e2ee'; +import { SaveE2EEPasswordModal } from '../page-objects/fragments/modals'; + +/** + * Click the banner to open the dialog to save the generated password + */ +export const setupE2EEPassword = async (page: Page) => { + const saveE2EEPasswordBanner = new SaveE2EEPasswordBanner(page); + const saveE2EEPasswordModal = new SaveE2EEPasswordModal(page); + + await saveE2EEPasswordBanner.click(); + const password = await saveE2EEPasswordModal.getPassword(); + await saveE2EEPasswordModal.confirm(); + await saveE2EEPasswordBanner.waitForDisappearance(); + + return password; +}; diff --git a/apps/meteor/tests/e2e/email-inboxes.spec.ts b/apps/meteor/tests/e2e/email-inboxes.spec.ts new file mode 100644 index 0000000..ecf2737 --- /dev/null +++ b/apps/meteor/tests/e2e/email-inboxes.spec.ts @@ -0,0 +1,52 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { AdminEmailInboxes } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('email-inboxes', () => { + let poAdminEmailInboxes: AdminEmailInboxes; + + const email = faker.internet.email(); + + test.beforeEach(async ({ page }) => { + poAdminEmailInboxes = new AdminEmailInboxes(page); + + await poAdminEmailInboxes.goto(); + }); + + test('expect create an email inbox', async () => { + await poAdminEmailInboxes.btnNewEmailInbox.click(); + const name = faker.person.firstName(); + await poAdminEmailInboxes.inputName.type(name); + await poAdminEmailInboxes.inputEmail.type(email); + + // SMTP + await poAdminEmailInboxes.inputSmtpServer.type(faker.internet.domainName()); + await poAdminEmailInboxes.inputSmtpUsername.type(faker.internet.userName()); + await poAdminEmailInboxes.inputSmtpPassword.type(faker.internet.password()); + await poAdminEmailInboxes.inputSmtpSecure.click(); + + // IMAP + await poAdminEmailInboxes.inputImapServer.type(faker.internet.domainName()); + await poAdminEmailInboxes.inputImapUsername.type(faker.internet.userName()); + await poAdminEmailInboxes.inputImapPassword.type(faker.internet.password()); + await poAdminEmailInboxes.inputImapSecure.click(); + + await poAdminEmailInboxes.btnSave.click(); + + await expect(poAdminEmailInboxes.itemRow(name)).toBeVisible(); + }); + + test('expect delete an email inbox', async ({ page }) => { + await poAdminEmailInboxes.deleteEmailInboxByName(email); + // await poAdminEmailInboxes.itemRow(email).click(); + // await poAdminEmailInboxes.btnDelete.click(); + // await poUtils.btnModalConfirmDelete.click(); + // await expect(poUtils.toastBarSuccess).toBeVisible(); + + await expect(page.locator('text=No results found')).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/embedded-layout.spec.ts b/apps/meteor/tests/e2e/embedded-layout.spec.ts new file mode 100644 index 0000000..e883e6b --- /dev/null +++ b/apps/meteor/tests/e2e/embedded-layout.spec.ts @@ -0,0 +1,137 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createDirectMessage, createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; + +const embeddedLayoutURL = (pageUrl: string) => `${pageUrl}?layout=embedded`; + +test.use({ storageState: Users.user1.state }); + +test.describe('embedded-layout', () => { + let poHomeChannel: HomeChannel; + let targetChannelId: string; + let notMemberChannelId: string; + let joinChannelId: string; + + test.beforeAll(async ({ api }) => { + [targetChannelId, notMemberChannelId, joinChannelId] = await Promise.all([ + createTargetChannel(api, { members: ['user1'] }), + createTargetChannel(api, { members: ['user2'] }), + createTargetChannel(api, { members: ['user2'] }), + ]); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([deleteChannel(api, targetChannelId), deleteChannel(api, notMemberChannelId), deleteChannel(api, joinChannelId)]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test.describe('Layout elements visibility', () => { + test('should hide primary navigation elements in embedded layout', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannelId); + await expect(poHomeChannel.roomHeaderToolbar).toBeVisible(); + + await page.goto(embeddedLayoutURL(page.url())); + await expect(poHomeChannel.roomHeaderToolbar).not.toBeVisible(); + await poHomeChannel.sidebar.waitForDismissal(); + }); + + test.describe('should show room header toolbar when show top navbar setting is enabled', () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/UI_Show_top_navbar_embedded_layout', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/UI_Show_top_navbar_embedded_layout', { value: false }); + }); + + test('should show room header toolbar as top navbar when setting enabled', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + await expect(poHomeChannel.roomHeaderToolbar).toBeVisible(); + }); + }); + }); + + test.describe('Channel member functionality', () => { + test('should hide join button and enable messaging for members', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + }); + + test('should allow sending and receiving messages', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + const testMessage = `Embedded test message ${Date.now()}`; + await poHomeChannel.content.sendMessage(testMessage); + await expect(poHomeChannel.content.lastUserMessage).toContainText(testMessage); + }); + + test('should preserve message composer functionality', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(poHomeChannel.composer.toolbarPrimaryActions).toBeVisible(); + + await poHomeChannel.composer.inputMessage.fill('Test message'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('Test message'); + + await poHomeChannel.composer.inputMessage.fill(''); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + }); + + test.describe('Channel non-member functionality', () => { + test('should display join button and disable composer for non-members', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.openChat(notMemberChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + await expect(poHomeChannel.composer.inputMessage).toBeDisabled(); + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + }); + + test('should allow joining channel and enable messaging', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.openChat(joinChannelId); + await page.goto(embeddedLayoutURL(page.url())); + + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + + const joinMessage = `Joined and sent message ${Date.now()}`; + await poHomeChannel.content.sendMessage(joinMessage); + await expect(poHomeChannel.content.lastUserMessage).toContainText(joinMessage); + }); + }); + + test.describe('Direct message functionality', () => { + test('should allow sending direct messages', async ({ page, api }) => { + await createDirectMessage(api); + await poHomeChannel.goto(); + await poHomeChannel.navbar.openChat(Users.user2.data.username); + await page.goto(embeddedLayoutURL(page.url())); + + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + + const dmMessage = `Embedded DM test ${Date.now()}`; + await poHomeChannel.content.sendMessage(dmMessage); + await expect(poHomeChannel.content.lastUserMessage).toContainText(dmMessage); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/emojis.spec.ts b/apps/meteor/tests/e2e/emojis.spec.ts new file mode 100644 index 0000000..f1689c4 --- /dev/null +++ b/apps/meteor/tests/e2e/emojis.spec.ts @@ -0,0 +1,106 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel, AdminEmoji } from './page-objects'; +import { createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('emoji', () => { + let poHomeChannel: HomeChannel; + let poAdminEmoji: AdminEmoji; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test('should display emoji picker properly', async ({ page }) => { + await poHomeChannel.composer.btnEmoji.click(); + + await test.step('should display scroller', async () => { + await expect(poHomeChannel.scrollerEmojiPicker).toBeVisible(); + }); + + await test.step('should focus the active emoji tab category', async () => { + const activityEmojiTab = poHomeChannel.getEmojiPickerTabByName('Activity'); + await activityEmojiTab.click(); + //Picker search input can steal focus and cause failures here + await expect(activityEmojiTab).toHaveAttribute('aria-selected', 'true'); + await poHomeChannel.composer.inputMessage.click(); // To close the emoji picker + }); + + await test.step('should pick and send grinning emoji', async () => { + await poHomeChannel.pickEmoji('grinning'); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastUserMessage).toContainText('😀'); + }); + }); + + test('expect send emoji via text', async ({ page }) => { + await poHomeChannel.content.sendMessage(':innocent:'); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastUserMessage).toContainText('😇'); + }); + + test('expect render special characters and numbers properly', async () => { + await poHomeChannel.content.sendMessage('® © ™ # *'); + await expect(poHomeChannel.content.lastUserMessage).toContainText('® © ™ # *'); + }); + + test('should add a custom emoji, send it, rename it, and check render', async ({ page }) => { + const emojiName = 'customemoji'; + const newEmojiName = 'renamedemoji'; + const emojiUrl = './tests/e2e/fixtures/files/test-image.jpeg'; + + poAdminEmoji = new AdminEmoji(page); + + await test.step('Add custom emoji', async () => { + await poHomeChannel.navbar.openManageMenuItem('Workspace'); + await poAdminEmoji.sidebar.linkEmoji.click(); + await poAdminEmoji.btnNew.click(); + await poAdminEmoji.addEmojiFlexTab.inputName.fill(emojiName); + + const [fileChooser] = await Promise.all([page.waitForEvent('filechooser'), page.locator('role=button[name="Custom Emoji"]').click()]); + await fileChooser.setFiles(emojiUrl); + + await poAdminEmoji.addEmojiFlexTab.save(); + await poAdminEmoji.sidebar.close(); + + await poHomeChannel.gotoChannel(targetChannel); + + await poHomeChannel.content.sendMessage(`:${emojiName}:`); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessage.getByTitle(`:${emojiName}:`)).toBeVisible(); + }); + + await test.step('Rename custom emoji', async () => { + await poHomeChannel.navbar.openManageMenuItem('Workspace'); + await poAdminEmoji.sidebar.linkEmoji.click(); + await (await poAdminEmoji.findEmojiByName(emojiName)).click(); + await poAdminEmoji.editEmojiFlexTab.inputName.fill(newEmojiName); + await poAdminEmoji.editEmojiFlexTab.save(); + await poAdminEmoji.sidebar.close(); + + await poHomeChannel.gotoChannel(targetChannel); + + await poHomeChannel.content.sendMessage(`:${newEmojiName}:`); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessage.getByTitle(`:${newEmojiName}:`)).toBeVisible(); + }); + + await test.step('Delete custom emoji', async () => { + await poHomeChannel.navbar.openManageMenuItem('Workspace'); + await poAdminEmoji.sidebar.linkEmoji.click(); + await poAdminEmoji.deleteEmoji(newEmojiName); + await expect(await poAdminEmoji.findEmojiByName(newEmojiName)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/enforce-2FA.spec.ts b/apps/meteor/tests/e2e/enforce-2FA.spec.ts new file mode 100644 index 0000000..a95ab6a --- /dev/null +++ b/apps/meteor/tests/e2e/enforce-2FA.spec.ts @@ -0,0 +1,106 @@ +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel, AccountSecurity } from './page-objects'; +import { createCustomRole, deleteCustomRole } from './utils/custom-role'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('enforce two factor authentication', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poHomeChannel: HomeChannel; + let poAccountSecurity: AccountSecurity; + let customRoleId = ''; + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + poAccountSecurity = new AccountSecurity(page); + }); + + test.beforeAll(async ({ api }) => { + const roleResponse = await createCustomRole(api, { name: 'enforce-2FA', mandatory2fa: true }); + expect(roleResponse.status()).toBe(200); + const { role } = await roleResponse.json(); + customRoleId = role._id; + + const userUpdateRes = await api.post('/users.update', { + data: { roles: ['user', customRoleId, 'admin'] }, + userId: 'rocketchat.internal.admin.test', + }); + expect(userUpdateRes.status()).toBe(200); + + const disableEmail2FA = await api.post('/users.2fa.disableEmail', {}); + expect(disableEmail2FA.status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + const userUpdateRes = await api.post('/users.update', { + data: { roles: ['user', 'admin'] }, + userId: 'rocketchat.internal.admin.test', + }); + expect(userUpdateRes.status()).toBe(200); + + const deleteRole = await deleteCustomRole(api, 'enforce-2FA'); + expect(deleteRole.status()).toBe(200); + + const enableEmail2FA = await api.post('/users.2fa.enableEmail', {}); + expect(enableEmail2FA.status()).toBe(200); + }); + + test('should redirect to 2FA setup page and setup email 2FA', async () => { + await poHomeChannel.gotoExpecting(poAccountSecurity.required2faModalSetUpButton); + await poAccountSecurity.required2faModalSetUpButton.click(); + await expect(poHomeChannel.navbar.btnHome).not.toBeVisible(); + + await expect(poAccountSecurity.securityHeader).toBeVisible(); + + await expect(poAccountSecurity.security2FASection).toHaveAttribute('aria-expanded', 'true'); + await expect(poAccountSecurity.email2FASwitch).toBeVisible(); + await poAccountSecurity.email2FASwitch.click(); + + await poHomeChannel.toastMessage.waitForDisplay(); + await expect(poHomeChannel.navbar.btnHome).toBeVisible(); + await expect(poAccountSecurity.securityHeader).not.toBeVisible(); + }); + + test.describe('should still redirect to 2FA setup page when email 2FA is disabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_Email_Enabled', false); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_Email_Enabled', true); + }); + + test('should redirect to 2FA setup page and show totp 2FA setup', async () => { + await poHomeChannel.gotoExpecting(poAccountSecurity.required2faModalSetUpButton); + await poAccountSecurity.required2faModalSetUpButton.click(); + await expect(poHomeChannel.navbar.btnHome).not.toBeVisible(); + + await expect(poAccountSecurity.securityHeader).toBeVisible(); + + await expect(poAccountSecurity.security2FASection).toHaveAttribute('aria-expanded', 'true'); + await expect(poAccountSecurity.totp2FASwitch).toBeVisible(); + await expect(poAccountSecurity.email2FASwitch).not.toBeVisible(); + }); + }); + + test.describe('should not redirect to 2FA setup page when both email and totp 2FA are disabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_Email_Enabled', false); + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_TOTP_Enabled', false); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_Email_Enabled', true); + await setSettingValueById(api, 'Accounts_TwoFactorAuthentication_By_TOTP_Enabled', true); + }); + + test('should not redirect to 2FA setup page', async () => { + await poHomeChannel.goto(); + await expect(poHomeChannel.navbar.btnHome).toBeVisible(); + await expect(poAccountSecurity.securityHeader).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/export-messages.spec.ts b/apps/meteor/tests/e2e/export-messages.spec.ts new file mode 100644 index 0000000..faf25d8 --- /dev/null +++ b/apps/meteor/tests/e2e/export-messages.spec.ts @@ -0,0 +1,164 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const uniqueMessage = (): string => `msg-${faker.string.uuid()}`; + +test.describe('export-messages', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/users.setPreferences', { userId: 'rocketchat.internal.admin.test', data: { hideFlexTab: false } }), + deleteChannel(api, targetChannel), + ]); + }); + + test('should all export methods be available in targetChannel', async () => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + await poHomeChannel.tabs.exportMessages.exposeMethods(); + await expect(poHomeChannel.tabs.exportMessages.getMethodOptionByName('Send email')).toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getMethodOptionByName('Send file via email')).toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getMethodOptionByName('Download file')).toBeVisible(); + }); + + test('should display export output format correctly depending on the selected method', async () => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + // TODO: Fix the base component to have a disabled statement and not only a class attribute + // Here we are checking for a button because the internal select element is not accessible + // and the higher component that is a button doesn't appear as disabled. + await expect(poHomeChannel.tabs.exportMessages.outputFormat).toContainClass('disabled'); + + await poHomeChannel.tabs.exportMessages.setMethod('Send file via email'); + + await poHomeChannel.tabs.exportMessages.exposeOutputFormats(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('html')).toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('json')).toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('pdf')).not.toBeVisible(); + + await poHomeChannel.tabs.exportMessages.setOutputFormat('html'); + + await poHomeChannel.tabs.exportMessages.setMethod('Download file'); + + await poHomeChannel.tabs.exportMessages.exposeOutputFormats(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('html')).not.toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('json')).toBeVisible(); + await expect(poHomeChannel.tabs.exportMessages.getOutputFormatOptionByName('pdf')).toBeVisible(); + }); + + test('when trying to send email without filling to users or to additional emails, should mark both fields as invalid', async () => { + const testMessage = uniqueMessage(); + + await poHomeChannel.content.sendMessage(testMessage); + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + await expect(poHomeChannel.btnContextualbarClose).toBeVisible(); + + await poHomeChannel.content.getMessageByText(testMessage).click(); + await poHomeChannel.tabs.exportMessages.send(); + + const usersField = poHomeChannel.tabs.exportMessages.inputUsers; + const additionalEmailsField = poHomeChannel.tabs.exportMessages.inputAdditionalEmails; + + await expect(usersField).toHaveAttribute('aria-invalid', 'true'); + await expect(additionalEmailsField).toHaveAttribute('aria-invalid', 'true'); + + const errorMessages = poHomeChannel.tabs.exportMessages.errorMessage( + 'You must select one or more users or provide one or more email addresses, separated by commas', + ); + await expect(errorMessages).toHaveCount(2); + }); + + test('should display an error when trying to send email without selecting any message', async ({ page }) => { + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + await poHomeChannel.tabs.exportMessages.setAdditionalEmail('mail@mail.com'); + await poHomeChannel.tabs.exportMessages.send(); + + await expect( + page.locator('[role="alert"]', { + hasText: `You haven't selected any messages`, + }), + ).toBeVisible(); + }); + + test('should be able to send messages after closing export messages', async () => { + const message1 = uniqueMessage(); + const message2 = uniqueMessage(); + + await poHomeChannel.content.sendMessage(message1); + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + await poHomeChannel.content.getMessageByText(message1).click(); + await poHomeChannel.btnContextualbarClose.click(); + await poHomeChannel.content.sendMessage(message2); + + await expect(poHomeChannel.content.getMessageByText(message2)).toBeVisible(); + }); + + test('should be able to select a single message to export', async () => { + const message1 = uniqueMessage(); + const message2 = uniqueMessage(); + + await poHomeChannel.content.sendMessage(message1); + await poHomeChannel.content.sendMessage(message2); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + await poHomeChannel.tabs.exportMessages.waitForDisplay(); + + await poHomeChannel.content.getMessageByText(message1).click(); + + await expect(poHomeChannel.tabs.exportMessages.getMessageCheckbox(message1)).toBeChecked(); + await expect(poHomeChannel.tabs.exportMessages.getMessageCheckbox(message2)).not.toBeChecked(); + await expect(poHomeChannel.tabs.exportMessages.clearSelectionButton).toBeEnabled(); + + await expect(poHomeChannel.tabs.exportMessages.sendButton).toBeEnabled(); + }); + + test('should be able to select a single message to export with hide contextual bar preference enabled', async ({ api }) => { + await api.post('/users.setPreferences', { + userId: 'rocketchat.internal.admin.test', + data: { hideFlexTab: true }, + }); + const message1 = uniqueMessage(); + const message2 = uniqueMessage(); + + await poHomeChannel.content.sendMessage(message1); + await poHomeChannel.content.sendMessage(message2); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemExportMessages.click(); + + await poHomeChannel.tabs.exportMessages.waitForDisplay(); + await poHomeChannel.content.getMessageByText(message1).click(); + + await expect(poHomeChannel.tabs.exportMessages.getMessageCheckbox(message1)).toBeChecked(); + await expect(poHomeChannel.tabs.exportMessages.clearSelectionButton).toBeEnabled(); + + await expect(poHomeChannel.tabs.exportMessages.sendButton).toBeEnabled(); + }); +}); diff --git a/apps/meteor/tests/e2e/feature-preview.spec.ts b/apps/meteor/tests/e2e/feature-preview.spec.ts new file mode 100644 index 0000000..5ac3937 --- /dev/null +++ b/apps/meteor/tests/e2e/feature-preview.spec.ts @@ -0,0 +1,620 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { AccountFeaturePreview, AdminInfo, HomeChannel, HomeDiscussion, HomeTeam } from './page-objects'; +import { CreateNewChannelModal, CreateNewDiscussionModal } from './page-objects/fragments/modals'; +import { + createTargetChannel, + createTargetTeam, + deleteChannel, + deleteTeam, + setSettingValueById, + createTargetDiscussion, + deleteRoom, +} from './utils'; +import { setUserPreferences } from './utils/setUserPreferences'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('feature preview', () => { + let poHomeChannel: HomeChannel; + let poHomeTeam: HomeTeam; + let targetChannel: string; + let targetDiscussion: Record; + let sidepanelTeam: string; + const targetChannelNameInTeam = `channel-from-team-${faker.number.int()}`; + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_AllowFeaturePreview', true); + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + targetDiscussion = await createTargetDiscussion(api); + + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: true, + }, + ], + }); + }); + + test.afterAll(async ({ api }) => { + // await setSettingValueById(api, 'Accounts_AllowFeaturePreview', false); + await deleteChannel(api, targetChannel); + await deleteRoom(api, targetDiscussion._id); + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: false, + }, + ], + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + poHomeTeam = new HomeTeam(page); + }); + + test('should show "Navigation" feature section', async ({ page }) => { + await new AccountFeaturePreview(page).goto(); + + await expect(page.getByRole('main').getByRole('button', { name: 'Navigation' })).toBeVisible(); + }); + + test.describe('Sidepanel', () => { + let newChannelModal: CreateNewChannelModal; + + test.beforeEach(async ({ api }) => { + sidepanelTeam = await createTargetTeam(api); + + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: true, + }, + { + name: 'secondarySidebar', + value: true, + }, + ], + }); + }); + + test.afterEach(async ({ api }) => { + await deleteTeam(api, sidepanelTeam); + + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: false, + }, + { + name: 'secondarySidebar', + value: false, + }, + ], + }); + }); + + test('should display new channel from team on the sidepanel', async ({ page, api }) => { + newChannelModal = new CreateNewChannelModal(page); + + await poHomeTeam.gotoGroup(sidepanelTeam); + + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.btnCreateNew.click(); + + await newChannelModal.inputName.fill(targetChannelNameInTeam); + await newChannelModal.checkboxPrivate.click(); + await newChannelModal.btnCreate.click(); + + await expect(poHomeTeam.sidepanel.sidepanelList).toBeVisible(); + await expect(poHomeTeam.sidepanel.getItemByName(targetChannelNameInTeam)).toBeVisible(); + + await deleteChannel(api, targetChannelNameInTeam); + }); + + test('should display sidepanel item with the same display preference as the sidebar', async () => { + await poHomeChannel.goto(); + const message = 'hello world'; + + await poHomeChannel.navbar.openChat(sidepanelTeam); + await poHomeChannel.content.sendMessage(message); + await expect(poHomeChannel.sidepanel.getItemByName(sidepanelTeam)).toBeVisible(); + }); + + test('should escape special characters on item subtitle', async () => { + await poHomeChannel.goto(); + const message = 'hello > world'; + const parsedWrong = 'hello > world'; + + await poHomeChannel.navbar.openChat(sidepanelTeam); + await poHomeChannel.content.sendMessage(message); + + await expect(poHomeChannel.sidepanel.getItemByName(sidepanelTeam)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(sidepanelTeam)).not.toHaveText(parsedWrong); + }); + + test('should show channel in sidepanel after adding existing one', async () => { + await poHomeTeam.gotoGroup(sidepanelTeam); + + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.addExistingChannel(targetChannel); + + await expect(poHomeTeam.sidepanel.getItemByName(targetChannel)).toBeVisible(); + }); + + test('should keep the main room on the top even if child has unread messages', async ({ page, browser }) => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + + await poHomeTeam.gotoGroup(sidepanelTeam); + + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.addExistingChannel(targetChannel); + + const sidepanelTeamItem = poHomeTeam.sidepanel.getTeamItemByName(sidepanelTeam); + const targetChannelItem = poHomeTeam.sidepanel.getTeamItemByName(targetChannel); + + await targetChannelItem.click(); + expect(page.url()).toContain(`/channel/${targetChannel}`); + await poHomeTeam.content.sendMessage('hello channel'); + + await expect(async () => { + await sidepanelTeamItem.focus(); + await sidepanelTeamItem.click(); + expect(page.url()).toContain(`/group/${sidepanelTeam}`); + }).toPass(); + await poHomeTeam.content.sendMessage('hello team'); + + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.openReplyInThread(); + await user1Channel.content.toggleAlsoSendThreadToChannel(false); + await user1Channel.content.sendMessageInThread('hello thread'); + + const item = poHomeTeam.sidepanel.getTeamItemByName(targetChannel); + await expect(item.locator('..')).toHaveAttribute('data-item-index', '1'); + + await user1Page.close(); + }); + + test('sidepanel should open the respective parent room filter if its a room filter', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannel); + await poHomeChannel.waitForRoomLoad(); + await poHomeChannel.sidebar.getFilterItemByName(sidepanelTeam).click(); + await poHomeChannel.content.waitForChannel(); + + await expect(page).toHaveURL(`/group/${sidepanelTeam}`); + await expect(poHomeChannel.sidepanel.getSidepanelHeader(sidepanelTeam)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(sidepanelTeam)).toHaveAttribute('aria-current', 'page'); + }); + + test('sidepanel should not open the respective parent room filter if its not a room filter', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Favorites')).toBeVisible(); + await expect(poHomeChannel.navbar.btnHome).toBeVisible(); + + await poHomeChannel.navbar.openChat(sidepanelTeam); + await poHomeChannel.content.waitForChannel(); + + await expect(page).toHaveURL(`/group/${sidepanelTeam}`); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Favorites')).toBeVisible(); + await expect(poHomeChannel.sidebar.favoritesTeamCollabFilter).toHaveAttribute('aria-selected', 'true'); + }); + + test('should show all filters and tablist on sidepanel', async () => { + await poHomeChannel.goto(); + + await expect(poHomeChannel.sidebar.teamCollabFilters).toBeVisible(); + await expect(poHomeChannel.sidebar.omnichannelFilters).toBeVisible(); + + await expect(poHomeChannel.sidebar.allTeamCollabFilter).toBeVisible(); + await expect(poHomeChannel.sidebar.favoritesTeamCollabFilter).toBeVisible(); + await expect(poHomeChannel.sidebar.discussionsTeamCollabFilter).toBeVisible(); + }); + + test('should show favorite team on the sidepanel', async () => { + test.skip(IS_EE); + + await poHomeChannel.gotoGroup(sidepanelTeam); + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.getTeamItemByName(sidepanelTeam)).not.toBeVisible(); + + await poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE).click(); + + await expect(poHomeChannel.sidepanel.getTeamItemByName(sidepanelTeam)).toBeVisible(); + }); + + test('should show discussion in discussions and all sidepanel filter, should remove after deleting discussion', async ({ page }) => { + const poHomeDiscussion = new HomeDiscussion(page); + await poHomeChannel.gotoGroup(sidepanelTeam); + + const discussionName = faker.string.uuid(); + + await poHomeChannel.composer.btnMenuMoreActions.click(); + await page.getByRole('menuitem', { name: 'Discussion' }).click(); + const createDiscussionModal = new CreateNewDiscussionModal(page); + await createDiscussionModal.inputName.fill(discussionName); + await createDiscussionModal.create(); + await expect(page.getByRole('heading', { name: discussionName })).toBeVisible(); + + await poHomeDiscussion.sidebar.discussionsTeamCollabFilter.click(); + await expect(poHomeDiscussion.sidepanel.getItemByName(discussionName)).toBeVisible(); + + await poHomeDiscussion.sidebar.allTeamCollabFilter.click(); + await expect(poHomeDiscussion.sidepanel.getItemByName(discussionName)).toBeVisible(); + + await poHomeDiscussion.roomToolbar.openRoomInfo(); + await poHomeDiscussion.tabs.room.deleteRoom(); + + await poHomeDiscussion.sidebar.discussionsTeamCollabFilter.click(); + await expect(poHomeDiscussion.sidepanel.getItemByName(discussionName)).not.toBeVisible(); + + await poHomeDiscussion.sidebar.allTeamCollabFilter.click(); + await expect(poHomeDiscussion.sidepanel.getItemByName(discussionName)).not.toBeVisible(); + }); + + test('should persist sidepanel state after page reload', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.sidebar.discussionsTeamCollabFilter.click(); + await poHomeChannel.sidepanel.unreadToggleLabel.click({ force: true }); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Discussions')).toBeVisible(); + + await page.reload(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Discussions')).toBeVisible(); + }); + + test('should show unread filter for thread messages', async ({ browser }) => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + + await test.step('mark all rooms as read', async () => { + await poHomeChannel.goto(); + await poHomeChannel.content.markAllRoomsAsRead(); + }); + + await poHomeChannel.gotoChannel(targetChannel); + await poHomeChannel.content.sendMessage('test thread message'); + + await poHomeChannel.navbar.btnHome.click(); + await poHomeChannel.sidebar.allTeamCollabFilter.click(); + await poHomeChannel.sidepanel.unreadToggleLabel.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + + await test.step('send a thread message from another user', async () => { + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.openReplyInThread(); + await user1Channel.content.toggleAlsoSendThreadToChannel(false); + await user1Channel.content.sendMessageInThread('hello thread'); + }); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + await expect( + poHomeChannel.sidepanel.getItemByName(targetChannel).getByRole('status', { name: '1 unread threaded message' }), + ).toBeVisible(); + + await poHomeChannel.sidepanel.getItemByName(targetChannel).click(); + await poHomeChannel.content.waitForChannel(); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + await expect( + poHomeChannel.sidepanel.getItemByName(targetChannel).getByRole('status', { name: '1 unread threaded message' }), + ).toBeVisible(); + + await poHomeChannel.content.openReplyInThread(); + await user1Page.close(); + }); + + test('unread mentions badges on filters', async ({ browser }) => { + test.skip(IS_EE); + + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + + await test.step('mark all rooms as read', async () => { + await poHomeChannel.goto(); + await poHomeChannel.content.markAllRoomsAsRead(); + }); + + await test.step('should favorite the target channel', async () => { + await poHomeChannel.gotoChannel(targetChannel); + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).not.toBeVisible(); + + await poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE).click(); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + }); + + await test.step('unread state should be empty', async () => { + await poHomeChannel.sidebar.allTeamCollabFilter.click(); + await poHomeChannel.sidepanel.unreadToggleLabel.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + }); + + await poHomeChannel.navbar.btnHome.click(); + + await test.step('send a mention message from another user', async () => { + await user1Channel.gotoChannel(targetChannel); + await user1Channel.content.sendMessage(`hello @${Users.admin.data.username}`); + }); + + await test.step('unread mentions badge should be visible on all filters', async () => { + await poHomeChannel.sidebar.allTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel).getByRole('status', { name: '1 mention' })).toBeVisible(); + await expect(poHomeChannel.sidebar.allTeamCollabFilter.getByRole('status', { name: '1 mention from All' })).toBeVisible(); + + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(targetChannel).getByRole('status', { name: '1 mention' })).toBeVisible(); + await expect( + poHomeChannel.sidebar.favoritesTeamCollabFilter.getByRole('status', { name: '1 mention from Favorites' }), + ).toBeVisible(); + }); + + await test.step('read the room', async () => { + await poHomeChannel.sidepanel.getItemByName(targetChannel).click(); + await poHomeChannel.content.waitForChannel(); + }); + + await test.step('unread mentions badge should not be visible on any filters', async () => { + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + + await poHomeChannel.sidebar.allTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + }); + + await user1Page.close(); + }); + + test('should persist sidepanel state after switching admin panel', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.sidebar.discussionsTeamCollabFilter.click(); + await poHomeChannel.sidepanel.unreadToggleLabel.click(); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Discussions')).toBeVisible(); + + await poHomeChannel.navbar.openAdminPanel(); + await expect(page).toHaveURL(/\/admin/); + + const adminPage = new AdminInfo(page); + + await adminPage.sidebar.btnClose.click(); + await expect(page).toHaveURL(/\/home/); + + await expect(poHomeChannel.sidepanel.unreadCheckbox).toBeChecked(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Discussions')).toBeVisible(); + }); + + test.describe('sidebar and sidepanel in small viewport', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 640, height: 460 }); + }); + + test('should show button to toggle sidebar/sidepanel', async () => { + await poHomeChannel.goto(); + + await poHomeChannel.sidebar.waitForDismissal(); + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + await expect(poHomeChannel.navbar.btnSidebarToggler()).toBeVisible(); + }); + + test('should toggle sidebar/sidepanel when clicking the button', async () => { + await poHomeChannel.goto(); + + await poHomeChannel.navbar.btnSidebarToggler().click(); + await poHomeChannel.sidebar.waitForDismissal(); + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + + await poHomeChannel.navbar.btnSidebarToggler(true).click(); + await poHomeChannel.sidebar.waitForDismissal(); + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + }); + + test('toggle sidebar and sidepanel', async () => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.btnSidebarToggler().click(); + + await expect(poHomeChannel.sidepanel.sidepanelBackButton).toBeVisible(); + + await poHomeChannel.sidepanel.sidepanelBackButton.click(); + + await poHomeChannel.sidebar.waitForDisplay(); + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + + await poHomeChannel.sidebar.favoritesTeamCollabFilter.click(); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader('Favorites')).toBeVisible(); + }); + + test('should close nav region when clicking outside of it', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.btnSidebarToggler().click(); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + + await page.click('main', { force: true }); + + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + await poHomeChannel.sidebar.waitForDismissal(); + }); + + test('should close nav region when opening a room', async () => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.btnSidebarToggler().click(); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + + await poHomeChannel.sidepanel.getItemByName(targetChannel).click(); + await poHomeChannel.content.waitForChannel(); + await expect(poHomeChannel.content.channelHeader).toContainText(targetChannel); + + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + await poHomeChannel.sidebar.waitForDismissal(); + }); + }); + }); + + test.describe('Sidebar room filters', () => { + test.beforeAll(async ({ api }) => { + sidepanelTeam = await createTargetTeam(api); + + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: true, + }, + { + name: 'secondarySidebar', + value: true, + }, + ], + }); + }); + + test.afterAll(async ({ api }) => { + await deleteTeam(api, sidepanelTeam); + + await setUserPreferences(api, { + featuresPreview: [ + { + name: 'newNavigation', + value: false, + }, + { + name: 'secondarySidebar', + value: false, + }, + ], + }); + }); + + test('should open rooms when clicking on sidebar filter', async ({ page }) => { + await poHomeChannel.goto(); + + await expect(poHomeChannel.sidebar.channelsList).toBeVisible(); + await poHomeChannel.sidebar.getFilterItemByName(sidepanelTeam).click({ force: true }); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader(sidepanelTeam)).toBeVisible(); + + await expect(poHomeChannel.sidepanel.getTeamItemByName(sidepanelTeam)).toBeVisible(); + await expect(page).toHaveURL(`/group/${sidepanelTeam}`); + }); + + test('should open room when clicking on sidepanel item', async ({ page }) => { + await poHomeChannel.goto(); + await poHomeChannel.sidebar.getFilterItemByName(sidepanelTeam).click(); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader(sidepanelTeam)).toBeVisible(); + + await poHomeChannel.sidepanel.getTeamItemByName(sidepanelTeam).click(); + await poHomeChannel.content.waitForChannel(); + await expect(page).toHaveURL(`/group/${sidepanelTeam}`); + }); + + test('should display rooms in direct message filter', async ({ page }) => { + const discussionName = faker.string.uuid(); + + await test.step('create a direct message with user1', async () => { + await poHomeChannel.goto(); + + await poHomeChannel.navbar.openChat(Users.user1.data.username); + await poHomeChannel.content.waitForChannel(); + await expect(poHomeChannel.content.channelHeader).toContainText(Users.user1.data.username); + }); + + await test.step('create discussion in DM', async () => { + await poHomeChannel.composer.btnMenuMoreActions.click(); + await page.getByRole('menuitem', { name: 'Discussion' }).click(); + const createDiscussionModal = new CreateNewDiscussionModal(page); + await createDiscussionModal.inputName.fill(discussionName); + await createDiscussionModal.create(); + await poHomeChannel.content.waitForChannel(); + await poHomeChannel.content.sendMessage('hello'); + + await expect(page.getByRole('heading', { name: discussionName })).toBeVisible(); + }); + + await test.step('open direct message sidebar filter', async () => { + await poHomeChannel.sidebar.teamsCollapser.click(); + await poHomeChannel.sidebar.channelsCollapser.click(); + + await expect(poHomeChannel.sidebar.directMessagesCollapser).toBeVisible(); + await expect(poHomeChannel.sidebar.directMessagesCollapser.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + await expect(poHomeChannel.sidebar.getFilterItemByName(Users.user1.data.username)).toBeVisible(); + + await poHomeChannel.sidebar.getFilterItemByName(Users.user1.data.username).click(); + + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + await expect(poHomeChannel.sidepanel.getSidepanelHeader(Users.user1.data.username)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).toBeVisible(); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).toContainText('hello'); + + await poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username).click(); + await poHomeChannel.content.waitForChannel(); + await poHomeChannel.content.sendMessage('hello DM'); + + await expect(poHomeChannel.content.channelHeader).toContainText(Users.user1.data.username); + await expect(page).toHaveURL(/direct/); + await expect(poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username)).toHaveAttribute('aria-current', 'page'); + await expect(poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username)).toContainText('hello DM'); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).not.toHaveAttribute('aria-current'); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).toContainText('hello'); + + await poHomeChannel.sidepanel.getItemByName(discussionName).click(); + await poHomeChannel.content.waitForChannel(); + + await expect(page).toHaveURL(/group/); + await expect(poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username)).not.toHaveAttribute('aria-current'); + await expect(poHomeChannel.sidepanel.getMainRoomByName(Users.user1.data.username)).toContainText('hello DM'); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).toHaveAttribute('aria-current', 'page'); + await expect(poHomeChannel.sidepanel.getItemByName(discussionName)).toContainText('hello'); + await expect(poHomeChannel.sidepanel.getSidepanelHeader(Users.user1.data.username)).toBeVisible(); + }); + }); + + test('should not open rooms when clicking on sidebar filters in small viewport', async ({ page }) => { + await page.setViewportSize({ width: 640, height: 460 }); + await poHomeChannel.goto(); + + await poHomeChannel.sidebar.waitForDismissal(); + await expect(poHomeChannel.sidepanel.sidepanel).not.toBeVisible(); + await poHomeChannel.navbar.btnSidebarToggler().click(); + await expect(poHomeChannel.sidepanel.sidepanel).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/README.md b/apps/meteor/tests/e2e/federation/README.md new file mode 100644 index 0000000..c14c69f --- /dev/null +++ b/apps/meteor/tests/e2e/federation/README.md @@ -0,0 +1,12 @@ +# E2E Testing with playwright + +## Running tests + +```sh +$ RC_SERVER_1=http://localhost:3000 RC_SERVER_1_ADMIN_USER=test RC_SERVER_1_ADMIN_PASSWORD=test RC_SERVER_1_MATRIX_SERVER_NAME=my.matrix.server \ +RC_SERVER_2=http://localhost:3000 RC_SERVER_2_ADMIN_USER=test2 RC_SERVER_2_ADMIN_PASSWORD=test RC_SERVER_2_MATRIX_SERVER_NAME=my2.matrix.server \ +yarn run test:e2e:federation +``` + +## Important +Make sure to increase the rate limiter for the user register api under Admin => Rate Limiter => Feature limiting. This is necessary since we are registering new users programatically. \ No newline at end of file diff --git a/apps/meteor/tests/e2e/federation/config/constants.ts b/apps/meteor/tests/e2e/federation/config/constants.ts new file mode 100644 index 0000000..928a912 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/config/constants.ts @@ -0,0 +1,20 @@ +export const RC_SERVER_1 = { + url: process.env.RC_SERVER_1 ?? 'http://localhost:3000', + username: process.env.RC_SERVER_1_ADMIN_USER ?? '', + password: process.env.RC_SERVER_1_ADMIN_PASSWORD ?? '', + matrixServerName: process.env.RC_SERVER_1_MATRIX_SERVER_NAME ?? '', +}; + +export const RC_SERVER_2 = { + url: process.env.RC_SERVER_2 ?? 'http://localhost:3000', + username: process.env.RC_SERVER_2_ADMIN_USER ?? '', + password: process.env.RC_SERVER_2_ADMIN_PASSWORD ?? '', + matrixServerName: process.env.RC_SERVER_2_MATRIX_SERVER_NAME ?? '', +}; + +export const RC_EXTRA_SERVER = { + url: process.env.RC_EXTRA_SERVER ?? 'http://localhost:3000', + username: process.env.RC_EXTRA_SERVER_ADMIN_USER ?? '', + password: process.env.RC_EXTRA_SERVER_ADMIN_PASSWORD ?? '', + matrixServerName: process.env.RC_EXTRA_SERVER_MATRIX_SERVER_NAME ?? '', +}; diff --git a/apps/meteor/tests/e2e/federation/config/global-setup.ts b/apps/meteor/tests/e2e/federation/config/global-setup.ts new file mode 100644 index 0000000..92a8dbe --- /dev/null +++ b/apps/meteor/tests/e2e/federation/config/global-setup.ts @@ -0,0 +1,20 @@ +export default async function (): Promise { + const requiredEnvVars = [ + 'RC_SERVER_1', + 'RC_SERVER_2', + 'RC_SERVER_1_ADMIN_USER', + 'RC_SERVER_1_ADMIN_PASSWORD', + 'RC_SERVER_1_MATRIX_SERVER_NAME', + 'RC_SERVER_2_ADMIN_USER', + 'RC_SERVER_2_ADMIN_PASSWORD', + 'RC_SERVER_2_MATRIX_SERVER_NAME', + 'RC_EXTRA_SERVER', + 'RC_EXTRA_SERVER_ADMIN_USER', + 'RC_EXTRA_SERVER_ADMIN_PASSWORD', + 'RC_EXTRA_SERVER_MATRIX_SERVER_NAME', + ]; + + if (requiredEnvVars.some((envVar) => !process.env[envVar])) { + throw new Error(`Missing required environment variables: ${requiredEnvVars.filter((envVar) => !process.env[envVar]).join(', ')}`); + } +} diff --git a/apps/meteor/tests/e2e/federation/files/test_pdf_file.pdf b/apps/meteor/tests/e2e/federation/files/test_pdf_file.pdf new file mode 100644 index 0000000..1f72c0e Binary files /dev/null and b/apps/meteor/tests/e2e/federation/files/test_pdf_file.pdf differ diff --git a/apps/meteor/tests/e2e/federation/page-objects/account-profile.ts b/apps/meteor/tests/e2e/federation/page-objects/account-profile.ts new file mode 100644 index 0000000..631eedd --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/account-profile.ts @@ -0,0 +1,17 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationAccountProfile { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get inputName(): Locator { + return this.page.locator('//label[contains(text(), "Name")]/..//input'); + } + + get btnSubmit(): Locator { + return this.page.getByRole('button', { name: 'Save changes', exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/admin.ts b/apps/meteor/tests/e2e/federation/page-objects/admin.ts new file mode 100644 index 0000000..94538db --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/admin.ts @@ -0,0 +1,78 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FederationAdminFlextab } from './fragments/admin-flextab'; + +export class FederationAdmin { + private readonly page: Page; + + readonly tabs: FederationAdminFlextab; + + constructor(page: Page) { + this.page = page; + this.tabs = new FederationAdminFlextab(page); + } + + get inputSearchRooms(): Locator { + return this.page.locator('input[placeholder ="Search rooms"]'); + } + + get inputSearchUsers(): Locator { + return this.page.locator('input[placeholder="Search Users"]'); + } + + get inputSearchSettings(): Locator { + return this.page.locator('input[type=search]'); + } + + get roomsInputName(): Locator { + return this.page.locator('//label[text()="Name"]/following-sibling::span//input'); + } + + get roomsInputDescription(): Locator { + return this.page.locator('//label[text()="Description"]/following-sibling::span//textarea'); + } + + get roomsInputAnnouncement(): Locator { + return this.page.locator('//label[text()="Announcement"]/following-sibling::span//textarea'); + } + + get roomsInputTopic(): Locator { + return this.page.locator('//label[text()="Topic"]/following-sibling::span//textarea'); + } + + get roomsInputFavorite(): Locator { + return this.page.locator('//label[text()="Favorite"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsInputPrivate(): Locator { + return this.page.locator('//label[text()="Private"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsInputReadOnly(): Locator { + return this.page.locator('//label[text()="Read Only"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsInputArchived(): Locator { + return this.page.locator('//label[text()="Archived"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsInputDefault(): Locator { + return this.page.locator('//label[text()="Default"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsInputFeatured(): Locator { + return this.page.locator('//label[text()="Featured"]/following-sibling::span//input/following-sibling::i'); + } + + get roomsBtnDelete(): Locator { + return this.page.locator('role=button[name="Delete"]'); + } + + get roomsBtnUploadAvatar(): Locator { + return this.page.locator('role=button[name="Upload"]'); + } + + get roomsBtnDefaultAvatar(): Locator { + return this.page.locator('button[title="Set Default Avatar"]'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/channel.ts b/apps/meteor/tests/e2e/federation/page-objects/channel.ts new file mode 100644 index 0000000..34c5e48 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/channel.ts @@ -0,0 +1,94 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FederationHomeContent } from './fragments/home-content'; +import { FederationHomeFlextab } from './fragments/home-flextab'; +import { FederationSidenav } from './fragments/home-sidenav'; +import { Navbar, RoomToolbar, ToastMessages } from '../../page-objects/fragments'; +import { CreateNewChannelModal, CreateNewDMModal } from '../../page-objects/fragments/modals'; + +export class FederationChannel { + private readonly page: Page; + + readonly content: FederationHomeContent; + + readonly sidenav: FederationSidenav; + + readonly navbar: Navbar; + + readonly tabs: FederationHomeFlextab; + + readonly roomToolbar: RoomToolbar; + + readonly toastMessage: ToastMessages; + + readonly newChannelModal: CreateNewChannelModal; + + readonly newDMModal: CreateNewDMModal; + + constructor(page: Page) { + this.page = page; + this.content = new FederationHomeContent(page); + this.sidenav = new FederationSidenav(page); + this.navbar = new Navbar(page); + this.tabs = new FederationHomeFlextab(page); + this.roomToolbar = new RoomToolbar(page); + this.toastMessage = new ToastMessages(page); + this.newChannelModal = new CreateNewChannelModal(page); + this.newDMModal = new CreateNewDMModal(page); + } + + get btnContextualbarClose(): Locator { + return this.page.locator('[data-qa="ContextualbarActionClose"]'); + } + + async getFederationServerName(): Promise { + return (await this.page.locator('[data-qa="federated-origin-server-name"]').locator('span').innerText()).substring(1).trim(); + } + + async createPublicChannelAndInviteUsersUsingCreationModal(channelName: string, usernamesToInvite: string[]) { + await this.navbar.createNew('Channel', channelName, { + private: false, + federated: true, + members: usernamesToInvite, + }); + } + + async createDiscussionSearchingForChannel(channelName: string) { + await this.navbar.openCreate('Discussion'); + await this.page.locator('//label[text()="Parent channel or group"]/following-sibling::span//input').waitFor(); + await this.page.locator('//label[text()="Parent channel or group"]/following-sibling::span//input').focus(); + await this.page.locator('//label[text()="Parent channel or group"]/following-sibling::span//input').type(channelName, { delay: 100 }); + } + + async createTeam(teamName: string) { + await this.navbar.createNew('Team', teamName); + } + + async createPrivateGroupAndInviteUsersUsingCreationModal(channelName: string, usernamesToInvite: string[]) { + await this.navbar.createNew('Channel', channelName, { + private: true, + federated: true, + members: usernamesToInvite, + }); + } + + async createDirectMessagesUsingModal(usernamesToInvite: string[]) { + await this.navbar.openCreate('Direct message'); + + for await (const username of usernamesToInvite) { + await this.newDMModal.inviteUserToDM(username); + } + await this.newDMModal.btnCreate.click(); + } + + async createNonFederatedPublicChannelAndInviteUsersUsingCreationModal(channelName: string, usernamesToInvite: string[]) { + await this.navbar.openCreate('Channel'); + await this.newChannelModal.checkboxPrivate.click(); + await this.newChannelModal.inputName.fill(channelName); + for await (const username of usernamesToInvite) { + await this.newChannelModal.inviteUserToChannel(username); + } + + await this.newChannelModal.btnCreate.click(); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab-users.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab-users.ts new file mode 100644 index 0000000..19da93d --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab-users.ts @@ -0,0 +1,46 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationAdminFlextabUsers { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get btnNew(): Locator { + return this.page.locator('role=button[name="New"]'); + } + + get btnEdit(): Locator { + return this.page.locator('role=button[name="Edit"]'); + } + + get btnSave(): Locator { + return this.page.locator('role=button[name="Save"]'); + } + + get inputName(): Locator { + return this.page.locator('//label[text()="Name"]/following-sibling::span//input'); + } + + get inputUserName(): Locator { + return this.page.locator('//label[text()="Username"]/following-sibling::span//input'); + } + + get inputEmail(): Locator { + return this.page.locator('//label[text()="Email"]/following-sibling::span//input').first(); + } + + get inputPassword(): Locator { + return this.page.locator('//label[text()="Password"]/following-sibling::span//input'); + } + + get checkboxVerified(): Locator { + return this.page.locator('//label[text()="Email"]/following-sibling::span//input/following-sibling::i'); + } + + async addRole(role: string): Promise { + await this.page.locator('//label[text()="Roles"]/following-sibling::span//input').click(); + await this.page.locator(`li[value=${role}]`).click(); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab.ts new file mode 100644 index 0000000..6319ddc --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/admin-flextab.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test'; + +import { FederationAdminFlextabUsers } from './admin-flextab-users'; + +export class FederationAdminFlextab { + readonly users: FederationAdminFlextabUsers; + + constructor(page: Page) { + this.users = new FederationAdminFlextabUsers(page); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts new file mode 100644 index 0000000..4fb82d8 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-content.ts @@ -0,0 +1,244 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeContent { + protected readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get inputMessage(): Locator { + return this.page.locator('[name="msg"]'); + } + + get messagePopUpItems(): Locator { + return this.page.locator('role=menu[name="People"]'); + } + + get messageListItems(): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]'); + } + + get lastUserMessage(): Locator { + return this.messageListItems.last(); + } + + get lastUserMessageBody(): Locator { + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); + } + + async sendMessage(text: string): Promise { + await this.page.locator('[name="msg"]').type(text); + await this.page.locator('button[aria-label="Send"]').click(); + } + + async sendMessageUsingEnter(text: string): Promise { + await this.page.locator('[name="msg"]').type(text); + await this.page.keyboard.press('Enter'); + } + + async editLastMessage(message: string): Promise { + await this.openLastMessageMenu(); + await this.btnOptionEditMessage.click(); + await this.page.locator('[name="msg"]').fill(message); + await this.page.keyboard.press('Enter'); + } + + async deleteLastMessage(): Promise { + await this.openLastMessageMenu(); + await this.btnOptionDeleteMessage.click(); + await this.page.locator('#modal-root dialog .rcx-modal__inner .rcx-modal__footer .rcx-button--danger').click(); + } + + async starLastMessage(): Promise { + await this.openLastMessageMenu(); + await this.btnOptionStarMessage.waitFor(); + await this.btnOptionStarMessage.click(); + } + + async replyInDm(message: string): Promise { + await this.openLastMessageMenu(); + await this.btnOptionReplyDirectly.click(); + await this.page.waitForTimeout(2000); + await this.page.locator('[name="msg"]').type(message, { delay: 100 }); + await this.page.waitForTimeout(2000); + await this.page.keyboard.press('Enter'); + } + + async sendAudioRecordedMessage(): Promise { + await this.btnRecordAudio.click(); + await this.page.waitForTimeout(3000); + await this.page.locator('.rc-message-box__icon.rc-message-box__audio-message-done').click(); + await this.btnModalConfirm.click(); + } + + async sendAudioRecordedInThreadMessage(): Promise { + await this.btnRecordAudio.nth(1).click(); + await this.page.waitForTimeout(3000); + await this.page.locator('.rc-message-box__icon.rc-message-box__audio-message-done').click(); + await this.btnModalConfirm.click(); + } + + async sendVideoRecordedMessage(): Promise { + await this.btnVideoMessage.click(); + await this.page.locator('.rcx-box.rcx-box--full.rcx-icon--name-video').click(); + await this.page.waitForTimeout(3000); + await this.page.locator('.rcx-box.rcx-box--full.rcx-icon--name-stop-unfilled').click(); + await this.page.locator('button >> text="Send"').click(); + await this.page.waitForTimeout(3000); + await this.btnModalConfirm.click(); + } + + async dispatchSlashCommand(text: string): Promise { + await this.page.locator('[name="msg"]').fill(text); + await this.page.locator('button[aria-label="Send"]').waitFor(); + await this.page.locator('button[aria-label="Send"]').click(); + } + + get btnModalConfirm(): Locator { + return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary'); + } + + get lastMessageFileName(): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child'); + } + + async getLastFileMessageByFileName(filename: string): Promise { + return this.page + .locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container') + .last() + .locator(`div[title="${filename}"]`); + } + + async getLastFileThreadMessageByFileName(filename: string): Promise { + return this.page + .locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container') + .last() + .locator(`div[title="${filename}"]`); + } + + get lastFileMessage(): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-message-container').last(); + } + + get waitForLastMessageTextAttachmentEqualsText(): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]:last-child .rcx-attachment__details .rcx-message-body'); + } + + get waitForLastThreadMessageTextAttachmentEqualsText(): Locator { + return this.page + .locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]') + .last() + .locator('.rcx-attachment__details'); + } + + get btnOptionEditMessage(): Locator { + return this.page.locator('[data-qa-id="edit-message"]'); + } + + get btnOptionDeleteMessage(): Locator { + return this.page.locator('[data-qa-id="delete-message"]'); + } + + get btnOptionPinMessage(): Locator { + return this.page.locator('[data-qa-id="pin-message"]'); + } + + get btnOptionStarMessage(): Locator { + return this.page.locator('[data-qa-id="star-message"]'); + } + + get btnVideoMessage(): Locator { + return this.page.locator('[data-qa-id="video-message"]'); + } + + get btnRecordAudio(): Locator { + return this.page.locator('[data-qa-id="audio-record"]'); + } + + get btnMenuMoreActions() { + return this.page.locator('[data-qa-id="menu-more-actions"]'); + } + + get btnContactEdit(): Locator { + return this.page.locator('.rcx-vertical-bar button:has-text("Edit")'); + } + + get btnOptionReplyInThread(): Locator { + return this.page.locator('[data-qa-id="reply-in-thread"]'); + } + + get btnOptionStartDiscussion(): Locator { + return this.page.locator('[data-qa-id="start-discussion"]'); + } + + get btnOptionReplyDirectly(): Locator { + return this.page.locator('[data-qa-id="reply-directly"]'); + } + + get lastThreadMessageText(): Locator { + return this.page.locator('div.thread-list ul.thread [role="listitem"][aria-roledescription="message"]').last(); + } + + async sendFileMessage(fileName: string): Promise { + await this.page.locator('input[type=file]').setInputFiles(`./tests/e2e/federation/files/${fileName}`); + } + + async sendThreadMessage(message: string): Promise { + await this.page.getByRole('dialog').locator('[name="msg"]').last().fill(message); + await this.page.keyboard.press('Enter'); + } + + async openLastMessageMenu(): Promise { + await this.lastUserMessage.hover(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).click(); + } + + threadSendToChannelAlso(): Locator { + return this.page.getByRole('dialog').locator('label', { hasText: 'Also send to channel' }); + } + + async quoteMessage(message: string): Promise { + await this.openLastMessageMenu(); + await this.page.locator('[data-qa-id="quote-message"]').click(); + await this.page.locator('[name="msg"]').fill(message); + await this.page.keyboard.press('Enter'); + } + + async openLastThreadMessageMenu(): Promise { + await this.lastThreadMessageText.hover(); + await this.lastThreadMessageText.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastThreadMessageText.getByRole('button', { name: 'More', exact: true }).click(); + } + + async quoteMessageInsideThread(message: string): Promise { + await this.openLastThreadMessageMenu(); + await this.page.locator('[data-qa-id="quote-message"]').click(); + await this.sendThreadMessage(message); + } + + async reactToMessage(emoji: string): Promise { + await this.openLastMessageMenu(); + await this.page.locator('[data-qa-id="reaction-message"]').click(); + await this.page.locator('input.js-emojipicker-search').type(emoji); + await this.page.locator(`[data-emoji="${emoji}"]`).last().click(); + } + + async unreactLastMessage(): Promise { + await this.lastUserMessage.locator('.rcx-message-reactions__reaction').nth(1).waitFor(); + await this.lastUserMessage.locator('.rcx-message-reactions__reaction').nth(1).click(); + } + + async getSystemMessageByText(text: string): Promise { + return this.page.locator('[role="document"][aria-roledescription="system message body"]', { hasText: text }); + } + + async getLastSystemMessageName(): Promise { + return this.page.locator('[role="listitem"][aria-roledescription="system message"]').last().getByRole('button'); + } + + async getAllReactions(): Promise { + return this.lastUserMessage.locator('.rcx-message-reactions__reaction'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-channels.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-channels.ts new file mode 100644 index 0000000..8727579 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-channels.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeFlextabChannels { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get btnAddExisting(): Locator { + return this.page.locator('button >> text="Add Existing"'); + } + + get inputChannels(): Locator { + return this.page.locator('#modal-root input').first(); + } + + get btnAdd(): Locator { + return this.page.locator('#modal-root button:has-text("Add")'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-dm-member.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-dm-member.ts new file mode 100644 index 0000000..e464792 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-dm-member.ts @@ -0,0 +1,13 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeFlextabDirectMessageMember { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + getUserInfoUsername(username: string): Locator { + return this.page.locator(`//div[contains(@class, "rcx-box--full") and contains(@title, "${username}")]`); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-members.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-members.ts new file mode 100644 index 0000000..1d04abc --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-members.ts @@ -0,0 +1,57 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeFlextabMembers { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + getUserInList(username: string): Locator { + return this.page.locator(`[data-qa="MemberItem-${username}"]`); + } + + get addUsersButton(): Locator { + return this.page.locator('role=button[name="Add"]'); + } + + get btnRemoveUserFromRoom(): Locator { + return this.page.locator('[value="removeUser"]'); + } + + get btnMenuUserInfo(): Locator { + return this.page.getByRole('dialog', { name: 'User Info', exact: true }).getByRole('button', { name: 'More', exact: true }); + } + + getKebabMenuForUser(username: string): Locator { + return this.page.locator(`[data-username="${username}"] [data-testid="menu"]`); + } + + async getOptionFromKebabMenuForUser(optionName: string): Promise { + return this.page.locator(`ol li[value="${optionName}"].rcx-option`); + } + + async removeUserFromRoom(username: string): Promise { + await this.getUserInList(username).hover(); + await this.getKebabMenuForUser(username).click(); + await (await this.getOptionFromKebabMenuForUser('removeUser')).click(); + await this.page.locator('#modal-root dialog .rcx-modal__inner .rcx-modal__footer .rcx-button--danger').click(); + } + + async addMultipleUsers(usernames: string[]) { + await this.addUsersButton.click(); + for await (const username of usernames) { + await this.page.getByRole('textbox', { name: 'Choose users' }).click(); + await this.page.getByRole('textbox', { name: 'Choose users' }).fill(username); + + await this.page.getByRole('listbox').waitFor(); + await this.page.getByRole('listbox').selectOption(username); + } + await this.addUsersButton.click(); + } + + async showAllUsers() { + await this.page.locator('.rcx-select >> text=Online').first().click(); + await this.page.locator('.rcx-option:has-text("All")').first().click(); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-notificationPreferences.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-notificationPreferences.ts new file mode 100644 index 0000000..43f5106 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-notificationPreferences.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeFlextabNotificationPreferences { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get btnSave(): Locator { + return this.page.locator('role=button[name="Save"]'); + } + + getPreferenceByDevice(device: string): Locator { + return this.page.locator(`//div[@id="${device}Alert"]`); + } + + async selectDropdownById(text: string): Promise { + await this.page.locator(`//div[@id="${text}"]`).click(); + } + + async selectOptionByLabel(text: string): Promise { + await this.page.locator(`li.rcx-option >> text="${text}"`).click(); + } + + async selectDevice(text: string): Promise { + await this.page.locator(`[data-qa-id="${text}-notifications"]`).click(); + } + + async updateDevicePreference(device: string): Promise { + await this.selectDevice(device); + await this.selectDropdownById(`${device}Alert`); + await this.selectOptionByLabel('Mentions'); + } + + async updateAllNotificationPreferences(): Promise { + await this.updateDevicePreference('Desktop'); + await this.updateDevicePreference('Mobile'); + await this.updateDevicePreference('Email'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-room.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-room.ts new file mode 100644 index 0000000..e7ea13e --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab-room.ts @@ -0,0 +1,49 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationHomeFlextabRoom { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get btnEdit(): Locator { + return this.page.locator('role=button[name="Edit"]'); + } + + get btnLeave(): Locator { + return this.page.locator('role=button[name="Leave"]'); + } + + get btnDelete(): Locator { + return this.page.locator('role=button[name="Delete"]'); + } + + get inputName(): Locator { + return this.page.getByRole('dialog').getByRole('textbox', { name: 'Name' }); + } + + get inputTopic(): Locator { + return this.page.getByRole('dialog').getByRole('textbox', { name: 'Topic' }); + } + + get inputAnnouncement(): Locator { + return this.page.getByRole('dialog').getByRole('textbox', { name: 'Announcement' }); + } + + get inputDescription(): Locator { + return this.page.getByRole('dialog').getByRole('textbox', { name: 'Description' }); + } + + get checkboxReadOnly(): Locator { + return this.page.locator('text=Read OnlyOnly authorized users can write new messages >> i'); + } + + get btnSave(): Locator { + return this.page.locator('role=button[name="Save"]'); + } + + get btnModalConfirm(): Locator { + return this.page.locator('#modal-root dialog .rcx-modal__inner .rcx-modal__footer .rcx-button--danger'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab.ts new file mode 100644 index 0000000..0bb0691 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-flextab.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FederationHomeFlextabChannels } from './home-flextab-channels'; +import { FederationHomeFlextabDirectMessageMember } from './home-flextab-dm-member'; +import { FederationHomeFlextabMembers } from './home-flextab-members'; +import { FederationHomeFlextabNotificationPreferences } from './home-flextab-notificationPreferences'; +import { FederationHomeFlextabRoom } from './home-flextab-room'; + +export class FederationHomeFlextab { + private readonly page: Page; + + readonly members: FederationHomeFlextabMembers; + + readonly dmUserMember: FederationHomeFlextabDirectMessageMember; + + readonly room: FederationHomeFlextabRoom; + + readonly channels: FederationHomeFlextabChannels; + + readonly notificationPreferences: FederationHomeFlextabNotificationPreferences; + + constructor(page: Page) { + this.page = page; + this.members = new FederationHomeFlextabMembers(page); + this.room = new FederationHomeFlextabRoom(page); + this.channels = new FederationHomeFlextabChannels(page); + this.notificationPreferences = new FederationHomeFlextabNotificationPreferences(page); + this.dmUserMember = new FederationHomeFlextabDirectMessageMember(page); + } + + get btnAddExistingChannelToTeam(): Locator { + return this.page.locator('role=button[name="Add Existing"]'); + } + + async searchForChannelOnAddChannelToTeam(channelName: string): Promise { + await this.page.locator('//label[contains(text(), "Channels")]/..//input').focus(); + await this.page.waitForTimeout(2000); + await this.page.locator('//label[contains(text(), "Channels")]/..//input').type(channelName); + } + + get userInfoUsername(): Locator { + return this.page.locator('[data-qa="UserInfoUserName"]'); + } +} diff --git a/apps/meteor/tests/e2e/federation/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-sidenav.ts new file mode 100644 index 0000000..8890944 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/page-objects/fragments/home-sidenav.ts @@ -0,0 +1,51 @@ +import type { Locator, Page } from '@playwright/test'; + +export class FederationSidenav { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get checkboxFederatedChannel(): Locator { + return this.page.locator( + '//*[@id="modal-root"]//*[contains(@class, "rcx-field") and contains(text(), "Federated")]/../following-sibling::label/i', + ); + } + + async countFilteredChannelsOnDirectory(name: string): Promise { + await this.page.locator('button[title="Directory"]').click(); + await this.page.locator('button:has-text("Channels")').click(); + await this.page.locator('input[placeholder ="Search Channels"]').focus(); + await this.page.locator('input[placeholder ="Search Channels"]').type(name, { delay: 100 }); + await this.page.waitForTimeout(5000); + + return this.page.locator('table tbody tr').count(); + } + + async openChatWhenHaveMultipleWithTheSameName(name: string, item: number): Promise { + await this.page.locator('role=navigation >> role=button[name=Search]').click(); + await this.page.locator('role=search >> role=searchbox').focus(); + await this.page.locator('role=search >> role=searchbox').type(name); + await this.page.waitForTimeout(2000); + await this.page.locator(`role=search >> role=listbox >> role=link >> text="${name}"`).nth(item).click({ force: true }); + } + + async countRoomsByNameOnSearch(name: string): Promise { + await this.page.locator('role=navigation >> role=button[name=Search]').click(); + await this.page.locator('role=search >> role=searchbox').focus(); + await this.page.locator('role=search >> role=searchbox').type(name); + await this.page.locator(`role=search >> role=listbox >> role=link >> text="${name}"`).waitFor(); + await this.page.waitForTimeout(2000); + + return this.page.locator(`role=search >> role=listbox >> role=link >> text="${name}"`).count(); + } + + async openDMMultipleChat(name: string): Promise { + await this.page.locator('role=navigation >> role=button[name=Search]').click(); + await this.page.locator('role=search >> role=searchbox').focus(); + await this.page.locator('role=search >> role=searchbox').type(name); + await this.page.locator(`role=search >> role=listbox >> role=link >> text="${name}"`).waitFor(); + await this.page.locator(`.rcx-sidebar-item`).nth(1).click({ force: true }); + } +} diff --git a/apps/meteor/tests/e2e/federation/tests/admin/rooms.spec.ts b/apps/meteor/tests/e2e/federation/tests/admin/rooms.spec.ts new file mode 100644 index 0000000..8625e69 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/admin/rooms.spec.ts @@ -0,0 +1,81 @@ +import { faker } from '@faker-js/faker'; + +import * as constants from '../../config/constants'; +import { FederationAdmin } from '../../page-objects/admin'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelUsingAPI } from '../../utils/channel'; +import { formatIntoFullMatrixUsername } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Admin Panel - Rooms', () => { + let poFederationChannelServer1: FederationChannel; + let userFromServer2UsernameOnly: string; + const channelName = faker.string.uuid(); + let poFederationAdmin: FederationAdmin; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer2UsernameOnly = await registerUser(apiServer2); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: constants.RC_SERVER_1, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await createChannelUsingAPI(apiServer2, channelName); + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + await page.close(); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationAdmin = new FederationAdmin(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_2.url, + username: constants.RC_SERVER_2.username, + password: constants.RC_SERVER_2.password, + }, + }); + await page.goto(`${constants.RC_SERVER_2.url}/admin/rooms`); + }); + + test('expect to have the 2 rooms(with the same name) created and showing correctly', async ({ page }) => { + await poFederationAdmin.inputSearchRooms.type(channelName); + await page.waitForTimeout(5000); + await expect(await page.locator(`table tbody tr`).count()).toBe(2); + }); + + test('expect to be able to edit only (name, topic and favorite) when the room is a federated one', async ({ page }) => { + await poFederationAdmin.inputSearchRooms.type(channelName); + await page.waitForTimeout(5000); + await page.locator(`table tbody tr`).first().click(); + + await expect(poFederationAdmin.roomsInputName).not.toBeDisabled(); + await expect(poFederationAdmin.roomsInputTopic).not.toBeDisabled(); + await expect(poFederationAdmin.roomsInputFavorite).not.toBeDisabled(); + + await expect(poFederationAdmin.roomsInputDescription).not.toBeVisible(); + await expect(poFederationAdmin.roomsInputAnnouncement).not.toBeVisible(); + await expect(poFederationAdmin.roomsInputPrivate).not.toBeVisible(); + await expect(poFederationAdmin.roomsInputReadOnly).not.toBeVisible(); + await expect(poFederationAdmin.roomsInputArchived).not.toBeVisible(); + await expect(poFederationAdmin.roomsInputDefault).toBeDisabled(); + await expect(poFederationAdmin.roomsInputFeatured).toBeDisabled(); + await expect(poFederationAdmin.roomsBtnDelete).toBeDisabled(); + await expect(poFederationAdmin.roomsBtnUploadAvatar).toBeDisabled(); + await expect(poFederationAdmin.roomsBtnDefaultAvatar).toBeDisabled(); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/admin/users.spec.ts b/apps/meteor/tests/e2e/federation/tests/admin/users.spec.ts new file mode 100644 index 0000000..9dabe99 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/admin/users.spec.ts @@ -0,0 +1,128 @@ +import * as constants from '../../config/constants'; +import { FederationAdmin } from '../../page-objects/admin'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Admin Panel - Users', () => { + let poFederationChannelServer1: FederationChannel; + let userFromServer2UsernameOnly: string; + let usernameWithDomainFromServer2: string; + + let poFederationAdmin: FederationAdmin; + + test.beforeEach(async ({ page }) => { + poFederationAdmin = new FederationAdmin(page); + }); + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer2UsernameOnly = await registerUser(apiServer2); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + await page.goto(`${constants.RC_SERVER_1.url}/admin/users`); + }); + + test('expect to not be able to edit federated users from the admin panel', async ({ page }) => { + await poFederationAdmin.inputSearchUsers.type(usernameWithDomainFromServer2); + await page.locator(`table tr`).locator(`figure[data-username="${usernameWithDomainFromServer2}"]`).click(); + await expect(poFederationAdmin.tabs.users.btnEdit).toBeDisabled(); + }); + + test('expect to not be able to edit federated users from the admin panel (trying to bypass by url)', async ({ page }) => { + await poFederationAdmin.inputSearchUsers.type(usernameWithDomainFromServer2); + await page.locator(`table tr`).locator(`figure[data-username="${usernameWithDomainFromServer2}"]`).click(); + await expect(poFederationAdmin.tabs.users.btnEdit).toBeDisabled(); + const currentUrl = await page.url(); + await page.goto(`${currentUrl.replace('info', 'edit')}`); + await expect(page.locator('.rcx-box.rcx-box--full.rcx-callout__wrapper')).toBeVisible(); + await expect( + page.locator('.rcx-box.rcx-box--full.rcx-callout__wrapper').locator('.rcx-box.rcx-box--full.rcx-callout__children'), + ).toContainText('Not possible to edit a federated user'); + }); + + test('expect not to have the kebab menu enabled, since there should not be any menu option enabled for federated users', async ({ + page, + }) => { + await poFederationAdmin.inputSearchUsers.type(usernameWithDomainFromServer2); + await page.locator(`table tr`).locator(`figure[data-username="${usernameWithDomainFromServer2}"]`).click(); + await expect(page.locator('[data-testid="menu"]')).not.toBeVisible(); + }); + + test('expect to federated users (remote only) not being counted as a seat cap in an EE environment', async ({ + browser, + page, + apiServer2, + }) => { + const before = parseInt((await page.locator('role=status').textContent())?.replace('Seats Available', '').trim() || '0'); + const newUserFromServer2 = await registerUser(apiServer2); + const page2 = await browser.newPage(); + const poFederationChannelServer1ForUser2 = new FederationChannel(page2); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(newUserFromServer2, constants.RC_SERVER_2.matrixServerName); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page: page2, + poFederationChannelServer: poFederationChannelServer1ForUser2, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + const after = parseInt((await page.locator('role=status').textContent())?.replace('Seats Available', '').trim() || '0'); + expect(before).toEqual(after); + await page2.close(); + }); + + test('expect to federated users (but local ones) being counted as a seat cap in an EE environment', async ({ + browser, + page, + apiServer1, + }) => { + const before = parseInt((await page.locator('role=status').textContent())?.replace('Seats Available', '').trim() || '0'); + const newUserFromServer1 = await registerUser(apiServer1); + const page2 = await browser.newPage(); + const poFederationChannelServer1ForUser2 = new FederationChannel(page2); + const fullUsernameFromServer1 = formatIntoFullMatrixUsername(newUserFromServer1, constants.RC_SERVER_1.matrixServerName); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page: page2, + poFederationChannelServer: poFederationChannelServer1ForUser2, + fullUsernameFromServer: fullUsernameFromServer1, + server: constants.RC_SERVER_1, + }); + await page.reload(); + const after = parseInt((await page.locator('role=status').textContent())?.replace('Seats Available', '').trim() || '0'); + expect(before).not.toEqual(after); + expect(before - 1).toEqual(after); + await page2.close(); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/ce-version/ce.spec.ts b/apps/meteor/tests/e2e/federation/tests/ce-version/ce.spec.ts new file mode 100644 index 0000000..87c6efa --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/ce-version/ce.spec.ts @@ -0,0 +1,88 @@ +import { faker } from '@faker-js/faker'; + +import { CreateNewChannelModal } from '../../../page-objects/fragments/modals'; +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createGroupAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - CE version', () => { + let poFederationChannelServer2: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let fullUsernameFromServer1: string; + let fullUsernameFromServer2: string; + let newChannelModal: CreateNewChannelModal; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + fullUsernameFromServer1 = formatIntoFullMatrixUsername(userFromServer1UsernameOnly, constants.RC_SERVER_1.matrixServerName); + fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer2 = new FederationChannel(page); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationChannelServer2 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_2.url, + username: constants.RC_SERVER_2.username, + password: constants.RC_SERVER_2.password, + }, + }); + await page.goto(`${constants.RC_SERVER_2.url}/home`); + }); + + test('expect to not be able to create channels from the UI', async ({ page }) => { + newChannelModal = new CreateNewChannelModal(page); + await poFederationChannelServer2.navbar.openChat('Channel'); + await newChannelModal.advancedSettingsAccordion.click(); + await expect(newChannelModal.checkboxFederated).toBeDisabled(); + }); + + test('expect to not be able to create DMs from UI inviting external users (an user who does not exists on the server yet)', async () => { + await poFederationChannelServer2.createDirectMessagesUsingModal([fullUsernameFromServer1]); + await poFederationChannelServer2.toastMessage.waitForDisplay({ type: 'error' }); + }); + + test('expect to not be able to create DMs from UI inviting external users (an user who already exists in the server)', async ({ + browser, + }) => { + const page2 = await browser.newPage(); + const poFederationChannelServer1 = new FederationChannel(page2); + await createGroupAndInviteRemoteUserToCreateLocalUser({ + page: page2, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + await poFederationChannelServer2.createDirectMessagesUsingModal([fullUsernameFromServer1]); + await poFederationChannelServer2.toastMessage.waitForDisplay({ type: 'error' }); + }); + + test('expect to not be able to invite federated users to non-federated rooms (using modal)', async () => { + const channelName = faker.string.uuid(); + await poFederationChannelServer2.createNonFederatedPublicChannelAndInviteUsersUsingCreationModal(channelName, [ + fullUsernameFromServer2, + ]); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(constants.RC_SERVER_2.username)).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/channel/dm.spec.ts b/apps/meteor/tests/e2e/federation/tests/channel/dm.spec.ts new file mode 100644 index 0000000..9535417 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/channel/dm.spec.ts @@ -0,0 +1,876 @@ +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Direct Messages', () => { + let poFederationChannelServer1: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.describe('Direct Messages (DMs)', () => { + test.describe('Inviting users using the creation modal', () => { + test('expect to create a DM inviting an user from the Server B who does not exist in Server A yet', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a DM inviting an user from the Server B who already exist in Server A', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createDirectMessagesUsingModal([userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + let createdUsernameFromServer2: string; + + test('expect to create a DM inviting an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const userCreatedWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2, userFromServer1UsernameOnly]); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.sidenav.openDMMultipleChat(userCreatedWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userCreatedWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(adminUsernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + test('expect to create a DM inviting an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createDirectMessagesUsingModal([userFromServer2UsernameOnly, userFromServer1UsernameOnly]); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.sidenav.openDMMultipleChat(usernameOriginalFromServer1OnlyWithDomain); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('Using Slash commands', () => { + test('expect to create a DM inviting an user from the Server B who does not exist in Server A yet', async ({ + browser, + page, + apiServer2, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.navbar.openChat('general'); + + await poFederationChannelServer1.content.dispatchSlashCommand(`/federation dm ${fullUsernameFromServer2}`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a DM inviting an user from the Server B who already exist in Server A', async ({ + browser, + page, + apiServer2, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.navbar.logout(); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + closePageAfterCreation: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.navbar.openChat('general'); + + await poFederationChannelServer1.content.dispatchSlashCommand(`/federation dm @${usernameWithDomainFromServer2}`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(adminUsernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a DM inviting an user from the Server A who does not exist in Server B yet', async ({ + browser, + page, + apiServer1, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const createdUsernameFromServer1 = await registerUser(apiServer1); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: constants.RC_SERVER_2.username, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer1 = formatIntoFullMatrixUsername(createdUsernameFromServer1, constants.RC_SERVER_1.matrixServerName); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer1, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_2.username, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannelServer2.navbar.openChat('general'); + + await poFederationChannelServer2.content.dispatchSlashCommand(`/federation dm ${fullUsernameFromServer1}`); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + await poFederationChannelServer2.content.sendMessage('hello world'); + + await poFederationChannelServer1.navbar.logout(); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: createdUsernameFromServer1, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a DM inviting an user from the Server A who already exist in Server B', async ({ + browser, + page, + apiServer2, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.navbar.logout(); + await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + closePageAfterCreation: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + await poFederationChannelServer2.navbar.openChat('general'); + + await poFederationChannelServer2.content.dispatchSlashCommand(`/federation dm @${adminUsernameWithDomainFromServer1}`); + await page.waitForTimeout(2000); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openUserInfo(); + await poFederationChannelServer2.content.sendMessage('hello world'); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openUserInfo(); + + await expect(poFederationChannelServer2.tabs.dmUserMember.getUserInfoUsername(adminUsernameWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.dmUserMember.getUserInfoUsername(usernameWithDomainFromServer2)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + let createdUsernameFromServer2: string; + + test('expect to create a DM, and invite an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.navbar.openChat('general'); + await poFederationChannelServer1.content.dispatchSlashCommand( + `/federation dm ${fullUsernameFromServer2} @${userFromServer1UsernameOnly}`, + ); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(userFromServer1UsernameOnly); + await poFederationChannelServer1.content.sendMessage('hello world'); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.sidenav.openDMMultipleChat(usernameOriginalFromServer1OnlyWithDomain); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + test('expect to create a DM, and invite an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.navbar.openChat('general'); + + await poFederationChannelServer1.content.dispatchSlashCommand( + `/federation dm @${usernameWithDomainFromServer2} @${userFromServer1UsernameOnly}`, + ); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.sidenav.openDMMultipleChat(usernameOriginalFromServer1OnlyWithDomain); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('DMs', () => { + let createdUsernameFromServer2: string; + let usernameWithDomainFromServer2: string; + let fullUsernameFromServer2: string; + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + test.beforeAll(async ({ browser, apiServer2 }) => { + createdUsernameFromServer2 = await registerUser(apiServer2); + const page = await browser.newPage(); + const poDmFederationServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + await poDmFederationServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + await page.close(); + }); + + test.describe('Visual Elements', () => { + test('expect the calls button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await expect(poFederationChannelServer1.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer1.roomToolbar.btnVideoCall).toBeDisabled(); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await expect(poFederationChannelServer2.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer1.roomToolbar.btnVideoCall).toBeDisabled(); + + await pageForServer2.close(); + }); + + test('expect the discussion button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await expect(poFederationChannelServer1.roomToolbar.btnDiscussion).toBeDisabled(); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await expect(poFederationChannelServer2.roomToolbar.btnDiscussion).toBeDisabled(); + + await pageForServer2.close(); + }); + }); + + test.describe('Owner rights', () => { + test('expect neither the owner nor the invitee to be able to edit the channel info (the channel info button should not be visible)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await expect(poFederationChannelServer1.roomToolbar.btnRoomInfo).not.toBeVisible(); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await expect(poFederationChannelServer2.roomToolbar.btnRoomInfo).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect the owner nor the invitee to be able to add users to DMs (the button to add users should not be visible)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world'); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).not.toBeVisible(); + + await poFederationChannelServer2.navbar.openChat(usernameWithDomainFromServer1); + await expect(poFederationChannelServer2.roomToolbar.btnMembers).not.toBeVisible(); + + await pageForServer2.close(); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/channel/private.spec.ts b/apps/meteor/tests/e2e/federation/tests/channel/private.spec.ts new file mode 100644 index 0000000..f0c0c28 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/channel/private.spec.ts @@ -0,0 +1,1252 @@ +import { faker } from '@faker-js/faker'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createGroupAndInviteRemoteUserToCreateLocalUser, createGroupUsingAPI } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Group Creation', () => { + let poFederationChannelServer1: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let createdGroupName: string; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + createdGroupName = await createGroupAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await poFederationChannelServer1.navbar.logout(); + await page.close(); + }); + + test.describe('Group (Private)', () => { + test.describe('Inviting users using the creation modal', () => { + test('expect to create a group inviting an user from the Server B who does not exist in Server A yet', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a group inviting an user from the Server B who already exist in Server A', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + const createdGroup = faker.string.uuid(); + let createdUsernameFromServer2: string; + + test('expect to create a group inviting an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const userCreatedWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, [ + fullUsernameFromServer2, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(createdGroup); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userCreatedWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(adminUsernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + const createdGroup = faker.string.uuid(); + test('expect to create a group inviting an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, [ + userFromServer2UsernameOnly, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(createdGroup); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With local users only', () => { + const createdGroup = faker.string.uuid(); + + test('Create a group inviting an user from Server A only (locally)', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, [userFromServer1UsernameOnly]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('Inviting users using the Add Members button', () => { + test('expect to create an empty group, and invite an user from the Server B who does not exist in Server A yet', async ({ + browser, + page, + apiServer2, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([fullUsernameFromServer2]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create an empty group, and invite an user from the Server B who already exist in Server A', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer2UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + const createdGroup = faker.string.uuid(); + let createdUsernameFromServer2: string; + + test('expect to create an empty group, and invite an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const userCreatedWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([fullUsernameFromServer2, userFromServer1UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(createdGroup); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userCreatedWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + const createdGroup = faker.string.uuid(); + + test('expect to create an empty group, and invite an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer1UsernameOnly, userFromServer2UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(createdGroup); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With local users only', () => { + const createdGroup = faker.string.uuid(); + + test('Create an empty group, and invite an an user from Server A only (locally)', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer1UsernameOnly]); + + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + }); + + test('expect the user from Server A (locally) is able to access the previous created group', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('Creating rooms with the same name in different servers, and invite users for those rooms', () => { + const groupName = faker.string.uuid(); + let usernameFromServer2: string; + + test('expect to create a group and invite users from a server which already have a group with the exact same name', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + usernameFromServer2 = await registerUser(apiServer2); + await createGroupUsingAPI(apiServer2, groupName); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await expect(await poFederationChannelServer2.getFederationServerName()).toBe(constants.RC_SERVER_1.matrixServerName); + await pageForServer2.close(); + }); + // TODO: skipping this test until we have an extra server ready to test this. + test.skip('expect to create a group in the extra server inviting the same user from the same server as the previous one', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const pageForServerExtra = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const poFederationChannelServerExtra = new FederationChannel(pageForServerExtra); + await createGroupUsingAPI(apiServer2, groupName); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await doLogin({ + page: pageForServerExtra, + server: { + url: constants.RC_EXTRA_SERVER.url, + username: constants.RC_EXTRA_SERVER.username, + password: constants.RC_EXTRA_SERVER.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + await pageForServerExtra.goto(`${constants.RC_EXTRA_SERVER.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServerExtra = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_EXTRA_SERVER.username, + constants.RC_EXTRA_SERVER.matrixServerName, + ); + + await poFederationChannelServerExtra.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_EXTRA_SERVER.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServerExtra)).toBeVisible(); + await pageForServer2.close(); + }); + }); + + test.describe('Visual Elements', () => { + test('expect the calls button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroupName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer1.roomToolbar.btnVideoCall).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnVideoCall).toBeDisabled(); + + await pageForServer2.close(); + }); + + test('expect the discussion button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroupName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnDiscussion).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnDiscussion).toBeDisabled(); + + await pageForServer2.close(); + }); + }); + + test.describe('Owner rights', () => { + test('expect only the owner of the room being able to add users through the UI', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroupName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await expect(poFederationChannelServer1.tabs.members.addUsersButton).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await expect(poFederationChannelServer2.tabs.members.addUsersButton).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect only the room owner being able remove users from the room (user info page)', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroupName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await (await poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).click(); + await poFederationChannelServer1.tabs.members.btnMenuUserInfo.click(); + await expect(poFederationChannelServer1.tabs.members.btnRemoveUserFromRoom).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await (await poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).click(); + await expect(poFederationChannelServer2.tabs.members.btnMenuUserInfo).not.toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.btnRemoveUserFromRoom).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect only the owner of the room being able to edit the channel name AND the channel topic', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroupName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnRoomInfo).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer1.tabs.room.btnEdit).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnRoomInfo).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer2.tabs.room.btnEdit).not.toBeVisible(); + + await poFederationChannelServer1.tabs.room.btnEdit.click(); + await poFederationChannelServer1.tabs.room.inputName.fill(`NAME-EDITED-${createdGroupName}`); + await poFederationChannelServer1.tabs.room.btnSave.click(); + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + // waiting for the toast dismiss + await page.waitForTimeout(3000); + + const nameChangedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + `changed room name to NAME-EDITED-${createdGroupName}`, + ); + await expect(nameChangedSystemMessageServer1).toBeVisible(); + const nameChangedSystemMessageServer2 = await poFederationChannelServer2.content.getSystemMessageByText( + `changed room name to NAME-EDITED-${createdGroupName}`, + ); + await expect(nameChangedSystemMessageServer2).toBeVisible(); + + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + await poFederationChannelServer1.tabs.room.btnEdit.click(); + await poFederationChannelServer1.tabs.room.inputTopic.fill('hello-topic-edited'); + await poFederationChannelServer1.tabs.room.btnSave.click(); + + const topicChangedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + 'changed room topic to hello-topic-edited', + ); + await expect(topicChangedSystemMessageServer1).toBeVisible(); + const topicChangedSystemMessageServer2 = await poFederationChannelServer2.content.getSystemMessageByText( + 'changed room topic to hello-topic-edited', + ); + await expect(topicChangedSystemMessageServer2).toBeVisible(); + + await pageForServer2.close(); + }); + }); + + test.describe('Removing users from room', () => { + test('expect to remove the invitee from the room', async ({ browser, page, apiServer2 }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + + await poFederationChannelServer1.tabs.members.removeUserFromRoom(usernameWithDomainFromServer2); + const removedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + `removed ${usernameWithDomainFromServer2}`, + ); + await expect(removedSystemMessageServer1).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).not.toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await pageForServer2.close(); + }); + }); + + test.describe('Leaving the room', () => { + test('expect the invitee to be able to leave the room', async ({ browser, page, apiServer2 }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const groupName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${groupName}`); + + await poFederationChannelServer1.navbar.openChat(groupName); + + await poFederationChannelServer2.navbar.openChat(groupName); + await poFederationChannelServer2.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer2.tabs.room.btnLeave).toBeVisible(); + + await poFederationChannelServer2.tabs.room.btnLeave.click(); + await poFederationChannelServer2.tabs.room.btnModalConfirm.click(); + await pageForServer2.close(); + + const leftChannelSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText('left the channel'); + await expect(leftChannelSystemMessageServer1).toBeVisible(); + await expect(await (await poFederationChannelServer1.content.getLastSystemMessageName()).textContent()).toBe( + usernameWithDomainFromServer2, + ); + }); + }); + + test.describe('Discussions', () => { + test('expect the federated channels not to be shown as parent channels in discussion creation', async ({ page, browser }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer1.createDiscussionSearchingForChannel(channelName); + await expect(page.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await poFederationChannelServer2.createDiscussionSearchingForChannel(channelName); + await expect(pageForServer2.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + await pageForServer2.close(); + }); + }); + + test.describe('Teams', () => { + test('expect the federated channels not to be shown as parent channels in the input to add rooms to the team on Server A', async ({ + page, + }) => { + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer1.createTeam(teamName); + await poFederationChannelServer1.roomToolbar.openTeamChannels(); + await poFederationChannelServer1.tabs.btnAddExistingChannelToTeam.click(); + await poFederationChannelServer1.tabs.searchForChannelOnAddChannelToTeam(channelName); + await expect(page.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + }); + + test('expect the federated channels not to be shown as parent channels in the input to add rooms to the team on Server B', async ({ + page, + browser, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer2.createTeam(teamName); + await poFederationChannelServer2.roomToolbar.openTeamChannels(); + await poFederationChannelServer2.tabs.btnAddExistingChannelToTeam.click(); + await poFederationChannelServer2.tabs.searchForChannelOnAddChannelToTeam(channelName); + await expect(pageForServer2.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + await pageForServer2.close(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/channel/public.spec.ts b/apps/meteor/tests/e2e/federation/tests/channel/public.spec.ts new file mode 100644 index 0000000..0933f6d --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/channel/public.spec.ts @@ -0,0 +1,1360 @@ +import { faker } from '@faker-js/faker'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelAndInviteRemoteUserToCreateLocalUser, createChannelUsingAPI } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Channel Creation', () => { + let poFederationChannelServer1: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let createdChannelName: string; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + createdChannelName = await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page }) => { + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await poFederationChannelServer1.navbar.logout(); + await page.close(); + }); + + test.describe('Channel (Public)', () => { + test.describe('Inviting users using the creation modal', () => { + test('expect to create a channel inviting an user from the Server B who does not exist in Server A yet', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create a channel inviting an user from the Server B who already exist in Server A', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + const createdChannel = faker.string.uuid(); + let createdUsernameFromServer2: string; + + test('expect to create a channel inviting an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const userCreatedWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, [ + fullUsernameFromServer2, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(createdChannel); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userCreatedWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(adminUsernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + const createdChannel = faker.string.uuid(); + + test('expect to create a channel inviting an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, [ + userFromServer2UsernameOnly, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(createdChannel); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With local users only', () => { + const createdChannel = faker.string.uuid(); + + test('Create a channel inviting an user from Server A only (locally)', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, [ + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('Inviting users using the Add Members button', () => { + test('expect to create an empty channel, and invite an user from the Server B who does not exist in Server A yet', async ({ + browser, + page, + apiServer2, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([fullUsernameFromServer2]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect to create an empty channel, and invite an user from the Server B who already exist in Server A', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer2UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test.describe('With multiple users (when the remote user does not exists in the server A yet)', () => { + const createdChannel = faker.string.uuid(); + let createdUsernameFromServer2: string; + + test('expect to create an empty channel, and invite an user from the Server B who does not exist in Server A yet + an user from Server A only (locally)', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const userCreatedWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([fullUsernameFromServer2, userFromServer1UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(createdChannel); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(createdUsernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userCreatedWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With multiple users (when the user from Server B already exists in Server A)', () => { + const createdChannel = faker.string.uuid(); + + test('expect to create an empty channel, and invite an user from the Server B who already exist in Server A + an user from Server A only (locally)', async ({ + browser, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + const usernameOriginalFromServer1OnlyWithDomain = formatUsernameAndDomainIntoMatrixFormat( + userFromServer1UsernameOnly, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer1UsernameOnly, userFromServer2UsernameOnly]); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await poFederationChannelServer2.navbar.openChat(createdChannel); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameOriginalFromServer1OnlyWithDomain)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await pageForServer2.close(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + + test.describe('With local users only', () => { + const createdChannel = faker.string.uuid(); + + test('Create an empty channel, and invite an an user from Server A only (locally)', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, []); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + await poFederationChannelServer1.tabs.members.addMultipleUsers([userFromServer1UsernameOnly]); + + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + }); + + test('expect the user from Server A (locally) is able to access the previous created channel', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.roomToolbar.openMembersTab(); + await poFederationChannel1ForUser2.tabs.members.showAllUsers(); + + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(userFromServer1UsernameOnly)).toBeVisible(); + await expect(poFederationChannel1ForUser2.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await page2.close(); + }); + }); + }); + + test.describe('Creating rooms with the same name in different servers, and invite users for those rooms', () => { + const channelName = faker.string.uuid(); + let usernameFromServer2: string; + + test('expect to create a group and invite users from a server which already have a group with the exact same name', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + usernameFromServer2 = await registerUser(apiServer2); + await createChannelUsingAPI(apiServer2, channelName); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await expect(await poFederationChannelServer2.sidenav.countRoomsByNameOnSearch(channelName)).toBe(2); + + await pageForServer2.reload(); + await poFederationChannelServer2.sidenav.openChatWhenHaveMultipleWithTheSameName(channelName, 1); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await expect(await poFederationChannelServer2.getFederationServerName()).toBe(constants.RC_SERVER_1.matrixServerName); + await pageForServer2.close(); + }); + // TODO: skipping this test until we have an extra server ready to test this. + test.skip('expect to create a group in the extra server inviting the same user from the same server as the previous one', async ({ + browser, + apiServer2, + page, + }) => { + const pageForServer2 = await browser.newPage(); + const pageForServerExtra = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const poFederationChannelServerExtra = new FederationChannel(pageForServerExtra); + await createChannelUsingAPI(apiServer2, channelName); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await doLogin({ + page: pageForServerExtra, + server: { + url: constants.RC_EXTRA_SERVER.url, + username: constants.RC_EXTRA_SERVER.username, + password: constants.RC_EXTRA_SERVER.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + await pageForServerExtra.goto(`${constants.RC_EXTRA_SERVER.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServerExtra = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_EXTRA_SERVER.username, + constants.RC_EXTRA_SERVER.matrixServerName, + ); + + await poFederationChannelServerExtra.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_EXTRA_SERVER.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameFromServer2)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServerExtra)).toBeVisible(); + await pageForServer2.close(); + }); + }); + + test.describe('Visual Elements', () => { + test('expect the calls button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnVoiceCall).toBeDisabled(); + await expect(poFederationChannelServer1.roomToolbar.btnVideoCall).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnVideoCall).toBeDisabled(); + + await pageForServer2.close(); + }); + + test('expect the discussion button to be disabled', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnDiscussion).toBeDisabled(); + await expect(poFederationChannelServer2.roomToolbar.btnDiscussion).toBeDisabled(); + + await pageForServer2.close(); + }); + }); + + test.describe('Owner rights', () => { + test('expect only the room owner being able to add users through the UI', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await expect(poFederationChannelServer1.tabs.members.addUsersButton).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await expect(poFederationChannelServer2.tabs.members.addUsersButton).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect only the room owner being able remove users from the room (kebab menu)', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await (await poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).hover(); + await (await poFederationChannelServer1.tabs.members.getKebabMenuForUser(usernameWithDomainFromServer2)).click(); + await expect(await poFederationChannelServer1.tabs.members.getOptionFromKebabMenuForUser('removeUser')).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await (await poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).hover(); + await (await poFederationChannelServer2.tabs.members.getKebabMenuForUser(usernameWithDomainFromServer1)).click(); + await expect(await poFederationChannelServer2.tabs.members.getOptionFromKebabMenuForUser('removeUser')).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect only the room owner being able remove users from the room (user info page)', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await (await poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).click(); + await poFederationChannelServer1.tabs.members.btnMenuUserInfo.click(); + await expect(poFederationChannelServer1.tabs.members.btnRemoveUserFromRoom).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnMembers).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await (await poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).click(); + await expect(poFederationChannelServer2.tabs.members.btnMenuUserInfo).not.toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.btnRemoveUserFromRoom).not.toBeVisible(); + + await pageForServer2.close(); + }); + + test('expect only the room owner being able to edit the channel name AND the channel topic', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannelName}`); + + await expect(poFederationChannelServer1.roomToolbar.btnRoomInfo).toBeVisible(); + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer1.tabs.room.btnEdit).toBeVisible(); + + await expect(poFederationChannelServer2.roomToolbar.btnRoomInfo).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer2.tabs.room.btnEdit).not.toBeVisible(); + + await poFederationChannelServer1.tabs.room.btnEdit.click(); + await poFederationChannelServer1.tabs.room.inputName.fill(`NAME-EDITED-${createdChannelName}`); + await poFederationChannelServer1.tabs.room.btnSave.click(); + + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + // waiting for the toast dismiss + await page.waitForTimeout(3000); + + const nameChangedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + `changed room name to NAME-EDITED-${createdChannelName}`, + ); + await expect(nameChangedSystemMessageServer1).toBeVisible(); + const nameChangedSystemMessageServer2 = await poFederationChannelServer2.content.getSystemMessageByText( + `changed room name to NAME-EDITED-${createdChannelName}`, + ); + await expect(nameChangedSystemMessageServer2).toBeVisible(); + + await poFederationChannelServer1.roomToolbar.openRoomInfo(); + await poFederationChannelServer1.tabs.room.btnEdit.click(); + await poFederationChannelServer1.tabs.room.inputTopic.fill('hello-topic-edited'); + await poFederationChannelServer1.tabs.room.btnSave.click(); + + const topicChangedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + 'changed room topic to hello-topic-edited', + ); + await expect(topicChangedSystemMessageServer1).toBeVisible(); + const topicChangedSystemMessageServer2 = await poFederationChannelServer2.content.getSystemMessageByText( + 'changed room topic to hello-topic-edited', + ); + await expect(topicChangedSystemMessageServer2).toBeVisible(); + + await pageForServer2.close(); + }); + }); + + test.describe('Removing users from room', () => { + test('expect to remove the invitee from the room', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const usernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await poFederationChannelServer2.navbar.openChat(channelName); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + + await poFederationChannelServer2.content.sendMessage('hello world'); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + await poFederationChannelServer1.tabs.members.removeUserFromRoom(usernameWithDomainFromServer2); + const removedSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText( + `removed ${usernameWithDomainFromServer2}`, + ); + await expect(removedSystemMessageServer1).toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).not.toBeVisible(); + await expect(poFederationChannelServer1.tabs.members.getUserInList(constants.RC_SERVER_1.username)).toBeVisible(); + await expect(await (await poFederationChannelServer1.content.getLastSystemMessageName()).textContent()).toBe( + constants.RC_SERVER_1.username, + ); + + await poFederationChannelServer2.navbar.openChat(channelName); + const removedSystemMessageServer2 = await poFederationChannelServer2.content.getSystemMessageByText( + `removed ${userFromServer2UsernameOnly}`, + ); + await expect(removedSystemMessageServer2).toBeVisible(); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).not.toBeVisible(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(usernameWithDomainFromServer1)).toBeVisible(); + await expect(await (await poFederationChannelServer2.content.getLastSystemMessageName()).textContent()).toBe( + usernameWithDomainFromServer1, + ); + + await pageForServer2.close(); + }); + }); + + test.describe('Leaving the room', () => { + test('expect the invitee to be able to leave the room', async ({ browser, page }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${channelName}`); + + await poFederationChannelServer1.navbar.openChat(channelName); + + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessage('hello world'); + await poFederationChannelServer2.roomToolbar.openRoomInfo(); + await expect(poFederationChannelServer2.tabs.room.btnLeave).toBeVisible(); + + await poFederationChannelServer2.tabs.room.btnLeave.click(); + await poFederationChannelServer2.tabs.room.btnModalConfirm.click(); + + const leftChannelSystemMessageServer1 = await poFederationChannelServer1.content.getSystemMessageByText('left the channel'); + await expect(leftChannelSystemMessageServer1).toBeVisible(); + await expect(await (await poFederationChannelServer1.content.getLastSystemMessageName()).textContent()).toBe( + usernameWithDomainFromServer2, + ); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + await poFederationChannelServer2.navbar.openChat(channelName); + const leftChannelSystemMessageServer2 = await poFederationChannelServer1.content.getSystemMessageByText('left the channel'); + await expect(leftChannelSystemMessageServer2).toBeVisible(); + await expect(await (await poFederationChannelServer2.content.getLastSystemMessageName()).textContent()).toBe( + userFromServer2UsernameOnly, + ); + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + await expect(poFederationChannelServer2.tabs.members.getUserInList(userFromServer2UsernameOnly)).not.toBeVisible(); + + await pageForServer2.close(); + }); + }); + + test.describe('Discussions', () => { + test('expect the federated channels not to be shown as parent channels in discussion creation', async ({ page, browser }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer1.createDiscussionSearchingForChannel(channelName); + await expect(page.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await poFederationChannelServer2.createDiscussionSearchingForChannel(channelName); + await expect(pageForServer2.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + await pageForServer2.close(); + }); + }); + + test.describe('Teams', () => { + test('expect the federated channels not to be shown as parent channels in the input to add rooms to the team on Server A', async ({ + page, + }) => { + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer1.createTeam(teamName); + await poFederationChannelServer1.roomToolbar.openTeamChannels(); + await poFederationChannelServer1.tabs.btnAddExistingChannelToTeam.click(); + await poFederationChannelServer1.tabs.searchForChannelOnAddChannelToTeam(channelName); + await expect(page.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + }); + + test('expect the federated channels not to be shown as parent channels in the input to add rooms to the team on Server B', async ({ + page, + browser, + }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await poFederationChannelServer2.createTeam(teamName); + await poFederationChannelServer2.roomToolbar.openTeamChannels(); + await poFederationChannelServer2.tabs.btnAddExistingChannelToTeam.click(); + await poFederationChannelServer2.tabs.searchForChannelOnAddChannelToTeam(channelName); + await expect(pageForServer2.locator('div.rcx-option__content', { hasText: 'Empty' })).toBeVisible(); + await page.reload(); + await pageForServer2.close(); + }); + }); + + test.describe('Directory', () => { + test('expect the created channels to be shown in Server B correctly', async ({ apiServer2, browser }) => { + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + const channelName = faker.string.uuid(); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await createChannelUsingAPI(apiServer2, channelName); + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer2]); + + await expect(await poFederationChannelServer2.sidenav.countFilteredChannelsOnDirectory(channelName)).toBe(2); + await pageForServer2.close(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/messaging/dm.spec.ts b/apps/meteor/tests/e2e/federation/tests/messaging/dm.spec.ts new file mode 100644 index 0000000..4292327 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/messaging/dm.spec.ts @@ -0,0 +1,1361 @@ +import type { Page } from '@playwright/test'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - DM Messaging', () => { + let poFederationChannelServer1: FederationChannel; + let poFederationChannelServer2: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let usernameWithDomainFromServer2: string; + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + let pageForServer2: Page; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const page = await browser.newPage(); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + poFederationChannelServer1 = new FederationChannel(page); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + await page.close(); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page, browser }) => { + pageForServer2 = await browser.newPage(); + + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + poFederationChannelServer2 = new FederationChannel(pageForServer2); + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + await pageForServer2.close(); + }); + + test.describe('Messaging - Direct Messages (DMs)', () => { + test.describe('Send message', () => { + test('expect to send a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A'); + }); + + test('expect to send a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + }); + + test.describe('With multiple users', () => { + let createdUsernameFromServer2: string; + let usernameWithDomainFromServer2: string; + + test('expect to send a message from Server A (creator) to Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + createdUsernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(createdUsernameFromServer2, constants.RC_SERVER_2.matrixServerName); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + createdUsernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2, userFromServer1UsernameOnly]); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('hello world from server A (creator)'); + + await poFederationChannelServer2.sidenav.openDMMultipleChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await pageForServer2.close(); + await page2.close(); + }); + + test('expect to send a message from Server A (user 2) to Server B', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.sidenav.openDMMultipleChat(adminUsernameWithDomainFromServer1); + + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2)'); + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2) message 2'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + + await page2.close(); + await pageForServer2.close(); + }); + + test('expect to send a message from Server B to Server A', async ({ page, browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + const pageForServer2 = await browser.newPage(); + const poFederationChannelServer2 = new FederationChannel(pageForServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: createdUsernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannel1ForUser2.sidenav.openDMMultipleChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.sidenav.openDMMultipleChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await pageForServer2.close(); + await page2.close(); + }); + }); + }); + + test.describe('Send "Special" messages', () => { + test('expect to send a message with emojis from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendMessage('😀 😀 hello world 🌎 from server A with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + }); + + test('expect to send a message with emojis from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendMessage('😀 😀 hello world 🌎 from server B with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + }); + + test('expect to send an audio message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send an audio message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send a video message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await page.reload(); + await pageForServer2.reload(); + + await poFederationChannelServer1.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a video message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await page.reload(); + await pageForServer2.reload(); + + await poFederationChannelServer2.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (image) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (image) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (video) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (video) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (pdf) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a file message (pdf) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a message mentioning an user from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A`, + ); + }); + + test('expect to send a message mentioning an user Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.content.sendMessage('hello'); + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer2.content.sendMessage( + `hello @${adminUsernameWithDomainFromServer1}, here's @${userFromServer2UsernameOnly} from Server B`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${constants.RC_SERVER_1.username}, here's ${usernameWithDomainFromServer2} from Server B`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${adminUsernameWithDomainFromServer1}, here's ${userFromServer2UsernameOnly} from Server B`, + ); + }); + + test('expect to send a message with multiple mentions, including @all from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A, @all, @${usernameWithDomainFromServer2}`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A, all, ${usernameWithDomainFromServer2}`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A, all, ${userFromServer2UsernameOnly}`, + ); + }); + }); + + test.describe('Message actions', () => { + test('expect to send a message quoting a message from Server A to Server B', async ({ browser, apiServer2, page }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer1.content.sendMessageUsingEnter(message); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer1.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServerUser2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await page2.close(); + }); + + test('expect to send a message quoting a message Server B to Server A', async ({ browser, apiServer2, page }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer1.content.sendMessageUsingEnter(message); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServerUser2.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServerUser2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await page2.close(); + }); + + test('expect to react a message from Server A to Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServerUser2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + await page2.close(); + }); + + test('expect to react a message from Server B to Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServerUser2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + await page2.close(); + }); + + test('expect to unreact a message from Server A to Server B', async ({ page, apiServer2 }) => { + await poFederationChannelServer2.navbar.logout(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + await poFederationChannelServer1.content.reactToMessage('grin'); + + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer1.content.unreactLastMessage(); + await pageForServer2.reload({ waitUntil: 'domcontentloaded' }); + + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to unreact a message from Server B to Server A', async ({ page, apiServer2 }) => { + await poFederationChannelServer2.navbar.logout(); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer2.content.reactToMessage('slight_smile'); + await poFederationChannelServer2.content.reactToMessage('grin'); + + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer2.content.unreactLastMessage(); + await pageForServer2.reload({ waitUntil: 'domcontentloaded' }); + + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to edit a message from Server A to Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.editLastMessage('message from Server A - Edited'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + await page2.close(); + }); + + test('expect to edit a message from Server B to Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server B'); + + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B'); + + await poFederationChannelServerUser2.content.editLastMessage('message from Server B - Edited'); + + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + await page2.close(); + }); + + test('expect to delete a message from Server A to Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.deleteLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServerUser2.content.lastUserMessage).not.toBeVisible(); + await page2.close(); + }); + + test('expect to delete a message from Server B to Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.deleteLastMessage(); + await page.waitForTimeout(2000); + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server B'); + await poFederationChannelServerUser2.content.deleteLastMessage(); + await poFederationChannelServerUser2.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServerUser2.content.lastUserMessage).not.toBeVisible(); + await page2.close(); + }); + + test('expect to star a message on Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.starLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServer1.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + await page2.close(); + }); + + test('expect to star a message on Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServerUser2.content.starLastMessage(); + await poFederationChannelServerUser2.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServerUser2.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + await page2.close(); + }); + + test('expect to not be able to start a discussion from a message in Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionStartDiscussion).not.toBeVisible(); + await page2.close(); + }); + + test('expect to not be able to start a discussion from a message in Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServerUser2.content.openLastMessageMenu(); + await expect(poFederationChannelServerUser2.content.btnOptionStartDiscussion).not.toBeVisible(); + await page2.close(); + }); + + test('expect to not be able to pin a message in Server A', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionPinMessage).not.toBeVisible(); + await page2.close(); + }); + + test('expect to not be able to pin a message in Server B', async ({ browser, page, apiServer2 }) => { + const page2 = await browser.newPage(); + const poFederationChannelServerUser2 = new FederationChannel(page2); + const usernameFromServer2 = await registerUser(apiServer2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_2.url, + username: usernameFromServer2, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await page2.goto(`${constants.RC_SERVER_2.url}/home`); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(usernameFromServer2, constants.RC_SERVER_2.matrixServerName); + const usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + usernameFromServer2, + constants.RC_SERVER_2.matrixServerName, + ); + await poFederationChannelServer1.createDirectMessagesUsingModal([fullUsernameFromServer2]); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.content.sendMessage('message'); + await poFederationChannelServerUser2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await poFederationChannelServerUser2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServerUser2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServerUser2.content.openLastMessageMenu(); + await expect(poFederationChannelServerUser2.content.btnOptionPinMessage).not.toBeVisible(); + await page2.close(); + }); + }); + + test.describe('Visual Elements', () => { + test('expect to see the file list sent in the DM on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + + await expect(poFederationChannelServer1.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see the file list sent in the DM on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer2.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the DM on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the DM on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the DM on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the DM on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the DM on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the DM on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/messaging/private.spec.ts b/apps/meteor/tests/e2e/federation/tests/messaging/private.spec.ts new file mode 100644 index 0000000..7bc63a7 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/messaging/private.spec.ts @@ -0,0 +1,1100 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createGroupAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Group Messaging', () => { + let poFederationChannelServer1: FederationChannel; + let poFederationChannelServer2: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let createdGroupName: string; + let usernameWithDomainFromServer2: string; + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + let pageForServer2: Page; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + createdGroupName = await createGroupAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page, browser }) => { + pageForServer2 = await browser.newPage(); + + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + poFederationChannelServer2 = new FederationChannel(pageForServer2); + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + await pageForServer2.close(); + }); + + test.describe('Messaging - Group (Private)', () => { + test.describe('Send message', () => { + test('expect to send a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A'); + }); + + test('expect to send a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + }); + + test.describe('With multiple users', () => { + const createdGroup = faker.string.uuid(); + + test('expect to send a message from Server A (creator) to Server B', async ({ browser, page }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(createdGroup, [ + userFromServer2UsernameOnly, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer2.navbar.openChat(createdGroup); + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + + await poFederationChannelServer1.content.sendMessage('hello world from server A (creator)'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await pageForServer2.close(); + await page2.close(); + }); + + test('expect to send a message from Server A (user 2) to Server B', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannelServer2.navbar.openChat(createdGroup); + + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2)'); + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2) message 2'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + + await page2.close(); + await pageForServer2.close(); + }); + + test('expect to send a message from Server B to Server A', async ({ browser, page }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroup); + await poFederationChannel1ForUser2.navbar.openChat(createdGroup); + await poFederationChannelServer2.navbar.openChat(createdGroup); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/group/${createdGroup}`); + + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await pageForServer2.close(); + await page2.close(); + }); + }); + }); + + test.describe('Send "Special" messages', () => { + test('expect to send a message with emojis from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.sendMessage('😀 😀 hello world 🌎 from server A with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + }); + + test('expect to send a message with emojis from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.sendMessage('😀 😀 hello world 🌎 from server B with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + }); + + test('expect to send an audio message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send an audio message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send a video message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await page.reload(); + await pageForServer2.reload(); + + await poFederationChannelServer1.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a video message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await page.reload(); + await pageForServer2.reload(); + + await poFederationChannelServer2.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (image) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (image) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (video) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (video) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (pdf) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a file message (pdf) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a message mentioning an user from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A`, + ); + }); + + test('expect to send a message mentioning an user Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer2.content.sendMessage( + `hello @${adminUsernameWithDomainFromServer1}, here's @${userFromServer2UsernameOnly} from Server B`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${constants.RC_SERVER_1.username}, here's ${usernameWithDomainFromServer2} from Server B`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${adminUsernameWithDomainFromServer1}, here's ${userFromServer2UsernameOnly} from Server B`, + ); + }); + + test('expect to send a message with multiple mentions, including @all from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A, @all, @${usernameWithDomainFromServer2}`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A, all, ${usernameWithDomainFromServer2}`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A, all, ${userFromServer2UsernameOnly}`, + ); + }); + }); + + test.describe('Message actions', () => { + test('expect to send a message quoting a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer2.content.sendMessage(message); + await poFederationChannelServer1.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to send a message quoting a message Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer1.content.sendMessage(message); + await poFederationChannelServer2.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to react a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + }); + + test('expect to react a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + }); + + test('expect to unreact a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + await poFederationChannelServer1.content.reactToMessage('grin'); + await page.reload(); + + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer1.content.unreactLastMessage(); + await pageForServer2.reload(); + + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to unreact a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.content.reactToMessage('slight_smile'); + await poFederationChannelServer2.content.reactToMessage('grin'); + await page.reload(); + + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer2.content.unreactLastMessage(); + await pageForServer2.reload(); + + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to edit a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.editLastMessage('message from Server A - Edited'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + }); + + test('expect to edit a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server B'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B'); + + await poFederationChannelServer2.content.editLastMessage('message from Server B - Edited'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + }); + + test('expect to delete a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.deleteLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServer2.content.lastUserMessage).not.toBeVisible(); + }); + + test('expect to delete a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.deleteLastMessage(); + await poFederationChannelServer2.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServer2.content.lastUserMessage).not.toBeVisible(); + }); + + test('expect to reply a message in DM from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + const message = 'message from Server B'; + await poFederationChannelServer2.content.sendMessageUsingEnter(message); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText(message); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText(message); + + await poFederationChannelServer1.content.replyInDm('reply directly in DM from server A'); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to reply a message in DM Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + const message = 'message from Server A'; + await poFederationChannelServer1.content.sendMessageUsingEnter(message); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText(message); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText(message); + + await poFederationChannelServer2.content.replyInDm('reply directly in DM from server B'); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to star a message on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.starLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServer1.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + }); + + test('expect to star a message on Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.starLastMessage(); + await poFederationChannelServer2.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServer2.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + }); + + test('expect to not be able to start a discussion from a message in Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionStartDiscussion).not.toBeVisible(); + }); + + test('expect to not be able to start a discussion from a message in Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await expect(poFederationChannelServer2.content.btnOptionStartDiscussion).not.toBeVisible(); + }); + + test('expect to not be able to pin a message in Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionPinMessage).not.toBeVisible(); + }); + + test('expect to not be able to pin a message in Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const groupName = faker.string.uuid(); + + await poFederationChannelServer1.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(groupName); + await poFederationChannelServer2.navbar.openChat(groupName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await expect(poFederationChannelServer2.content.btnOptionPinMessage).not.toBeVisible(); + }); + }); + + test.describe('Visual Elements', () => { + test('expect to see the file list sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + + await expect(poFederationChannelServer1.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see the file list sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(poFederationChannelServer2.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see all the mentions sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemMentions).toBeVisible(); + }); + + test('expect to see all the mentions sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemMentions).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/messaging/public.spec.ts b/apps/meteor/tests/e2e/federation/tests/messaging/public.spec.ts new file mode 100644 index 0000000..d3e884c --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/messaging/public.spec.ts @@ -0,0 +1,1094 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - Channel Messaging', () => { + let poFederationChannelServer1: FederationChannel; + let poFederationChannelServer2: FederationChannel; + let userFromServer2UsernameOnly: string; + let userFromServer1UsernameOnly: string; + let createdChannelName: string; + let usernameWithDomainFromServer2: string; + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + let pageForServer2: Page; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer1UsernameOnly = await registerUser(apiServer1); + userFromServer2UsernameOnly = await registerUser(apiServer2); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + createdChannelName = await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page, browser }) => { + pageForServer2 = await browser.newPage(); + + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + poFederationChannelServer2 = new FederationChannel(pageForServer2); + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + await pageForServer2.close(); + }); + + test.describe('Messaging - Channel (Public)', () => { + test.describe('Send message', () => { + test('expect to send a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A'); + }); + + test('expect to send a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + }); + + test.describe('With multiple users', () => { + const createdChannel = faker.string.uuid(); + + test('expect to send a message from Server A (creator) to Server B', async ({ browser, page }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(createdChannel, [ + userFromServer2UsernameOnly, + userFromServer1UsernameOnly, + ]); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + await poFederationChannelServer2.navbar.openChat(createdChannel); + + await poFederationChannelServer1.content.sendMessage('hello world from server A (creator)'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (creator)'); + await pageForServer2.close(); + await page2.close(); + }); + + test('expect to send a message from Server A (user 2) to Server B', async ({ browser }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + + await page2.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + await poFederationChannelServer2.navbar.openChat(createdChannel); + + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2)'); + await poFederationChannel1ForUser2.content.sendMessage('hello world from server A (user 2) message 2'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server A (user 2) message 2'); + + await page2.close(); + await pageForServer2.close(); + }); + + test('expect to send a message from Server B to Server A', async ({ browser, page }) => { + const page2 = await browser.newPage(); + const poFederationChannel1ForUser2 = new FederationChannel(page2); + + await doLogin({ + page: page2, + server: { + url: constants.RC_SERVER_1.url, + username: userFromServer1UsernameOnly, + password: constants.RC_SERVER_1.password, + }, + storeState: false, + }); + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannel); + await poFederationChannelServer2.navbar.openChat(createdChannel); + await poFederationChannel1ForUser2.navbar.openChat(createdChannel); + + await expect(page).toHaveURL(`${constants.RC_SERVER_1.url}/channel/${createdChannel}`); + + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannel1ForUser2.content.lastUserMessageBody).toHaveText('hello world from server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('hello world from server B'); + await pageForServer2.close(); + await page2.close(); + }); + }); + }); + + test.describe('Send "Special" messages', () => { + test('expect to send a message with emojis from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendMessage('😀 😀 hello world 🌎 from server A with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + }); + + test('expect to send a message with emojis from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.content.sendMessage('😀 😀 hello world 🌎 from server B with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + }); + + test('expect to send an audio message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send an audio message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.content.sendAudioRecordedMessage(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Audio record.mp3')).innerText()).toEqual( + 'Audio record.mp3', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('audio')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('audio')).toBeVisible(); + }); + + test('expect to send a video message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a video message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.content.sendVideoRecordedMessage(); + + await expect( + await (await poFederationChannelServer2.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + await expect( + await (await poFederationChannelServer1.content.getLastFileMessageByFileName('Video record.webm')).innerText(), + ).toEqual('Video record.webm'); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (image) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (image) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.content.sendFileMessage('test_image.jpeg'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_image.jpeg'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_image.jpeg'); + }); + + test('expect to send a file message (video) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (video) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.content.sendFileMessage('test_video.mp4'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(await (await poFederationChannelServer2.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + await expect(await (await poFederationChannelServer1.content.getLastFileMessageByFileName('test_video.mp4')).innerText()).toEqual( + 'test_video.mp4', + ); + + await expect(poFederationChannelServer2.content.lastFileMessage.locator('video')).toBeVisible(); + await expect(poFederationChannelServer1.content.lastFileMessage.locator('video')).toBeVisible(); + }); + + test('expect to send a file message (pdf) from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer1.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a file message (pdf) from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.content.sendFileMessage('test_pdf_file.pdf'); + await poFederationChannelServer2.content.btnModalConfirm.click(); + + await expect(poFederationChannelServer2.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + await expect(poFederationChannelServer1.content.lastMessageFileName).toContainText('test_pdf_file.pdf'); + }); + + test('expect to send a message mentioning an user from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A`, + ); + }); + + test('expect to send a message mentioning an user Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.content.inputMessage.type(`@${constants.RC_SERVER_1.username}`, { delay: 100 }); + await poFederationChannelServer2.content.messagePopUpItems + .locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`) + .waitFor(); + await expect( + poFederationChannelServer2.content.messagePopUpItems.locator(`role=listitem >> text="${adminUsernameWithDomainFromServer1}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.type(`@${userFromServer2UsernameOnly}`, { delay: 100 }); + await poFederationChannelServer1.content.messagePopUpItems + .locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`) + .waitFor(); + await expect( + poFederationChannelServer1.content.messagePopUpItems.locator(`role=listitem >> text="${usernameWithDomainFromServer2}"`), + ).toBeVisible(); + + await poFederationChannelServer1.content.inputMessage.fill(''); + await poFederationChannelServer2.content.inputMessage.fill(''); + + await poFederationChannelServer2.content.sendMessage( + `hello @${adminUsernameWithDomainFromServer1}, here's @${userFromServer2UsernameOnly} from Server B`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${constants.RC_SERVER_1.username}, here's ${usernameWithDomainFromServer2} from Server B`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${adminUsernameWithDomainFromServer1}, here's ${userFromServer2UsernameOnly} from Server B`, + ); + }); + + test('expect to send a message with multiple mentions, including @all from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer1.content.sendMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A, @all, @${usernameWithDomainFromServer2}`, + ); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A, all, ${usernameWithDomainFromServer2}`, + ); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A, all, ${userFromServer2UsernameOnly}`, + ); + }); + }); + + test.describe('Message actions', () => { + test('expect to send a message quoting a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer2.content.sendMessage(message); + await poFederationChannelServer1.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to send a message quoting a message Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + const message = `Message for quote - ${Date.now()}`; + + await poFederationChannelServer1.content.sendMessage(message); + await poFederationChannelServer2.content.quoteMessage('this is a quote message'); + + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to react a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + }); + + test('expect to react a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.content.reactToMessage('slight_smile'); + + const reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + const reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + const reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + const reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText('🙂'); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText('1'); + } + }); + + test('expect to unreact a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer1.content.reactToMessage('slight_smile'); + await poFederationChannelServer1.content.reactToMessage('grin'); + + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer1.content.unreactLastMessage(); + await pageForServer2.reload(); + + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to unreact a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + await poFederationChannelServer2.content.reactToMessage('slight_smile'); + await poFederationChannelServer2.content.reactToMessage('grin'); + + let reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + let reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + const reactionsMap: any = { + 0: { + emoji: '🙂', + count: '1', + }, + 1: { + emoji: '😁', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + let reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + let reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText(reactionsMap[i].emoji); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText(reactionsMap[i].count); + } + + await poFederationChannelServer2.content.unreactLastMessage(); + await pageForServer2.reload(); + + reactionsServer2 = await poFederationChannelServer2.content.getAllReactions(); + reactionListExcludingTheActionServer2 = (await reactionsServer2.count()) - 1; + + const reactionsMapAfterUnreaction: any = { + 0: { + emoji: '🙂', + count: '1', + }, + }; + for (let i = 0; i < reactionListExcludingTheActionServer2; i++) { + await expect(reactionsServer2.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer2.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + reactionsServer1 = await poFederationChannelServer1.content.getAllReactions(); + reactionListExcludingTheActionServer1 = (await reactionsServer1.count()) - 1; + for (let i = 0; i < reactionListExcludingTheActionServer1; i++) { + await expect(reactionsServer1.nth(i).locator('span.rcx-message-reactions__emoji.emoji')).toContainText( + reactionsMapAfterUnreaction[i].emoji, + ); + await expect(reactionsServer1.nth(i).locator('div.rcx-message-reactions__counter')).toContainText( + reactionsMapAfterUnreaction[i].count, + ); + } + }); + + test('expect to edit a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.editLastMessage('message from Server A - Edited'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A - Edited'); + }); + + test('expect to edit a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server B'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server B'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B'); + + await poFederationChannelServer2.content.editLastMessage('message from Server B - Edited'); + + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server B - Edited'); + }); + + test('expect to delete a message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.deleteLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServer2.content.lastUserMessage).not.toBeVisible(); + }); + + test('expect to delete a message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.deleteLastMessage(); + await poFederationChannelServer2.toastMessage.waitForDisplay(); + + await expect(poFederationChannelServer1.content.lastUserMessage).not.toBeVisible(); + await expect(poFederationChannelServer2.content.lastUserMessage).not.toBeVisible(); + }); + + test('expect to reply a message in DM from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + const message = 'message from Server B'; + await poFederationChannelServer2.content.sendMessageUsingEnter(message); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText(message); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText(message); + + await poFederationChannelServer1.content.replyInDm('reply directly in DM from server A'); + + await poFederationChannelServer2.navbar.openChat(adminUsernameWithDomainFromServer1); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to reply a message in DM Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + const message = 'message from Server A'; + await poFederationChannelServer1.content.sendMessageUsingEnter(message); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText(message); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText(message); + + await poFederationChannelServer2.content.replyInDm('reply directly in DM from server B'); + + await poFederationChannelServer1.navbar.openChat(usernameWithDomainFromServer2); + + await expect(poFederationChannelServer1.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poFederationChannelServer2.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect to star a message on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.starLastMessage(); + await poFederationChannelServer1.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServer1.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + }); + + test('expect to star a message on Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.starLastMessage(); + await poFederationChannelServer2.toastMessage.waitForDisplay({ type: 'success', message: 'Message has been starred' }); + + await expect(poFederationChannelServer2.content.lastUserMessage.locator('.rcx-icon--name-star-filled')).toBeVisible(); + }); + + test('expect to not be able to start a discussion from a message in Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer1.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionStartDiscussion).not.toBeVisible(); + }); + + test('expect to not be able to start a discussion from a message in Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await expect(poFederationChannelServer2.content.btnOptionStartDiscussion).not.toBeVisible(); + }); + + test('expect to not be able to pin a message in Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await expect(poFederationChannelServer1.content.btnOptionPinMessage).not.toBeVisible(); + }); + + test('expect to not be able to pin a message in Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + const channelName = faker.string.uuid(); + + await poFederationChannelServer1.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [userFromServer2UsernameOnly]); + + await poFederationChannelServer1.navbar.openChat(channelName); + await poFederationChannelServer2.navbar.openChat(channelName); + + await poFederationChannelServer2.content.sendMessageUsingEnter('message from Server A'); + + await expect(poFederationChannelServer1.content.lastUserMessageBody).toHaveText('message from Server A'); + await expect(poFederationChannelServer2.content.lastUserMessageBody).toHaveText('message from Server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await expect(poFederationChannelServer2.content.btnOptionPinMessage).not.toBeVisible(); + }); + }); + + test.describe('Visual Elements', () => { + test('expect to see the file list sent in the channel on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + + await expect(poFederationChannelServer1.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see the file list sent in the channel on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await expect(poFederationChannelServer2.roomToolbar.btnFiles).toBeVisible(); + }); + + test('expect to see all the mentions sent in the channel on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemMentions).toBeVisible(); + }); + + test('expect to see all the mentions sent in the channel on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemMentions).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the channel on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to see all the starred messages sent in the channel on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemStarredMessages).toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the channel on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not to see the pinned messages sent in the channel on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPinnedMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the channel on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer1.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer1.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + + test('expect to not be able to prune messages sent in the channel on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdChannelName); + await poFederationChannelServer2.roomToolbar.openMoreOptions(); + + await expect(poFederationChannelServer2.roomToolbar.menuItemPruneMessages).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/messaging/threads.spec.ts b/apps/meteor/tests/e2e/federation/tests/messaging/threads.spec.ts new file mode 100644 index 0000000..7a07692 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/messaging/threads.spec.ts @@ -0,0 +1,367 @@ +import type { Page } from '@playwright/test'; + +import * as constants from '../../config/constants'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createGroupAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe('Federation - Threads', () => { + let poFederationChannelServer1: FederationChannel; + let poFederationChannelServer2: FederationChannel; + let userFromServer2UsernameOnly: string; + let createdGroupName: string; + let usernameWithDomainFromServer2: string; + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + let pageForServer2: Page; + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer2UsernameOnly = await registerUser(apiServer2); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + createdGroupName = await createGroupAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ page, browser }) => { + pageForServer2 = await browser.newPage(); + + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + poFederationChannelServer2 = new FederationChannel(pageForServer2); + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + await pageForServer2.close(); + }); + + test.describe('Messaging - Threads', () => { + test.describe('Create thread message', () => { + test('expect to send a thread message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await expect(pageForServer2).toHaveURL(/.*thread/); + + await expect(poFederationChannelServer2.content.threadSendToChannelAlso()).not.toBeVisible(); + + await poFederationChannelServer2.content.sendThreadMessage('This is a thread message sent from server B'); + + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText('This is a thread message sent from server B'); + await expect(poFederationChannelServer2.content.lastUserMessage).toContainText('This is a thread message sent from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await expect(page).toHaveURL(/.*thread/); + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText('This is a thread message sent from server B'); + }); + + test('expect to send a thread message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await expect(page).toHaveURL(/.*thread/); + + await expect(poFederationChannelServer1.content.threadSendToChannelAlso()).not.toBeVisible(); + + await poFederationChannelServer1.content.sendThreadMessage('This is a thread message sent from server A'); + + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText('This is a thread message sent from server A'); + await expect(poFederationChannelServer1.content.lastUserMessage).toContainText('This is a thread message sent from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await expect(pageForServer2).toHaveURL(/.*thread/); + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText('This is a thread message sent from server A'); + }); + }); + + test.describe('Send "Special" messages', () => { + test('expect to send a thread message with emojis from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer2.content.sendThreadMessage('😀 😀 hello world 🌎 from server B with emojis 😀 😀'); + + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + await expect(poFederationChannelServer2.content.lastUserMessage).toContainText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText( + '😀 😀 hello world 🌎 from server B with emojis 😀 😀', + ); + }); + + test('expect to send a thread message with emojis from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer1.content.sendThreadMessage('😀 😀 hello world 🌎 from server A with emojis 😀 😀'); + + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + await expect(poFederationChannelServer1.content.lastUserMessage).toContainText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText( + '😀 😀 hello world 🌎 from server A with emojis 😀 😀', + ); + }); + + test('expect to send an audio message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer2.content.sendAudioRecordedInThreadMessage(); + + await expect( + await (await poFederationChannelServer2.content.getLastFileThreadMessageByFileName('Audio record.mp3')).innerText(), + ).toEqual('Audio record.mp3'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await expect( + await (await poFederationChannelServer1.content.getLastFileThreadMessageByFileName('Audio record.mp3')).innerText(), + ).toEqual('Audio record.mp3'); + }); + + test('expect to send an audio message from Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer1.content.sendAudioRecordedInThreadMessage(); + + await expect( + await (await poFederationChannelServer1.content.getLastFileThreadMessageByFileName('Audio record.mp3')).innerText(), + ).toEqual('Audio record.mp3'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await expect( + await (await poFederationChannelServer2.content.getLastFileThreadMessageByFileName('Audio record.mp3')).innerText(), + ).toEqual('Audio record.mp3'); + }); + + test('expect to send a thread message mentioning an user from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer2.content.sendThreadMessage( + `hello @${adminUsernameWithDomainFromServer1}, here's @${userFromServer2UsernameOnly} from Server B`, + ); + + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText( + `hello ${adminUsernameWithDomainFromServer1}, here's ${userFromServer2UsernameOnly} from Server B`, + ); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText( + `hello ${constants.RC_SERVER_1.username}, here's ${usernameWithDomainFromServer2} from Server B`, + ); + }); + + test('expect to send a thread message mentioning an user Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer1.content.sendThreadMessage( + `hello @${usernameWithDomainFromServer2}, here's @${constants.RC_SERVER_1.username} from Server A`, + ); + + await expect(poFederationChannelServer1.content.lastThreadMessageText).toContainText( + `hello ${usernameWithDomainFromServer2}, here's ${constants.RC_SERVER_1.username} from Server A`, + ); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await expect(poFederationChannelServer2.content.lastThreadMessageText).toContainText( + `hello ${userFromServer2UsernameOnly}, here's ${adminUsernameWithDomainFromServer1} from Server A`, + ); + }); + }); + + test.describe('Message actions', () => { + test('expect to send a thread message quoting a thread message from Server A to Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + const message = `Message for quote - ${Date.now()}`; + await poFederationChannelServer1.content.sendMessage('hello world from server A'); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer2.content.sendThreadMessage(message); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer1.content.quoteMessageInsideThread('this is a quote message'); + + await expect(poFederationChannelServer1.content.waitForLastThreadMessageTextAttachmentEqualsText).toContainText(message); + await expect(poFederationChannelServer2.content.waitForLastThreadMessageTextAttachmentEqualsText).toContainText(message); + }); + + test('expect to send a thread message quoting a thread message Server B to Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + await poFederationChannelServer1.navbar.openChat(createdGroupName); + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + const message = `Message for quote - ${Date.now()}`; + await poFederationChannelServer2.content.sendMessage('hello world from server B'); + + await poFederationChannelServer1.content.openLastMessageMenu(); + await poFederationChannelServer1.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer1.content.sendThreadMessage(message); + + await poFederationChannelServer2.content.openLastMessageMenu(); + await poFederationChannelServer2.content.btnOptionReplyInThread.click(); + + await poFederationChannelServer2.content.quoteMessageInsideThread('this is a quote message'); + + await expect(poFederationChannelServer1.content.waitForLastThreadMessageTextAttachmentEqualsText).toContainText(message); + await expect(poFederationChannelServer2.content.waitForLastThreadMessageTextAttachmentEqualsText).toContainText(message); + }); + }); + + test.describe('Visual Elements', () => { + test('expect to see the thread list sent in the group on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdGroupName); + + await expect(poFederationChannelServer1.roomToolbar.btnThreads).toBeVisible(); + }); + + test('expect to see the thread list sent in the group on Server B', async () => { + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer2.navbar.openChat(createdGroupName); + + await expect(poFederationChannelServer2.roomToolbar.btnThreads).toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/tests/user-account/user.spec.ts b/apps/meteor/tests/e2e/federation/tests/user-account/user.spec.ts new file mode 100644 index 0000000..cde7457 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/tests/user-account/user.spec.ts @@ -0,0 +1,130 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import * as constants from '../../config/constants'; +import { FederationAccountProfile } from '../../page-objects/account-profile'; +import { FederationChannel } from '../../page-objects/channel'; +import { doLogin } from '../../utils/auth'; +import { createChannelAndInviteRemoteUserToCreateLocalUser } from '../../utils/channel'; +import { formatIntoFullMatrixUsername, formatUsernameAndDomainIntoMatrixFormat } from '../../utils/format'; +import { registerUser } from '../../utils/register-user'; +import { test, expect, setupTesting, tearDownTesting } from '../../utils/test'; + +test.describe.parallel('Federation - User Account Pannel', () => { + let poFederationChannelServer1: FederationChannel; + let poFederationChannelServer2: FederationChannel; + let poFederationAccountProfileServer1: FederationAccountProfile; + let userFromServer2UsernameOnly: string; + let createdChannelName: string; + let pageForServer2: Page; + let usernameWithDomainFromServer2: string; + const adminUsernameWithDomainFromServer1 = formatUsernameAndDomainIntoMatrixFormat( + constants.RC_SERVER_1.username, + constants.RC_SERVER_1.matrixServerName, + ); + + test.beforeAll(async ({ apiServer1, apiServer2, browser }) => { + await setupTesting(apiServer1); + await setupTesting(apiServer2); + userFromServer2UsernameOnly = await registerUser(apiServer2); + const page = await browser.newPage(); + poFederationChannelServer1 = new FederationChannel(page); + usernameWithDomainFromServer2 = formatUsernameAndDomainIntoMatrixFormat( + userFromServer2UsernameOnly, + constants.RC_SERVER_2.matrixServerName, + ); + const fullUsernameFromServer2 = formatIntoFullMatrixUsername(userFromServer2UsernameOnly, constants.RC_SERVER_2.matrixServerName); + createdChannelName = await createChannelAndInviteRemoteUserToCreateLocalUser({ + page, + poFederationChannelServer: poFederationChannelServer1, + fullUsernameFromServer: fullUsernameFromServer2, + server: constants.RC_SERVER_1, + }); + }); + + test.afterAll(async ({ apiServer1, apiServer2 }) => { + await tearDownTesting(apiServer1); + await tearDownTesting(apiServer2); + }); + + test.beforeEach(async ({ browser, page }) => { + pageForServer2 = await browser.newPage(); + + poFederationAccountProfileServer1 = new FederationAccountProfile(page); + poFederationChannelServer1 = new FederationChannel(page); + await doLogin({ + page, + server: { + url: constants.RC_SERVER_1.url, + username: constants.RC_SERVER_1.username, + password: constants.RC_SERVER_1.password, + }, + }); + + poFederationChannelServer2 = new FederationChannel(pageForServer2); + await doLogin({ + page: pageForServer2, + server: { + url: constants.RC_SERVER_2.url, + username: userFromServer2UsernameOnly, + password: constants.RC_SERVER_2.password, + }, + storeState: false, + }); + + await page.addInitScript(() => { + window.localStorage.setItem('fuselage-localStorage-members-list-type', JSON.stringify('online')); + }); + }); + + test.afterEach(async ({ page }) => { + await poFederationChannelServer1.navbar.logout(); + await page.close(); + }); + + test('expect to be able to edit a name in Server A and it must be reflected on Server B', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const newName = faker.person.fullName(); + + await page.goto(`${constants.RC_SERVER_1.url}/account/profile`); + await poFederationAccountProfileServer1.inputName.fill(newName); + await poFederationAccountProfileServer1.btnSubmit.click(); + + await page.goto(`${constants.RC_SERVER_1.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer2.roomToolbar.openMembersTab(); + await poFederationChannelServer2.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer2.tabs.members.getUserInList(adminUsernameWithDomainFromServer1)).toContainText(newName); + await pageForServer2.close(); + }); + + test('expect to be able to edit a name in Server B and it must be reflected on Server A', async ({ page }) => { + await page.goto(`${constants.RC_SERVER_1.url}/home`); + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + const poFederationAccountProfileServer2 = new FederationAccountProfile(pageForServer2); + + const newName = faker.person.fullName(); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/account/profile`); + await poFederationAccountProfileServer2.inputName.fill(newName); + await poFederationAccountProfileServer2.btnSubmit.click(); + + await pageForServer2.goto(`${constants.RC_SERVER_2.url}/home`); + + await poFederationChannelServer1.navbar.openChat(createdChannelName); + await poFederationChannelServer2.navbar.openChat(createdChannelName); + + await poFederationChannelServer1.roomToolbar.openMembersTab(); + await poFederationChannelServer1.tabs.members.showAllUsers(); + + await expect(poFederationChannelServer1.tabs.members.getUserInList(usernameWithDomainFromServer2)).toContainText(newName); + await pageForServer2.close(); + }); +}); diff --git a/apps/meteor/tests/e2e/federation/utils/auth.ts b/apps/meteor/tests/e2e/federation/utils/auth.ts new file mode 100644 index 0000000..8960458 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/utils/auth.ts @@ -0,0 +1,28 @@ +import type { Page } from '@playwright/test'; + +export const doLogin = async ({ + page, + server, + storageNamePrefix, + storeState, +}: { + page: Page; + server: { + username: string; + password: string; + url: string; + }; + storageNamePrefix?: string; + storeState?: boolean; +}) => { + await page.goto(`${server.url}/login`); + + await page.locator('role=textbox[name=/username/i]').type(server.username); + await page.locator('[name=password]').type(server.password); + await page.locator('role=button[name="Login"]').click(); + + await page.waitForTimeout(1000); + if (storeState) { + await page.context().storageState({ path: `${storageNamePrefix}-session.json` }); + } +}; diff --git a/apps/meteor/tests/e2e/federation/utils/channel.ts b/apps/meteor/tests/e2e/federation/utils/channel.ts new file mode 100644 index 0000000..c3dbd21 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/utils/channel.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { doLogin } from './auth'; +import type { API } from './test'; +import type { FederationChannel } from '../page-objects/channel'; + +const doLoginAndGoToHome = async ( + page: Page, + server: { + url: string; + username: string; + password: string; + }, +): Promise => { + await doLogin({ + page, + server, + }); + + await page.goto(`${server.url}/home`); +}; + +export const createChannelAndInviteRemoteUserToCreateLocalUser = async ({ + page, + poFederationChannelServer, + fullUsernameFromServer, + server, + closePageAfterCreation = true, +}: { + page: Page; + poFederationChannelServer: FederationChannel; + fullUsernameFromServer: string; + server: { + url: string; + username: string; + password: string; + }; + closePageAfterCreation?: boolean; +}): Promise => { + const channelName = faker.string.uuid(); + + await doLoginAndGoToHome(page, server); + + await poFederationChannelServer.createPublicChannelAndInviteUsersUsingCreationModal(channelName, [fullUsernameFromServer]); + if (closePageAfterCreation) { + await page.close(); + } + + return channelName; +}; + +export const createGroupAndInviteRemoteUserToCreateLocalUser = async ({ + page, + poFederationChannelServer, + fullUsernameFromServer, + server, +}: { + page: Page; + poFederationChannelServer: FederationChannel; + fullUsernameFromServer: string; + server: { + url: string; + username: string; + password: string; + matrixServerName: string; + }; +}): Promise => { + const groupName = faker.string.uuid(); + + await doLoginAndGoToHome(page, server); + + await poFederationChannelServer.createPrivateGroupAndInviteUsersUsingCreationModal(groupName, [fullUsernameFromServer]); + await page.close(); + + return groupName; +}; + +export const createGroupUsingAPI = async (api: API, name: string) => { + await api.post('/groups.create', { name }); +}; + +export const createChannelUsingAPI = async (api: API, name: string) => { + await api.post('/channels.create', { name }); +}; diff --git a/apps/meteor/tests/e2e/federation/utils/format.ts b/apps/meteor/tests/e2e/federation/utils/format.ts new file mode 100644 index 0000000..b4de9a8 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/utils/format.ts @@ -0,0 +1,4 @@ +export const formatUsernameAndDomainIntoMatrixFormat = (username: string, domain: string): string => `${username}:${domain}`; + +export const formatIntoFullMatrixUsername = (username: string, domain: string): string => + `@${formatUsernameAndDomainIntoMatrixFormat(username, domain)}`; diff --git a/apps/meteor/tests/e2e/federation/utils/register-user.ts b/apps/meteor/tests/e2e/federation/utils/register-user.ts new file mode 100644 index 0000000..aac75a5 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/utils/register-user.ts @@ -0,0 +1,17 @@ +import { faker } from '@faker-js/faker'; + +import type { API } from './test'; +import * as constants from '../config/constants'; + +export async function registerUser(api: API): Promise { + const username = faker.string.uuid(); + + await api.post('/users.register', { + username, + email: `${username}@test-rc.com`, + pass: constants.RC_SERVER_2.password, + name: username, + }); + + return username; +} diff --git a/apps/meteor/tests/e2e/federation/utils/test.ts b/apps/meteor/tests/e2e/federation/utils/test.ts new file mode 100644 index 0000000..01b2376 --- /dev/null +++ b/apps/meteor/tests/e2e/federation/utils/test.ts @@ -0,0 +1,84 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import type { APIResponse } from '@playwright/test'; +import { test as baseTest } from '@playwright/test'; + +import { API_PREFIX } from '../../config/constants'; +import * as constants from '../config/constants'; + +export type AnyObj = { [key: string]: any }; + +export type API = { + get(uri: string, prefix?: string): Promise; + post(uri: string, data: AnyObj, prefix?: string): Promise; + put(uri: string, data: AnyObj, prefix?: string): Promise; + delete(uri: string, prefix?: string): Promise; +}; + +export type BaseTest = { + apiServer1: API; + apiServer2: API; +}; + +const api = async (request: any, use: any, user: string, password: string, baseUrl: string) => { + const resp = await request.post(`${baseUrl + API_PREFIX}/login`, { data: { user, password } }); + const json = await resp.json(); + + const headers = { + 'X-Auth-Token': json.data.authToken, + 'X-User-Id': json.data.userId, + }; + + await use({ + get(uri: string, prefix = API_PREFIX) { + return request.get(baseUrl + prefix + uri, { headers }); + }, + post(uri: string, data: AnyObj, prefix = API_PREFIX) { + return request.post(baseUrl + prefix + uri, { headers, data }); + }, + put(uri: string, data: AnyObj, prefix = API_PREFIX) { + return request.put(baseUrl + prefix + uri, { headers, data }); + }, + delete(uri: string, prefix = API_PREFIX) { + return request.delete(baseUrl + prefix + uri, { headers }); + }, + }); +}; + +export const test = baseTest.extend({ + apiServer1: async ({ request }, use) => + api(request, use, constants.RC_SERVER_1.username, constants.RC_SERVER_1.password, constants.RC_SERVER_1.url), + apiServer2: async ({ request }, use) => + api(request, use, constants.RC_SERVER_2.username, constants.RC_SERVER_2.password, constants.RC_SERVER_2.url), +}); + +export const { expect } = test; + +export const setupTesting = async (api: API) => { + await api.post('/settings/Message_AudioRecorderEnabled', { value: true }); + await api.post('/settings/Accounts_ManuallyApproveNewUsers', { value: false }); + await api.post('/settings/API_Enable_Rate_Limiter', { value: false }); + await api.post('/settings/DDP_Rate_Limit_IP_Enabled', { value: false }); + await api.post('/settings/DDP_Rate_Limit_User_Enabled', { value: false }); + await api.post('/settings/DDP_Rate_Limit_Connection_Enabled', { value: false }); + await api.post('/settings/DDP_Rate_Limit_Connection_Interval_Time', { value: false }); + await api.post('/settings/DDP_Rate_Limit_User_By_Method_Enabled', { value: false }); + await api.post('/settings/DDP_Rate_Limit_Connection_By_Method_Enabled', { value: false }); + await api.post('/settings/Accounts_RegistrationForm', { value: 'Public' }); + await api.post('/settings/UI_Use_Real_Name', { value: false }); + await api.post('/settings/Rate_Limiter_Limit_RegisterUser', { value: 10 }); + await api.post('/settings/Hide_System_Messages', { value: [] }); + await api.post('/permissions.update', { permissions: [{ _id: 'force-delete-message', roles: ['admin', 'user'] }] }); +}; + +export const tearDownTesting = async (api: API) => { + await api.post('/settings/Accounts_ManuallyApproveNewUsers', { value: true }); + await api.post('/settings/API_Enable_Rate_Limiter', { value: true }); + await api.post('/settings/DDP_Rate_Limit_IP_Enabled', { value: true }); + await api.post('/settings/DDP_Rate_Limit_User_Enabled', { value: true }); + await api.post('/settings/DDP_Rate_Limit_Connection_Enabled', { value: true }); + await api.post('/settings/DDP_Rate_Limit_Connection_Interval_Time', { value: true }); + await api.post('/settings/DDP_Rate_Limit_User_By_Method_Enabled', { value: true }); + await api.post('/settings/DDP_Rate_Limit_Connection_By_Method_Enabled', { value: true }); + await api.post('/settings/Rate_Limiter_Limit_RegisterUser', { value: 1 }); + await api.post('/settings/Accounts_RegistrationForm', { value: 'Disabled' }); +}; diff --git a/apps/meteor/tests/e2e/file-upload.spec.ts b/apps/meteor/tests/e2e/file-upload.spec.ts new file mode 100644 index 0000000..80abe97 --- /dev/null +++ b/apps/meteor/tests/e2e/file-upload.spec.ts @@ -0,0 +1,248 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { FileUploadWarningModal } from './page-objects/fragments/modals'; +import { createTargetChannel } from './utils'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +const TEST_FILE_TXT = 'any_file.txt'; +const TEST_FILE_LST = 'lst-test.lst'; +const TEST_FILE_DRAWIO = 'diagram.drawio'; +const TEST_EMPTY_FILE = 'empty_file.txt'; + +test.describe.serial('file-upload', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + }); + + test('should cancel uploaded file attached to message composer', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.removeFileByName(TEST_FILE_TXT); + + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toBeVisible(); + }); + + test('should send file with name updated', async () => { + const updatedFileName = `edited_${TEST_FILE_TXT}`; + await poHomeChannel.content.dragAndDropTxtFile(); + + await test.step('update file name and send', async () => { + await poHomeChannel.composer.getFileByName(TEST_FILE_TXT).click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + + await expect(poHomeChannel.composer.getFileByName(updatedFileName)).toBeVisible(); + await poHomeChannel.composer.btnSend.click(); + }); + + await expect(poHomeChannel.content.getLastMessageByFileName(updatedFileName)).toContainText(updatedFileName); + }); + + test('should attach multiple files and send one per message', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.dragAndDropLstFile(); + + await expect(poHomeChannel.composer.inputMessage).toBeFocused(); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_LST)).toBeVisible(); + + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessageDownloadLink).toHaveCount(1); + }); + + test('should not be able to attach files when editing a message', async () => { + await poHomeChannel.content.sendMessage('message to be edited'); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + + await poHomeChannel.content.dragAndDropTxtFile({ waitForResponse: false }); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toBeVisible(); + }); + + test('should send lst file successfully', async () => { + await poHomeChannel.content.dragAndDropLstFile(); + await poHomeChannel.composer.inputMessage.fill('lst_description'); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.getFileDescription).toHaveText('lst_description'); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_LST)).toBeVisible(); + }); + + test('should send drawio (unknown media type) file successfully', async ({ page }) => { + await page.reload(); + await poHomeChannel.content.sendFileMessage(TEST_FILE_DRAWIO); + await poHomeChannel.composer.inputMessage.fill('drawio_description'); + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.getFileDescription).toHaveText('drawio_description'); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_DRAWIO)).toBeVisible(); + }); + + test('should not to send drawio file (unknown media type) when the default media type is blocked', async ({ api, page }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'application/octet-stream'); + + await page.reload(); + await poHomeChannel.content.sendFileMessage(TEST_FILE_DRAWIO, { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_DRAWIO)).toHaveAttribute('readonly'); + }); + + test('should be able to remove file from composer before sending', async () => { + await poHomeChannel.content.sendFileMessage(TEST_FILE_TXT); + await poHomeChannel.content.sendFileMessage(TEST_FILE_LST); + + await poHomeChannel.composer.removeFileByName(TEST_FILE_TXT); + + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_LST)).toBeVisible(); + + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage).not.toContainText(TEST_FILE_TXT); + await expect(poHomeChannel.content.lastUserMessage).toContainText(TEST_FILE_LST); + }); + + test('should respect the maximum number of files allowed per message: 10', async () => { + const files = new Array(10).fill('number1.png'); + + await Promise.all(files.map((file) => poHomeChannel.content.sendFileMessage(file))); + await poHomeChannel.content.dragAndDropTxtFile({ waitForResponse: false }); + + await expect(poHomeChannel.composer.getFilesInComposer()).toHaveCount(10); + await expect(poHomeChannel.composer.getFileByName('any_file.txt')).not.toBeVisible(); + }); + + test('should upload file in composer after recording video message', async ({ context }) => { + await context.grantPermissions(['camera', 'microphone']); + await poHomeChannel.gotoChannel(targetChannel); + + await test.step('should be able to record a video with text content in composer ', async () => { + await poHomeChannel.composer.inputMessage.fill('this is a message with video message'); + await expect(poHomeChannel.composer.btnVideoMessage).toBeEnabled(); + }); + + await poHomeChannel.composer.btnVideoMessage.click(); + await poHomeChannel.composer.videoRecorderPopup.record(); + + await expect(poHomeChannel.composer.getFileByName('Video record.webm')).toBeVisible(); + }); + + test.describe.serial('thread multiple file upload', () => { + test('should be able to remove file from thread composer before sending', async () => { + await poHomeChannel.content.sendMessage('this is a message for thread reply'); + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendFileMessageToThread(TEST_FILE_TXT); + await poHomeChannel.content.sendFileMessageToThread(TEST_FILE_LST); + + await poHomeChannel.threadComposer.removeFileByName(TEST_FILE_LST); + + await expect(poHomeChannel.threadComposer.getFileByName(TEST_FILE_TXT)).toBeVisible(); + await expect(poHomeChannel.threadComposer.getFileByName(TEST_FILE_LST)).not.toBeVisible(); + }); + }); + + test.describe.serial('file upload fails', () => { + let fileUploadWarningModal: FileUploadWarningModal; + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'application/octet-stream'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + }); + + test('should open warning modal when all file uploads fail', async ({ page }) => { + fileUploadWarningModal = new FileUploadWarningModal(page.getByRole('dialog', { name: 'Warning' })); + + await poHomeChannel.content.sendMultipleFilesMessage([TEST_EMPTY_FILE, TEST_FILE_DRAWIO], { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(TEST_EMPTY_FILE)).toHaveAttribute('readonly'); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_DRAWIO)).toHaveAttribute('readonly'); + + await poHomeChannel.composer.btnSend.click(); + await fileUploadWarningModal.waitForDisplay(); + + await expect(fileUploadWarningModal.getContent('2 files failed to upload')).toBeVisible(); + await expect(fileUploadWarningModal.btnOk).toBeVisible(); + await expect(fileUploadWarningModal.btnSendAnyway).not.toBeVisible(); + }); + + test('should handle multiple files with one failing upload', async ({ page }) => { + fileUploadWarningModal = new FileUploadWarningModal(page.getByRole('dialog', { name: 'Are you sure' })); + + await test.step('should only mark as "Upload failed" the specific file that failed to upload', async () => { + await poHomeChannel.content.sendMultipleFilesMessage([TEST_FILE_TXT, TEST_EMPTY_FILE], { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(TEST_EMPTY_FILE)).toHaveAttribute('readonly'); + + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toHaveAttribute('readonly'); + }); + + await test.step('should open warning modal', async () => { + await poHomeChannel.composer.btnSend.click(); + await fileUploadWarningModal.waitForDisplay(); + + await expect(fileUploadWarningModal.getContent('1 file failed to upload')).toBeVisible(); + }); + + await test.step('should close modal when clicking "Cancel" button', async () => { + await fileUploadWarningModal.cancel(); + + await expect(poHomeChannel.composer.getFileByName(TEST_EMPTY_FILE)).toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).toBeVisible(); + }); + + await test.step('should send message with the valid file when confirming "Send anyway"', async () => { + await poHomeChannel.composer.btnSend.click(); + await fileUploadWarningModal.confirmSend(); + + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toBeVisible(); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_TXT)).toContainText(TEST_FILE_TXT); + await expect(poHomeChannel.composer.getFileByName(TEST_EMPTY_FILE)).not.toBeVisible(); + }); + }); + }); +}); + +test.describe('file-upload-not-member', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + }); + + test('should not be able to upload if not a member', async () => { + await poHomeChannel.content.dragAndDropTxtFile({ waitForResponse: false }); + await expect(poHomeChannel.composer.getFileByName(TEST_FILE_TXT)).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/files-management.spec.ts b/apps/meteor/tests/e2e/files-management.spec.ts new file mode 100644 index 0000000..5b37ec4 --- /dev/null +++ b/apps/meteor/tests/e2e/files-management.spec.ts @@ -0,0 +1,54 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +const TEST_FILE_TXT = 'any_file.txt'; + +test.describe.serial('files-management', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + }); + + test('should send a file and manage it in the list', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_TXT)).toBeVisible(); + + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemFiles.click(); + await expect(poHomeChannel.tabs.files.getFileByName(TEST_FILE_TXT)).toBeVisible(); + + await test.step('should open download dialog when clicking on the file in the files tab', async () => { + const [download] = await Promise.all([ + poHomeChannel.page.waitForEvent('download'), + poHomeChannel.tabs.files.getFileByName(TEST_FILE_TXT).click(), + ]); + + expect(download).toBeDefined(); + expect(download.suggestedFilename()).toBe(TEST_FILE_TXT); + }); + + await test.step('should delete the file from the list', async () => { + await poHomeChannel.tabs.files.deleteFile(TEST_FILE_TXT); + + await expect(poHomeChannel.tabs.files.getFileByName(TEST_FILE_TXT)).toHaveCount(0); + await expect(poHomeChannel.content.getLastMessageByFileName(TEST_FILE_TXT)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts new file mode 100644 index 0000000..70885f1 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts @@ -0,0 +1,21 @@ +import { request } from '@playwright/test'; + +import { Users } from './userStates'; +import { BASE_API_URL } from '../config/constants'; + +export default async function addCustomOAuth(): Promise { + const api = await request.newContext(); + + const headers = { + 'X-Auth-Token': Users.admin.data.loginToken, + 'X-User-Id': Users.admin.data._id, + }; + + await api.post(`${BASE_API_URL}/settings.addCustomOAuth`, { data: { name: 'Test' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test`, { data: { value: false }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-url`, { data: { value: 'https://rocket.chat' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-login_style`, { data: { value: 'redirect' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-id`, { data: { value: 'abc' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-secret`, { data: { value: 'def' }, headers }); + await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-authorize_path`, { data: { value: '/authorize' }, headers }); +} diff --git a/apps/meteor/tests/e2e/fixtures/collections/users.ts b/apps/meteor/tests/e2e/fixtures/collections/users.ts new file mode 100644 index 0000000..460b9da --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/collections/users.ts @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker'; +import type { IUser } from '@rocket.chat/core-typings'; + +import { DEFAULT_USER_CREDENTIALS } from '../../config/constants'; +import type { IUserState } from '../userStates'; + +type UserFixture = IUser & { + username: string; + __rooms: string[]; +}; + +export function createUserFixture(user: IUserState): UserFixture { + const { username, hashedToken, loginExpire, e2e } = user.data; + + return { + _id: `${username}`, + type: 'user', + active: true, + emails: [{ address: `${username}@email.com`, verified: true }], + roles: ['user'], + name: username, + lastLogin: new Date(), + statusConnection: 'offline', + utcOffset: -3, + username, + services: { + password: { bcrypt: DEFAULT_USER_CREDENTIALS.bcrypt }, + email2fa: { enabled: true, changedAt: new Date() }, + email: { + verificationTokens: [ + { + token: faker.string.uuid(), + address: `${username}@email.com`, + when: new Date(), + }, + ], + }, + resume: { + loginTokens: [ + { + when: loginExpire, + hashedToken, + }, + ], + }, + emailCode: { code: '', attempts: 0, expire: new Date() }, + }, + createdAt: new Date(), + _updatedAt: new Date(), + __rooms: ['GENERAL'], + e2e, + }; +} diff --git a/apps/meteor/tests/e2e/fixtures/createAuxContext.ts b/apps/meteor/tests/e2e/fixtures/createAuxContext.ts new file mode 100644 index 0000000..c32d7c6 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/createAuxContext.ts @@ -0,0 +1,19 @@ +import type { Browser, Page } from '@playwright/test'; + +import type { IUserState } from './userStates'; + +export const createAuxContext = async ( + browser: Browser, + userState: IUserState, + route = '/', + waitForMainContent = true, +): Promise<{ page: Page }> => { + const page = await browser.newPage({ storageState: userState.state }); + await page.goto(route); + + if (waitForMainContent) { + await page.locator('#main-content').waitFor(); + } + + return { page }; +}; diff --git a/apps/meteor/tests/e2e/fixtures/files/another_file.txt b/apps/meteor/tests/e2e/fixtures/files/another_file.txt new file mode 100644 index 0000000..ad7bbe0 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/another_file.txt @@ -0,0 +1 @@ +another_file diff --git a/apps/meteor/tests/e2e/fixtures/files/any_file.txt b/apps/meteor/tests/e2e/fixtures/files/any_file.txt new file mode 100644 index 0000000..1bd5293 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/any_file.txt @@ -0,0 +1 @@ +any_file diff --git a/apps/meteor/tests/e2e/fixtures/files/csv_import.zip b/apps/meteor/tests/e2e/fixtures/files/csv_import.zip new file mode 100644 index 0000000..63c502a Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/csv_import.zip differ diff --git a/apps/meteor/tests/e2e/fixtures/files/csv_import_rooms.csv b/apps/meteor/tests/e2e/fixtures/files/csv_import_rooms.csv new file mode 100644 index 0000000..4d605b7 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/csv_import_rooms.csv @@ -0,0 +1,4 @@ +"imported-csv-group","billy.bob","private","" +"imported-csv-channel","billy.bob","public","" +"imported-csv-with-members-group","billy.bob","private","billy.bob;billy.joe" +"imported-csv-with-members-channel","billy.bob","public","billy.bob;billy.joe" diff --git a/apps/meteor/tests/e2e/fixtures/files/csv_import_users.csv b/apps/meteor/tests/e2e/fixtures/files/csv_import_users.csv new file mode 100644 index 0000000..433133e --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/csv_import_users.csv @@ -0,0 +1,3 @@ +billy.bob, billy.bob@example.com, Billy Bob Jr. +billy.joe, billy.joe@example.com, Billy Joe Jr. +billy.billy, billy.billy@example.com, Billy Billy Jr. \ No newline at end of file diff --git a/apps/meteor/tests/e2e/fixtures/files/diagram.drawio b/apps/meteor/tests/e2e/fixtures/files/diagram.drawio new file mode 100644 index 0000000..a86c267 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/diagram.drawio @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/meteor/tests/e2e/fixtures/files/dm_messages.csv b/apps/meteor/tests/e2e/fixtures/files/dm_messages.csv new file mode 100644 index 0000000..2837e1d --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/dm_messages.csv @@ -0,0 +1,2 @@ +"billy.joe","billy.bob","1479162481336","this is a test message" +"billy.bob","billy.joe","1479162481654","this is another message, a test message" diff --git a/apps/meteor/tests/e2e/fixtures/files/empty_file.txt b/apps/meteor/tests/e2e/fixtures/files/empty_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/apps/meteor/tests/e2e/fixtures/files/iframe-login.html b/apps/meteor/tests/e2e/fixtures/files/iframe-login.html new file mode 100644 index 0000000..59d0c5f --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/iframe-login.html @@ -0,0 +1,37 @@ + + + + + Iframe Login + + + +

Iframe Authentication Login Form

+
+
+

+ +
+

+ + +
+
+ + diff --git a/apps/meteor/tests/e2e/fixtures/files/lst-test.lst b/apps/meteor/tests/e2e/fixtures/files/lst-test.lst new file mode 100644 index 0000000..a62b7b2 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/lst-test.lst @@ -0,0 +1,6 @@ +# Comment + +# Name Age +Tom 23 +Marcus 22 +Bryan 21 \ No newline at end of file diff --git a/apps/meteor/tests/e2e/fixtures/files/number1.png b/apps/meteor/tests/e2e/fixtures/files/number1.png new file mode 100644 index 0000000..4359ce1 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number1.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/number2.png b/apps/meteor/tests/e2e/fixtures/files/number2.png new file mode 100644 index 0000000..b4ed8a1 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number2.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/number3.png b/apps/meteor/tests/e2e/fixtures/files/number3.png new file mode 100644 index 0000000..7a29d41 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number3.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/number4.png b/apps/meteor/tests/e2e/fixtures/files/number4.png new file mode 100644 index 0000000..7bd2b90 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number4.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/number5.png b/apps/meteor/tests/e2e/fixtures/files/number5.png new file mode 100644 index 0000000..0ea5095 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number5.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/number6.png b/apps/meteor/tests/e2e/fixtures/files/number6.png new file mode 100644 index 0000000..95e0d42 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/number6.png differ diff --git a/apps/meteor/tests/e2e/fixtures/files/sample-audio.mp3 b/apps/meteor/tests/e2e/fixtures/files/sample-audio.mp3 new file mode 100644 index 0000000..fa991af Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/sample-audio.mp3 differ diff --git a/apps/meteor/tests/e2e/fixtures/files/slack_export_users.csv b/apps/meteor/tests/e2e/fixtures/files/slack_export_users.csv new file mode 100644 index 0000000..b7e8dc1 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/slack_export_users.csv @@ -0,0 +1,4 @@ +username,email,status,billing-active,has-2fa,has-sso,userid,fullname,displayname,expiration-timestamp +user4.test,user4@email.com.br,Primary Owner,0,0,0,U03TVFGMHUY,user4.test,, +user5.test,user5@email.com.br,Primary Owner,0,0,0,U0G9QF9C6,user5.test,, +user6.test,user6@email.com.br,Primary Owner,0,0,0,U03TVFGMH,user6.test,, diff --git a/apps/meteor/tests/e2e/fixtures/files/test-app_0.0.1.zip b/apps/meteor/tests/e2e/fixtures/files/test-app_0.0.1.zip new file mode 100644 index 0000000..820f664 Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/test-app_0.0.1.zip differ diff --git a/apps/meteor/tests/e2e/fixtures/files/test-image.jpeg b/apps/meteor/tests/e2e/fixtures/files/test-image.jpeg new file mode 100644 index 0000000..4391d6f Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/test-image.jpeg differ diff --git a/apps/meteor/tests/e2e/fixtures/files/test_pdf_file.pdf b/apps/meteor/tests/e2e/fixtures/files/test_pdf_file.pdf new file mode 100644 index 0000000..1f72c0e Binary files /dev/null and b/apps/meteor/tests/e2e/fixtures/files/test_pdf_file.pdf differ diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts new file mode 100644 index 0000000..46b0197 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -0,0 +1,84 @@ +import type { ISetting, IUser } from '@rocket.chat/core-typings'; +import { MongoClient } from 'mongodb'; + +import * as constants from '../config/constants'; +import { createUserFixture } from './collections/users'; +import { Users } from './userStates'; + +export default async function injectInitialData() { + const connection = await MongoClient.connect(constants.URL_MONGODB); + + const usersFixtures = [ + createUserFixture(Users.user1), + createUserFixture(Users.user2), + createUserFixture(Users.user3), + createUserFixture(Users.userE2EE), + createUserFixture(Users.userNotAllowedByApp), + ]; + + await Promise.all( + usersFixtures.map((user) => + connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), + ), + ); + + await connection + .db() + .collection('users') + .updateOne( + { username: Users.admin.data.username }, + { $addToSet: { 'services.resume.loginTokens': { when: Users.admin.data.loginExpire, hashedToken: Users.admin.data.hashedToken } } }, + ); + + await Promise.all( + [ + { + _id: 'API_Enable_Rate_Limiter_Dev', + value: false, + }, + { + _id: 'Show_Setup_Wizard', + value: 'completed', + }, + { + _id: 'Country', + value: 'brazil', + }, + { + _id: 'Organization_Type', + value: 'community', + }, + { + _id: 'Industry', + value: 'aerospaceDefense', + }, + { + _id: 'Size', + value: 0, + }, + { + _id: 'Organization_Name', + value: 'any_name', + }, + { + _id: 'API_Enable_Rate_Limiter_Dev', + value: false, + }, + { + _id: 'Accounts_OAuth_Google', + value: false, + }, + { + _id: 'Livechat_Require_Contact_Verification', + value: 'never', + }, + ].map((setting) => + connection + .db() + .collection('rocketchat_settings') + .updateOne({ _id: setting._id }, { $set: { value: setting.value } }), + ), + ); + + return { usersFixtures }; +} diff --git a/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts b/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts new file mode 100644 index 0000000..ed0c6fe --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts @@ -0,0 +1,8 @@ +import type { Page, Response } from '@playwright/test'; + +const isMediaResponse = (response: Response) => + /api\/v1\/rooms\.media(?:\/|\?|$)/.test(response.url()) && response.request().method() === 'POST'; + +export const createMediaResponsePromise = (page: Page) => { + return page.waitForResponse((response) => isMediaResponse(response)); +}; diff --git a/apps/meteor/tests/e2e/fixtures/userStates.ts b/apps/meteor/tests/e2e/fixtures/userStates.ts new file mode 100644 index 0000000..6890955 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/userStates.ts @@ -0,0 +1,155 @@ +import crypto from 'crypto'; + +import type { BrowserContextOptions, Page } from '@playwright/test'; + +import { BASE_URL } from '../config/constants'; + +export type IUserState = { + data: { + _id: string; + username: string; + loginToken: string; + loginExpire: Date; + hashedToken: string; + e2e?: { + private_key: string; + public_key: string; + }; + e2ePassword?: string; + }; + state: Exclude; +}; + +const e2eeData: Record = { + userE2EE: { + server: { + private_key: + '{"$binary":"hvTpjm9S6L6sBARrdqjjzkuI8fmoIYOpEkXuLS5nW50S8WGG73deDRQXM6GcFZQCi5iJXkRICJCXyVnDcr6HE5vVbQkDyXe6qx/cHYqKKA9DajAzE8Za4VrAPH7dU+UijWy2YZhBDhfcmfkJ4iRFhLuZSoetmtLSs3MdgoAtp4NTO2LAkiAki83iRKXHLE+CMf1EYxF8S359x/mScMNIgaasYRM+HVJaayEZMOwj2hlDTawcX6hXKdyxr/0PwKdpFUtPAxwmAIaW2iSkY5L4W5U8kQKvPVQo8VmYNZkoHtTKuNFBw+TrIAprsPwZIl3/2BM4TgxCU6hrQsewoZQcYKtmu9uEUYk38bQ/DKz8B3xD3oPG8aviY+OSQIhkVJ9vb1L1xrNud9sOxurq49N91KA40ooO0YDHF0kBwj8Ue8BlLRNIlVXCiOy9hxBxo3DC6uDKFgJdAae04yFWGamafHpt8nX2dDxTi54k6vieNdQpqLLthS7WGiLEaUR5RaFQDVUBXgFGdcrIfwwKcZy7Uk/PTU/ETjf76mG2Mb3lGAdJc03YUT4yBrcWoQpusPWdJ+7I/bCFjGa0ZSj0TGQ6R+CGSPFc5FK+qrsUolKDUgOTGrtLnpuMoCtuhvwe3JfvdiEHGFiKJZhrEV7ggVm48IUC5P14DnSX8TFveqV0VyF/r9pT7/BRdTihT/t3Inq5lEISGW7fQ07LUyd+unSHJ+Ex5riE6A+Ag8t/MPOAAt6adGhkAot9jme97D/wJA/YMnLPoQNaRAHtlv7laOPuR2FCJjS3Q7L+o/BWOi4I6TJ3VIvKd5JgQBz0CVTkgFVGGgvTXer5FljK3r5YBGBJBx7GVEJ9YSFm5fYyKXG9dFpGG22UE3DNq73pAVc0xqSYnDmz8s3XDNiV0nMp31EZFoP3K4rTFdzmMe+8LQqE4TvIX39rPBLrD7YVpS3xKNoaDsdCQPnqo10+plpaxS+0768prZT9DFlfYuijOu09V8P2tny/8Oayw0FOa0Ju97ExnD7MXcwTdvREHre3Rc/W7tL+KbTQ/9y1DHH7ilHR51zcuS7pkCmRVpuXsQVPhJvg1qkxU9Z+KQqpyle76/sAHsO5Ezhp7EZZ4j2JVo54CwmlENyxD2wngxOXB+RYQcd3Uaoa8VGxSPcPE4xSm0h2E3m2xhnMtEMUmat2R+d+2kNuZHSAFNKdnX8EBeGrKBLwTduOriTmmGgELc3j0fWx4Q5JfoY87oh1OtNqO2txVC6CbzvO+KDkB7h/2iRNZMmY//KIP0mHlSkpMkWqWVp+/wUgVlH+GoSVabWGaaKgZjYXqT8Xpb3fFJ2VOp9FfX7MexHaCnQf1lL6PfoNHawJLp8m9YtVo1EOXsi/TIkZvEbJf7BwUf9nRku9GX9CVkY50KFcP9ZwhBZWUpH3V4WKrLwr/RRdWnOkWCzK2Em6P/N5ei+Cc4bNax1Oms/9AMTFmBjfFHjoAVH+NbqubUgr+nL9/GXnax9fiaToJQvAAoH5Vzmi4IrZOrvE/OHKypVA/SCNOLCEwysai/w+aczFKZV6SUNmslFbApByrPA/Txvs1LiLVJgj9xL/Jn9QTLSCzFA3x/gGzFJRNLxfE1pJ0tHvQtlUn3hDQUy0blDii6tFyog87tSHHo/v/rE0Ps8Q4k/c7yAMelVEEGXomqdw2s5kh4jhR0BcFFA7KWypRB7Bzy2juR1TA1miu1aWmnl6nnhRgQ2NbpSbyOj1LlYpkyI0hekDx4t9txfwmqH/84AS9f9gmCZX8gkIMC0iXgcWyjth2X9xL3z/SWsyvb7863jdB8IWaOFHg3k12nuJGRmytvqq+0VQfqZ5guzXMzKvvE51TQwQQqYscoXmjeBvIFfF7MGel0FSc7iUHsel3rwaRi2C/0UAif4wAXJg4SyUBRvWU8hDG2Hp8X9UECXF15AgzUMJDEtShZYGoB+/xN9N7WvbXcOoFWevI+DbICqg14DR9uSN1lBNGJKgtDRlcWZEhCcXq8aSvzaFxT6vDUaAGcYbmlo7V8/XhNg7bqyHmDicuH5E1JUcjCoOfzvk8wRGpyX/mHuWsMxLiJQoLrwRmg8sU4IwgFUV7j/CBmBWpquMQg9cIoaz87hAeWyIts0HuWfnfeW1gXBUFLLeDoOk4ss+h/P2RAH1q2JIst3+KRNIDTZzVhu6QGF/JeweXkL2d9FVOGCUKDLLQ8ngKoFtPR4bZr1qnqtSJb67nRpGukpEqC4JGpGRtkWDRXKYKQ=="}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}', + }, + client: { + private_key: + '{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}', + }, + }, +}; + +function generateContext(username: string): IUserState { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + + const token = { + token: crypto.createHash('sha256').update(username).digest('base64'), + when: date, + }; + + const hashedToken = crypto.createHash('sha256').update(token.token).digest('base64'); + + return { + data: { + _id: username, + username, + loginToken: token.token, + loginExpire: token.when, + hashedToken, + ...(e2eeData[username] && { + e2e: e2eeData[username]?.server, + e2ePassword: 'minus mobile dexter forest elvis', + }), + }, + state: { + cookies: [ + { + sameSite: 'Lax', + name: 'rc_uid', + value: username, + domain: 'localhost', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + }, + { + sameSite: 'Lax', + name: 'rc_token', + value: token.token, + domain: 'localhost', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + }, + ], + origins: [ + { + origin: BASE_URL, + localStorage: [ + { + name: 'userLanguage', + value: 'en-US', + }, + { + name: 'Meteor.loginToken', + value: token.token, + }, + { + name: 'Meteor.loginTokenExpires', + value: token.when.toISOString(), + }, + { + name: 'Meteor.userId', + value: username, + }, + ...(e2eeData[username] + ? [ + { + name: 'private_key', + value: e2eeData[username]?.client.private_key, + }, + ] + : []), + ], + }, + ], + }, + }; +} + +export const Users = { + user1: generateContext('user1'), + user2: generateContext('user2'), + user3: generateContext('user3'), + userNotAllowedByApp: generateContext('userNotAllowedByApp'), + userE2EE: generateContext('userE2EE'), + samluser1: generateContext('samluser1'), + samluser2: generateContext('samluser2'), + samluser4: generateContext('samluser4'), + samluser5: generateContext('samluser5'), + samluser6: generateContext('samluser6'), + samluser7: generateContext('samluser7'), + samlusernoname: generateContext('custom_saml_username'), + samlusernoname2: generateContext('custom_saml_username2'), + userForSamlMerge: generateContext('user_for_saml_merge'), + userForSamlMerge2: generateContext('user_for_saml_merge2'), + admin: generateContext('rocketchat.internal.admin.test'), +}; + +export async function storeState(page: Page, user: IUserState) { + user.state.origins = (await page.context().storageState()).origins; +} + +export async function restoreState(page: Page, user: IUserState, options: { except?: string[] } = {}) { + let ls = user.state.origins[0].localStorage; + if (options.except?.length) { + ls = ls.filter(({ name }) => !options.except?.includes(name)); + } + + await page.evaluate((items) => { + items.forEach(({ name, value }) => { + window.localStorage.setItem(name, value); + }); + }, ls); + + await page.waitForTimeout(2000); // Wait for the login to be completed +} diff --git a/apps/meteor/tests/e2e/forgot-password.spec.ts b/apps/meteor/tests/e2e/forgot-password.spec.ts new file mode 100644 index 0000000..ce45af1 --- /dev/null +++ b/apps/meteor/tests/e2e/forgot-password.spec.ts @@ -0,0 +1,46 @@ +import { Registration } from './page-objects'; +import { test, expect } from './utils/test'; + +test.describe.parallel('Forgot Password', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await poRegistration.goto(); + await poRegistration.btnForgotPassword.click(); + }); + + test('Send email to recover account', async () => { + await test.step('expect trigger a validation error if no email is provided', async () => { + await poRegistration.btnSendInstructions.click(); + await expect(poRegistration.inputEmail).toBeInvalid(); + }); + + await test.step('expect trigger a validation if a invalid email is provided (1)', async () => { + await poRegistration.inputEmail.fill('mail@mail'); + await poRegistration.btnSendInstructions.click(); + + await expect(poRegistration.inputEmail).toBeInvalid(); + }); + + await test.step('expect trigger a validation if a invalid email is provided (2)', async () => { + await poRegistration.inputEmail.fill('mail'); + await poRegistration.btnSendInstructions.click(); + + await expect(poRegistration.inputEmail).toBeInvalid(); + }); + + await test.step('expect to show a success callout if a valid email is provided', async () => { + await poRegistration.inputEmail.fill('mail@mail.com'); + await poRegistration.btnSendInstructions.click(); + + await expect(poRegistration.forgotPasswordEmailCallout).toBeVisible(); + }); + }); + + test('should not have any accessibility violations', async ({ makeAxeBuilder }) => { + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/apps/meteor/tests/e2e/global-search.spec.ts b/apps/meteor/tests/e2e/global-search.spec.ts new file mode 100644 index 0000000..3376f81 --- /dev/null +++ b/apps/meteor/tests/e2e/global-search.spec.ts @@ -0,0 +1,89 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { setSettingValueById } from './utils'; +import type { BaseTest } from './utils/test'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Global Search', () => { + let targetChannel: { name: string; _id: string }; + let targetGroup: { name: string; _id: string }; + let threadMessage: IMessage; + let regularMessage: IMessage; + let poHomeChannel: HomeChannel; + + const fillMessages = async (api: BaseTest['api']) => { + const { message: parentMessage } = await ( + await api.post('/chat.postMessage', { roomId: targetChannel._id, text: 'This is main message in channel' }) + ).json(); + + const { message: childMessage } = await ( + await api.post('/chat.postMessage', { roomId: targetChannel._id, text: `This is thread message in channel`, tmid: parentMessage._id }) + ).json(); + threadMessage = childMessage; + + const { message: standaloneMessage } = await ( + await api.post('/chat.postMessage', { roomId: targetChannel._id, text: 'This is regular message in channel' }) + ).json(); + regularMessage = standaloneMessage; + }; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api + .post('/channels.create', { name: Random.id() }) + .then((res) => res.json()) + .then((data) => { + targetChannel = data.channel; + }), + api + .post('/groups.create', { + name: Random.id(), + extraData: { encrypted: false }, + }) + .then((res) => res.json()) + .then((data) => { + targetGroup = data.group; + }), + setSettingValueById(api, 'Search.defaultProvider.GlobalSearchEnabled', true), + ]); + await fillMessages(api); + }); + + test.afterAll(({ api }) => + Promise.all([ + api.post('/channels.delete', { roomId: targetChannel._id }), + api.post('/groups.delete', { roomId: targetGroup._id }), + setSettingValueById(api, 'Search.defaultProvider.GlobalSearchEnabled', false), + ]), + ); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoGroup(targetGroup.name); + }); + + test('should open the correct message when jumping from global search in group to channel thread', async () => { + await poHomeChannel.roomToolbar.btnSearchMessages.click(); + + await poHomeChannel.tabs.searchMessages.search(threadMessage.msg.slice(10), { global: true }); + await poHomeChannel.tabs.searchMessages.jumpToMessage(threadMessage.msg); + + await expect(poHomeChannel.content.channelHeader).toContainText(targetChannel.name); + await expect(poHomeChannel.tabs.threads.getThreadMessageByText(threadMessage.msg)).toBeVisible(); + }); + + test('should open the correct message when jumping from global search in group to channel message', async () => { + await poHomeChannel.roomToolbar.btnSearchMessages.click(); + + await poHomeChannel.tabs.searchMessages.search(regularMessage.msg.slice(10), { global: true }); + await poHomeChannel.tabs.searchMessages.jumpToMessage(regularMessage.msg); + + await expect(poHomeChannel.content.channelHeader).toContainText(targetChannel.name); + await expect(poHomeChannel.content.getMessageByText(regularMessage.msg)).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/homepage.spec.ts b/apps/meteor/tests/e2e/homepage.spec.ts new file mode 100644 index 0000000..7fabaea --- /dev/null +++ b/apps/meteor/tests/e2e/homepage.spec.ts @@ -0,0 +1,217 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { expect, test } from './utils/test'; + +const CardNames = { + Users: 'Add users', + Chan: 'Create channels', + Rooms: 'Join rooms', + Mobile: 'Mobile apps', + Desktop: 'Desktop apps', + Docs: 'Documentation', + Custom: 'Custom content', +}; +test.use({ storageState: Users.admin.state }); + +test.describe.serial('homepage', () => { + let regularUserPage: Page; + let adminPage: Page; + + test.describe('for admins', () => { + test.beforeAll(async ({ browser }) => { + adminPage = await browser.newPage({ storageState: Users.admin.state }); + await new HomeChannel(adminPage).goto(); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Layout_Home_Custom_Block_Visible', { value: false })).status()).toBe(200); + expect((await api.post('/settings/Layout_Custom_Body_Only', { value: false })).status()).toBe(200); + await adminPage.close(); + }); + + test('expect customize button and all cards to be visible', async () => { + await test.step('expect show customize button', async () => { + await expect(adminPage.locator('role=button[name="Customize"]')).toBeVisible(); + }); + + await test.step('expect all cards to be visible', async () => { + await Promise.all(Object.values(CardNames).map((name) => expect(adminPage.getByRole('region', { name })).toBeVisible())); + }); + }); + + test.describe('custom body with empty custom content', async () => { + test.beforeAll(async ({ api }) => { + await expect((await api.post('/settings/Layout_Home_Body', { value: '' })).status()).toBe(200); + }); + + test('visibility and button functionality in custom body with empty custom content', async () => { + await test.step('expect default value in custom body', async () => { + await expect(adminPage.locator('div >> text="Admins may insert content html to be rendered in this white space."')).toBeVisible(); + }); + + await test.step('expect both change visibility and show only custom content buttons to be disabled', async () => { + await expect(adminPage.locator('role=button[name="Show to workspace"]')).toBeDisabled(); + await expect(adminPage.locator('role=button[name="Show only this content"]')).toBeDisabled(); + }); + + await test.step('expect visibility tag to show "not visible"', async () => { + await expect(adminPage.locator('span >> text="Not visible to workspace"')).toBeVisible(); + }); + }); + }); + + test.describe('custom body with custom content', () => { + test.beforeAll(async ({ api }) => { + await expect((await api.post('/settings/Layout_Home_Body', { value: 'Hello admin' })).status()).toBe(200); + }); + + test('visibility and button functionality in custom body with custom content', async () => { + await test.step('expect custom body to be visible', async () => { + await expect(adminPage.locator('div >> text="Hello admin"')).toBeVisible(); + }); + + await test.step('expect correct state for card buttons', async () => { + await expect(adminPage.locator('role=button[name="Show to workspace"]')).not.toBeDisabled(); + await expect(adminPage.locator('role=button[name="Show only this content"]')).toBeDisabled(); + }); + }); + + test.describe('enterprise edition', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + await expect((await api.post('/settings/Layout_Home_Body', { value: 'Hello admin' })).status()).toBe(200); + await expect((await api.post('/settings/Layout_Home_Custom_Block_Visible', { value: true })).status()).toBe(200); + await expect((await api.post('/settings/Layout_Custom_Body_Only', { value: true })).status()).toBe(200); + }); + + test('display custom content only', async () => { + await test.step('expect default layout to not be visible (show only custom content card)', async () => { + await expect(adminPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).not.toBeVisible(); + }); + + await test.step('expect correct state for card buttons', async () => { + await expect(adminPage.locator('role=button[name="Hide on workspace"]')).toBeDisabled(); + await expect(adminPage.locator('role=button[name="Show default content"]')).not.toBeDisabled(); + }); + + await test.step('expect visibility tag to show "visible to workspace"', async () => { + await expect(adminPage.locator('span >> text="Visible to workspace"')).toBeVisible(); + }); + }); + }); + }); + }); + + test.describe('for regular users', () => { + const notVisibleCards = [CardNames.Users, CardNames.Custom]; + + test.beforeAll(async ({ api, browser }) => { + expect((await api.post('/settings/Layout_Home_Body', { value: '' })).status()).toBe(200); + regularUserPage = await browser.newPage({ storageState: Users.user2.state }); + await new HomeChannel(regularUserPage).goto(); + }); + + test.afterAll(async () => { + await regularUserPage.close(); + }); + + test('the option customize is not be active', async () => { + await test.step('expect to not show customize button', async () => { + await expect(regularUserPage.locator('role=button[name="Customize"]')).not.toBeVisible(); + }); + + await test.step(`expect ${notVisibleCards.join(' and ')} cards to not be visible`, async () => { + await Promise.all(notVisibleCards.map((name) => expect(regularUserPage.getByRole('region', { name })).not.toBeVisible())); + }); + + await test.step('expect all other cards to be visible', async () => { + await Promise.all( + Object.values(CardNames) + .filter((name) => !notVisibleCards.includes(name)) + .map((name) => expect(regularUserPage.getByRole('region', { name })).toBeVisible()), + ); + }); + + await test.step('expect welcome text to use Site_Name default setting', async () => { + await expect(regularUserPage.locator('role=heading[name="Welcome to Rocket.Chat"]')).toBeVisible(); + }); + + await test.step('expect header text to use Layout_Home_Title default setting', async () => { + await expect(regularUserPage.getByRole('main').getByRole('heading', { level: 1, name: 'Home', exact: true })).toBeVisible(); + }); + }); + + test.describe('custom values', () => { + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/Site_Name', { value: 'NewSiteName' })).status()).toBe(200); + expect((await api.post('/settings/Layout_Home_Title', { value: 'NewTitle' })).status()).toBe(200); + + // the home heading is the setting under test, so wait for its new value rather than the default one + await new HomeChannel(regularUserPage).gotoExpecting( + regularUserPage.getByRole('main').getByRole('heading', { level: 1, name: 'NewTitle', exact: true }), + ); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Site_Name', { value: 'Rocket.Chat' })).status()).toBe(200); + expect((await api.post('/settings/Layout_Home_Title', { value: 'Home' })).status()).toBe(200); + }); + + test('expect welcome text and header text to be correct', async () => { + await test.step('expect welcome text to be NewSiteName', async () => { + await expect(regularUserPage.locator('role=heading[name="Welcome to NewSiteName"]')).toBeVisible(); + }); + + await test.step('expect header text to be Layout_Home_Title setting', async () => { + await expect(regularUserPage.getByRole('main').getByRole('heading', { name: 'NewTitle', exact: true })).toBeVisible(); + }); + }); + }); + + test.describe('custom body with content', () => { + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/Layout_Home_Body', { value: 'Hello' })).status()).toBe(200); + expect((await api.post('/settings/Layout_Home_Custom_Block_Visible', { value: true })).status()).toBe(200); + + await new HomeChannel(regularUserPage).goto(); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Layout_Home_Body', { value: '' })).status()).toBe(200); + expect((await api.post('/settings/Layout_Home_Custom_Block_Visible', { value: false })).status()).toBe(200); + }); + + test('expect custom body to be visible', async () => { + await expect(regularUserPage.locator('div >> text="Hello"')).toBeVisible(); + }); + + test.describe('enterprise edition', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/Layout_Custom_Body_Only', { value: true })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/Layout_Custom_Body_Only', { value: false })).status()).toBe(200); + }); + + test('expect default layout not be visible and custom body visible', async () => { + await test.step('expect default layout to not be visible', async () => { + await expect( + regularUserPage.getByRole('main').getByRole('heading', { level: 2, name: 'Welcome to Rocket.chat', exact: true }), + ).not.toBeVisible(); + }); + + await test.step('expect custom body to be visible', async () => { + await expect(regularUserPage.locator('div >> text="Hello"')).toBeVisible(); + }); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/iframe-authentication.spec.ts b/apps/meteor/tests/e2e/iframe-authentication.spec.ts new file mode 100644 index 0000000..37ececa --- /dev/null +++ b/apps/meteor/tests/e2e/iframe-authentication.spec.ts @@ -0,0 +1,140 @@ +import fs from 'fs'; +import path from 'path'; + +import { Users } from './fixtures/userStates'; +import { Authenticated, Login } from './page-objects'; +import { test, expect } from './utils/test'; + +const IFRAME_URL = 'http://iframe.rocket.chat'; +const API_URL = 'http://auth.rocket.chat/api/login'; + +test.describe('iframe-authentication', () => { + let poLogin: Login; + let poAuth: Authenticated; + + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_iframe_enabled', { value: true }); + await api.post('/settings/Accounts_iframe_url', { value: IFRAME_URL }); + await api.post('/settings/Accounts_Iframe_api_url', { value: API_URL }); + await api.post('/settings/Accounts_Iframe_api_method', { value: 'POST' }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_iframe_enabled', { value: false }); + await api.post('/settings/Accounts_iframe_url', { value: '' }); + await api.post('/settings/Accounts_Iframe_api_url', { value: '' }); + await api.post('/settings/Accounts_Iframe_api_method', { value: '' }); + }); + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + poAuth = new Authenticated(page); + + await page.route(API_URL, async (route) => { + await route.fulfill({ + status: 200, + }); + }); + + const htmlContent = fs + .readFileSync(path.resolve(__dirname, 'fixtures/files/iframe-login.html'), 'utf-8') + .replace('REPLACE_WITH_TOKEN', Users.user1.data.loginToken); + + await page.route(IFRAME_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: htmlContent, + }); + }); + }); + + test('should render iframe instead of login page', async () => { + await poLogin.goto('/home', poLogin.loginIframeForm); + }); + + test('should render iframe login page if API returns error', async ({ page }) => { + await page.route(API_URL, async (route) => { + await route.fulfill({ + status: 500, + }); + }); + + await poLogin.goto('/home', poLogin.loginIframeForm); + }); + + test('should login with token when API returns valid token', async ({ page }) => { + await page.route(API_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ loginToken: Users.user1.data.loginToken }), + }); + }); + + await poAuth.goto(); + }); + + test('should show login page when API returns invalid token', async ({ page }) => { + await page.route(API_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ loginToken: 'invalid-token' }), + }); + }); + + await poLogin.goto('/home', poLogin.loginIframeForm); + }); + + test('should login through iframe', async () => { + await poLogin.goto('/home', poLogin.loginIframeForm); + + await poLogin.loginIframeSubmitButton.click(); + + await poAuth.waitForDisplay(); + }); + + test('should return error to iframe when login fails', async ({ page }) => { + const htmlContent = fs.readFileSync(path.resolve(__dirname, 'fixtures/files/iframe-login.html'), 'utf-8'); + + await page.route(IFRAME_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: htmlContent, + }); + }); + + await poLogin.goto('/home', poLogin.loginIframeForm); + + await poLogin.loginIframeSubmitButton.click(); + + await expect(poLogin.loginIframeError).toBeVisible(); + }); + + test.describe('incomplete settings', () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_Iframe_api_url', { value: '' }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_Iframe_api_url', { value: API_URL }); + }); + + test('should render default login page, if settings are incomplete', async ({ page }) => { + const htmlContent = fs.readFileSync(path.resolve(__dirname, 'fixtures/files/iframe-login.html'), 'utf-8'); + + await page.route(IFRAME_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: htmlContent, + }); + }); + + await poLogin.goto(); + await expect(poLogin.loginIframeForm).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/image-gallery.spec.ts b/apps/meteor/tests/e2e/image-gallery.spec.ts new file mode 100644 index 0000000..9dc2a43 --- /dev/null +++ b/apps/meteor/tests/e2e/image-gallery.spec.ts @@ -0,0 +1,139 @@ +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.describe.serial('Image Gallery', async () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetChannelLargeImage: string; + const viewport = { + width: 1280, + height: 720, + }; + // Using more than 5 images so that new images need to be loaded by the gallery + const imageNames = ['number1.png', 'number2.png', 'number3.png', 'number4.png', 'number5.png', 'number6.png']; + + test.use({ viewport }); + + test.beforeAll(async ({ api, browser }) => { + targetChannel = await createTargetChannel(api); + targetChannelLargeImage = await createTargetChannel(api); + const { page } = await createAuxContext(browser, Users.user1); + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.navbar.openChat(targetChannelLargeImage); + await poHomeChannel.composer.btnJoinRoom.click(); + + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.composer.btnJoinRoom.click(); + }); + + test.afterAll(async ({ api }) => { + await poHomeChannel.page.close(); + await deleteChannel(api, targetChannel); + await deleteChannel(api, targetChannelLargeImage); + }); + + test.describe('When sending an image as a file', () => { + test.beforeAll(async () => { + const largeFileName = 'test-large-image.jpeg'; + + await poHomeChannel.navbar.openChat(targetChannel); + for await (const imageName of imageNames) { + await poHomeChannel.content.sendFileMessage(imageName); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(imageName); + } + + await poHomeChannel.navbar.openChat(targetChannelLargeImage); + await poHomeChannel.content.sendFileMessage(largeFileName); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(largeFileName); + + await poHomeChannel.content.lastUserMessage.locator('img.gallery-item').click(); + }); + + test('expect to have a large image not out of viewport bounds', async () => { + expect( + await poHomeChannel.content.imageGalleryImage.evaluate((el) => parseInt(window.getComputedStyle(el).getPropertyValue('width'))), + ).toBeLessThanOrEqual(viewport.width); + + expect( + await poHomeChannel.content.imageGalleryImage.evaluate((el) => parseInt(window.getComputedStyle(el).getPropertyValue('height'))), + ).toBeLessThanOrEqual(viewport.height); + }); + + test('expect to zoom in image', async () => { + await (await poHomeChannel.content.getGalleryButtonByName('zoom-in')).click(); + + expect(parseInt((await poHomeChannel.content.imageGalleryImage.getAttribute('data-qa-zoom-scale')) as string)).toBeGreaterThan(1); + }); + + test('expect to zoom out image', async () => { + await (await poHomeChannel.content.getGalleryButtonByName('zoom-out')).click(); + + expect(parseInt((await poHomeChannel.content.imageGalleryImage.getAttribute('data-qa-zoom-scale')) as string)).toEqual(1); + }); + + test('expect to resize image to default ratio', async () => { + await expect(await poHomeChannel.content.getGalleryButtonByName('zoom-out')).toBeDisabled(); + + await (await poHomeChannel.content.getGalleryButtonByName('zoom-in')).dblclick(); + + await expect(await poHomeChannel.content.getGalleryButtonByName('zoom-out')).toBeEnabled(); + + await (await poHomeChannel.content.getGalleryButtonByName('resize')).click(); + + expect(parseInt((await poHomeChannel.content.imageGalleryImage.getAttribute('data-qa-zoom-scale')) as string)).toEqual(1); + }); + + test('expect to close gallery', async () => { + await (await poHomeChannel.content.getGalleryButtonByName('close')).click(); + + await expect(poHomeChannel.content.imageGalleryImage).not.toBeVisible(); + }); + + test('expect successfully move to older images by using the left arrow button', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.lastUserMessage.locator('img.gallery-item').click(); + + for (let i = 0; i < imageNames.length - 1; i++) { + await expect(poHomeChannel.content.previousSlideButton).toBeEnabled(); + await expect(poHomeChannel.content.currentGalleryImage).toHaveAttribute( + 'src', + new RegExp(`${imageNames[imageNames.length - (i + 1)]}$`), + ); + await poHomeChannel.content.previousSlideButton.click(); + } + await expect(poHomeChannel.content.previousSlideButton).toBeDisabled(); + }); + + test('expect successfully move to newer images by using the right arrow button', async () => { + for (let i = 0; i < imageNames.length - 1; i++) { + await expect(poHomeChannel.content.nextSlideButton).toBeEnabled(); + await expect(poHomeChannel.content.currentGalleryImage).toHaveAttribute('src', new RegExp(`${imageNames[i]}$`)); + await poHomeChannel.content.nextSlideButton.click(); + } + await expect(poHomeChannel.content.nextSlideButton).toBeDisabled(); + await (await poHomeChannel.content.getGalleryButtonByName('close')).click(); + }); + }); + + test.describe('When sending an image as a link', () => { + const imageLink = 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/2020/png/logo-horizontal-red.png'; + + test.beforeAll(async () => { + await poHomeChannel.content.sendMessage(imageLink); + + await expect(poHomeChannel.content.lastUserMessage).toContainText(imageLink); + + await poHomeChannel.content.lastUserMessage.locator('img.preview-image').click(); + }); + + test('expect to open the image inside the image gallery', async () => { + await expect(poHomeChannel.content.imageGalleryImage).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/image-upload.spec.ts b/apps/meteor/tests/e2e/image-upload.spec.ts new file mode 100644 index 0000000..dc7de61 --- /dev/null +++ b/apps/meteor/tests/e2e/image-upload.spec.ts @@ -0,0 +1,57 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, getSettingValueById, setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('image-upload', () => { + let defaultStripSetting: unknown; + let defaultRotateSetting: unknown; + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + defaultStripSetting = await getSettingValueById(api, 'Message_Attachments_Strip_Exif'); + defaultRotateSetting = await getSettingValueById(api, 'FileUpload_RotateImages'); + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Attachments_Strip_Exif', defaultStripSetting); + await setSettingValueById(api, 'FileUpload_RotateImages', defaultRotateSetting); + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + }); + + test.describe('strip exif disabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Attachments_Strip_Exif', false); + }); + + test('should show error indicator when upload fails', async () => { + await poHomeChannel.content.sendFileMessage('bad-orientation.jpeg'); + + await expect(poHomeChannel.composer.getFileByName('bad-orientation.jpeg')).toHaveAttribute('readonly'); + }); + }); + + test.describe('strip exif enabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Attachments_Strip_Exif', true); + // Image rotation now happens before EXIF stripping, so we need to disable it to properly test it + await setSettingValueById(api, 'FileUpload_RotateImages', false); + }); + + test('should succeed upload of bad-orientation.jpeg', async () => { + const imgName = 'bad-orientation.jpeg'; + await poHomeChannel.content.sendFileMessage(imgName); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(imgName); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/imports.spec.ts b/apps/meteor/tests/e2e/imports.spec.ts new file mode 100644 index 0000000..4582569 --- /dev/null +++ b/apps/meteor/tests/e2e/imports.spec.ts @@ -0,0 +1,181 @@ +import fs from 'fs'; +import * as path from 'path'; + +import { parse } from 'csv-parse'; + +import { Users } from './fixtures/userStates'; +import { AdminImports, AdminRooms, AdminUsers } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +type csvRoomSpec = { + name: string; + ownerUsername: string; + visibility: 'public' | 'private'; + members: string; +}; + +const rowUserName: string[] = []; +const csvImportedUsernames: string[] = []; +const dmMessages: string[] = []; +const importedRooms: csvRoomSpec[] = []; +const slackCsvDir = path.resolve(__dirname, 'fixtures', 'files', 'slack_export_users.csv'); +const zipCsvImportDir = path.resolve(__dirname, 'fixtures', 'files', 'csv_import.zip'); + +// These files have the same content from users.csv, channels.csv and messages1.csv from the zip file +// They have been extracted just so that we don't need to do that on the fly +const usersCsvDir = path.resolve(__dirname, 'fixtures', 'files', 'csv_import_users.csv'); +const roomsCsvDir = path.resolve(__dirname, 'fixtures', 'files', 'csv_import_rooms.csv'); +const dmMessagesCsvDir = path.resolve(__dirname, 'fixtures', 'files', 'dm_messages.csv'); + +const usersCsvsToJson = async (): Promise => { + await new Promise((resolve) => + fs + .createReadStream(slackCsvDir) + .pipe(parse({ delimiter: ',', from_line: 2 })) + .on('data', (rows) => { + rowUserName.push(rows[0]); + }) + .on('end', resolve), + ); + + await new Promise((resolve) => + fs + .createReadStream(usersCsvDir) + .pipe(parse({ delimiter: ',' })) + .on('data', (rows) => { + rowUserName.push(rows[0]); + csvImportedUsernames.push(rows[0]); + }) + .on('end', resolve), + ); +}; + +const countDmMessages = (): Promise => + new Promise((resolve) => + fs + .createReadStream(dmMessagesCsvDir) + .pipe(parse({ delimiter: ',' })) + .on('data', (rows) => { + dmMessages.push(rows[3]); + }) + .on('end', resolve), + ); + +const roomsCsvToJson = (): Promise => + new Promise((resolve) => + fs + .createReadStream(roomsCsvDir) + .pipe(parse({ delimiter: ',' })) + .on('data', (rows) => { + importedRooms.push({ + name: rows[0], + ownerUsername: rows[1], + visibility: rows[2], + members: rows[3], + }); + }) + .on('end', resolve), + ); + +test.describe.serial('imports', () => { + test.beforeAll(async () => { + await usersCsvsToJson(); + await roomsCsvToJson(); + await countDmMessages(); + }); + + test('expect import users data from slack', async ({ page }) => { + const poAdminImports = new AdminImports(page); + await poAdminImports.goto(); + + await poAdminImports.btnImportNewFile.click(); + + await (await poAdminImports.getOptionFileType("Slack's Users CSV")).click(); + + await poAdminImports.inputFile.setInputFiles(slackCsvDir); + await poAdminImports.btnImport.click(); + + await poAdminImports.btnStartImport.click(); + + await expect(poAdminImports.importStatusTableFirstRowCell).toBeVisible({ + timeout: 30_000, + }); + }); + + test('expect import users data from zipped CSV files', async ({ page }) => { + const poAdminImports = new AdminImports(page); + await poAdminImports.goto(); + + await poAdminImports.btnImportNewFile.click(); + + await (await poAdminImports.getOptionFileType('CSV')).click(); + + await poAdminImports.inputFile.setInputFiles(zipCsvImportDir); + await poAdminImports.btnImport.click(); + + await poAdminImports.findFileCheckboxByUsername('billy.billy').click(); + + await poAdminImports.btnStartImport.click(); + + await expect(poAdminImports.importStatusTableFirstRowCell).toBeVisible({ + timeout: 30_000, + }); + }); + + test('expect all imported users to be actually listed as users', async ({ page }) => { + await new AdminUsers(page).goto(); + + for await (const user of rowUserName) { + if (user === 'billy.billy') { + await expect(page.locator(`tbody tr td:first-child >> text="${user}"`)).not.toBeVisible(); + } else { + expect(page.locator(`tbody tr td:first-child >> text="${user}"`)); + } + } + }); + + test('expect all imported rooms to be actually listed as rooms with correct members count', async ({ page }) => { + const poAdmin: AdminRooms = new AdminRooms(page); + await poAdmin.goto(); + + for await (const room of importedRooms) { + await poAdmin.inputSearchRooms.fill(room.name); + + const expectedMembersCount = room.members.split(';').filter((username) => username !== room.ownerUsername).length + 1; + expect(page.locator(`tbody tr td:nth-child(2) >> text="${expectedMembersCount}"`)); + } + }); + + test('expect all imported rooms to have correct room type and owner', async ({ page }) => { + const poAdmin = new AdminRooms(page); + await poAdmin.goto(); + + for await (const room of importedRooms) { + await poAdmin.inputSearchRooms.fill(room.name); + await poAdmin.getRoomRow(room.name).click(); + + room.visibility === 'private' + ? await expect(poAdmin.editRoom.privateInput).toBeChecked() + : await expect(poAdmin.editRoom.privateInput).not.toBeChecked(); + await expect(poAdmin.editRoom.roomOwnerInput).toHaveValue(room.ownerUsername); + } + }); + + test('expect imported DM to be actually listed as a room with correct members and messages count', async ({ page }) => { + const poAdmin = new AdminRooms(page); + await poAdmin.goto(); + + for await (const user of csvImportedUsernames) { + await poAdmin.inputSearchRooms.fill(user); + expect(page.locator(`tbody tr td:first-child >> text="${user}"`)); + + const expectedMembersCount = 2; + expect(page.locator(`tbody tr td:nth-child(2) >> text="${expectedMembersCount}"`)); + + const expectedMessagesCount = dmMessages.length; + expect(page.locator(`tbody tr td:nth-child(3) >> text="${expectedMessagesCount}"`)); + } + }); +}); diff --git a/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts b/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts new file mode 100644 index 0000000..4894450 --- /dev/null +++ b/apps/meteor/tests/e2e/jump-to-thread-message.spec.ts @@ -0,0 +1,72 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import type { BaseTest } from './utils/test'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.serial('Threads', () => { + let poHomeChannel: HomeChannel; + let targetChannel: { name: string; _id: string }; + let threadMessage: IMessage; + let mainMessage: IMessage; + + const fillMessages = async (api: BaseTest['api']) => { + const { message: parentMessage } = await ( + await api.post('/chat.postMessage', { roomId: targetChannel._id, text: 'this is a message for reply' }) + ).json(); + mainMessage = parentMessage; + + // fill thread with messages + const largeSimpleMessage = 'This is a large message with a lot of text to create scroll view in thread'.repeat(5); + const { message: childMessage } = await ( + await api.post('/chat.postMessage', { roomId: targetChannel._id, text: largeSimpleMessage, tmid: parentMessage._id }) + ).json(); + threadMessage = childMessage; + + await Promise.all( + Array.from({ length: 5 }).map(() => + api.post('/chat.postMessage', { roomId: targetChannel._id, text: largeSimpleMessage, tmid: parentMessage._id }), + ), + ); + + // fill room with normal messages + await Promise.all( + Array.from({ length: 5 }).map(() => api.post('/chat.postMessage', { roomId: targetChannel._id, text: largeSimpleMessage })), + ); + }; + + test.beforeAll(async ({ api }) => { + targetChannel = (await (await api.post('/channels.create', { name: Random.id() })).json()).channel; + await fillMessages(api); + }); + + test.afterAll(({ api }) => api.post('/channels.delete', { roomId: targetChannel._id })); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test('expect to jump scroll to a non-thread message on opening its message link', async () => { + await poHomeChannel.gotoChannelMessage(targetChannel.name, mainMessage._id); + + await expect(poHomeChannel.content.getRoomById(targetChannel._id)).toBeVisible(); + await expect(poHomeChannel.content.getMessageById(mainMessage._id)).toBeVisible(); + }); + + test('expect to jump scroll to thread message on opening its message link', async () => { + await poHomeChannel.gotoChannelMessage(targetChannel.name, threadMessage._id, true); + + await expect(poHomeChannel.content.getRoomById(targetChannel._id)).toBeVisible(); + await expect(poHomeChannel.content.getThreadMessageById(threadMessage._id)).toBeVisible(); + }); + + test('expect to jump scroll to thread message on opening its message link from a different channel', async () => { + await poHomeChannel.gotoChannelMessage('general', threadMessage._id, true); + + await expect(poHomeChannel.content.getRoomById(targetChannel._id)).toBeVisible(); + await expect(poHomeChannel.content.getThreadMessageById(threadMessage._id)).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/login.spec.ts b/apps/meteor/tests/e2e/login.spec.ts new file mode 100644 index 0000000..495951e --- /dev/null +++ b/apps/meteor/tests/e2e/login.spec.ts @@ -0,0 +1,69 @@ +import { faker } from '@faker-js/faker'; + +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Authenticated, Login } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe.parallel('Login', () => { + let poLogin: Login; + let poAuth: Authenticated; + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + poAuth = new Authenticated(page); + + await poLogin.goto(); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Language', 'en'); + }); + + test('should not have any accessibility violations', async ({ makeAxeBuilder }) => { + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + + test('Login with invalid credentials', async () => { + await test.step('expect to have username and password marked as invalid', async () => { + await poLogin.login(faker.internet.email(), 'any_password'); + + await expect(poLogin.form.username).toBeInvalid(); + await expect(poLogin.form.inputPassword).toBeInvalid(); + }); + }); + + test('Login with valid username and password', async () => { + await test.step('expect successful login', async () => { + await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await poAuth.waitForDisplay(); + }); + }); + + test('Login with valid email and password', async () => { + await test.step('expect successful login', async () => { + await poLogin.login('user1@email.com', DEFAULT_USER_CREDENTIALS.password); + + await poAuth.waitForDisplay(); + }); + }); + + test('Should correctly display switch language button', async ({ page, api }) => { + expect((await setSettingValueById(api, 'Language', 'pt-BR')).status()).toBe(200); + + const buttonDefault = page.getByRole('button', { name: 'Change to Default' }); + await expect(buttonDefault).not.toBeVisible(); + + const button = page.getByRole('button', { name: 'Alterar para português (Brasil)' }); + await button.click(); + + await expect(page.getByRole('button', { name: 'Fazer Login' })).toBeVisible(); + + const buttonEnglish = page.getByRole('button', { name: 'Change to English' }); + await buttonEnglish.click(); + + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/mark-unread.spec.ts b/apps/meteor/tests/e2e/mark-unread.spec.ts new file mode 100644 index 0000000..3938610 --- /dev/null +++ b/apps/meteor/tests/e2e/mark-unread.spec.ts @@ -0,0 +1,75 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannelAndReturnFullRoom } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('mark-unread', () => { + let poHomeChannel: HomeChannel; + let targetChannel: Required; + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeChannel(page); + const result = await createTargetChannelAndReturnFullRoom(api, { members: ['user2'] }); + targetChannel = result.channel as Required; + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await poHomeChannel.goto(); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel.name }); + }); + + test.describe('Mark Unread - Sidebar Action', () => { + test('should not mark empty room as unread', async () => { + const item = poHomeChannel.sidebar.getSidebarItemByName(targetChannel.name); + await poHomeChannel.sidebar.markItemAsUnread(item); + + await expect(poHomeChannel.sidebar.getItemUnreadBadge(item)).not.toBeVisible(); + }); + + test('should mark a populated room as unread', async () => { + await poHomeChannel.navbar.openChat(targetChannel.name); + await poHomeChannel.content.sendMessage('this is a message for reply'); + const item = poHomeChannel.sidebar.getSidebarItemByName(targetChannel.name); + await poHomeChannel.sidebar.markItemAsUnread(item); + + await expect(poHomeChannel.sidebar.getItemUnreadBadge(item)).toBeVisible(); + }); + + test('should mark a populated room as unread - search', async () => { + await poHomeChannel.navbar.openChat(targetChannel.name); + await poHomeChannel.content.sendMessage('this is a message for reply'); + const item = poHomeChannel.sidebar.getSidebarItemByName(targetChannel.name); + await poHomeChannel.sidebar.markItemAsUnread(item); + await poHomeChannel.navbar.typeSearch(targetChannel.name); + + await expect(poHomeChannel.navbar.getSearchItemBadge(targetChannel.name)).toBeVisible(); + }); + }); + + test.describe('Mark Unread - Message Action', () => { + test.use({ storageState: Users.user2.state }); + + test('should mark a populated room as unread', async ({ api }) => { + await api.post('/chat.sendMessage', { + message: { + rid: targetChannel._id, + msg: 'this is a message for reply', + }, + }); + + await expect(async () => { + await poHomeChannel.navbar.openChat(targetChannel.name); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.markUnread.click(); + const item = poHomeChannel.sidebar.getSidebarItemByName(targetChannel.name); + await expect(poHomeChannel.sidebar.getItemUnreadBadge(item)).toBeVisible(); + }).toPass(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts new file mode 100644 index 0000000..8384ce8 --- /dev/null +++ b/apps/meteor/tests/e2e/message-actions.spec.ts @@ -0,0 +1,310 @@ +import { ADMIN_CREDENTIALS } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { CreateNewDiscussionModal } from './page-objects/fragments'; +import { createTargetChannel, createTargetTeam, getPermissionRoles, sendTargetChannelMessage } from './utils'; +import { setUserPreferences } from './utils/setUserPreferences'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.serial('message-actions', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let forwardChannel: string; + let forwardTeam: string; + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user2'] }); + forwardChannel = await createTargetChannel(api); + forwardTeam = await createTargetTeam(api); + }); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test('expect reply the message', async ({ page }) => { + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.content.openReplyInThread(); + await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetChannel}"]`).type('this is a reply message'); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastUserThreadMessage).toHaveText('this is a reply message'); + }); + + // with thread open we listen to the subscription and update the collection from there + test('expect follow/unfollow message with thread open', async ({ page }) => { + await test.step('start thread', async () => { + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.content.openReplyInThread(); + await page.getByRole('dialog').locator(`role=textbox[name="Message #${targetChannel}"]`).fill('this is a reply message'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserThreadMessage).toHaveText('this is a reply message'); + }); + + await test.step('unfollow thread', async () => { + const unFollowButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' }); + await expect(unFollowButton).toBeVisible(); + + await unFollowButton.click(); + }); + + await test.step('follow thread', async () => { + const followButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Not following' }); + await expect(followButton).toBeVisible(); + await followButton.click(); + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' })).toBeVisible(); + }); + }); + + // with thread closed we depend on message changed updates + test('expect follow/unfollow message with thread closed', async ({ page }) => { + await test.step('start thread', async () => { + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.content.openReplyInThread(); + await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetChannel}"]`).fill('this is a reply message'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserThreadMessage).toHaveText('this is a reply message'); + }); + + await test.step('close thread before testing closed-thread behavior', async () => { + await poHomeChannel.btnContextualbarClose.click(); + await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); + }); + + await test.step('unfollow thread', async () => { + const unFollowButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' }); + await expect(unFollowButton).toBeVisible(); + await unFollowButton.click(); + }); + + await test.step('follow thread', async () => { + const followButton = poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Not following' }); + await expect(followButton).toBeVisible(); + await followButton.click(); + await expect(poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Following' })).toBeVisible(); + }); + }); + + test('expect edit the message', async ({ page }) => { + await poHomeChannel.content.sendMessage('This is a message to edit'); + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Edit"]').click(); + await page.locator('[name="msg"]').fill('this message was edited'); + await page.keyboard.press('Enter'); + + await poHomeChannel.content.scrollToMessage(poHomeChannel.content.getMessageByText('this message was edited'), 'down'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this message was edited'); + }); + + test('should delete message ', async () => { + await poHomeChannel.content.sendMessage('Message to delete'); + await poHomeChannel.content.deleteLastMessage(); + + await expect(poHomeChannel.content.lastUserMessageBody).not.toHaveText('Message to delete'); + }); + + test('expect quote the message', async ({ page }) => { + const message = `Message for quote - ${Date.now()}`; + + await poHomeChannel.content.sendMessage(message); + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'Quote' }).click(); + await page.locator('[name="msg"]').fill('this is a quote message'); + await page.keyboard.press('Enter'); + + await poHomeChannel.content.scrollToMessage(poHomeChannel.content.getMessageByText(message), 'down'); + await expect(poHomeChannel.content.lastMessageTextAttachmentEqualsText).toHaveText(message); + }); + + test('expect create a discussion from message', async ({ page }) => { + const message = `Message for discussion - ${Date.now()}`; + const discussionName = `Discussion Name - ${Date.now()}`; + + await poHomeChannel.content.sendMessage(message); + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Start a Discussion"]').click(); + const createDiscussionModal = new CreateNewDiscussionModal(page); + const createButton = createDiscussionModal.btnCreate; + // Name should be prefilled thus making the create button enabled + await expect(createButton).not.toBeDisabled(); + await createDiscussionModal.inputName.fill(discussionName); + await createButton.click(); + await expect(page.locator('header h1')).toHaveText(discussionName); + await poHomeChannel.gotoChannel(targetChannel); + // Should fail if more than one discussion has been created + await poHomeChannel.content.scrollToMessage(poHomeChannel.content.getMessageByText(message), 'down'); + await expect(poHomeChannel.content.getMessageByText(discussionName)).toHaveCount(1); + }); + + test('expect star the message', async ({ page }) => { + await poHomeChannel.content.sendMessage('Message to star'); + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Star"]').click(); + await poHomeChannel.toastMessage.dismissToast(); + await poHomeChannel.roomToolbar.openMoreOptions(); + await poHomeChannel.roomToolbar.menuItemStarredMessages.click(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Message to star'); + }); + + test('expect copy the message content to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await poHomeChannel.content.sendMessage('Message to copy'); + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Copy text"]').click(); + + const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + expect(clipboardText).toBe('Message to copy'); + }); + + test('expect copy the message link to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await poHomeChannel.content.sendMessage('Message to permalink'); + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Copy link"]').click(); + + const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + expect(clipboardText).toContain('http'); + }); + + test.describe.serial('expect reply in direct message', () => { + test.use({ storageState: Users.user2.state }); + + let defaultCreateDRoles: string[]; + + test.beforeAll(async ({ api }) => { + defaultCreateDRoles = await getPermissionRoles(api, 'create-d'); + + await sendTargetChannelMessage(api, targetChannel, { msg: 'message from admin for reply in DM' }); + }); + + test('expect option be visible and redirect to DM', async ({ page }) => { + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionReplyInDm.click(); + + await expect(page).toHaveURL(/.*reply/); + }); + + test('expect option not be visible without create-d permission and no existing DM', async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: ['admin'] }] })).status()).toBe(200); + + await poHomeChannel.content.openLastMessageMenu(); + await expect(poHomeChannel.content.btnOptionReplyInDm).toBeHidden(); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: defaultCreateDRoles }] })).status()).toBe( + 200, + ); + }); + }); + + test.describe('Preference Hide Contextual Bar by clicking outside of it Enabled', () => { + test.beforeAll(async ({ api }) => { + await setUserPreferences(api, { hideFlexTab: true }); + }); + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { hideFlexTab: false }); + }); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + test('expect reply the message in direct', async ({ page }) => { + await poHomeChannel.content.sendMessage('this is a message for reply in direct'); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionReplyInDm.click(); + + await expect(page).toHaveURL(/.*reply/); + }); + }); + + test('expect forward message to channel', async () => { + const message = 'this is a message to forward to channel'; + await poHomeChannel.content.sendMessage(message); + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(message); + }); + + test('expect forward message to team', async () => { + const message = 'this is a message to forward to team'; + await poHomeChannel.content.sendMessage(message); + await poHomeChannel.content.forwardMessage(forwardTeam); + + await poHomeChannel.navbar.openChat(forwardTeam); + await expect(poHomeChannel.content.lastUserMessage).toContainText(message); + }); + + test('expect forward message to direct message', async () => { + const message = 'this is a message to forward to direct message'; + const direct = 'RocketChat Internal Admin Test'; + + // todo: Forward modal is using name as display and the sidebar is using username + await poHomeChannel.content.sendMessage(message); + await poHomeChannel.content.forwardMessage(direct); + + await poHomeChannel.navbar.openChat(ADMIN_CREDENTIALS.username); + await expect(poHomeChannel.content.lastUserMessage).toContainText(message); + }); + + test('expect forward text file to channel', async () => { + const filename = 'any_file.txt'; + await poHomeChannel.content.sendFileMessage(filename); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + }); + + test('expect forward image file to channel', async () => { + const filename = 'test-image.jpeg'; + await poHomeChannel.content.sendFileMessage(filename); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + }); + + test('expect forward pdf file to channel', async () => { + const filename = 'test_pdf_file.pdf'; + await poHomeChannel.content.sendFileMessage(filename); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + }); + + test('expect forward audio message to channel', async () => { + const filename = 'sample-audio.mp3'; + await poHomeChannel.content.sendFileMessage(filename); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + }); + + test('expect forward video message to channel', async () => { + const filename = 'test_video.mp4'; + await poHomeChannel.content.sendFileMessage(filename); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + + await poHomeChannel.content.forwardMessage(forwardChannel); + + await poHomeChannel.gotoChannel(forwardChannel); + await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); + }); +}); diff --git a/apps/meteor/tests/e2e/message-attachment-collapse.spec.ts b/apps/meteor/tests/e2e/message-attachment-collapse.spec.ts new file mode 100644 index 0000000..c9bd3a3 --- /dev/null +++ b/apps/meteor/tests/e2e/message-attachment-collapse.spec.ts @@ -0,0 +1,210 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { setUserPreferences } from './utils/setUserPreferences'; +import type { BaseTest } from './utils/test'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const TRANSPARENT_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +const postAttachmentMessage = async ( + api: BaseTest['api'], + channel: string, + { text, collapsed }: { text: string; collapsed?: boolean }, +): Promise => { + await api.post('/chat.postMessage', { + channel, + attachments: [{ title: 'GIPHY', text, image_url: TRANSPARENT_PIXEL, collapsed }], + }); +}; + +const floodChannel = (api: BaseTest['api'], channel: string, count: number): Promise => + Promise.all(Array.from({ length: count }, () => api.post('/chat.postMessage', { channel, text: faker.lorem.paragraphs(2) }))); + +test.describe.serial('Message Attachment Collapse', () => { + let poHomeChannel: HomeChannel; + + const scrollUp = async (): Promise => { + const scroller = poHomeChannel.content.mainMessageListScroller; + + await scroller.evaluate((el) => { + (el.firstElementChild as HTMLElement).scrollTop = 0; + }); + }; + + const waitForFloodToSettle = async (api: BaseTest['api'], channel: string): Promise => { + const marker = `flood settled ${faker.string.uuid()}`; + await api.post('/chat.postMessage', { channel, text: marker }); + await expect(poHomeChannel.content.mainMessageList.getByText(marker)).toBeVisible(); + }; + + test.describe('Default preference (expanded by default)', () => { + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test('should collapse and re-expand an attachment on toggle click', async ({ api }) => { + const text = `attachment text ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text }); + + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible(); + + await poHomeChannel.content.mainMessageList.getByRole('button', { name: 'Collapse' }).last().click(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeHidden(); + + await poHomeChannel.content.mainMessageList.getByRole('button', { name: 'Uncollapse' }).last().click(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible(); + }); + + test('should not affect a different message when one attachment is toggled', async ({ api }) => { + const textA = `attachment text A ${faker.string.uuid()}`; + const textB = `attachment text B ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text: textA }); + await postAttachmentMessage(api, targetChannel, { text: textB }); + + const messageA = poHomeChannel.content.messageListItems.filter({ hasText: textA }); + const messageB = poHomeChannel.content.messageListItems.filter({ hasText: textB }); + + await expect(messageA).toBeVisible(); + await expect(messageB).toBeVisible(); + + // Collapse only the most recent message's attachment (textB). + await messageB.getByRole('button', { name: 'Collapse' }).click(); + + await expect(poHomeChannel.content.mainMessageList.getByText(textA)).toBeVisible(); + await expect(poHomeChannel.content.mainMessageList.getByText(textB)).toBeHidden(); + }); + + test('should keep a manually collapsed attachment collapsed after it is scrolled out of view and back', async ({ api }) => { + const text = `attachment text ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text }); + + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible(); + await poHomeChannel.content.mainMessageList.getByRole('button', { name: 'Collapse' }).last().click(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeHidden(); + + await floodChannel(api, targetChannel, 30); + await waitForFloodToSettle(api, targetChannel); + + await expect(async () => { + await scrollUp(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeHidden({ timeout: 1000 }); + }).toPass(); + }); + }); + + test.describe('"Collapse Embedded Media by Default" preference enabled', () => { + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + await setUserPreferences(api, { collapseMediaByDefault: true }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { collapseMediaByDefault: false }); + await deleteChannel(api, targetChannel); + }); + + test('should render a new attachment collapsed by default', async ({ api }) => { + const text = `attachment text ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text }); + + await expect(poHomeChannel.content.mainMessageList.getByRole('button', { name: 'Uncollapse' }).last()).toBeVisible(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeHidden(); + }); + + test('should keep a manually expanded attachment expanded after it is scrolled out of view and back', async ({ api }) => { + const text = `attachment text ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text }); + + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeHidden(); + await poHomeChannel.content.mainMessageList.getByRole('button', { name: 'Uncollapse' }).last().click(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible(); + + await floodChannel(api, targetChannel, 30); + await waitForFloodToSettle(api, targetChannel); + + await expect(async () => { + await scrollUp(); + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible({ timeout: 1000 }); + }).toPass(); + }); + }); + + test.describe('Nested attachments (quoted messages)', () => { + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test('should keep a quoted attachment collapse state independent from the original message it quotes', async ({ api }) => { + const text = `nested attachment text ${faker.string.uuid()}`; + const quoteReplyText = `quoting the attachment ${faker.string.uuid()}`; + await postAttachmentMessage(api, targetChannel, { text, collapsed: false }); + + await expect(poHomeChannel.content.mainMessageList.getByText(text)).toBeVisible(); + + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + await poHomeChannel.content.sendMessage(quoteReplyText); + + const originalRow = poHomeChannel.content.messageListItems.filter({ hasText: text, hasNotText: quoteReplyText }); + const quoteRow = poHomeChannel.content.messageListItems.filter({ hasText: quoteReplyText }); + + // The original message's attachment is untouched by quoting it. + await expect(originalRow.getByText(text)).toBeVisible(); + + const quoteTextVisibleBeforeToggle = await quoteRow.getByText(text).isVisible(); + + // Toggling the ORIGINAL message's attachment must not affect the nested copy inside the quote. + await originalRow.getByRole('button', { name: 'Collapse' }).click(); + await expect(originalRow.getByText(text)).toBeHidden(); + if (quoteTextVisibleBeforeToggle) { + await expect(quoteRow.getByText(text)).toBeVisible(); + } else { + await expect(quoteRow.getByText(text)).toBeHidden(); + } + + // Toggling the NESTED copy inside the quote must not affect the now collapsed original. + await quoteRow.getByRole('button', { name: quoteTextVisibleBeforeToggle ? 'Collapse' : 'Uncollapse' }).click(); + if (quoteTextVisibleBeforeToggle) { + await expect(quoteRow.getByText(text)).toBeHidden(); + } else { + await expect(quoteRow.getByText(text)).toBeVisible(); + } + await expect(originalRow.getByText(text)).toBeHidden(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts new file mode 100644 index 0000000..a0cc85f --- /dev/null +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -0,0 +1,232 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('message-composer', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['rocket.cat'] }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test('should have all formatters and the main actions visible on toolbar', async () => { + await poHomeChannel.content.sendMessage('hello composer'); + + await expect(poHomeChannel.composer.allPrimaryActions).toHaveCount(12); + }); + + test('should have only the main formatter and the main action', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 600 }); + + await expect(poHomeChannel.composer.allPrimaryActions).toHaveCount(6); + }); + + test('should navigate on toolbar using arrow keys', async ({ page }) => { + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await expect(poHomeChannel.composer.btnItalicFormatter).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect(poHomeChannel.composer.btnBoldFormatter).toBeFocused(); + }); + + test('should move the focus away from toolbar using tab key', async ({ page }) => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + await expect(poHomeChannel.composer.btnEmoji).not.toBeFocused(); + }); + + test('should add a link to the selected text', async ({ page }) => { + const url = faker.internet.url(); + + await page.keyboard.type('hello composer'); + await page.keyboard.press('Control+A'); // on Windows and Linux + await page.keyboard.press('Meta+A'); // on macOS + await poHomeChannel.composer.btnLinkFormatter.click(); + // It takes a while for the modal to be visible and ready to receive input, so we need to wait for it before typing the url + await poHomeChannel.composer.addLinkModal.waitFor(); + await page.keyboard.type(url); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.composer.inputMessage).toHaveValue(`[hello composer](${url})`); + }); + + test('should select popup item and not send the message when pressing enter', async ({ page }) => { + await poHomeChannel.content.sendMessage('hello composer'); + + await test.step('mention popup', async () => { + await page.keyboard.type('hello composer @all'); + + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer @all '); + + await poHomeChannel.composer.inputMessage.fill(''); + }); + + await test.step('emoji popup', async () => { + await page.keyboard.type('hello composer :flag_br'); + + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer 🇧🇷 '); + + await poHomeChannel.composer.inputMessage.fill(''); + }); + + await test.step('slash command', async () => { + await page.keyboard.type('/gim'); + + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.composer.inputMessage).toHaveValue('/gimme '); + + await poHomeChannel.composer.inputMessage.fill(''); + }); + }); + + test('should list popup items correctly', async ({ page }) => { + await poHomeChannel.content.sendMessage('hello composer'); + + await test.step('mention popup', async () => { + await page.keyboard.type('hello composer @rocket.cat'); + + await expect(poHomeChannel.composer.boxPopup.getByText('rocket.cat')).toBeVisible(); + }); + }); + + test('should close mention popup when canceling a message edit via "Cancel" button', async ({ page }) => { + await poHomeChannel.content.sendMessage('hello composer @rocket.cat'); + + await test.step('expect to edit last message', async () => { + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer @rocket.cat'); + }); + + await test.step('expect to open popup on mention', async () => { + await page.keyboard.type(' @'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + }); + + await test.step('expect popup to close after the first edit is cancelled', async () => { + await poHomeChannel.composer.btnCancel.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer @rocket.cat'); + await expect(poHomeChannel.composer.boxPopup).not.toBeVisible(); + }); + + await test.step('expect to leave editing mode', async () => { + await poHomeChannel.composer.btnCancel.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + }); + + test('should close mention popup when canceling a message edit via keyboard', async ({ page }) => { + await poHomeChannel.content.sendMessage('hello composer @rocket.cat'); + + await test.step('expect to edit last message', async () => { + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer @rocket.cat'); + }); + + await test.step('expect to open popup on mention', async () => { + await page.keyboard.type(' @'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + }); + + await test.step('expect popup to close after the first edit is cancelled', async () => { + await page.keyboard.press('Escape'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue('hello composer @rocket.cat'); + await expect(poHomeChannel.composer.boxPopup).not.toBeVisible(); + }); + + await test.step('expect to leave editing mode', async () => { + await page.keyboard.press('Escape'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + }); + + test('should close mention popup after sending a message ending with a mention', async ({ page }) => { + await poHomeChannel.composer.inputMessage.click(); + + await test.step('expect to open popup on mention', async () => { + await page.keyboard.type('hello composer @rocket.cat'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + }); + + await test.step('expect popup to close after sending the message', async () => { + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + await expect(poHomeChannel.composer.boxPopup).not.toBeVisible(); + }); + }); + + test('should open mention popup on text inserted without keyboard events', async ({ page }) => { + await poHomeChannel.composer.inputMessage.click(); + + await page.keyboard.insertText('hello composer @rocket.cat'); + await expect(poHomeChannel.composer.boxPopup).toBeVisible(); + + await poHomeChannel.composer.inputMessage.fill(''); + }); + + test('should clear the composer after running a client-only slash command', async () => { + await poHomeChannel.content.dispatchSlashCommand('/shrug'); + + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('(ツ)'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + + test('should clear the composer after an unrecognized slash command', async () => { + await poHomeChannel.content.dispatchSlashCommand('/notaslashcommand'); + + await expect(poHomeChannel.content.lastUserMessageBody).toContainText('No such command'); + await expect(poHomeChannel.composer.inputMessage).toHaveValue(''); + }); + + test.describe('audio recorder', () => { + test('should open audio recorder', async () => { + await test.step('should be able to record an audio with text content in composer ', async () => { + await poHomeChannel.composer.inputMessage.fill('this is a message with audio message'); + await expect(poHomeChannel.composer.btnAudioMessage).toBeEnabled(); + }); + + await poHomeChannel.composer.btnAudioMessage.click(); + await expect(poHomeChannel.audioRecorder).toBeVisible(); + }); + + test('should stop recording when clicking on cancel', async () => { + await poHomeChannel.composer.btnAudioMessage.click(); + await expect(poHomeChannel.audioRecorder).toBeVisible(); + + await poHomeChannel.audioRecorder.getByRole('button', { name: 'Cancel recording', exact: true }).click(); + await expect(poHomeChannel.audioRecorder).not.toBeVisible(); + }); + + test('should attach file to the composer when clicking on "Finish recording"', async ({ page }) => { + await poHomeChannel.composer.btnAudioMessage.click(); + await expect(poHomeChannel.audioRecorder).toBeVisible(); + + await page.waitForTimeout(1000); + await poHomeChannel.audioRecorder.getByRole('button', { name: 'Finish Recording', exact: true }).click(); + await expect(poHomeChannel.composer.getFileByName('Audio record.mp3')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/message-mentions.spec.ts b/apps/meteor/tests/e2e/message-mentions.spec.ts new file mode 100644 index 0000000..b115342 --- /dev/null +++ b/apps/meteor/tests/e2e/message-mentions.spec.ts @@ -0,0 +1,344 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetPrivateChannel, createTargetTeam, deleteChannel, deleteTeam } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const getMentionText = (username: string, kind?: number): string => { + if (kind === 1) { + return `You mentioned ${username}, but they're not in this room.`; + } + if (kind === 2) { + return `You mentioned ${username}, but they're not in this room. You can ask a room admin to add them.`; + } + if (kind === 3) { + return `You mentioned ${username}, but they're not in this room. You let them know via DM.`; + } + return `Hello @${username}, how are you`; +}; + +test.describe.serial('Should not allow to send @all mention if permission to do so is disabled', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + let targetChannel2: string; + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'mention-all', roles: [] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect( + ( + await api.post('/permissions.update', { permissions: [{ _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user'] }] }) + ).status(), + ).toBe(200); + await deleteChannel(api, targetChannel2); + }); + + test('expect to receive an error as notification when sending @all while permission is disabled', async ({ page }) => { + const adminPage = new HomeChannel(page); + + await test.step('create private room', async () => { + targetChannel2 = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', targetChannel2); + await expect(page).toHaveURL(`/group/${targetChannel2}`); + }); + await test.step('receive notify message', async () => { + await adminPage.content.sendMessage('@all ', false); + await expect(adminPage.content.lastUserMessage).toContainText('Notify all in this room is not allowed'); + }); + }); +}); + +test.describe.serial('Should not allow to send @here mention if permission to do so is disabled', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + let targetChannel2: string; + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'mention-here', roles: [] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect( + ( + await api.post('/permissions.update', { permissions: [{ _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user'] }] }) + ).status(), + ).toBe(200); + await deleteChannel(api, targetChannel2); + }); + + test('expect to receive an error as notification when sending here while permission is disabled', async ({ page }) => { + const adminPage = new HomeChannel(page); + + await test.step('create private room', async () => { + targetChannel2 = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', targetChannel2); + await expect(page).toHaveURL(`/group/${targetChannel2}`); + }); + await test.step('receive notify message', async () => { + await adminPage.content.sendMessage('@here ', false); + await expect(adminPage.content.lastUserMessage).toContainText('Notify all in this room is not allowed'); + }); + }); +}); + +test.describe.serial('message-mentions', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test('expect show "all" and "here" options', async () => { + await poHomeChannel.navbar.openChat('general'); + await poHomeChannel.composer.inputMessage.type('@'); + + await expect(poHomeChannel.content.messagePopupUsers.locator('role=listitem >> text="@all"')).toBeVisible(); + await expect(poHomeChannel.content.messagePopupUsers.locator('role=listitem >> text="@here"')).toBeVisible(); + }); + + test.describe('users not in channel', () => { + let targetChannel: string; + let targetChannel2: string; + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetPrivateChannel(api); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test('all actions', async ({ page }) => { + const adminPage = new HomeChannel(page); + const mentionText = getMentionText(Users.user1.data.username, 1); + + await test.step('receive bot message', async () => { + await adminPage.navbar.openChat(targetChannel); + await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); + await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + + await test.step('show "Do nothing" action', async () => { + await expect(adminPage.content.getLastMessageActionButton('Do nothing')).toBeVisible(); + }); + await test.step('show "Add them" action', async () => { + await expect(adminPage.content.getLastMessageActionButton('Add them')).toBeVisible(); + }); + await test.step('show "Let them know" action', async () => { + await expect(adminPage.content.getLastMessageActionButton('Let them know')).toBeVisible(); + }); + + await test.step('dismiss', async () => { + await adminPage.content.getLastMessageActionButton('Do nothing').click(); + }); + + await test.step('receive second bot message', async () => { + await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); + await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + await test.step('send message to users', async () => { + await adminPage.content.getLastMessageActionButton('Let them know').click(); + await expect(adminPage.content.lastUserMessageBody).toContainText(getMentionText(Users.user1.data.username, 3)); + }); + + await test.step('receive third bot message', async () => { + await adminPage.content.sendMessage(getMentionText(Users.user1.data.username)); + await expect(adminPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + await test.step('add users to room', async () => { + await adminPage.content.getLastMessageActionButton('Add them').click(); + await expect(adminPage.content.lastSystemMessageBody).toContainText('added'); + }); + }); + + test('should show non-member mention warning inside threads', async ({ page }) => { + const adminPage = new HomeChannel(page); + const mentionText = getMentionText(Users.user2.data.username, 1); + + await test.step('open thread', async () => { + await adminPage.navbar.openChat(targetChannel); + await adminPage.content.sendMessage('thread parent for non-member mention warning'); + await adminPage.content.openReplyInThread(); + await adminPage.content.waitForThread(); + }); + + await test.step('receive bot message inside thread', async () => { + await adminPage.content.sendMessageInThread(getMentionText(Users.user2.data.username)); + await expect(adminPage.content.lastUserThreadMessage).toContainText(mentionText); + }); + + await test.step('show actions inside thread', async () => { + await expect(adminPage.content.getLastThreadMessageActionButton('Do nothing')).toBeVisible(); + await expect(adminPage.content.getLastThreadMessageActionButton('Add them')).toBeVisible(); + await expect(adminPage.content.getLastThreadMessageActionButton('Let them know')).toBeVisible(); + }); + }); + + test.describe(() => { + test.use({ storageState: Users.user1.state }); + + test('dismiss and share message actions', async ({ page }) => { + const mentionText = getMentionText(Users.user2.data.username, 1); + const userPage = new HomeChannel(page); + + await test.step('receive bot message', async () => { + await userPage.navbar.openChat(targetChannel); + await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + + await test.step('show "Do nothing" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Do nothing')).toBeVisible(); + }); + await test.step('show "Let them know" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Let them know')).toBeVisible(); + }); + await test.step('not show "Add them action', async () => { + await expect(userPage.content.getLastMessageActionButton('Add them')).not.toBeVisible(); + }); + + await test.step('dismiss', async () => { + await userPage.content.getLastMessageActionButton('Do nothing').click(); + }); + + await test.step('receive second bot message', async () => { + await userPage.navbar.openChat(targetChannel); + await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + await test.step('send message to users', async () => { + await userPage.content.getLastMessageActionButton('Let them know').click(); + await expect(userPage.content.lastUserMessageBody).toContainText(getMentionText(Users.user2.data.username, 3)); + }); + }); + }); + + test.describe(() => { + test.use({ storageState: Users.user1.state }); + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: ['admin'] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect( + (await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }] })).status(), + ).toBe(200); + }); + + test('dismiss and add users actions', async ({ page }) => { + const mentionText = getMentionText(Users.user2.data.username, 1); + const userPage = new HomeChannel(page); + + await test.step('create private room', async () => { + targetChannel2 = faker.string.uuid(); + + await poHomeChannel.navbar.createNew('Channel', targetChannel2); + await expect(page).toHaveURL(`/group/${targetChannel2}`); + }); + + await test.step('receive bot message', async () => { + await userPage.navbar.openChat(targetChannel2); + await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + await test.step('show "Do nothing" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Do nothing')).toBeVisible(); + }); + await test.step('show "Add them" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Add them')).toBeVisible(); + }); + await test.step('not show "Let them know" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Let them know')).not.toBeVisible(); + }); + + await test.step('dismiss', async () => { + await userPage.content.getLastMessageActionButton('Do nothing').click(); + }); + + await test.step('receive second bot message', async () => { + await userPage.navbar.openChat(targetChannel2); + await userPage.content.sendMessage(getMentionText(Users.user2.data.username)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText(mentionText); + }); + await test.step('add users to room', async () => { + await userPage.content.getLastMessageActionButton('Add them').click(); + await expect(userPage.content.lastSystemMessageBody).toContainText('added'); + }); + }); + }); + + test.describe(() => { + test.use({ storageState: Users.user2.state }); + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: ['admin'] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect( + (await api.post('/permissions.update', { permissions: [{ _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }] })).status(), + ).toBe(200); + }); + test('no actions', async ({ page }) => { + const userPage = new HomeChannel(page); + + await test.step('receive bot message', async () => { + await userPage.navbar.openChat(targetChannel2); + await userPage.content.sendMessage(getMentionText(Users.user3.data.username)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).toContainText( + getMentionText(Users.user3.data.username, 2), + ); + }); + + await test.step('not show "Do nothing" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Do nothing')).not.toBeVisible(); + }); + await test.step('not show "Add them" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Add them')).not.toBeVisible(); + }); + await test.step('not show "Let them know" action', async () => { + await expect(userPage.content.getLastMessageActionButton('Let them know')).not.toBeVisible(); + }); + }); + }); + + test.describe('team mention', () => { + let team: string; + test.use({ storageState: Users.user1.state }); + test.beforeAll(async ({ api }) => { + team = await createTargetTeam(api); + }); + + test.afterAll(async ({ api }) => { + await deleteTeam(api, team); + }); + + test('should not receive bot message', async ({ page }) => { + const userPage = new HomeChannel(page); + + await test.step('do not receive bot message', async () => { + await userPage.navbar.openChat(targetChannel); + await userPage.content.sendMessage(getMentionText(team)); + await expect(userPage.content.lastUserMessage.locator('.rcx-message-block')).not.toBeVisible(); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts b/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts new file mode 100644 index 0000000..11c5b0e --- /dev/null +++ b/apps/meteor/tests/e2e/messaging-scroll-to-bottom.spec.ts @@ -0,0 +1,119 @@ +import { faker } from '@faker-js/faker'; +import type { Locator } from 'playwright-core'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { deleteChannel } from './utils'; +import type { BaseTest } from './utils/test'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.serial('Messaging scroll to bottom', () => { + let targetChannel: { name: string; _id: string }; + let poHomeChannel: HomeChannel; + let mainMessage: { _id: string }; + + const fillMessages = async (api: BaseTest['api']) => { + const firstResponse = await api.post('/chat.postMessage', { + roomId: targetChannel._id, + text: faker.lorem.paragraphs(6), + }); + + const { message: firstMessage } = await firstResponse.json(); + mainMessage = firstMessage; + + await Promise.all( + Array.from({ length: 4 }).map(() => + api.post('/chat.postMessage', { + roomId: targetChannel._id, + text: faker.lorem.paragraphs(6), + }), + ), + ); + + await Promise.all( + Array.from({ length: 5 }).map(() => + api.post('/chat.postMessage', { + roomId: targetChannel._id, + text: faker.lorem.paragraphs(6), + tmid: firstMessage._id, + }), + ), + ); + }; + + const scrollToTop = async (scroller: Locator) => { + await scroller.evaluate((el) => { + el.scrollTop = 0; + }); + await expect(scroller).toHaveJSProperty('scrollTop', 0); + }; + + const expectScrolledToBottom = async (scroller: Locator) => { + await expect.poll(() => scroller.evaluate((el) => Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) < 2)).toBe(true); + }; + + test.beforeAll(async ({ api }) => { + await api + .post('/channels.create', { name: faker.string.uuid() }) + .then((res) => res.json()) + .then((data) => { + targetChannel = data.channel; + }); + + await fillMessages(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannelThread(targetChannel._id, mainMessage._id); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel.name); + }); + + test('should scroll the main message list to bottom when sending a message in the main channel', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 700 }); + + await scrollToTop(poHomeChannel.content.mainMessageListScroller); + + await poHomeChannel.content.sendMessage('main channel message'); + + await expectScrolledToBottom(poHomeChannel.content.mainMessageListScroller); + }); + + test('should scroll the thread message list to bottom when sending a message in the thread', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 700 }); + + await scrollToTop(poHomeChannel.content.threadMessageListScroller); + + await poHomeChannel.content.sendMessageInThread('new thread reply'); + + await expectScrolledToBottom(poHomeChannel.content.threadMessageListScroller); + }); + + test('should not scroll the main channel message list when sending a message in the thread', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 700 }); + + await scrollToTop(poHomeChannel.content.mainMessageListScroller); + await scrollToTop(poHomeChannel.content.threadMessageListScroller); + + await poHomeChannel.content.sendMessageInThread('another thread reply'); + + await expectScrolledToBottom(poHomeChannel.content.threadMessageListScroller); + await expect(poHomeChannel.content.mainMessageListScroller).toHaveJSProperty('scrollTop', 0); + }); + + test('should not scroll the thread message list when sending a message in the main channel', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 700 }); + + await scrollToTop(poHomeChannel.content.mainMessageListScroller); + await scrollToTop(poHomeChannel.content.threadMessageListScroller); + + await poHomeChannel.content.sendMessage('another main channel message'); + + await expectScrolledToBottom(poHomeChannel.content.mainMessageListScroller); + await expect(poHomeChannel.content.threadMessageListScroller).toHaveJSProperty('scrollTop', 0); + }); +}); diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts new file mode 100644 index 0000000..279a5aa --- /dev/null +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -0,0 +1,315 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('Messaging', () => { + let channelPage: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + channelPage = new HomeChannel(page); + await channelPage.goto(); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test.describe.serial('Navigation', () => { + test.beforeEach(async () => { + await channelPage.navbar.openChat(targetChannel); + // wait for the room toolbox to mount, since it's a lazy loaded component + await channelPage.roomToolbar.waitFor(); + }); + + // TODO: this should be replaced by a unit test + test('should navigate on messages using keyboard', async ({ page }) => { + await test.step('open chat and send message', async () => { + await channelPage.content.sendMessage('msg1'); + await channelPage.content.sendMessage('msg2'); + }); + + await test.step('move focus to the second message', async () => { + await page.keyboard.press('Shift+Tab'); + await expect(channelPage.content.lastUserMessage).toBeFocused(); + }); + + await test.step('move focus to the first system message', async () => { + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await expect(channelPage.content.systemMessageListItems.first()).toBeFocused(); + }); + + await test.step('move focus to the first typed message', async () => { + await page.keyboard.press('ArrowDown'); + await expect(channelPage.content.getMessageByText('msg1')).toBeFocused(); + }); + + await test.step('move focus to the room title', async () => { + const roomHeaderFavoriteBtn = channelPage.getRoomHeaderFavoriteBtn(IS_EE); + await page.keyboard.press('Shift+Tab'); + + await expect(roomHeaderFavoriteBtn).toBeFocused(); + }); + + await test.step('move focus to the channel list', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(channelPage.content.getMessageByText('msg1')).toBeFocused(); + }); + + await test.step('move focus to the message toolbar', async () => { + await channelPage.content + .getMessageByText('msg1') + .locator('[role=toolbar][aria-label="Message actions"]') + .getByRole('button', { name: 'Add reaction' }) + .waitFor(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect( + channelPage.content + .getMessageByText('msg1') + .locator('[role=toolbar][aria-label="Message actions"]') + .getByRole('button', { name: 'Add reaction' }), + ).toBeFocused(); + }); + + await test.step('move focus to the composer', async () => { + await page.keyboard.press('Tab'); + await channelPage.content + .getMessageByText('msg2') + .locator('[role=toolbar][aria-label="Message actions"]') + .getByRole('button', { name: 'Add reaction' }) + .waitFor(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(channelPage.composer.inputMessage).toBeFocused(); + }); + }); + + test('should navigate properly on the user card', async ({ page }) => { + await test.step('open UserCard', async () => { + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + await expect(channelPage.userCardToolbar).toBeVisible(); + }); + + await test.step('close UserCard with Esc', async () => { + await page.keyboard.press('Escape'); + await expect(channelPage.userCardToolbar).not.toBeVisible(); + }); + + await test.step('with focus restored reopen toolbar', async () => { + await page.keyboard.press('Space'); + await expect(channelPage.userCardToolbar).toBeVisible(); + }); + + await test.step('close UserCard with button', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + await expect(channelPage.userCardToolbar).not.toBeVisible(); + }); + }); + + test('should not restore focus on the last focused if it was triggered by click', async ({ page }) => { + await channelPage.content.getMessageByText('msg1').click(); + + await channelPage.composer.inputMessage.click(); + await page.keyboard.press('Shift+Tab'); + + await expect(channelPage.content.getMessageByText('msg2')).toBeFocused(); + }); + + test('should not focus on the last message when focusing by click', async () => { + await channelPage.content.getMessageByText('msg1').click(); + + await expect(channelPage.content.lastUserMessage).not.toBeFocused(); + }); + + test('should focus the latest message when moving the focus on the list and theres no previous focus', async ({ page }) => { + await channelPage.getBtnOpenRoomInfo(targetChannel).focus(); + await expect(channelPage.getBtnOpenRoomInfo(targetChannel)).toBeFocused(); + + await test.step('move focus to the list', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(channelPage.content.lastUserMessage).toBeFocused(); + }); + + await test.step('move focus to the list again', async () => { + await channelPage.getBtnOpenRoomInfo(targetChannel).focus(); + await expect(channelPage.getBtnOpenRoomInfo(targetChannel)).toBeFocused(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(channelPage.content.lastUserMessage).toBeFocused(); + }); + }); + }); + + test.describe.serial('Message edition', () => { + test('should edit messages', async ({ page }) => { + await channelPage.navbar.openChat(targetChannel); + + await test.step('focus on the second message', async () => { + await expect(channelPage.composer.inputMessage).toBeFocused(); + await page.keyboard.press('ArrowUp'); + + await expect(channelPage.composer.inputMessage).toHaveValue('msg2'); + }); + + await test.step('send edited message', async () => { + const editPromise = page.waitForResponse( + (response) => /api\/v1\/chat.update/.test(response.url()) && response.status() === 200 && response.request().method() === 'POST', + ); + + await channelPage.content.sendMessage('edited msg2', false); + await editPromise; + + await expect(channelPage.content.lastUserMessageBody).toHaveText('edited msg2'); + }); + + await test.step('stress test on message editions', async () => { + const editPromise = page.waitForResponse( + (response) => /api\/v1\/chat.update/.test(response.url()) && response.status() === 200 && response.request().method() === 'POST', + ); + + for (const element of ['edited msg2 a', 'edited msg2 b', 'edited msg2 c', 'edited msg2 d', 'edited msg2 e']) { + await expect(channelPage.composer.inputMessage).toBeFocused(); + await page.keyboard.press('ArrowUp'); + + await channelPage.content.sendMessage(element, false); + } + + await editPromise; + await expect(channelPage.content.lastUserMessageBody).toHaveText('edited msg2 e'); + }); + }); + }); + + test.describe('Message by "chat.postMessage" API method', () => { + test('expect show a message', async ({ api }) => { + const messageText = faker.lorem.sentence(); + + await channelPage.navbar.openChat(targetChannel); + + await api.post('/chat.postMessage', { + channel: targetChannel, + text: messageText, + }); + + await expect(channelPage.content.lastUserMessageBody).toHaveText(messageText); + }); + + test('expect show attachment text', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const attachmentText = faker.lorem.sentence(); + + await channelPage.navbar.openChat(targetChannel); + + await api.post('/chat.postMessage', { + channel: targetChannel, + text: messageText, + attachments: [ + { + text: attachmentText, + }, + ], + }); + + await expect(channelPage.content.lastUserMessageAttachment).toHaveText(attachmentText); + }); + + test('expect show attachment text with emoji', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const attachmentText = faker.lorem.sentence(); + + await channelPage.navbar.openChat(targetChannel); + + await api.post('/chat.postMessage', { + channel: targetChannel, + text: messageText, + attachments: [ + { + text: `${attachmentText} :sunglasses: `, + }, + ], + }); + + await expect(channelPage.content.lastUserMessageAttachment).toHaveText(`${attachmentText} \ud83d\ude0e `); + }); + + test('expect show attachment text without emoji inside code block', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const attachmentText = faker.lorem.sentence(); + + await channelPage.navbar.openChat(targetChannel); + + await api.post('/chat.postMessage', { + channel: targetChannel, + text: messageText, + attachments: [ + { + text: `\`\`\`${attachmentText} B) \`\`\``, + }, + ], + }); + + await expect(channelPage.content.lastUserMessageAttachment).toHaveText(`${attachmentText} B) `); + }); + }); + + test.describe('Both contexts', () => { + let auxContext: { page: Page; poHomeChannel: HomeChannel }; + test.beforeEach(async ({ browser }) => { + const { page } = await createAuxContext(browser, Users.user2); + auxContext = { page, poHomeChannel: new HomeChannel(page) }; + }); + + test.afterEach(async () => { + await auxContext.page.close(); + }); + + test('expect show "hello word" in both contexts (targetChannel)', async () => { + await channelPage.navbar.openChat(targetChannel); + + await auxContext.poHomeChannel.navbar.openChat(targetChannel); + + await channelPage.content.sendMessage('hello world'); + + await expect(async () => { + await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(channelPage.content.lastUserMessageBody).toHaveText('hello world'); + }).toPass(); + }); + + test('expect show "hello word" in both contexts (direct)', async () => { + await channelPage.navbar.openChat('user2'); + await auxContext.poHomeChannel.navbar.openChat('user1'); + + await channelPage.content.sendMessage('hello world'); + + await expect(async () => { + await expect(channelPage.content.lastUserMessageBody).toHaveText('hello world'); + await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + }).toPass(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/notification-sounds.spec.ts b/apps/meteor/tests/e2e/notification-sounds.spec.ts new file mode 100644 index 0000000..01af3e3 --- /dev/null +++ b/apps/meteor/tests/e2e/notification-sounds.spec.ts @@ -0,0 +1,152 @@ +import type { Page } from 'playwright-core'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannelAndReturnFullRoom, deleteRoom, setUserPreferences } from './utils'; +import { test, expect } from './utils/test'; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + __audioCalls: { src?: string; played?: boolean }; + } +} + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Notification Sounds', () => { + let targetChannel: string; + let targetChannelId: string; + let poHomeChannel: HomeChannel; + let user1PoHomeChannel: HomeChannel; + let user1Page: Page; + + test.beforeAll(async ({ api }) => { + const { channel } = await createTargetChannelAndReturnFullRoom(api, { + members: [Users.admin.data.username, Users.user1.data.username], + }); + targetChannel = channel.name as string; + targetChannelId = channel._id; + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, targetChannel); + }); + + test.beforeEach(async ({ page, browser }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + + user1Page = await browser.newPage({ storageState: Users.user1.state }); + user1PoHomeChannel = new HomeChannel(user1Page); + await user1PoHomeChannel.gotoChannel(targetChannel); + + await page.evaluate(() => { + Audio.prototype.play = ((fn) => + function (this: HTMLAudioElement, ...args: unknown[]) { + window.__audioCalls = { src: this.src, played: false }; + const ret = fn.call(this, ...args); + window.__audioCalls.played = true; + return ret; + })(Audio.prototype.play); + }); + }); + + test.afterEach(async () => { + await user1Page.close(); + }); + + test('should play default notification sounds', async ({ page }) => { + await poHomeChannel.content.waitForChannel(); + await user1PoHomeChannel.content.waitForChannel(); + + await poHomeChannel.navbar.btnHome.click(); + await user1PoHomeChannel.content.sendMessage(`Hello @${Users.admin.data.username} from User 1`); + + await page.waitForTimeout(100); // wait for the sound to play + + const audioCalls = await page.evaluate(() => window.__audioCalls); + expect(audioCalls).toHaveProperty('src'); + expect(audioCalls.src).toContain('chime'); + expect(audioCalls.played).toBe(true); + }); + + test.describe('Notification sound preferences', () => { + test.beforeAll(async ({ api }) => { + await setUserPreferences(api, { + newMessageNotification: 'ringtone', + }); + }); + + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { + newMessageNotification: 'chime', + }); + }); + + test('should play notification sound based on user preferences', async ({ page }) => { + await poHomeChannel.content.waitForChannel(); + await user1PoHomeChannel.content.waitForChannel(); + + await poHomeChannel.navbar.btnHome.click(); + await user1PoHomeChannel.content.sendMessage(`Hello @${Users.admin.data.username} from User 1`); + + await page.waitForTimeout(100); // wait for the sound to play + + const audioCalls = await page.evaluate(() => window.__audioCalls); + expect(audioCalls).toHaveProperty('src'); + expect(audioCalls.src).toContain('ringtone'); + expect(audioCalls.played).toBe(true); + }); + }); + + test.describe('Custom room notification preferences', () => { + test.beforeEach(async ({ api }) => { + await api.post('/rooms.saveNotification', { + roomId: targetChannelId, + notifications: { + audioNotificationValue: 'door', + }, + }); + }); + + test('should play custom room notification sound', async ({ page }) => { + await poHomeChannel.content.waitForChannel(); + await user1PoHomeChannel.content.waitForChannel(); + + await poHomeChannel.navbar.btnHome.click(); + await user1PoHomeChannel.content.sendMessage(`Hello @${Users.admin.data.username} from User 1`); + + await page.waitForTimeout(100); // wait for the sound to play + + const audioCalls = await page.evaluate(() => window.__audioCalls); + expect(audioCalls).toHaveProperty('src'); + expect(audioCalls.src).toContain('door'); + expect(audioCalls.played).toBe(true); + }); + }); + + test.describe('none sound notification preferences', () => { + test.beforeEach(async ({ api }) => { + await api.post('/rooms.saveNotification', { + roomId: targetChannelId, + notifications: { + audioNotificationValue: 'none', + }, + }); + }); + + test('should not play any notification sound', async ({ page }) => { + await poHomeChannel.content.waitForChannel(); + await user1PoHomeChannel.content.waitForChannel(); + + await poHomeChannel.navbar.btnHome.click(); + await user1PoHomeChannel.content.sendMessage(`Hello @${Users.admin.data.username} from User 1`); + + await page.waitForTimeout(100); // wait for the sound to play + + const audioCalls = await page.evaluate(() => window.__audioCalls); + expect(audioCalls).toBeUndefined(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts new file mode 100644 index 0000000..0fb27bc --- /dev/null +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -0,0 +1,173 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; + +import { BASE_URL } from './config/constants'; +import { Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import type { BaseTest } from './utils/test'; +import { test, expect } from './utils/test'; + +type ServiceConfigurations = { + configurations: Array; +}; + +const customOAuthAuthorizeUrlPattern = /https:\/\/(www\.)?rocket\.chat\/(oauth\/)?authorize/; +const googleOAuthService = 'google'; +const customOAuthService = 'test'; + +const getOAuthService = async ( + api: BaseTest['api'], + service: string, +): Promise => { + const response = await api.get('/service.configurations'); + await expect(response).toBeOK(); + const { configurations } = (await response.json()) as ServiceConfigurations; + + return configurations.find((configuration) => configuration.service === service); +}; + +const getOAuthServiceLoginStyle = async (api: BaseTest['api'], serviceName: string): Promise => { + return (await getOAuthService(api, serviceName))?.loginStyle; +}; + +test.describe('OAuth', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + }); + + test.describe('Login Page', () => { + test.afterEach(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Google', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + }); + + test('Login Page', async ({ page, api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await expect.poll(async () => (await getOAuthService(api, googleOAuthService))?.service).toBe(googleOAuthService); + + await poRegistration.goto(); + + await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); + }); + + await test.step('expect Custom OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(async () => (await getOAuthService(api, customOAuthService))?.service).toBe(customOAuthService); + await poRegistration.goto(); + + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); + + await test.step('expect redirect to the configured URL.', async () => { + await poRegistration.btnLoginWithCustomOAuth.click(); + await expect(page).toHaveURL(/https:\/\/(www)?\.rocket\.chat/); + }); + + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await expect.poll(() => getOAuthService(api, googleOAuthService)).toBeUndefined(); + + await poRegistration.goto(); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + }); + + await test.step('expect Custom OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false)).status()).toBe(200); + await expect.poll(() => getOAuthService(api, customOAuthService)).toBeUndefined(); + + await poRegistration.goto(); + await expect(poRegistration.btnLoginWithCustomOAuth).not.toBeVisible(); + }); + }); + }); + + test.describe('Pop-up login style', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'popup')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('popup'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + }); + + test('open authorize flow in a popup', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible', async () => { + await poRegistration.goto(); + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); + + await test.step('expect authorize flow to be opened in a popup', async () => { + const [popup] = await Promise.all([page.waitForEvent('popup'), poRegistration.btnLoginWithCustomOAuth.click()]); + + await expect(popup).toHaveURL(customOAuthAuthorizeUrlPattern); + await expect(page).toHaveURL(/\/home/); + await popup.close(); + }); + }); + }); + + test.describe('Proxy Redirect', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', 'test')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Use_Modern_Flow', false)).status()).toBe(200); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('redirect'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', ''); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Use_Modern_Flow', true)).status()).toBe(200); + }); + + test('redirect login through the proxy', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible', async () => { + await poRegistration.goto(); + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); + + await test.step('expect authorize flow to be redirected through the proxy', async () => { + await poRegistration.btnLoginWithCustomOAuth.click(); + await expect(page).toHaveURL(/^https:\/\/oauth-proxy\.rocket\.chat\/redirect\//); + }); + }); + }); + + test.describe('Iframe login', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('redirect'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + }); + + test('login flow in iframe', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible in iframe', async () => { + await page.goto('/'); + await page.setContent(``); + await expect(page.frameLocator('iframe[title="login"]').getByRole('button', { name: 'Sign in with Test' })).toBeVisible(); + }); + + await test.step('expect authorize flow to be started from the iframe', async () => { + const [oauthRequest] = await Promise.all([ + page.waitForRequest(customOAuthAuthorizeUrlPattern), + page.frameLocator('iframe[title="login"]').getByRole('button', { name: 'Sign in with Test' }).click(), + ]); + + expect(oauthRequest.url()).toMatch(customOAuthAuthorizeUrlPattern); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts new file mode 100644 index 0000000..e9abd0a --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts @@ -0,0 +1,191 @@ +import { createFakeDepartment } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelAgents } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('OC - Manage Agents', () => { + let poOmnichannelAgents: OmnichannelAgents; + let department: Awaited>; + + // Create agent and department + test.beforeEach(async ({ api }) => { + department = await createDepartment(api); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelAgents = new OmnichannelAgents(page); + await poOmnichannelAgents.goto(); + }); + + test.beforeAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); + }); + + // Ensure that there is no leftover data even if test fails + test.afterEach(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await department.delete(); + }); + + test('OC - Manage Agents - Add, search and remove using table', async ({ page }) => { + await test.step('expect "user1" be first ', async () => { + await poOmnichannelAgents.inputUsername.type('user'); + await expect(page.locator('role=option[name="user1"]')).toContainText('user1'); + + await poOmnichannelAgents.inputUsername.fill(''); + }); + + await test.step('expect add "user1" as agent', async () => { + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + + await poOmnichannelAgents.search('user1'); + await expect(poOmnichannelAgents.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect remove "user1" as agent', async () => { + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.deleteAgent('user1'); + + await poOmnichannelAgents.search('user1'); + await expect(poOmnichannelAgents.table.findRowByName('user1')).not.toBeVisible(); + }); + }); + + test('OC - Manage Agents [CE]- Edit and Remove', async () => { + test.skip(IS_EE, 'Community Edition Only'); + + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.table.findRowByName('user1').click(); + await poOmnichannelAgents.agentInfo.btnEdit.click(); + + await test.step('expect max chats fields to be hidden', async () => { + await expect(poOmnichannelAgents.editAgent.inputMaxChats).toBeHidden(); + }); + + await test.step('expect update "user1" information', async () => { + await poOmnichannelAgents.editAgent.selectStatus('Not Available'); + await poOmnichannelAgents.editAgent.selectDepartment(department.data.name); + await poOmnichannelAgents.editAgent.save(); + }); + + await test.step('expect removing "user1" via sidebar', async () => { + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.table.findRowByName('user1').click(); + await poOmnichannelAgents.agentInfo.btnRemove.click(); + }); + }); + + test('OC - Manage Agents [EE] - Edit ', async () => { + test.skip(!IS_EE, 'Enterprise Only'); + + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.table.findRowByName('user1').click(); + await poOmnichannelAgents.agentInfo.btnEdit.click(); + + await test.step('expect max chats field to be visible', async () => { + await expect(poOmnichannelAgents.editAgent.inputMaxChats).toBeVisible(); + }); + + await test.step('expect update "user1" information', async () => { + await poOmnichannelAgents.editAgent.inputMaxChats.click(); + await poOmnichannelAgents.editAgent.inputMaxChats.fill('2'); + await poOmnichannelAgents.editAgent.btnSave.click(); + }); + }); + + test('OC - Edit agent - Manage departments', async ({ page }) => { + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.table.findRowByName('user1').click(); + + await poOmnichannelAgents.agentInfo.btnEdit.click(); + await poOmnichannelAgents.editAgent.selectDepartment(department.data.name); + const response = page.waitForResponse('**/api/v1/livechat/agents.saveInfo'); + await poOmnichannelAgents.editAgent.btnSave.click(); + + /** + * between saving and opening the agent info again it is necessary to + * wait for the agent to be saved, since after successfully saving + * the contextual bar is closed + * otherwise content will be closed even if the current one is not the editing one + */ + + await response; + + await expect(poOmnichannelAgents.editAgent.root).not.toBeVisible(); + + await test.step('expect the selected department is visible', async () => { + await poOmnichannelAgents.table.findRowByName('user1').click(); + + // mock the endpoint to use the one without pagination + await page.route('/api/v1/livechat/department?showArchived=true', async (route) => { + await route.fulfill({ json: { departments: [] } }); + }); + + await poOmnichannelAgents.agentInfo.btnEdit.click(); + await expect(poOmnichannelAgents.editAgent.findSelectedDepartment(department.data.name)).toBeVisible(); + }); + }); + + test('OC - Edit agent - Departments pagination', async ({ page }) => { + const data = Array.from({ length: 100 }, (_, i) => createFakeDepartment({ name: `Department ${i}` })); + await page.route('**/v1/livechat/department*', async (route, request) => { + const url = new URL(request.url()); + const offset = parseInt(url.searchParams.get('offset') || '0', 10); + const count = parseInt(url.searchParams.get('count') || '50', 10); + const departments = data.slice(offset, offset + count); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ departments, offset, count, total: data.length }), + }); + }); + + await test.step('expect to add agent', async () => { + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + }); + + await test.step('expect to edit agent', async () => { + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.table.findRowByName('user1').click(); + await poOmnichannelAgents.agentInfo.btnEdit.click(); + }); + + await test.step('expect department field to be paginated', async () => { + await poOmnichannelAgents.editAgent.inputDepartment.click(); + await expect(poOmnichannelAgents.editAgent.getDepartmentOption('Department 0')).toBeVisible(); + await poOmnichannelAgents.scrollToListBottom(); + await expect(poOmnichannelAgents.editAgent.getDepartmentOption('Department 49')).toBeVisible(); + await poOmnichannelAgents.scrollToListBottom(); + await expect(poOmnichannelAgents.editAgent.getDepartmentOption('Department 99')).toBeVisible(); + await poOmnichannelAgents.editAgent.close(); + }); + + await test.step('expect remove "user1" as agent', async () => { + await poOmnichannelAgents.search('user1'); + await poOmnichannelAgents.deleteAgent('user1'); + + await poOmnichannelAgents.search('user1'); + await expect(poOmnichannelAgents.table.findRowByName('user1')).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts new file mode 100644 index 0000000..c087fb9 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-appearance.spec.ts @@ -0,0 +1,102 @@ +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLivechatAppearance } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('OC - Livechat Appearance - EE', () => { + let poLivechatAppearance: OmnichannelLivechatAppearance; + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeEach(async ({ page }) => { + poLivechatAppearance = new OmnichannelLivechatAppearance(page); + await poLivechatAppearance.goto(); + }); + + test.afterAll(async ({ api }) => { + const res = await Promise.all([ + api.post('/settings/Livechat_hide_system_messages', { value: ['uj', 'ul', 'livechat-close'] }), + api.post('/settings/Livechat_background', { value: '' }), + ]); + + if (res.some((r) => r.status() !== 200)) { + throw new Error('Failed to reset settings'); + } + }); + + test('OC - Livechat Appearance - Hide system messages', async ({ page }) => { + await test.step('expect to have default values', async () => { + await poLivechatAppearance.inputHideSystemMessages.click(); + await expect(poLivechatAppearance.findHideSystemMessageOption('uj')).toHaveAttribute('aria-selected', 'true'); + await expect(poLivechatAppearance.findHideSystemMessageOption('ul')).toHaveAttribute('aria-selected', 'true'); + await expect(poLivechatAppearance.findHideSystemMessageOption('livechat-close')).toHaveAttribute('aria-selected', 'true'); + await poLivechatAppearance.inputHideSystemMessages.click(); + }); + + await test.step('expect to change values', async () => { + await poLivechatAppearance.inputHideSystemMessages.click(); + await poLivechatAppearance.findHideSystemMessageOption('livechat_transfer_history').click(); + await poLivechatAppearance.findHideSystemMessageOption('livechat-close').click(); + await poLivechatAppearance.btnSaveChanges.click(); + }); + + await test.step('expect to have saved changes', async () => { + await page.reload(); + await poLivechatAppearance.inputHideSystemMessages.click(); + await expect(poLivechatAppearance.findHideSystemMessageOption('uj')).toHaveAttribute('aria-selected', 'true'); + await expect(poLivechatAppearance.findHideSystemMessageOption('ul')).toHaveAttribute('aria-selected', 'true'); + await expect(poLivechatAppearance.findHideSystemMessageOption('livechat_transfer_history')).toHaveAttribute('aria-selected', 'true'); + await expect(poLivechatAppearance.findHideSystemMessageOption('livechat-close')).toHaveAttribute('aria-selected', 'false'); + }); + }); + + test('OC - Livechat Appearance - Change Livechat background', async ({ page }) => { + await test.step('expect to have default value', async () => { + await expect(await poLivechatAppearance.inputLivechatBackground).toHaveValue(''); + }); + + await test.step('expect to change value', async () => { + await poLivechatAppearance.inputLivechatBackground.fill('rgb(186, 1, 85)'); + await poLivechatAppearance.btnSaveChanges.click(); + }); + + await test.step('expect to have saved changes', async () => { + await page.reload(); + await expect(poLivechatAppearance.inputLivechatBackground).toHaveValue('rgb(186, 1, 85)'); + }); + }); +}); + +test.describe('OC - Livechat Appearance - CE', () => { + let poLivechatAppearance: OmnichannelLivechatAppearance; + + test.beforeEach(async ({ page }) => { + poLivechatAppearance = new OmnichannelLivechatAppearance(page); + await poLivechatAppearance.goto(); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_title', { value: 'Rocket.Chat' }); + + if (res.status() !== 200) { + throw new Error('Failed to reset settings'); + } + }); + + test('OC - Livechat Appearance - Change Livechat Title', async ({ page }) => { + await test.step('expect to have default value', async () => { + await expect(poLivechatAppearance.inputLivechatTitle).toHaveValue('Rocket.Chat'); + }); + + await test.step('expect to change value', async () => { + await poLivechatAppearance.inputLivechatTitle.fill('Test Title'); + await poLivechatAppearance.btnSaveChanges.click(); + }); + + await test.step('expect to have saved changes', async () => { + await page.reload(); + await expect(poLivechatAppearance.inputLivechatTitle).toHaveValue('Test Title'); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts new file mode 100644 index 0000000..2682541 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -0,0 +1,147 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { createTag } from '../utils/omnichannel/tags'; +import { test, expect } from '../utils/test'; + +const visitorA = createFakeVisitor(); +const visitorB = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Tags Visibility', () => { + test.skip(!IS_EE, 'OC - Tags Visibility > Enterprise Edition Only'); + + let poOmnichannel: HomeOmnichannel; + let conversations: Awaited>[] = []; + let departmentA: Awaited>; + let departmentB: Awaited>; + let agent: Awaited>; + let tagA: Awaited>; + let tagB: Awaited>; + let globalTag: Awaited>; + let sharedTag: Awaited>; + + test.beforeAll('Create departments', async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); + departmentA = await createDepartment(api, { name: 'Department A' }); + departmentB = await createDepartment(api, { name: 'Department B' }); + }); + + test.beforeAll('Create agent', async ({ api }) => { + agent = await createAgent(api, 'user1'); + }); + + test.beforeAll('Add agents to departments', async ({ api }) => { + await Promise.all([ + addAgentToDepartment(api, { department: departmentA.data, agentId: 'user1' }), + addAgentToDepartment(api, { department: departmentB.data, agentId: 'user1' }), + ]); + }); + + test.beforeAll('Create tags', async ({ api }) => { + tagA = await createTag(api, { departments: [departmentA.data._id] }); + tagB = await createTag(api, { departments: [departmentB.data._id] }); + globalTag = await createTag(api, { departments: [] }); + sharedTag = await createTag(api, { + departments: [departmentA.data._id, departmentB.data._id], + }); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannel = new HomeOmnichannel(page); + await poOmnichannel.goto(); + }); + + test.beforeEach(async ({ api }) => { + if (conversations.length === 0) { + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA.name, + agentId: 'user1', + departmentId: departmentA.data._id, + }), + createConversation(api, { + visitorName: visitorB.name, + agentId: 'user1', + departmentId: departmentB.data._id, + }), + ]); + } + }); + + test.afterAll(async ({ api }) => { + await Promise.all(conversations.map((conversation) => conversation.delete())); + await Promise.all([tagA, tagB, globalTag, sharedTag].map((tag) => tag.delete())); + await agent.delete(); + await departmentA.delete(); + await departmentB.delete(); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); + }); + + test('Verify agent should see correct tags based on department association', async () => { + await test.step('Agent opens room', async () => { + await poOmnichannel.navbar.openChat(visitorA.name); + }); + + await test.step('should not be able to see tags field', async () => { + await expect(poOmnichannel.roomInfo.getLabel('Tags')).not.toBeVisible(); + }); + + await test.step('check available tags', async () => { + await poOmnichannel.roomInfo.btnEdit.click(); + await expect(poOmnichannel.editRoomInfo.root).toBeVisible(); + await poOmnichannel.editRoomInfo.inputTags.click(); + }); + + await test.step('Should see TagA (department A specific)', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(tagA.data.name)).toBeVisible(); + }); + + await test.step('Should see SharedTag (both departments)', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(sharedTag.data.name)).toBeVisible(); + }); + + await test.step('Should see Public Tags for all chats (no department restriction)', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(globalTag.data.name)).toBeVisible(); + }); + + await test.step('Should not see TagB (department B specific)', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(tagB.data.name)).not.toBeVisible(); + }); + + await test.step('add tags and save', async () => { + await poOmnichannel.editRoomInfo.selectTag(tagA.data.name); + await poOmnichannel.editRoomInfo.selectTag(globalTag.data.name); + await poOmnichannel.editRoomInfo.btnSave.click(); + }); + + await test.step('verify selected tags are displayed under room information', async () => { + await expect(poOmnichannel.roomInfo.getLabel('Tags')).toBeVisible(); + await expect(poOmnichannel.roomInfo.getTagInfoByLabel(tagA.data.name)).toBeVisible(); + await expect(poOmnichannel.roomInfo.getTagInfoByLabel(globalTag.data.name)).toBeVisible(); + }); + }); + + test('Verify tags visibility for agent associated with multiple departments', async () => { + await test.step('Open room info', async () => { + await poOmnichannel.navbar.openChat(visitorB.name); + await poOmnichannel.roomInfo.btnEdit.click(); + await expect(poOmnichannel.editRoomInfo.root).toBeVisible(); + await poOmnichannel.editRoomInfo.inputTags.click(); + }); + + await test.step('Agent associated with DepartmentB should be able to see tags for Department B', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(tagB.data.name)).toBeVisible(); + }); + + await test.step('Agent associated with DepartmentB should not be able to see tags for DepartmentA', async () => { + await expect(poOmnichannel.editRoomInfo.optionTag(tagA.data.name)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-onhold-chat-closing.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-onhold-chat-closing.spec.ts new file mode 100644 index 0000000..0d6a39a --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-onhold-chat-closing.spec.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('omnichannel-auto-onhold-chat-closing', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_auto_close_on_hold_chats_timeout', { value: 5 }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_allow_manual_on_hold', { value: true }).then((res) => expect(res.status()).toBe(200)), + ]); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + }); + test.afterAll(async ({ api }) => { + await agent.page.close(); + + await Promise.all([ + api.delete('/livechat/users/agent/user1').then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_auto_close_on_hold_chats_timeout', { value: 3600 }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_allow_manual_on_hold', { value: false }).then((res) => expect(res.status()).toBe(200)), + ]); + }); + + test.beforeEach(async ({ page, api }) => { + // make "user-1" online + await agent.poHomeChannel.navbar.changeUserStatus('online'); + + // start a new chat for each test + newVisitor = createFakeVisitor(); + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + // Note: Skipping this test as the scheduler is gonna take 1 minute to process now + // And waiting for 1 minute in a test is horrible + test.skip('expect on-hold chat to be closed automatically in 5 seconds', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + await agent.poHomeChannel.content.sendMessage('this_is_a_test_message_from_agent'); + + await agent.poHomeChannel.quickActionsRoomToolbar.placeChatOnHold(); + + await agent.poHomeChannel.content.btnModalConfirm.click(); + + // expect to see a system message saying the chat was on-hold + await expect(agent.poHomeChannel.content.lastSystemMessageBody).toHaveText( + `Chat On Hold: The chat was manually placed On Hold by user1`, + ); + await expect(agent.poHomeChannel.composer.inputMessage).not.toBeVisible(); + await expect(agent.poHomeChannel.content.resumeOnHoldOmnichannelChatButton).toBeVisible(); + + // current url + const chatRoomUrl = agent.page.url(); + + // wait for the chat to be closed automatically for 5 seconds + await agent.page.waitForTimeout(7000); + + // expect to see a system message saying the chat was closed automatically in the closed chat room + await agent.page.goto(chatRoomUrl); + expect(await agent.poHomeChannel.content.lastSystemMessageBody.innerText()).toBe( + 'Conversation closed: Closed automatically because chat was On Hold for 5 seconds.', + ); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-transfer-unanswered-chat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-transfer-unanswered-chat.spec.ts new file mode 100644 index 0000000..c1bc089 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-auto-transfer-unanswered-chat.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('omnichannel-auto-transfer-unanswered-chat', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent1: { page: Page; poHomeChannel: HomeChannel }; + let agent2: { page: Page; poHomeChannel: HomeChannel }; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }).then((res) => expect(res.status()).toBe(200)), + api.post('/livechat/users/agent', { username: 'user2' }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }).then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_auto_transfer_chat_timeout', { value: 5 }).then((res) => expect(res.status()).toBe(200)), + ]); + + const { page } = await createAuxContext(browser, Users.user1); + agent1 = { page, poHomeChannel: new HomeChannel(page) }; + + const { page: page2 } = await createAuxContext(browser, Users.user2); + agent2 = { page: page2, poHomeChannel: new HomeChannel(page2) }; + }); + + test.afterAll(async ({ api }) => { + await agent1.page.close(); + await agent2.page.close(); + + await Promise.all([ + api.delete('/livechat/users/agent/user1').then((res) => expect(res.status()).toBe(200)), + api.delete('/livechat/users/agent/user2').then((res) => expect(res.status()).toBe(200)), + api.post('/settings/Livechat_auto_transfer_chat_timeout', { value: 0 }).then((res) => expect(res.status()).toBe(200)), + ]); + }); + + test.beforeEach(async ({ page, api }) => { + // make "user-1" online + await agent1.poHomeChannel.navbar.switchOmnichannelStatus('online'); + await agent2.poHomeChannel.navbar.switchOmnichannelStatus('offline'); + + // start a new chat for each test + newVisitor = createFakeVisitor(); + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + test('expect chat to be auto transferred to next agent within 5 seconds of no reply from first agent', async () => { + await agent1.poHomeChannel.navbar.openChat(newVisitor.name); + + await agent2.poHomeChannel.navbar.switchOmnichannelStatus('online'); + + // wait for the chat to be closed automatically for 5 seconds + await agent1.page.waitForTimeout(7000); + + await agent2.poHomeChannel.navbar.openChat(newVisitor.name); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts new file mode 100644 index 0000000..5a3c60e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts @@ -0,0 +1,163 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelBusinessHours } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createBusinessHour } from '../utils/omnichannel/businessHours'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +// TODO: These tests needs to be refactored to be independent from each other +test.describe('OC - Business Hours', () => { + test.skip(!IS_EE, 'OC - Manage Business Hours > Enterprise Edition Only'); + + let poOmnichannelBusinessHours: OmnichannelBusinessHours; + let department: Awaited>; + let department2: Awaited>; + let agent: Awaited>; + + const BHName = 'TEST Business Hours'; + + test.beforeAll(async ({ api }) => { + department = await createDepartment(api); + department2 = await createDepartment(api); + agent = await createAgent(api, 'user2'); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); + await api.post('/settings/Livechat_enable_business_hours', { value: true }).then((res) => expect(res.status()).toBe(200)); + await api.post('/settings/Livechat_business_hour_type', { value: 'Multiple' }).then((res) => expect(res.status()).toBe(200)); + }); + + test.afterAll(async ({ api }) => { + await department.delete(); + await department2.delete(); + await agent.delete(); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); + await api.post('/settings/Livechat_enable_business_hours', { value: false }).then((res) => expect(res.status()).toBe(200)); + await api.post('/settings/Livechat_business_hour_type', { value: 'Single' }).then((res) => expect(res.status()).toBe(200)); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelBusinessHours = new OmnichannelBusinessHours(page); + }); + + test('OC - Manage Business Hours - Create Business Hours', async () => { + await poOmnichannelBusinessHours.goto(); + + await test.step('expect correct form default state', async () => { + await poOmnichannelBusinessHours.btnCreateBusinessHour.click(); + await poOmnichannelBusinessHours.btnBack.click(); + await expect(poOmnichannelBusinessHours.inputSearch).toBeVisible(); + }); + + await test.step('expect to create a new business hours', async () => { + await poOmnichannelBusinessHours.btnCreateBusinessHour.click(); + await poOmnichannelBusinessHours.inputName.fill(BHName); + await poOmnichannelBusinessHours.selectDepartment(department.data.name); + await poOmnichannelBusinessHours.btnSave.click(); + + await test.step('expect business hours to have been created', async () => { + await poOmnichannelBusinessHours.search(BHName); + await expect(poOmnichannelBusinessHours.table.findRowByName(BHName)).toBeVisible(); + }); + }); + + await test.step('expect to be able to delete business hours', async () => { + await poOmnichannelBusinessHours.deleteBusinessHour(BHName); + }); + + await test.step('expect business hours to have been deleted', async () => { + await poOmnichannelBusinessHours.search(BHName); + await expect(poOmnichannelBusinessHours.table.findRowByName(BHName)).not.toBeVisible(); + }); + }); + + test('OC - Business hours - Edit BH departments', async ({ api }) => { + await test.step('expect to create new businessHours', async () => { + const createBH = await createBusinessHour(api, { + name: BHName, + departments: [department.data._id], + }); + + expect(createBH.status()).toBe(200); + }); + + await poOmnichannelBusinessHours.goto(); + + await test.step('expect to add business hours departments', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + await poOmnichannelBusinessHours.selectDepartment(department2.data.name); + await poOmnichannelBusinessHours.btnSave.click(); + await expect(poOmnichannelBusinessHours.btnSave).not.toBeVisible(); + }); + + await test.step('expect department to be in the chosen departments list', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + await expect(poOmnichannelBusinessHours.findDepartmentsChipOption(department2.data.name)).toBeVisible(); + await poOmnichannelBusinessHours.btnBack.click(); + }); + + await test.step('expect to remove business hours departments', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + await poOmnichannelBusinessHours.selectDepartment(department2.data.name); + await poOmnichannelBusinessHours.btnSave.click(); + await expect(poOmnichannelBusinessHours.btnSave).not.toBeVisible(); + }); + + await test.step('expect department to not be in the chosen departments list', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + await expect(poOmnichannelBusinessHours.findDepartmentsChipOption(department2.data.name)).toBeHidden(); + await poOmnichannelBusinessHours.btnBack.click(); + }); + + await test.step('expect delete business hours', async () => { + await poOmnichannelBusinessHours.deleteBusinessHour(BHName); + }); + }); + + test('OC - Business hours - Toggle BH active status', async ({ api }) => { + await test.step('expect to create new businessHours', async () => { + const createBH = await createBusinessHour(api, { + name: BHName, + departments: [department.data._id], + }); + + expect(createBH.status()).toBe(200); + }); + + await poOmnichannelBusinessHours.goto(); + + await test.step('expect to disable business hours', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + + await poOmnichannelBusinessHours.getCheckboxByLabel('Enabled').click(); + await expect(poOmnichannelBusinessHours.getCheckboxByLabel('Enabled')).not.toBeChecked(); + + await poOmnichannelBusinessHours.btnSave.click(); + await expect(poOmnichannelBusinessHours.btnSave).not.toBeVisible(); + }); + + await test.step('expect to enable business hours', async () => { + await poOmnichannelBusinessHours.search(BHName); + await poOmnichannelBusinessHours.table.findRowByName(BHName).click(); + + await poOmnichannelBusinessHours.getCheckboxByLabel('Enabled').click(); + await expect(poOmnichannelBusinessHours.getCheckboxByLabel('Enabled')).toBeChecked(); + + await poOmnichannelBusinessHours.btnSave.click(); + await expect(poOmnichannelBusinessHours.btnSave).not.toBeVisible(); + }); + + await test.step('expect delete business hours', async () => { + await poOmnichannelBusinessHours.deleteBusinessHour(BHName); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-sidebar.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-sidebar.spec.ts new file mode 100644 index 0000000..edf7fd1 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-sidebar.spec.ts @@ -0,0 +1,93 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test } from '../utils/test'; + +test.describe.serial('OC - Canned Responses Sidebar', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + + const cannedResponseName = faker.string.uuid(); + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll('close livechat conversation', async () => { + await agent.poHomeChannel.quickActionsRoomToolbar.closeChat(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + poLiveChat.page.close(), + agent.page.close(), + ]); + }); + + test('OC - Canned Responses Sidebar - Create', async () => { + await test.step('expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to be able to open canned responses sidebar and creation', async () => { + await agent.poHomeChannel.roomToolbar.openCannedResponses(); + }); + + await test.step('expect to create new canned response', async () => { + await agent.poHomeChannel.content.btnNewCannedResponse.click(); + await agent.poHomeChannel.cannedResponses.inputShortcut.fill(cannedResponseName); + await agent.poHomeChannel.cannedResponses.inputMessage.fill(faker.lorem.paragraph()); + await agent.poHomeChannel.cannedResponses.addTag(faker.commerce.department()); + await agent.poHomeChannel.cannedResponses.radioPublic.click(); + await agent.poHomeChannel.cannedResponses.btnSave.click(); + }); + }); + + test('OC - Canned Responses Sidebar - Edit', async () => { + await test.step('expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to be able to open canned responses sidebar and creation', async () => { + await agent.poHomeChannel.roomToolbar.openCannedResponses(); + }); + + await test.step('expect to edit canned response', async () => { + await agent.poHomeChannel.cannedResponses.listItem(cannedResponseName).click(); + await agent.poHomeChannel.cannedResponses.btnEdit.click(); + await agent.poHomeChannel.cannedResponses.radioPrivate.click(); + await agent.poHomeChannel.cannedResponses.btnSave.click(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts new file mode 100644 index 0000000..da8bea2 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-canned-responses-usage.spec.ts @@ -0,0 +1,212 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('OC - Canned Responses Usage', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + let cannedResponseId: string; + let placeholderResponseId: string; + let secondResponseId: string; + + const cannedResponseName = `test-${faker.string.uuid()}`; + const cannedResponseText = 'Hello! Welcome to our support chat. How can I assist you today?'; + const cannedResponseWithPlaceholder = `Hello {{contact.name}}, thank you for contacting us!`; + const placeholderResponseName = `placeholder-${faker.string.uuid()}`; + const secondResponseName = `followup-${faker.string.uuid()}`; + const secondResponseText = 'Is there anything else I can help you with today?'; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + api.post('/canned-responses', { + shortcut: cannedResponseName, + text: cannedResponseText, + scope: 'global', + tags: ['tag'], + }), + api.post('/canned-responses', { + shortcut: placeholderResponseName, + text: cannedResponseWithPlaceholder, + scope: 'global', + tags: ['tag'], + }), + api.post('/canned-responses', { + shortcut: secondResponseName, + text: secondResponseText, + scope: 'global', + tags: ['tag'], + }), + ]); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + + const [mainResponse, placeholderResponse, secondResponse] = await Promise.all([ + api.get('/canned-responses', { shortcut: cannedResponseName }), + api.get('/canned-responses', { shortcut: placeholderResponseName }), + api.get('/canned-responses', { shortcut: secondResponseName }), + ]); + + const [mainData, placeholderData, secondData] = await Promise.all([ + mainResponse.json(), + placeholderResponse.json(), + secondResponse.json(), + ]); + + cannedResponseId = mainData.cannedResponses[0]._id; + placeholderResponseId = placeholderData.cannedResponses[0]._id; + secondResponseId = secondData.cannedResponses[0]._id; + }); + + test.beforeEach(async ({ page, api }) => { + newVisitor = createFakeVisitor(); + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterEach('close livechat conversation', async () => { + await poLiveChat.closeChat(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.delete('/canned-responses', { _id: cannedResponseId }), + api.delete('/canned-responses', { _id: placeholderResponseId }), + api.delete('/canned-responses', { _id: secondResponseId }), + agent.poHomeChannel.page.close(), + ]); + }); + + test('OC - Canned Responses Usage - Create response and use in chat', async () => { + await test.step('expect to start a visitor conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('expect agent to receive and open the chat', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to use the canned response in the chat', async () => { + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); + }); + + await test.step('expect canned response text to appear in message composer', async () => { + await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${cannedResponseText} `); + }); + + await test.step('expect agent to be able to send the canned response message', async () => { + await agent.poHomeChannel.content.sendMessage(cannedResponseText); + }); + + await test.step('expect visitor to receive the canned response message', async () => { + await expect(poLiveChat.txtChatMessage(cannedResponseText)).toBeVisible(); + }); + }); + + test('OC - Canned Responses Usage - Use response with placeholder replacement', async () => { + await test.step('expect to start a new visitor conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('test_message_for_placeholder'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('expect agent to receive and open the chat', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to use the canned response with placeholder', async () => { + await agent.poHomeChannel.content.selectCannedResponse(placeholderResponseName); + }); + + await test.step('expect placeholder to be replaced with actual visitor name', async () => { + await agent.poHomeChannel.content.sendMessage(cannedResponseWithPlaceholder); + await expect(poLiveChat.txtChatMessage('{{contact.name}}')).not.toBeVisible(); + }); + }); + + test('OC - Canned Responses Usage - Modify response before sending', async () => { + const modifiedText = `Additionally, I'll be happy to help you with any specific questions.`; + + await test.step('expect to start a visitor conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('test_message_for_modification'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('expect agent to open the chat', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to use existing canned response and modify it', async () => { + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); + }); + + await test.step('expect to modify the canned response text before sending', async () => { + await agent.poHomeChannel.composer.inputMessage.click(); + await agent.page.keyboard.press('End'); + await agent.poHomeChannel.composer.inputMessage.pressSequentially(modifiedText); + + await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${cannedResponseText} ${modifiedText}`); + }); + + await test.step('expect to send the modified message', async () => { + await agent.page.keyboard.press('Enter'); + }); + + await test.step('expect visitor to receive the modified message', async () => { + await expect(poLiveChat.txtChatMessage(`${cannedResponseText} ${modifiedText}`)).toBeVisible(); + }); + }); + + test('OC - Canned Responses Usage - Use multiple responses in sequence', async () => { + await test.step('expect to start a visitor conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('test_message_for_sequence'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('expect agent to open the chat', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to use first canned response', async () => { + await agent.poHomeChannel.content.selectCannedResponse(cannedResponseName); + await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${cannedResponseText} `); + await agent.page.keyboard.press('Enter'); + }); + + await test.step('expect to use second canned response', async () => { + await agent.poHomeChannel.content.selectCannedResponse(secondResponseName); + await expect(agent.poHomeChannel.composer.inputMessage).toHaveValue(`${secondResponseText} `); + await agent.page.keyboard.press('Enter'); + }); + + await test.step('expect visitor to receive both messages in correct order', async () => { + await expect(poLiveChat.txtChatMessage(cannedResponseText)).toBeVisible(); + await expect(poLiveChat.txtChatMessage(secondResponseText)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-changing-room-priority-and-sla.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-changing-room-priority-and-sla.spec.ts new file mode 100644 index 0000000..8e72ad4 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-changing-room-priority-and-sla.spec.ts @@ -0,0 +1,106 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { ADMIN_CREDENTIALS, IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { getPriorityByi18nLabel } from '../utils/omnichannel/priority'; +import { createSLA } from '../utils/omnichannel/sla'; +import { test, expect } from '../utils/test'; + +const getRoomId = (page: Page): string => { + // url is of the form: http://localhost:3000/live/:rid/room-info + const url = page?.url(); + // rid comes after /live/ and before /room-info (or /) + const rid = url?.split('/live/')[1].split('/')[0]; + if (!rid) { + throw new Error(`Could not get room id from url: ${page.url()}`); + } + return rid; +}; + +test.describe.serial('omnichannel-changing-room-priority-and-sla', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeChannel }; + + test.beforeAll(async ({ api, browser }) => { + let statusCode = (await api.post('/livechat/users/agent', { username: ADMIN_CREDENTIALS.username })).status(); + expect(statusCode).toBe(200); + + statusCode = (await api.post('/livechat/users/manager', { username: ADMIN_CREDENTIALS.username })).status(); + expect(statusCode).toBe(200); + + statusCode = (await api.post('/livechat/agent.status', { status: 'available' })).status(); + expect(statusCode).toBe(200); + + statusCode = (await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' })).status(); + expect(statusCode).toBe(200); + + const { page } = await createAuxContext(browser, Users.admin); + agent = { page, poHomeChannel: new HomeChannel(page) }; + + await agent.poHomeChannel.navbar.changeUserStatus('online'); + }); + + test.afterAll(async ({ api }) => { + await agent.page.close(); + + await Promise.all([ + api.delete(`/livechat/users/agent/${ADMIN_CREDENTIALS.username}`), + api.delete(`/livechat/users/manager/${ADMIN_CREDENTIALS.username}`), + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + ]); + }); + + test('expect to initiate a new livechat conversation', async ({ page, api }) => { + newVisitor = createFakeVisitor(); + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await agent.poHomeChannel.sidebar.getSidebarItemByName(newVisitor.name).click(); + }); + + test('expect to change priority of room and corresponding system message should be displayed', async ({ api }) => { + const priority = await getPriorityByi18nLabel(api, 'High'); + + await test.step('change priority of room to the new priority', async () => { + const status = (await api.post(`/livechat/room/${getRoomId(agent.page)}/priority`, { priorityId: priority._id })).status(); + await expect(status).toBe(200); + + await agent.page.waitForTimeout(1000); + }); + + await expect(agent.poHomeChannel.content.lastSystemMessageBody).toHaveText( + `Priority changed: ${ADMIN_CREDENTIALS.username} changed the priority to ${priority.name || priority.i18n}`, + ); + }); + + test('expect to change SLA of room and corresponding system message should be displayed', async ({ api }) => { + const sla = await createSLA(api); + + await test.step('change SLA of room to the new SLA', async () => { + const status = (await api.put(`/livechat/inquiry.setSLA`, { sla: sla.name, roomId: getRoomId(agent.page) })).status(); + expect(status).toBe(200); + await agent.page.waitForTimeout(1000); + }); + + await expect(agent.poHomeChannel.content.lastSystemMessageBody).toHaveText( + `SLA Policy changed: ${ADMIN_CREDENTIALS.username} changed the SLA Policy to ${sla.name}`, + ); + + await test.step('cleanup SLA', async () => { + const status = (await api.delete(`/livechat/sla/${sla._id}`)).status(); + expect(status).toBe(200); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-history.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-history.spec.ts new file mode 100644 index 0000000..4a8a83f --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-history.spec.ts @@ -0,0 +1,98 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('Omnichannel chat history', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeOmnichannel: new HomeOmnichannel(page) }; + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'owner', 'moderator', 'user'] }] }); + }); + + test('Receiving a message from visitor', async ({ api }) => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeOmnichannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect to be able to edit room info', async () => { + await agent.poHomeOmnichannel.roomInfo.btnEdit.click(); + await agent.poHomeOmnichannel.editRoomInfo.inputTopic.fill('any_topic'); + await agent.poHomeOmnichannel.editRoomInfo.btnSave.click(); + + await expect(agent.poHomeOmnichannel.roomInfo.root).toContainText('any_topic'); + }); + + await test.step('Expect to be able to close an omnichannel to conversation', async () => { + await agent.poHomeOmnichannel.quickActionsRoomToolbar.closeChat(); + }); + + await test.step('Expect send a message as a visitor again to reopen chat', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeOmnichannel.navbar.openChat(newVisitor.name); + }); + + await test.step('Expect to be able to see conversation history', async () => { + await agent.poHomeOmnichannel.btnContactInfo.click(); + await agent.poHomeOmnichannel.contacts.contactInfo.tabHistory.click(); + await expect(agent.poHomeOmnichannel.contacts.contactInfo.historyItem).toBeVisible(); + + await agent.poHomeOmnichannel.contacts.contactInfo.historyItem.click(); + await expect(agent.poHomeOmnichannel.contacts.contactInfo.historyMessage).toBeVisible(); + }); + + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: [] }] }); + + await test.step('Expect agent to see conversation history, but not join room', async () => { + await agent.page.reload(); + + await agent.poHomeOmnichannel.contacts.contactInfo.historyItem.click(); + await agent.poHomeOmnichannel.contacts.contactInfo.historyMessage.click(); + await agent.poHomeOmnichannel.contacts.contactInfo.btnOpenChat.click(); + + // Should not show the NoSubscribedRoom.tsx component on livechat rooms + await expect(agent.page.locator('div >> text=This conversation is already closed.')).toBeVisible(); + await expect(agent.page.locator('div >> text="this_a_test_message_from_visitor"')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts new file mode 100644 index 0000000..4a87cb9 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts @@ -0,0 +1,512 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { createMonitor } from '../utils/omnichannel/monitors'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { createOrUpdateUnit } from '../utils/omnichannel/units'; +import { expect, test } from '../utils/test'; + +const wrapSession = async ({ page }: { page: Page }) => ({ page, poHomeOmnichannel: new HomeOmnichannel(page) }); + +test.use({ storageState: Users.user3.state }); + +test.skip(!IS_EE, 'Enterprise Edition Only'); + +test.describe('OC - Chat transfers [Monitor role]', () => { + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let monitors: Awaited>[]; + let units: Awaited>[]; + let sessions: { page: Page; poHomeOmnichannel: HomeOmnichannel }[] = []; + + let poOmnichannel: HomeOmnichannel; + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2'), createAgent(api, 'rocketchat.internal.admin.test')]); + + (await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id)))).forEach((res) => { + expect(res.status()).toBe(200); + }); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Add agents to departments + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + const promises = await Promise.all([ + addAgentToDepartment(api, { department: departmentA, agentId: 'user1' }), + addAgentToDepartment(api, { department: departmentA, agentId: 'rocketchat.internal.admin.test' }), + addAgentToDepartment(api, { department: departmentB, agentId: 'user2' }), + ]); + + promises.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create conversations + test.beforeAll(async ({ api }) => { + const [departmentA] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + ]); + }); + + // Create monitors + test.beforeAll(async ({ api }) => { + monitors = await Promise.all([createMonitor(api, 'user3')]); + }); + + // Create units + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + units = await Promise.all([ + createOrUpdateUnit(api, { + monitors: [{ monitorId: 'user3', username: 'user3' }], + departments: [{ departmentId: departmentA._id }], + }), + createOrUpdateUnit(api, { + monitors: [{ monitorId: 'rocketchat.internal.admin.test', username: 'rocketchat.internal.admin.test' }], + departments: [{ departmentId: departmentB._id }], + }), + ]); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); + }); + + // Create sessions + test.beforeEach(async ({ browser }) => { + sessions = []; + for (const user of [Users.user1, Users.user2, Users.admin]) { + sessions.push(await createAuxContext(browser, user).then(wrapSession)); + } + }); + + test.beforeEach(async ({ page }) => { + poOmnichannel = new HomeOmnichannel(page); + + await poOmnichannel.chats.goto(); + }); + + // Close sessions + test.afterEach(async () => { + await Promise.all(sessions.map(({ page }) => page.close())); + sessions = []; + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + ...conversations.map((conversation) => conversation.delete()), + ...monitors.map((monitor) => monitor.delete()), + ...agents.map((agent) => agent.delete()), + ...units.map((unit) => unit.delete()), + ...departments.map((department) => department.delete()), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + test(`OC - Chat transfers [Monitor role] - Transfer to department with no online agents should fail`, async ({ api }) => { + const [roomA] = conversations.map(({ data }) => data.room); + const [, agentB] = sessions; + + const emptyDepartment = await createDepartment(api); + + await test.step('expect to open forward chat modal', async () => { + await poOmnichannel.chats.openChat(roomA.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect transfer to department with no online agents to fail', async () => { + await poOmnichannel.content.forwardChatModal.selectDepartment(emptyDepartment.data.name); + await poOmnichannel.content.forwardChatModal.inputComment.type('transfer_attempt'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + await poOmnichannel.toastMessage.waitForDisplay({ type: 'error' }); + }); + + await test.step('expect conversation to remain with original agent', async () => { + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).not.toBeVisible(); + }); + + await emptyDepartment.delete(); + }); + + test(`OC - Chat transfers [Monitor role] - Transfer to another department`, async () => { + const [, departmentB] = departments.map(({ data }) => data); + const [roomA] = conversations.map(({ data }) => data.room); + const [agentA, agentB] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomA.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from dep a to dep b', async () => { + await poOmnichannel.content.forwardChatModal.selectDepartment(departmentB.name); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + await poOmnichannel.content.forwardChatModal.waitForDismissal(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).not.toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname).click(); + await agentB.poHomeOmnichannel.content.findSystemMessage( + `Transfer: user3 transferred the chat to the department ${departmentB.name}}`, + ); + await agentB.poHomeOmnichannel.content.findSystemMessage('left the channel'); + }); + }); + + test(`OC - Chat transfers [Monitor role] - Transfer to another agent, different department`, async () => { + const [, roomB] = conversations.map(({ data }) => data.room); + const [agentA, agentB] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomB.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from user1 to user2', async () => { + await poOmnichannel.content.forwardChatModal.selectUser(`user2`); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).not.toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname).click(); + await expect( + agentB.poHomeOmnichannel.content.findSystemMessage( + `New Chat Transfer: user3 transferred the chat to user2 with a comment: any_comment`, + ), + ).toBeVisible(); + await expect(agentB.poHomeOmnichannel.content.findSystemMessage('left the channel')).toBeVisible(); + }); + }); + + test(`OC - Chat transfers [Monitor role] - Transfer to another agent, same department`, async () => { + const [, , roomC] = conversations.map(({ data }) => data.room); + const [agentA, , agentC] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomC.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from dep a to dep b', async () => { + await poOmnichannel.content.forwardChatModal.selectUser('rocketchat.internal.admin.test'); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).not.toBeVisible(); + await expect(agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname).click(); + await expect( + agentC.poHomeOmnichannel.content.findSystemMessage( + `New Chat Transfer: user3 transferred the chat to RocketChat Internal Admin Test with a comment: any_comment`, + ), + ).toBeVisible(); + await expect(agentC.poHomeOmnichannel.content.findSystemMessage('left the channel')).toBeVisible(); + }); + }); +}); + +test.describe('OC - Chat transfers [Manager role]', () => { + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let managers: Awaited>[]; + let sessions: { page: Page; poHomeOmnichannel: HomeOmnichannel }[]; + + let poOmnichannel: HomeOmnichannel; + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2'), createAgent(api, 'rocketchat.internal.admin.test')]); + + (await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id)))).forEach((res) => { + expect(res.status()).toBe(200); + }); + }); + + // Create managers + test.beforeAll(async ({ api }) => { + managers = await Promise.all([createManager(api, 'user3')]); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Add agents to departments + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + const promises = await Promise.all([ + addAgentToDepartment(api, { department: departmentA, agentId: 'user1' }), + addAgentToDepartment(api, { department: departmentA, agentId: 'rocketchat.internal.admin.test' }), + addAgentToDepartment(api, { department: departmentB, agentId: 'user2' }), + ]); + + promises.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create conversations + test.beforeAll(async ({ api }) => { + const [departmentA] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + agentId: `user1`, + departmentId: departmentA._id, + }), + ]); + }); + + // Create sessions + test.beforeEach(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(wrapSession), + createAuxContext(browser, Users.user2).then(wrapSession), + createAuxContext(browser, Users.admin).then(wrapSession), + ]); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannel = new HomeOmnichannel(page); + + await poOmnichannel.chats.goto(); + }); + + // Close sessions + test.afterEach(async () => { + await Promise.all(sessions.map(({ page }) => page.close())); + }); + + test.afterAll(async () => { + await Promise.all([ + ...conversations.map((conversation) => conversation.delete()), + ...managers.map((manager) => manager.delete()), + ...agents.map((agent) => agent.delete()), + ...departments.map((department) => department.delete()), + ]); + }); + + test(`OC - Chat transfers [Manager role] - Transfer to another department`, async () => { + const [, departmentB] = departments.map(({ data }) => data); + const [roomA] = conversations.map(({ data }) => data.room); + const [agentA, agentB] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomA.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from dep a to dep b', async () => { + await poOmnichannel.content.forwardChatModal.selectDepartment(departmentB.name); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + await poOmnichannel.content.forwardChatModal.waitForDismissal(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).not.toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomA.fname).click(); + await agentB.poHomeOmnichannel.content.findSystemMessage( + `Transfer: user3 transferred the chat to the department ${departmentB.name}}`, + ); + await agentB.poHomeOmnichannel.content.findSystemMessage('left the channel'); + }); + }); + + test(`OC - Chat transfers [Manager role] - Transfer to another agent, different department`, async () => { + const [, roomB] = conversations.map(({ data }) => data.room); + const [agentA, agentB] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomB.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from user1 to user2', async () => { + await poOmnichannel.content.forwardChatModal.selectUser(`user2`); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).not.toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(roomB.fname).click(); + await expect( + agentB.poHomeOmnichannel.content.findSystemMessage( + `New Chat Transfer: user3 transferred the chat to user2 with a comment: any_comment`, + ), + ).toBeVisible(); + await expect(agentB.poHomeOmnichannel.content.findSystemMessage('left the channel')).toBeVisible(); + }); + }); + + test(`OC - Chat transfers [Manager role] - Transfer to another agent, same department`, async () => { + const [, , roomC] = conversations.map(({ data }) => data.room); + const [agentA, , agentC] = sessions; + + await test.step('expect room a to bot be visible for user2', async () => { + await expect(agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(roomC.fname); + await poOmnichannel.quickActionsRoomToolbar.forwardChat(); + }); + + await test.step('expect agent and department fields to be visible and enabled', async () => { + await expect(poOmnichannel.content.forwardChatModal.inputForwardUser).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.inputForwardDepartment).toBeEnabled(); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeDisabled(); + }); + + await test.step('expect to transfer from dep a to dep b', async () => { + await poOmnichannel.content.forwardChatModal.selectUser('rocketchat.internal.admin.test'); + await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); + await poOmnichannel.content.forwardChatModal.btnForward.click(); + // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + + await test.step('expect conversation to have been assigned to user 2', async () => { + await expect(agentA.page).toHaveURL(`/home`); + await expect(agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).not.toBeVisible(); + await expect(agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname)).toBeVisible(); + }); + + await test.step('expect user 1 to have left the conversation', async () => { + await agentC.poHomeOmnichannel.sidebar.getSidebarItemByName(roomC.fname).click(); + await expect( + agentC.poHomeOmnichannel.content.findSystemMessage( + `New Chat Transfer: user3 transferred the chat to RocketChat Internal Admin Test with a comment: any_comment`, + ), + ).toBeVisible(); + await expect(agentC.poHomeOmnichannel.content.findSystemMessage('left the channel')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-close-chat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-close-chat.spec.ts new file mode 100644 index 0000000..0def603 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-close-chat.spec.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test } from '../utils/test'; + +test.describe('Omnichannel close chat', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeOmnichannel: new HomeOmnichannel(page) }; + }); + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + }); + + test('Receiving a message from visitor', async () => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeOmnichannel.navbar.openChat(newVisitor.name); + }); + + await test.step('Expect to be able to close an omnichannel to conversation', async () => { + await agent.poHomeOmnichannel.quickActionsRoomToolbar.closeChat(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-close-inquiry.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-close-inquiry.spec.ts new file mode 100644 index 0000000..16c5447 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-close-inquiry.spec.ts @@ -0,0 +1,67 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('Omnichannel close inquiry', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api }) => { + newVisitor = createFakeVisitor(); + + await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }).then((res) => expect(res.status()).toBe(200)); + await api.post('/livechat/users/manager', { username: 'user1' }); + await api.post('/livechat/users/agent', { username: 'user1' }); + }); + + test.beforeEach(async ({ page, api, browser }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + + const { page: auxPage } = await createAuxContext(browser, Users.user1); + agent = { page: auxPage, poHomeOmnichannel: new HomeOmnichannel(auxPage) }; + }); + + test.afterEach(async () => { + await agent.page.close(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + await api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }).then((res) => expect(res.status()).toBe(200)), + await api.delete('/livechat/users/agent/user1'), + await api.delete('/livechat/users/manager/user1'), + ]); + }); + + test('Receiving a message from visitor', async () => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeOmnichannel.sidebar.getSidebarItemByName(newVisitor.name).click(); + await expect(agent.poHomeOmnichannel.content.btnTakeChat).toBeVisible(); + }); + + await test.step('Expect to be able to close an inquiry conversation', async () => { + await agent.poHomeOmnichannel.quickActionsRoomToolbar.closeChat(); + }); + + await test.step('Expect to inquiry be closed when navigate back', async () => { + await agent.poHomeOmnichannel.chats.goto(); + await agent.poHomeOmnichannel.chats.openChat(newVisitor.name); + await expect(agent.poHomeOmnichannel.content.btnTakeChat).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats-filters.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats-filters.spec.ts new file mode 100644 index 0000000..f31205c --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats-filters.spec.ts @@ -0,0 +1,77 @@ +import { faker } from '@faker-js/faker/locale/af_ZA'; + +import { Users } from '../fixtures/userStates'; +import { OmnichannelContactCenterChats } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Contact Center - Chats', () => { + let conversations: Awaited>[]; + let agent: Awaited>; + + let poOmniChats: OmnichannelContactCenterChats; + + const uuid = faker.string.uuid(); + const visitorA = `visitorA_${uuid}`; + const visitorB = `visitorB_${uuid}`; + + test.beforeAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection')).status()).toBe(200); + }); + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + expect((await makeAgentAvailable(api, agent.data._id)).status()).toBe(200); + }); + + test.beforeAll(async ({ api }) => { + conversations = await Promise.all([ + createConversation(api, { agentId: `user1`, visitorName: visitorA }), + createConversation(api, { agentId: `user1`, visitorName: visitorB }), + ]); + }); + + test.beforeEach(async ({ page }) => { + poOmniChats = new OmnichannelContactCenterChats(page); + + await poOmniChats.goto(); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([...conversations.map((conversation) => conversation.delete()), agent.delete()]); + expect((await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection')).status()).toBe(200); + }); + + test(`OC - Contact Center - Chats - Filter from and to same date`, async ({ page }) => { + await test.step('expect conversations to be visible', async () => { + await poOmniChats.inputSearch.fill(uuid); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + }); + + await test.step('expect to filter [from] and [to] today', async () => { + const [chatA] = conversations.map((c) => c.data); + const [todayString] = new Date(chatA.room.ts).toISOString().split('T'); + await poOmniChats.btnFilters.click(); + await expect(page).toHaveURL('/omnichannel/current/chats/filters'); + await poOmniChats.filters.inputFrom.fill(todayString); + await poOmniChats.filters.inputTo.fill(todayString); + await poOmniChats.filters.btnApply.click(); + await page.waitForResponse('**/api/v1/livechat/rooms*'); + }); + + await test.step('expect conversations to be visible', async () => { + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts new file mode 100644 index 0000000..804f21d --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts @@ -0,0 +1,227 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelContactCenterChats } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createConversation, updateRoom } from '../utils/omnichannel/rooms'; +import { createTag } from '../utils/omnichannel/tags'; +import { test, expect } from '../utils/test'; + +const visitorA = `${faker.person.firstName()}-${faker.database.mongodbObjectId()}`; +const visitorB = `${faker.person.firstName()}-${faker.database.mongodbObjectId()}`; +const visitorC = `${faker.person.firstName()}-${faker.database.mongodbObjectId()}`; + +test.skip(!IS_EE, 'OC - Contact Center Chats > Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Contact Center Chats [Auto Selection]', async () => { + let poContactCenterChats: OmnichannelContactCenterChats; + let poHomeOmnichannel: HomeOmnichannel; + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let tagA: Awaited>; + let tagB: Awaited>; + + // Allow manual on hold + test.beforeAll(async ({ api }) => { + const responses = await Promise.all([ + api.post('/settings/Livechat_allow_manual_on_hold', { value: true }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: false }), + ]); + responses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + + const agentsStatuses = await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id))); + + agentsStatuses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Add agents to departments + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + const promises = await Promise.all([ + addAgentToDepartment(api, { department: departmentA, agentId: 'user1' }), + addAgentToDepartment(api, { department: departmentB, agentId: 'user2' }), + ]); + + promises.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create tags + test.beforeAll(async ({ api }) => { + tagA = await createTag(api); + tagB = await createTag(api); + + [tagA, tagB].forEach((res) => expect(res.response.status()).toBe(200)); + }); + + // Create rooms + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA, + visitorToken: visitorA, + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + visitorName: visitorB, + visitorToken: visitorB, + agentId: `user2`, + departmentId: departmentB._id, + }), + createConversation(api, { + visitorName: visitorC, + visitorToken: visitorC, + }), + ]); + + const [conversationA, conversationB] = conversations.map(({ data }) => data); + + await Promise.all([ + updateRoom(api, { + roomId: conversationA.room._id, + visitorId: conversationA.visitor._id, + tags: [tagA.data.name], + }), + updateRoom(api, { + roomId: conversationB.room._id, + visitorId: conversationB.visitor._id, + tags: [tagB.data.name], + }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poContactCenterChats = new OmnichannelContactCenterChats(page); + poHomeOmnichannel = new HomeOmnichannel(page); + + await poContactCenterChats.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + // Delete conversations + ...conversations.map((conversation) => conversation.delete()), + // Delete departments + ...departments.map((department) => department.delete()), + // Delete agents + ...agents.map((agent) => agent.delete()), + // Delete tags + ...[tagA, tagB].map((tag) => tag.delete()), + // Reset setting + api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + // Change conversation A to on hold and close conversation B + test.beforeAll(async ({ api }) => { + const [conversationA, , conversationC] = conversations.map(({ data }) => data); + + const statesPromises = await Promise.all([ + api.post('/livechat/room.onHold', { roomId: conversationA.room._id }), + api.post('/livechat/room.close', { rid: conversationC.room._id, token: visitorC }), + ]); + + statesPromises.forEach((res) => expect(res.status()).toBe(200)); + }); + + test('OC - Contact Center Chats - Basic navigation', async ({ page }) => { + await test.step('expect to be return using return button', async () => { + await poContactCenterChats.openChat(visitorA); + await poHomeOmnichannel.content.btnReturn.click(); + expect(page.url()).toContain(`/omnichannel/current`); + }); + }); + + test('OC - Contact Center Chats - Access in progress conversation from another agent', async () => { + await test.step('expect to be able to join', async () => { + const { visitor: visitorB } = conversations[1].data; + await poContactCenterChats.openChat(visitorB.name); + await expect(poHomeOmnichannel.composer.btnJoinRoom).toBeVisible(); + await poHomeOmnichannel.composer.btnJoinRoom.click(); + await expect(poHomeOmnichannel.composer.btnJoinRoom).not.toBeVisible(); + }); + }); + + test('OC - Contact Center Chats - Remove conversations', async () => { + await test.step('expect to be able to remove conversation from table', async () => { + await poContactCenterChats.removeChatByName(visitorC); + await expect(poContactCenterChats.table.findRowByName(visitorC)).not.toBeVisible(); + }); + + // TODO: await test.step('expect to be able to close all closes conversations', async () => {}); + }); +}); + +test.describe('OC - Contact Center [Manual Selection]', () => { + let queuedConversation: Awaited>; + let poHomeOmnichannel: HomeOmnichannel; + let poCurrentChats: OmnichannelContactCenterChats; + let agent: Awaited>; + + test.beforeAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }); + expect(res.status()).toBe(200); + }); + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'rocketchat.internal.admin.test'); + + const agentStatus = await makeAgentAvailable(api, agent.data._id); + + expect(agentStatus.status()).toBe(200); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poHomeOmnichannel = new HomeOmnichannel(page); + poCurrentChats = new OmnichannelContactCenterChats(page); + + await poCurrentChats.goto(); + }); + + test('OC - Contact Center Chats - Access queued conversation', async ({ api }) => { + queuedConversation = await createConversation(api, { visitorToken: 'visitorQueued' }); + + await test.step('expect to be able to take it', async () => { + const { visitor } = queuedConversation.data; + await poCurrentChats.inputSearch.fill(visitor.name); + await poCurrentChats.openChat(visitor.name); + await expect(poHomeOmnichannel.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannel.content.btnTakeChat.click(); + await expect(poHomeOmnichannel.content.btnTakeChat).not.toBeVisible(); + }); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }); + expect(res.status()).toBe(200); + }); + + test.afterAll(async () => { + await queuedConversation.delete(); + await agent.delete(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-contacts.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-contacts.spec.ts new file mode 100644 index 0000000..ef08f21 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-contacts.spec.ts @@ -0,0 +1,287 @@ +import { randomBytes } from 'crypto'; + +import { faker } from '@faker-js/faker'; + +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { Navbar } from '../page-objects/fragments'; +import { OmnichannelContactCenterContacts } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const createToken = (): string => { + const array = new Uint8Array(16); + + const buffer = randomBytes(16); + array.set(buffer); + + return Array.from(array) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +}; + +const createContact = (generateToken = false) => ({ + id: null, + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + email: faker.internet.email().toLowerCase(), + phone: faker.phone.number('+############'), + token: generateToken ? createToken() : null, + customFields: {}, +}); + +const NEW_CONTACT = createContact(); +const EDIT_CONTACT = createContact(); +const EXISTING_CONTACT = { + id: undefined, + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number('+############')], + token: undefined, +}; +const DELETE_CONTACT = { + id: undefined, + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number('+############')], +}; + +const URL = { + contactCenter: '/omnichannel-directory/contacts', + newContact: '/omnichannel-directory/contacts/new', + get editContact() { + return `${this.contactCenter}/edit/${NEW_CONTACT.id}`; + }, + get contactInfo() { + return `${this.contactCenter}/details/${NEW_CONTACT.id}`; + }, +}; + +const ERROR = { + nameRequired: 'Name required', + invalidEmail: 'Invalid email address', + emailRequired: 'Email required', + emailAlreadyExists: 'Email already exists', + phoneRequired: 'Phone required', + phoneAlreadyExists: 'Phone already exists', +}; + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Contact Center - Contacts', () => { + let poContacts: OmnichannelContactCenterContacts; + let poNavbar: Navbar; + + test.beforeAll(async ({ api }) => { + // Add contacts + await Promise.all([api.post('/omnichannel/contacts', EXISTING_CONTACT), api.post('/omnichannel/contacts', DELETE_CONTACT)]); + }); + + test.afterAll(async ({ api }) => { + // Remove added contacts + await Promise.all([api.delete(`/livechat/visitor/${NEW_CONTACT.token}`), api.delete(`/livechat/visitor/${EXISTING_CONTACT.token}`)]); + }); + + test.beforeEach(async ({ page }) => { + poContacts = new OmnichannelContactCenterContacts(page); + poNavbar = new Navbar(page); + }); + + test.afterEach(async ({ api }) => { + await api + .get('/omnichannel/contacts.search', { searchText: NEW_CONTACT.phone }) + .then((res) => res.json()) + .then((res) => { + NEW_CONTACT.token = res.contacts?.[0]?.token; + NEW_CONTACT.id = res.contacts?.[0]?._id; + }); + + await api + .get('/omnichannel/contacts.search', { searchText: EXISTING_CONTACT.phones[0] }) + .then((res) => res.json()) + .then((res) => { + EXISTING_CONTACT.token = res.contacts?.[0]?.token; + EXISTING_CONTACT.id = res.contacts?.[0]?._id; + }); + }); + + test.beforeEach(async ({ page }) => { + await new HomeOmnichannel(page).goto(); + await poNavbar.btnContactCenter.click(); + await poContacts.tabContacts.click(); + await page.waitForURL(URL.contactCenter); + }); + + test('Add new contact', async ({ page }) => { + await test.step('cancel button', async () => { + await poContacts.btnNewContact.click(); + await page.waitForURL(URL.newContact); + await expect(poContacts.editContact.inputName).toBeVisible(); + await poContacts.editContact.btnCancel.click(); + await page.waitForURL(URL.contactCenter); + await expect(poContacts.editContact.inputName).not.toBeVisible(); + }); + + await test.step('open contextual bar', async () => { + await poContacts.btnNewContact.click(); + await page.waitForURL(URL.newContact); + await expect(poContacts.editContact.inputName).toBeVisible(); + }); + + await test.step('input name', async () => { + await poContacts.editContact.inputName.fill(NEW_CONTACT.name); + }); + + await test.step('validate email format', async () => { + await poContacts.editContact.btnAddEmail.click(); + await poContacts.editContact.inputEmail.fill('invalidemail'); + await page.keyboard.press('Enter'); + await expect(poContacts.editContact.getErrorMessage(ERROR.invalidEmail)).toBeVisible(); + }); + + await test.step('validate email is duplicated', async () => { + await poContacts.editContact.inputEmail.fill(EXISTING_CONTACT.emails[0]); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailAlreadyExists)).toBeVisible(); + }); + + await test.step('validate email is required', async () => { + await poContacts.editContact.inputEmail.clear(); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailRequired)).toBeVisible(); + }); + + await test.step('input email', async () => { + await poContacts.editContact.inputEmail.fill(NEW_CONTACT.email); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.invalidEmail)).not.toBeVisible(); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailRequired)).not.toBeVisible(); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailAlreadyExists)).not.toBeVisible(); + }); + + await test.step('validate phone is duplicated', async () => { + await poContacts.editContact.btnAddPhone.click(); + await poContacts.editContact.inputPhone.fill(EXISTING_CONTACT.phones[0]); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.phoneAlreadyExists)).toBeVisible(); + }); + + await test.step('input phone', async () => { + await poContacts.editContact.inputPhone.fill(NEW_CONTACT.phone); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.phoneRequired)).not.toBeVisible(); + }); + + await test.step('save new contact', async () => { + await expect(poContacts.editContact.btnSave).toBeEnabled(); + await poContacts.editContact.btnSave.click(); + + await poContacts.inputSearch.fill(NEW_CONTACT.name); + await expect(poContacts.table.findRowByName(NEW_CONTACT.name)).toBeVisible(); + }); + }); + + test('Edit new contact', async ({ page }) => { + await test.step('search contact and open contextual bar', async () => { + await poContacts.inputSearch.fill(NEW_CONTACT.name); + const row = poContacts.table.findRowByName(NEW_CONTACT.name); + await expect(row).toBeVisible(); + await row.click(); + await page.waitForURL(URL.contactInfo); + }); + + await test.step('cancel button', async () => { + await poContacts.contactInfo.btnEdit.click(); + await page.waitForURL(URL.editContact); + await poContacts.editContact.btnCancel.click(); + await page.waitForURL(URL.contactInfo); + }); + + await test.step('edit contact', async () => { + await poContacts.contactInfo.btnEdit.click(); + await page.waitForURL(URL.editContact); + }); + + await test.step('initial values', async () => { + await expect(poContacts.editContact.inputName).toHaveValue(NEW_CONTACT.name); + await expect(poContacts.editContact.inputEmail).toHaveValue(NEW_CONTACT.email); + await expect(poContacts.editContact.inputPhone).toHaveValue(NEW_CONTACT.phone); + }); + + await test.step('validate email format', async () => { + await poContacts.editContact.inputEmail.fill('invalidemail'); + await page.keyboard.press('Enter'); + await expect(poContacts.editContact.getErrorMessage(ERROR.invalidEmail)).toBeVisible(); + }); + + await test.step('validate email is duplicated', async () => { + await poContacts.editContact.inputEmail.fill(EXISTING_CONTACT.emails[0]); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailAlreadyExists)).toBeVisible(); + }); + + await test.step('validate email is required', async () => { + await poContacts.editContact.inputEmail.clear(); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailRequired)).toBeVisible(); + }); + + await test.step('validate name is required', async () => { + await poContacts.editContact.inputName.clear(); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.nameRequired)).toBeVisible(); + }); + + await test.step('edit name', async () => { + await poContacts.editContact.inputName.fill(EDIT_CONTACT.name); + }); + + await test.step('validate phone is duplicated', async () => { + await poContacts.editContact.inputPhone.fill(EXISTING_CONTACT.phones[0]); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.phoneAlreadyExists)).toBeVisible(); + }); + + await test.step('input phone ', async () => { + await poContacts.editContact.inputPhone.fill(EDIT_CONTACT.phone); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.phoneRequired)).not.toBeVisible(); + }); + + await test.step('input email', async () => { + await poContacts.editContact.inputEmail.fill(EDIT_CONTACT.email); + await page.keyboard.press('Tab'); + await expect(poContacts.editContact.getErrorMessage(ERROR.invalidEmail)).not.toBeVisible(); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailRequired)).not.toBeVisible(); + await expect(poContacts.editContact.getErrorMessage(ERROR.emailAlreadyExists)).not.toBeVisible(); + }); + + await test.step('save new contact ', async () => { + await poContacts.editContact.btnSave.click(); + + await poContacts.inputSearch.fill(EDIT_CONTACT.name); + await expect(poContacts.table.findRowByName(EDIT_CONTACT.name)).toBeVisible(); + }); + }); + + test('Delete a contact', async () => { + await test.step('Find contact and open modal', async () => { + await poContacts.inputSearch.fill(DELETE_CONTACT.name); + await poContacts.deleteContact(DELETE_CONTACT.name); + }); + + await test.step('Fill confirmation and delete contact', async () => { + // Fills the input with the wrong confirmation + await poContacts.deleteContactModal.inputConfirmation.fill('wrong'); + await expect(poContacts.deleteContactModal.btnDelete).toBeDisabled(); + + // Fills the input correctly + await poContacts.deleteContactModal.inputConfirmation.fill('delete'); + await expect(poContacts.deleteContactModal.btnDelete).toBeEnabled(); + await poContacts.deleteContactModal.delete(); + }); + + await test.step('Confirm contact removal', async () => { + await poContacts.inputSearch.fill(DELETE_CONTACT.name); + await expect(poContacts.table.findRowByName(DELETE_CONTACT.name)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts new file mode 100644 index 0000000..c258821 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts @@ -0,0 +1,345 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { Navbar } from '../page-objects/fragments'; +import { OmnichannelContactCenterChats } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createConversation, updateRoom } from '../utils/omnichannel/rooms'; +import { createTag } from '../utils/omnichannel/tags'; +import { createOrUpdateUnit } from '../utils/omnichannel/units'; +import { test, expect } from '../utils/test'; + +const URL = { + contactCenterChats: '/omnichannel-directory/chats', +}; + +const visitorA = createFakeVisitor().name; +const visitorB = createFakeVisitor().name; +const visitorC = createFakeVisitor().name; + +test.skip(!IS_EE, 'OC - Contact center Filters > Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Contact Center', async () => { + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let tagA: Awaited>; + let tagB: Awaited>; + let units: Awaited>[]; + let poNavbar: Navbar; + let poOmniChats: OmnichannelContactCenterChats; + + // Allow manual on hold + test.beforeAll(async ({ api }) => { + const responses = await Promise.all([ + api.post('/settings/Livechat_allow_manual_on_hold', { value: true }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: false }), + ]); + responses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + + const agentsStatuses = await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id))); + + agentsStatuses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Add agents to departments + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + const promises = await Promise.all([ + addAgentToDepartment(api, { department: departmentA, agentId: 'user1' }), + addAgentToDepartment(api, { department: departmentB, agentId: 'user2' }), + ]); + + promises.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create tags + test.beforeAll(async ({ api }) => { + tagA = await createTag(api); + tagB = await createTag(api); + + [tagA, tagB].forEach((res) => expect(res.response.status()).toBe(200)); + }); + + // Create unit + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map((dep) => dep.data); + units = await Promise.all([ + createOrUpdateUnit(api, { + monitors: [{ monitorId: 'user1', username: 'user1' }], + departments: [{ departmentId: departmentA._id }], + }), + createOrUpdateUnit(api, { + monitors: [{ monitorId: 'user2', username: 'user2' }], + departments: [{ departmentId: departmentB._id }], + }), + ]); + units.forEach((res) => expect(res.response.status()).toBe(200)); + }); + + // Create rooms + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA, + visitorToken: visitorA, + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + visitorName: visitorB, + visitorToken: visitorB, + agentId: `user2`, + departmentId: departmentB._id, + }), + createConversation(api, { + visitorName: visitorC, + visitorToken: visitorC, + }), + ]); + + const [conversationA, conversationB] = conversations.map(({ data }) => data); + + await Promise.all([ + updateRoom(api, { + roomId: conversationA.room._id, + visitorId: conversationA.visitor._id, + tags: [tagA.data.name], + }), + updateRoom(api, { + roomId: conversationB.room._id, + visitorId: conversationB.visitor._id, + tags: [tagB.data.name], + }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + // Delete conversations + ...conversations.map((conversation) => conversation.delete()), + // Delete departments + ...departments.map((department) => department.delete()), + // Delete agents + ...agents.map((agent) => agent.delete()), + // Delete tags + ...[tagA, tagB].map((tag) => tag.delete()), + // Delete units + ...units.map((unit) => unit.delete()), + // Reset setting + api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + // Change conversation A to on hold and close conversation B + test.beforeAll(async ({ api }) => { + const [conversationA, , conversationC] = conversations.map(({ data }) => data); + + const statesPromises = await Promise.all([ + api.post('/livechat/room.onHold', { roomId: conversationA.room._id }), + api.post('/livechat/room.close', { + rid: conversationC.room._id, + token: visitorC, + }), + ]); + + statesPromises.forEach((res) => expect(res.status()).toBe(200)); + }); + + test.beforeEach(async ({ page }) => { + poOmniChats = new OmnichannelContactCenterChats(page); + poNavbar = new Navbar(page); + + await new HomeOmnichannel(page).goto(); + await poNavbar.btnContactCenter.click(); + await poOmniChats.tabChats.click(); + await page.waitForURL(URL.contactCenterChats); + }); + + test('OC - Contact Center - Filters', async () => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + const [unitA, unitB] = units.map((unit) => unit.data); + + await test.step('expect to filter by guest', async () => { + await poOmniChats.inputSearch.fill(visitorA); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.btnSearchChip(visitorA)).toBeVisible(); + + await poOmniChats.inputSearch.fill(''); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + }); + + await test.step('expect to filter by Served By', async () => { + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + + await poOmniChats.btnFilters.click(); + + // Select user1 + await poOmniChats.filters.selectServedBy('user1'); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.btnServedByChip('user1')).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + + // Select user2 + await poOmniChats.btnServedByChip('user1').locator('i').click(); + await poOmniChats.filters.selectServedBy('user2'); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + await expect(poOmniChats.btnServedByChip('user2')).toBeVisible(); + + // Select all users + await poOmniChats.btnServedByChip('user2').locator('i').click(); + await poOmniChats.filters.selectServedBy('user1'); + await poOmniChats.filters.selectServedBy('user2'); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await poOmniChats.btnServedByChip('user1').locator('i').click(); + }); + + await test.step('expect to filter by status', async () => { + await poOmniChats.filters.selectStatus('Closed'); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).toBeVisible(); + await expect(poOmniChats.btnStatusChip('Closed')).toBeVisible(); + + await poOmniChats.filters.selectStatus('Open'); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await expect(poOmniChats.btnStatusChip('Open')).toBeVisible(); + + await poOmniChats.filters.selectStatus('all'); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).toBeVisible(); + + await poOmniChats.filters.selectStatus('On hold'); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await expect(poOmniChats.btnStatusChip('On hold')).toBeVisible(); + await poOmniChats.btnStatusChip('On hold').locator('i').click(); + }); + + await test.step('expect to filter by department', async () => { + // select department A + await poOmniChats.filters.selectDepartment(departmentA.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await expect(poOmniChats.btnDepartmentChip(departmentA.name)).toBeVisible(); + await poOmniChats.btnDepartmentChip(departmentA.name).locator('i').click(); + + // select department B + await poOmniChats.filters.selectDepartment(departmentB.name); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await expect(poOmniChats.btnDepartmentChip(departmentB.name)).toBeVisible(); + await poOmniChats.btnDepartmentChip(departmentB.name).locator('i').click(); + + // select all departments + await poOmniChats.filters.selectDepartment(departmentA.name); + await poOmniChats.filters.selectDepartment(departmentB.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + }); + + await test.step('expect to filter by tags', async () => { + await poOmniChats.filters.selectTag(tagA.data.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + + await poOmniChats.filters.selectTag(tagB.data.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + + await poOmniChats.filters.removeTag(tagA.data.name); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + + await poOmniChats.filters.removeTag(tagB.data.name); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + }); + + await test.step('expect to filter by units', async () => { + // select unitA + await poOmniChats.filters.selectUnit(unitA.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await poOmniChats.btnUnitsChip(unitA.name).locator('i').click(); + + // select unitB + await poOmniChats.filters.selectUnit(unitB.name); + await expect(poOmniChats.table.findRowByName(visitorA)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + await poOmniChats.btnUnitsChip(unitB.name).locator('i').click(); + + // no unit selected + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + }); + }); + + test('OC - Contact Center - Clear all applied Filters', async () => { + const [departmentA] = departments.map(({ data }) => data); + const [unitA] = units.map((unit) => unit.data); + + await test.step('expect to display result as per applied filters ', async () => { + await poOmniChats.btnFilters.click(); + await poOmniChats.filters.selectServedBy('user1'); + await poOmniChats.filters.selectStatus('On hold'); + await poOmniChats.filters.selectDepartment(departmentA.name); + await poOmniChats.filters.selectTag(tagA.data.name); + await poOmniChats.filters.selectUnit(unitA.name); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).not.toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).not.toBeVisible(); + }); + + await test.step('expect to clear all filters ', async () => { + await poOmniChats.filters.btnClearFilters.click(); + await expect(poOmniChats.table.findRowByName(visitorA)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorB)).toBeVisible(); + await expect(poOmniChats.table.findRowByName(visitorC)).toBeVisible(); + }); + }); + + test('OC - Contact Center - Close contextual bar with filter screen', async () => { + await test.step('expect to close filters contextual bar ', async () => { + await poOmniChats.btnFilters.click(); + await poOmniChats.filters.close(); + await expect(poOmniChats.btnFilters).toBeVisible(); + await expect(poOmniChats.filters.btnClearFilters).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts new file mode 100644 index 0000000..95f9956 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createCustomField } from '../utils/omnichannel/custom-field'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.skip(!IS_EE, 'Omnichannel Contact Review > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Contact Review', () => { + let poHomeChannel: HomeOmnichannel; + + const customFieldName = faker.string.uuid(); + const visitorToken = faker.string.uuid(); + let conversation: Awaited>; + let customField: Awaited>; + + test.beforeAll(async ({ api }) => { + ( + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + ]) + ).every((res) => expect(res.status()).toBe(200)); + + customField = await createCustomField(api, { field: customFieldName }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async () => { + await poHomeChannel.goto(); + }); + + test.beforeEach(async ({ api }) => { + conversation = await createConversation(api, { visitorName: visitor.name, agentId: `user1`, visitorToken }); + }); + + test.beforeEach(async ({ api }) => { + const resCustomFieldA = await api.post('/livechat/custom.field', { + token: visitorToken, + key: customFieldName, + value: 'custom-field-value', + overwrite: true, + }); + + expect(resCustomFieldA.status()).toBe(200); + + const resCustomFieldB = await api.post('/livechat/custom.field', { + token: visitorToken, + key: customFieldName, + value: 'custom-field-value-2', + overwrite: false, + }); + + expect(resCustomFieldB.status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + (await Promise.all([api.delete('/livechat/users/agent/user1'), api.delete('/livechat/users/manager/user1')])).every((res) => + expect(res.status()).toBe(200), + ); + + await conversation.delete(); + await customField.delete(); + }); + + test('OC - Contact Review - Update custom field conflicting', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeChannel.roomToolbar.openContactInfo(); + await poHomeChannel.contacts.contactInfo.solveConflict(customFieldName, 'custom-field-value-2'); + + await expect(poHomeChannel.contacts.contactInfo.btnSeeConflicts).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts new file mode 100644 index 0000000..635ee47 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts @@ -0,0 +1,61 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { expect, test } from '../utils/test'; + +test.describe('Omnichannel contact info', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + }); + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + }); + + test('Receiving a message from visitor, and seeing its information', async () => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('Expect to be able to see contact information and edit', async () => { + await agent.poHomeChannel.roomToolbar.openContactInfo(); + await agent.poHomeChannel.contacts.contactInfo.btnEdit.click(); + }); + + await test.step('Expect to update room name and subscription when updating contact name', async () => { + await agent.poHomeChannel.contacts.editContact.inputName.fill('Edited Contact Name'); + await agent.poHomeChannel.contacts.editContact.btnSave.click(); + await expect(agent.poHomeChannel.sidebar.channelsList.getByText('Edited Contact Name')).toBeVisible(); + await expect(agent.poHomeChannel.content.channelHeader.getByText('Edited Contact Name')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-unknown-callout.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-unknown-callout.spec.ts new file mode 100644 index 0000000..5e67f20 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-unknown-callout.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { expect, test } from '../utils/test'; + +test.describe('OC - Contact Unknown Callout', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeChannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeChannel(page) }; + }); + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.beforeEach('create livechat conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + test.afterEach('close livechat conversation', async () => { + await poLiveChat.closeChat(); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + }); + + test('OC - Contact Unknown Callout - Dismiss callout', async () => { + await test.step('expect to open conversation', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('expect contact unknown callout to be visible', async () => { + await expect(agent.poHomeChannel.content.contactUnknownCallout).toBeVisible(); + }); + + await test.step('expect to hide callout when dismiss is clicked', async () => { + await agent.poHomeChannel.content.btnDismissContactUnknownCallout.click(); + await expect(agent.poHomeChannel.content.contactUnknownCallout).not.toBeVisible(); + }); + + await test.step('expect keep callout hidden after changing pages', async () => { + await agent.poHomeChannel.navbar.btnHome.click(); + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + await expect(agent.poHomeChannel.content.contactUnknownCallout).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts new file mode 100644 index 0000000..eaa953b --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -0,0 +1,154 @@ +import { faker } from '@faker-js/faker'; + +import { createFakeVisitor } from '../../mocks/data'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createCustomField, setVisitorCustomFieldValue } from '../utils/omnichannel/custom-field'; +import { createManager } from '../utils/omnichannel/managers'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => { + let poHomeChannel: HomeOmnichannel; + + const roomCustomFieldLabel = `room_cf_${faker.string.alpha(8)}`; + const roomCustomFieldName = roomCustomFieldLabel; + const roomCustomFieldValue = faker.lorem.words(3); + + const visitorCustomFieldLabel = `visitor_cf_${faker.string.alpha(8)}`; + const visitorCustomFieldName = visitorCustomFieldLabel; + const visitorCustomFieldValue = faker.lorem.words(3); + const visitorToken = faker.string.uuid(); + + let agent: Awaited>; + let manager: Awaited>; + let conversation: Awaited>; + let roomCustomField: Awaited>; + let visitorCustomField: Awaited>; + + test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { + [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); + + [roomCustomField, visitorCustomField] = await Promise.all([ + createCustomField(api, { + field: roomCustomFieldLabel, + label: roomCustomFieldName, + scope: 'room', + }), + createCustomField(api, { + field: visitorCustomFieldLabel, + label: visitorCustomFieldName, + scope: 'visitor', + }), + ]); + }); + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeOmnichannel(page); + await poHomeChannel.goto(); + + conversation = await createConversation(api, { + visitorName: visitor.name, + agentId: 'user1', + visitorToken, + }); + + await setVisitorCustomFieldValue(api, { + token: visitorToken, + customFieldId: visitorCustomField.customField._id, + value: visitorCustomFieldValue, + }); + }); + + test.afterAll('Remove agent, manager, custom fields and conversation', async () => { + await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); + }); + + test('Should be allowed to set room custom field for a conversation', async () => { + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens edit room', async () => { + await poHomeChannel.roomInfo.waitForDisplay(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + }); + + await test.step('Agent fills room custom field and saves', async () => { + await poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel).fill(roomCustomFieldValue); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('Custom field should be updated successfully', async () => { + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await expect(poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel)).toHaveValue(roomCustomFieldValue); + }); + }); + + test('Should be allowed to update existing room custom field', async () => { + const updatedValue = faker.lorem.words(2); + + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens edit room and updates custom field', async () => { + await poHomeChannel.roomInfo.waitForDisplay(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel).fill(updatedValue); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('Room Information displays the updated custom field value', async () => { + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.waitForDisplay(); + await expect(poHomeChannel.editRoomInfo.getRoomCustomField(roomCustomFieldLabel)).toHaveValue(updatedValue); + }); + }); + + test('Should verify that the visitor custom field is set', async () => { + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens Contact Information', async () => { + await poHomeChannel.roomToolbar.openContactInfo(); + await poHomeChannel.contacts.contactInfo.waitForDisplay(); + }); + + await test.step('Assert custom field is set successfully', async () => { + await expect(poHomeChannel.contacts.contactInfo.getInfoByValue(visitorCustomFieldValue)).toBeVisible(); + }); + }); + + test('Should be allowed to update existing visitor custom field', async () => { + const updatedVisitorCustomFieldValue = faker.lorem.words(2); + + await test.step('Agent opens the conversation', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('Agent opens Contact Information', async () => { + await poHomeChannel.roomToolbar.openContactInfo(); + await poHomeChannel.contacts.contactInfo.waitForDisplay(); + }); + + await test.step('Agent clicks edit and updates visitor custom field', async () => { + await poHomeChannel.contacts.contactInfo.btnEdit.click(); + await poHomeChannel.contacts.contactInfo.getVisitorCustomField(visitorCustomFieldLabel).fill(updatedVisitorCustomFieldValue); + await poHomeChannel.contacts.contactInfo.btnSave.click(); + }); + + await test.step('Assert custom field is updated successfully', async () => { + await expect(poHomeChannel.contacts.contactInfo.getInfoByValue(updatedVisitorCustomFieldValue)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-fields.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-fields.spec.ts new file mode 100644 index 0000000..c8bc5dc --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-fields.spec.ts @@ -0,0 +1,47 @@ +import { Users } from '../fixtures/userStates'; +import { OmnichannelCustomFields } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('omnichannel-customFields', () => { + let poOmnichannelCustomFields: OmnichannelCustomFields; + const newField = 'any_field'; + + test.beforeEach(async ({ page }) => { + poOmnichannelCustomFields = new OmnichannelCustomFields(page); + await poOmnichannelCustomFields.goto(); + }); + + test('expect add new "custom field"', async ({ page }) => { + await poOmnichannelCustomFields.createNew(); + + await page.waitForURL('/omnichannel/customfields/new'); + await poOmnichannelCustomFields.manageCustomFields.inputField.fill(newField); + await poOmnichannelCustomFields.manageCustomFields.inputLabel.fill('any_label'); + await poOmnichannelCustomFields.manageCustomFields.save(); + + await expect(poOmnichannelCustomFields.table.findRowByName(newField)).toBeVisible(); + }); + + test('expect update "newField"', async () => { + const newLabel = 'new_any_label'; + await poOmnichannelCustomFields.inputSearch.fill(newField); + await poOmnichannelCustomFields.table.findRowByName(newField).click(); + + await poOmnichannelCustomFields.manageCustomFields.inputLabel.fill('new_any_label'); + await poOmnichannelCustomFields.manageCustomFields.labelVisible.click(); + await poOmnichannelCustomFields.manageCustomFields.save(); + + await expect(poOmnichannelCustomFields.table.findRowByName(newField)).toContainText(newLabel); + }); + + test('expect remove "new_field"', async () => { + await poOmnichannelCustomFields.inputSearch.fill(newField); + await poOmnichannelCustomFields.table.findRowByName(newField).click(); + await poOmnichannelCustomFields.deleteCustomField(newField); + + await poOmnichannelCustomFields.inputSearch.fill(newField); + await expect(poOmnichannelCustomFields.table.findRowByName(newField)).toBeHidden(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments-ce.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments-ce.spec.ts new file mode 100644 index 0000000..f07ec0e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments-ce.spec.ts @@ -0,0 +1,39 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelDepartments } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('OC - Manage Departments (CE)', () => { + test.skip(IS_EE, 'Community Edition Only'); + let poOmnichannelDepartments: OmnichannelDepartments; + let departmentName: string; + + test.beforeAll(async () => { + departmentName = faker.string.uuid(); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelDepartments = new OmnichannelDepartments(page); + await poOmnichannelDepartments.goto(); + }); + + test('OC - Manage Departments (CE) - Create department', async () => { + await test.step('expect create new department', async () => { + await poOmnichannelDepartments.createNew(); + await poOmnichannelDepartments.createDepartment(departmentName, faker.internet.email()); + + await poOmnichannelDepartments.inputSearch.fill(departmentName); + await expect(poOmnichannelDepartments.table.findRowByName(departmentName)).toBeVisible(); + }); + + await test.step('expect to not be possible adding a second department ', async () => { + await poOmnichannelDepartments.createNew(); + await poOmnichannelDepartments.upsellDepartmentsModal.waitForDisplay(); + await poOmnichannelDepartments.upsellDepartmentsModal.close(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts new file mode 100644 index 0000000..480980e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-departaments.spec.ts @@ -0,0 +1,256 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelDepartments } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createDepartment, deleteDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +const ERROR = { + requiredName: 'Name required', + requiredEmail: 'Email required', + invalidEmail: 'Invalid email address', +}; + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Manage Departments', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poOmnichannelDepartments: OmnichannelDepartments; + let agent: Awaited>; + + test.beforeAll(async ({ api }) => { + // turn on department removal + await api.post('/settings/Omnichannel_enable_department_removal', { value: true }); + agent = await createAgent(api, 'user1'); + }); + + test.afterAll(async ({ api }) => { + // turn off department removal + await api.post('/settings/Omnichannel_enable_department_removal', { value: false }); + await agent.delete(); + }); + + test.describe('Create first department', async () => { + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelDepartments = new OmnichannelDepartments(page); + await poOmnichannelDepartments.goto(); + }); + + test('Create department', async ({ page }) => { + const departmentName = faker.string.uuid(); + + await poOmnichannelDepartments.createNew(); + + await test.step('expect name and email to be required', async () => { + await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredName)).not.toBeVisible(); + await poOmnichannelDepartments.btnSave.click(); + await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredName)).toBeVisible(); + await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredEmail)).toBeVisible(); + }); + + await test.step('expect to fill required fields', async () => { + await poOmnichannelDepartments.labelEnabled.click(); + await poOmnichannelDepartments.inputName.fill(departmentName); + await poOmnichannelDepartments.inputEmail.fill(faker.internet.email()); + }); + + await test.step('expect to fetch agents a reasonable number of times', async () => { + let requestCount = 0; + + await page.route('**/v1/livechat/users/agent*', async (route) => { + requestCount++; + await route.continue(); + }); + + await poOmnichannelDepartments.inputAgents.click(); + await poOmnichannelDepartments.inputAgents.fill('user1'); + + await page.waitForTimeout(1000); + await expect(requestCount).toBeGreaterThan(0); + await expect(requestCount).toBeLessThan(3); + + await poOmnichannelDepartments.inputAgents.click(); + }); + + await test.step('expect to add an agent', async () => { + await poOmnichannelDepartments.inputAgents.click(); + await poOmnichannelDepartments.inputAgents.fill('user1'); + await poOmnichannelDepartments.findOption('user1 (@user1)').click(); + await poOmnichannelDepartments.btnAddAgent.click(); + await expect(poOmnichannelDepartments.agentsTable.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect create new department', async () => { + await poOmnichannelDepartments.btnSave.click(); + await poOmnichannelDepartments.search(departmentName); + await expect(poOmnichannelDepartments.table.findRowByName(departmentName)).toBeVisible(); + }); + + await test.step('expect to delete department', async () => { + await poOmnichannelDepartments.search(departmentName); + await poOmnichannelDepartments.getDepartmentMenuByName(departmentName).click(); + await poOmnichannelDepartments.menuDeleteOption.click(); + + await test.step('expect confirm delete department', async () => { + await test.step('expect delete to be disabled when name is incorrect', async () => { + await expect(poOmnichannelDepartments.deleteModal.btnDelete).toBeDisabled(); + await poOmnichannelDepartments.deleteModal.inputConfirmDepartmentName.fill('someramdomname'); + await expect(poOmnichannelDepartments.deleteModal.btnDelete).toBeDisabled(); + }); + + await test.step('expect to successfuly delete if department name is correct', async () => { + await expect(poOmnichannelDepartments.deleteModal.btnDelete).toBeDisabled(); + await poOmnichannelDepartments.deleteModal.inputConfirmDepartmentName.fill(departmentName); + await expect(poOmnichannelDepartments.deleteModal.btnDelete).toBeEnabled(); + await poOmnichannelDepartments.deleteModal.deleteDepartment(departmentName); + }); + }); + + await test.step('expect department to have been deleted', async () => { + await poOmnichannelDepartments.search(departmentName); + await expect(poOmnichannelDepartments.table.findRowByName(departmentName)).toHaveCount(0); + }); + }); + }); + }); + + test.describe('After creation', async () => { + let department: Awaited>['data']; + + test.beforeEach(async ({ api, page }) => { + poOmnichannelDepartments = new OmnichannelDepartments(page); + + department = await createDepartment(api).then((res) => res.data); + await poOmnichannelDepartments.goto(); + }); + + test.afterEach(async ({ api }) => { + await deleteDepartment(api, { id: department._id }); + }); + + test('Edit department', async () => { + await test.step('expect create new department', async () => { + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + + return department; + }); + + await test.step('expect update department name', async () => { + await poOmnichannelDepartments.search(department.name); + await poOmnichannelDepartments.getDepartmentMenuByName(department.name).click(); + await poOmnichannelDepartments.menuEditOption.click(); + + await poOmnichannelDepartments.inputName.fill(`edited-${department.name}`); + await poOmnichannelDepartments.btnSave.click(); + + await poOmnichannelDepartments.search(`edited-${department.name}`); + await expect(poOmnichannelDepartments.table.findRowByName(`edited-${department.name}`)).toBeVisible(); + }); + }); + + test('Archive department', async () => { + await test.step('expect create new department', async () => { + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + }); + + await test.step('expect archive department', async () => { + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + + await poOmnichannelDepartments.archiveDepartmentByName(department.name); + await poOmnichannelDepartments.tabArchivedDepartments.click(); + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + }); + + await test.step('expect archived department to not be editable', async () => { + await poOmnichannelDepartments.getDepartmentMenuByName(department.name).click(); + await expect(poOmnichannelDepartments.menuEditOption).not.toBeVisible(); + }); + + await test.step('expect unarchive department', async () => { + await poOmnichannelDepartments.menuUnarchiveOption.click(); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toHaveCount(0); + }); + }); + + test('Request tag(s) before closing conversation', async () => { + await test.step('should create new department', async () => { + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + }); + + const tagName = faker.string.sample(5); + await poOmnichannelDepartments.getDepartmentMenuByName(department.name).click(); + await poOmnichannelDepartments.menuEditOption.click(); + + await test.step('should be able to add a tag properly', async () => { + await poOmnichannelDepartments.inputConversationClosingTags.fill(tagName); + await poOmnichannelDepartments.btnAddTags.click(); + + await expect(poOmnichannelDepartments.btnTag(tagName)).toBeVisible(); + await expect(poOmnichannelDepartments.btnSave).toBeEnabled(); + }); + + await test.step('should be able to remove a tag properly', async () => { + await poOmnichannelDepartments.btnTag(tagName).click(); + await expect(poOmnichannelDepartments.btnAddTags).toBeDisabled(); + }); + + await test.step('should not be possible to add empty tags', async () => { + await poOmnichannelDepartments.inputConversationClosingTags.fill(''); + await expect(poOmnichannelDepartments.btnAddTags).toBeDisabled(); + }); + + await test.step('should not be possible to add same tag twice', async () => { + const tagName = faker.string.sample(5); + await poOmnichannelDepartments.inputConversationClosingTags.fill(tagName); + await poOmnichannelDepartments.btnAddTags.click(); + await poOmnichannelDepartments.inputConversationClosingTags.fill(tagName); + await expect(poOmnichannelDepartments.btnAddTags).toBeDisabled(); + }); + }); + + test('Toggle department removal', async ({ api, page }) => { + await test.step('expect create new department', async () => { + await poOmnichannelDepartments.search(department.name); + await expect(poOmnichannelDepartments.table.findRowByName(department.name)).toBeVisible(); + }); + + await test.step('expect to be able to delete department', async () => { + await poOmnichannelDepartments.search(department.name); + await poOmnichannelDepartments.getDepartmentMenuByName(department.name).click(); + await expect(poOmnichannelDepartments.menuDeleteOption).toBeEnabled(); + await page.keyboard.press('Escape'); + }); + + await test.step('expect to disable department removal setting', async () => { + const statusCode = (await api.post('/settings/Omnichannel_enable_department_removal', { value: false })).status(); + expect(statusCode).toBe(200); + }); + + await test.step('expect not to be able to delete department', async () => { + await poOmnichannelDepartments.search(department.name); + await poOmnichannelDepartments.getDepartmentMenuByName(department.name).click(); + await expect(poOmnichannelDepartments.menuDeleteOption).toBeDisabled(); + await page.keyboard.press('Escape'); + }); + + await test.step('expect to enable department removal setting', async () => { + const statusCode = (await api.post('/settings/Omnichannel_enable_department_removal', { value: true })).status(); + expect(statusCode).toBe(200); + }); + + await test.step('expect to delete department', async () => { + const deleteRes = await deleteDepartment(api, { id: department._id }); + expect(deleteRes.status()).toBe(200); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-enterprise-menus-logout.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-enterprise-menus-logout.spec.ts new file mode 100644 index 0000000..68a3f31 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-enterprise-menus-logout.spec.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; + +import { ADMIN_CREDENTIALS, IS_EE } from '../config/constants'; +import injectInitialData from '../fixtures/inject-initial-data'; +import { Login } from '../page-objects'; +import { test, expect } from '../utils/test'; + +// TODO: Replace this test with a unit test +test.describe('OC - Enterprise Menu Items After Relogin', () => { + // Create page object and redirect to home + test.beforeEach(async ({ page }: { page: Page }) => { + await new Login(page).goto('/omnichannel/current'); + + await page.locator('role=textbox[name=/username/i]').fill(ADMIN_CREDENTIALS.email); + await page.locator('[name=password]').fill(ADMIN_CREDENTIALS.password); + await page.locator('role=button[name="Login"]').click(); + + await page.locator('#main-content').waitFor(); + }); + + // Delete all data + test.afterAll(async () => { + await injectInitialData(); + }); + + test('OC - Enterprise Menu Items - Logout & Login', async ({ page }) => { + test.skip(!IS_EE); + await test.step('expect EE menu items to be visible', async () => { + await expect(page.locator('a[href="/omnichannel/tags"]')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts new file mode 100644 index 0000000..88caab7 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -0,0 +1,129 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Routing to Idle Agents', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLivechat: OmnichannelLiveChat; + + let livechatPage: Page | undefined; + + let agent: Awaited>; + let visitor: { name: string; email: string }; + + const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + await expect(agent.response).toBeOK(); + + const settingResponses = await Promise.all([ + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + setSettingValueById(api, 'Omnichannel_enable_department_removal', true), + ]); + + for (const response of settingResponses) { + await expect(response).toBeOK(); + } + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + await expect(await makeAgentAvailable(api, agent.data._id)).toBeOK(); + + poHomeOmnichannel = new HomeOmnichannel(page); + await poHomeOmnichannel.goto(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('online')).toBeVisible(); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) { + await livechatPage.context().close(); + } + + await expect(await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300)).toBeOK(); + }); + + test.afterAll(async ({ api }) => { + const responses = await Promise.all([ + agent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + setSettingValueById(api, 'Omnichannel_enable_department_removal', false), + ]); + + for (const response of responses) { + await expect(response).toBeOK(); + } + }); + + routingMethods.forEach((routingMethod) => { + test.describe(`Routing method: ${routingMethod}`, () => { + test(`should not route to idle agents`, async ({ api }) => { + await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod)).toBeOK(); + await expect(await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false)).toBeOK(); + }); + + await test.step('Visitor tries to initiate a conversation with the away agent', async () => { + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('Hello from visitor'); + + // Force Agent to become away by idle timeout + await expect(await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1)).toBeOK(); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); + + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify visitor is not taken by agent', async () => { + await expect( + poLivechat.alertMessage('Error starting a new conversation: Sorry, no online agents [no-agent-online]'), + ).toBeVisible(); + }); + }); + + test(`should route to agents even if they are idle when setting is enabled`, async ({ api }) => { + await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod)).toBeOK(); + await expect(await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true)).toBeOK(); + }); + + await test.step('Force agent to become away by idle timeout', async () => { + await expect(await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1)).toBeOK(); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.openAnyLiveChatAndSendMessage({ + liveChatUser: visitor, + message: 'test message', + isOffline: false, + }); + }); + + await test.step('Verify chat is served to an agent', async () => { + await expect(poLivechat.headerTitle).toHaveText(agent.data.username); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts new file mode 100644 index 0000000..b18916f --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -0,0 +1,927 @@ +import { faker } from '@faker-js/faker/locale/af_ZA'; +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +// TODO: Use official widget typing once that is merged +declare const window: Window & { + // TODO: Improve tests to no longer use window object properties + onPrechatFormSubmit: boolean; + onAssignAgent: boolean; + onAgentStatusChange: boolean; + onOfflineFormSubmit: boolean; + onChatStarted: boolean; + onChatEnded: boolean; + + RocketChat: { + livechat: { + clearBusinessUnit: () => void; + clearDepartment: () => void; + initialize: () => void; + maximizeWidget: () => void; + minimizeWidget: () => void; + hideWidget: () => void; + showWidget: () => void; + pageVisited: () => void; + registerGuest: (visitor: { name: string; email: string; token: string }) => void; + setAgent: (agent: { username: string; _id: string }) => void; + setBusinessUnit: (businessUnit?: string) => void; + setCustomField: (field: { key: string; value: string }) => void; + setDepartment: (dep: string) => void; + setGuestEmail: (email: string) => void; + setGuestName: (name: string) => void; + setGuestToken: (token: string) => void; + setParentUrl: (url: string) => void; + setTheme: (theme: { + color?: string; + fontColor?: string; + iconColor?: string; + title?: string; + offlineTitle?: string; + hideExpandChat?: boolean; + }) => void; + setLanguage: (language: string) => void; + transferChat: (department: string) => void; + onChatMaximized: (callback: () => void) => void; + onChatMinimized: (callback: () => void) => void; + onChatStarted: (callback: () => void) => void; + onChatEnded: (callback: () => void) => void; + onPrechatFormSubmit: (callback: () => void) => void; + onAssignAgent: (callback: () => void) => void; + onAgentStatusChange: (callback: () => void) => void; + onOfflineFormSubmit: (callback: () => void) => void; + onWidgetHidden: (callback: () => void) => void; + onWidgetShown: (callback: () => void) => void; + onServiceOffline: (callback: () => void) => void; + onQueuePositionChange: (callback: () => void) => void; + }; + }; +}; + +const createFakeVisitorRegistration = (extra?: { department?: string }) => ({ + ...createFakeVisitor(), + token: faker.string.uuid(), + ...extra, +}); + +test.describe('OC - Livechat API', () => { + // TODO: Check if there is a way to add livechat to the global window object + + test.describe('Basic Widget Interactions', () => { + // Tests that rely only on the widget itself, without requiring further interaction from the main RC app + let poAuxContext: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + let poLiveChat: OmnichannelLiveChatEmbedded; + let page: Page; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + page = await browser.newPage(); + + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + const { page: pageCtx } = await createAuxContext(browser, Users.user1); + poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; + + await poLiveChat.goto(); + }); + + test.afterAll(async () => { + await Promise.all([poAuxContext.page.close(), agent.delete(), page.close()]); + }); + + test('OC - Livechat API - Open and Close widget', async () => { + await test.step('Expect widget to be visible after maximizeWidget()', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + }); + + await test.step('Expect widget not be visible after minimizeWidget()', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.minimizeWidget()); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + }); + }); + + test('OC - Livechat API - Show and Hide widget', async () => { + await test.step('Expect livechat button not be visible after minimizeWidget()', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.hideWidget()); + + await expect(page.frameLocator('#rocketchat-iframe').getByRole('button', { name: 'Rocket.Chat' })).not.toBeVisible(); + }); + + await test.step('Expect livechat button to be visible after show()', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.showWidget()); + + await expect(page.frameLocator('#rocketchat-iframe').getByRole('button', { name: 'Rocket.Chat' })).toBeVisible(); + }); + }); + + test.skip('OC - Livechat API - setAgent', async () => { + // Set agent does not actually set the agent, it just sets the default agent on the widget state + // Maybe that is used in an integration? Since as it is now, when the user starts a chat, the agent will be overriden + // TODO: Find the use case of the setAgent method + await test.step('Expect setAgent to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setAgent({ username: 'user1', _id: 'user1' })); + }); + }); + + test('OC - Livechat API - setLanguage', async () => { + await test.step('Expect language to be pt-BR', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setLanguage('pt-BR')); + + await expect( + page.frameLocator('#rocketchat-iframe').getByText('Por favor, conte-nos algumas informações para iniciarmos o chat'), + ).toBeVisible(); + }); + + await test.step('Expect language to be en', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setLanguage('en')); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Please, tell us some information to start the chat')).toBeVisible(); + }); + }); + + test('OC - Livechat API - setTheme', async () => { + const registerGuestVisitor = createFakeVisitorRegistration(); + + await test.step('Expect setTheme set color', async () => { + await poLiveChat.page.evaluate(() => { + window.RocketChat.livechat.maximizeWidget(); + window.RocketChat.livechat.setTheme({ color: 'rgb(50, 50, 50)' }); + }); + + await expect(page.frameLocator('#rocketchat-iframe').locator('header')).toHaveCSS('background-color', 'rgb(50, 50, 50)'); + }); + + await test.step('Expect setTheme set fontColor', async () => { + await poLiveChat.page.evaluate(() => { + window.RocketChat.livechat.maximizeWidget(); + window.RocketChat.livechat.setTheme({ fontColor: 'rgb(50, 50, 50)' }); + }); + + await expect(page.frameLocator('#rocketchat-iframe').locator('header')).toHaveCSS('color', 'rgb(50, 50, 50)'); + }); + + await test.step('expect setTheme set hideExpandChat', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + + await expect(poLiveChat.btnExpandChat).toBeVisible(); + + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ hideExpandChat: true })); + + await expect(poLiveChat.btnExpandChat).not.toBeVisible(); + + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ hideExpandChat: false })); + + await expect(poLiveChat.btnExpandChat).toBeVisible(); + }); + + // TODO: fix iconColor setTheme property + // await test.step('Expect setTheme set iconColor', async () => { + // await poLiveChat.page.evaluate(() => { + // window.RocketChat.livechat.maximizeWidget(); + // window.RocketChat.livechat.setTheme({ iconColor: 'rgb(50, 50, 50)' }); + // }); + + // await expect(page.frameLocator('#rocketchat-iframe').locator('header')).toHaveCSS('color', 'rgb(50, 50, 50)'); + // }); + + await test.step('Expect setTheme set title', async () => { + await poLiveChat.page.evaluate(() => { + window.RocketChat.livechat.maximizeWidget(); + window.RocketChat.livechat.setTheme({ title: 'CustomTitle' }); + }); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('CustomTitle')).toBeVisible(); + }); + + // await test.step('Expect setTheme set offlineTitle', async () => { + // await poLiveChat.page.evaluate(() => { + // window.RocketChat.livechat.maximizeWidget(); + // window.RocketChat.livechat.setTheme({ offlineTitle: 'CustomOfflineTitle' }); + // }); + + // await expect(page.frameLocator('#rocketchat-iframe').getByText('CustomTitle')).toBeVisible(); + // }); + }); + + test.skip('OC - Livechat API - setParentUrl', async () => { + // TODO: check how to test this, not sure there is a clear indication of parent url changes + await test.step('Expect setParentUrl to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setParentUrl('http://localhost:3000')); + }); + }); + }); + + test.describe('Complex Widget Interactions', () => { + // Needs Departments to test this, so needs an EE license for multiple deps + test.skip(!IS_EE, 'Enterprise Only'); + // Tests that requires interaction from an agent or more + let poAuxContext: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + let poLiveChat: OmnichannelLiveChatEmbedded; + let page: Page; + let agent: Awaited>; + let agent2: Awaited>; + let departments: Awaited>[]; + let pageContext: Page; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + agent2 = await createAgent(api, 'user2'); + + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + const [departmentA, departmentB] = departments.map(({ data }) => data); + + await addAgentToDepartment(api, { department: departmentA, agentId: agent.data._id }); + await addAgentToDepartment(api, { department: departmentB, agentId: agent2.data._id }); + expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }); + }); + + test.beforeEach(async ({ browser }, testInfo) => { + page = await browser.newPage(); + + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + const { page: pageCtx } = await createAuxContext(browser, Users.user1); + poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; + + // This is needed since the livechat will not react to online/offline status changes if already loaded in a page + if (testInfo.title === 'Expect onOfflineFormSubmit to trigger callback') { + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('offline'); + } else { + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('online'); + } + + await poLiveChat.goto(); + }); + + test.afterEach(async () => { + await Promise.all([poAuxContext.page.close(), page.close(), pageContext?.close()]); + }); + + test.afterAll(async ({ api }) => { + await agent.delete(); + await agent2.delete(); + + await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: true })).status()).toBe(200); + await Promise.all([...departments.map((department) => department.delete())]); + await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: false })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }); + }); + + // clearBusinessUnit + // clearDepartment + // initialize + // maximizeWidget + // minimizeWidget + // pageVisited + // registerGuest + // setAgent + // setBusinessUnit + // setCustomField + // setDepartment + // setGuestEmail + // setGuestName + // setGuestToken + // setParentUrl + // setTheme + + test.skip('OC - Livechat API - clearBusinessUnit', async () => { + // TODO: check how to test this, and if this is working as intended + await test.step('Expect clearBusinessUnit to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.clearBusinessUnit()); + }); + }); + + test.skip('OC - Livechat API - setBusinessUnit', async () => { + // TODO + await test.step('Expect setBusinessUnit to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setBusinessUnit()); + }); + }); + + test.skip('OC - Livechat API - setCustomField', async () => { + // TODO + await test.step('Expect setCustomField to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setCustomField({ key: 'test', value: 'test' })); + }); + }); + + test.skip('OC - Livechat API - clearDepartment', async () => { + // TODO + await test.step('Expect clearDepartment to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.clearDepartment()); + }); + }); + + test.describe('OC - Livechat API - setDepartment', () => { + let poAuxContext2: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeEach(async ({ browser }) => { + const { page: pageCtx2 } = await createAuxContext(browser, Users.user2); + poAuxContext2 = { page: pageCtx2, poHomeOmnichannel: new HomeOmnichannel(pageCtx2) }; + }); + + test.afterEach(async () => { + await poAuxContext2.page.close(); + }); + + test('setDepartment - Called during ongoing conversation', async () => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + const registerGuestVisitor = createFakeVisitorRegistration({ + department: departmentA._id, + }); + + // Start Chat + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('Expect registered guest to be in dep1', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + await expect(poAuxContext.poHomeOmnichannel.content.channelHeader).toContainText(registerGuestVisitor.name); + }); + + const depId = departmentB._id; + + await test.step('Expect chat not be transferred', async () => { + await poLiveChat.page.evaluate((depId) => window.RocketChat.livechat.setDepartment(depId), depId); + + await poAuxContext2.poHomeOmnichannel.navbar.searchInput.click(); + await poAuxContext2.poHomeOmnichannel.navbar.typeSearch(registerGuestVisitor.name); + await expect(poAuxContext2.poHomeOmnichannel.navbar.getSearchRoomByName(registerGuestVisitor.name)).not.toBeVisible(); + }); + + await test.step('Expect registered guest to still be in dep1', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + await expect(poAuxContext.poHomeOmnichannel.content.channelHeader).toContainText(registerGuestVisitor.name); + }); + }); + + test('setDepartment - Called before conversation', async () => { + const departmentB = departments[1].data; + const registerGuestVisitor = createFakeVisitor(); + + const depId = departmentB._id; + + await poLiveChat.page.evaluate((depId) => window.RocketChat.livechat.setDepartment(depId), depId); + + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.sendMessage(registerGuestVisitor, false); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('Expect registered guest to be in dep2', async () => { + await poAuxContext2.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + + await poAuxContext2.page.locator('role=main').waitFor(); + await poAuxContext2.page.locator('role=main >> role=heading[level=1]').waitFor(); + await expect(poAuxContext2.page.locator('role=main >> .rcx-skeleton')).toHaveCount(0); + await expect(poAuxContext2.page.locator('role=main >> role=list')).not.toHaveAttribute('aria-busy', 'true'); + }); + + await test.step('Expect registered guest not to be in dep1', async () => { + await poAuxContext.poHomeOmnichannel.navbar.typeSearch(registerGuestVisitor.name); + await expect(poAuxContext.poHomeOmnichannel.navbar.getSearchRoomByName(registerGuestVisitor.name)).not.toBeVisible(); + }); + }); + }); + + test.describe('OC - Livechat API - transferChat', () => { + let poAuxContext2: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeEach(async ({ browser }) => { + const { page: pageCtx2 } = await createAuxContext(browser, Users.user2); + poAuxContext2 = { page: pageCtx2, poHomeOmnichannel: new HomeOmnichannel(pageCtx2) }; + }); + + test.afterEach(async () => { + await poAuxContext2.page.close(); + }); + + test('transferChat - Called during ongoing conversation', async () => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + const registerGuestVisitor = createFakeVisitorRegistration({ + department: departmentA._id, + }); + + // Start Chat + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('Expect registered guest to be in dep1', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + await expect(poAuxContext.poHomeOmnichannel.content.channelHeader).toContainText(registerGuestVisitor.name); + }); + + const depId = departmentB._id; + + await test.step('Expect chat to be transferred', async () => { + await poLiveChat.page.evaluate((depId) => window.RocketChat.livechat.transferChat(depId), depId); + + await poAuxContext2.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + await poAuxContext2.poHomeOmnichannel.navbar.typeSearch(registerGuestVisitor.name); + await expect(poAuxContext2.poHomeOmnichannel.navbar.getSearchRoomByName(registerGuestVisitor.name)).toBeVisible(); + }); + }); + }); + + test('OC - Livechat API - registerGuest', async ({ browser }) => { + const registerGuestVisitor = createFakeVisitorRegistration(); + + await test.step('Expect registerGuest to create a valid guest', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect registered guest to have valid info', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + + await poAuxContext.poHomeOmnichannel.roomToolbar.openContactInfo(); + // For some reason the guest info email information is being set to lowercase + await expect(poAuxContext.poHomeOmnichannel.contacts.contactInfo.infoContactEmail).toHaveText( + registerGuestVisitor.email.toLowerCase(), + ); + }); + + await test.step('Expect registerGuest to log in an existing guest and load chat history', async () => { + ({ page: pageContext } = await createAuxContext(browser, Users.user1)); + + await new OmnichannelLiveChatEmbedded(pageContext).goto(); + + await pageContext.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await pageContext.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); + }); + }); + + test('OC - Livechat API - registerGuest different guests', async () => { + const registerGuestVisitor1 = createFakeVisitorRegistration(); + + const registerGuestVisitor2 = createFakeVisitorRegistration(); + + await test.step('Expect registerGuest to create guest 1', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor1) => window.RocketChat.livechat.registerGuest(registerGuestVisitor1), + registerGuestVisitor1, + ); + + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor_1'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_1')).toBeVisible(); + + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor1.name); + await poAuxContext.poHomeOmnichannel.content.sendMessage('this_is_a_test_message_from_agent'); + await expect(poLiveChat.txtChatMessage('this_is_a_test_message_from_agent')).toBeVisible(); + }); + + await test.step('Expect registerGuest to create guest 2', async () => { + await poLiveChat.page.evaluate( + (registerGuestVisitor2) => window.RocketChat.livechat.registerGuest(registerGuestVisitor2), + registerGuestVisitor2, + ); + + // wait for load messages to happen + await page.waitForResponse((response) => response.url().includes(`token=${registerGuestVisitor2.token}`)); + + await poLiveChat.page + .frameLocator('#rocketchat-iframe') + .getByText('this_a_test_message_from_visitor_1') + .waitFor({ state: 'hidden' }); + + await expect(poLiveChat.page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor_2'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2').waitFor({ state: 'visible' }); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor_2')).toBeVisible(); + }); + }); + + test('OC - Livechat API - registerGuest multiple times', async () => { + const registerGuestVisitor = createFakeVisitorRegistration(); + + await test.step('Expect registerGuest work with the same token, multiple times', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor')).toBeVisible(); + + await poLiveChat.page.evaluate((registerGuestVisitor) => { + window.RocketChat.livechat.registerGuest(registerGuestVisitor); + window.RocketChat.livechat.registerGuest(registerGuestVisitor); + }, registerGuestVisitor); + + await page.waitForResponse('**/api/v1/livechat/visitor'); + await page.waitForTimeout(500); // NOTE: timeout is necessary to allow websocket unsubscribes to happen + + await poLiveChat.onlineAgentMessage.fill('this_a_new_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor')).toBeVisible(); + await expect(poLiveChat.txtChatMessage('this_a_new_test_message_from_visitor')).toBeVisible(); + }); + }); + + test.skip('OC - Livechat API - setGuestEmail', async () => { + const registerGuestVisitor = createFakeVisitorRegistration(); + // Start Chat + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('Expect setGuestEmail to change a guest email', async () => { + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.setGuestEmail(`changed${registerGuestVisitor.email}`), + registerGuestVisitor, + ); + }); + + await test.step('Expect registered guest to have valid info', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + + await poAuxContext.poHomeOmnichannel.roomToolbar.openContactInfo(); + // For some reason the guest info email information is being set to lowercase + await expect(poAuxContext.poHomeOmnichannel.contacts.contactInfo.infoContactEmail).toHaveText( + `changed${registerGuestVisitor.email}`.toLowerCase(), + ); + }); + }); + + test('OC - Livechat API - setGuestName', async () => { + const registerGuestVisitor = createFakeVisitorRegistration(); + // Start Chat + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poAuxContext.poHomeOmnichannel.navbar.openChat(registerGuestVisitor.name); + + await test.step('Expect setGuestEmail to change a guest email', async () => { + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.setGuestName(`changed${registerGuestVisitor.name}`), + registerGuestVisitor, + ); + }); + + await test.step('Expect registered guest to have valid info', async () => { + await expect(poAuxContext.poHomeOmnichannel.content.infoHeaderName).toContainText(`changed${registerGuestVisitor.name}`); + }); + }); + + test('OC - Livechat API - setGuestToken', async ({ browser }) => { + const registerGuestVisitor = createFakeVisitorRegistration(); + + // Register guest and send a message + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + + await poLiveChat.page.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.registerGuest(registerGuestVisitor), + registerGuestVisitor, + ); + + await expect(page.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('Expect setGuestToken to log in an existing guest and load chat history', async () => { + ({ page: pageContext } = await createAuxContext(browser, Users.user1)); + + await new OmnichannelLiveChatEmbedded(pageContext).goto(); + + await pageContext.evaluate(() => window.RocketChat.livechat.maximizeWidget()); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).toBeVisible(); + + await pageContext.evaluate( + (registerGuestVisitor) => window.RocketChat.livechat.setGuestToken(registerGuestVisitor.token), + registerGuestVisitor, + ); + + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('Start Chat')).not.toBeVisible(); + await expect(pageContext.frameLocator('#rocketchat-iframe').getByText('this_a_test_message_from_visitor')).toBeVisible(); + }); + }); + }); + + test.describe('Widget Listeners', () => { + // Tests that listen to events from the widget, and check if they are being triggered + + let poAuxContext: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + let poLiveChat: OmnichannelLiveChatEmbedded; + let page: Page; + let agent: Awaited>; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }); + }); + + test.beforeEach(async ({ browser }, testInfo) => { + page = await browser.newPage(); + + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + const { page: pageCtx } = await createAuxContext(browser, Users.user1); + poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; + + // This is needed since the livechat will not react to online/offline status changes if already loaded in a page + if (testInfo.title === 'Expect onOfflineFormSubmit to trigger callback') { + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('offline'); + } else { + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('online'); + } + + await poLiveChat.goto(); + }); + + test.afterEach(async () => { + await poAuxContext.page.close(); + await page.close(); + }); + + test.afterAll(async ({ api }) => { + await agent.delete(); + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }); + }); + + test('OC - Livechat API - onChatMaximized & onChatMinimized', async () => { + await test.step('Expect onChatMaximized to trigger callback', async () => { + await poLiveChat.page.evaluate( + () => + new Promise((resolve: (value?: unknown) => void) => { + window.RocketChat.livechat.onChatMaximized(() => { + resolve(); + }); + + window.RocketChat.livechat.maximizeWidget(); + }), + ); + }); + + await test.step('Expect onChatMinimized to trigger callback', async () => { + await poLiveChat.page.evaluate( + () => + new Promise((resolve: (value?: unknown) => void) => { + window.RocketChat.livechat.onChatMinimized(() => { + resolve(); + }); + + window.RocketChat.livechat.minimizeWidget(); + }), + ); + }); + }); + + test('OC - Livechat API - onChatStarted & onChatEnded', async () => { + const newVisitor = createFakeVisitor(); + + await test.step('Expect onChatStarted to trigger callback', async () => { + const watchForTrigger = page.waitForFunction(() => window.onChatStarted === true); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onChatStarted(() => { + window.onChatStarted = true; + }), + ); + + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await watchForTrigger; + }); + + await test.step('Expect onChatEnded to trigger callback', async () => { + const watchForTrigger = page.waitForFunction(() => window.onChatEnded === true); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onChatEnded(() => { + window.onChatEnded = true; + }), + ); + + await poAuxContext.poHomeOmnichannel.navbar.openChat(newVisitor.name); + await poAuxContext.poHomeOmnichannel.quickActionsRoomToolbar.closeChat({ comment: 'this_is_a_test_comment' }); + await watchForTrigger; + }); + }); + + test('OC - Livechat API - onPrechatFormSubmit & onAssignAgent', async () => { + const newVisitor = createFakeVisitor(); + + await test.step('Expect onPrechatFormSubmit to trigger callback', async () => { + const watchForTrigger = page.waitForFunction(() => window.onPrechatFormSubmit === true); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onPrechatFormSubmit(() => { + window.onPrechatFormSubmit = true; + }), + ); + + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await watchForTrigger; + }); + + await test.step('Expect onAssignAgent to trigger callback', async () => { + const watchForTrigger = page.waitForFunction(() => window.onAssignAgent === true); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onAssignAgent(() => { + window.onAssignAgent = true; + }), + ); + + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await watchForTrigger; + }); + }); + + // TODO: Fix this Flaky test + test.skip('onAgentStatusChange', async () => { + const newVisitor = createFakeVisitor(); + + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + const watchForTrigger = page.waitForFunction(() => window.onAgentStatusChange === true); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onAgentStatusChange(() => { + window.onAgentStatusChange = true; + }), + ); + + await poAuxContext.poHomeOmnichannel.navbar.openChat(newVisitor.name); + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('offline'); + + await watchForTrigger; + }); + + test('OC - Livechat API - onOfflineFormSubmit', async () => { + const newVisitor = createFakeVisitor(); + + await poAuxContext.poHomeOmnichannel.navbar.changeUserStatus('offline'); + await poAuxContext.poHomeOmnichannel.navbar.switchOmnichannelStatus('offline'); + + const watchForTrigger = page.waitForFunction(() => window.onOfflineFormSubmit === true); + + await poLiveChat.page.reload(); + + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.onOfflineFormSubmit(() => { + window.onOfflineFormSubmit = true; + }), + ); + + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, true); + + await watchForTrigger; + }); + + test('OC - Livechat API - onWidgetHidden & onWidgetShown', async () => { + await test.step('Expect onWidgetHidden to trigger callback', async () => { + await poLiveChat.page.evaluate( + () => + new Promise((resolve: (value?: unknown) => void) => { + window.RocketChat.livechat.onWidgetHidden(() => { + resolve(); + }); + + window.RocketChat.livechat.hideWidget(); + }), + ); + }); + + await test.step('Expect onWidgetShown to trigger callback', async () => { + await poLiveChat.page.evaluate( + () => + new Promise((resolve: (value?: unknown) => void) => { + window.RocketChat.livechat.onWidgetShown(() => { + resolve(); + }); + + window.RocketChat.livechat.showWidget(); + }), + ); + }); + }); + + test.skip('OC - Livechat API - onServiceOffline', async () => { + // TODO: Not sure how to test this, need to check if playwright has a way to mock a server disconnect + await test.step('Expect onServiceOffline to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.onServiceOffline(() => console.log('onServiceOffline'))); + }); + }); + + test.skip('OC - Livechat API - onQueuePositionChange', async () => { + // TODO + await test.step('Expect onQueuePositionChange to do something', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.onQueuePositionChange(() => console.log('onQueuePositionChange'))); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-avatar-visibility.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-avatar-visibility.spec.ts new file mode 100644 index 0000000..f409a7c --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-avatar-visibility.spec.ts @@ -0,0 +1,96 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +declare const window: Window & { + RocketChat: { + livechat: { + setTheme: (theme: { hideAgentAvatar?: boolean; hideGuestAvatar?: boolean }) => void; + }; + }; +}; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Avatar visibility', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChatEmbedded; + let poAuxContext: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + if (res.status() !== 200) { + throw new Error('Failed to make agent available'); + } + }); + + test.beforeEach(async ({ browser }) => { + const { page: pageCtx } = await createAuxContext(browser, Users.user1); + poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; + }); + + test.beforeEach(async ({ page }) => { + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + await poLiveChat.goto(); + }); + + test.afterEach(async ({ page }) => { + await poAuxContext.page.close(); + await page.close(); + }); + + test.afterAll(async () => { + await agent.delete(); + }); + + test('OC - Livechat - Change avatar visibility', async () => { + const visitor = createFakeVisitor(); + + await test.step('should initiate Livechat conversation', async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + }); + + await test.step('expect to send a message as agent', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(visitor.name); + await poAuxContext.poHomeOmnichannel.content.sendMessage('this_is_a_test_message_from_agent'); + await expect(poLiveChat.txtChatMessage('this_is_a_test_message_from_agent')).toBeVisible(); + }); + + await test.step('expect visitor avatar to be hidden', async () => { + await expect(poLiveChat.imgAvatar(visitor.name)).not.toBeVisible(); + }); + + await test.step('expect agent avatar to be visible', async () => { + await expect(poLiveChat.imgAvatar('user1')).toBeVisible(); + }); + + await test.step('expect to make visitor avatar visible', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ hideGuestAvatar: false })); + await expect(poLiveChat.imgAvatar(visitor.name)).toBeVisible(); + }); + + await test.step('expect to hide agent avatar', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ hideAgentAvatar: false })); + await expect(poLiveChat.imgAvatar('user1')).toBeVisible(); + }); + + await test.step('should close the conversation', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(visitor.name); + await poAuxContext.poHomeOmnichannel.quickActionsRoomToolbar.closeChat({ comment: 'this_is_a_test_comment' }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-background.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-background.spec.ts new file mode 100644 index 0000000..245991c --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-background.spec.ts @@ -0,0 +1,99 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +declare const window: Window & { + RocketChat: { + livechat: { + setTheme: (theme: { background?: string }) => void; + }; + }; +}; + +test.use({ storageState: Users.admin.state }); + +test.skip(!IS_EE, 'Enterprise Only'); + +test.describe('OC - Livechat - Message list background', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChatEmbedded; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + if (res.status() !== 200) { + throw new Error('Failed to make agent available'); + } + }); + + test.beforeEach(async ({ page }) => { + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + await poLiveChat.goto(); + }); + + test.afterEach(async ({ page }) => { + await page.close(); + }); + + test.afterAll(async () => { + await agent.delete(); + }); + + test('OC - Livechat - Change message list background', async ({ api, page }) => { + const visitor = createFakeVisitor(); + + await test.step('should initiate Livechat conversation', async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('message_from_user')).toBeVisible(); + }); + + await test.step('expect message list to have default background', async () => { + await expect(await poLiveChat.messageListBackground).toBe('rgba(0, 0, 0, 0)'); + }); + + await test.step('expect to change message list background', async () => { + const res = await api.post('/settings/Livechat_background', { value: 'rgb(186, 1, 85)' }); + await expect(res.status()).toBe(200); + + await page.reload(); + await poLiveChat.openLiveChat(); + await expect(await poLiveChat.messageListBackground).toBe('rgb(186, 1, 85)'); + }); + + await test.step('expect to give priority to background provided via api', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ background: 'rgb(186, 218, 85)' })); + + await expect(await poLiveChat.messageListBackground).toBe('rgb(186, 218, 85)'); + }); + + await test.step('expect to fallback to setting if api background is not available', async () => { + await poLiveChat.page.evaluate(() => window.RocketChat.livechat.setTheme({ background: undefined })); + await expect(await poLiveChat.messageListBackground).toBe('rgb(186, 1, 85)'); + }); + + await test.step('expect to reset message list background to default', async () => { + const res = await api.post('/settings/Livechat_background', { value: '' }); + await expect(res.status()).toBe(200); + + await page.reload(); + await poLiveChat.openLiveChat(); + await expect(await poLiveChat.messageListBackground).toBe('rgba(0, 0, 0, 0)'); + }); + + await test.step('should close the conversation', async () => { + await poLiveChat.btnOptions.click(); + await poLiveChat.btnCloseChat.click(); + await poLiveChat.btnCloseChatConfirm.click(); + await expect(poLiveChat.btnNewChat).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts new file mode 100644 index 0000000..f9e960d --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-department.spec.ts @@ -0,0 +1,153 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Department Flow', () => { + // Needs Departments to test this, so needs an EE license for multiple deps + test.skip(!IS_EE, 'Enterprise Only'); + + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannelAgent1: HomeOmnichannel; + let poHomeOmnichannelAgent2: HomeOmnichannel; + let departments: Awaited>[]; + let departmentA: Awaited>['data']; + let departmentB: Awaited>['data']; + let agents: Awaited>[]; + let agent1: Awaited>['data']; + let agent2: Awaited>['data']; + + test.beforeAll(async ({ api }) => { + // Assign agents & departments + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + [agent1, agent2] = agents.map(({ data }) => data); + departments = await Promise.all([ + createDepartment(api, { showOnRegistration: true }), + createDepartment(api, { showOnRegistration: true }), + ]); + [departmentA, departmentB] = departments.map(({ data }) => data); + await addAgentToDepartment(api, { department: departmentA, agentId: agent1._id }); + await addAgentToDepartment(api, { department: departmentB, agentId: agent2._id }); + }); + + test.beforeEach(async ({ page, api, browser }) => { + // Create Pages + const { page: agent1Page } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannelAgent1 = new HomeOmnichannel(agent1Page); + const { page: agent2Page } = await createAuxContext(browser, Users.user2, '/', true); + poHomeOmnichannelAgent2 = new HomeOmnichannel(agent2Page); + + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + }); + + test.afterEach(async ({ page }) => { + await poHomeOmnichannelAgent1.page.close(); + await poHomeOmnichannelAgent2.page.close(); + await page.close(); + }); + + test.afterAll(async ({ api }) => { + await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: true })).status()).toBe(200); + await Promise.all([...agents.map((agent) => agent.delete())]); + await Promise.all([...departments.map((department) => department.delete())]); + await expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: false })).status()).toBe(200); + }); + + test('OC - Livechat - Chat with Department', async () => { + const guest = createFakeVisitor(); + + await test.step('expect start Chat with department', async () => { + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(guest, false, departmentA.name); + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + }); + + await test.step('expect message to be received by department', async () => { + await poHomeOmnichannelAgent1.navbar.openChat(guest.name); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + + await test.step('expect message to be sent by department', async () => { + await poHomeOmnichannelAgent1.content.sendMessage('this_a_test_message_from_agent'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent"')).toBeVisible(); + }); + }); + + test('OC - Livechat - Change Department', async () => { + const guest = createFakeVisitor(); + await test.step('expect start Chat with department', async () => { + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(guest, false, departmentA.name); + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + }); + + await test.step('expect message to be received by department 1', async () => { + await poHomeOmnichannelAgent1.navbar.openChat(guest.name); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannelAgent1.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + + await test.step('expect message to be sent by department 1', async () => { + await poHomeOmnichannelAgent1.content.sendMessage('this_a_test_message_from_agent_department_1'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent_department_1"')).toBeVisible(); + await poHomeOmnichannelAgent1.page.close(); + }); + + await test.step('expect to change department', async () => { + await poLiveChat.btnOptions.click(); + await poLiveChat.btnChangeDepartment.click(); + + await expect(poLiveChat.selectDepartment).toBeVisible(); + await poLiveChat.selectDepartment.selectOption({ label: departmentB.name }); + + await expect(poLiveChat.btnSendMessage('Start chat')).toBeEnabled(); + await poLiveChat.btnSendMessage('Start chat').click(); + + await expect(poLiveChat.livechatModal).toBeVisible(); + + await expect(poLiveChat.livechatModalText('Are you sure you want to switch the department?')).toBeVisible(); + await poLiveChat.btnYes.click(); + + await expect(poLiveChat.livechatModal).toBeVisible(); + + await expect(poLiveChat.livechatModalText('Department switched')).toBeVisible(); + await poLiveChat.btnOk.click(); + + // Expect keep chat history + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + + // Expect user to have changed + await expect(await poLiveChat.headerTitle.textContent()).toEqual(agent2.username); + + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user_to_department_2'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user_to_department_2"')).toBeVisible(); + }); + + await test.step('expect message to be received by department', async () => { + await poHomeOmnichannelAgent2.navbar.openChat(guest.name); + await expect(poHomeOmnichannelAgent2.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannelAgent2.content.lastUserMessage).toContainText('this_a_test_message_from_user_to_department_2'); + }); + + await test.step('expect message to be sent by department', async () => { + await poHomeOmnichannelAgent2.content.sendMessage('this_a_test_message_from_agent_department_2'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent_department_2"')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts new file mode 100644 index 0000000..23472a8 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-fileupload.spec.ts @@ -0,0 +1,124 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +// Endpoint defaults are reset after each test, so if not in matrix assume is true +const endpointMatrix = [ + [{ url: '/settings/FileUpload_Enabled', value: false }], + [{ url: '/settings/Livechat_fileupload_enabled', value: false }], + [ + { url: '/settings/FileUpload_Enabled', value: false }, + { url: '/settings/Livechat_fileupload_enabled', value: false }, + ], +]; + +const beforeTest = async (poLiveChat: OmnichannelLiveChat) => { + await poLiveChat.goto(); + + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_user').waitFor({ state: 'visible' }); +}; + +test.describe('OC - Livechat - OC - File Upload', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + poHomeOmnichannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/FileUpload_Enabled', { value: true }); + await api.post('/settings/Livechat_fileupload_enabled', { value: true }); + + await poHomeOmnichannel.page.close(); + await agent.delete(); + }); + + // Default settings are FileUpload_Enabled true and Livechat_fileupload_enabled true + test('OC - Livechat - txt Drag & Drop', async () => { + await beforeTest(poLiveChat); + + await test.step('expect to upload a txt file', async () => { + await poLiveChat.dragAndDropTxtFile(); + await expect(poLiveChat.findUploadedFileLink('any_file.txt')).toBeVisible(); + }); + }); + + test('OC - Livechat - lst Drag & Drop', async () => { + await beforeTest(poLiveChat); + + await test.step('expect to upload a lst file', async () => { + await poLiveChat.dragAndDropLstFile(); + await expect(poLiveChat.findUploadedFileLink('lst-test.lst')).toBeVisible(); + }); + }); +}); + +test.describe('OC - Livechat - OC - File Upload - Disabled', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + poHomeOmnichannel = new HomeOmnichannel(page); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/FileUpload_Enabled', { value: true }); + await api.post('/settings/Livechat_fileupload_enabled', { value: true }); + + await poHomeOmnichannel.page?.close(); + await agent.delete(); + }); + + endpointMatrix.forEach((endpoints) => { + const testName = endpoints.map((endpoint) => endpoint.url.split('/').pop()?.concat(`=${endpoint.value}`)).join(' '); + + test(`OC - Livechat - txt Drag & Drop - ${testName}`, async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + + await Promise.all( + endpoints.map(async (endpoint: { url: string; value: boolean }) => { + await api.post(endpoint.url, { value: endpoint.value }); + }), + ); + + await poLiveChat.goto(); + + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await poLiveChat.txtChatMessage('this_a_test_message_from_user').waitFor({ state: 'visible' }); + + await test.step('expect to upload a txt file', async () => { + await poLiveChat.dragAndDropTxtFile(); + + await expect(poLiveChat.alertMessage('File upload is disabled')).toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-hide-expand-chat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-hide-expand-chat.spec.ts new file mode 100644 index 0000000..45382da --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-hide-expand-chat.spec.ts @@ -0,0 +1,66 @@ +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat, OmnichannelLivechatAppearance } from '../page-objects/omnichannel'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +test.skip(!IS_EE, 'Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Livechat - Hide "Expand chat"', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChat; + let poLivechatAppearance: OmnichannelLivechatAppearance; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + await expect(res.status()).toBe(200); + }); + + test.beforeEach(async ({ browser, api }) => { + const { page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async () => { + await poLiveChat.page.close(); + }); + + test.beforeEach(async ({ page }) => { + poLivechatAppearance = new OmnichannelLivechatAppearance(page); + + await poLivechatAppearance.goto(); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_hide_expand_chat', { value: false }); + await expect(res.status()).toBe(200); + }); + + test('OC - Livechat - Hide "Expand chat"', async () => { + await test.step('expect to open Livechat', async () => { + await poLiveChat.openLiveChat(); + }); + + await test.step('expect "Expand chat" button to start visible (default)', async () => { + await expect(poLiveChat.btnExpandChat).toBeVisible(); + }); + + await test.step('expect to change setting', async () => { + await poLivechatAppearance.labelHideExpandChat.click(); + await poLivechatAppearance.btnSaveChanges.click(); + }); + + await test.step('expect "Expand chat" button to be hidden', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openLiveChat(); + await expect(poLiveChat.btnExpandChat).toBeHidden(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-logo.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-logo.spec.ts new file mode 100644 index 0000000..2938501 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-logo.spec.ts @@ -0,0 +1,63 @@ +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat, OmnichannelSettings } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.skip(!IS_EE, 'Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Livechat - Widget logo', async () => { + let poLiveChat: OmnichannelLiveChat; + let poOmnichannelSettings: OmnichannelSettings; + + test.beforeEach(async ({ browser, api }) => { + const { page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async () => { + await poLiveChat.page.close(); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelSettings = new OmnichannelSettings(page); + + await poOmnichannelSettings.goto(); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/assets.unsetAsset', { assetName: 'livechat_widget_logo' }); + if (res.status() !== 200) { + throw new Error('Failed to unset asset'); + } + }); + + test('OC - Livechat - Change widget logo', async () => { + await test.step('expect to have default logo', async () => { + await poOmnichannelSettings.group('Livechat').click(); + await expect(poLiveChat.imgLogo).not.toBeVisible(); + await expect(poOmnichannelSettings.imgLivechatLogoPreview).not.toBeVisible(); + }); + + await test.step('expect to change widget logo', async () => { + await expect(poOmnichannelSettings.labelLivechatLogo).toHaveText('Livechat widget logo (svg, png, jpg)'); + await poOmnichannelSettings.inputLivechatLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + await expect(poOmnichannelSettings.imgLivechatLogoPreview).toBeVisible(); + + await poLiveChat.page.reload(); + await expect(poLiveChat.imgLogo).toBeVisible(); + await expect(poLiveChat.imgLogo).toHaveAttribute('src', 'assets/livechat_widget_logo.jpeg'); + }); + + await test.step('expect to remove custom logo', async () => { + await poOmnichannelSettings.btnDeleteLivechatLogo.click(); + await expect(poOmnichannelSettings.imgLivechatLogoPreview).not.toBeVisible(); + + await poLiveChat.page.reload(); + await expect(poLiveChat.imgLogo).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-message-bubble-color.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-message-bubble-color.spec.ts new file mode 100644 index 0000000..6d11236 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-message-bubble-color.spec.ts @@ -0,0 +1,103 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +declare const window: Window & { + RocketChat: { + livechat: { + setTheme: (theme: { guestBubbleBackgroundColor?: string; agentBubbleBackgroundColor?: string }) => void; + }; + }; +}; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Bubble background color', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChatEmbedded; + let poAuxContext: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + if (res.status() !== 200) { + throw new Error('Failed to make agent available'); + } + }); + + test.beforeEach(async ({ browser }) => { + const { page: pageCtx } = await createAuxContext(browser, Users.user1); + poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; + }); + + test.beforeEach(async ({ page }) => { + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + await poLiveChat.goto(); + }); + + test.afterEach(async ({ page }) => { + await poAuxContext.page?.close(); + await page.close(); + }); + + test.afterAll(async () => { + await agent.delete(); + }); + + test('OC - Livechat - Change bubble background color', async () => { + const visitor = createFakeVisitor(); + + await test.step('should initiate Livechat conversation', async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill('message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('message_from_user')).toBeVisible(); + }); + + await test.step('expect to send a message as agent', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(visitor.name); + await poAuxContext.poHomeOmnichannel.content.sendMessage('message_from_agent'); + }); + + await test.step('expect to have default bubble background color', async () => { + expect(await poLiveChat.messageBubbleBackground('message_from_user')).toBe('rgb(193, 39, 45)'); + expect(await poLiveChat.messageBubbleBackground('message_from_agent')).toBe('rgb(247, 248, 250)'); + }); + + await test.step('expect to change bubble background color', async () => { + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.setTheme({ + guestBubbleBackgroundColor: 'rgb(186, 218, 85)', + agentBubbleBackgroundColor: 'rgb(0, 100, 250)', + }), + ); + + expect(await poLiveChat.messageBubbleBackground('message_from_user')).toBe('rgb(186, 218, 85)'); + expect(await poLiveChat.messageBubbleBackground('message_from_agent')).toBe('rgb(0, 100, 250)'); + }); + + await test.step('expect to reset bubble background color to defaults', async () => { + await poLiveChat.page.evaluate(() => + window.RocketChat.livechat.setTheme({ guestBubbleBackgroundColor: undefined, agentBubbleBackgroundColor: undefined }), + ); + + expect(await poLiveChat.messageBubbleBackground('message_from_user')).toBe('rgb(193, 39, 45)'); + expect(await poLiveChat.messageBubbleBackground('message_from_agent')).toBe('rgb(247, 248, 250)'); + }); + + await test.step('should close the conversation', async () => { + await poAuxContext.poHomeOmnichannel.navbar.openChat(visitor.name); + await poAuxContext.poHomeOmnichannel.quickActionsRoomToolbar.closeChat({ comment: 'this_is_a_test_comment' }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management-autoselection.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management-autoselection.spec.ts new file mode 100644 index 0000000..fe5fc8b --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management-autoselection.spec.ts @@ -0,0 +1,124 @@ +import type { BrowserContext } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const firstVisitor = createFakeVisitor(); + +const secondVisitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Queue Management', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLiveChat: OmnichannelLiveChat; + let liveChatContext: BrowserContext; + + const waitingQueueMessage = 'This is a message from Waiting Queue'; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + api.post('/settings/Livechat_accept_chats_with_no_agents', { value: true }), + api.post('/settings/Livechat_waiting_queue', { value: true }), + api.post('/settings/Livechat_waiting_queue_message', { value: waitingQueueMessage }), + api.post('/livechat/users/agent', { username: 'user1' }), + ]); + + const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + + // Agent will be offline for these tests + await poHomeOmnichannel.navbar.switchOmnichannelStatus('offline'); + }); + + test.beforeEach(async ({ browser, api }) => { + liveChatContext = await browser.newContext(); + const page2 = await liveChatContext.newPage(); + + poLiveChat = new OmnichannelLiveChat(page2, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Livechat_accept_chats_with_no_agents', { value: false }), + api.post('/settings/Livechat_waiting_queue', { value: false }), + api.post('/settings/Livechat_waiting_queue_message', { value: '' }), + api.delete('/livechat/users/agent/user1'), + ]); + await poHomeOmnichannel.page.close(); + }); + + test.describe('OC - Queue Management - Auto Selection', () => { + let poLiveChat2: OmnichannelLiveChat; + let liveChat2Context: BrowserContext; + + test.beforeEach(async ({ browser, api }) => { + liveChat2Context = await browser.newContext(); + const page = await liveChat2Context.newPage(); + poLiveChat2 = new OmnichannelLiveChat(page, api); + await poLiveChat2.goto(); + }); + + test.afterEach(async () => { + await poLiveChat2.closeChat(); + await liveChat2Context.close(); + await poLiveChat.closeChat(); + await liveChatContext.close(); + }); + + test('Update user position on Queue', async () => { + await test.step('should start livechat session', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: firstVisitor, + message: 'Test message', + isOffline: false, + }); + }); + + await test.step('expect to receive Waiting Queue message on chat', async () => { + await expect(poLiveChat.page.locator(`div >> text=${waitingQueueMessage}`)).toBeVisible(); + }); + + await test.step('expect to be on spot #1', async () => { + await expect(poLiveChat.queuePosition(1)).toBeVisible(); + }); + + await test.step('should start secondary livechat session', async () => { + await poLiveChat2.openAnyLiveChatAndSendMessage({ + liveChatUser: secondVisitor, + message: 'Test message', + isOffline: false, + }); + }); + + await test.step('should start secondary livechat on spot #2', async () => { + await expect(poLiveChat2.queuePosition(2)).toBeVisible(); + }); + + await test.step('should start the queue by making the agent available again', async () => { + await poHomeOmnichannel.navbar.switchOmnichannelStatus('online'); + }); + + await test.step('user1 should get assigned to the first chat', async () => { + await expect(poLiveChat.queuePosition(1)).not.toBeVisible(); + }); + + await test.step('secondary session should be on position #1', async () => { + await expect(poLiveChat2.queuePosition(1)).toBeVisible(); + }); + + await test.step('secondary session should be taken by user1', async () => { + await expect(poLiveChat2.queuePosition(1)).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts new file mode 100644 index 0000000..0078802 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts @@ -0,0 +1,247 @@ +import type { BrowserContext } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const firstVisitor = createFakeVisitor(); + +const secondVisitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Queue Management', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLiveChat: OmnichannelLiveChat; + let liveChatContext: BrowserContext; + + const waitingQueueMessage = 'This is a message from Waiting Queue'; + const queuePosition1 = 'Your spot is #1'; + const queuePosition2 = 'Your spot is #2'; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }), + api.post('/settings/Livechat_waiting_queue', { value: true }), + api.post('/settings/Livechat_waiting_queue_message', { value: waitingQueueMessage }), + api.post('/livechat/users/agent', { username: 'user1' }), + ]); + + const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + }); + + test.beforeEach(async ({ browser, api }) => { + liveChatContext = await browser.newContext(); + const page2 = await liveChatContext.newPage(); + + poLiveChat = new OmnichannelLiveChat(page2, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + api.post('/settings/Livechat_waiting_queue', { value: false }), + api.post('/settings/Livechat_waiting_queue_message', { value: '' }), + api.delete('/livechat/users/agent/user1'), + ]); + await poHomeOmnichannel.page.close(); + }); + + test.afterEach(async () => { + await poLiveChat.closeChat(); + await liveChatContext.close(); + }); + + test('OC - Queue Management - Waiting Queue Message enabled', async () => { + await test.step('should start livechat session', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: firstVisitor, + message: 'Test message', + isOffline: false, + }); + }); + await test.step('expect to receive Waiting Queue message on chat', async () => { + await expect(poLiveChat.page.locator(`div >> text=${waitingQueueMessage}`)).toBeVisible(); + }); + }); + + test.describe('OC - Queue Management - Update Queue Position', () => { + let poLiveChat2: OmnichannelLiveChat; + let liveChat2Context: BrowserContext; + + test.beforeEach(async ({ browser, api }) => { + liveChat2Context = await browser.newContext(); + const page = await liveChat2Context.newPage(); + poLiveChat2 = new OmnichannelLiveChat(page, api); + await poLiveChat2.goto(); + }); + + test.afterEach(async () => { + await poLiveChat2.closeChat(); + await liveChat2Context.close(); + }); + + test('Update user position on Queue', async () => { + await test.step('should start secondary livechat session', async () => { + await poLiveChat2.openAnyLiveChatAndSendMessage({ + liveChatUser: secondVisitor, + message: 'Test message', + isOffline: false, + }); + }); + + await test.step('should start primary livechat session', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: firstVisitor, + message: 'Test message', + isOffline: false, + }); + }); + + await test.step('should verify the queue position of the primary user', async () => { + await expect(poLiveChat.page.locator(`div[role='alert'] >> text=${queuePosition2}`)).toBeVisible(); + }); + + await test.step('should allow the agent to take the secondary user chat', async () => { + await poHomeOmnichannel.sidebar.getSidebarItemByName(secondVisitor.name).click(); + await expect(poHomeOmnichannel.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannel.content.btnTakeChat.click(); + await expect(poHomeOmnichannel.content.lastSystemMessageBody).toHaveText('joined the channel'); + }); + + await test.step('expect the queue position of the primary user to update after the secondary users chat is taken', async () => { + await expect(poLiveChat.page.locator(`div[role='alert'] >> text=${queuePosition1}`)).toBeVisible(); + }); + }); + }); +}); + +test.describe('OC - Contact Manager Routing', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLiveChat: OmnichannelLiveChat; + let liveChatContext: BrowserContext; + + // User2 will be the contact manager + let poHomeOmnichannelUser2: HomeOmnichannel; + + const visitorWithManager = createFakeVisitor(); + const contactId = `contact-${Date.now()}`; + + test.beforeAll(async ({ api, browser }) => { + await api.post('/livechat/users/agent', { username: 'user2' }); + await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }); + await api.post('/omnichannel/contact', { + _id: contactId, + name: visitorWithManager.name, + email: visitorWithManager.email, + contactManager: { + username: 'user2', + }, + }); + + const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + + const { page: omniPageUser2 } = await createAuxContext(browser, Users.user2, '/', true); + poHomeOmnichannelUser2 = new HomeOmnichannel(omniPageUser2); + }); + + test.beforeEach(async ({ browser, api }) => { + liveChatContext = await browser.newContext(); + const page = await liveChatContext.newPage(); + + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/agent/user2'), + api.delete(`/omnichannel/contact/${contactId}`), + ]); + + await poHomeOmnichannel.page.close(); + await poHomeOmnichannelUser2.page.close(); + }); + + test.afterEach(async () => { + await poLiveChat.closeChat(); + await liveChatContext.close(); + }); + + test('should route inquiry only to the contact manager', async () => { + await test.step('visitor with contact manager starts a chat', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: visitorWithManager, + message: 'I need assistance', + isOffline: false, + }); + }); + + await test.step('verify non-manager agent does not see the inquiry', async () => { + const nonManagerQueuedChat = poHomeOmnichannel.sidebar.getSidebarItemByName(visitorWithManager.name); + await expect(nonManagerQueuedChat).toHaveCount(0); + }); + + await test.step('verify the contact manager agent sees the inquiry', async () => { + const managerQueuedChat = poHomeOmnichannelUser2.sidebar.getSidebarItemByName(visitorWithManager.name); + await expect(managerQueuedChat).toBeVisible(); + }); + + await test.step('contact manager can take the chat', async () => { + await poHomeOmnichannelUser2.sidebar.getSidebarItemByName(visitorWithManager.name).click(); + await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannelUser2.content.btnTakeChat.click(); + await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel'); + }); + }); + + test('inquiry should persist only in contact manager queue after page refresh', async () => { + const anotherVisitorWithManager = createFakeVisitor(); + + await test.step('visitor with contact manager starts a chat', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: anotherVisitorWithManager, + message: 'I need help after refresh test', + isOffline: false, + }); + }); + + await test.step('refresh both agent pages', async () => { + await poHomeOmnichannel.page.reload(); + await poHomeOmnichannel.page.waitForLoadState('networkidle'); + + await poHomeOmnichannelUser2.page.reload(); + await poHomeOmnichannelUser2.page.waitForLoadState('networkidle'); + }); + + await test.step('verify non-manager agent still does not see the inquiry after refresh', async () => { + const nonManagerQueuedChat = poHomeOmnichannel.sidebar.getSidebarItemByName(anotherVisitorWithManager.name); + await expect(nonManagerQueuedChat).toHaveCount(0); + }); + + await test.step('verify the contact manager still sees the inquiry after refresh', async () => { + const managerQueuedChat = poHomeOmnichannelUser2.sidebar.getSidebarItemByName(anotherVisitorWithManager.name); + await expect(managerQueuedChat).toBeVisible(); + }); + + await test.step('contact manager can take the chat after refresh', async () => { + await poHomeOmnichannelUser2.sidebar.getSidebarItemByName(anotherVisitorWithManager.name).click(); + await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannelUser2.content.btnTakeChat.click(); + await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel'); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts new file mode 100644 index 0000000..3cd8956 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts @@ -0,0 +1,88 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Livechat - Read Receipts', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + let agentPage: Page; + let agent: Awaited>; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api, browser }) => { + agent = await createAgent(api, 'user1'); + + await Promise.all([ + setSettingValueById(api, 'Message_Read_Receipt_Enabled', true), + setSettingValueById(api, 'Message_Read_Receipt_Store_Users', true), + setSettingValueById(api, 'UI_Use_Real_Name', true), + ]); + + ({ page: agentPage } = await createAuxContext(browser, Users.user1, '/', true)); + poHomeOmnichannel = new HomeOmnichannel(agentPage); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterAll(({ api }) => + Promise.all([ + setSettingValueById(api, 'Message_Read_Receipt_Enabled', false), + setSettingValueById(api, 'Message_Read_Receipt_Store_Users', false), + setSettingValueById(api, 'UI_Use_Real_Name', false), + agent.delete(), + livechatPage.close(), + agentPage.close(), + ]), + ); + + test('OC - Livechat - Read receipts should display visitor name', async () => { + const testMessage = 'test_message_for_read_receipts'; + + await test.step('send message from livechat widget', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(visitor, false); + await poLiveChat.onlineAgentMessage.fill(testMessage); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage(testMessage)).toBeVisible(); + }); + + await test.step('agent reads the message', async () => { + const markedAsRead = agentPage.waitForResponse( + (res) => /api\/v1\/subscriptions\.read(?:\/|\?|$)/.test(res.url()) && res.request().method() === 'POST', + ); + await poHomeOmnichannel.navbar.openChat(visitor.name); + await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannel.content.lastUserMessage).toContainText(testMessage); + await markedAsRead; + await expect(poHomeOmnichannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + }); + + await test.step('read receipts show both agent and visitor names', async () => { + await poHomeOmnichannel.content.openLastMessageMenu(); + await agentPage.locator('role=menuitem[name="Read receipts"]').click(); + + const dialog = agentPage.getByRole('dialog', { name: 'Read by' }); + const listItems = dialog.getByRole('listitem'); + await expect(listItems).toHaveCount(2); + await expect(listItems.filter({ hasText: visitor.name })).toHaveCount(1); + await expect(listItems.filter({ hasText: 'user1' })).toHaveCount(1); + await dialog.getByLabel('Close').click(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-tab-communication.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-tab-communication.spec.ts new file mode 100644 index 0000000..446eb80 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-tab-communication.spec.ts @@ -0,0 +1,99 @@ +import type { BrowserContext } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +test.describe('OC - Livechat - Cross Tab Communication', () => { + let pageLivechat1: OmnichannelLiveChat; + let pageLivechat2: OmnichannelLiveChat; + let livechatContext: BrowserContext; + + let poHomeOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + test.beforeAll(async ({ browser, api }) => { + agent = await createAgent(api, 'user1'); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + poHomeOmnichannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async ({ browser, api }) => { + livechatContext = await browser.newContext(); + const p1 = await livechatContext.newPage(); + const p2 = await livechatContext.newPage(); + + pageLivechat1 = new OmnichannelLiveChat(p1, api); + pageLivechat2 = new OmnichannelLiveChat(p2, api); + + await pageLivechat1.goto(); + await pageLivechat2.goto(); + }); + + test.afterEach(async () => { + // Both pages share the same context; closing it disposes both pages + await livechatContext.close(); + }); + + test.afterAll(async () => { + await poHomeOmnichannel.page.close(); + await agent.delete(); + }); + + test('OC - Livechat - Send messages, close chat and start again 2 tabs', async () => { + const visitor = createFakeVisitor(); + + await test.step('expect livechat conversations to be synced', async () => { + await pageLivechat1.openAnyLiveChat(); + + await pageLivechat1.sendMessage(visitor, false); + await pageLivechat1.onlineAgentMessage.fill('this_a_test_message_from_user'); + await pageLivechat1.btnSendMessageToOnlineAgent.click(); + + await expect(pageLivechat1.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + await expect(pageLivechat2.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + }); + + await test.step('expect to close livechat conversation', async () => { + await expect(pageLivechat1.btnOptions).toBeVisible(); + await pageLivechat1.btnOptions.click(); + + await expect(pageLivechat1.btnCloseChat).toBeVisible(); + await pageLivechat1.btnCloseChat.click(); + + await pageLivechat1.btnCloseChatConfirm.click(); + }); + + await test.step('expect to restart a livechat conversation and tabs to be synced', async () => { + await expect(pageLivechat1.btnNewChat).toBeVisible(); + await pageLivechat1.startNewChat(); + + await pageLivechat1.onlineAgentMessage.fill('this_a_test_message_from_user_after_close'); + await pageLivechat1.btnSendMessageToOnlineAgent.click(); + + await pageLivechat1.txtChatMessage('this_a_test_message_from_user').waitFor({ state: 'hidden' }); + await pageLivechat2.txtChatMessage('this_a_test_message_from_user').waitFor({ state: 'hidden' }); + + await expect(pageLivechat1.txtChatMessage('this_a_test_message_from_user')).not.toBeVisible(); + await expect(pageLivechat2.txtChatMessage('this_a_test_message_from_user')).not.toBeVisible(); + + await expect(pageLivechat1.txtChatMessage('this_a_test_message_from_user_after_close')).toBeVisible(); + await expect(pageLivechat2.txtChatMessage('this_a_test_message_from_user_after_close')).toBeVisible(); + }); + + await test.step('expect to close livechat conversation', async () => { + await expect(pageLivechat1.btnOptions).toBeVisible(); + await pageLivechat1.btnOptions.click(); + + await expect(pageLivechat1.btnCloseChat).toBeVisible(); + await pageLivechat1.btnCloseChat.click(); + + await pageLivechat1.btnCloseChatConfirm.click(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-typing-indicator.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-typing-indicator.spec.ts new file mode 100644 index 0000000..b0de0c9 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-typing-indicator.spec.ts @@ -0,0 +1,60 @@ +import type { Page } from 'playwright-core'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Livechat - Typing indicator', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/livechat/users/agent', { username: 'user1' })).status(); + expect(statusCode).toBe(200); + }); + + test.beforeAll(async ({ browser, api }) => { + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.beforeEach(async ({ page }) => { + poHomeOmnichannel = new HomeOmnichannel(page); + await poHomeOmnichannel.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([api.delete('/livechat/users/agent/user1'), livechatPage.close()]); + }); + + test('OC - Livechat - Visitor/Agent types and sees typing indicator', async () => { + await test.step('expect conversation to be started', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: visitor, + message: 'Hello, I need help', + isOffline: false, + }); + await poHomeOmnichannel.navbar.openChat(visitor.name); + await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); + }); + + await test.step('expect agent to see typing indicator when visitor types', async () => { + await poLiveChat.onlineAgentMessage.pressSequentially('abc', { delay: 20 }); + await expect(poHomeOmnichannel.composer.typingIndicator).toBeVisible(); + }); + + await test.step('expect visitor to see typing indicator when agent types', async () => { + await poHomeOmnichannel.composer.inputMessage.pressSequentially('xyz', { delay: 20 }); + await expect(poLiveChat.typingIndicatorForVisitor).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts new file mode 100644 index 0000000..8b1a2de --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-watermark.spec.ts @@ -0,0 +1,73 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat, OmnichannelSettings } from '../page-objects/omnichannel'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +test.skip(!IS_EE, 'Enterprise Only'); + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Livechat - Hide watermark', async () => { + let agent: Awaited>; + let poLiveChat: OmnichannelLiveChat; + let poOmnichannelSettings: OmnichannelSettings; + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + + const res = await makeAgentAvailable(api, agent.data._id); + + await expect(res.status()).toBe(200); + }); + + test.beforeEach(async ({ browser, api }) => { + const { page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async () => { + await poLiveChat.page.close(); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelSettings = new OmnichannelSettings(page); + + await poOmnichannelSettings.goto(); + }); + + test.afterAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_hide_watermark', { value: false }); + await expect(res.status()).toBe(200); + }); + + test('OC - Livechat - Hide watermark', async () => { + await test.step('expect to open Livechat', async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(visitor, false); + }); + + await test.step('expect watermark to start visible (default)', async () => { + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await expect(poLiveChat.txtWatermark).toBeVisible(); + }); + + await test.step('expect to change setting', async () => { + await poOmnichannelSettings.group('Livechat').click(); + await poOmnichannelSettings.labelHideWatermark.click(); + await poOmnichannelSettings.btnSaveChanges.click(); + }); + + await test.step('expect watermark to be hidden', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openLiveChat(); + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await expect(poLiveChat.txtWatermark).toBeHidden(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts new file mode 100644 index 0000000..fbda294 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts @@ -0,0 +1,28 @@ +import type { Page } from '@playwright/test'; + +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('Omnichannel - Livechat Widget Embedded', () => { + test.describe('Widget is working on Embedded View', () => { + let page: Page; + let poLiveChat: OmnichannelLiveChatEmbedded; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + poLiveChat = new OmnichannelLiveChatEmbedded(page); + + await poLiveChat.goto(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('Open and Close widget', async () => { + await test.step('Expect widget to be visible while embedded in an iframe', async () => { + await expect(poLiveChat.btnOpenLiveChat()).toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts new file mode 100644 index 0000000..3d5cc8b --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -0,0 +1,350 @@ +import type { Page } from 'playwright-core'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { test, expect } from '../utils/test'; + +const firstVisitor = createFakeVisitor(); + +const secondVisitor = createFakeVisitor(); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Livechat', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/livechat/users/agent', { username: 'user1' })).status(); + expect(statusCode).toBe(200); + }); + + test.beforeAll(async ({ browser, api }) => { + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.beforeEach(async ({ page }) => { + poHomeOmnichannel = new HomeOmnichannel(page); + + await poHomeOmnichannel.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([api.delete('/livechat/users/agent/user1'), livechatPage.close()]); + }); + + test('OC - Livechat - Send message to online agent', async () => { + await test.step('expect message to be sent by livechat', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(firstVisitor, false); + + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + }); + + await test.step('expect message to be received by agent', async () => { + await poHomeOmnichannel.navbar.openChat(firstVisitor.name); + await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannel.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + }); + + test('OC - Livechat - Send message to livechat customer', async () => { + await poHomeOmnichannel.navbar.openChat(firstVisitor.name); + + await test.step('expect message to be sent by agent', async () => { + await poHomeOmnichannel.content.sendMessage('this_a_test_message_from_agent'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent"')).toBeVisible(); + }); + + await test.step('expect when user minimizes the livechat screen, the composer should be hidden', async () => { + await poLiveChat.openAnyLiveChat(); + await expect(poLiveChat.page.locator('[contenteditable="true"]')).not.toBeVisible(); + }); + + await test.step('expect message to be received by minimized livechat', async () => { + await poHomeOmnichannel.content.sendMessage('this_a_test_message_again_from_agent'); + await expect(poLiveChat.unreadMessagesBadge(1)).toBeVisible(); + }); + + await test.step('expect multiple messages to be received by minimized livechat', async () => { + await poHomeOmnichannel.content.sendMessage('this_a_test_message_once_again_from_agent'); + await expect(poLiveChat.unreadMessagesBadge(2)).toBeVisible(); + }); + + await test.step('expect unread messages to be visible after a reload', async () => { + await poLiveChat.page.reload(); + await expect(poLiveChat.unreadMessagesBadge(2)).toBeVisible(); + }); + + await test.step('expect unread counter to be empty after user sends a message after reload', async () => { + const message = 'this_a_test_message_from_user_after_reload'; + + await poLiveChat.openAnyLiveChat(); + await poLiveChat.onlineAgentMessage.fill(message); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage(message)).toBeVisible(); + await expect(poLiveChat.unreadMessagesBadge(2)).toHaveCount(0); + await expect(poLiveChat.unreadMessagesBadge(1)).toHaveCount(0); + }); + }); + + test('OC - Livechat - Close livechat conversation', async () => { + await poHomeOmnichannel.navbar.openChat(firstVisitor.name); + + await test.step('expect livechat conversation to be closed by agent', async () => { + await poHomeOmnichannel.quickActionsRoomToolbar.closeChat({ comment: 'this_is_a_test_comment' }); + }); + }); +}); + +test.describe.serial('OC - Livechat - Visitors closing the room is disabled', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + let omniPage: Page; + + test.beforeAll(async ({ api }) => { + await api.post('/livechat/users/agent', { username: 'user1' }); + }); + + test.beforeAll(async ({ browser, api }) => { + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.beforeAll(async ({ browser, api }) => { + await setSettingValueById(api, 'Livechat_allow_visitor_closing_chat', false); + ({ page: omniPage } = await createAuxContext(browser, Users.user1, '/', true)); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Livechat_allow_visitor_closing_chat', true), + api.delete('/livechat/users/agent/user1'), + livechatPage.close(), + omniPage.close(), + ]); + }); + + test('OC - Livechat - Close Chat disabled', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(firstVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('expect to close a livechat conversation', async () => { + await expect(poLiveChat.btnOptions).not.toBeVisible(); + await expect(poLiveChat.btnCloseChat).not.toBeVisible(); + }); + }); + + test('OC - Livechat - Close chat disabled, agents can close', async () => { + await poHomeOmnichannel.navbar.openChat(firstVisitor.name); + + await test.step('expect livechat conversation to be closed by agent', async () => { + await poHomeOmnichannel.quickActionsRoomToolbar.closeChat({ comment: 'this_is_a_test_comment' }); + }); + }); +}); + +test.describe.serial('OC - Livechat - Resub after close room', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + let omniPage: Page; + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/livechat/users/agent', { username: 'user1' })).status(); + expect(statusCode).toBe(200); + }); + + test.beforeAll(async ({ browser, api }) => { + await api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: true }); + ({ page: omniPage } = await createAuxContext(browser, Users.user1, '/', true)); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + + await poLiveChat.sendMessageAndCloseChat(firstVisitor); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }), + api.delete('/livechat/users/agent/user1'), + livechatPage.close(), + omniPage.close(), + ]); + }); + + test('OC - Livechat - Resub after close room', async () => { + await test.step('expect livechat conversation to be opened again, different guest', async () => { + await poLiveChat.startNewChat(); + await poLiveChat.sendMessage(secondVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + }); + + await test.step('expect message to be received by agent', async () => { + await poHomeOmnichannel.navbar.openChat(secondVisitor.name); + await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannel.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + + await test.step('expect message to be sent by agent', async () => { + await poHomeOmnichannel.content.sendMessage('this_a_test_message_from_agent'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent"')).toBeVisible(); + }); + }); +}); + +test.describe('OC - Livechat - Resume chat after closing', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + let livechatPage: Page; + let omniPage: Page; + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/livechat/users/agent', { username: 'user1' })).status(); + expect(statusCode).toBe(200); + }); + + test.beforeAll(async ({ browser, api }) => { + ({ page: omniPage } = await createAuxContext(browser, Users.user1, '/', true)); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + + await poLiveChat.sendMessageAndCloseChat(firstVisitor); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([api.delete('/livechat/users/agent/user1'), livechatPage.close(), omniPage.close()]); + }); + + test('OC - Livechat - Resume chat after closing', async () => { + await test.step('expect livechat conversation to be opened again', async () => { + await poLiveChat.startNewChat(); + await expect(poLiveChat.onlineAgentMessage).toBeVisible(); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_user"')).toBeVisible(); + }); + + await test.step('expect message to be received by agent', async () => { + await poHomeOmnichannel.navbar.openChat(firstVisitor.name); + await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); + await expect(poHomeOmnichannel.content.lastUserMessage).toContainText('this_a_test_message_from_user'); + }); + + await test.step('expect message to be sent by agent', async () => { + await poHomeOmnichannel.content.sendMessage('this_a_test_message_from_agent'); + await expect(poLiveChat.page.locator('div >> text="this_a_test_message_from_agent"')).toBeVisible(); + }); + }); +}); + +test.describe('OC - Livechat - Close chat using widget', () => { + let poLiveChat: OmnichannelLiveChat; + let agent: Awaited>; + + test.beforeAll(async ({ api, browser }) => { + agent = await createAgent(api, 'user1'); + + await createAuxContext(browser, Users.user1, '/', true); + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + + await poLiveChat.goto(); + }); + + test.afterAll(async () => { + await agent.delete(); + }); + + test('OC - Livechat - Close Chat', async () => { + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(firstVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('expect to close a livechat conversation', async () => { + await expect(poLiveChat.btnOptions).toBeVisible(); + await poLiveChat.btnOptions.click(); + + await expect(poLiveChat.btnCloseChat).toBeVisible(); + await poLiveChat.btnCloseChat.click(); + + await poLiveChat.btnCloseChatConfirm.click(); + + await expect(poLiveChat.btnNewChat).toBeVisible(); + }); + }); + + test('OC - Livechat - Close Chat twice', async () => { + await poLiveChat.sendMessageAndCloseChat(firstVisitor); + await poLiveChat.startNewChat(); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('expect to close a livechat conversation a second time', async () => { + await expect(poLiveChat.btnOptions).toBeVisible(); + await poLiveChat.btnOptions.click(); + + await expect(poLiveChat.btnCloseChat).toBeVisible(); + await poLiveChat.btnCloseChat.click(); + + await poLiveChat.btnCloseChatConfirm.click(); + + await expect(poLiveChat.btnNewChat).toBeVisible(); + }); + }); +}); + +test.describe('OC - Livechat - Livechat_Display_Offline_Form', () => { + let poLiveChat: OmnichannelLiveChat; + const message = 'This form is not available'; + + test.beforeAll(async ({ api }) => { + await api.post('/settings/Livechat_display_offline_form', { value: false }); + await api.post('/settings/Livechat_offline_form_unavailable', { value: message }); + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Livechat_display_offline_form', { value: true }); + await api.post('/settings/Livechat_offline_form_unavailable', { value: '' }); + }); + + test('OC - Livechat - Livechat_Display_Offline_Form false', async () => { + await test.step('expect offline form to not be visible', async () => { + await poLiveChat.openAnyLiveChat(); + await expect(poLiveChat.page.locator(`div >> text=${message}`)).toBeVisible(); + await expect(poLiveChat.textAreaMessage).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts new file mode 100644 index 0000000..5dc0660 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts @@ -0,0 +1,229 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelAgents, OmnichannelManager, OmnichannelMonitors } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +const MANAGER = 'user3'; +const ROOM_A = faker.person.fullName(); +const ROOM_B = faker.person.fullName(); +const ROOM_C = faker.person.fullName(); + +test.use({ storageState: Users.user3.state }); + +test.describe('OC - Manager Role', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let poOmnichannel: HomeOmnichannel; + + let manager: Awaited>; + + // Allow manual on hold + test.beforeAll(async ({ api }) => { + const responses = await Promise.all([ + api.post('/settings/Livechat_allow_manual_on_hold', { value: true }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: false }), + ]); + responses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2'), createAgent(api, MANAGER)]); + + const agentsStatuses = await Promise.all(agents.slice(0, 2).map(({ data: agent }) => makeAgentAvailable(api, agent._id))); + + agentsStatuses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Create manager + test.beforeAll(async ({ api }) => { + manager = await createManager(api, MANAGER); + }); + + // Create conversations + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + visitorName: ROOM_A, + visitorToken: 'roomA', + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + visitorName: ROOM_B, + visitorToken: 'roomB', + agentId: `user2`, + departmentId: departmentB._id, + }), + createConversation(api, { + visitorName: ROOM_C, + visitorToken: 'roomC', + agentId: `user2`, + }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + // Delete all created data + test.afterAll(async ({ api }) => { + await Promise.all([ + ...agents.map((agent) => agent.delete()), + ...departments.map((department) => department.delete()), + ...conversations.map((conversation) => conversation.delete()), + manager.delete(), + // Reset setting + api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannel = new HomeOmnichannel(page); + await poOmnichannel.chats.goto(); + }); + + test('OC - Manager Role - Basic permissions', async () => { + await test.step('expect agent to not have access to omnichannel administration', async () => { + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Contact Center')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Analytics')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Real-time Monitoring')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Agents')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Departments')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Business Hours')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Reports')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Canned Responses')).toBeVisible(); + }); + }); + + test('OC - Manager Role - Contact Center', async () => { + await test.step('expect to be able to view all chats', async () => { + await expect(poOmnichannel.chats.table.findRowByName(ROOM_A)).toBeVisible(); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_B)).toBeVisible(); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_C)).toBeVisible(); + }); + + await test.step('expect to be able to join chats', async () => { + await poOmnichannel.chats.openChat(ROOM_A); + await expect(poOmnichannel.composer.btnJoinRoom).toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + + await poOmnichannel.composer.btnJoinRoom.click(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText('joined the channel'); + await expect(poOmnichannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + }); + + await test.step('expect to be able to put a conversation from another agent on hold', async () => { + await poOmnichannel.quickActionsRoomToolbar.placeChatOnHold(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText( + `Chat On Hold: The chat was manually placed On Hold by ${MANAGER}`, + ); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + await expect(poOmnichannel.content.btnResume).toBeVisible(); + }); + + await test.step('expect to be able resume a conversation from another agent on hold', async () => { + await poOmnichannel.content.btnResume.click(); + await expect(poOmnichannel.content.btnResume).not.toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + await expect(poOmnichannel.quickActionsRoomToolbar.btnOnHold).toBeVisible(); + }); + + await test.step('expect to be able to close a conversation from another agent', async () => { + await poOmnichannel.quickActionsRoomToolbar.closeChat(); + await poOmnichannel.chats.goto(); + }); + + await test.step('expect to be able to remove closed rooms', async () => { + await poOmnichannel.chats.removeChatByName(ROOM_A); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_A)).not.toBeVisible(); + }); + }); + + test('OC - Manager Role - Add/remove agents', async ({ page }) => { + const poOmnichannelAgents = new OmnichannelAgents(page); + await poOmnichannelAgents.goto(); + + await test.step('expect add "user1" as agent', async () => { + await poOmnichannelAgents.selectUsername('user1'); + await poOmnichannelAgents.btnAddAgent.click(); + + await poOmnichannelAgents.inputSearch.fill('user1'); + await expect(poOmnichannelAgents.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect remove "user1" as agent', async () => { + await poOmnichannelAgents.inputSearch.fill('user1'); + await poOmnichannelAgents.deleteAgent('user1'); + + await poOmnichannelAgents.inputSearch.fill(''); + await poOmnichannelAgents.inputSearch.fill('user1'); + await expect(poOmnichannelAgents.table.findRowByName('user1')).toBeHidden(); + }); + }); + + test('OC - Manager Role - Add/remove managers', async ({ page }) => { + const poOmnichannelManagers = new OmnichannelManager(page); + await poOmnichannelManagers.goto(); + + await test.step('expect add "user1" as manager', async () => { + await poOmnichannelManagers.selectUsername('user1'); + await poOmnichannelManagers.btnAddManager.click(); + + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect search for manager', async () => { + await poOmnichannelManagers.search('user1'); + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeVisible(); + + await poOmnichannelManagers.search('NonExistingUser'); + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeHidden(); + await poOmnichannelManagers.clearSearch(); + }); + + await test.step('expect remove "user1" as manager', async () => { + await poOmnichannelManagers.search('user1'); + await poOmnichannelManagers.removeManager('user1'); + + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeHidden(); + }); + }); + + test('OC - Manager Role - Add/remove monitors', async ({ page }) => { + const poOmnichannelMonitors = new OmnichannelMonitors(page); + await poOmnichannelMonitors.goto(); + + await test.step('expect to add agent as monitor', async () => { + await expect(poOmnichannelMonitors.table.findRowByName('user1')).not.toBeVisible(); + await poOmnichannelMonitors.addMonitor('user1'); + await expect(poOmnichannelMonitors.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect to remove agent from monitor', async () => { + await poOmnichannelMonitors.removeMonitor('user1'); + + await expect(poOmnichannelMonitors.table.findRowByName('user1')).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts new file mode 100644 index 0000000..c573812 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts @@ -0,0 +1,45 @@ +import { Users } from '../fixtures/userStates'; +import { OmnichannelManager } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('omnichannel-manager', () => { + let poOmnichannelManagers: OmnichannelManager; + + test.beforeEach(async ({ page }) => { + poOmnichannelManagers = new OmnichannelManager(page); + await poOmnichannelManagers.goto(); + }); + + test('OC - Manage Managers - Add, Search and Remove', async ({ page }) => { + await test.step('expect "user1" be first ', async () => { + await poOmnichannelManagers.inputUsername.fill('user'); + await expect(page.locator('role=option[name="user1"]')).toContainText('user1'); + }); + + await test.step('expect add "user1" as manager', async () => { + await poOmnichannelManagers.selectUsername('user1'); + await poOmnichannelManagers.btnAddManager.click(); + + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect search for manager', async () => { + await poOmnichannelManagers.search('user1'); + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeVisible(); + + await poOmnichannelManagers.search('NonExistingUser'); + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeHidden(); + + await poOmnichannelManagers.clearSearch(); + }); + + await test.step('expect remove "user1" as manager', async () => { + await poOmnichannelManagers.search('user1'); + await poOmnichannelManagers.removeManager('user1'); + + await expect(poOmnichannelManagers.table.findRowByName('user1')).toBeHidden(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection-logout.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection-logout.spec.ts new file mode 100644 index 0000000..f99734f --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection-logout.spec.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test'; + +import { DEFAULT_USER_CREDENTIALS } from '../config/constants'; +import injectInitialData from '../fixtures/inject-initial-data'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Manual Selection After Relogin', () => { + let poOmnichannel: HomeOmnichannel; + let agent: Awaited>; + + // Change routing method to manual selection + test.beforeAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }); + expect(res.status()).toBe(200); + }); + + // Create agent and make it available + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user1'); + await makeAgentAvailable(api, agent.data._id); + }); + + // Create page object and redirect to home + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannel = new HomeOmnichannel(page); + await poOmnichannel.goto(); + + await poOmnichannel.navbar.logout(); + await poOmnichannel.page.locator('role=textbox[name=/username/i]').waitFor({ state: 'visible' }); + await poOmnichannel.page.locator('role=textbox[name=/username/i]').fill('user1'); + await poOmnichannel.page.locator('[name=password]').fill(DEFAULT_USER_CREDENTIALS.password); + await poOmnichannel.page.locator('role=button[name="Login"]').click(); + + await poOmnichannel.page.locator('#main-content').waitFor(); + }); + + // Delete all data + test.afterAll(async ({ api }) => { + await agent.delete(); + await api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }); + await injectInitialData(); + }); + + test('OC - Manual Selection - Logout & Login', async ({ api }) => { + expect(await poOmnichannel.page.locator('#omnichannel-status-toggle').getAttribute('title')).toEqual('Turn off answer chats'); + + const { + data: { room }, + } = await createConversation(api); + + await test.step('expect login and see the chat in queue after login', async () => { + await poOmnichannel.sidebar.getSidebarItemByName(room.fname).click(); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + }); + + await test.step('expect take chat to be visible and return to queue not visible', async () => { + await expect(poOmnichannel.content.btnTakeChat).toBeVisible(); + await expect(poOmnichannel.content.btnReturnToQueue).not.toBeVisible(); + }); + + await test.step('expect to be able take chat', async () => { + await poOmnichannel.content.btnTakeChat.click(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText('joined the channel'); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + await expect(poOmnichannel.content.btnTakeChat).not.toBeVisible(); + await expect(poOmnichannel.content.btnReturnToQueue).toBeVisible(); + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection.spec.ts new file mode 100644 index 0000000..4823150 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manual-selection.spec.ts @@ -0,0 +1,105 @@ +import type { Page } from '@playwright/test'; + +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user3.state }); + +test.describe('OC - Manual Selection', () => { + let poOmnichannel: HomeOmnichannel; + let agents: Awaited>[]; + let agentB: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + // Change routing method to manual selection + test.beforeAll(async ({ api }) => { + const res = await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }); + expect(res.status()).toBe(200); + }); + + // Create agent and make it available + test.beforeAll(async ({ api }) => { + agents = await Promise.all([await createAgent(api, 'user3'), await createAgent(api, 'user1')]); + (await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id)))).forEach((res) => { + expect(res.status()).toBe(200); + }); + }); + + // Create page object and redirect to home + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannel = new HomeOmnichannel(page); + await poOmnichannel.goto(); + }); + + // Create agent b session + test.beforeEach(async ({ browser }) => { + agentB = await createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeOmnichannel: new HomeOmnichannel(page) })); + }); + + test.afterEach(async () => { + await agentB.page.close(); + }); + // Delete all data + test.afterAll(async ({ api }) => { + await Promise.all([ + ...agents.map((agent) => agent.delete()), + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + ]); + }); + + test('OC - Manual Selection - Queue', async ({ page, api }) => { + const { + data: { room }, + } = await createConversation(api); + + await test.step('expect not be able to see queue when livechat is disabled', async () => { + await poOmnichannel.navbar.switchOmnichannelStatus('offline'); + await agentB.poHomeOmnichannel.navbar.switchOmnichannelStatus('offline'); + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).not.toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(room.fname)).not.toBeVisible(); + await poOmnichannel.navbar.switchOmnichannelStatus('online'); + await agentB.poHomeOmnichannel.navbar.switchOmnichannelStatus('online'); + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + }); + + await test.step('expect to be able join chat in read mode', async () => { + await poOmnichannel.sidebar.getSidebarItemByName(room.fname).click(); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + await expect(poOmnichannel.content.btnTakeChat).toBeVisible(); + }); + + await test.step('expect to be able take chat', async () => { + await poOmnichannel.content.btnTakeChat.click(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText('joined the channel'); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + await expect(poOmnichannel.content.btnTakeChat).not.toBeVisible(); + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + }); + + await test.step('expect chat to leave the queue', async () => { + await page.waitForTimeout(250); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(room.fname)).not.toBeVisible(); + }); + + await test.step('expect to be able return to queue', async () => { + await poOmnichannel.content.btnReturnToQueue.click(); + await poOmnichannel.content.returnToQueueModal.confirm(); + await expect(page).toHaveURL('/home'); + }); + + await test.step('expect chat to be back in queue', async () => { + // the returning agent can transiently see the room twice (queued inquiry + not-yet-removed subscription) + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).toHaveCount(1); + await expect(poOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + await expect(agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(room.fname)).toBeVisible(); + + await poOmnichannel.sidebar.getSidebarItemByName(room.fname).click(); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + await expect(poOmnichannel.content.btnTakeChat).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts new file mode 100644 index 0000000..4f468f5 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts @@ -0,0 +1,184 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelDepartments } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createMonitor } from '../utils/omnichannel/monitors'; +import { createOrUpdateUnit } from '../utils/omnichannel/units'; +import { test, expect } from '../utils/test'; + +const MONITOR = 'user3'; + +test.use({ storageState: Users.user3.state }); + +test.describe.serial('OC - Monitor Role', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let departments: Awaited>[]; + let agents: Awaited>[]; + let monitor: Awaited>; + let units: Awaited>[]; + let poOmnichannelDepartments: OmnichannelDepartments; + const newDepartmentName = faker.string.uuid(); + + // Reset user3 roles + test.beforeAll(async ({ api }) => { + const res = await api.post('/users.update', { data: { roles: ['user'] }, userId: MONITOR }); + await expect(res.status()).toBe(200); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + }); + + // Create department + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Create monitor + test.beforeAll(async ({ api }) => { + monitor = await createMonitor(api, MONITOR); + }); + + // Create unit + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map((dep) => dep.data); + + units = await Promise.all([ + await createOrUpdateUnit(api, { + monitors: [{ monitorId: 'user2', username: 'user2' }], + departments: [{ departmentId: departmentB._id }], + }), + await createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }], + }), + await createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }], + }), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + // Delete all created data + test.afterAll(async ({ api }) => { + await Promise.all([ + ...agents.map((agent) => agent.delete()), + ...departments.map((department) => department.delete()), + ...units.map((unit) => unit.delete()), + monitor.delete(), + ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelDepartments = new OmnichannelDepartments(page); + + await poOmnichannelDepartments.goto(); + }); + + test('OC - Monitor Role - Create department with business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + const [unitA, unitB, unitC] = units.map((unit) => unit.data); + + await test.step('expect to see only departmentA in the list', async () => { + await expect(poOmnichannelDepartments.table.findRowByName(departmentA.name)).toBeVisible(); + }); + + await test.step('expect to fill departments mandatory field', async () => { + await poOmnichannelDepartments.createNew(); + await poOmnichannelDepartments.inputName.fill(newDepartmentName); + await poOmnichannelDepartments.inputEmail.fill(faker.internet.email()); + }); + + await test.step('expect to only have the units from monitor visible', async () => { + await expect(poOmnichannelDepartments.inputUnit).not.toBeDisabled(); + await poOmnichannelDepartments.inputUnit.click(); + await expect(poOmnichannelDepartments.findOption(unitA.name)).not.toBeVisible(); + await expect(poOmnichannelDepartments.findOption(unitB.name)).toBeVisible(); + await expect(poOmnichannelDepartments.findOption(unitC.name)).toBeVisible(); + }); + + await test.step('expect to be able to switch freely between available units', async () => { + await poOmnichannelDepartments.findOption(unitC.name).click(); + await expect(poOmnichannelDepartments.inputUnit).not.toBeDisabled(); + await poOmnichannelDepartments.inputUnit.click(); + await poOmnichannelDepartments.findOption(unitB.name).click(); + }); + + await test.step('expect unit field to be required', async () => { + await poOmnichannelDepartments.inputUnit.click(); + await poOmnichannelDepartments.findOption('None').click(); + await poOmnichannelDepartments.btnSave.click(); + await expect(poOmnichannelDepartments.errorMessage('Unit required')).toBeVisible(); + + await poOmnichannelDepartments.inputUnit.click(); + await poOmnichannelDepartments.findOption(unitB.name).click(); + await expect(poOmnichannelDepartments.errorMessage('Unit required')).not.toBeVisible(); + }); + + await test.step('expect to save department', async () => { + await poOmnichannelDepartments.labelEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect to have departmentA and departmentB visible', async () => { + await expect(poOmnichannelDepartments.table.findRowByName(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.table.findRowByName(newDepartmentName)).toBeVisible(); + }); + }); + + test('OC - Monitor Role - Not allow editing department business unit', async () => { + await test.step('expect not to be able to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.getDepartmentMenuByName(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await expect(poOmnichannelDepartments.inputUnit).toBeDisabled(); + }); + }); + + // TODO: We are going to prevent editing busines unit for now + test.skip('OC - Monitor Role - Edit department business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + const [, , unitC] = units.map((unit) => unit.data); + + await test.step('expect to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.getDepartmentMenuByName(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await poOmnichannelDepartments.selectUnit(unitC.name); + await poOmnichannelDepartments.labelEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect departmentB to still be visible', async () => { + await expect(poOmnichannelDepartments.table.findRowByName(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.table.findRowByName(newDepartmentName)).toBeVisible(); + }); + }); + + test.skip('OC - Monitor Role - Edit department and remove business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + + await test.step('expect to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.getDepartmentMenuByName(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await poOmnichannelDepartments.selectUnit('None'); + await poOmnichannelDepartments.labelEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect departmentB to not be visible', async () => { + await expect(poOmnichannelDepartments.table.findRowByName(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.table.findRowByName(newDepartmentName)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts new file mode 100644 index 0000000..a62252c --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts @@ -0,0 +1,273 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createMonitor } from '../utils/omnichannel/monitors'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { createOrUpdateUnit, fetchUnitMonitors } from '../utils/omnichannel/units'; +import { test, expect } from '../utils/test'; + +const MONITOR = 'user3'; +const MONITOR_ADMIN = 'rocketchat.internal.admin.test'; +const ROOM_A = faker.person.fullName(); +const ROOM_B = faker.person.fullName(); +const ROOM_C = faker.person.fullName(); +const ROOM_D = faker.person.fullName(); + +test.use({ storageState: Users.user3.state }); + +test.describe('OC - Monitor Role', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let departments: Awaited>[]; + let conversations: Awaited>[]; + let agents: Awaited>[]; + let monitors: Awaited>[]; + let units: Awaited>[]; + let poOmnichannel: HomeOmnichannel; + + // Reset user3 roles + test.beforeAll(async ({ api }) => { + const res = await api.post('/users.update', { + data: { roles: ['user'] }, + userId: 'user3', + }); + expect(res.status()).toBe(200); + }); + + // Allow manual on hold + test.beforeAll(async ({ api }) => { + const responses = await Promise.all([ + api.post('/settings/Livechat_allow_manual_on_hold', { value: true }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: false }), + setSettingValueById(api, 'Omnichannel_enable_department_removal', true), + // This is required now we're sending a chat into a department with no agents and no default agent + api.post('/settings/Livechat_accept_chats_with_no_agents', { value: true }), + ]); + responses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + + const agentsStatuses = await Promise.all(agents.map(({ data: agent }) => makeAgentAvailable(api, agent._id))); + + agentsStatuses.forEach((res) => expect(res.status()).toBe(200)); + }); + + // Create departments + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api), createDepartment(api)]); + }); + + // Create conversations + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB, departmentC] = departments.map(({ data }) => data); + + conversations = await Promise.all([ + createConversation(api, { + visitorName: ROOM_A, + agentId: `user1`, + departmentId: departmentA._id, + }), + createConversation(api, { + visitorName: ROOM_B, + agentId: `user2`, + departmentId: departmentA._id, + }), + createConversation(api, { + visitorName: ROOM_C, + agentId: `user2`, + departmentId: departmentB._id, + }), + createConversation(api, { + visitorName: ROOM_D, + departmentId: departmentC._id, + }), + ]); + }); + + // Create monitors + test.beforeAll(async ({ api }) => { + monitors = await Promise.all([createMonitor(api, MONITOR), createMonitor(api, MONITOR_ADMIN)]); + }); + + // Create units + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB, departmentC] = departments.map(({ data }) => data); + + units = await Promise.all([ + createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }, { departmentId: departmentB._id }], + }), + createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR_ADMIN, username: MONITOR_ADMIN }], + departments: [{ departmentId: departmentC._id }], + }), + ]); + }); + + // Delete all created data + test.afterAll(async ({ api }) => { + await Promise.all([ + ...agents.map((agent) => agent.delete()), + ...departments.map((department) => department.delete()), + ...conversations.map((conversation) => conversation.delete()), + ...units.map((unit) => unit.delete()), + ...monitors.map((monitor) => monitor.delete()), + // Reset setting + api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), + api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), + setSettingValueById(api, 'Omnichannel_enable_department_removal', false), + api.post('/settings/Livechat_accept_chats_with_no_agents', { value: false }), + ]); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannel = new HomeOmnichannel(page); + await poOmnichannel.chats.goto(); + }); + + test('OC - Monitor Role - Basic permissions', async () => { + await test.step('expect agent to not have access to omnichannel administration', async () => { + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Contact Center')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Analytics')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Real-time Monitoring')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Agents')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Departments')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Business Hours')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Reports')).toBeVisible(); + await expect(poOmnichannel.omnisidenav.getSidebarLinkByName('Canned Responses')).toBeVisible(); + }); + + // await test.step('expect to be able to see contact center', async () => {}); + + // await test.step('expect to be able to see queue', async () => {}); + + // await test.step('expect to be able to edit custom fields', async () => {}); + }); + + test('OC - Monitor Role - Canned responses', async () => { + // TODO: move to unit test + await test.step('expect not to be able to create public canned responses (administration)', async () => { + await poOmnichannel.cannedResponses.goto(); + await poOmnichannel.cannedResponses.btnNew.click(); + await expect(poOmnichannel.cannedResponses.radioPublic).toBeDisabled(); + }); + }); + + test('OC - Monitor Role - Contact Center', async () => { + await test.step('expect to be able to view only chats from same unit', async () => { + await expect(poOmnichannel.chats.table.findRowByName(ROOM_A)).toBeVisible(); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_B)).toBeVisible(); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_C)).toBeVisible(); + await expect(poOmnichannel.chats.table.findRowByName(ROOM_D)).not.toBeVisible(); + }); + + await test.step('expect to be able to join chats from same unit', async () => { + await poOmnichannel.chats.openChat(ROOM_A); + await expect(poOmnichannel.composer.btnJoinRoom).toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + + await poOmnichannel.composer.btnJoinRoom.click(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText('joined the channel'); + await expect(poOmnichannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + }); + + await test.step('expect to be able to put a conversation from another agent on hold', async () => { + await poOmnichannel.quickActionsRoomToolbar.placeChatOnHold(); + await expect(poOmnichannel.content.lastSystemMessageBody).toHaveText( + `Chat On Hold: The chat was manually placed On Hold by ${MONITOR}`, + ); + await expect(poOmnichannel.composer.inputMessage).not.toBeVisible(); + await expect(poOmnichannel.content.btnResume).toBeVisible(); + }); + + await test.step('expect to be able resume a conversation from another agent on hold', async () => { + await poOmnichannel.content.btnResume.click(); + await expect(poOmnichannel.content.btnResume).not.toBeVisible(); + await expect(poOmnichannel.composer.inputMessage).toBeVisible(); + await expect(poOmnichannel.quickActionsRoomToolbar.btnOnHold).toBeVisible(); + }); + + await test.step('expect to be able to close a conversation from another agent', async () => { + await poOmnichannel.quickActionsRoomToolbar.closeChat(); + await poOmnichannel.chats.goto(); + }); + + await test.step('expect not to be able to remove closed room', async () => { + await expect(poOmnichannel.chats.table.findRowByName(ROOM_A)).toBeVisible(); + await expect(poOmnichannel.chats.table.btnRemoveByName(ROOM_A)).not.toBeVisible(); + }); + }); + + test('OC - Monitor Role - Permission revoked', async ({ page, api }) => { + const [unitA, unitB] = units; + const [monitor] = monitors; + + const poContactCenterChats = poOmnichannel.chats; + await poContactCenterChats.goto(); + + await test.step('expect not to be able to see chats from removed department', async () => { + await test.step('expect rooms from both departments to be visible', async () => { + await expect(poContactCenterChats.table.findRowByName(ROOM_B)).toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_C)).toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_D)).not.toBeVisible(); + }); + + await test.step('expect to remove departmentB from unit', async () => { + const [departmentA] = departments.map(({ data }) => data); + + await createOrUpdateUnit(api, { + id: unitA.data._id, + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }], + }); + + await page.reload(); + }); + + await test.step('expect to have only room B visible', async () => { + await expect(poContactCenterChats.table.findRowByName(ROOM_B)).toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_C)).not.toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_D)).not.toBeVisible(); + }); + }); + + await test.step('expect not to be able to see conversations once unit is removed', async () => { + const res = await unitA.delete(); + expect(res.status()).toBe(200); + await page.reload(); + await expect(poContactCenterChats.table.findRowByName(ROOM_B)).not.toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_C)).not.toBeVisible(); + await expect(poContactCenterChats.table.findRowByName(ROOM_D)).not.toBeVisible(); + }); + + await test.step('expect to be able to see all conversations once all units are removed', async () => { + const res = await unitB.delete(); + expect(res.status()).toBe(200); + await page.reload(); + await expect(poContactCenterChats.table.findRowByName(ROOM_D)).toBeVisible(); + }); + + await test.step('expect not to be able to see chats once role is removed', async () => { + const res = await monitor.delete(); + expect(res.status()).toBe(200); + await page.reload(); + await expect(page.locator('p >> text="You are not authorized to view this page."')).toBeVisible(); + }); + + await test.step('expect monitor to be automaticaly removed from unit once monitor is removed', async () => { + const { data: monitors } = await fetchUnitMonitors(api, unitA.data._id); + expect(monitors).toHaveLength(0); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitors.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitors.spec.ts new file mode 100644 index 0000000..0cbbde6 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitors.spec.ts @@ -0,0 +1,82 @@ +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelMonitors } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.skip(!IS_EE, 'OC - Manage Monitors > Enterprise Only'); + +test.describe.serial('OC - Manage Monitors', () => { + let poMonitors: OmnichannelMonitors; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/agent', { username: 'user2' }), + api.post('/livechat/users/manager', { username: 'user1' }), + ]); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.delete('/livechat/users/agent/user2'), + api.delete('/livechat/users/manager/user2'), + ]); + }); + + test.beforeEach(async ({ page }) => { + poMonitors = new OmnichannelMonitors(page); + await poMonitors.goto(); + }); + + test('OC - Manager Monitors - Add monitor', async () => { + await test.step('expect to add agent as monitor', async () => { + await expect(poMonitors.table.findRowByName('user1')).not.toBeVisible(); + await poMonitors.addMonitor('user1'); + await expect(poMonitors.table.findRowByName('user1')).toBeVisible(); + }); + + await test.step('expect to remove agent from monitor', async () => { + await poMonitors.removeMonitor('user1'); + await expect(poMonitors.table.findRowByName('user1')).not.toBeVisible(); + }); + }); + + test('OC - Manager Monitors - Search', async () => { + await test.step('expect to add 2 monitors', async () => { + await poMonitors.addMonitor('user1'); + await expect(poMonitors.table.findRowByName('user1')).toBeVisible(); + + await poMonitors.addMonitor('user2'); + await expect(poMonitors.table.findRowByName('user2')).toBeVisible(); + }); + + await test.step('expect to search monitor', async () => { + await expect(poMonitors.table.findRowByName('user1')).toBeVisible(); + await expect(poMonitors.table.findRowByName('user2')).toBeVisible(); + + await poMonitors.inputSearch.fill('user1'); + await expect(poMonitors.table.findRowByName('user1')).toBeVisible(); + await expect(poMonitors.table.findRowByName('user2')).not.toBeVisible(); + + await poMonitors.inputSearch.fill('user2'); + await expect(poMonitors.table.findRowByName('user1')).not.toBeVisible(); + await expect(poMonitors.table.findRowByName('user2')).toBeVisible(); + + await poMonitors.inputSearch.fill(''); + await expect(poMonitors.table.findRowByName('user1')).toBeVisible(); + await expect(poMonitors.table.findRowByName('user2')).toBeVisible(); + }); + + await test.step('expect to remove monitors', async () => { + await poMonitors.removeMonitor('user1'); + await expect(poMonitors.table.findRowByName('user1')).not.toBeVisible(); + + await poMonitors.removeMonitor('user2'); + await expect(poMonitors.table.findRowByName('user2')).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts new file mode 100644 index 0000000..f73a716 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts @@ -0,0 +1,103 @@ +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +const visitor = createFakeVisitor(); + +const getPrioritySystemMessage = (username: string, priority: string) => + `Priority changed: ${username} changed the priority to ${priority}`; + +test.skip(!IS_EE, 'Omnichannel Priorities > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('OC - Priorities [Sidebar]', () => { + let poHomeChannel: HomeOmnichannel; + + test.beforeAll(async ({ api }) => { + ( + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }), + ]) + ).every((res) => expect(res.status()).toBe(200)); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async () => { + await poHomeChannel.goto(); + }); + + test.beforeEach(async ({ api }) => { + await createConversation(api, { visitorName: visitor.name }); + }); + + test.afterAll(async ({ api }) => { + ( + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + ]) + ).every((res) => expect(res.status()).toBe(200)); + }); + + test('OC - Priorities [Sidebar] - Update conversation priority', async ({ page }) => { + const systemMessage = poHomeChannel.content.lastSystemMessageBody; + await page.emulateMedia({ reducedMotion: 'reduce' }); + + await test.step('expect to change inquiry priority using sidebar menu', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitor.name).click(); + await expect(poHomeChannel.content.btnTakeChat).toBeVisible(); + + await expect(poHomeChannel.roomInfo.getLabel('Priority')).not.toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Lowest'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Lowest')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getLabel('Priority')).toBeVisible(); + await expect(poHomeChannel.sidebar.getBadgeIndicator(visitor.name, 'Lowest')).toBeVisible(); + await expect(poHomeChannel.roomInfo.getInfo('Lowest')).toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Highest'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Highest')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getInfo('Highest')).toBeVisible(); + await expect(poHomeChannel.sidebar.getBadgeIndicator(visitor.name, 'Highest')).toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Unprioritized'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Unprioritized')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getLabel('Priority')).not.toBeVisible(); + await expect(poHomeChannel.sidebar.getBadgeIndicator(visitor.name, 'Unprioritized')).not.toBeVisible(); + }); + + await test.step('expect to change subscription priority using sidebar menu', async () => { + await poHomeChannel.content.btnTakeChat.click(); + await systemMessage.locator('text="joined the channel"').waitFor(); + await page.waitForTimeout(500); + + await expect(poHomeChannel.roomInfo.getLabel('Priority')).not.toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Lowest'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Lowest')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getLabel('Priority')).toBeVisible(); + await expect(poHomeChannel.roomInfo.getInfo('Lowest')).toBeVisible(); + await expect(poHomeChannel.sidebar.getBadgeIndicator(visitor.name, 'Lowest')).toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Highest'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Highest')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getInfo('Highest')).toBeVisible(); + await expect(poHomeChannel.sidebar.getBadgeIndicator(visitor.name, 'Highest')).toBeVisible(); + + await poHomeChannel.sidebar.selectPriority(visitor.name, 'Unprioritized'); + await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Unprioritized')}"`).waitFor(); + await expect(poHomeChannel.roomInfo.getLabel('Priority')).not.toBeVisible(); + await expect(poHomeChannel.roomInfo.getInfo('Unprioritized')).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts new file mode 100644 index 0000000..7a3f3fb --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities.spec.ts @@ -0,0 +1,127 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelPriorities } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const PRIORITY_NAME = faker.person.firstName(); + +const ERROR = { + fieldNameRequired: 'Name required', +}; + +test.skip(!IS_EE, 'Omnichannel Priorities > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('Omnichannel Priorities', () => { + let poOmnichannelPriorities: OmnichannelPriorities; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + ]); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelPriorities = new OmnichannelPriorities(page); + await poOmnichannelPriorities.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([api.delete('/livechat/users/agent/user1'), api.delete('/livechat/users/manager/user1')]); + }); + + test('Manage Priorities', async () => { + await test.step('All default priorities should be visible', async () => { + await Promise.all([ + expect(poOmnichannelPriorities.findPriority('Highest')).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('High')).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Medium')).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Low')).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Lowest')).toBeVisible(), + ]); + }); + + await test.step('Add new priority', async () => { + await expect(poOmnichannelPriorities.btnReset).toBeDisabled(); + await poOmnichannelPriorities.findPriority('Highest').click(); + + await test.step('default state', async () => { + await Promise.all([ + expect(poOmnichannelPriorities.editPriority.btnSave).not.toBeDisabled(), + expect(poOmnichannelPriorities.editPriority.inputName).toHaveValue('Highest'), + ]); + }); + + await test.step('field name is required', async () => { + await poOmnichannelPriorities.editPriority.inputName.fill('any_text'); + await poOmnichannelPriorities.editPriority.inputName.clear(); + await poOmnichannelPriorities.editPriority.btnSave.click(); + await expect(poOmnichannelPriorities.editPriority.errorMessage(ERROR.fieldNameRequired)).toBeVisible(); + }); + + await test.step('should trim field name and show error if only spaces', async () => { + await poOmnichannelPriorities.editPriority.inputName.fill(' '); + await poOmnichannelPriorities.editPriority.btnSave.click(); + await expect(poOmnichannelPriorities.editPriority.errorMessage(ERROR.fieldNameRequired)).toBeVisible(); + }); + + await test.step('edit and save priority', async () => { + await poOmnichannelPriorities.editPriority.inputName.fill(PRIORITY_NAME); + await expect(poOmnichannelPriorities.editPriority.errorMessage(ERROR.fieldNameRequired)).not.toBeVisible(); + await poOmnichannelPriorities.editPriority.save(); + + await Promise.all([ + expect(poOmnichannelPriorities.findPriority(PRIORITY_NAME)).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Highest')).not.toBeVisible(), + ]); + }); + }); + + await test.step('Reset priority', async () => { + await test.step('reset individual', async () => { + await poOmnichannelPriorities.findPriority(PRIORITY_NAME).click(); + await expect(poOmnichannelPriorities.editPriority.btnReset).toBeVisible(); + await poOmnichannelPriorities.editPriority.btnReset.click(); + await Promise.all([ + expect(poOmnichannelPriorities.editPriority.inputName).toHaveValue('Highest'), + expect(poOmnichannelPriorities.editPriority.btnReset).toBeDisabled(), + ]); + + await expect(poOmnichannelPriorities.editPriority.btnSave).toBeEnabled(); + await poOmnichannelPriorities.editPriority.save(); + await expect(poOmnichannelPriorities.findPriority('Highest')).toBeVisible(); + await expect(poOmnichannelPriorities.btnReset).toBeDisabled(); + }); + + await test.step('reset all', async () => { + await poOmnichannelPriorities.findPriority('Highest').click(); + + await poOmnichannelPriorities.editPriority.inputName.fill(PRIORITY_NAME); + await Promise.all([ + expect(poOmnichannelPriorities.editPriority.btnReset).toBeVisible(), + expect(poOmnichannelPriorities.editPriority.btnSave).toBeEnabled(), + ]); + await poOmnichannelPriorities.editPriority.save(); + + await Promise.all([ + expect(poOmnichannelPriorities.editPriority.inputName).not.toBeVisible(), + expect(poOmnichannelPriorities.findPriority(PRIORITY_NAME)).toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Highest')).not.toBeVisible(), + ]); + + await expect(poOmnichannelPriorities.btnReset).toBeEnabled(); + await poOmnichannelPriorities.resetPriorities(); + + await Promise.all([ + expect(poOmnichannelPriorities.btnReset).toBeDisabled(), + expect(poOmnichannelPriorities.findPriority(PRIORITY_NAME)).not.toBeVisible(), + expect(poOmnichannelPriorities.findPriority('Highest')).toBeVisible(), + ]); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-reports.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-reports.spec.ts new file mode 100644 index 0000000..b36d891 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-reports.spec.ts @@ -0,0 +1,364 @@ +import type { Route } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelReports } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const ENDPOINTS = { + BY_STATUS: /\/v1\/livechat\/analytics\/dashboards\/conversations-by-status/, + BY_SOURCE: /\/v1\/livechat\/analytics\/dashboards\/conversations-by-source/, + BY_DEPARTMENT: /\/v1\/livechat\/analytics\/dashboards\/conversations-by-department/, + BY_TAGS: /\/v1\/livechat\/analytics\/dashboards\/conversations-by-tags/, + BY_AGENT: /\/v1\/livechat\/analytics\/dashboards\/conversations-by-agent/, +}; + +test.skip(!IS_EE, 'Omnichannel Reports > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('Omnichannel Reports', () => { + let poReports: OmnichannelReports; + + test.beforeAll(async ({ api }) => { + const requests = await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + ]); + await expect(requests.every((request) => request.status() === 200)).toBe(true); + }); + + test.beforeEach(async ({ page }) => { + poReports = new OmnichannelReports(page); + }); + + test.beforeEach(async () => { + await poReports.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([api.delete('/livechat/users/agent/user1'), api.delete('/livechat/users/manager/user1')]); + }); + + test('Status Section', async ({ page }) => { + await test.step('Empty state', async () => { + await page.route(ENDPOINTS.BY_STATUS, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + await poReports.statusSection.selectPeriod('this week'); + await expect(poReports.statusSection.txtStateTitle).toHaveText('No data available for the selected period'); + await expect(poReports.statusSection.txtStateSubtitle).toHaveText('This chart will update as soon as conversations start.'); + await expect(poReports.statusSection.txtSummary).toHaveText('0 conversations, this week'); + }); + + await test.step('Error state', async () => { + await page.route(ENDPOINTS.BY_STATUS, async (route: Route) => { + await route.abort(); + }); + + await poReports.statusSection.selectPeriod('this month'); + await expect(poReports.statusSection.element).toHaveAttribute('aria-busy', 'false'); + await expect(poReports.statusSection.btnRetry).toBeVisible(); + await expect(poReports.statusSection.txtStateTitle).toHaveText('Something went wrong'); + + await test.step('Retry', async () => { + await page.route(ENDPOINTS.BY_STATUS, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + const responsePromise = page.waitForResponse(ENDPOINTS.BY_STATUS); + await poReports.statusSection.btnRetry.click(); + await responsePromise; + }); + }); + + await test.step('Render data', async () => { + const mock = { + data: [ + { label: 'Open', value: 25 }, + { label: 'Queued', value: 25 }, + { label: 'Closed', value: 25 }, + { label: 'On_Hold', value: 25 }, + ], + total: 100, + }; + + await page.route(ENDPOINTS.BY_STATUS, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.statusSection.selectPeriod('this year'); + await expect(poReports.statusSection.legendItem(`Closed 25 (25%)`)).toBeVisible(); + await expect(poReports.statusSection.legendItem(`Open 25 (25%)`)).toBeVisible(); + await expect(poReports.statusSection.legendItem(`Queued 25 (25%)`)).toBeVisible(); + await expect(poReports.statusSection.legendItem(`On hold 25 (25%)`)).toBeVisible(); + await expect(poReports.statusSection.txtSummary).toHaveText('100 conversations, this year'); + }); + }); + + test('Channels Section', async ({ page }) => { + await test.step('Empty state', async () => { + await page.route(ENDPOINTS.BY_SOURCE, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + await poReports.channelsSection.selectPeriod('this week'); + await expect(poReports.channelsSection.txtStateTitle).toHaveText('No data available for the selected period'); + await expect(poReports.channelsSection.txtStateSubtitle).toHaveText('This chart shows the most used channels.'); + await expect(poReports.channelsSection.txtSummary).toHaveText('0 conversations, this week'); + }); + + await test.step('Error state', async () => { + await page.route(ENDPOINTS.BY_SOURCE, async (route: Route) => { + await route.abort(); + }); + + await poReports.channelsSection.selectPeriod('last 15 days'); + await expect(poReports.channelsSection.element).toHaveAttribute('aria-busy', 'false'); + await expect(poReports.channelsSection.btnRetry).toBeVisible(); + await expect(poReports.channelsSection.txtStateTitle).toHaveText('Something went wrong'); + + await test.step('Retry', async () => { + await page.route(ENDPOINTS.BY_SOURCE, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + const responsePromise = page.waitForResponse(ENDPOINTS.BY_SOURCE); + await poReports.channelsSection.btnRetry.click(); + await responsePromise; + }); + }); + + await test.step('Render data', async () => { + const mock = { + data: [ + { label: 'Channel 1', value: 50 }, + { label: 'Channel 2', value: 50 }, + ], + total: 100, + }; + await page.route(ENDPOINTS.BY_SOURCE, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.channelsSection.selectPeriod('this year'); + await expect(poReports.channelsSection.legendItem(`Channel 1 50 (50%)`)).toBeVisible(); + await expect(poReports.channelsSection.legendItem(`Channel 2 50 (50%)`)).toBeVisible(); + await expect(poReports.channelsSection.txtSummary).toHaveText('100 conversations, this year'); + }); + + await test.step('More than 5 channels', async () => { + const mock = { + data: [ + { label: 'Channel 1', value: 15 }, + { label: 'Channel 2', value: 15 }, + { label: 'Channel 3', value: 15 }, + { label: 'Channel 4', value: 15 }, + { label: 'Channel 5', value: 15 }, + { label: 'Channel 6', value: 15 }, + { label: 'Channel 7', value: 15 }, + { label: 'Channel 8', value: 15 }, + ], + total: 120, + }; + await page.route(ENDPOINTS.BY_SOURCE, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.channelsSection.selectPeriod('last 6 months'); + await expect(poReports.channelsSection.legendItem(`Others 45 (37.5%)`)).toBeVisible(); + await expect(poReports.channelsSection.txtSummary).toHaveText('120 conversations, last 6 months'); + }); + }); + + test('Departments Section', async ({ page }) => { + await test.step('Empty state', async () => { + await page.route(ENDPOINTS.BY_DEPARTMENT, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + await poReports.departmentsSection.selectPeriod('this week'); + await expect(poReports.departmentsSection.txtStateTitle).toHaveText('No data available for the selected period'); + await expect(poReports.departmentsSection.txtStateSubtitle).toHaveText( + 'This chart displays the departments that receive the most conversations.', + ); + await expect(poReports.departmentsSection.txtSummary).toHaveText('0 departments and 0 conversations, this week'); + }); + + await test.step('Error state', async () => { + await page.route(ENDPOINTS.BY_DEPARTMENT, async (route: Route) => { + await route.abort(); + }); + + await poReports.departmentsSection.selectPeriod('this month'); + await expect(poReports.departmentsSection.element).toHaveAttribute('aria-busy', 'false'); + await expect(poReports.departmentsSection.btnRetry).toBeVisible(); + await expect(poReports.departmentsSection.txtStateTitle).toHaveText('Something went wrong'); + + await test.step('Retry', async () => { + await page.route(ENDPOINTS.BY_DEPARTMENT, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + const responsePromise = page.waitForResponse(ENDPOINTS.BY_DEPARTMENT); + await poReports.departmentsSection.btnRetry.click(); + await responsePromise; + }); + }); + + await test.step('Render data', async () => { + const mock = { + data: [ + { label: 'Department 1', value: 10 }, + { label: 'Department 2', value: 20 }, + { label: 'Department 3', value: 30 }, + { label: 'Department 4', value: 40 }, + ], + total: 100, + unspecified: 42, + }; + await page.route(ENDPOINTS.BY_DEPARTMENT, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.departmentsSection.selectPeriod('last 6 months'); + await expect(poReports.departmentsSection.chartItem('Department 1', 10)).toBeVisible(); + await expect(poReports.departmentsSection.chartItem('Department 2', 20)).toBeVisible(); + await expect(poReports.departmentsSection.chartItem('Department 3', 30)).toBeVisible(); + await expect(poReports.departmentsSection.chartItem('Department 4', 40)).toBeVisible(); + await expect(poReports.departmentsSection.txtSummary).toHaveText( + '4 departments and 100 conversations, last 6 months (42 without department)', + ); + }); + }); + + test('Tags Section', async ({ page }) => { + await test.step('Empty state', async () => { + await page.route(ENDPOINTS.BY_TAGS, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + await poReports.tagsSection.selectPeriod('this week'); + await expect(poReports.tagsSection.txtStateTitle).toHaveText('No data available for the selected period'); + await expect(poReports.tagsSection.txtStateSubtitle).toHaveText('This chart shows the most frequently used tags.'); + await expect(poReports.tagsSection.txtSummary).toHaveText('0 tags and 0 conversations, this week'); + }); + + await test.step('Error state', async () => { + await page.route(ENDPOINTS.BY_TAGS, async (route: Route) => { + await route.abort(); + }); + + await poReports.tagsSection.selectPeriod('this month'); + await expect(poReports.tagsSection.element).toHaveAttribute('aria-busy', 'false'); + await expect(poReports.tagsSection.btnRetry).toBeVisible(); + await expect(poReports.tagsSection.txtStateTitle).toHaveText('Something went wrong'); + + await test.step('Retry', async () => { + await page.route(ENDPOINTS.BY_TAGS, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + const responsePromise = page.waitForResponse(ENDPOINTS.BY_TAGS); + await poReports.tagsSection.btnRetry.click(); + await responsePromise; + }); + }); + + await test.step('Render data', async () => { + const mock = { + data: [ + { label: 'Tag 1', value: 10 }, + { label: 'Tag 2', value: 20 }, + { label: 'Tag 3', value: 30 }, + { label: 'Tag 4', value: 40 }, + ], + total: 100, + unspecified: 42, + }; + await page.route(ENDPOINTS.BY_TAGS, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.tagsSection.selectPeriod('last 6 months'); + await expect(poReports.tagsSection.chartItem('Tag 1', 10)).toBeVisible(); + await expect(poReports.tagsSection.chartItem('Tag 2', 20)).toBeVisible(); + await expect(poReports.tagsSection.chartItem('Tag 3', 30)).toBeVisible(); + await expect(poReports.tagsSection.chartItem('Tag 4', 40)).toBeVisible(); + await expect(poReports.tagsSection.txtSummary).toHaveText('4 tags and 100 conversations, last 6 months (42 without tags)'); + }); + }); + + test('Agents Section', async ({ page }) => { + await test.step('Empty state', async () => { + await page.route(ENDPOINTS.BY_AGENT, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + await poReports.agentsSection.selectPeriod('this week'); + await expect(poReports.agentsSection.txtStateTitle).toHaveText('No data available for the selected period'); + await expect(poReports.agentsSection.txtStateSubtitle).toHaveText( + 'This chart displays which agents receive the highest volume of conversations.', + ); + await expect(poReports.agentsSection.txtSummary).toHaveText('0 agents and 0 conversations, this week'); + }); + + await test.step('Error state', async () => { + await page.route(ENDPOINTS.BY_AGENT, async (route: Route) => { + await route.abort(); + }); + + await poReports.agentsSection.selectPeriod('this month'); + await expect(poReports.agentsSection.element).toHaveAttribute('aria-busy', 'false'); + await expect(poReports.agentsSection.btnRetry).toBeVisible(); + await expect(poReports.agentsSection.txtStateTitle).toHaveText('Something went wrong'); + + await test.step('Retry', async () => { + await page.route(ENDPOINTS.BY_AGENT, async (route: Route) => { + await route.fulfill({ json: { data: [], total: 0 } }); + }); + + const responsePromise = page.waitForResponse(ENDPOINTS.BY_AGENT); + await poReports.agentsSection.btnRetry.click(); + await responsePromise; + }); + }); + + await test.step('Render data', async () => { + const mock = { + data: [ + { label: 'Agent 1', value: 10 }, + { label: 'Agent 2', value: 20 }, + { label: 'Agent 3', value: 30 }, + { label: 'Agent 4', value: 40 }, + ], + total: 100, + unspecified: 42, + }; + await page.route(ENDPOINTS.BY_AGENT, async (route: Route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: mock }); + }); + + await poReports.agentsSection.selectPeriod('last 6 months'); + + await expect(poReports.agentsSection.txtSummary).toHaveText('4 agents and 100 conversations, last 6 months (42 without assignee)'); + + await expect(poReports.agentsSection.chartItem('Agent 1', 10)).toBeVisible(); + await expect(poReports.agentsSection.chartItem('Agent 2', 20)).toBeVisible(); + await expect(poReports.agentsSection.chartItem('Agent 3', 30)).toBeVisible(); + await expect(poReports.agentsSection.chartItem('Agent 4', 40)).toBeVisible(); + + await expect(poReports.agentsSection.findRowByName('Agent 1')).toBeVisible(); + await expect(poReports.agentsSection.findRowByName('Agent 2')).toBeVisible(); + await expect(poReports.agentsSection.findRowByName('Agent 3')).toBeVisible(); + await expect(poReports.agentsSection.findRowByName('Agent 4')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts new file mode 100644 index 0000000..c9345f2 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -0,0 +1,229 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Forwarding to away departments (EE)', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannelOnlineAgent: HomeOmnichannel; + let poHomeOmnichannelAwayAgent: HomeOmnichannel; + + let omnichannelPage: Page | undefined; + + let manager: Awaited>; + let onlineAgent: Awaited>; + let awayAgent: Awaited>; + let initialDepartment: Awaited>; + let forwardToOfflineDepartment: Awaited>; + let conversation: Awaited>; + let visitor: { name: string; email: string }; + + test.beforeAll(async ({ api }) => { + [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ + createManager(api, 'user1'), + createAgent(api, 'user1'), + createAgent(api, 'user2'), + createDepartment(api, { name: 'Initial Dept' }), + createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), + ]); + await expect(manager.response).toBeOK(); + await expect(onlineAgent.response).toBeOK(); + await expect(awayAgent.response).toBeOK(); + await expect(initialDepartment.response).toBeOK(); + await expect(forwardToOfflineDepartment.response).toBeOK(); + + const responses = await Promise.all([ + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + setSettingValueById(api, 'Omnichannel_enable_department_removal', true), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + addAgentToDepartment(api, { department: initialDepartment.data, agentId: 'user1' }), + addAgentToDepartment(api, { department: forwardToOfflineDepartment.data, agentId: 'user2' }), + ]); + + for (const response of responses) { + await expect(response).toBeOK(); + } + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + // Online Agent window opens with idleTimeLimit of 300 by default, therefore it will remain online + poHomeOmnichannelOnlineAgent = new HomeOmnichannel(page); + await poHomeOmnichannelOnlineAgent.goto(); + + await expect(await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1)).toBeOK(); + + // Away Agent window opens with idleTimeLimit of 1, therefore after a second it will turn away + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await poHomeOmnichannelAwayAgent.waitForHome(); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + test.afterEach(async ({ api }) => { + if (omnichannelPage) { + await omnichannelPage.context().close(); + } + + await expect(await setSettingValueById(api, 'Livechat_waiting_queue', false)).toBeOK(); + await expect(await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300)).toBeOK(); + }); + + test.afterAll(async ({ api }) => { + const responses = await Promise.all([ + initialDepartment.delete(), + forwardToOfflineDepartment.delete(), + manager.delete(), + onlineAgent.delete(), + awayAgent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true), + setSettingValueById(api, 'Omnichannel_enable_department_removal', false), + ]); + for (const response of responses) { + await expect(response).toBeOK(); + } + }); + + test('when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', async ({ + api, + }) => { + await test.step('Setup routing settings', async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', 'Manual_Selection')).toBeOK(); + }); + + await test.step('Visitor initiates chat', async () => { + conversation = await createConversation(api, { visitorName: visitor.name, agentId: onlineAgent.data._id }); + }); + + await test.step('Manager forwards chat', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${conversation.data.room._id}`); + await expect(inquiryResp).toBeOK(); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', async ({ + api, + }) => { + await test.step('Setup routing settings', async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection')).toBeOK(); + }); + + await test.step('Visitor initiates chat', async () => { + conversation = await createConversation(api, { visitorName: visitor.name, agentId: onlineAgent.data._id }); + }); + + await test.step('Manager enables queue and forwards chat', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await expect(await setSettingValueById(api, 'Livechat_waiting_queue', true)).toBeOK(); + + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${conversation.data.room._id}`); + await expect(inquiryResp).toBeOK(); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ + api, + }) => { + await test.step('Setup routing and department settings', async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection')).toBeOK(); + await expect( + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: false }, + }), + ).toBeOK(); + }); + + await test.step('Visitor initiates chat', async () => { + conversation = await createConversation(api, { visitorName: visitor.name, agentId: onlineAgent.data._id }); + }); + + await test.step('Manager attempts to forward and sees error', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await expect(await setSettingValueById(api, 'Livechat_waiting_queue', true)).toBeOK(); + + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.page.locator('role=alert')).toContainText( + 'No agents are available for service on this department.', + ); + }); + + await test.step('Restore department setting', async () => { + await expect( + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, + }), + ).toBeOK(); + }); + }); + + test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ + api, + }) => { + await test.step('Setup routing settings', async () => { + await expect(await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection')).toBeOK(); + }); + + await test.step('Visitor initiates chat', async () => { + conversation = await createConversation(api, { visitorName: visitor.name, agentId: onlineAgent.data._id }); + }); + + await test.step('Set Livechat_enabled_when_agent_idle to true', async () => { + // We set Livechat_enabled_when_agent_idle to true here in order to not assign the away agent in the previous step + await expect(await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true)).toBeOK(); + }); + + await test.step('Manager forwards chat successfully', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check room routing via API serves to away agent', async () => { + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${conversation.data.room._id}`); + await expect(inquiryResp).toBeOK(); + const inquiryBody = await inquiryResp.json(); + expect(inquiryBody.inquiry.status).toBe('taken'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts new file mode 100644 index 0000000..7c9c68e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-pdf-transcript.spec.ts @@ -0,0 +1,84 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.skip(!IS_EE, 'Export transcript as PDF > Enterprie Only'); + +test.describe('omnichannel- export chat transcript as PDF', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + }); + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + }); + + test('Export PDF transcript', async ({ page }) => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await new Promise((resolve) => setTimeout(resolve, 5000)); + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('Expect to be not able send transcript as PDF', async () => { + await agent.poHomeChannel.content.btnSendTranscript.click(); + await agent.poHomeChannel.content.btnSendTranscriptAsPDF.hover(); + await expect(agent.poHomeChannel.content.btnSendTranscriptAsPDF).toHaveAttribute('aria-disabled', 'true'); + }); + + await test.step('Expect chat to be closed', async () => { + await agent.poHomeChannel.quickActionsRoomToolbar.closeChat({ downloadPDF: true }); + }); + + // Exported PDF can be downloaded from rocket.cat room + await test.step('Expect to have exported PDF in rocket.cat', async () => { + await page.waitForTimeout(3000); + await agent.poHomeChannel.navbar.openChat('rocket.cat'); + await expect(agent.poHomeChannel.content.lastUserMessage.getByText('PDF Transcript successfully generated')).toBeVisible(); + await expect(agent.poHomeChannel.content.lastUserMessage.getByRole('link', { name: 'Transcript' })).toBeVisible(); + }); + + // PDF can be exported from Omnichannel Contact Center + await test.step('Expect to have exported PDF in rocket.cat', async () => { + await agent.poHomeChannel.navbar.btnContactCenter.click(); + await agent.poHomeChannel.transcript.contactCenterChats.click(); + await agent.poHomeChannel.transcript.contactCenterSearch.type(newVisitor.name); + await page.waitForTimeout(3000); + await agent.poHomeChannel.transcript.firstRow.click(); + await agent.poHomeChannel.transcript.btnOpenChat.click(); + await agent.poHomeChannel.content.btnSendTranscript.click(); + await expect(agent.poHomeChannel.content.btnSendTranscriptAsPDF).toHaveAttribute('aria-disabled', 'false'); + await agent.poHomeChannel.content.btnSendTranscriptAsPDF.click(); + await agent.poHomeChannel.toastMessage.waitForDisplay(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-send-transcript.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-transcript.spec.ts new file mode 100644 index 0000000..7bc128b --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-send-transcript.spec.ts @@ -0,0 +1,63 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('omnichannel-transcript', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeChannel }; + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + // Set user user 1 as manager and agent + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeChannel(page) }; + }); + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + await agent.page.close(); + }); + + test('Receiving a message from visitor', async () => { + await test.step('Expect send a message as a visitor', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => { + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + }); + + await test.step('Expect to be able to send transcript to email', async () => { + await agent.poHomeChannel.content.btnSendTranscript.click(); + await agent.poHomeChannel.content.btnSendTranscriptToEmail.click(); + await agent.poHomeChannel.content.btnModalConfirm.click(); + await agent.poHomeChannel.toastMessage.waitForDisplay(); + }); + + await test.step('Expect to be not able send transcript as PDF', async () => { + test.skip(!IS_EE, 'Enterprise Only'); + await agent.poHomeChannel.content.btnSendTranscript.click(); + await agent.poHomeChannel.content.btnSendTranscriptAsPDF.hover(); + await expect(agent.poHomeChannel.content.btnSendTranscriptAsPDF).toHaveAttribute('aria-disabled', 'true'); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies-sidebar.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies-sidebar.spec.ts new file mode 100644 index 0000000..06642f3 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies-sidebar.spec.ts @@ -0,0 +1,148 @@ +import { + OmnichannelSortingMechanismSettingType, + type IOmnichannelServiceLevelAgreements, + type Serialized, +} from '@rocket.chat/core-typings'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { createSLA } from '../utils/omnichannel/sla'; +import { test, expect } from '../utils/test'; + +const visitorA = createFakeVisitor(); +const visitorB = createFakeVisitor(); +const visitorC = createFakeVisitor(); + +test.skip(!IS_EE, 'Omnichannel SLAs > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - SLA Policies [Sidebar]', () => { + let poHomeChannel: HomeOmnichannel; + let conversations: Awaited>[] = []; + let slas: Serialized>[] = []; + + test.beforeAll('create SLAs', async ({ api }) => { + slas = await Promise.all([ + createSLA(api, { name: 'Very Urgent', dueTimeInMinutes: 1 }), + createSLA(api, { name: 'Urgent', dueTimeInMinutes: 10 }), + createSLA(api, { name: 'Not Urgent', dueTimeInMinutes: 30 }), + ]); + }); + + test.beforeAll(async ({ api }) => { + ( + await Promise.all([ + // Create agent and manager + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + // Settings + api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }), + api.post('/settings/Omnichannel_sorting_mechanism', { value: OmnichannelSortingMechanismSettingType.SLAs }), + ]) + ).every((res) => expect(res.status()).toBe(200)); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeOmnichannel(page); + }); + + test.beforeEach(async () => { + await poHomeChannel.goto(); + }); + + test.beforeEach(async ({ api }) => { + conversations = await Promise.all([ + createConversation(api, { visitorName: visitorA.name, agentId: 'user1' }), + createConversation(api, { visitorName: visitorB.name, agentId: 'user1' }), + createConversation(api, { visitorName: visitorC.name, agentId: 'user1' }), + ]); + }); + + test.afterAll('delete SLAs', async ({ api }) => { + const responses = await Promise.all(slas.map((sla) => api.delete(`/livechat/sla/${sla._id}`))); + responses.every((res) => expect(res.status()).toBe(200)); + }); + + test.afterAll(async ({ api }) => { + // Delete conversations + await Promise.all(conversations.map((conversation) => conversation.delete())); + + ( + await Promise.all([ + // Delete agent and manager + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + // Reset settings + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + api.post('/settings/Omnichannel_sorting_mechanism', { value: OmnichannelSortingMechanismSettingType.Timestamp }), + ]) + ).every((res) => expect(res.status()).toBe(200)); + }); + + test('OC - SLA Policies [Sidebar] - Update conversation SLA Policy', async () => { + await test.step('expect to change room SLA policy to "Not urgent"', async () => { + await test.step('expect to open room and room info to be visible', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitorA.name).click(); + await expect(poHomeChannel.roomInfo.root).toBeVisible(); + }); + + await test.step('expect to update room SLA policy', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).not.toBeVisible(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.selectSLA('Not Urgent'); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('expect SLA to have been updated in the room info and queue order to be correct', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).toHaveText('Not Urgent'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorA.name)).toBeVisible(); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorA.name)).toHaveAttribute('data-index', '1'); + }); + }); + + await test.step('expect to change room SLA policy to "Urgent"', async () => { + await test.step('expect to open room and room info to be visible', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitorB.name).click(); + await expect(poHomeChannel.roomInfo.root).toBeVisible(); + }); + + await test.step('expect to update room SLA policy', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).not.toBeVisible(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.selectSLA('Urgent'); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('expect SLA to have been updated in the room info and queue order to be correct', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).toHaveText('Urgent'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorB.name)).toHaveAttribute('data-index', '1'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorA.name)).toHaveAttribute('data-index', '2'); + }); + }); + + await test.step('expect to change room SLA policy to "Very Urgent"', async () => { + await test.step('expect to open room and room info to be visible', async () => { + await poHomeChannel.sidebar.getSidebarItemByName(visitorC.name).click(); + await expect(poHomeChannel.roomInfo.root).toBeVisible(); + }); + + await test.step('expect to update room SLA policy', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).not.toBeVisible(); + await poHomeChannel.roomInfo.btnEdit.click(); + await poHomeChannel.editRoomInfo.selectSLA('Very Urgent'); + await poHomeChannel.editRoomInfo.btnSave.click(); + }); + + await test.step('expect SLA to have been updated in the room info and queue order to be correct', async () => { + await expect(poHomeChannel.roomInfo.getInfoByLabel('SLA Policy')).toHaveText('Very Urgent'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorC.name)).toHaveAttribute('data-index', '1'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorB.name)).toHaveAttribute('data-index', '2'); + await expect(poHomeChannel.sidebar.getSidebarListItem(visitorA.name)).toHaveAttribute('data-index', '3'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts new file mode 100644 index 0000000..7d6bfaf --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-sla-policies.spec.ts @@ -0,0 +1,135 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelSlaPolicies } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +const ERROR = { + nameRequired: 'Name required', + estimatedWaitTimeRequired: 'Estimated wait time (time in minutes) required', +}; + +const INITIAL_SLA = { + name: faker.person.firstName(), + description: faker.lorem.sentence(), + estimatedWaitTime: faker.string.numeric({ length: 1, exclude: '0' }), +}; + +const EDITED_SLA = { + name: faker.person.firstName(), + description: faker.lorem.sentence(), + estimatedWaitTime: faker.string.numeric({ length: 1, exclude: '0' }), +}; + +test.skip(!IS_EE, 'Omnichannel SLA Policies > Enterprise Only'); + +test.use({ storageState: Users.user1.state }); + +test.describe('Omnichannel SLA Policies', () => { + let poOmnichannelSlaPolicies: OmnichannelSlaPolicies; + + test.beforeAll(async ({ api }) => { + await api.post('/livechat/users/agent', { username: 'user1' }); + await api.post('/livechat/users/manager', { username: 'user1' }); + }); + + test.beforeEach(async ({ page }) => { + poOmnichannelSlaPolicies = new OmnichannelSlaPolicies(page); + await poOmnichannelSlaPolicies.goto(); + }); + + test.afterAll(async ({ api }) => { + await api.delete('/livechat/users/agent/user1'); + await api.delete('/livechat/users/manager/user1'); + }); + + test('Manage SLAs', async () => { + await test.step('Add new SLA', async () => { + await poOmnichannelSlaPolicies.createNew(); + + await test.step('should not submit form with empty required fields: name and estimated wait time', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.btnSave.click(); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.nameRequired)).toBeVisible(); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.estimatedWaitTimeRequired)).toBeVisible(); + }); + + await test.step('input a valid name', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputName.fill(INITIAL_SLA.name); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.nameRequired)).not.toBeVisible(); + }); + + await test.step('input a valid description', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputDescription.fill(INITIAL_SLA.description); + }); + + await test.step('only allow numbers on estimated wait time field', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime.pressSequentially('a'); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime).toHaveValue('0'); + }); + + await test.step('not allow 0 on estimated wait time field', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime.fill('0'); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.estimatedWaitTimeRequired)).toBeVisible(); + }); + + await test.step('input a valid estimated wait time', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime.fill(INITIAL_SLA.estimatedWaitTime); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.estimatedWaitTimeRequired)).not.toBeVisible(); + }); + + await test.step('save sla', async () => { + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.btnSave).toBeEnabled(); + await poOmnichannelSlaPolicies.manageSlaPolicy.btnSave.click(); + + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputName).not.toBeVisible(); + await expect(poOmnichannelSlaPolicies.table.findRowByName(INITIAL_SLA.name)).toBeVisible(); + }); + }); + + await test.step('Search SLA', async () => { + await poOmnichannelSlaPolicies.inputSearch.fill('random_text_that_should_have_no_match'); + await expect(poOmnichannelSlaPolicies.table.findRowByName(INITIAL_SLA.name)).not.toBeVisible(); + await expect(poOmnichannelSlaPolicies.emptyState).toBeVisible(); + await poOmnichannelSlaPolicies.inputSearch.fill(INITIAL_SLA.name); + await expect(poOmnichannelSlaPolicies.table.findRowByName(INITIAL_SLA.name)).toBeVisible(); + await expect(poOmnichannelSlaPolicies.emptyState).not.toBeVisible(); + await poOmnichannelSlaPolicies.inputSearch.fill(''); + }); + + await test.step('Edit SLA', async () => { + await poOmnichannelSlaPolicies.table.findRowByName(INITIAL_SLA.name).click(); + + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputName).toHaveValue(INITIAL_SLA.name); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputDescription).toHaveValue(INITIAL_SLA.description); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime).toHaveValue(INITIAL_SLA.estimatedWaitTime); + + await test.step('edit name', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputName.fill(EDITED_SLA.name); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.nameRequired)).not.toBeVisible(); + }); + + await test.step('edit description', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputDescription.fill(EDITED_SLA.description); + }); + + await test.step('edit estimated wait time', async () => { + await poOmnichannelSlaPolicies.manageSlaPolicy.inputEstimatedWaitTime.fill(EDITED_SLA.estimatedWaitTime); + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.errorMessage(ERROR.estimatedWaitTimeRequired)).not.toBeVisible(); + }); + + await test.step('save sla', async () => { + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.btnSave).toBeEnabled(); + await poOmnichannelSlaPolicies.manageSlaPolicy.btnSave.click(); + + await expect(poOmnichannelSlaPolicies.manageSlaPolicy.inputName).not.toBeVisible(); + await expect(poOmnichannelSlaPolicies.table.findRowByName(EDITED_SLA.name)).toBeVisible(); + }); + }); + + await test.step('Remove SLA', async () => { + await poOmnichannelSlaPolicies.removeSLA(EDITED_SLA.name); + await expect(poOmnichannelSlaPolicies.table.findRowByName(EDITED_SLA.name)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts new file mode 100644 index 0000000..1c271af --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelTags } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createTag } from '../utils/omnichannel/tags'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Manage Tags', () => { + test.skip(!IS_EE, 'OC - Manage Tags > Enterprise Edition Only'); + + let poOmnichannelTags: OmnichannelTags; + + let department: Awaited>; + let department2: Awaited>; + let agent: Awaited>; + + test.beforeAll(async ({ api }) => { + department = await createDepartment(api); + department2 = await createDepartment(api); + }); + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user2'); + }); + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.afterAll(async ({ api }) => { + await department.delete(); + await department2.delete(); + await agent.delete(); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelTags = new OmnichannelTags(page); + }); + + test('OC - Manage Tags - Create Tag', async () => { + const tagName = faker.string.uuid(); + + await poOmnichannelTags.goto(); + + await test.step('expect correct form default state', async () => { + await poOmnichannelTags.createNew(); + await expect(poOmnichannelTags.editTag.root).toBeVisible(); + await expect(poOmnichannelTags.editTag.btnSave).toBeEnabled(); + await expect(poOmnichannelTags.editTag.btnCancel).toBeEnabled(); + await poOmnichannelTags.editTag.btnCancel.click(); + await expect(poOmnichannelTags.editTag.root).not.toBeVisible(); + }); + + await test.step('expect to create new tag', async () => { + await poOmnichannelTags.createNew(); + await poOmnichannelTags.editTag.inputName.fill(tagName); + await poOmnichannelTags.editTag.selectDepartment(department.data.name); + await poOmnichannelTags.editTag.btnSave.click(); + await expect(poOmnichannelTags.editTag.root).not.toBeVisible(); + + await test.step('expect tag to have been created', async () => { + await poOmnichannelTags.search(tagName); + await expect(poOmnichannelTags.table.findRowByName(tagName)).toBeVisible(); + }); + }); + + await test.step('should be able to delete tag', async () => { + await poOmnichannelTags.deleteTag(tagName); + await poOmnichannelTags.waitForEmptyState(); + }); + }); + + test('OC - Manage Tags - Edit tag departments', async ({ api, page }) => { + const tag = await test.step('expect to create new tag', async () => { + const { data: tag } = await createTag(api, { + name: faker.string.uuid(), + departments: [department.data._id], + }); + + return tag; + }); + + await poOmnichannelTags.goto(); + + await test.step('expect to add tag departments', async () => { + await poOmnichannelTags.search(tag.name); + await poOmnichannelTags.table.findRowByName(tag.name).click(); + await expect(poOmnichannelTags.editTag.root).toBeVisible(); + await poOmnichannelTags.editTag.selectDepartment(department2.data.name); + await expect(poOmnichannelTags.editTag.findSelectedDepartment(department2.data.name)).toBeVisible(); + await poOmnichannelTags.editTag.save(); + }); + + await test.step('expect department to be in the chosen departments list', async () => { + await poOmnichannelTags.search(tag.name); + await poOmnichannelTags.table.findRowByName(tag.name).click(); + await expect(poOmnichannelTags.editTag.root).toBeVisible(); + await expect(poOmnichannelTags.editTag.inputDepartments).toBeVisible(); + await poOmnichannelTags.editTag.close(); + }); + + await test.step('expect to remove tag departments', async () => { + await poOmnichannelTags.search(tag.name); + await poOmnichannelTags.table.findRowByName(tag.name).click(); + await expect(poOmnichannelTags.editTag.root).toBeVisible(); + await poOmnichannelTags.editTag.selectDepartment(department2.data.name); + await expect(poOmnichannelTags.editTag.findSelectedDepartment(department2.data.name)).not.toBeVisible(); + await poOmnichannelTags.editTag.save(); + }); + + await test.step('expect department to not be in the chosen departments list', async () => { + await poOmnichannelTags.search(tag.name); + await poOmnichannelTags.table.findRowByName(tag.name).click(); + await expect(poOmnichannelTags.editTag.root).toBeVisible(); + await expect(page.getByRole('option', { name: department2.data.name })).toBeHidden(); + }); + + await test.step('expect to delete tag', async () => { + await poOmnichannelTags.deleteTag(tag.name); + await poOmnichannelTags.waitForEmptyState(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-takeChat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-takeChat.spec.ts new file mode 100644 index 0000000..db0444f --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-takeChat.spec.ts @@ -0,0 +1,99 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { expectPollUserStatus } from '../utils/expectPollUserStatus'; +import { test, expect } from '../utils/test'; + +test.describe('omnichannel-takeChat', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + + let agent: { page: Page; poHomeChannel: HomeOmnichannel }; + + const sendLivechatMessage = async () => { + await poLiveChat.openLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + }; + + test.beforeAll(async ({ api, browser }) => { + await Promise.all([ + await api.post('/livechat/users/agent', { username: 'user1' }).then((res) => expect(res.status()).toBe(200)), + await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }).then((res) => expect(res.status()).toBe(200)), + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }).then((res) => expect(res.status()).toBe(200)), + ]); + + const { page } = await createAuxContext(browser, Users.user1); + agent = { page, poHomeChannel: new HomeOmnichannel(page) }; + }); + + test.afterAll(async ({ api }) => { + await agent.poHomeChannel.navbar.switchOmnichannelStatus('online'); + await agent.poHomeChannel.navbar.changeUserStatus('online'); + + await agent.page.close(); + await Promise.all([ + await api.delete('/livechat/users/agent/user1'), + await api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }), + ]); + }); + + test.beforeEach('start a new livechat chat', async ({ page, api }) => { + await agent.poHomeChannel.navbar.changeUserStatus('online'); + await expectPollUserStatus(api, 'user1', 'online'); + + newVisitor = createFakeVisitor(); + + poLiveChat = new OmnichannelLiveChat(page, api); + + await poLiveChat.goto(); + }); + + test('When agent is online should take the chat', async () => { + await sendLivechatMessage(); + + await agent.poHomeChannel.sidebar.getSidebarItemByName(newVisitor.name).click(); + + await expect(agent.poHomeChannel.content.btnTakeChat).toBeVisible(); + + await agent.poHomeChannel.content.btnTakeChat.click(); + await agent.poHomeChannel.navbar.openChat(newVisitor.name); + + await expect(agent.poHomeChannel.content.btnTakeChat).not.toBeVisible(); + await expect(agent.poHomeChannel.composer.inputMessage).toBeVisible(); + }); + + test('When agent is offline should not take the chat', async ({ api }) => { + await agent.poHomeChannel.navbar.changeUserStatus('offline'); + await expectPollUserStatus(api, 'user1', 'offline'); + + await sendLivechatMessage(); + + await expect(poLiveChat.alertMessage('Error starting a new conversation: Sorry, no online agents [no-agent-online]')).toBeVisible(); + }); + + test('When a new livechat conversation is selected and the agent becomes offline or unavailable, they should not be able to take the chat', async ({ + api, + }) => { + await sendLivechatMessage(); + + await agent.poHomeChannel.sidebar.getSidebarItemByName(newVisitor.name).click(); + + await agent.poHomeChannel.navbar.changeUserStatus('offline'); + await expectPollUserStatus(api, 'user1', 'offline'); + + await expect(agent.poHomeChannel.content.btnTakeChat).toBeDisabled(); + + await agent.poHomeChannel.navbar.changeUserStatus('online'); + await expectPollUserStatus(api, 'user1', 'online'); + await agent.poHomeChannel.navbar.switchOmnichannelStatus('offline'); + + await expect(agent.poHomeChannel.content.btnTakeChat).toBeDisabled(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts new file mode 100644 index 0000000..6c81729 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts @@ -0,0 +1,98 @@ +import type { Page } from '@playwright/test'; + +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createManager } from '../utils/omnichannel/managers'; +import { createConversation } from '../utils/omnichannel/rooms'; +import { test, expect } from '../utils/test'; + +test.describe('OC - Chat transfers [Agent role]', () => { + let sessions: { page: Page; poHomeOmnichannel: HomeOmnichannel }[]; + let agents: Awaited>[]; + let managers: Awaited>[]; + let conversations: Awaited>[]; + + // Create agents and managers + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + + managers = await Promise.all([createManager(api, 'user1')]); + }); + + // Livechat when agent idle + test.beforeAll(async ({ api }) => { + await api.post('/settings/Livechat_enabled_when_agent_idle', { value: false }).then((res) => expect(res.status()).toBe(200)); + }); + + // Create agent sessions + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeOmnichannel: new HomeOmnichannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeOmnichannel: new HomeOmnichannel(page) })), + ]); + }); + + // Delete all data + test.afterAll(async ({ api }) => { + await Promise.all([ + ...conversations.map((conversation) => conversation.delete()), + ...agents.map((agent) => agent.delete()), + ...managers.map((manager) => manager.delete()), + api.post('/settings/Livechat_enabled_when_agent_idle', { value: true }).then((res) => expect(res.status()).toBe(200)), + ]); + }); + + // Make "user-1" online & "user-2" offline so that chat can be automatically routed to "user-1" + test.beforeAll(async () => { + const [agentA, agentB] = sessions; + await agentA.poHomeOmnichannel.navbar.changeUserStatus('online'); + await agentB.poHomeOmnichannel.navbar.changeUserStatus('offline'); + }); + + // Close sessions + test.afterAll(async () => { + await Promise.all(sessions.map(({ page }) => page.close())); + }); + + // Start a new chat for each test + test.beforeAll(async ({ api }) => { + conversations = [await createConversation(api)]; + }); + + test('OC - Chat transfers [Agent role] - Transfer omnichannel chat to another agent', async () => { + const [agentA, agentB] = sessions; + const [{ visitor }] = conversations.map(({ data }) => data); + + await test.step('expect to have 1 omnichannel assigned to agent 1', async () => { + await agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + + await test.step('expect to not be able to transfer chat to "user-2" when that user is offline', async () => { + await agentB.poHomeOmnichannel.navbar.changeUserStatus('offline'); + + await agentA.poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await agentA.poHomeOmnichannel.content.forwardChatModal.inputForwardUser.click(); + await agentA.poHomeOmnichannel.content.forwardChatModal.inputForwardUser.type('user2'); + await expect(agentA.page.locator('text=Empty')).toBeVisible(); + + await agentA.poHomeOmnichannel.goto(); + }); + + await test.step('expect to be able to transfer an omnichannel to conversation to agent 2 as agent 1 when agent 2 is online', async () => { + await agentB.poHomeOmnichannel.navbar.changeUserStatus('online'); + + await agentA.poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + await agentA.poHomeOmnichannel.quickActionsRoomToolbar.forwardChat(); + await agentA.poHomeOmnichannel.content.forwardChatModal.selectUser('user2'); + await agentA.poHomeOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); + await agentA.poHomeOmnichannel.content.forwardChatModal.btnForward.click(); + await agentA.poHomeOmnichannel.toastMessage.waitForDisplay(); + }); + + await test.step('expect to have 1 omnichannel assigned to agent 2', async () => { + await agentB.poHomeOmnichannel.sidebar.getSidebarItemByName(visitor.name).click(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-after-registration.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-after-registration.spec.ts new file mode 100644 index 0000000..d3216fe --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-after-registration.spec.ts @@ -0,0 +1,133 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe('OC - Livechat New Chat Triggers - After Registration', () => { + let triggersName: string; + let triggerMessage: string; + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeEach(async ({ api, browser, page }) => { + newVisitor = createFakeVisitor(); + triggersName = faker.string.uuid(); + triggerMessage = 'This is a trigger message after guest registration'; + + await api.post('/livechat/triggers', { + name: triggersName, + description: 'Creating a fresh trigger', + enabled: true, + runOnce: false, + conditions: [ + { + name: 'after-guest-registration', + value: '', + }, + ], + actions: [ + { + name: 'send-message', + params: { + name: '', + msg: triggerMessage, + sender: 'queue', + }, + }, + ], + }); + + await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + ]); + + const { page: agentPage } = await createAuxContext(browser, Users.user1); + agent = { page: agentPage, poHomeOmnichannel: new HomeOmnichannel(agentPage) }; + + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterEach(async ({ api }) => { + const ids = (await (await api.get('/livechat/triggers')).json()).triggers.map( + (trigger: { _id: string }) => trigger._id, + ) as unknown as string[]; + + await Promise.all(ids.map((id) => api.delete(`/livechat/triggers/${id}`))); + + await Promise.all([api.delete('/livechat/users/agent/user1'), api.delete('/livechat/users/manager/user1')]); + + await agent.page.close(); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }); + }); + + test.describe('OC - Livechat New Chat Triggers - After Registration', async () => { + test('expect trigger message after registration', async () => { + await poLiveChat.goto(); + await poLiveChat.sendMessageAndCloseChat(newVisitor); + + await poLiveChat.startNewChat(); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + }); + + test.describe('OC - Livechat Triggers - After Registration - Reload', async () => { + test('expect trigger message after registration', async () => { + await test.step('expect trigger message after registration to be visible', async () => { + await poLiveChat.goto(); + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(newVisitor, false); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + + await test.step('expect trigger message after registration to be visible after reload', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChat(); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + + await test.step('expect to close room and reload', async () => { + await poLiveChat.onlineAgentMessage.type('message_after_trigger'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('message_after_trigger')).toBeVisible(); + await poLiveChat.closeChat(); + + await expect(poLiveChat.btnNewChat).toBeVisible(); + await poLiveChat.startNewChat(); + await poLiveChat.page.reload(); + }); + + await test.step('expect trigger message after registration to be visible after reload on new chat', async () => { + await poLiveChat.openAnyLiveChat(); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + }); + }); + + test.describe('OC - Livechat New Chat Triggers - After Registration, clear Local storage', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: true }); + }); + + test('expect trigger message after registration not be visible after local storage clear', async () => { + await poLiveChat.goto(); + await poLiveChat.sendMessageAndCloseChat(newVisitor); + + await expect(poLiveChat.btnNewChat).toBeVisible(); + await expect(poLiveChat.txtChatMessage(triggerMessage)).not.toBeVisible(); + + await poLiveChat.startNewChat(); + await poLiveChat.sendMessage(newVisitor, false); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-open-by-visitor.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-open-by-visitor.spec.ts new file mode 100644 index 0000000..6950b5d --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-open-by-visitor.spec.ts @@ -0,0 +1,98 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe('OC - Livechat Triggers - Open by Visitor', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + const requests = await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + api.post( + '/livechat/triggers', + + { + name: 'open', + description: '', + enabled: true, + runOnce: false, + conditions: [ + { + name: 'chat-opened-by-visitor', + value: '', + }, + ], + actions: [ + { + name: 'send-message', + params: { + name: '', + msg: 'This is a trigger message open by visitor', + sender: 'queue', + }, + }, + ], + }, + ), + ]); + + requests.every((e) => expect(e.status()).toBe(200)); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + agent = { page, poHomeOmnichannel: new HomeOmnichannel(page) }; + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + const ids = (await (await api.get('/livechat/triggers')).json()).triggers.map( + (trigger: { _id: string }) => trigger._id, + ) as unknown as string[]; + + await Promise.all(ids.map((id) => api.delete(`/livechat/triggers/${id}`))); + + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }), + ]); + await agent.page.close(); + }); + + test('OC - Livechat Triggers - after the visitor opens the chat the trigger message should not be visible neither after a page reload', async () => { + await poLiveChat.openLiveChat(); + + await expect(poLiveChat.txtChatMessage('This is a trigger message open by visitor')).toBeVisible(); + + await poLiveChat.btnChatNow.click(); + await poLiveChat.sendMessage(newVisitor, false); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor')).toBeVisible(); + + await poLiveChat.page.reload(); + + // if the request took too long, the loadMessage cleans triggers, but if the fetch is fast enough, the trigger message will be visible + await poLiveChat.page.waitForRequest(/livechat\/messages.history/); + + await poLiveChat.openLiveChat(); + await expect(poLiveChat.txtChatMessage('This is a trigger message open by visitor')).not.toBeVisible(); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_visitor')).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-setDepartment.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-setDepartment.spec.ts new file mode 100644 index 0000000..aeb20d1 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-setDepartment.spec.ts @@ -0,0 +1,128 @@ +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChatEmbedded } from '../page-objects/omnichannel'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +declare const window: Window & { + RocketChat: { livechat: { setDepartment: (dep: string) => void; maximizeWidget: () => void } }; +}; + +test.use({ storageState: Users.admin.state }); +test.describe('OC - Livechat Triggers - SetDepartment', () => { + test.skip(!IS_EE, 'Enterprise Only'); + let poLiveChat: OmnichannelLiveChatEmbedded; + let departments: Awaited>[]; + let departmentA: Awaited>['data']; + let departmentB: Awaited>['data']; + let agents: Awaited>[]; + let agent1: Awaited>['data']; + let agent2: Awaited>['data']; + let poHomeOmnichannelAgent1: HomeOmnichannel; + let poHomeOmnichannelAgent2: HomeOmnichannel; + + test.beforeAll(async ({ api }) => { + // Assign agents & departments + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + [agent1, agent2] = agents.map(({ data }) => data); + departments = await Promise.all([ + createDepartment(api, { showOnRegistration: true }), + createDepartment(api, { showOnRegistration: true }), + ]); + + [departmentA, departmentB] = departments.map(({ data }) => data); + + await Promise.all([ + addAgentToDepartment(api, { department: departmentA, agentId: agent1._id }), + addAgentToDepartment(api, { department: departmentB, agentId: agent2._id }), + api.post( + '/livechat/triggers', + + { + name: 'open', + description: '', + enabled: true, + runOnce: false, + conditions: [ + { + name: 'chat-opened-by-visitor', + value: '', + }, + ], + actions: [ + { + name: 'send-message', + params: { + name: '', + msg: 'This is a trigger message open by visitor', + sender: 'queue', + }, + }, + ], + }, + ), + ]); + }); + + test.beforeEach(async ({ browser, page }) => { + const { page: agent1Page } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannelAgent1 = new HomeOmnichannel(agent1Page); + const { page: agent2Page } = await createAuxContext(browser, Users.user2, '/', true); + poHomeOmnichannelAgent2 = new HomeOmnichannel(agent2Page); + + poLiveChat = new OmnichannelLiveChatEmbedded(page); + }); + + test.afterEach(async ({ page }) => { + await poHomeOmnichannelAgent1.page.close(); + await poHomeOmnichannelAgent2.page.close(); + await poLiveChat.page.close(); + await page.close(); + }); + + test.afterAll(async ({ api }) => { + const ids = (await (await api.get('/livechat/triggers')).json()).triggers.map( + (trigger: { _id: string }) => trigger._id, + ) as unknown as string[]; + + await Promise.all(ids.map((id) => api.delete(`/livechat/triggers/${id}`))); + expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: true })).status()).toBe(200); + await Promise.all([...agents.map((agent) => agent.delete())]); + await Promise.all([...departments.map((department) => department.delete())]); + expect((await api.post('/settings/Omnichannel_enable_department_removal', { value: false })).status()).toBe(200); + await api.post('/settings/Livechat_registration_form', { value: true }); + }); + + test('OC - Livechat Triggers - setDepartment should affect agent.next call', async () => { + await poLiveChat.goto(); + + const depId = departmentB._id; + + await poLiveChat.page.evaluate((depId) => window.RocketChat.livechat.setDepartment(depId), depId); + + await poLiveChat.openLiveChat(); + + await expect(poLiveChat.txtChatMessage('This is a trigger message open by visitor')).toBeVisible(); + + await expect(poLiveChat.headerTitle).toContainText(agent2.username); + }); + + test('OC - Livechat Triggers - setDepartment should affect agent.next call - Register Form Disabled', async ({ api }) => { + await api.post('/settings/Livechat_registration_form', { value: false }); + + await poLiveChat.goto(); + + const depId = departmentB._id; + + await poLiveChat.page.evaluate((depId) => window.RocketChat.livechat.setDepartment(depId), depId); + + await poLiveChat.openLiveChat(); + + await expect(poLiveChat.txtChatMessage('This is a trigger message open by visitor')).toBeVisible(); + + await expect(poLiveChat.headerTitle).toContainText(agent2.username); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-time-on-site.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-time-on-site.spec.ts new file mode 100644 index 0000000..6097120 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers-time-on-site.spec.ts @@ -0,0 +1,97 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe('OC - Livechat Triggers - Time on site', () => { + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + let agent: { page: Page; poHomeOmnichannel: HomeOmnichannel }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + + const requests = await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + api.post( + '/livechat/triggers', + + { + enabled: true, + runOnce: false, + conditions: [{ name: 'time-on-site', value: '1' }], + actions: [{ name: 'send-message', params: { name: '', msg: 'This is a trigger message time on site', sender: 'queue' } }], + name: 'test', + description: 'test', + }, + ), + ]); + + requests.every((e) => expect(e.status()).toBe(200)); + + const { page } = await createAuxContext(browser, Users.user1, '/'); + agent = { page, poHomeOmnichannel: new HomeOmnichannel(page) }; + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.goto(); + }); + + test.afterAll(async ({ api }) => { + const ids = (await (await api.get('/livechat/triggers')).json()).triggers.map( + (trigger: { _id: string }) => trigger._id, + ) as unknown as string[]; + + await Promise.all(ids.map((id) => api.delete(`/livechat/triggers/${id}`))); + + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }), + ]); + await agent.page.close(); + }); + + test('expect to receive trigger message after 1 second', async () => { + await expect(poLiveChat.page.locator('role=button[name="Close"]')).toBeVisible(); + await expect(poLiveChat.page.locator('role=main')).toContainText('This is a trigger message time on site'); + await expect(poLiveChat.page.locator('role=button[name="Start chat"]')).toBeVisible(); + await expect(poLiveChat.page.locator('role=button[name="Messages"]')).toBeVisible(); + }); + + test('OC - Livechat Triggers - after the visitor opens the chat the trigger time-on-site should not be triggered after reload', async () => { + await expect(poLiveChat.page.locator('role=button[name="Close"]')).toBeVisible(); + await expect(poLiveChat.page.locator('role=main')).toContainText('This is a trigger message time on site'); + await expect(poLiveChat.page.locator('role=button[name="Start chat"]')).toBeVisible(); + await expect(poLiveChat.page.locator('role=button[name="Messages"]')).toBeVisible(); + + await poLiveChat.btnOpenOnlineLiveChat('Start chat').click(); + await poLiveChat.btnOpenOnlineLiveChat('Chat now').click(); + + await poLiveChat.sendMessage(newVisitor, false); + + await test.step('expect to not have any trigger message after registration', async () => { + await expect(poLiveChat.txtChatMessage('This is a trigger message time on site')).not.toBeVisible(); + }); + + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await expect(poLiveChat.page.locator('role=main')).toContainText('Chat started'); + + await poLiveChat.page.reload(); + + await poLiveChat.btnOpenOnlineLiveChat('Rocket.Chat').click(); + + await expect(poLiveChat.page.locator('role=main')).toContainText('Chat started'); + await poLiveChat.page.waitForTimeout(1000); + await expect(poLiveChat.txtChatMessage('This is a trigger message time on site')).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts new file mode 100644 index 0000000..4829c74 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts @@ -0,0 +1,159 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelLiveChat, OmnichannelTriggers } from '../page-objects/omnichannel'; +import { test, expect } from '../utils/test'; + +test.describe.serial('OC - Livechat Triggers', () => { + let triggersName: string; + let triggerMessage: string; + let poLiveChat: OmnichannelLiveChat; + let newVisitor: { email: string; name: string }; + let agent: { page: Page; poHomeOmnichannelTriggers: OmnichannelTriggers }; + + test.beforeAll(async ({ api, browser }) => { + newVisitor = createFakeVisitor(); + triggersName = faker.string.uuid(); + triggerMessage = 'This is a trigger message'; + const requests = await Promise.all([ + api.post('/livechat/users/agent', { username: 'user1' }), + api.post('/livechat/users/manager', { username: 'user1' }), + api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: true }), + ]); + requests.every((e) => expect(e.status()).toBe(200)); + + const { page } = await createAuxContext(browser, Users.user1, '/omnichannel/triggers'); + agent = { page, poHomeOmnichannelTriggers: new OmnichannelTriggers(page) }; + await page.emulateMedia({ reducedMotion: 'reduce' }); + }); + + test.beforeEach(async ({ page, api }) => { + poLiveChat = new OmnichannelLiveChat(page, api); + }); + + test.afterAll(async ({ api }) => { + const ids = (await (await api.get('/livechat/triggers')).json()).triggers.map( + (trigger: { _id: string }) => trigger._id, + ) as unknown as string[]; + + await Promise.all(ids.map((id) => api.delete(`/livechat/triggers/${id}`))); + + await Promise.all([ + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/manager/user1'), + api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }), + ]); + await agent.page.close(); + }); + + test('OC - Livechat Triggers - Baseline', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + + await test.step('expect to register visitor', async () => { + await expect(poLiveChat.btnChatNow).not.toBeVisible(); + await poLiveChat.sendMessage(newVisitor, false); + }); + + await test.step('expect send a message as a visitor', async () => { + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + }); + + await test.step('expect to finish this chat', async () => { + await poLiveChat.closeChat(); + await expect(poLiveChat.txtHeaderTitle).toBeVisible(); + }); + }); + + test('OC - Livechat Triggers - Create and edit trigger', async () => { + triggerMessage = 'This is a trigger message time on site'; + await test.step('expect create new trigger', async () => { + await agent.poHomeOmnichannelTriggers.createTrigger(triggersName, triggerMessage, 'Visitor time on site', 5); + }); + + triggerMessage = 'This is a trigger message chat opened by visitor'; + await test.step('expect update trigger', async () => { + await agent.poHomeOmnichannelTriggers.table.findRowByName(triggersName).click(); + await agent.poHomeOmnichannelTriggers.updateTrigger(`edited-${triggersName}`, triggerMessage); + }); + }); + + test('OC - Livechat Triggers - Condition: chat opened by visitor', async () => { + await test.step('expect to start conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + }); + + await test.step('expect trigger message before registration', async () => { + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + + await test.step('expect to register visitor', async () => { + await poLiveChat.btnChatNow.click(); + await poLiveChat.sendMessage(newVisitor, false); + }); + + await test.step('expect trigger message after registration', async () => { + await expect(poLiveChat.txtChatMessage(triggerMessage)).not.toBeVisible(); + }); + + await test.step('expect send a message as a visitor', async () => { + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + }); + + await test.step('expect to finish this chat', async () => { + await poLiveChat.closeChat(); + await expect(poLiveChat.txtHeaderTitle).toBeVisible(); + }); + }); + + test('OC - Livechat Triggers - Condition: after guest registration', async () => { + triggerMessage = 'This is a trigger message after guest registration'; + await test.step('expect update trigger to after guest registration', async () => { + await agent.poHomeOmnichannelTriggers.table.findRowByName(`edited-${triggersName}`).click(); + await agent.poHomeOmnichannelTriggers.updateTrigger(`re-edited-${triggersName}`, triggerMessage, 'After guest registration'); + }); + + await test.step('expect to start conversation', async () => { + await poLiveChat.goto(); + await poLiveChat.openLiveChat(); + }); + + await test.step('expect not to have trigger message before registration', async () => { + await expect(poLiveChat.txtChatMessage(triggerMessage)).not.toBeVisible(); + await expect(poLiveChat.btnChatNow).not.toBeVisible(); + }); + + await test.step('expect to register visitor', async () => { + await expect(poLiveChat.inputName).toBeVisible(); + await poLiveChat.sendMessage(newVisitor, false); + }); + + await test.step('expect trigger message after registration', async () => { + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + + await test.step('expect send a message as a visitor', async () => { + await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + await expect(poLiveChat.txtChatMessage('this_a_test_message_from_user')).toBeVisible(); + await expect(poLiveChat.txtChatMessage(triggerMessage)).toBeVisible(); + }); + + await test.step('expect to finish this chat', async () => { + await poLiveChat.closeChat(); + await expect(poLiveChat.txtHeaderTitle).toBeVisible(); + }); + }); + + test('OC - Livechat Triggers - Delete trigger', async () => { + await agent.poHomeOmnichannelTriggers.removeTrigger(`re-edited-${triggersName}`); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts new file mode 100644 index 0000000..d7b96fa --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts @@ -0,0 +1,214 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelUnits } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createMonitor } from '../utils/omnichannel/monitors'; +import { createOrUpdateUnit } from '../utils/omnichannel/units'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('OC - Manage Units', () => { + test.skip(!IS_EE, 'OC - Manage Units > Enterprise Edition Only'); + + let poOmnichannelUnits: OmnichannelUnits; + + let department: Awaited>; + let department2: Awaited>; + + let agent: Awaited>; + + let monitor: Awaited>; + + let monitor2: Awaited>; + + test.beforeAll(async ({ api }) => { + department = await createDepartment(api); + department2 = await createDepartment(api); + }); + + test.beforeAll(async ({ api }) => { + agent = await createAgent(api, 'user2'); + }); + + test.beforeAll(async ({ api }) => { + monitor = await createMonitor(api, 'user2'); + monitor2 = await createMonitor(api, 'user3'); + }); + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.afterAll(async ({ api }) => { + await department.delete(); + await department2.delete(); + await monitor.delete(); + await monitor2.delete(); + await agent.delete(); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelUnits = new OmnichannelUnits(page); + await poOmnichannelUnits.goto(); + }); + + test('OC - Manage Units - Create Unit', async () => { + const unitName = faker.string.uuid(); + + await test.step('expect correct form default state', async () => { + await poOmnichannelUnits.createNew(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await expect(poOmnichannelUnits.manageUnit.btnSave).toBeEnabled(); + await expect(poOmnichannelUnits.manageUnit.btnCancel).toBeEnabled(); + await poOmnichannelUnits.manageUnit.btnCancel.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + }); + + await test.step('expect to create new unit', async () => { + await poOmnichannelUnits.createNew(); + await poOmnichannelUnits.manageUnit.inputName.fill(unitName); + await poOmnichannelUnits.manageUnit.selectVisibility('public'); + await poOmnichannelUnits.manageUnit.selectDepartment(department.data.name); + await poOmnichannelUnits.manageUnit.selectMonitor('user2'); + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user2')).toBeVisible(); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + + await poOmnichannelUnits.search(unitName); + await expect(poOmnichannelUnits.table.findRowByName(unitName)).toBeVisible(); + }); + + await test.step('expect to delete unit', async () => { + await poOmnichannelUnits.deleteUnit(unitName); + await expect(poOmnichannelUnits.table.findRowByName(unitName)).toHaveCount(0); + }); + }); + + test('OC - Manage Units - Edit unit name', async ({ api }) => { + const unitName = faker.string.uuid(); + const editedUnitName = faker.string.uuid(); + + const unit = await test.step('expect to create new unit', async () => { + const { data: newUnit } = await createOrUpdateUnit(api, { + name: unitName, + visibility: 'public', + monitors: [{ monitorId: monitor.data._id, username: 'user2' }], + departments: [{ departmentId: department.data._id }], + }); + + return newUnit; + }); + + await poOmnichannelUnits.goto(); + + await test.step('expect to edit unit', async () => { + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await poOmnichannelUnits.manageUnit.inputName.fill(editedUnitName); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + await expect(poOmnichannelUnits.inputSearch).toBeVisible(); + await poOmnichannelUnits.search(editedUnitName); + await expect(poOmnichannelUnits.table.findRowByName(editedUnitName)).toBeVisible(); + }); + + await test.step('expect to add another monitor to list', async () => { + await poOmnichannelUnits.table.findRowByName(editedUnitName).click(); + await poOmnichannelUnits.manageUnit.selectMonitor('user3'); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + + await poOmnichannelUnits.search(editedUnitName); + await poOmnichannelUnits.table.findRowByName(editedUnitName).click(); + + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user2')).toBeVisible(); + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user3')).toBeVisible(); + }); + + await test.step('expect unit to remove one of the two monitors', async () => { + await poOmnichannelUnits.search(editedUnitName); + await poOmnichannelUnits.table.findRowByName(editedUnitName).click(); + await poOmnichannelUnits.manageUnit.removeMonitor('user2'); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + + await poOmnichannelUnits.search(editedUnitName); + await poOmnichannelUnits.table.findRowByName(editedUnitName).click(); + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user3')).toBeVisible(); + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user2')).toBeHidden(); + }); + + await test.step('expect to delete unit', async () => { + await poOmnichannelUnits.table.findRowByName(editedUnitName).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + + await poOmnichannelUnits.deleteUnit(editedUnitName); + await expect(poOmnichannelUnits.table.findRowByName(editedUnitName)).not.toBeVisible(); + }); + }); + + test('OC - Manage Units - Edit unit departments', async ({ api, page }) => { + const unit = await test.step('expect to create new unit', async () => { + const { data: unit } = await createOrUpdateUnit(api, { + name: faker.string.uuid(), + visibility: 'public', + monitors: [{ monitorId: monitor.data._id, username: 'user2' }], + departments: [{ departmentId: department.data._id }], + }); + + return unit; + }); + + await page.reload(); + + await test.step('expect to add unit departments', async () => { + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await poOmnichannelUnits.manageUnit.selectDepartment(department2.data.name); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await expect(poOmnichannelUnits.manageUnit.findDepartmentsChipOption(department2.data.name)).toBeVisible(); + await poOmnichannelUnits.manageUnit.findDepartmentsChipOption(department2.data.name).hover(); + + await expect(page.getByRole('tooltip', { name: department2.data.name })).toBeVisible(); + await poOmnichannelUnits.manageUnit.close(); + }); + + await test.step('expect to remove unit departments', async () => { + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await poOmnichannelUnits.manageUnit.selectDepartment(department2.data.name); + await poOmnichannelUnits.manageUnit.btnSave.click(); + await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); + + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + await expect(poOmnichannelUnits.manageUnit.findDepartmentsChipOption(department2.data.name)).toBeHidden(); + await poOmnichannelUnits.manageUnit.close(); + }); + + await test.step('expect to delete unit', async () => { + await poOmnichannelUnits.search(unit.name); + await poOmnichannelUnits.table.findRowByName(unit.name).click(); + await expect(poOmnichannelUnits.manageUnit.root).toBeVisible(); + + await poOmnichannelUnits.deleteUnit(unit.name); + await expect(poOmnichannelUnits.table.findRowByName(unit.name)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/account-accessibility.ts b/apps/meteor/tests/e2e/page-objects/account-accessibility.ts new file mode 100644 index 0000000..052e272 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-accessibility.ts @@ -0,0 +1,7 @@ +import { Account, AccountSectionsHref } from './account'; + +export class AccountAccessibility extends Account { + protected readonly route = AccountSectionsHref.accessibilityAndAppearance; + + protected readonly title = 'Accessibility & appearance'; +} diff --git a/apps/meteor/tests/e2e/page-objects/account-feature-preview.ts b/apps/meteor/tests/e2e/page-objects/account-feature-preview.ts new file mode 100644 index 0000000..46d6835 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-feature-preview.ts @@ -0,0 +1,7 @@ +import { Account, AccountSectionsHref } from './account'; + +export class AccountFeaturePreview extends Account { + protected readonly route = AccountSectionsHref.featurePreview; + + protected readonly title = 'Feature preview'; +} diff --git a/apps/meteor/tests/e2e/page-objects/account-omnichannel.ts b/apps/meteor/tests/e2e/page-objects/account-omnichannel.ts new file mode 100644 index 0000000..2ee8693 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-omnichannel.ts @@ -0,0 +1,7 @@ +import { Account, AccountSectionsHref } from './account'; + +export class AccountOmnichannel extends Account { + protected readonly route = AccountSectionsHref.omnichannel; + + protected readonly title = 'Omnichannel'; +} diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts new file mode 100644 index 0000000..96c7692 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -0,0 +1,107 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Account, AccountSectionsHref } from './account'; +import { DeleteAccountModal } from './fragments'; + +export class AccountProfile extends Account { + protected readonly route = AccountSectionsHref.profile; + + protected readonly title = 'Profile'; + + readonly deleteAccountModal: DeleteAccountModal; + + constructor(page: Page) { + super(page); + this.deleteAccountModal = new DeleteAccountModal(page); + } + + get inputName(): Locator { + return this.page.locator('//label[contains(text(), "Name")]/..//input'); + } + + get inputAvatarLink(): Locator { + return this.page.getByRole('textbox', { name: 'Use URL for avatar' }); + } + + get btnSetAvatarLink(): Locator { + return this.page.getByRole('button', { name: 'Add URL', exact: true }); + } + + get inputUsername(): Locator { + return this.page.locator('//label[contains(text(), "Username")]/..//input'); + } + + // TODO: remove this locator + get btnSubmit(): Locator { + return this.page.getByRole('button', { name: 'Save changes', exact: true }); + } + + get avatarFileInput(): Locator { + return this.page.locator('.avatar-file-input'); + } + + get userAvatarEditor(): Locator { + return this.page.getByAltText('profile picture'); + } + + get emailTextInput(): Locator { + return this.page.locator('//label[contains(text(), "Email")]/..//input'); + } + + get inputStatusText(): Locator { + return this.page.getByRole('textbox', { name: 'Status' }); + } + + get selectClearStatusAfter(): Locator { + return this.page.getByLabel('Clear status after'); + } + + async chooseClearStatusAfter(option: string): Promise { + await this.selectClearStatusAfter.click(); + await this.page.getByRole('option', { name: new RegExp(option) }).click(); + } + + get preferencesSoundAccordionOption(): Locator { + return this.page.locator('h2:has-text("Sound")'); + } + + get preferencesCallRingerVolumeSlider(): Locator { + return this.page.getByRole('slider', { name: 'Call Ringer Volume' }); + } + + get btnClose(): Locator { + return this.page.locator('role=navigation >> role=button[name=Close]'); + } + + get inputImageFile(): Locator { + return this.page.locator('input[type=file]'); + } + + getAccordionItemByName(name: string): Locator { + return this.page.getByRole('button', { name, exact: true }); + } + + getCheckboxByLabelText(name: string): Locator { + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name }) }); + } + + get btnSaveChanges(): Locator { + return this.page.getByRole('button', { name: 'Save changes', exact: true }); + } + + get profileHeading(): Locator { + return this.page.getByRole('heading', { name: 'Profile', exact: true }); + } + + get btnDeleteMyAccount(): Locator { + return this.page.getByRole('button', { name: 'Delete my account' }); + } + + private getErrorAlertByText(text: string): Locator { + return this.page.getByRole('alert').filter({ hasText: text }); + } + + get errorInvalidUrl(): Locator { + return this.getErrorAlertByText('Invalid image URL'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/account-security.ts b/apps/meteor/tests/e2e/page-objects/account-security.ts new file mode 100644 index 0000000..39b3c53 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-security.ts @@ -0,0 +1,103 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Account, AccountSectionsHref } from './account'; +import { EnterPasswordModal } from './fragments/modals'; + +export class AccountSecurity extends Account { + protected readonly route = AccountSectionsHref.security; + + protected readonly title = 'Security'; + + private readonly enterPasswordModal: EnterPasswordModal; + + constructor(page: Page) { + super(page); + this.enterPasswordModal = new EnterPasswordModal(page); + } + + get inputNewPassword() { + return this.page.getByRole('textbox', { name: 'New password' }); + } + + private get inputConfirmPassword() { + return this.page.getByRole('textbox', { name: 'Confirm password' }); + } + + private get btnExpandE2EESection() { + return this.page.getByRole('button', { name: 'End-to-end encryption' }); + } + + private get btnResetE2EEPassword() { + return this.page.getByRole('button', { name: 'Reset E2EE password' }); + } + + private get inputNewE2EEPassword() { + return this.page.getByRole('textbox', { name: 'New E2EE password' }); + } + + private get inputConfirmNewE2EEPassword() { + return this.page.getByRole('textbox', { name: 'Confirm new E2EE password' }); + } + + get securityHeader(): Locator { + return this.page.getByRole('main').getByRole('heading', { level: 1, name: 'Security', exact: true }); + } + + get securityPasswordSection(): Locator { + return this.page.locator('[role="button"]:has-text("Password")'); + } + + get security2FASection(): Locator { + return this.page.locator('[role="button"]:has-text("Two Factor Authentication")'); + } + + get securityE2EEncryptionSection(): Locator { + return this.page.locator('[role="button"]:has-text("End-to-end encryption")'); + } + + get securityE2EEncryptionResetKeyButton(): Locator { + return this.page.locator("role=button[name='Reset E2EE password']"); + } + + get securityE2EEncryptionSavePasswordButton(): Locator { + return this.page.locator("role=button[name='Save changes']"); + } + + get email2FASwitch(): Locator { + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Two-factor authentication via email' }) }); + } + + get totp2FASwitch(): Locator { + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name: 'Two-factor authentication via TOTP' }) }); + } + + get required2faModalSetUpButton(): Locator { + return this.page.locator('dialog >> button'); + } + + async changePassword(newPassword: string, confirmPassword: string, currentPassword: string) { + await this.inputNewPassword.fill(newPassword); + await this.inputConfirmPassword.fill(confirmPassword); + await this.saveChangesButton.click(); + await this.enterPasswordModal.enterPassword(currentPassword); + } + + async resetE2EEPassword() { + await this.btnExpandE2EESection.click(); + await this.btnResetE2EEPassword.click(); + await this.toastMessage.dismissToast('success'); + // Logged out + } + + async setE2EEPassword(newPassword: string) { + await this.btnExpandE2EESection.click(); + await this.inputNewE2EEPassword.fill(newPassword); + await this.inputConfirmNewE2EEPassword.fill(newPassword); + await this.saveChangesButton.click(); + await this.toastMessage.dismissToast('success'); + } + + async close() { + await this.sidebar.close(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/account-tokens.ts b/apps/meteor/tests/e2e/page-objects/account-tokens.ts new file mode 100644 index 0000000..54d0c4a --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account-tokens.ts @@ -0,0 +1,49 @@ +import type { Locator } from '@playwright/test'; + +import { Account, AccountSectionsHref } from './account'; + +export class AccountTokens extends Account { + protected readonly route = AccountSectionsHref.tokens; + + protected readonly title = 'Personal Access Tokens'; + + get inputToken(): Locator { + return this.page.locator('[data-qa="PersonalTokenField"]'); + } + + get tokensTableEmpty(): Locator { + return this.page.locator('//h3[contains(text(), "No results found")]'); + } + + get btnTokensAdd(): Locator { + return this.page.locator('role=button[name="Add"]'); + } + + get tokenAddedModal(): Locator { + return this.page.locator('role=dialog[name="Personal Access Token successfully generated"]'); + } + + get btnTokenAddedOk(): Locator { + return this.tokenAddedModal.locator('role=button[name="Ok"]'); + } + + get tokensRows(): Locator { + return this.page.locator('table tbody tr'); + } + + tokenInTable(name: string): Locator { + return this.page.locator(`tr[qa-token-name="${name}"]`); + } + + get btnRegenerateTokenModal(): Locator { + return this.page.locator('role=button[name="Regenerate token"]'); + } + + get removeTokenModal(): Locator { + return this.page.locator('role=dialog', { hasText: 'personal access token' }); + } + + get btnRemoveTokenModal(): Locator { + return this.removeTokenModal.getByRole('button', { name: 'Remove' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/account.manage-devices.ts b/apps/meteor/tests/e2e/page-objects/account.manage-devices.ts new file mode 100644 index 0000000..115d32f --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account.manage-devices.ts @@ -0,0 +1,33 @@ +import type { Page } from 'playwright-core'; + +import { Account, AccountSectionsHref } from './account'; +import { ConfirmLogoutModal, DevicesTable } from './fragments'; + +export class AccountManageDevices extends Account { + protected readonly route = AccountSectionsHref.manageDevices; + + protected readonly title = 'Manage Devices'; + + readonly logoutModal: ConfirmLogoutModal; + + readonly table: DevicesTable; + + constructor(page: Page) { + super(page); + this.logoutModal = new ConfirmLogoutModal(page.getByRole('dialog', { name: 'Log out device' })); + this.table = new DevicesTable(page); + } + + async getNthDeviceId(nth: number): Promise { + const deviceId = await this.pageContent.getByRole('row').nth(nth).getAttribute('aria-label'); + if (!deviceId) { + throw new Error(`Device ID not found for the device ${nth}`); + } + return deviceId || ''; + } + + async logoutDeviceById(deviceId: string) { + await this.table.getDeviceRowById(deviceId).getByRole('button', { name: 'Logout' }).click(); + await this.logoutModal.confirmLogout(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/account.ts b/apps/meteor/tests/e2e/page-objects/account.ts new file mode 100644 index 0000000..4501cfc --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/account.ts @@ -0,0 +1,50 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AccountSidebar, ToastMessages } from './fragments'; +import { RoutedPage } from './routed-page'; + +export enum AccountSectionsHref { + preferences = '/account/preferences', + profile = '/account/profile', + security = '/account/security', + integrations = '/account/integrations', + tokens = '/account/tokens', + omnichannel = '/account/omnichannel', + featurePreview = '/account/feature-preview', + accessibilityAndAppearance = '/account/accessibility-and-appearance', + manageDevices = '/account/manage-devices', +} + +/** + * A page under `/account/*`. Subclasses declare `route` and `title` and get `goto()`, + * `pageContent` and their ready state for free. + */ +export abstract class Account extends RoutedPage { + protected abstract override readonly route: AccountSectionsHref; + + /** Text of the page's main heading. Identifies the page and scopes `pageContent`. */ + protected abstract readonly title: string; + + readonly toastMessage: ToastMessages; + + readonly sidebar: AccountSidebar; + + constructor(page: Page) { + super(page); + this.toastMessage = new ToastMessages(page); + this.sidebar = new AccountSidebar(page); + } + + /** The `main` region of this page, scoped by its heading so it cannot match another route. */ + get pageContent(): Locator { + return this.page.getByRole('main').filter({ has: this.page.getByRole('heading', { name: this.title, exact: true }) }); + } + + async waitForReady(): Promise { + await this.pageContent.waitFor({ state: 'visible' }); + } + + protected get saveChangesButton() { + return this.page.getByRole('button', { name: 'Save changes' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-device-management.ts b/apps/meteor/tests/e2e/page-objects/admin-device-management.ts new file mode 100644 index 0000000..02c8f97 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-device-management.ts @@ -0,0 +1,62 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; +import { MenuOptions, DevicesTable } from './fragments'; +import { DeviceInfoFlexTab } from './fragments/flextabs'; +import { ConfirmLogoutModal } from './fragments/modals'; + +export class AdminDeviceManagement extends Admin { + readonly deviceInfo: DeviceInfoFlexTab; + + readonly deviceRowMenu: MenuOptions; + + readonly table: DevicesTable; + + readonly logoutModal: ConfirmLogoutModal; + + constructor(page: Page) { + super(page); + this.deviceInfo = new DeviceInfoFlexTab(page.getByRole('dialog', { name: 'Device Info' })); + this.deviceRowMenu = new MenuOptions(page); + this.table = new DevicesTable(page); + this.logoutModal = new ConfirmLogoutModal(page.getByRole('dialog', { name: 'Log out device' })); + } + + protected readonly route = AdminSectionsHref.deviceManagement; + + protected readonly title = 'Device management'; + + get upsellModal(): Locator { + return this.page.getByRole('dialog', { name: 'Device management' }); + } + + get emptyState(): Locator { + return this.pageContent.getByRole('heading', { name: 'No results found', exact: true }); + } + + async searchUserDevice(user: string): Promise { + await this.pageContent.getByRole('textbox', { name: 'Search devices or users' }).fill(user); + } + + async getUsersDeviceId(username: string): Promise { + await this.searchUserDevice(username); + const deviceId = await this.pageContent + .getByRole('row') + .filter({ has: this.page.getByRole('cell', { name: username }) }) + .first() + .getAttribute('aria-label'); + + return deviceId || ''; + } + + async openDeviceOptionsById(deviceId: string) { + const row = this.table.getDeviceRowById(deviceId); + await row.getByRole('button', { name: 'Options' }).click(); + } + + async logoutDeviceById(deviceId: string) { + await this.openDeviceOptionsById(deviceId); + await this.deviceRowMenu.getMenuItem('Log out device').click(); + await this.logoutModal.confirmLogout(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-email-inboxes.ts b/apps/meteor/tests/e2e/page-objects/admin-email-inboxes.ts new file mode 100644 index 0000000..5a43aa7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-email-inboxes.ts @@ -0,0 +1,69 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminEmailInboxes extends Admin { + constructor(page: Page) { + super(page); + } + + get btnNewEmailInbox(): Locator { + return this.page.locator('role=button[name="New Email Inbox"]'); + } + + protected readonly route = AdminSectionsHref.emailInboxes; + + protected readonly title = 'Email inboxes'; + + get inputName(): Locator { + return this.page.locator('input[name="name"]'); + } + + get inputEmail(): Locator { + return this.page.locator('input[name="email"]'); + } + + // SMTP + get inputSmtpServer(): Locator { + return this.page.locator('input[name="smtpServer"]'); + } + + get inputSmtpUsername(): Locator { + return this.page.locator('input[name="smtpUsername"]'); + } + + get inputSmtpPassword(): Locator { + return this.page.locator('input[name="smtpPassword"]'); + } + + get inputSmtpSecure(): Locator { + return this.page.locator('label >> text="Connect with SSL/TLS"').first(); + } + + // IMAP + get inputImapServer(): Locator { + return this.page.locator('input[name="imapServer"]'); + } + + get inputImapUsername(): Locator { + return this.page.locator('input[name="imapUsername"]'); + } + + get inputImapPassword(): Locator { + return this.page.locator('input[name="imapPassword"]'); + } + + get inputImapSecure(): Locator { + return this.page.locator('label >> text="Connect with SSL/TLS"').last(); + } + + itemRow(name: string): Locator { + return this.page.locator(`td >> text="${name}"`); + } + + async deleteEmailInboxByName(name: string): Promise { + await this.itemRow(name).click(); + await this.btnDelete.click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-emojis.ts b/apps/meteor/tests/e2e/page-objects/admin-emojis.ts new file mode 100644 index 0000000..de21f02 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-emojis.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; +import { AddEmojiFlexTab, EditEmojiFlexTab } from './fragments/flextabs'; + +export class AdminEmoji extends Admin { + readonly addEmojiFlexTab: AddEmojiFlexTab; + + readonly editEmojiFlexTab: EditEmojiFlexTab; + + protected readonly route = AdminSectionsHref.emoji; + + protected readonly title = 'Emoji'; + + constructor(page: Page) { + super(page); + this.addEmojiFlexTab = new AddEmojiFlexTab(page); + this.editEmojiFlexTab = new EditEmojiFlexTab(page); + } + + private get inputSearch(): Locator { + return this.page.getByRole('textbox', { name: 'Search' }); + } + + private get emojiTable() { + return this.page.getByRole('table', { name: 'Emoji' }); + } + + async findEmojiByName(emojiName: string) { + await this.inputSearch.fill(emojiName); + return this.emojiTable.getByRole('link', { name: emojiName }); + } + + async deleteEmoji(emojiName: string) { + await (await this.findEmojiByName(emojiName)).click(); + await this.editEmojiFlexTab.delete(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-engagement.ts b/apps/meteor/tests/e2e/page-objects/admin-engagement.ts new file mode 100644 index 0000000..9ed2361 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-engagement.ts @@ -0,0 +1,13 @@ +import type { Locator } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminEngagement extends Admin { + protected readonly route = AdminSectionsHref.engagement; + + protected readonly title = 'Engagement'; + + get upsellModal(): Locator { + return this.page.getByRole('dialog', { name: 'Engagement dashboard' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-imports.ts b/apps/meteor/tests/e2e/page-objects/admin-imports.ts new file mode 100644 index 0000000..41b1235 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-imports.ts @@ -0,0 +1,38 @@ +import type { Locator } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminImports extends Admin { + protected readonly route = AdminSectionsHref.import; + + protected readonly title = 'Import'; + + override async waitForReady(): Promise { + await this.btnImportNewFile.waitFor({ state: 'visible' }); + } + + get btnImportNewFile(): Locator { + return this.page.locator('.rcx-button--primary.rcx-button >> text="Import New File"'); + } + + async getOptionFileType(option: string): Promise { + await this.page.locator('.rcx-select').click(); + return this.page.locator(`.rcx-option__content >> text="${option}"`); + } + + get inputFile(): Locator { + return this.page.locator('input[type=file]'); + } + + get btnImport(): Locator { + return this.page.locator('.rcx-button--primary.rcx-button >> text="Import"'); + } + + get btnStartImport(): Locator { + return this.page.locator('.rcx-button--primary.rcx-button >> text="Start Importing"'); + } + + get importStatusTableFirstRowCell(): Locator { + return this.page.locator('[data-qa-id="ImportTable"] tbody tr:first-child td >> text="Completed successfully"'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-info.ts b/apps/meteor/tests/e2e/page-objects/admin-info.ts new file mode 100644 index 0000000..02e6f32 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-info.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminInfo extends Admin { + constructor(page: Page) { + super(page); + } + + protected readonly route = AdminSectionsHref.workspace; + + protected readonly title = 'Workspace'; + + /** Opens the workspace info page in the embedded layout. */ + async gotoEmbedded(): Promise { + await this.navigateTo(`${AdminSectionsHref.workspace}?layout=embedded`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-integrations.ts b/apps/meteor/tests/e2e/page-objects/admin-integrations.ts new file mode 100644 index 0000000..d3cd027 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-integrations.ts @@ -0,0 +1,47 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminIntegrations extends Admin { + constructor(page: Page) { + super(page); + } + + get btnInstructions(): Locator { + return this.page.getByRole('button', { name: 'Instructions', exact: true }); + } + + protected readonly route = AdminSectionsHref.integrations; + + protected readonly title = 'Integrations'; + + codeExamplePayload(text: string): Locator { + return this.page.locator('code', { hasText: text }); + } + + get inputName(): Locator { + return this.page.getByRole('textbox', { name: 'Name' }); + } + + get inputPostToChannel(): Locator { + return this.page.getByRole('textbox', { name: 'Post to Channel' }); + } + + get inputPostAs(): Locator { + return this.page.getByRole('textbox', { name: 'Post as' }); + } + + getIntegrationByName(name: string): Locator { + return this.page.getByRole('table', { name: 'Integrations table' }).locator('tr', { hasText: name }); + } + + get inputWebhookUrl(): Locator { + return this.page.getByRole('textbox', { name: 'Webhook URL' }); + } + + async deleteIntegrationByName(name: string) { + await this.getIntegrationByName(name).click(); + await this.btnDelete.click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-mailer.ts b/apps/meteor/tests/e2e/page-objects/admin-mailer.ts new file mode 100644 index 0000000..61b16f5 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-mailer.ts @@ -0,0 +1,7 @@ +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminMailer extends Admin { + protected readonly route = AdminSectionsHref.mailer; + + protected readonly title = 'Mailer'; +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-moderation.ts b/apps/meteor/tests/e2e/page-objects/admin-moderation.ts new file mode 100644 index 0000000..c17dfaf --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-moderation.ts @@ -0,0 +1,25 @@ +import type { Locator } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminModeration extends Admin { + protected readonly route = AdminSectionsHref.moderation; + + protected readonly title = 'Moderation'; + + get tabReportedMessages(): Locator { + return this.page.getByRole('tab', { name: 'Reported messages' }); + } + + get tabReportedUsers(): Locator { + return this.page.getByRole('tab', { name: 'Reported users' }); + } + + async gotoMessages(): Promise { + await this.navigateTo(`${AdminSectionsHref.moderation}/messages`, this.tabReportedMessages); + } + + async gotoUsers(): Promise { + await this.navigateTo(`${AdminSectionsHref.moderation}/users`, this.tabReportedUsers); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-permissions.ts b/apps/meteor/tests/e2e/page-objects/admin-permissions.ts new file mode 100644 index 0000000..9e38ea4 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-permissions.ts @@ -0,0 +1,42 @@ +import type { Locator } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminPermissions extends Admin { + protected readonly route = AdminSectionsHref.permissions; + + protected readonly title = 'Permissions'; + + get btnCreateRole(): Locator { + return this.page.locator('button[name="New role"]'); + } + + openRoleByName(name: string): Locator { + return this.page.getByRole('table').getByRole('button', { name }); + } + + get btnUsersInRole(): Locator { + return this.page.getByRole('dialog').getByRole('button', { name: 'Users in role', exact: true }); + } + + get tableUsersInRole(): Locator { + return this.page.getByRole('table'); + } + + get inputRoom(): Locator { + return this.page.locator('input[placeholder="Room"]'); + } + + get inputUsers(): Locator { + return this.page.locator('input[placeholder="Users"]'); + } + + getUserInRoleRowByUsername(username: string): Locator { + return this.tableUsersInRole.locator('tr', { hasText: username }); + } + + removeUserFromRoleByUsername = async (username: string): Promise => { + await this.getUserInRoleRowByUsername(username).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + }; +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-rooms.ts b/apps/meteor/tests/e2e/page-objects/admin-rooms.ts new file mode 100644 index 0000000..f7cabd7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-rooms.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; +import { EditAdminRoomFlexTab } from './fragments/flextabs'; + +export class AdminRooms extends Admin { + readonly editRoom: EditAdminRoomFlexTab; + + constructor(page: Page) { + super(page); + this.editRoom = new EditAdminRoomFlexTab(page.getByRole('dialog', { name: 'Room Information' })); + } + + protected readonly route = AdminSectionsHref.rooms; + + protected readonly title = 'Rooms'; + + get inputSearchRooms(): Locator { + return this.pageContent.getByPlaceholder('Search rooms'); + } + + getRoomRow(name?: string): Locator { + return this.pageContent.getByRole('link', { name }); + } + + get btnEdit(): Locator { + return this.pageContent.getByRole('button', { name: 'Edit' }); + } + + dropdownFilterRoomType(text = 'All rooms'): Locator { + return this.pageContent.getByRole('button', { name: text }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-settings.ts b/apps/meteor/tests/e2e/page-objects/admin-settings.ts new file mode 100644 index 0000000..73b792f --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-settings.ts @@ -0,0 +1,79 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +/** + * A single settings section, e.g. `/admin/settings/Omnichannel`. Its `title` is the section heading, so it gets its ready state from `Admin` like any other page. + */ +export abstract class AdminSettingsSection extends Admin { + protected abstract override readonly route: `${AdminSectionsHref.settings}/${string}`; +} + +/** The settings index at `/admin/settings`. Individual sections are `AdminSettingsSection`s. */ +export class AdminSettings extends Admin { + constructor(page: Page) { + super(page); + } + + get inputSearchSettings(): Locator { + return this.page.locator('input[type=search]'); + } + + protected readonly route = AdminSectionsHref.settings; + + protected readonly title = 'Settings'; + + override async waitForReady(): Promise { + await this.inputSearchSettings.waitFor({ state: 'visible' }); + } + + get inputSiteURL(): Locator { + return this.page.getByRole('textbox', { name: 'Site URL' }); + } + + get btnResetSiteURL(): Locator { + return this.page.locator('//label[@title="Site_Url"]//following-sibling::button'); + } + + get btnAssetsSettings(): Locator { + return this.page.locator('[data-qa-id="Assets"] >> role=link[name="Open"]'); + } + + get btnDeleteAssetsLogo(): Locator { + return this.page.locator('//label[@title="Assets_logo"]/following-sibling::span >> role=button[name="Delete"]'); + } + + get inputAssetsLogo(): Locator { + return this.page.locator('//label[@title="Assets_logo"]/following-sibling::span >> input[type="file"]'); + } + + get btnFullScreen(): Locator { + return this.page.getByRole('button', { name: 'Full Screen', exact: true }); + } + + get btnExitFullScreen(): Locator { + return this.page.getByRole('button', { name: 'Exit Full Screen', exact: true }); + } + + getSectionHeading(section: string): Locator { + return this.page.getByRole('main').getByRole('heading', { level: 1, name: section, exact: true }); + } + + /** + * Opens a settings section, e.g. `Message` for `/admin/settings/Message`. + * + * @param section id of the section, which doubles as its heading + * @param until element to wait for, when the section heading is not a strong enough ready signal + */ + async gotoSection(section: string, until: Locator = this.getSectionHeading(section)): Promise { + await this.navigateTo(`${AdminSectionsHref.settings}/${section}`, until); + } + + async gotoGeneral(): Promise { + await this.gotoSection('General', this.inputSiteURL); + } + + async gotoLayout(): Promise { + await this.gotoSection('Layout', this.getAccordionBtnByName('Custom CSS')); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-third-party-login.ts b/apps/meteor/tests/e2e/page-objects/admin-third-party-login.ts new file mode 100644 index 0000000..673ec3c --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-third-party-login.ts @@ -0,0 +1,47 @@ +import type { Locator } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; + +export class AdminThirdPartyLogin extends Admin { + protected readonly route = AdminSectionsHref.thirdPartyLogin; + + protected readonly title = 'Third-party login'; + + get btnNewApplication(): Locator { + return this.page.getByRole('button', { name: 'New Application', exact: true }); + } + + get inputRedirectURI(): Locator { + return this.page.getByRole('textbox', { name: 'Redirect URI' }); + } + + get inputApplicationName(): Locator { + return this.page.getByRole('textbox', { name: 'Application Name' }); + } + + get inputClientId(): Locator { + return this.page.getByRole('textbox', { name: 'Client ID' }); + } + + get inputClientSecret(): Locator { + return this.page.getByRole('textbox', { name: 'Client Secret' }); + } + + get inputAuthUrl(): Locator { + return this.page.getByRole('textbox', { name: 'Authorization URL' }); + } + + get inputTokenUrl(): Locator { + return this.page.getByRole('textbox', { name: 'Access Token URL' }); + } + + getThirdPartyAppByName(name: string): Locator { + return this.page.getByRole('table', { name: 'Third-party applications table' }).locator('tr', { hasText: name }); + } + + async deleteThirdPartyAppByName(name: string) { + await this.getThirdPartyAppByName(name).click(); + await this.btnDelete.click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin-users.ts b/apps/meteor/tests/e2e/page-objects/admin-users.ts new file mode 100644 index 0000000..a4659fb --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-users.ts @@ -0,0 +1,79 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; +import { MenuMoreActions, UserInfoFlexTab, EditUserFlexTab } from './fragments'; +import { expect } from '../utils/test'; + +type UserActions = 'Make Admin' | 'Remove Admin' | 'Activate' | 'Deactivate'; + +export class AdminUsers extends Admin { + readonly editUser: EditUserFlexTab; + + readonly userInfo: UserInfoFlexTab; + + readonly userRowMenu: MenuMoreActions; + + constructor(page: Page) { + super(page); + this.editUser = new EditUserFlexTab(page); + this.userInfo = new UserInfoFlexTab(page); + this.userRowMenu = new MenuMoreActions(page); + } + + protected readonly route = AdminSectionsHref.users; + + protected readonly title = 'Users'; + + override async waitForReady(): Promise { + await this.inputSearchUsers.waitFor({ state: 'visible' }); + } + + get btnNewUser(): Locator { + return this.page.getByRole('button', { name: 'New user', exact: true }); + } + + get btnInvite(): Locator { + return this.page.getByRole('button', { name: 'Invite', exact: true }); + } + + private get inputSearchUsers(): Locator { + return this.page.getByRole('textbox', { name: 'Search Users' }); + } + + get btnMoreActionsMenu(): Locator { + return this.page.getByRole('button', { name: 'More actions' }); + } + + getUserRowByUsername(username: string): Locator { + return this.page.locator('tr', { hasText: username }).first(); + } + + getTabByName(name: 'All' | 'Pending' | 'Active' | 'Deactivated' = 'All'): Locator { + return this.page.getByRole('tab', { name }); + } + + private async openUserActionMenu(username: string): Promise { + await this.getUserRowByUsername(username).getByRole('button', { name: 'More actions' }).click(); + } + + async dispatchUserAction(username: string, action: UserActions) { + await this.openUserActionMenu(username); + await this.userRowMenu.root.getByRole('menuitem', { name: action }).click(); + } + + async activatePendingUser(username: string): Promise { + await this.getUserRowByUsername(username).getByRole('button', { name: 'Activate', exact: true }).click(); + } + + async deleteUser(username: string): Promise { + await this.searchUser(username); + await this.getUserRowByUsername(username).click(); + await this.userInfo.btnMoreActions.click(); + await this.userInfo.menuItemDeleteUser.click(); + } + + async searchUser(username: string): Promise { + await this.inputSearchUsers.fill(username); + await expect(this.getUserRowByUsername(username)).toHaveCount(1); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/admin.ts b/apps/meteor/tests/e2e/page-objects/admin.ts new file mode 100644 index 0000000..efcb804 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin.ts @@ -0,0 +1,101 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AdminSidebar, ToastMessages } from './fragments'; +import { ConfirmDeleteModal } from './fragments/modals'; +import { RoutedPage } from './routed-page'; + +export enum AdminSectionsHref { + workspace = '/admin/info', + subscription = '/admin/subscription', + engagement = '/admin/engagement/users', + moderation = '/admin/moderation', + rooms = '/admin/rooms', + users = '/admin/users', + invites = '/admin/invites', + userStatus = '/admin/user-status', + permissions = '/admin/permissions', + deviceManagement = '/admin/device-management', + emailInboxes = '/admin/email-inboxes', + mailer = '/admin/mailer', + thirdPartyLogin = '/admin/third-party-login', + integrations = '/admin/integrations', + import = '/admin/import', + reports = '/admin/analytic-reports', + sounds = '/admin/sounds', + emoji = '/admin/emoji', + settings = '/admin/settings', +} + +/** + * A page under `/admin/*`. Subclasses declare `route` and `title` and get `goto()`, `pageContent` + * and their ready state for free; override `waitForReady()` when the heading is not a strong enough + * signal that the page is usable. + */ +export abstract class Admin extends RoutedPage { + protected abstract override readonly route: AdminSectionsHref | `${AdminSectionsHref}/${string}`; + + /** Text of the page's main heading. Identifies the page and scopes `pageContent`. */ + protected abstract readonly title: string; + + readonly sidebar: AdminSidebar; + + readonly deleteModal: ConfirmDeleteModal; + + readonly toastMessage: ToastMessages; + + constructor(page: Page) { + super(page); + this.sidebar = new AdminSidebar(page); + this.deleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' })); + this.toastMessage = new ToastMessages(page); + } + + /** The `main` region of this page, scoped by its heading so it cannot match another route. */ + get pageContent(): Locator { + return this.page.getByRole('main').filter({ has: this.page.getByRole('heading', { name: this.title, exact: true }) }); + } + + async waitForReady(): Promise { + await this.pageContent.waitFor({ state: 'visible' }); + } + + get btnAdd(): Locator { + return this.page.getByRole('button', { name: 'Add', exact: true }); + } + + get btnBack(): Locator { + return this.page.getByRole('button', { name: 'Back', exact: true }); + } + + get btnSave(): Locator { + return this.page.getByRole('button', { name: 'Save', exact: true }); + } + + get btnNew(): Locator { + return this.page.getByRole('button', { name: 'New', exact: true }); + } + + get btnSaveChanges(): Locator { + return this.page.getByRole('button', { name: 'Save changes', exact: true }); + } + + get btnDelete(): Locator { + return this.page.getByRole('button', { name: 'Delete', exact: true }); + } + + getAccordionBtnByName(name: string): Locator { + return this.page.getByRole('button', { name, exact: true }); + } + + async adminSectionButton(href: AdminSectionsHref): Promise { + return this.page.locator(`a[href="${href}"]`); + } + + findFileRowByUsername(username: string) { + return this.page.locator('tr', { has: this.page.getByRole('cell', { name: username }) }); + } + + findFileCheckboxByUsername(username: string) { + return this.findFileRowByUsername(username).locator('label', { has: this.page.getByRole('checkbox') }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts new file mode 100644 index 0000000..8619da5 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -0,0 +1,194 @@ +import type { FrameLocator, Locator, Page } from '@playwright/test'; + +import { expect } from '../utils/test'; + +abstract class Main { + protected abstract readonly page: Page; + + constructor(protected root: Locator) {} + + /** + * Navigates to `url` and waits for this screen to render. + * + * Auth screens are what an unauthenticated visitor is bounced to, so the url a test navigates to + * is usually not the one it lands on — hence the parameter. Pass `until` when the route settles + * on something else, e.g. a login iframe replacing the login form. + */ + async goto(url = '/home', until?: Locator): Promise { + await this.page.goto(url); + + if (until) { + await expect(until).toBeVisible(); + return; + } + + await this.waitForDisplay(); + } + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } +} + +export class Authenticated extends Main { + constructor(protected page: Page) { + super(page.locator('#main-content')); + } +} + +class LoginForm { + readonly root: Locator; + + constructor(protected page: Page) { + this.root = page?.getByRole('form', { name: 'Login' }); + } + + get username(): Locator { + return this.root.locator('role=textbox[name=/username/i]'); + } + + get inputPassword(): Locator { + return this.root.getByRole('textbox', { name: 'Password', exact: true }); + } + + get btnLogin(): Locator { + return this.root.getByRole('button', { name: 'Login', exact: true }); + } + + async fillForm(username: string, password: string) { + await this.username.fill(username); + await this.inputPassword.fill(password); + } +} + +export class Login extends Main { + readonly form: LoginForm; + + constructor(protected page: Page) { + super(page.getByRole('main', { name: 'Login', exact: true })); + this.form = new LoginForm(page); + } + + async login(username: string, password: string) { + await this.form.fillForm(username, password); + await this.form.btnLogin.click(); + } + + get loginIframe(): FrameLocator { + return this.page.frameLocator('iframe[title="Login"]'); + } + + get loginIframeForm(): Locator { + return this.loginIframe.locator('#login-form'); + } + + get loginIframeSubmitButton(): Locator { + return this.loginIframe.locator('#submit'); + } + + get loginIframeError(): Locator { + return this.loginIframe.locator('#login-error', { hasText: 'Login failed' }); + } +} + +export class Registration extends Main { + constructor(protected page: Page) { + super(page.getByRole('main')); + } + + get btnSendInstructions(): Locator { + return this.page.locator('role=button[name="Send instructions"]'); + } + + get btnReset(): Locator { + return this.page.locator('role=button[name="Reset"]'); + } + + get btnLoginWithSaml(): Locator { + return this.page.locator('role=button[name="SAML test login button"]'); + } + + get btnLoginWithGoogle(): Locator { + return this.page.locator('role=button[name="Sign in with Google"]'); + } + + get btnLoginWithCustomOAuth(): Locator { + return this.page.locator('role=button[name="Sign in with Test"]'); + } + + get goToRegister(): Locator { + return this.page.locator('role=link[name="Create an account"]'); + } + + get btnRegister(): Locator { + return this.page.locator('role=button[name="Join your team"]'); + } + + get btnRegisterConfirmUsername(): Locator { + return this.page.locator('role=button[name="Use this username"]'); + } + + get btnForgotPassword(): Locator { + return this.page.locator('role=link[name="Forgot your password?"]'); + } + + get username(): Locator { + return this.page.locator('role=textbox[name=/username/i]'); + } + + get inputName(): Locator { + return this.page.locator('[name=name]'); + } + + get inputEmail(): Locator { + return this.page.locator('role=textbox[name=/Email/]'); + } + + get inputPassword(): Locator { + return this.page.locator('[name=password]'); + } + + get inputReason(): Locator { + return this.page.locator('role=textbox[name=/Reason/]'); + } + + get inputPasswordConfirm(): Locator { + return this.page.locator('[name=passwordConfirmation]'); + } + + get forgotPasswordEmailCallout(): Locator { + return this.page.locator('role=status'); + } + + get registrationDisabledCallout(): Locator { + return this.page.getByRole('status').filter({ hasText: 'New user registration is currently disabled' }); + } + + /** Opens an invite url, e.g. `/invite/`. */ + async gotoInvite(inviteId: string): Promise { + await this.goto(`/invite/${inviteId}`); + } + + /** + * Opens the secret registration url, e.g. `/register/`. + * + * Depending on `Accounts_RegistrationForm` the page settles on the register form or on an + * invalid-secret callout, and neither renders a `main` landmark - so the caller says which it expects. + */ + async gotoWithSecret(secret: string, until: Locator): Promise { + await this.goto(`/register/${secret}`, until); + } + + /** Opens the password reset url for a token. */ + async gotoResetPassword(token: string): Promise { + await this.goto(`/reset-password/${token}`, this.inputPassword); + } + + get registrationInvalidUrlCallout(): Locator { + return this.page.getByRole('status').filter({ hasText: 'The URL provided is invalid' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/directory.ts b/apps/meteor/tests/e2e/page-objects/directory.ts new file mode 100644 index 0000000..e9dc0c3 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/directory.ts @@ -0,0 +1,28 @@ +import type { Locator } from '@playwright/test'; + +import { RoutedPage } from './routed-page'; + +export class Directory extends RoutedPage { + protected readonly route = '/directory'; + + async waitForReady(): Promise { + await this.directoryHeader.waitFor({ state: 'visible' }); + } + + async searchChannel(name: string) { + await this.page.getByRole('textbox', { name: 'Search' }).fill(name); + } + + getSearchByChannelName(name: string) { + return this.page.locator(`role=table >> role=link >> text="${name}"`); + } + + async openChannel(name: string) { + await this.searchChannel(name); + await this.getSearchByChannelName(name).click(); + } + + private get directoryHeader(): Locator { + return this.page.locator('main').getByRole('heading', { name: 'Directory', exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/encrypted-room.ts b/apps/meteor/tests/e2e/page-objects/encrypted-room.ts new file mode 100644 index 0000000..cd3be94 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/encrypted-room.ts @@ -0,0 +1,51 @@ +import type { Page } from '@playwright/test'; + +import { EncryptedRoomToolbar, HomeContent } from './fragments'; +import { Message } from './fragments/message'; +import { DisableRoomEncryptionModal, EnableRoomEncryptionModal } from './fragments/modals'; + +export class EncryptedRoomPage extends HomeContent { + readonly toolbar: EncryptedRoomToolbar; + + constructor(page: Page) { + super(page); + this.toolbar = new EncryptedRoomToolbar(page); + } + + get encryptedTitle() { + return this.page.getByRole('button', { name: '- encrypted' }); + } + + get encryptionNotReadyIndicator() { + return this.page.getByText("You're sending an unencrypted message"); + } + + get lastMessage() { + return new Message(this.lastUserMessage); + } + + lastNthMessage(index: number) { + return new Message(this.nthMessage(-index - 1)); + } + + async enableEncryption() { + const enableRoomEncryptionModal = new EnableRoomEncryptionModal(this.page); + + await this.toolbar.openMoreOptions(); + await this.toolbar.menuItemEnableE2EEncryption.click(); + await enableRoomEncryptionModal.enable(); + } + + async disableEncryption() { + const disableRoomEncryptionModal = new DisableRoomEncryptionModal(this.page); + + await this.toolbar.openMoreOptions(); + await this.toolbar.menuItemDisableE2EEncryption.click(); + await disableRoomEncryptionModal.disable(); + } + + async showExportMessagesTab() { + await this.toolbar.openMoreOptions(); + await this.toolbar.menuItemExportMessages.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/composer.ts b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts new file mode 100644 index 0000000..5dbe783 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts @@ -0,0 +1,137 @@ +import type { Locator, Page } from 'playwright-core'; + +import { Popup } from './popup'; + +class VideoRecorderPopup extends Popup { + constructor(root: Locator) { + super(root); + } + + get btnRecord(): Locator { + return this.root.getByRole('button', { name: 'Record' }); + } + + get btnStopRecording(): Locator { + return this.root.getByRole('button', { name: 'Stop Recording' }); + } + + get btnSend(): Locator { + return this.root.getByRole('button', { name: 'Send' }); + } + + async record() { + await this.btnRecord.click(); + await this.btnStopRecording.waitFor({ state: 'visible' }).then(() => this.btnStopRecording.click()); + await this.btnSend.click(); + await this.waitForDismissal(); + } +} + +export abstract class Composer { + readonly videoRecorderPopup: VideoRecorderPopup; + + constructor(protected root: Locator) { + this.root = root; + this.videoRecorderPopup = new VideoRecorderPopup(this.root.page().getByRole('dialog', { name: 'Video record' })); + } + + get inputMessage(): Locator { + return this.root.locator('[name="msg"]'); + } + + get btnJoinRoom(): Locator { + return this.root.getByRole('button', { name: 'Join' }); + } + + get toolbarPrimaryActions(): Locator { + return this.root.getByRole('toolbar', { name: 'Composer Primary Actions' }); + } + + get allPrimaryActions(): Locator { + return this.toolbarPrimaryActions.getByRole('button'); + } + + get btnEmoji(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Emoji', exact: true }); + } + + get btnAudioMessage(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Audio message' }); + } + + getFileByName(fileName: string): Locator { + return this.root.getByRole('group', { name: fileName, exact: true }); + } + + get groupUploads() { + return this.root.getByRole('group', { name: 'Uploads', exact: true }); + } + + getFilesInComposer(): Locator { + return this.groupUploads.getByRole('group', { name: /^(?!Close$)/ }); + } + + async removeFileByName(fileName: string): Promise { + return this.getFileByName(fileName).getByRole('button', { name: 'Remove', exact: true }).click(); + } + + get btnSend(): Locator { + return this.root.getByRole('button', { name: 'Send' }); + } + + get btnCancel(): Locator { + return this.root.getByRole('button', { name: 'Cancel', exact: true }); + } + + get btnOptionFileUpload(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Upload file' }); + } + + get btnVideoMessage(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Video message' }); + } + + get btnMenuMoreActions() { + return this.toolbarPrimaryActions.getByRole('button', { name: 'More actions' }); + } + + get btnItalicFormatter(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Italic', exact: true }); + } + + get btnBoldFormatter(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Bold', exact: true }); + } + + get btnLinkFormatter(): Locator { + return this.toolbarPrimaryActions.getByRole('button', { name: 'Link', exact: true }); + } + + get boxPopup(): Locator { + return this.root.locator('[role="menu"][name="ComposerBoxPopup"]'); + } + + get readOnlyFooter(): Locator { + return this.root.getByText('This room is read only'); + } + + get typingIndicator(): Locator { + return this.root.getByRole('status').getByText(/typing/i); + } + + get addLinkModal(): Locator { + return this.root.page().getByRole('dialog', { name: 'Add link' }); + } +} + +export class RoomComposer extends Composer { + constructor(page: Page) { + super(page.locator('footer[aria-label="Room composer"]')); + } +} + +export class ThreadComposer extends Composer { + constructor(page: Page) { + super(page.locator('footer[aria-label="Thread composer"]')); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/e2ee.ts b/apps/meteor/tests/e2e/page-objects/fragments/e2ee.ts new file mode 100644 index 0000000..783048e --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/e2ee.ts @@ -0,0 +1,60 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Navbar } from './navbar'; +import { expect } from '../../utils/test'; + +abstract class E2EEBanner { + constructor(protected root: Locator) {} + + click() { + return this.root.click(); + } + + async waitForDisappearance() { + await expect(this.root).not.toBeVisible(); + } +} + +export class SaveE2EEPasswordBanner extends E2EEBanner { + constructor(page: Page) { + super(page.getByRole('button', { name: 'Save your new E2EE password' })); + } +} + +export class EnterE2EEPasswordBanner extends E2EEBanner { + constructor(page: Page) { + // TODO: there is a typo in the default translation + super(page.getByRole('button', { name: 'Enter your E2E password' })); + } +} + +export class E2EEKeyDecodeFailureBanner extends E2EEBanner { + constructor(page: Page) { + super(page.getByRole('button', { name: "Wasn't possible to decode your encryption key to be imported." })); + } + + async expectToNotBeVisible() { + await expect(this.root).not.toBeVisible(); + } +} + +export class CreateE2EEChannel { + private readonly navbar: Navbar; + + constructor(page: Page) { + this.navbar = new Navbar(page); + } + + async create(name: string): Promise { + await this.navbar.createEncryptedChannel(name); + } + + async createAndStore(name: string, createdChannels: string[]): Promise { + await this.create(name); + this.store(name, createdChannels); + } + + store(name: string, createdChannels: string[]): void { + createdChannels.push(name); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-edit-room-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-edit-room-flextab.ts new file mode 100644 index 0000000..fe0d520 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-edit-room-flextab.ts @@ -0,0 +1,49 @@ +import type { Locator } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class EditAdminRoomFlexTab extends FlexTab { + constructor(locator: Locator) { + super(locator); + } + + get roomNameInput(): Locator { + return this.root.locator('input[name="roomName"]'); + } + + get privateLabel(): Locator { + return this.root.locator(`label >> text=Private`); + } + + get privateInput(): Locator { + return this.root.locator('input[name="roomType"]'); + } + + get roomOwnerInput(): Locator { + return this.root.locator('input[name="roomOwner"]'); + } + + get archivedLabel(): Locator { + return this.root.locator('label >> text=Archived'); + } + + get archivedInput(): Locator { + return this.root.locator('input[name="archived"]'); + } + + get favoriteLabel(): Locator { + return this.root.locator('label >> text=Favorite'); + } + + get favoriteInput(): Locator { + return this.root.locator('input[name="favorite"]'); + } + + get defaultLabel(): Locator { + return this.root.locator('label >> text=Default'); + } + + get defaultInput(): Locator { + return this.root.locator('input[name="isDefault"]'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-flextab-emoji.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-flextab-emoji.ts new file mode 100644 index 0000000..5821a77 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/admin-flextab-emoji.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class AddEmojiFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Add New Emoji' })); + } +} + +export class EditEmojiFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Custom Emoji Info' })); + } + + async delete() { + await this.btnDelete.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/auto-translate-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/auto-translate-flextab.ts new file mode 100644 index 0000000..163d436 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/auto-translate-flextab.ts @@ -0,0 +1,28 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class AutoTranslateFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Auto-Translate' })); + } + + get checkboxAutomaticTranslation(): Locator { + return this.root.getByRole('checkbox', { name: 'Automatic Translation' }); + } + + get textAutomaticTranslation(): Locator { + return this.root.getByText('Automatic Translation', { exact: true }); + } + + get textEncryptedRoomCallout(): Locator { + return this.root.getByText('Automatic translation not available'); + } + + async setAutomaticTranslation(enabled: boolean): Promise { + if ((await this.checkboxAutomaticTranslation.isChecked()) === enabled) { + return; + } + await this.textAutomaticTranslation.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/channels-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/channels-flextab.ts new file mode 100644 index 0000000..f3f8dc7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/channels-flextab.ts @@ -0,0 +1,83 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { Listbox } from '../listbox'; +import { ConfirmDeleteRoomModal, ConfirmRemoveModal } from '../modals'; +import { Modal } from '../modals/modal'; + +class AddExistingChannelModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Add Existing Channels' })); + } + + get inputChannels(): Locator { + return this.root.getByRole('textbox'); + } + + get btnAdd(): Locator { + return this.root.getByRole('button', { name: 'Add', exact: true }); + } + + async confirmAdd() { + await this.btnAdd.click(); + await this.waitForDismissal(); + } +} + +export class ChannelsFlexTab extends FlexTab { + readonly confirmRemoveModal: ConfirmRemoveModal; + + readonly confirmDeleteModal: ConfirmDeleteRoomModal; + + readonly addExistingChannelModal: AddExistingChannelModal; + + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Team Channels' })); + this.confirmRemoveModal = new ConfirmRemoveModal(page.getByRole('dialog', { name: 'Are you sure?' })); + this.confirmDeleteModal = new ConfirmDeleteRoomModal(page); + this.addExistingChannelModal = new AddExistingChannelModal(page); + this.listbox = new Listbox(page); + } + + get btnAddExisting(): Locator { + return this.root.getByRole('button', { name: 'Add Existing', exact: true }); + } + + get btnCreateNew(): Locator { + return this.root.getByRole('button', { name: 'Create new', exact: true }); + } + + get channelsList(): Locator { + return this.root.getByRole('list'); + } + + getListboxOption(name: string): Locator { + return this.listbox.getOption(name); + } + + channelOption(name: string) { + return this.root.locator('li', { hasText: name }); + } + + async openChannelOptionMoreActions(name: string) { + await this.channelOption(name).hover(); + await this.channelOption(name).getByRole('button', { name: 'More' }).click(); + } + + async confirmRemoveChannel() { + return this.confirmRemoveModal.confirmRemove(); + } + + async confirmDeleteRoom() { + return this.confirmDeleteModal.confirmDelete(); + } + + async addExistingChannel(name: string) { + await this.btnAddExisting.click(); + await this.addExistingChannelModal.inputChannels.fill(name); + await this.listbox.selectOption(name); + await this.addExistingChannelModal.confirmAdd(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/device-info-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/device-info-flextab.ts new file mode 100644 index 0000000..7d88db9 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/device-info-flextab.ts @@ -0,0 +1,17 @@ +import type { Locator } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class DeviceInfoFlexTab extends FlexTab { + constructor(locator: Locator) { + super(locator); + } + + get btnLogoutDevice(): Locator { + return this.root.getByRole('button', { name: 'Log out device' }); + } + + getDeviceInfoId(deviceId: string): Locator { + return this.root.getByText(deviceId, { exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-contact-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-contact-flextab.ts new file mode 100644 index 0000000..05afd75 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-contact-flextab.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class OmnichannelEditContactFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'contact' })); + } + + get inputEmail(): Locator { + return this.root.locator('input[name="emails.0.address"]'); + } + + get inputPhone(): Locator { + return this.root.locator('input[name="phones.0.phoneNumber"]'); + } + + get inputContactManager(): Locator { + return this.root.locator('input[name=contactManager]'); + } + + get btnAddEmail(): Locator { + return this.root.locator('role=button[name="Add email"]'); + } + + get btnAddPhone(): Locator { + return this.root.locator('role=button[name="Add phone"]'); + } + + getErrorMessage(message: string): Locator { + return this.root.locator(`role=alert >> text="${message}"`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-room-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-room-flextab.ts new file mode 100644 index 0000000..5dd6fe7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-room-flextab.ts @@ -0,0 +1,114 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { Listbox } from '../listbox'; + +export class EditRoomFlexTab extends FlexTab { + constructor(locator: Locator) { + super(locator); + } + + get inputTopic(): Locator { + return this.root.getByRole('textbox', { name: 'Topic' }); + } + + get inputAnnouncement(): Locator { + return this.root.getByRole('textbox', { name: 'Announcement' }); + } + + get inputDescription(): Locator { + return this.root.getByRole('textbox', { name: 'Description' }); + } + + get checkboxReadOnly(): Locator { + return this.root.locator('label', { hasText: 'Read-only' }); + } + + get calloutRetentionPolicy(): Locator { + return this.root.getByRole('alert', { name: 'Retention policy warning callout' }); + } + + get advancedSettingsAccordion(): Locator { + return this.root.getByRole('button', { name: 'Advanced settings' }); + } + + get pruneAccordion(): Locator { + return this.root.getByRole('button', { name: 'Prune', exact: true }); + } + + getMaxAgeLabel(maxAge = '30') { + return this.root.getByText(`Maximum message age in days (default: ${maxAge})`); + } + + get inputRetentionMaxAge(): Locator { + return this.root.locator('input[name="retentionMaxAge"]'); + } + + get checkboxPruneMessages(): Locator { + return this.root.locator('label', { hasText: 'Automatically prune old messages' }); + } + + get checkboxOverrideGlobalRetention(): Locator { + return this.root.locator('label', { hasText: 'Override global retention policy' }); + } + + get checkboxIgnoreThreads(): Locator { + return this.root.locator('label', { hasText: 'Do not prune Threads' }); + } + + get checkboxChannels(): Locator { + return this.root.locator('label', { hasText: 'Channels' }); + } + + get checkboxDiscussions(): Locator { + return this.root.locator('label', { hasText: 'Discussions' }); + } + + async toggleSidepanelItems() { + await this.checkboxChannels.click(); + await this.checkboxDiscussions.click(); + } +} + +export class EditTeamFlexTab extends EditRoomFlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Edit team' })); + } +} + +export class OmnichannelEditRoomFlexTab extends EditRoomFlexTab { + private readonly tagsListbox: Listbox; + + private readonly slaListbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Edit Room' })); + this.tagsListbox = new Listbox(page); + this.slaListbox = new Listbox(page, 'SLA Policy'); + } + + get inputSLAPolicy(): Locator { + return this.root.getByRole('button', { name: 'SLA Policy', exact: true }); + } + + optionTag(name: string): Locator { + return this.tagsListbox.getOption(name); + } + + async selectTag(name: string) { + await this.tagsListbox.selectOption(name); + } + + async selectSLA(name: string) { + await this.inputSLAPolicy.click(); + await this.slaListbox.selectOption(name, true); + } + + get inputTags(): Locator { + return this.root.getByRole('textbox', { name: 'Select an option' }); + } + + getRoomCustomField(label: string): Locator { + return this.root.getByRole('group', { name: 'Custom Fields' }).getByLabel(label); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-user-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-user-flextab.ts new file mode 100644 index 0000000..faef290 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/edit-user-flextab.ts @@ -0,0 +1,53 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class EditUserFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog')); + } + + get btnAddUser(): Locator { + return this.root.locator('role=button[name="Add user"]'); + } + + get btnSaveUser(): Locator { + return this.root.locator('role=button[name="Save user"]'); + } + + get inputUserName(): Locator { + return this.root.getByRole('textbox', { name: 'Username', exact: true }); + } + + get inputEmail(): Locator { + return this.root.getByRole('textbox', { name: 'Email', exact: true }); + } + + get inputSetManually(): Locator { + return this.root.locator('//label[text()="Set manually"]'); + } + + get inputPassword(): Locator { + return this.root.getByPlaceholder('Password', { exact: true }); + } + + get inputConfirmPassword(): Locator { + return this.root.getByPlaceholder('Confirm password', { exact: true }); + } + + get joinDefaultChannels(): Locator { + return this.root.locator('//label[text()="Join default channels"]'); + } + + get chipUserRole(): Locator { + return this.root.getByRole('button', { name: 'user', exact: true }); + } + + get setupSmtpLink(): Locator { + return this.root.getByRole('link', { name: 'Set up SMTP' }); + } + + getCustomField(fieldName: string): Locator { + return this.root.getByRole('textbox', { name: fieldName, exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/export-messages-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/export-messages-flextab.ts new file mode 100644 index 0000000..dc3de71 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/export-messages-flextab.ts @@ -0,0 +1,87 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class ExportMessagesFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Export Messages' })); + } + + async exposeMethods() { + await this.method.click(); + } + + async setMethod(optionName: string) { + await this.exposeMethods(); + await this.root.page().getByRole('option', { name: optionName }).click(); + } + + async exposeOutputFormats() { + await this.outputFormat.click(); + } + + async setOutputFormat(optionName: string) { + await this.exposeOutputFormats(); + await this.root.page().getByRole('option', { name: optionName }).click(); + } + + getMethodOptionByName(name: string) { + return this.root.page().getByRole('option', { name }); + } + + getOutputFormatOptionByName(name: string) { + return this.root.page().getByRole('option', { name }); + } + + async selectAllMessages() { + await this.root + .page() + .getByRole('button', { name: /Select \d+ messages/ }) + .click(); + } + + async downloadMessages() { + const [download] = await Promise.all([this.root.page().waitForEvent('download'), this.downloadButton.click()]); + return download; + } + + async send() { + await this.sendButton.click(); + } + + async setAdditionalEmail(email: string) { + await this.inputAdditionalEmails.fill(email); + } + + getMessageCheckbox(messageText: string): Locator { + return this.root.page().getByRole('listitem').filter({ hasText: messageText }).getByRole('checkbox'); + } + + get inputUsers() { + return this.root.getByRole('combobox', { name: 'To users' }); + } + + get inputAdditionalEmails() { + return this.root.getByRole('textbox', { name: 'To additional emails' }); + } + + get method() { + return this.root.getByTestId('export-messages-method'); + } + + get outputFormat() { + return this.root.page().getByTestId('export-messages-output-format'); + } + + get downloadButton() { + return this.root.getByRole('button', { name: 'Download', exact: true }); + } + + get sendButton() { + return this.root.getByRole('button', { name: 'Send', exact: true }); + } + + get clearSelectionButton() { + return this.root.page().getByRole('button', { name: 'Clear selection' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/files-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/files-flextab.ts new file mode 100644 index 0000000..95f2d79 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/files-flextab.ts @@ -0,0 +1,31 @@ +import type { Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { MenuMore } from '../menu'; +import { ConfirmDeleteModal } from '../modals'; + +export class FilesFlexTab extends FlexTab { + readonly menu: MenuMore; + + readonly confirmDeleteModal: ConfirmDeleteModal; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Files' })); + this.menu = new MenuMore(page); + this.confirmDeleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' })); + } + + get fileList() { + return this.root.getByRole('list', { name: 'Files list' }); + } + + getFileByName(name: string) { + return this.fileList.getByRole('listitem').filter({ hasText: name }); + } + + async deleteFile(name: string) { + await this.getFileByName(name).getByRole('button', { name: 'More' }).click(); + await this.menu.selectMenuItem('Delete'); + await this.confirmDeleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/flextab.ts new file mode 100644 index 0000000..3c5257a --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/flextab.ts @@ -0,0 +1,56 @@ +import type { Locator } from '@playwright/test'; + +import { expect } from '../../../utils/test'; + +export abstract class FlexTab { + /** + * @param root should be protected, but for now there are a lot of tests relying on accessing it directly, * so we need to keep it public until we can refactor those tests + */ + constructor(public root: Locator) {} + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } + + private get btnClose() { + return this.root.getByRole('button', { name: 'Close', exact: true }); + } + + get inputName() { + return this.root.getByRole('textbox', { name: 'Name', exact: true }); + } + + get btnSave() { + return this.root.getByRole('button', { name: 'Save', exact: true }); + } + + get btnCancel() { + return this.root.getByRole('button', { name: 'Cancel', exact: true }); + } + + get btnDelete() { + return this.root.getByRole('button', { name: 'Delete', exact: true }); + } + + get btnReset() { + return this.root.getByRole('button', { name: 'Reset', exact: true }); + } + + errorMessage(message: string): Locator { + return this.root.locator('[role="alert"]', { hasText: message }); + } + + async close() { + await this.btnClose.click(); + await this.waitForDismissal(); + } + + async save() { + await this.btnSave.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/index.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/index.ts new file mode 100644 index 0000000..39e6837 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/index.ts @@ -0,0 +1,17 @@ +export * from './auto-translate-flextab'; +export * from './admin-flextab-emoji'; +export * from './admin-edit-room-flextab'; +export * from './channels-flextab'; +export * from './device-info-flextab'; +export * from './edit-room-flextab'; +export * from './edit-user-flextab'; +export * from './edit-contact-flextab'; +export * from './export-messages-flextab'; +export * from './files-flextab'; +export * from './members-flextab'; +export * from './notification-preferences-flextab'; +export * from './prune-messages-flextab'; +export * from './room-info-flextab'; +export * from './threads-flextab'; +export * from './search-messages-flextab'; +export * from './user-info-flextab'; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/members-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/members-flextab.ts new file mode 100644 index 0000000..79c0be6 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/members-flextab.ts @@ -0,0 +1,179 @@ +import type { Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { Listbox } from '../listbox'; +import { MenuMore } from '../menu'; +import { ConfirmRemoveModal } from '../modals'; +import { UserInfoFlexTab } from './user-info-flextab'; +import { Modal } from '../modals/modal'; + +export class ConfirmMuteModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Are you sure' })); + } + + private get btnMute() { + return this.root.getByRole('button', { name: 'Yes, mute user!' }); + } + + async confirmMute() { + await this.btnMute.click(); + await this.waitForDismissal(); + } +} + +export class RemoveTeamMemberModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Removing Member' })); + } + + get description() { + return this.root.getByText('Select the channels you want the user to be removed from'); + } + + channel(channelName: string) { + return this.root.getByText(channelName); + } + + channelCheckbox(channelName: string) { + return this.root.getByRole('row', { name: channelName }).getByRole('checkbox'); + } + + async selectChannel(channelName: string) { + await this.channel(channelName).click(); + } + + private get btnContinue() { + return this.root.getByRole('button', { name: 'Continue' }); + } + + async continue() { + await this.btnContinue.click(); + await this.waitForDismissal(); + } +} + +class AddUsersFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Add users' })); + this.listbox = new Listbox(page); + } + + private btnAddUsers() { + return this.root.getByRole('button', { name: 'Add users' }); + } + + async addUser(username: string) { + await this.root.getByRole('combobox', { name: 'Choose users' }).pressSequentially(username); + await this.listbox.selectOption(username); + await this.btnAddUsers().click(); + await this.waitForDismissal(); + } +} + +export class MembersFlexTab extends FlexTab { + readonly listbox: Listbox; + + readonly removeModal: ConfirmRemoveModal; + + readonly removeTeamMemberModal: RemoveTeamMemberModal; + + readonly menu: MenuMore; + + readonly confirmMuteModal: ConfirmMuteModal; + + readonly addUsersFlexTab: AddUsersFlexTab; + + readonly userInfo: UserInfoFlexTab; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Members' })); + this.removeModal = new ConfirmRemoveModal(page.getByRole('dialog', { name: 'Confirmation', exact: true })); + this.removeTeamMemberModal = new RemoveTeamMemberModal(page); + this.listbox = new Listbox(page); + this.menu = new MenuMore(page); + this.confirmMuteModal = new ConfirmMuteModal(page); + this.addUsersFlexTab = new AddUsersFlexTab(page); + this.userInfo = new UserInfoFlexTab(page); + } + + memberOption(username: string) { + return this.root.locator('li', { hasText: username }); + } + + getMenuItemAction(action: string) { + return this.menu.getMenuItem(action); + } + + async openMemberInfo(username: string) { + await this.memberOption(username).click(); + } + + async openMoreActions() { + await this.root.getByRole('button', { name: 'More' }).click(); + } + + async openMemberOptionMoreActions(username: string) { + await this.memberOption(username).hover(); + await this.memberOption(username).getByRole('button', { name: 'More' }).click(); + } + + async addUser(username: string) { + await this.root.getByRole('button', { name: 'Add' }).click(); + await this.addUsersFlexTab.addUser(username); + } + + private get btnInviteLink() { + return this.root.getByRole('button', { name: 'Invite Link' }); + } + + async inviteUser() { + await this.btnInviteLink.click(); + } + + async muteUser(username: string) { + await this.openMemberOptionMoreActions(username); + await this.menu.selectMenuItem('Mute user'); + await this.confirmMuteModal.confirmMute(); + } + + async unmuteUser(username: string) { + await this.openMemberOptionMoreActions(username); + await this.menu.selectMenuItem('Unmute user'); + } + + async setUserAsModerator(username: string) { + await this.openMemberOptionMoreActions(username); + await this.menu.selectMenuItem('Set as moderator'); + } + + async setUserAsOwner(username: string) { + await this.openMemberOptionMoreActions(username); + await this.menu.selectMenuItem('Set as owner'); + } + + async showAllUsers() { + await this.root.getByRole('button', { name: 'Online' }).click(); + await this.listbox.selectOption('All'); + } + + private async ignoreUserAction(action: string, username: string) { + await this.openMemberInfo(username); + await this.userInfo.openMoreActions(); + await this.userInfo.menu.selectMenuItem(action); + } + + async ignoreUser(username: string) { + await this.ignoreUserAction('Ignore', username); + } + + async unignoreUser(username: string) { + await this.ignoreUserAction('Unignore', username); + } + + async confirmRemoveUser() { + return this.removeModal.confirmRemove(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/notification-preferences-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/notification-preferences-flextab.ts new file mode 100644 index 0000000..30d9f65 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/notification-preferences-flextab.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { Listbox } from '../listbox'; + +export class NotificationPreferencesFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Notifications Preferences' })); + this.listbox = new Listbox(page); + } + + getPreferenceByDevice(device: string): Locator { + return this.root.locator(`//div[@id="${device}Alert"]`); + } + + async selectDropdownById(text: string): Promise { + await this.root.locator(`//div[@id="${text}"]`).click(); + } + + async selectOptionByLabel(text: string): Promise { + await this.listbox.selectOption(text); + } + + async selectDevice(text: string): Promise { + await this.root.getByRole('button', { name: text }).click(); + } + + async updateDevicePreference(device: string): Promise { + await this.selectDevice(device); + await this.selectDropdownById(`${device}Alert`); + await this.selectOptionByLabel('Mentions'); + } + + async updateAllNotificationPreferences(): Promise { + await this.updateDevicePreference('Desktop'); + await this.updateDevicePreference('Mobile'); + await this.updateDevicePreference('Email'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/prune-messages-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/prune-messages-flextab.ts new file mode 100644 index 0000000..33e1d23 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/prune-messages-flextab.ts @@ -0,0 +1,37 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { Modal } from '../modals/modal'; + +class ConfirmPruneMessageModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Are you sure?' })); + } + + async pruneConfirm(): Promise { + await this.root.getByRole('button', { name: 'Yes, prune them!' }).click(); + await this.waitForDismissal(); + } +} + +export class PruneMessagesFlexTab extends FlexTab { + readonly confirmPruneModal: ConfirmPruneMessageModal; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Prune Messages' })); + this.confirmPruneModal = new ConfirmPruneMessageModal(page); + } + + get labelDoNotPrunePinned(): Locator { + return this.root.locator('label', { hasText: 'Do not prune pinned messages' }); + } + + get labelFilesOnly(): Locator { + return this.root.locator('label', { hasText: 'Only remove the attached files, keep messages' }); + } + + async prune(): Promise { + await this.root.getByRole('button', { name: 'Prune' }).click(); + await this.confirmPruneModal.pruneConfirm(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/room-info-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/room-info-flextab.ts new file mode 100644 index 0000000..9148bff --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/room-info-flextab.ts @@ -0,0 +1,124 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { MenuMore } from '../menu'; +import { ConfirmDeleteTeamModal, ConfirmDeleteRoomModal } from '../modals'; +import { Modal } from '../modals/modal'; + +class ConfirmLeaveRoomModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Confirmation', exact: true })); + } + + private get btnLeave() { + return this.root.getByRole('button', { name: 'Leave', exact: true }); + } + + async confirmLeave() { + await this.btnLeave.click(); + await this.waitForDismissal(); + } +} + +export class RoomInfoFlexTab extends FlexTab { + readonly menu: MenuMore; + + readonly confirmLeaveModal: ConfirmLeaveRoomModal; + + readonly confirmDeleteModal: ConfirmDeleteRoomModal; + + constructor(root: Locator) { + super(root); + this.menu = new MenuMore(root.page()); + this.confirmLeaveModal = new ConfirmLeaveRoomModal(root.page()); + this.confirmDeleteModal = new ConfirmDeleteRoomModal(root.page()); + } + + get btnEdit(): Locator { + return this.root.getByRole('button', { name: 'Edit' }); + } + + get btnLeave(): Locator { + return this.root.getByRole('button', { name: 'Leave' }); + } + + get btnMore(): Locator { + return this.root.getByRole('button', { name: 'More' }); + } + + get optionDelete(): Locator { + return this.menu.getMenuItem('Delete'); + } + + async leaveRoom() { + await this.btnLeave.click(); + await this.confirmLeaveModal.confirmLeave(); + } + + async deleteRoom() { + await this.btnMore.click(); + await this.menu.selectMenuItem('Delete'); + await this.confirmDeleteModal.confirmDelete(); + } +} + +class ConfirmConvertIntoChannelModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Confirmation', exact: true })); + } + + private get btnConvert() { + return this.root.getByRole('button', { name: 'Convert', exact: true }); + } + + async confirmConvert() { + await this.btnConvert.click(); + await this.waitForDismissal(); + } +} + +export class TeamInfoFlexTab extends RoomInfoFlexTab { + readonly confirmDeleteTeamModal: ConfirmDeleteTeamModal; + + readonly confirmConvertIntoChannelModal: ConfirmConvertIntoChannelModal; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Team info' })); + this.confirmDeleteTeamModal = new ConfirmDeleteTeamModal(page); + this.confirmConvertIntoChannelModal = new ConfirmConvertIntoChannelModal(page); + } + + async deleteTeam() { + await this.btnMore.click(); + await this.menu.selectMenuItem('Delete'); + return this.confirmDeleteTeamModal.confirmDelete(); + } + + async convertIntoChannel() { + await this.btnMore.click(); + await this.menu.selectMenuItem('Convert to Channel'); + await this.confirmConvertIntoChannelModal.confirmConvert(); + } +} + +export class OmnichannelRoomInfoFlexTab extends RoomInfoFlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Room Information' })); + } + + getInfo(value: string): Locator { + return this.root.locator(`span >> text="${value}"`); + } + + getLabel(label: string): Locator { + return this.root.locator(`div >> text="${label}"`); + } + + getInfoByLabel(label: string): Locator { + return this.root.getByLabel(label); + } + + getTagInfoByLabel(label: string): Locator { + return this.root.getByRole('list', { name: 'Tags' }).getByText(label, { exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/search-messages-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/search-messages-flextab.ts new file mode 100644 index 0000000..7449762 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/search-messages-flextab.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class SearchMessagesFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Search Messages' })); + } + + async search(text: string, { global = false }: { global?: boolean } = {}) { + if (global) { + await this.root.getByText('Global search').click(); + } + await this.root.getByPlaceholder('Search Messages').fill(text); + } + + async getResultItem(messageText: string) { + return this.root.getByRole('listitem', { name: messageText }); + } + + async jumpToMessage(messageText: string) { + const message = (await this.getResultItem(messageText)).first(); + await message.hover(); + await message.getByRole('button', { name: 'Jump to message' }).click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/threads-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/threads-flextab.ts new file mode 100644 index 0000000..b1e4ce6 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/threads-flextab.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; + +export class ThreadsFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog')); + } + + private get listThreadMessages(): Locator { + return this.root.getByRole('list', { name: 'Thread message list' }); + } + + get lastThreadMessage(): Locator { + return this.listThreadMessages.locator('[data-qa-type="message"]').last().locator('[data-qa-type="message-body"]'); + } + + getThreadMessageByText(messageText: string): Locator { + return this.listThreadMessages.getByRole('listitem').getByText(messageText); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/flextabs/user-info-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/user-info-flextab.ts new file mode 100644 index 0000000..f66ac4b --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/flextabs/user-info-flextab.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from './flextab'; +import { MenuMore } from '../menu'; + +export class UserInfoFlexTab extends FlexTab { + readonly menu: MenuMore; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'User Info' })); + this.menu = new MenuMore(page); + } + + get btnEdit(): Locator { + return this.root.getByRole('button', { name: 'Edit' }); + } + + get btnMoreActions(): Locator { + return this.root.getByRole('button', { name: 'More' }); + } + + get menuItemDeleteUser(): Locator { + return this.menu.root.getByRole('menuitem', { name: 'Delete' }); + } + + get username(): Locator { + return this.root.getByLabel('Username'); + } + + async openMoreActions() { + await this.btnMoreActions.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts new file mode 100644 index 0000000..73fc682 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -0,0 +1,680 @@ +import fs from 'fs/promises'; +import { resolve, join, relative } from 'node:path'; + +import type { Locator, Page } from '@playwright/test'; + +import { RoomComposer, ThreadComposer } from './composer'; +import { RoomHeader } from './room-header'; +import { createMediaResponsePromise } from '../../fixtures/responses/mediaResponse'; +import { expect } from '../../utils/test'; + +const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../../fixtures/files')); + +export function getFilePath(fileName: string): string { + return join(FIXTURES_PATH, fileName); +} + +export class HomeContent { + protected readonly page: Page; + + readonly header: RoomHeader; + + readonly composer: RoomComposer; + + protected readonly threadComposer: ThreadComposer; + + constructor(page: Page) { + this.page = page; + this.composer = new RoomComposer(page); + this.threadComposer = new ThreadComposer(page); + this.header = new RoomHeader(page.locator('main header')); + } + + get channelHeader(): Locator { + return this.header.root; + } + + get burgerButton(): Locator { + return this.channelHeader.getByRole('button', { name: 'Open sidebar' }); + } + + get channelRetentionPolicyWarning(): Locator { + return this.page.locator('main').getByRole('alert', { name: 'Retention policy warning banner' }); + } + + get messagePopupUsers(): Locator { + return this.page.locator('role=menu[name="People"]'); + } + + get mainMessageList(): Locator { + return this.page.getByRole('list', { name: 'Message list', exact: true }); + } + + get threadMessageList(): Locator { + return this.page.getByRole('list', { name: 'Thread message list', exact: true }); + } + + /** The open room, matched by id. Use it to assert a permalink landed in the room it should. */ + getRoomById(roomId: string): Locator { + return this.page.locator(`[data-qa-rc-room="${roomId}"]`); + } + + get messageListItems(): Locator { + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"]'); + } + + get mainMessageListScroller(): Locator { + return this.page.locator('[data-overlayscrollbars]', { has: this.mainMessageList }); + } + + get threadMessageListScroller(): Locator { + return this.page.locator('[data-overlayscrollbars]', { has: this.threadMessageList }); + } + + get systemMessageListItems(): Locator { + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="system message"]'); + } + + get threadMessageListItems(): Locator { + return this.threadMessageList.locator('[role="listitem"][aria-roledescription="thread message"]'); + } + + get lastUserMessage(): Locator { + return this.messageListItems.last(); + } + + get lastUserThreadMessage(): Locator { + return this.threadMessageListItems.last(); + } + + get lastThreadMessagePreview(): Locator { + return this.page.getByRole('listitem').locator('[role="link"][aria-roledescription="thread message preview"]').last(); + } + + get lastUserMessageDownloadLink(): Locator { + return this.lastUserMessage.getByRole('link', { name: 'Download' }); + } + + nthMessage(index: number): Locator { + return this.messageListItems.nth(index); + } + + get lastUserMessageBody(): Locator { + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); + } + + get lastUserMessageAttachment(): Locator { + return this.page.locator('[role="document"][aria-roledescription="message attachment"]').last(); + } + + get lastUserMessageNotSequential(): Locator { + return this.mainMessageList.locator('[role="listitem"][aria-roledescription="message"][data-sequential="false"]').last(); + } + + get encryptedRoomHeaderIcon(): Locator { + return this.page.locator('.rcx-room-header i.rcx-icon--name-key'); + } + + get lastIgnoredUserMessage(): Locator { + return this.lastUserMessageBody.locator('role=button[name="This message was ignored"]'); + } + + get lastIgnoredThreadMessage(): Locator { + return this.lastUserThreadMessage.getByRole('button', { name: 'This message was ignored' }); + } + + get ignoredThreadMessages(): Locator { + return this.threadMessageListItems.getByRole('button', { name: 'This message was ignored' }); + } + + async joinRoomIfNeeded(): Promise { + if (await this.composer.inputMessage.isEnabled()) { + return; + } + if (!(await this.composer.btnJoinRoom.isVisible())) { + return; + } + await this.composer.btnJoinRoom.click(); + } + + async sendMessage(text: string, enforce = true): Promise { + await this.joinRoomIfNeeded(); + await expect(this.composer.inputMessage).toBeEnabled(); + await this.composer.inputMessage.fill(text); + + if (!enforce) { + await this.composer.btnSend.click(); + return; + } + + // Wait for the message to settle in the DOM. Transport-agnostic — the + // message may be sent via REST (`/api/v1/method.call/sendMessage`) or + // via DDP/WS depending on whether DDPSDK is the active dispatcher; + // either way Meteor's optimistic insert renders the new list item + // before the server confirms, and the `rcx-message--pending` class + // drops once the server result lands. + if ((await this.messageListItems.count()) > 0) { + const lastMessageBeforeSend = await this.messageListItems.last().getAttribute('id'); + + await this.composer.btnSend.click(); + + await expect.poll(() => this.messageListItems.last().getAttribute('id'), { timeout: 10_000 }).not.toEqual(lastMessageBeforeSend); + await expect(this.lastUserMessage).not.toHaveClass(/rcx-message--pending/); + } else { + // No previous messages, nothing to compare + await this.composer.btnSend.click(); + await expect(this.lastUserMessage).not.toHaveClass(/rcx-message--pending/); + } + } + + async dispatchSlashCommand(text: string): Promise { + await this.joinRoomIfNeeded(); + await expect(this.composer.inputMessage).toBeEnabled(); + + await this.composer.inputMessage.fill(''); + await this.composer.inputMessage.fill(text); + await this.page.keyboard.press('Enter'); + await this.page.keyboard.press('Enter'); + } + + async forwardMessage(chatName: string) { + await this.messageListItems.last().hover(); + await this.page.locator('role=button[name="Forward message"]').click(); + + await this.page.getByRole('textbox', { name: 'Person or Channel', exact: true }).click(); + await this.page.keyboard.type(chatName); + await this.page.locator('#position-container').getByText(chatName).waitFor(); + await this.page.locator('#position-container').getByText(chatName).click(); + await this.page.locator('role=button[name="Forward"]').click(); + } + + // TODO: use modal fragments ----------------------------------------- + get btnModalCancel(): Locator { + return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--secondary'); + } + + private get fileUploadModal(): Locator { + return this.page.getByRole('dialog', { name: 'File Upload' }); + } + + get btnModalConfirm(): Locator { + return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary'); + } + + get btnModalConfirmDelete(): Locator { + return this.page.getByRole('button', { name: 'Yes, delete', exact: true }); + } + + get btnCancelQuotePreview(): Locator { + return this.page.getByRole('button', { name: 'Dismiss quoted message' }); + } + + get getFileDescription(): Locator { + return this.lastUserMessage.locator('[role="document"][aria-roledescription="message body"]'); + } + + get inputFileUploadName(): Locator { + return this.fileUploadModal.getByRole('textbox', { name: 'File name' }); + } + + get btnUpdateFileUpload(): Locator { + return this.fileUploadModal.getByRole('button', { name: 'Update' }); + } + + get btnCancelUpdateFileUpload(): Locator { + return this.fileUploadModal.getByRole('button', { name: 'Cancel' }); + } + + // ----------------------------------------- + + getLastMessageByFileName(filename: string): Locator { + return this.messageListItems + .filter({ has: this.page.getByRole('link', { name: filename }) }) + .last() + .getByRole('link', { name: filename }); + } + + get lastMessageTextAttachment(): Locator { + return this.messageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]'); + } + + get lastMessageTextAttachmentEqualsText(): Locator { + return this.messageListItems.last().locator('.rcx-attachment__details .rcx-message-body'); + } + + get btnQuoteMessage(): Locator { + return this.page.getByRole('button', { name: 'Quote' }); + } + + get quotePreview(): Locator { + return this.page.locator('footer blockquote'); + } + + get quotedMessage(): Locator { + return this.page.getByRole('blockquote'); + } + + quotedFileDescription(fileDescription: string): Locator { + return this.quotedMessage.getByText(fileDescription); + } + + quotedFileName(fileName: string): Locator { + return this.quotedMessage.getByTitle(fileName); + } + + threadMessageQuotedFileDescription(fileDescription: string): Locator { + return this.threadQuotedMessage.getByText(fileDescription); + } + + threadMessageQuotedFileName(fileName: string): Locator { + return this.threadQuotedMessage.getByTitle(fileName); + } + + get linkPreview(): Locator { + return this.lastUserMessage.getByText('Link Preview'); + } + + quotedLinkText(name: string): Locator { + return this.quotedMessage.getByRole('link', { name }); + } + + get threadQuotedMessage(): Locator { + return this.page.getByRole('dialog').getByRole('blockquote'); + } + + get threadQuotePreview(): Locator { + return this.page.getByRole('dialog').locator('footer blockquote'); + } + + get lastThreadMessageTextAttachmentEqualsText(): Locator { + return this.threadMessageListItems.last().locator('.rcx-attachment__details'); + } + + get mainThreadMessageText(): Locator { + return this.threadMessageListItems.first(); + } + + get lastThreadMessagePreviewText(): Locator { + return this.page.locator('div.messages-box .messages-list [role=link]').last(); + } + + get lastThreadMessageFileDescription(): Locator { + return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message body"]'); + } + + getLastThreadMessageByFileName(filename: string): Locator { + return this.threadMessageListItems.last().getByRole('link', { name: filename }); + } + + // TODO: improve locator specificity + get menuMore(): Locator { + return this.page.getByRole('menu', { name: 'More', exact: true }); + } + + get lastThreadMessageTextAttachment(): Locator { + return this.threadMessageListItems.last().locator('[role="document"][aria-roledescription="message attachment"]'); + } + + get btnOptionEditMessage(): Locator { + return this.menuMore.getByRole('menuitem', { name: 'Edit', exact: true }); + } + + get btnOptionDeleteMessage(): Locator { + return this.menuMore.getByRole('menuitem', { name: 'Delete', exact: true }); + } + + get btnOptionPinMessage(): Locator { + return this.menuMore.getByRole('menuitem', { name: 'Pin', exact: true }); + } + + get btnOptionStarMessage(): Locator { + return this.menuMore.getByRole('menuitem', { name: 'Star', exact: true }); + } + + get btnOptionReplyInDm(): Locator { + return this.menuMore.getByRole('menuitem', { name: 'Reply in direct message', exact: true }); + } + + get btnVoiceCall(): Locator { + return this.primaryRoomActionsToolbar.getByRole('button', { name: 'Voice call' }); + } + + get btnContactInformation(): Locator { + return this.page.getByRole('button', { name: 'User Info' }); + } + + get btnContactInfoVoiceCall(): Locator { + return this.page.getByRole('group').getByRole('button', { name: 'Voice call' }); + } + + get btnSendTranscript(): Locator { + return this.page.locator('role=button[name="Send transcript"]'); + } + + get btnSendTranscriptToEmail(): Locator { + return this.page.locator('li.rcx-option', { hasText: 'Send via email' }); + } + + get btnSendTranscriptAsPDF(): Locator { + return this.page.locator('li.rcx-option', { hasText: 'Export as PDF' }); + } + + get btnNewCannedResponse(): Locator { + return this.page.locator('.rcx-vertical-bar button:has-text("Create")'); + } + + get imageGallery(): Locator { + return this.page.getByRole('dialog', { name: 'Image gallery', exact: true }); + } + + get imageGalleryImage(): Locator { + return this.imageGallery.locator('.swiper-zoom-container img'); + } + + async getGalleryButtonByName(name: string) { + return this.imageGallery.locator(`button[name="${name}"]`); + } + + async dragAndDropTxtFile({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'any_file.txt', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null; + await this.composer.inputMessage.dispatchEvent('dragenter', { dataTransfer }); + await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + if (responsePromise) { + await responsePromise; + } + } + + async dragAndDropLstFile({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + const contract = await fs.readFile(getFilePath('lst-test.lst'), 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'lst-test.lst', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null; + await this.composer.inputMessage.dispatchEvent('dragenter', { dataTransfer }); + await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + if (responsePromise) { + await responsePromise; + } + } + + async dragAndDropTxtFileToThread({ waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'any_file.txt', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null; + await this.threadComposer.inputMessage.dispatchEvent('dragenter', { dataTransfer }); + await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + if (responsePromise) { + await responsePromise; + } + } + + async sendMultipleFilesMessage(fileNames: string[], { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + await this.page + .getByLabel('Room composer') + .locator('input[type=file]') + .setInputFiles(fileNames.map((name) => getFilePath(name))); + + if (!waitForResponse) { + return; + } + + await expect(this.composer.btnSend).toBeEnabled(); + } + + async sendFileMessage(fileName: string, { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null; + await this.page.getByLabel('Room composer').locator('input[type=file]').setInputFiles(getFilePath(fileName)); + if (responsePromise) { + await responsePromise; + } + } + + async sendFileMessageToThread(fileName: string, { waitForResponse = true }: { waitForResponse?: boolean } = {}): Promise { + await this.threadComposer.inputMessage.click(); + const responsePromise = waitForResponse ? createMediaResponsePromise(this.page) : null; + await this.page.getByLabel('Thread composer').locator('input[type=file]').setInputFiles(getFilePath(fileName)); + if (responsePromise) { + await responsePromise; + } + } + + async openLastMessageMenu(): Promise { + await this.lastUserMessage.hover(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastUserMessage.getByRole('button', { name: 'More', exact: true }).click(); + } + + async openLastThreadMessageMenu(): Promise { + await this.lastUserThreadMessage.hover(); + await this.lastUserThreadMessage.getByRole('button', { name: 'More', exact: true }).waitFor(); + await this.lastUserThreadMessage.getByRole('button', { name: 'More', exact: true }).click(); + } + + async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { + await this.page + .getByRole('dialog') + .locator('label', { has: this.page.getByRole('checkbox', { name: 'Also send to channel' }) }) + .setChecked(isChecked); + } + + get lastSystemMessageBody(): Locator { + return this.page.locator('[role=document][aria-roledescription="system message body"]').last(); + } + + get resumeOnHoldOmnichannelChatButton(): Locator { + return this.page.locator('button.rcx-button--primary >> text="Resume"'); + } + + get primaryRoomActionsToolbar(): Locator { + return this.page.getByRole('toolbar', { name: 'Primary Room actions' }); + } + + get btnVideoCall(): Locator { + return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]').getByRole('button', { name: 'Video call' }); + } + + get btnToolbarOptions(): Locator { + return this.primaryRoomActionsToolbar.getByRole('button', { name: 'Options', exact: true }); + } + + get optionsMenu(): Locator { + return this.page.getByRole('menu', { name: 'Options', exact: true }); + } + + get starredMessagesMenuOption(): Locator { + return this.optionsMenu.getByRole('menuitem', { name: 'Starred Messages', exact: true }); + } + + getVideoConfPopup(name?: string): Locator { + return this.page.getByRole('dialog', { name }); + } + + get btnStartVideoCall(): Locator { + return this.getVideoConfPopup().getByRole('button', { name: 'Start call' }); + } + + get btnVideoConfMic(): Locator { + return this.getVideoConfPopup().getByRole('button', { name: 'Mic' }); + } + + get btnDeclineVideoCall(): Locator { + return this.page.locator('.rcx-button--secondary-danger.rcx-button >> text="Decline"'); + } + + get videoConfMessageBlock(): Locator { + return this.page.locator('.rcx-videoconf-message-block'); + } + + get btnAnonymousSignIn(): Locator { + return this.page.locator('footer >> role=button[name="Sign in to start talking"]'); + } + + get btnAnonymousTalk(): Locator { + return this.page.locator('role=button[name="Or talk as anonymous"]'); + } + + get nextSlideButton(): Locator { + return this.page.getByLabel('Next slide'); + } + + get previousSlideButton(): Locator { + return this.page.getByLabel('Previous slide'); + } + + get currentGalleryImage(): Locator { + return this.page.locator('div[class="swiper-slide swiper-slide-active"] img'); + } + + // TODO: use getSystemMessageByText instead + findSystemMessage(text: string): Locator { + return this.page.locator(`[role="document"][aria-roledescription="system message body"]`, { hasText: text }); + } + + getSystemMessageByText(text: string): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="system message"]', { hasText: text }); + } + + getMessageByText(text: string): Locator { + return this.page.locator('[role="listitem"][aria-roledescription="message"]', { hasText: text }); + } + + getLastMessageActionButton(name: string): Locator { + return this.lastUserMessage.getByRole('button', { name, exact: true }); + } + + getLastThreadMessageActionButton(name: string): Locator { + return this.lastUserThreadMessage.getByRole('button', { name, exact: true }); + } + + getMessageById(id: string): Locator { + return this.page.locator(`[role="listitem"][aria-roledescription="message"][id="${id}"]`); + } + + /** Thread replies carry `aria-roledescription="thread message"`, so `getMessageById` skips them. */ + getThreadMessageById(id: string): Locator { + return this.threadMessageList.locator(`[role="listitem"][id="${id}"]`); + } + + async scrollToMessage(messageLocator: Locator, direction: 'up' | 'down' = 'up'): Promise { + const scroller = this.mainMessageListScroller; + const delta = direction === 'up' ? -400 : 400; + + await expect(async () => { + await scroller.evaluate((el, d) => el.scrollBy(0, d), delta); + await expect(messageLocator.first()).toBeVisible({ timeout: 500 }); + }).toPass({ timeout: 30000, intervals: [100] }); + + return messageLocator; + } + + get btnJoinChannel(): Locator { + return this.page.getByRole('main').getByRole('button', { name: 'Join channel', exact: true }); + } + + async waitForChannel(): Promise { + await this.page.locator('role=main').waitFor(); + + // a room the user cannot preview renders a join screen instead of the header and message list + await this.mainMessageList.or(this.btnJoinChannel).first().waitFor(); + + if (await this.btnJoinChannel.isVisible()) { + return; + } + + await this.page.locator('role=main >> role=heading[level=1]').waitFor(); + await expect(this.mainMessageList).not.toHaveAttribute('aria-busy', 'true'); + } + + async waitForThread(): Promise { + const messageList = this.page.getByRole('list', { name: 'Thread message list', exact: true }); + await messageList.waitFor(); + + await expect(messageList).not.toHaveAttribute('aria-busy', 'true'); + } + + async openReplyInThread(): Promise { + await this.lastUserMessage.hover(); + await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).waitFor(); + await this.lastUserMessage.getByRole('button', { name: 'Reply in thread' }).click(); + } + + async sendMessageInThread(text: string): Promise { + await this.threadComposer.inputMessage.fill(text); + await this.threadComposer.btnSend.click(); + } + + async sendMessageInVideoConfPopup(text: string): Promise { + await this.page.getByRole('dialog').getByRole('textbox', { name: 'Message' }).fill(text); + await this.page.getByRole('dialog').getByRole('button', { name: 'Send', exact: true }).click(); + } + + async deleteLastMessage(): Promise { + await this.lastUserMessage.hover(); + await this.openLastMessageMenu(); + await this.btnOptionDeleteMessage.click(); + await this.btnModalConfirmDelete.click(); + await expect(this.btnModalConfirmDelete).toBeDisabled(); + } + + get btnClearSelection() { + return this.page.getByRole('button', { name: 'Clear selection' }); + } + + get contactUnknownCallout() { + return this.page.getByRole('status', { name: 'Unknown contact. This contact is not on the contact list.' }); + } + + get btnDismissContactUnknownCallout() { + return this.contactUnknownCallout.getByRole('button', { name: 'Dismiss' }); + } + + get btnOptionStartDiscussion(): Locator { + return this.page.getByRole('menuitem', { name: 'Start a Discussion' }); + } + + async quoteMessage(quoteText: string, originalMessageText?: string): Promise { + await this.lastUserMessage.hover(); + await this.btnQuoteMessage.click(); + if (originalMessageText) { + await expect(this.quotePreview).toBeVisible(); + await expect(this.quotePreview).toContainText(originalMessageText); + } + await this.sendMessage(quoteText); + } + + get clearAllUnreadsModal(): Locator { + return this.page.getByRole('dialog', { name: 'Clear all unreads?' }); + } + + async markAllRoomsAsRead(): Promise { + await this.page.keyboard.down('Shift'); + await this.page.keyboard.press('Escape'); + await this.page.keyboard.up('Shift'); + await expect(this.clearAllUnreadsModal).toBeVisible(); + await this.clearAllUnreadsModal.getByRole('button', { name: 'Yes, clear all!' }).click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts new file mode 100644 index 0000000..3e8216f --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +import { HomeContent } from './home-content'; +import { OmnichannelTransferChatModal, OmnichannelReturnToQueueModal } from './modals'; + +export class HomeOmnichannelContent extends HomeContent { + readonly forwardChatModal: OmnichannelTransferChatModal; + + readonly returnToQueueModal: OmnichannelReturnToQueueModal; + + constructor(page: Page) { + super(page); + this.forwardChatModal = new OmnichannelTransferChatModal(page); + this.returnToQueueModal = new OmnichannelReturnToQueueModal(page); + } + + get btnReturnToQueue(): Locator { + return this.page.locator('role=button[name="Move to the queue"]'); + } + + get btnTakeChat(): Locator { + return this.page.locator('role=button[name="Take it!"]'); + } + + get btnReturn(): Locator { + return this.header.root.getByRole('button', { name: 'Back' }); + } + + get btnResume(): Locator { + return this.page.locator('role=button[name="Resume"]'); + } + + get infoHeaderName(): Locator { + return this.page.locator('.rcx-room-header').getByRole('heading'); + } + + async selectCannedResponse(cannedResponseName: string): Promise { + await this.composer.inputMessage.pressSequentially('!'); + const popup = this.composer.boxPopup; + await popup.waitFor({ state: 'visible' }); + await this.composer.inputMessage.pressSequentially(cannedResponseName); + await popup.getByText(cannedResponseName, { exact: true }).click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/index.ts b/apps/meteor/tests/e2e/page-objects/fragments/index.ts new file mode 100644 index 0000000..61b1944 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/index.ts @@ -0,0 +1,12 @@ +export * from './home-content'; +export * from './home-omnichannel-content'; +export * from './navbar'; +export * from './sidebar'; +export * from './sidepanel'; +export * from './modals'; +export * from './toast-messages'; +export * from './menu'; +export * from './table'; +export * from './toolbar'; +export * from './composer'; +export * from './flextabs'; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/listbox.ts b/apps/meteor/tests/e2e/page-objects/fragments/listbox.ts new file mode 100644 index 0000000..8b22ca2 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/listbox.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from 'playwright-core'; + +export class Listbox { + readonly root: Locator; + + constructor(page: Page, name?: string) { + /** + * Currently, our selects and multiSelects has multiple listboxes in the DOM. + * So, to avoid selecting the wrong one, we will always select the last until we fix it. + */ + this.root = page.getByRole('listbox', { name }).last(); + } + + async selectOption(name: string, exact?: boolean) { + return this.root.getByRole('option', { name, exact }).click(); + } + + public getOption(name: string): Locator { + return this.root.getByRole('option', { name, exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/menu.ts b/apps/meteor/tests/e2e/page-objects/fragments/menu.ts new file mode 100644 index 0000000..67a402c --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/menu.ts @@ -0,0 +1,50 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; + +export abstract class Menu { + constructor(public root: Locator) {} + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } + + getMenuItem(itemName: string) { + return this.root.getByRole('menuitem', { name: itemName, exact: true }); + } + + async selectMenuItem(itemName: string, submenuTrigger = false) { + await this.getMenuItem(itemName).click(); + if (!submenuTrigger) { + await this.waitForDismissal(); + } + } +} + +export class MenuMore extends Menu { + constructor(page: Page) { + super(page.getByRole('menu', { name: 'More' })); + } +} + +export class MenuMoreActions extends Menu { + constructor(page: Page) { + super(page.getByRole('menu', { name: 'More actions' })); + } +} + +export class MenuOptions extends Menu { + constructor(page: Page) { + super(page.getByRole('menu', { name: 'Options' })); + } +} + +export class MenuMoveTo extends Menu { + constructor(page: Page) { + super(page.getByRole('menu', { name: 'Move to' })); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/message.ts b/apps/meteor/tests/e2e/page-objects/fragments/message.ts new file mode 100644 index 0000000..cb2bc36 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/message.ts @@ -0,0 +1,17 @@ +import type { Locator } from '@playwright/test'; + +export class Message { + constructor(public readonly root: Locator) {} + + get body() { + return this.root.locator('[role="document"][aria-roledescription="message body"]'); + } + + getFileUploadByName(filename: string) { + return this.root.getByRole('link', { name: filename }); + } + + get encryptedIcon() { + return this.root.locator('.rcx-icon--name-key'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/apps-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/apps-modal.ts new file mode 100644 index 0000000..0f5ac01 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/apps-modal.ts @@ -0,0 +1,27 @@ +import type { Locator, Page } from 'playwright-core'; + +import { Modal } from './modal'; + +export class AppsModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Modal Example' })); + } + + get textInput(): Locator { + return this.root.locator('[name="modal_input"]'); + } + + get textInputErrorMessage(): Locator { + return this.root.getByText('Validation failed'); + } + + get btnSubmit(): Locator { + return this.root.getByRole('button', { name: 'Submit' }); + } + + async submit(inputText: string) { + await this.textInput.fill(inputText); + await this.btnSubmit.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-delete-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-delete-modal.ts new file mode 100644 index 0000000..5fea374 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-delete-modal.ts @@ -0,0 +1,63 @@ +import type { Locator, Page } from 'playwright-core'; + +import { Modal } from './modal'; + +export class ConfirmDeleteModal extends Modal { + constructor(root: Locator) { + super(root); + } + + get btnDelete() { + return this.root.getByRole('button', { name: 'Delete' }); + } + + async confirmDelete() { + await this.btnDelete.click(); + await this.waitForDismissal(); + } +} + +export class ConfirmDeleteRoomModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete' })); + } + + get btnDelete() { + return this.root.getByRole('button', { name: 'Yes, delete', exact: true }); + } + + async confirmDelete() { + await this.btnDelete.click(); + await this.waitForDismissal(); + } +} + +export class ConfirmDeleteDepartmentModal extends ConfirmDeleteModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete Department?' })); + } + + get inputConfirmDepartmentName() { + return this.root.getByRole('textbox', { name: 'Department name' }); + } + + async deleteDepartment(departmentName: string) { + await this.inputConfirmDepartmentName.fill(departmentName); + await this.confirmDelete(); + } +} + +export class ConfirmDeleteTeamModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete team', exact: true })); + } + + private get btnDelete() { + return this.root.getByRole('button', { name: 'Yes, delete', exact: true }); + } + + async confirmDelete() { + await this.btnDelete.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-logout-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-logout-modal.ts new file mode 100644 index 0000000..62d92c4 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-logout-modal.ts @@ -0,0 +1,12 @@ +import { Modal } from './modal'; + +export class ConfirmLogoutModal extends Modal { + get btnLogout() { + return this.root.getByRole('button', { name: 'Log out device' }); + } + + async confirmLogout() { + await this.btnLogout.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-remove-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-remove-modal.ts new file mode 100644 index 0000000..d1bfd0f --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/confirm-remove-modal.ts @@ -0,0 +1,18 @@ +import type { Locator } from '@playwright/test'; + +import { Modal } from './modal'; + +export class ConfirmRemoveModal extends Modal { + constructor(root: Locator) { + super(root); + } + + get btnRemove() { + return this.root.getByRole('button', { name: 'Remove' }); + } + + async confirmRemove() { + await this.btnRemove.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/create-new-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/create-new-modal.ts new file mode 100644 index 0000000..7dc2c21 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/create-new-modal.ts @@ -0,0 +1,129 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Listbox } from '../listbox'; +import { Modal } from './modal'; + +export abstract class CreateNewModal extends Modal { + readonly listbox: Listbox; + + constructor(root: Locator, page: Page) { + super(root, page); + this.listbox = new Listbox(page); + } + + get inputName(): Locator { + return this.root.getByRole('textbox', { name: 'Name' }); + } + + get checkboxPrivate(): Locator { + return this.root.locator('label', { hasText: 'Private' }); + } + + get checkboxEncrypted(): Locator { + return this.root.locator('label', { hasText: 'Encrypted' }); + } + + get checkboxReadOnly(): Locator { + return this.root.locator('label', { hasText: 'Read-only' }); + } + + get checkboxFederated(): Locator { + return this.root.locator('label', { hasText: 'Federated' }); + } + + get btnCreate(): Locator { + return this.root.getByRole('button', { name: 'Create', exact: true }); + } + + get inputAddMembers(): Locator { + return this.root.getByRole('combobox', { name: 'Members' }); + } + + async addMember(memberName: string): Promise { + await this.inputAddMembers.click(); + await this.inputAddMembers.fill(memberName, { force: true }); + await this.listbox.selectOption(memberName); + await this.inputAddMembers.click(); + } + + async create() { + await this.btnCreate.click(); + await this.waitForDismissal(); + } +} + +export class CreateNewChannelModal extends CreateNewModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Create channel' }), page); + } + + get advancedSettingsAccordion(): Locator { + return this.root.getByRole('button', { name: 'Advanced settings', exact: true }); + } + + async inviteUserToChannel(username: string) { + await this.inputAddMembers.click(); + await this.inputAddMembers.fill(username); + await this.listbox.selectOption(username); + } +} + +export class CreateNewDMModal extends CreateNewModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'New direct message' }), page); + } + + get autocompleteUser(): Locator { + return this.root.getByRole('combobox', { name: 'Select one or more people to message', exact: true }); + } + + async inviteUserToDM(username: string) { + await this.autocompleteUser.click(); + await this.autocompleteUser.fill(username); + await this.listbox.selectOption(username); + await this.page?.keyboard.press('Tab'); + } +} + +export class CreateNewTeamModal extends CreateNewModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Create team' }), page); + } + + get advancedSettingsAccordion(): Locator { + return this.root.getByRole('button', { name: 'Advanced settings', exact: true }); + } +} + +export class CreateNewDiscussionModal extends CreateNewModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Create discussion' }), page); + } + + get inputParentRoom(): Locator { + return this.root.getByRole('textbox', { name: 'Parent channel or team' }); + } + + getParentRoomListItem(name: string): Locator { + return this.listbox.getOption(name); + } + + get inputMessage(): Locator { + return this.root.getByRole('textbox', { name: 'Message', exact: true }); + } +} + +export class CreateNewCategoryModal extends CreateNewModal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Create category', exact: true }), page); + } + + private get btnCreateAndMove() { + return this.root.getByRole('button', { name: 'Create and move', exact: true }); + } + + override async create(hasRoom = false) { + await (hasRoom ? this.btnCreateAndMove.click() : this.btnCreate.click()); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-account-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-account-modal.ts new file mode 100644 index 0000000..722bfbb --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-account-modal.ts @@ -0,0 +1,36 @@ +import type { Locator, Page } from 'playwright-core'; + +import { Modal } from './modal'; + +export class DeleteAccountModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete account?' })); + } + + get btnDeleteAccount(): Locator { + return this.root.getByRole('button', { name: 'Delete Account' }); + } + + get btnCancel(): Locator { + return this.root.getByRole('button', { name: 'Cancel' }); + } + + async confirmDelete({ waitForDismissal = true } = {}): Promise { + await this.btnDeleteAccount.click(); + if (waitForDismissal) { + await this.waitForDismissal(); + } + } + + get deleteAccountDialogMessageWithPassword(): Locator { + return this.root.getByText('Enter your password to delete your account. This cannot be undone.'); + } + + get inputPassword(): Locator { + return this.root.getByRole('textbox', { name: 'Enter your password to delete your account. This cannot be undone.' }); + } + + get inputErrorMessage(): Locator { + return this.root.locator('[role="alert"]', { hasText: 'Invalid password' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-category-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-category-modal.ts new file mode 100644 index 0000000..b970f8e --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/delete-category-modal.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class DeleteCategoryModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete category', exact: true }), page); + } + + private get btnDelete() { + return this.root.getByRole('button', { name: 'Delete', exact: true }); + } + + async delete() { + await this.btnDelete.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/disable-room-encryption-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/disable-room-encryption-modal.ts new file mode 100644 index 0000000..35690e7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/disable-room-encryption-modal.ts @@ -0,0 +1,23 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class DisableRoomEncryptionModal extends Modal { + private readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Disable encryption' })); + this.toastMessages = new ToastMessages(page); + } + + private get disableButton() { + return this.root.getByRole('button', { name: 'Disable encryption' }); + } + + async disable() { + await this.disableButton.click(); + await this.waitForDismissal(); + await this.toastMessages.dismissToast('success'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/edit-status-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/edit-status-modal.ts new file mode 100644 index 0000000..3bb9965 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/edit-status-modal.ts @@ -0,0 +1,97 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class EditStatusModal extends Modal { + readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Set your status' }), page); + this.toastMessages = new ToastMessages(page); + } + + private get statusMessageInput() { + return this.root.getByRole('textbox', { name: 'Status' }); + } + + private get statusTypeButton() { + return this.root.getByRole('button', { name: 'User status menu' }); + } + + private get durationSelect() { + return this.root.getByLabel('Clear status after'); + } + + get customDateInput() { + return this.root.getByLabel('Expiration date'); + } + + get customTimeInput() { + return this.root.getByLabel('Expiration time'); + } + + get btnSubmit() { + return this.root.getByRole('button', { name: 'Save' }); + } + + get durationError() { + return this.root.getByRole('alert').filter({ hasText: 'Expiration must be in the future' }); + } + + get durationMissingError() { + return this.root.getByRole('alert').filter({ hasText: 'Choose date and time' }); + } + + async selectStatusType(status: string): Promise { + await this.statusTypeButton.click(); + await this.page!.getByRole('option', { name: status }).click(); + } + + async setStatusMessage(message: string): Promise { + await this.statusMessageInput.fill(message); + } + + async selectDuration(duration: string): Promise { + await this.durationSelect.click(); + await this.page!.getByRole('option', { name: new RegExp(duration) }).click(); + } + + async changeStatusMessage(statusMessage?: string): Promise { + if (statusMessage !== undefined) { + await this.setStatusMessage(statusMessage); + } + await this.save(); + await this.toastMessages.dismissToast(); + } + + async setStatusWithExpiration({ + message, + statusType, + duration, + customDate, + customTime, + }: { + message?: string; + statusType?: string; + duration: string; + customDate?: string; + customTime?: string; + }): Promise { + if (statusType) { + await this.selectStatusType(statusType); + } + if (message !== undefined) { + await this.setStatusMessage(message); + } + await this.selectDuration(duration); + if (customDate) { + await this.customDateInput.fill(customDate); + } + if (customTime) { + await this.customTimeInput.fill(customTime); + } + await this.save(); + await this.toastMessages.dismissToast(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/enable-room-encryption-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/enable-room-encryption-modal.ts new file mode 100644 index 0000000..a8205ec --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/enable-room-encryption-modal.ts @@ -0,0 +1,23 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class EnableRoomEncryptionModal extends Modal { + private readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Enable encryption' })); + this.toastMessages = new ToastMessages(page); + } + + private get enableButton() { + return this.root.getByRole('button', { name: 'Enable encryption' }); + } + + async enable() { + await this.enableButton.click(); + await this.waitForDismissal(); + await this.toastMessages.dismissToast('success'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-e2ee-password-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-e2ee-password-modal.ts new file mode 100644 index 0000000..cb5044a --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-e2ee-password-modal.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class EnterE2EEPasswordModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Enter E2EE password' })); + } + + private get passwordInput() { + return this.root.getByPlaceholder('Please enter your E2EE password'); + } + + private get forgotPasswordLink() { + return this.root.getByRole('link', { name: 'Forgot E2EE password?' }); + } + + private get enterE2EEPasswordButton() { + return this.root.getByRole('button', { name: 'Enable encryption' }); + } + + async enterPassword(password: string) { + await this.passwordInput.fill(password); + await this.enterE2EEPasswordButton.click(); + await this.waitForDismissal(); + } + + async forgotPassword() { + await this.forgotPasswordLink.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-password-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-password-modal.ts new file mode 100644 index 0000000..c76852e --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/enter-password-modal.ts @@ -0,0 +1,27 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class EnterPasswordModal extends Modal { + readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Please enter your password' })); + this.toastMessages = new ToastMessages(page); + } + + private get inputPassword() { + return this.root.getByRole('textbox', { name: 'For your security, you must enter your current password to continue' }); + } + + private get btnVerify() { + return this.root.getByRole('button', { name: 'Verify' }); + } + + async enterPassword(password: string): Promise { + await this.inputPassword.fill(password); + await this.btnVerify.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts new file mode 100644 index 0000000..7ea0d65 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts @@ -0,0 +1,58 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class FileUploadModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'File Upload' })); + } + + private get inputFileName() { + return this.root.getByRole('textbox', { name: 'File name' }); + } + + private get updateButton() { + return this.root.getByRole('button', { name: 'Update' }); + } + + setName(fileName: string) { + return this.inputFileName.fill(fileName); + } + + async update() { + await this.updateButton.click(); + await this.waitForDismissal(); + } +} + +export class FileUploadWarningModal extends Modal { + constructor(root: Locator) { + super(root); + } + + get btnOk() { + return this.root.getByRole('button', { name: 'Ok' }); + } + + get btnSendAnyway() { + return this.root.getByRole('button', { name: 'Send anyway' }); + } + + getContent(text: string) { + return this.root.getByText(text); + } + + private get btnCancel() { + return this.root.getByRole('button', { name: 'Cancel' }); + } + + async cancel() { + await this.btnCancel.click(); + await this.waitForDismissal(); + } + + async confirmSend() { + await this.btnSendAnyway.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/index.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/index.ts new file mode 100644 index 0000000..a1bf870 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/index.ts @@ -0,0 +1,26 @@ +export * from './apps-modal'; +export * from './confirm-delete-modal'; +export * from './confirm-logout-modal'; +export * from './confirm-remove-modal'; +export * from './create-new-modal'; +export * from './manage-category-modal'; +export * from './delete-account-modal'; +export * from './delete-category-modal'; +export * from './disable-room-encryption-modal'; +export * from './edit-status-modal'; +export * from './enable-room-encryption-modal'; +export * from './enter-e2ee-password-modal'; +export * from './enter-password-modal'; +export * from './file-upload-modal'; +export * from './omnichannel-close-chat-modal'; +export * from './omnichannel-on-hold-modal'; +export * from './omnichannel-transfer-chat-modal'; +export * from './omnichannel-confirm-remove-chat'; +export * from './omnichannel-contact-review-modal'; +export * from './omnichannel-delete-contact-modal'; +export * from './omnichannel-reset-priorities-modal'; +export * from './omnichannel-return-to-queue-modal'; +export * from './report-message-modal'; +export * from './reset-e2ee-password-modal'; +export * from './save-e2ee-password-modal'; +export * from './upsell-modal'; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/manage-category-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/manage-category-modal.ts new file mode 100644 index 0000000..7e3a29d --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/manage-category-modal.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class ManageCategoryModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Manage category', exact: true }), page); + } + + private get inputName() { + return this.root.getByRole('textbox', { name: 'Name', exact: true }); + } + + async rename(newName: string): Promise { + await this.inputName.fill(newName); + await this.save(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/modal.ts new file mode 100644 index 0000000..f6c9d20 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/modal.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../../utils/test'; + +export abstract class Modal { + constructor( + protected root: Locator, + protected page?: Page, + ) { + this.root = root; + this.page = page; + } + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } + + private get btnClose() { + return this.root.getByRole('button', { name: 'Close' }); + } + + async close() { + await this.btnClose.click(); + await this.waitForDismissal(); + } + + private get btnSave() { + return this.root.getByRole('button', { name: 'Save' }); + } + + async save() { + await this.btnSave.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-close-chat-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-close-chat-modal.ts new file mode 100644 index 0000000..e17df57 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-close-chat-modal.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class OmnichannelCloseChatModal extends Modal { + private readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Wrap up conversation' })); + this.toastMessages = new ToastMessages(page); + } + + private get inputComment(): Locator { + return this.root.locator('input[name="comment"]'); + } + + private get btnConfirm(): Locator { + return this.root.locator('role=button[name="Confirm"]'); + } + + private get labelPDF(): Locator { + return this.root.locator('label[for="transcript-pdf"]'); + } + + async confirm(comment: string, downloadPDF: boolean): Promise { + await this.inputComment.fill(comment); + if (downloadPDF) { + await this.labelPDF.click(); + } + await this.btnConfirm.click(); + await this.waitForDismissal(); + await this.toastMessages.dismissToast('success'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-confirm-remove-chat.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-confirm-remove-chat.ts new file mode 100644 index 0000000..42528f7 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-confirm-remove-chat.ts @@ -0,0 +1,18 @@ +import type { Locator, Page } from 'playwright-core'; + +import { Modal } from './modal'; + +export class OmnichannelConfirmRemoveChat extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog')); + } + + private get btnConfirmRemove(): Locator { + return this.root.getByRole('button', { name: 'Delete' }); + } + + async confirm() { + await this.btnConfirmRemove.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-contact-review-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-contact-review-modal.ts new file mode 100644 index 0000000..9ed8beb --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-contact-review-modal.ts @@ -0,0 +1,27 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; +import { expect } from '../../../utils/test'; +import { Listbox } from '../listbox'; + +export class OmnichannelContactReviewModal extends Modal { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Review contact' }), page); + this.listbox = new Listbox(page); + } + + private getFieldByName(name: string): Locator { + return this.root.getByLabel(name, { exact: true }); + } + + async solveConfirmation(field: string, value: string) { + await this.getFieldByName(field).click(); + await this.listbox.selectOption(value); + const responsePromise = this.page?.waitForResponse('**/api/v1/omnichannel/contacts.conflicts'); + await this.save(); + const responseListener = await responsePromise; + expect(responseListener?.status()).toBe(200); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-delete-contact-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-delete-contact-modal.ts new file mode 100644 index 0000000..a7494ce --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-delete-contact-modal.ts @@ -0,0 +1,22 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class OmnichannelDeleteContactModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Delete Contact' })); + } + + get inputConfirmation(): Locator { + return this.root.getByRole('textbox', { name: 'Confirm Contact Removal' }); + } + + get btnDelete(): Locator { + return this.root.getByRole('button', { name: 'Delete' }); + } + + async delete() { + await this.btnDelete.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-on-hold-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-on-hold-modal.ts new file mode 100644 index 0000000..593c672 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-on-hold-modal.ts @@ -0,0 +1,18 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class OmnichannelOnHoldModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Place chat On-Hold' })); + } + + private get btnPlaceChatOnHold(): Locator { + return this.root.getByRole('button', { name: 'Place chat On-Hold' }); + } + + async confirm(): Promise { + await this.btnPlaceChatOnHold.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-reset-priorities-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-reset-priorities-modal.ts new file mode 100644 index 0000000..d5be391 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-reset-priorities-modal.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class OmnichannelResetPrioritiesModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Reset priorities' })); + } + + private get btnResetConfirm() { + return this.root.getByRole('button', { name: 'Reset' }); + } + + async reset() { + await this.btnResetConfirm.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-return-to-queue-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-return-to-queue-modal.ts new file mode 100644 index 0000000..6a6eb70 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-return-to-queue-modal.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class OmnichannelReturnToQueueModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Return back to the Queue' })); + } + + private get btnReturnToQueueConfirm() { + return this.root.getByRole('button', { name: 'Confirm' }); + } + + async confirm() { + await this.btnReturnToQueueConfirm.click(); + await this.waitForDismissal(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-transfer-chat-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-transfer-chat-modal.ts new file mode 100644 index 0000000..9aa499d --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/omnichannel-transfer-chat-modal.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; +import { Listbox } from '../listbox'; + +export class OmnichannelTransferChatModal extends Modal { + private readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Forward chat' })); + this.listbox = new Listbox(page); + } + + get inputComment(): Locator { + return this.root.locator('textarea[name="comment"]'); + } + + get inputForwardDepartment(): Locator { + return this.root.getByLabel('Forward to department').getByRole('textbox'); + } + + get inputForwardUser(): Locator { + return this.root.getByLabel('Forward to user').getByRole('textbox'); + } + + get btnForward(): Locator { + return this.root.locator('role=button[name="Forward"]'); + } + + async selectDepartment(name: string) { + await this.inputForwardDepartment.click(); + await this.inputForwardDepartment.fill(name); + await this.listbox.selectOption(name); + } + + async selectUser(name: string, id?: string) { + await this.inputForwardUser.click(); + await this.inputForwardUser.fill(name); + await this.listbox.selectOption(id || name); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/report-message-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/report-message-modal.ts new file mode 100644 index 0000000..40d37c4 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/report-message-modal.ts @@ -0,0 +1,48 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Modal } from './modal'; +import { expect } from '../../../utils/test'; +import { ToastMessages } from '../toast-messages'; + +export class ReportMessageModal extends Modal { + readonly toastMessage: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Report message' })); + this.toastMessage = new ToastMessages(page); + } + + get inputReportDescription(): Locator { + return this.root.getByRole('textbox', { name: 'Report reason' }); + } + + private get btnSubmitReport(): Locator { + return this.root.getByRole('button', { name: 'Report' }); + } + + private get btnCancelReport(): Locator { + return this.root.getByRole('button', { name: 'Cancel' }); + } + + private get alertInputDescription(): Locator { + return this.root.getByRole('alert'); + } + + async cancelReport(): Promise { + await this.btnCancelReport.click(); + await this.waitForDismissal(); + } + + async submitReport(description?: string): Promise { + if (!description) { + await this.btnSubmitReport.click(); + await expect(this.alertInputDescription).toBeVisible(); + return; + } + + await this.inputReportDescription.fill(description); + await this.btnSubmitReport.click(); + await this.waitForDismissal(); + await this.toastMessage.waitForDisplay({ type: 'success', message: 'Report has been sent' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/reset-e2ee-password-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/reset-e2ee-password-modal.ts new file mode 100644 index 0000000..ecc6d34 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/reset-e2ee-password-modal.ts @@ -0,0 +1,23 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { LoginPage } from '../../login'; + +export class ResetE2EEPasswordModal extends Modal { + private readonly login: LoginPage; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Reset E2EE password' })); + this.login = new LoginPage(page); + } + + private get resetE2EEPasswordButton() { + return this.root.getByRole('button', { name: 'Reset E2EE password' }); + } + + async confirmReset() { + await this.resetE2EEPasswordButton.click(); + await this.waitForDismissal(); + await this.login.waitForIt(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/save-e2ee-password-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/save-e2ee-password-modal.ts new file mode 100644 index 0000000..2c849af --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/save-e2ee-password-modal.ts @@ -0,0 +1,31 @@ +import type { Page } from 'playwright-core'; + +import { Modal } from './modal'; +import { ToastMessages } from '../toast-messages'; + +export class SaveE2EEPasswordModal extends Modal { + private readonly toastMessages: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Save your new E2EE password' })); + this.toastMessages = new ToastMessages(page); + } + + private get password() { + return this.root.getByLabel('Your E2EE password is:').getByRole('code'); + } + + private get savedPasswordButton() { + return this.root.getByRole('button', { name: 'I saved my password' }); + } + + async getPassword() { + return (await this.password.textContent()) ?? ''; + } + + async confirm() { + await this.savedPasswordButton.click(); + await this.waitForDismissal(); + await this.toastMessages.dismissToast('success'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/upsell-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/upsell-modal.ts new file mode 100644 index 0000000..0881a01 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/upsell-modal.ts @@ -0,0 +1,15 @@ +import type { Page } from '@playwright/test'; + +import { Modal } from './modal'; + +export class VoiceCallsUpsellModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Team voice calls' })); + } +} + +export class OmnichannelUpsellDepartmentsModal extends Modal { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Departments' })); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts new file mode 100644 index 0000000..eee3f56 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -0,0 +1,335 @@ +import type { Locator, Page } from '@playwright/test'; + +import { + EditStatusModal, + CreateNewChannelModal, + CreateNewDiscussionModal, + CreateNewDMModal, + CreateNewTeamModal, + CreateNewCategoryModal, +} from './modals'; +import { expect } from '../../utils/test'; + +export class Navbar { + readonly modals: { + 'Channel': CreateNewChannelModal; + 'Team': CreateNewTeamModal; + 'Discussion': CreateNewDiscussionModal; + 'Direct message': CreateNewDMModal; + 'editStatus': EditStatusModal; + 'Category': CreateNewCategoryModal; + }; + + constructor(private readonly root: Page) { + this.modals = { + 'Channel': new CreateNewChannelModal(root), + 'Team': new CreateNewTeamModal(root), + 'Discussion': new CreateNewDiscussionModal(root), + 'Direct message': new CreateNewDMModal(root), + 'editStatus': new EditStatusModal(root), + 'Category': new CreateNewCategoryModal(root), + }; + } + + get btnVoiceAndOmnichannel(): Locator { + return this.root.getByRole('button', { name: 'Voice and omnichannel' }); + } + + get groupHistoryNavigation(): Locator { + return this.root.getByRole('group', { name: 'History navigation' }); + } + + get pagesGroup(): Locator { + return this.root.getByRole('group', { name: 'Pages and actions' }); + } + + get omnichannelGroup(): Locator { + return this.root.getByRole('group', { name: 'Omnichannel' }); + } + + get btnContactCenter(): Locator { + return this.omnichannelGroup.getByRole('button', { name: 'Contact Center' }); + } + + get voiceCallGroup(): Locator { + return this.root.getByRole('group', { name: 'Voice call' }); + } + + get btnNewVoiceCall(): Locator { + return this.voiceCallGroup.getByRole('button', { name: 'New voice call' }); + } + + get btnSwitchOmnichannelStatus(): Locator { + return this.omnichannelGroup.getByRole('button', { name: 'answer chats' }); + } + + get btnHome(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Home' }); + } + + get btnDirectory(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Directory' }); + } + + get btnMarketplace(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Marketplace' }); + } + + get btnMenuPages(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Pages' }); + } + + get btnDisplay(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Display' }); + } + + get menuDisplay(): Locator { + return this.root.getByRole('menu', { name: 'Display' }); + } + + get groupDisplay(): Locator { + return this.menuDisplay.getByRole('group', { name: 'Display' }); + } + + getDisplayMenuItem(mode: 'Extended' | 'Medium' | 'Condensed' | 'Avatars'): Locator { + return this.groupDisplay.getByRole('menuitemcheckbox', { name: mode }); + } + + get groupSortBy(): Locator { + return this.menuDisplay.getByRole('group', { name: 'Sort by' }); + } + + getSortMenuItem(mode: 'Activity' | 'Name'): Locator { + return this.groupSortBy.getByRole('menuitemcheckbox', { name: mode }); + } + + get groupGroupBy(): Locator { + return this.menuDisplay.getByRole('group', { name: 'Group by' }); + } + + getGroupByMenuItem(mode: 'Unread' | 'Favorites' | 'Types'): Locator { + return this.groupGroupBy.getByRole('menuitemcheckbox', { name: mode }); + } + + get btnCreateNew(): Locator { + return this.pagesGroup.getByRole('button', { name: 'Create new' }); + } + + get createNewMenu(): Locator { + return this.root.getByRole('menu', { name: 'Create new' }); + } + + get navbarSearchSection(): Locator { + return this.root.getByRole('search'); + } + + get searchInput(): Locator { + return this.navbarSearchSection.getByRole('combobox'); + } + + get searchList(): Locator { + return this.navbarSearchSection.getByRole('listbox', { name: 'Channels' }); + } + + get workspaceGroup(): Locator { + return this.root.getByRole('group', { name: 'Workspace and user preferences' }); + } + + get btnManageWorkspace(): Locator { + return this.workspaceGroup.getByRole('button', { name: 'Manage' }); + } + + async openManageMenuItem(name: 'Workspace' | 'Omnichannel'): Promise { + await this.btnManageWorkspace.click(); + await this.root.getByRole('menu', { name: 'Manage' }).getByRole('menuitem', { name }).click(); + } + + get btnUserMenu(): Locator { + return this.workspaceGroup.getByRole('button', { name: 'User menu' }); + } + + get userMenu(): Locator { + return this.root.getByRole('menu', { name: 'User menu' }); + } + + get btnLogout(): Locator { + return this.userMenu.getByRole('menuitemcheckbox', { name: 'Logout' }); + } + + get btnCustomStatus(): Locator { + return this.userMenu.getByRole('menuitemcheckbox', { name: 'Custom...' }); + } + + getUserProfileMenuOption(name: string): Locator { + return this.userMenu.getByRole('menuitemcheckbox', { name }); + } + + createNewMenuItem(name: 'Direct message' | 'Discussion' | 'Channel' | 'Team' | 'Outbound message' | 'Category'): Locator { + return this.createNewMenu.getByRole('menuitem', { name }); + } + + async openCreate(name: 'Direct message' | 'Discussion' | 'Channel' | 'Team' | 'Category'): Promise { + await this.btnCreateNew.click(); + await this.createNewMenuItem(name).click(); + } + + async openCreateCategory(): Promise { + await this.btnCreateNew.click(); + await this.createNewMenu.getByRole('menuitem', { name: 'Category', exact: true }).click(); + } + + async logout(): Promise { + await this.btnUserMenu.click(); + return this.btnLogout.click(); + } + + btnSidebarToggler(closeSidebar?: boolean): Locator { + return this.root.getByRole('button', { name: closeSidebar ? 'Close sidebar' : 'Open sidebar' }); + } + + async openAdminPanel(): Promise { + await this.btnManageWorkspace.click(); + await this.root.getByRole('menuitem', { name: 'Workspace' }).click(); + await this.root.waitForURL(/\/admin/); + } + + async typeSearch(name: string): Promise { + return this.searchInput.fill(name); + } + + async waitForChannel(): Promise { + await this.root.locator('role=main').waitFor(); + await this.root.locator('role=main >> role=heading[level=1]').waitFor(); + const messageList = this.root.getByRole('main').getByRole('list', { name: 'Message list', exact: true }); + await messageList.waitFor(); + + await expect(messageList).not.toHaveAttribute('aria-busy', 'true'); + } + + getSearchRoomByName(name: string): Locator { + return this.searchList.getByRole('option', { name }).filter({ has: this.root.getByText(name, { exact: true }) }); + } + + getSearchItemBadge(name: string): Locator { + return this.getSearchRoomByName(name).getByRole('status', { name: 'unread' }); + } + + async openChat(name: string): Promise { + await this.typeSearch(name); + await this.getSearchRoomByName(name).waitFor(); + await this.getSearchRoomByName(name).click(); + await this.waitForChannel(); + } + + async setDisplayMode(mode: 'Extended' | 'Medium' | 'Condensed'): Promise { + await this.btnDisplay.click(); + await this.menuDisplay.getByRole('menuitemcheckbox', { name: mode }).click(); + await this.root.keyboard.press('Escape'); + } + + async createNew( + type: 'Channel' | 'Team', + name: string, + options?: { private?: boolean; encrypted?: boolean; readOnly?: boolean; federated?: boolean; members?: string[] }, + ): Promise { + await this.openCreate(type); + await this.modals[type].inputName.fill(name); + + if (options?.private === false) { + await this.modals[type].checkboxPrivate.click(); + } + + if (options?.members && options.members.length > 0) { + await Promise.all(options.members.map((member) => this.modals[type].addMember(member))); + } + + if (options && ('encrypted' in options || 'readOnly' in options || 'federated' in options)) { + await this.modals[type].advancedSettingsAccordion.click(); + } + if (options?.encrypted) { + await this.modals[type].checkboxEncrypted.click(); + } + if (options?.readOnly) { + await this.modals[type].checkboxReadOnly.click(); + } + if (options?.federated) { + await this.modals[type].checkboxFederated.click(); + } + + await this.modals[type].btnCreate.click(); + } + + async createNewDM(username: string): Promise { + await this.openCreate('Direct message'); + await this.modals['Direct message'].inviteUserToDM(username); + await this.modals['Direct message'].btnCreate.click(); + } + + async createNewCategory(name: string): Promise { + await this.openCreate('Category'); + await this.modals.Category.inputName.fill(name); + await this.modals.Category.create(); + } + + async createNewDiscussion(parentRoom: string, name: string, message?: string): Promise { + await this.openCreate('Discussion'); + await this.modals.Discussion.inputParentRoom.click(); + await this.modals.Discussion.inputParentRoom.pressSequentially(parentRoom); + await this.modals.Discussion.getParentRoomListItem(parentRoom).click(); + await this.modals.Discussion.inputName.fill(name); + message && (await this.modals.Discussion.inputMessage.fill(message)); + await this.modals.Discussion.btnCreate.click(); + } + + async createEncryptedChannel(name: string): Promise { + await this.createNew('Channel', name, { encrypted: true }); + } + + async changeUserStatus(status: 'online' | 'away' | 'busy' | 'invisible' | 'offline' | string): Promise { + await this.btnUserMenu.click(); + await this.getUserProfileMenuOption(status).click(); + } + + async changeUserCustomStatus(text?: string): Promise { + await this.openEditStatusModal(); + await this.modals.editStatus.changeStatusMessage(text); + } + + get editStatusModal(): EditStatusModal { + return this.modals.editStatus; + } + + async openEditStatusModal(): Promise { + await this.btnUserMenu.click(); + await this.btnCustomStatus.click(); + } + + async changeUserCustomStatusWithExpiration(options: { + message?: string; + statusType?: string; + duration: string; + customDate?: string; + customTime?: string; + }): Promise { + await this.openEditStatusModal(); + await this.modals.editStatus.setStatusWithExpiration(options); + } + + async switchOmnichannelStatus(status: 'offline' | 'online') { + const toggleButton = this.btnSwitchOmnichannelStatus; + await expect(toggleButton).toBeVisible(); + + const expectedTitle = status === 'offline' ? 'Turn on answer chats' : 'Turn off answer chats'; + + const currentStatus = await toggleButton.getAttribute('title'); + if (currentStatus !== expectedTitle) { + await toggleButton.click(); + } + + await expect(toggleButton).toHaveAttribute('title', expectedTitle); + } + + getUserStatusBadge(status: 'online' | 'away' | 'busy' | 'offline'): Locator { + return this.btnUserMenu.locator(`svg[class*="${status}"]`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/popup.ts b/apps/meteor/tests/e2e/page-objects/fragments/popup.ts new file mode 100644 index 0000000..36f21c3 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/popup.ts @@ -0,0 +1,15 @@ +import type { Locator } from 'playwright-core'; + +import { expect } from '../../utils/test'; + +export abstract class Popup { + constructor(protected root: Locator) {} + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/room-header.ts b/apps/meteor/tests/e2e/page-objects/fragments/room-header.ts new file mode 100644 index 0000000..c383822 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/room-header.ts @@ -0,0 +1,42 @@ +import type { Locator } from '@playwright/test'; + +import { MenuMoveTo } from './menu'; +import { CreateNewCategoryModal } from './modals'; + +export class RoomHeader { + readonly menuMoveTo: MenuMoveTo; + + readonly createCategoryModal: CreateNewCategoryModal; + + constructor(public root: Locator) { + this.menuMoveTo = new MenuMoveTo(this.root.page()); + this.createCategoryModal = new CreateNewCategoryModal(this.root.page()); + } + + private get btnSelectCategory(): Locator { + return this.root.getByRole('button', { name: 'Move to', exact: true }); + } + + async openCategorySelector(): Promise { + await this.btnSelectCategory.click(); + await this.menuMoveTo.waitForDisplay(); + } + + async pickCategoryMenuItem(name: string): Promise { + await this.openCategorySelector(); + await this.menuMoveTo.selectMenuItem(name); + } + + async checkRoomBelongsToGroup(optionName: string): Promise { + await this.openCategorySelector(); + const exists = await this.menuMoveTo.getMenuItem(optionName).isVisible(); + await this.root.page().keyboard.press('Escape'); + return exists; + } + + async createCategory(name: string): Promise { + await this.pickCategoryMenuItem('New category'); + await this.createCategoryModal.inputName.fill(name); + await this.createCategoryModal.create(true); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts new file mode 100644 index 0000000..560c192 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts @@ -0,0 +1,256 @@ +import type { Locator, Page } from '@playwright/test'; + +import { MenuOptions, MenuMoveTo } from './menu'; +import { DeleteCategoryModal, ManageCategoryModal, CreateNewCategoryModal } from './modals'; +import { expect } from '../../utils/test'; + +export abstract class Sidebar { + constructor(protected root: Locator) {} + + get btnClose(): Locator { + return this.root.getByRole('button', { name: 'Close' }); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } +} + +export class RoomSidebar extends Sidebar { + readonly menuOptions: MenuOptions; + + readonly menuMoveTo: MenuMoveTo; + + readonly modals: { + deleteCategory: DeleteCategoryModal; + manageCategory: ManageCategoryModal; + createCategory: CreateNewCategoryModal; + }; + + constructor(protected page: Page) { + super(page.getByRole('navigation', { name: 'Sidebar' })); + this.modals = { + deleteCategory: new DeleteCategoryModal(page), + manageCategory: new ManageCategoryModal(page), + createCategory: new CreateNewCategoryModal(page), + }; + this.menuOptions = new MenuOptions(page); + this.menuMoveTo = new MenuMoveTo(page); + } + + get teamCollabFilters(): Locator { + return this.root.getByRole('tablist', { name: 'Team collaboration filters' }); + } + + get omnichannelFilters(): Locator { + return this.root.getByRole('tablist', { name: 'Omnichannel filters' }); + } + + get allTeamCollabFilter(): Locator { + return this.teamCollabFilters.getByRole('tab', { name: 'All' }); + } + + get favoritesTeamCollabFilter(): Locator { + return this.teamCollabFilters.getByRole('tab', { name: 'Favorites' }); + } + + get discussionsTeamCollabFilter(): Locator { + return this.teamCollabFilters.getByRole('tab', { name: 'Discussions' }); + } + + // TODO: fix this filter, workaround due to virtuoso + get topChannelList(): Locator { + return this.root.getByTestId('virtuoso-top-item-list'); + } + + get channelsList(): Locator { + // TODO: fix this filter, workaround due to virtuoso + // return this.sidebar.getByRole('list', { name: 'Channels' }).filter({ has: this.page.getByRole('listitem') }); + return this.root.getByTestId('virtuoso-item-list'); + } + + getSidebarItemByName(name: string) { + return this.channelsList.getByRole('link', { name }).filter({ has: this.page.getByText(name, { exact: true }) }); + } + + getFilterItemByName(name: string): Locator { + return this.root.getByRole('button', { name }).filter({ has: this.page.getByText(name, { exact: true }) }); + } + + getSidebarListItem(name: string): Locator { + return this.root.getByRole('listitem').filter({ has: this.page.getByText(name, { exact: true }) }); + } + + get firstCollapser(): Locator { + return this.topChannelList.getByRole('group').first().getByRole('button').first(); + } + + get teamsCollapser(): Locator { + return this.root.getByRole('group', { name: 'Collapse Teams' }).first(); + } + + get channelsCollapser(): Locator { + return this.channelsList.getByRole('group', { name: 'Collapse Channels' }); + } + + get directMessagesCollapser(): Locator { + return this.channelsList.getByRole('group', { name: 'Collapse Direct messages' }); + } + + get firstChannelFromList(): Locator { + return this.channelsList.getByRole('listitem').first(); + } + + async markItemAsUnread(item: Locator): Promise { + await item.hover(); + await item.focus(); + await item.getByRole('button', { name: 'Options', exact: true }).click(); + await this.page.getByRole('menuitem', { name: 'Mark Unread' }).click(); + } + + getCollapseGroupByName(name: string): Locator { + return this.root.getByRole('button').filter({ has: this.page.getByRole('heading', { name, exact: true }) }); + } + + getItemUnreadBadge(item: Locator): Locator { + return item.getByRole('status', { name: 'unread' }); + } + + getBadgeIndicator(name: string, title: string): Locator { + return this.getSidebarItemByName(name).getByTitle(title); + } + + async selectPriority(name: string, priority: string) { + const sidebarItem = this.getSidebarItemByName(name); + await sidebarItem.hover(); + await sidebarItem.focus(); + await sidebarItem.getByRole('button', { name: 'Options', exact: true }).click(); + await this.page.getByRole('menuitem', { name: priority }).click(); + } + + getSidebarListItemByName(name: string): Locator { + return this.channelsList.getByRole('listitem').filter({ has: this.getSidebarItemByName(name) }); + } + + getCategoryCollapser(name: string): Locator { + return this.root.getByRole('group', { name: `Collapse ${name}`, exact: true }).first(); + } + + getCategoryKebab(name: string): Locator { + return this.getCategoryCollapser(name).getByRole('button', { name: 'Options', exact: true }); + } + + async openCategoryMenu(name: string): Promise { + await this.getCategoryCollapser(name).hover(); + await this.getCategoryKebab(name).click(); + } + + async renameCategory(name: string, newName: string): Promise { + await this.openCategoryMenu(name); + await this.page.getByRole('menuitemcheckbox', { name: 'Manage', exact: true }).click(); + await this.modals.manageCategory.rename(newName); + } + + async deleteCategory(name: string): Promise { + await this.openCategoryMenu(name); + await this.page.getByRole('menuitemcheckbox', { name: 'Delete', exact: true }).click(); + await this.modals.deleteCategory.delete(); + } + + get moveToOption(): Locator { + return this.page.getByRole('menuitem', { name: 'Move to', exact: true }); + } + + async openRoomMenu(name: string): Promise { + const item = this.getSidebarItemByName(name); + await item.hover(); + await item.focus(); + await item.getByRole('button', { name: 'Options', exact: true }).click(); + await this.menuOptions.waitForDisplay(); + } + + /** The open submenu keeps the first Escape, so the kebab menu itself needs a second one. */ + async closeRoomMenu(): Promise { + await this.page.keyboard.press('Escape'); + await this.page.keyboard.press('Escape'); + await expect(this.moveToOption).toBeHidden(); + } + + /** Move a room into a custom category (or to "Favorites") through the kebab "Move to ▸" submenu. */ + async moveRoomToCategory(roomName: string, categoryName: string): Promise { + await this.openRoomMoveToSubmenu(roomName); + await this.menuMoveTo.selectMenuItem(categoryName); + } + + async removeRoomFromCategory(roomName: string, categoryName: string): Promise { + await this.openRoomMoveToSubmenu(roomName); + await this.menuMoveTo.selectMenuItem(`Remove from ${categoryName}`); + } + + async createCategoryFromRoom(roomName: string, name: string): Promise { + await this.openRoomMoveToSubmenu(roomName); + await this.menuMoveTo.selectMenuItem('New category'); + await this.modals.createCategory.inputName.fill(name); + await this.modals.createCategory.create(true); + } + + async moveRoomToFavorites(roomName: string): Promise { + await this.openRoomMoveToSubmenu(roomName); + await this.menuMoveTo.selectMenuItem('Favorites'); + } + + async removeRoomFromFavorites(roomName: string): Promise { + await this.openRoomMoveToSubmenu(roomName); + await this.menuMoveTo.selectMenuItem('Remove from Favorites'); + } + + async openRoomMoveToSubmenu(roomName: string): Promise { + await this.openRoomMenu(roomName); + await this.menuOptions.selectMenuItem('Move to', true); + await this.menuMoveTo.waitForDisplay(); + } +} + +export class AdminSidebar extends Sidebar { + constructor(page: Page) { + super(page.getByRole('navigation', { name: 'Administration' })); + } + + get linkEmoji() { + return this.root.getByRole('link', { name: 'Emoji' }); + } + + async close(): Promise { + await this.btnClose.click(); + await this.waitForDismissal(); + } +} + +export class AccountSidebar extends Sidebar { + constructor(page: Page) { + super(page.getByRole('navigation', { name: 'Account' })); + } + + get linkSecurity(): Locator { + return this.root.getByRole('link', { name: 'Security' }); + } + + async close(): Promise { + await this.btnClose.click(); + await this.waitForDismissal(); + } +} + +export class OmnichannelSidebar extends Sidebar { + constructor(page: Page) { + super(page.getByRole('navigation', { name: 'Omnichannel' })); + } + + getSidebarLinkByName(name: string): Locator { + return this.root.getByRole('link', { name, exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts b/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts new file mode 100644 index 0000000..1c56834 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/sidepanel.ts @@ -0,0 +1,61 @@ +import type { Locator, Page } from '@playwright/test'; + +export class Sidepanel { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get sidepanel(): Locator { + return this.page.getByRole('tabpanel', { name: 'Side panel' }); + } + + get sidepanelList(): Locator { + return this.sidepanel.getByRole('list', { name: 'Channels' }); + } + + get firstChannelFromList(): Locator { + return this.sidepanelList.getByRole('listitem').first(); + } + + get unreadCheckbox(): Locator { + return this.sidepanel.getByRole('heading').getByRole('checkbox', { name: 'Unread' }); + } + + get unreadToggleLabel(): Locator { + return this.sidepanel.getByRole('heading').locator('label', { hasText: 'Unread' }); + } + + get sidepanelBackButton(): Locator { + return this.sidepanel.getByRole('button', { name: 'Back' }); + } + + getSidepanelHeader(name: string): Locator { + return this.sidepanel.getByRole('heading', { name, exact: true }); + } + + getTeamItemByName(name: string): Locator { + return this.sidepanelList + .getByRole('link') + .filter({ hasText: name }) + .filter({ hasNot: this.page.getByRole('button', { name }) }); + } + + getMainRoomByName(name: string): Locator { + return this.getTeamItemByName(name); + } + + getItemByName(name: string): Locator { + return this.sidepanelList.getByRole('link', { name }); + } + + getSidepanelItem(name: string, subtitle?: string): Locator { + const regex = new RegExp(`${name}.*${subtitle}`); + return this.sidepanelList.getByRole('link', { name: regex }); + } + + getItemUnreadBadge(item: Locator): Locator { + return item.getByRole('status', { name: 'unread' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/table.ts b/apps/meteor/tests/e2e/page-objects/fragments/table.ts new file mode 100644 index 0000000..53c5ce9 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/table.ts @@ -0,0 +1,43 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; + +export class Table { + constructor(protected root: Locator) {} + + /** + * @param fallback also satisfies the wait, for tables the page may legitimately + * replace with something else — an empty state, most commonly. + */ + waitForDisplay(fallback?: Locator) { + return expect(fallback ? this.root.or(fallback) : this.root).toBeVisible(); + } + + findRowByName(name: string): Locator { + return this.root.getByRole('row').filter({ + has: this.root.page().getByText(name, { exact: true }), + }); + } +} + +export class DevicesTable extends Table { + constructor(page: Page) { + super(page.getByRole('table', { name: 'Devices' })); + } + + getRowsForUsername(username: string): Locator { + return this.root.getByRole('row').getByRole('cell', { name: username, exact: true }); + } + + getDeviceRowById(deviceId: string): Locator { + return this.findRowByName(deviceId); + } + + getColumnHeaderByName(name: string): Locator { + return this.root.getByRole('cell', { name, exact: true }); + } + + async orderByLastLogin() { + await this.getColumnHeaderByName('Last login').click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts b/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts new file mode 100644 index 0000000..58245ea --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts @@ -0,0 +1,29 @@ +import type { Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; + +export class ToastMessages { + constructor(private readonly page: Page) {} + + private readonly toastByType = { + success: this.page.locator('.rcx-toastbar--success'), + error: this.page.locator('.rcx-toastbar--error'), + }; + + async dismissToast(type: 'success' | 'error' = 'success') { + await this.toastByType[type].last().getByRole('button', { name: 'Dismiss alert' }).click(); + await this.page.mouse.move(0, 0); + } + + waitForDisplay({ type, message }: { type: 'success' | 'error'; message?: string } = { type: 'success' }) { + if (message) { + return expect( + this.toastByType[type].last().locator(`[role=${type === 'error' ? '"alert"' : '"status"'}]`, { + hasText: message, + }), + ).toBeVisible(); + } + + return expect(this.toastByType[type].last()).toBeVisible(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/toolbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/toolbar.ts new file mode 100644 index 0000000..c1b5181 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/toolbar.ts @@ -0,0 +1,212 @@ +import type { Locator, Page } from '@playwright/test'; + +import { MenuOptions } from './menu'; +import { OmnichannelCloseChatModal, OmnichannelOnHoldModal } from './modals'; + +export abstract class Toolbar { + constructor(protected root: Locator) {} + + click() { + return this.root.click(); + } +} + +export class RoomToolbar extends Toolbar { + readonly menu: MenuOptions; + + constructor(page: Page) { + super(page.getByRole('toolbar', { name: 'Primary Room actions' })); + this.menu = new MenuOptions(page); + } + + get btnRoomInfo() { + return this.root.getByRole('button', { name: 'Room Information' }); + } + + get btnMembers() { + return this.root.getByRole('button', { name: 'Members' }); + } + + get btnVideoCall() { + return this.root.getByRole('button', { name: 'Video Call' }); + } + + get btnVoiceCall() { + return this.root.getByRole('button', { name: 'Voice call' }); + } + + get btnUserInfo(): Locator { + return this.root.getByRole('button', { name: 'User Info' }); + } + + get btnDiscussion(): Locator { + return this.root.getByRole('button', { name: 'Discussions' }); + } + + get btnThreads(): Locator { + return this.root.getByRole('button', { name: 'Threads' }); + } + + get btnFiles(): Locator { + return this.root.getByRole('button', { name: 'Files' }); + } + + get btnMoreOptions(): Locator { + return this.root.getByRole('button', { name: 'Options' }); + } + + get btnSearchMessages(): Locator { + return this.root.getByRole('button', { name: 'Search Messages' }); + } + + get btnDisableE2EEncryption(): Locator { + return this.root.getByRole('button', { name: 'Disable E2E encryption' }); + } + + get menuItemExportMessages(): Locator { + return this.menu.getMenuItem('Export messages'); + } + + get menuItemMentions(): Locator { + return this.menu.getMenuItem('Mentions'); + } + + get menuItemStarredMessages(): Locator { + return this.menu.getMenuItem('Starred Messages'); + } + + get menuItemPinnedMessages(): Locator { + return this.menu.getMenuItem('Pinned Messages'); + } + + get menuItemPruneMessages(): Locator { + return this.menu.getMenuItem('Prune Messages'); + } + + get menuItemAutoTranslate(): Locator { + return this.menu.getMenuItem('Auto-Translate'); + } + + get menuItemNotificationsPreferences(): Locator { + return this.menu.getMenuItem('Notifications Preferences'); + } + + get menuItemDisableE2EEncryption(): Locator { + return this.menu.getMenuItem('Disable E2E encryption'); + } + + get menuItemEnableE2EEncryption(): Locator { + return this.menu.getMenuItem('Enable E2E encryption'); + } + + get menuItemFiles(): Locator { + return this.menu.getMenuItem('Files'); + } + + async openRoomInfo() { + await this.btnRoomInfo.click(); + } + + async openMembersTab() { + await this.btnMembers.click(); + } + + async openUserInfo() { + await this.btnUserInfo.click(); + } + + async openMoreOptions() { + await this.btnMoreOptions.click(); + } + + private get btnTeamChannels(): Locator { + return this.root.getByRole('button', { name: 'Team Channels' }); + } + + async openTeamChannels() { + await this.btnTeamChannels.click(); + } + + async waitFor() { + await this.root.waitFor({ state: 'visible' }); + } +} + +export class TeamToolbar extends RoomToolbar { + private get menuItemTeamMembers() { + return this.menu.getMenuItem('Teams Members'); + } + + private get btnTeamInfo() { + return this.root.getByRole('button', { name: 'Team info' }); + } + + async openTeamMembers() { + await this.menuItemTeamMembers.click(); + } + + async openTeamInfo() { + await this.btnTeamInfo.click(); + } +} + +export class EncryptedRoomToolbar extends RoomToolbar {} + +export class OmnichannelRoomToolbar extends RoomToolbar { + private get btnContactInfo(): Locator { + return this.root.getByRole('button', { name: 'Contact Information' }); + } + + private get btnCannedResponses(): Locator { + return this.root.getByRole('button', { name: 'Canned Responses' }); + } + + async openContactInfo() { + await this.btnContactInfo.click(); + } + + async openCannedResponses() { + await this.btnCannedResponses.click(); + } +} + +export class OmnichannelQuickActionsRoomToolbar extends Toolbar { + private closeChatModal: OmnichannelCloseChatModal; + + private onHoldModal: OmnichannelOnHoldModal; + + constructor(page: Page) { + super(page.getByRole('toolbar', { name: 'Omnichannel Quick Actions' })); + this.closeChatModal = new OmnichannelCloseChatModal(page); + this.onHoldModal = new OmnichannelOnHoldModal(page); + } + + get btnOnHold(): Locator { + return this.root.getByRole('button', { name: 'Place chat On-Hold' }); + } + + get btnForwardChat(): Locator { + return this.root.getByRole('button', { name: 'Forward chat' }); + } + + get btnEndConversation(): Locator { + return this.root.getByRole('button', { name: 'End conversation' }); + } + + /** + * FIXME: This `clickCount` seems a hack for a bad implementation + */ + async placeChatOnHold() { + await this.btnOnHold.click({ clickCount: 2 }); + await this.onHoldModal.confirm(); + } + + async forwardChat() { + await this.btnForwardChat.click(); + } + + async closeChat({ comment = 'any_comment', downloadPDF = false } = {}): Promise { + await this.btnEndConversation.click(); + await this.closeChatModal.confirm(comment, downloadPDF); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/user-card.ts b/apps/meteor/tests/e2e/page-objects/fragments/user-card.ts new file mode 100644 index 0000000..32c7ae4 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/user-card.ts @@ -0,0 +1,31 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; + +export class UserCard { + readonly root: Locator; + + constructor(protected page: Page) { + this.root = page.getByRole('dialog', { name: 'User card', exact: true }); + } + + waitForDisplay() { + return expect(this.root).toBeVisible(); + } + + waitForDismissal() { + return expect(this.root).not.toBeVisible(); + } + + get btnSeeFullProfile(): Locator { + return this.root.getByRole('button', { name: 'See full profile', exact: true }); + } + + get imgUserCard(): Locator { + return this.root.locator('img'); + } + + async openUserInfo() { + await this.btnSeeFullProfile.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts new file mode 100644 index 0000000..0fc68a0 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts @@ -0,0 +1,399 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; + +const timerToSeconds = (time: string): number => { + const [seconds, minutes, hours = '00'] = time.split(':').reverse(); + if (!seconds || !minutes) { + throw new Error('Voice call timer has an invalid time format'); + } + return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); +}; + +export class VoiceCallControls { + private readonly _controls: Locator; + + constructor(controls: Locator) { + this._controls = controls; + } + + get call(): Locator { + return this._controls.getByRole('button', { name: 'Call', exact: true }); + } + + get accept(): Locator { + return this._controls.getByRole('button', { name: 'Accept', exact: true }); + } + + get reject(): Locator { + return this._controls.getByRole('button', { name: 'Reject', exact: true }); + } + + get hangup(): Locator { + return this._controls.getByRole('button', { name: 'End call with', exact: false }); + } + + get cancel(): Locator { + return this._controls.getByRole('button', { name: 'Cancel', exact: true }); + } + + get dialpad(): Locator { + return this._controls.getByRole('button', { name: /Dialpad/i }); + } + + get mute(): Locator { + return this._controls.getByRole('button', { name: /Mute/i }); + } + + get hold(): Locator { + return this._controls.getByRole('button', { name: /Hold|Resume/i }); + } + + get transfer(): Locator { + return this._controls.getByRole('button', { name: 'Forward', exact: true }); + } + + get shareScreen(): Locator { + return this._controls.getByRole('button', { name: /Share screen|Stop sharing screen/i }); + } + + get toggleChat(): Locator { + return this._controls.getByRole('button', { name: /Hide chat|Show chat/i }); + } + + get directMessage(): Locator { + return this._controls.getByRole('button', { name: /Direct message/i }); + } + + get popout(): Locator { + return this._controls.getByRole('button', { name: /Open in new window|Return to main window/i }); + } +} + +export class TransferModal { + private readonly root: Locator; + + private readonly page: Page; + + constructor(page: Page, root: Locator) { + this.page = page; + this.root = root; + } + + get content(): Locator { + return this.root; + } + + get input(): Locator { + return this.root.getByRole('textbox'); + } + + get hangUpAndTransfer(): Locator { + return this.root.getByRole('button', { name: 'Hang up and transfer call', exact: true }); + } + + async transferCall(username: string): Promise { + // await this.input.click(); + await this.input.fill(username); + // Options have to be gotten from the page since the Options are rendered on a portal + await this.page.getByRole('option', { name: username }).getByRole('figure').click(); + await expect(this.content).toContainText(username); + await this.hangUpAndTransfer.click(); + } +} + +export class Widget { + private readonly root: Locator; + + private readonly callControls: VoiceCallControls; + + private readonly headerControls: VoiceCallControls; + + private readonly transferModal: TransferModal; + + constructor(private readonly page: Page) { + this.transferModal = new TransferModal(page, page.getByRole('dialog', { name: 'Transfer call' })); + this.root = page.getByRole('dialog', { name: 'Voice call', exact: false }); + this.callControls = new VoiceCallControls(this.root.getByRole('group')); + this.headerControls = new VoiceCallControls(this.root.getByRole('banner')); + } + + get content(): Locator { + return this.root; + } + + get controls(): VoiceCallControls { + return this.callControls; + } + + get timer(): Locator { + return this.root.getByRole('time'); + } + + get btnShowCallHere(): Locator { + return this.root.getByRole('button', { name: 'Show call here' }); + } + + async showCallHere(): Promise { + await this.btnShowCallHere.click(); + await expect(this.btnShowCallHere).not.toBeVisible(); + } + + async getTimerContentInSeconds() { + const text = await this.timer.innerText(); + return timerToSeconds(text); + } + + async initiateCall(username?: string): Promise { + if (username) { + await this.root.getByRole('textbox', { name: 'Enter username or number' }).fill(username); + await this.page.getByRole('listbox').getByRole('option', { name: username }).click(); + } + await this.callControls.call.click(); + await expect(this.callControls.cancel).toBeVisible(); + } + + async acceptCall(): Promise { + await this.callControls.accept.click(); + await expect(this.callControls.hangup).toBeVisible(); + } + + async hangup(): Promise { + await this.callControls.hangup.click(); + await expect(this.content).not.toBeVisible(); + } + + async reject(): Promise { + await this.callControls.reject.click(); + await expect(this.content).not.toBeVisible(); + } + + async goToDm(): Promise { + await this.headerControls.directMessage.click(); + await expect(this.content).not.toBeVisible(); + } + + async transferCall(username: string): Promise { + await this.callControls.transfer.click(); + await expect(this.transferModal.content).toBeVisible(); + await this.transferModal.transferCall(username); + await expect(this.transferModal.content).not.toBeVisible(); + await expect(this.content).not.toBeVisible(); + } + + async muteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute'); + } + + async unmuteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Mute'); + } + + async holdSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Resume'); + } + + async resumeSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Hold'); + } + + async openDialpad(): Promise { + await this.callControls.dialpad.click(); + await expect(this.callControls.dialpad).toHaveAttribute('title', 'Close dialpad'); + } + + async closeDialpad(): Promise { + await this.callControls.dialpad.click(); + await expect(this.callControls.dialpad).toHaveAttribute('title', 'Open dialpad'); + } + + get allScreenShareVideos(): Locator { + return this.root.locator('video'); + } + + async shareScreen(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen'); + } + + async stopSharing(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen'); + } + + async openPopout(): Promise { + await this.headerControls.popout.click(); + } + + peerCard(username: string): Locator { + return this.root.getByText(username); + } +} + +export class RoomSection { + private readonly root: Locator; + + public readonly callControls: VoiceCallControls; + + public readonly otherControls: VoiceCallControls; + + constructor(root: Locator) { + this.root = root; + // Right now there are 2 control groups in the room view, with no discrimination between them + this.callControls = new VoiceCallControls(this.root.getByRole('group').first()); + this.otherControls = new VoiceCallControls(this.root.getByRole('group').last()); + } + + get content(): Locator { + return this.root; + } + + get controls(): VoiceCallControls { + return this.callControls; + } + + get timer(): Locator { + return this.root.getByRole('time'); + } + + get allScreenShareVideos(): Locator { + return this.root.locator('video'); + } + + get btnShowCallHere(): Locator { + return this.root.getByRole('button', { name: 'Show call here' }); + } + + async showCallHere(): Promise { + await this.btnShowCallHere.click(); + await expect(this.btnShowCallHere).not.toBeVisible(); + } + + async getTimerContentInSeconds() { + const text = await this.timer.innerText(); + return timerToSeconds(text); + } + + async hangup(): Promise { + await this.callControls.hangup.click(); + await expect(this.content).not.toBeVisible(); + } + + async hideChat(): Promise { + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + await this.otherControls.toggleChat.click(); + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i); + } + + async showChat(): Promise { + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Show chat/i); + await this.otherControls.toggleChat.click(); + await expect(this.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + } + + async muteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Unmute'); + } + + async unmuteSelf(): Promise { + await this.callControls.mute.click(); + await expect(this.callControls.mute).toHaveAttribute('title', 'Mute'); + } + + async holdSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Resume'); + } + + async resumeSelf(): Promise { + await this.callControls.hold.click(); + await expect(this.callControls.hold).toHaveAttribute('title', 'Hold'); + } + + async shareScreen(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Stop sharing screen'); + } + + async stopSharing(): Promise { + await this.callControls.shareScreen.click(); + await expect(this.callControls.shareScreen).toHaveAttribute('title', 'Share screen'); + } + + peerCard(username: string): Locator { + return this.root.getByText(username); + } + + async openPopout(): Promise { + await this.otherControls.popout.click(); + } +} + +export class PopoutPage extends RoomSection { + private readonly page: Page; + + constructor(page: Page) { + super(page.getByRole('main', { name: 'Voice call' })); + this.page = page; + } + + override async hangup(): Promise { + const pageClosed = new Promise((resolve) => this.page.on('close', () => resolve(true))); + await this.callControls.hangup.click(); + await expect(pageClosed).resolves.toBe(true); + } +} + +export class VoiceCalls { + public readonly widget: Widget; + + public readonly roomSection: RoomSection; + + public popoutPage: PopoutPage | undefined; + + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + this.widget = new Widget(page); + this.roomSection = new RoomSection(page.getByRole('region', { name: 'Voice call' })); + } + + get popout(): PopoutPage { + if (!this.popoutPage) { + throw new Error('Voice call - Popout is not open (call openPopout(Room|Widget) first)'); + } + return this.popoutPage; + } + + // Open the popout from the room section + async openPopoutRoom(): Promise { + const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.roomSection.openPopout()]); + if (!popout) { + throw new Error('Voice call - Failed to open popout'); + } + await popout.waitForLoadState(); + popout.on('close', () => { + this.popoutPage = undefined; + }); + this.popoutPage = new PopoutPage(popout); + } + + // Open the popout from the widget + async openPopoutWidget(): Promise { + const [popout] = await Promise.all([this.page.waitForEvent('popup'), this.widget.openPopout()]); + if (!popout) { + throw new Error('Voice call - Failed to open popout'); + } + await popout.waitForLoadState(); + popout.on('close', () => { + this.popoutPage = undefined; + }); + this.popoutPage = new PopoutPage(popout); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts new file mode 100644 index 0000000..f2b182c --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -0,0 +1,237 @@ +import type { Locator, Page } from '@playwright/test'; + +import { + HomeContent, + Navbar, + Sidepanel, + RoomSidebar, + ToastMessages, + RoomComposer, + ThreadComposer, + MembersFlexTab, + ChannelsFlexTab, + NotificationPreferencesFlexTab, + AutoTranslateFlexTab, + ExportMessagesFlexTab, + PruneMessagesFlexTab, + SearchMessagesFlexTab, + RoomInfoFlexTab, + ThreadsFlexTab, + EditRoomFlexTab, + UserInfoFlexTab, + FilesFlexTab, +} from './fragments'; +import { RoomToolbar } from './fragments/toolbar'; +import { UserCard } from './fragments/user-card'; +import { VoiceCalls } from './fragments/voice-calls'; +import { RoutedPage } from './routed-page'; + +export class HomeChannel extends RoutedPage { + protected readonly route: string = '/home'; + + readonly content: HomeContent; + + readonly sidebar: RoomSidebar; + + readonly sidepanel: Sidepanel; + + readonly navbar: Navbar; + + readonly userCard: UserCard; + + private _tabs: { + members: MembersFlexTab; + userInfo: UserInfoFlexTab; + room: RoomInfoFlexTab; + editRoom: EditRoomFlexTab; + channels: ChannelsFlexTab; + notificationPreferences: NotificationPreferencesFlexTab; + autoTranslate: AutoTranslateFlexTab; + exportMessages: ExportMessagesFlexTab; + pruneMessages: PruneMessagesFlexTab; + searchMessages: SearchMessagesFlexTab; + threads: ThreadsFlexTab; + files: FilesFlexTab; + }; + + readonly roomToolbar: RoomToolbar; + + readonly voiceCalls: VoiceCalls; + + readonly toastMessage: ToastMessages; + + readonly composer: RoomComposer; + + readonly threadComposer: ThreadComposer; + + constructor(page: Page) { + super(page); + this.content = new HomeContent(page); + this.sidebar = new RoomSidebar(page); + this.sidepanel = new Sidepanel(page); + this.navbar = new Navbar(page); + this.userCard = new UserCard(page); + this._tabs = { + members: new MembersFlexTab(page), + userInfo: new UserInfoFlexTab(page), + room: new RoomInfoFlexTab(page.getByRole('dialog', { name: 'Channel info' })), + editRoom: new EditRoomFlexTab(page.getByRole('dialog', { name: 'Edit channel' })), + channels: new ChannelsFlexTab(page), + notificationPreferences: new NotificationPreferencesFlexTab(page), + autoTranslate: new AutoTranslateFlexTab(page), + exportMessages: new ExportMessagesFlexTab(page), + pruneMessages: new PruneMessagesFlexTab(page), + searchMessages: new SearchMessagesFlexTab(page), + threads: new ThreadsFlexTab(page), + files: new FilesFlexTab(page), + }; + this.roomToolbar = new RoomToolbar(page); + this.voiceCalls = new VoiceCalls(page); + this.toastMessage = new ToastMessages(page); + this.composer = new RoomComposer(page); + this.threadComposer = new ThreadComposer(page); + } + + get tabs() { + return this._tabs; + } + + async waitForReady(): Promise { + await this.homepageHeader.waitFor({ state: 'visible' }); + } + + async gotoChannel(name: string): Promise { + await this.navigateTo(`/channel/${name}`, () => this.content.waitForChannel()); + } + + async gotoGroup(name: string): Promise { + await this.navigateTo(`/group/${name}`, () => this.content.waitForChannel()); + } + + /** Opens a channel scrolled to a message. Pass `isThread` when the message lives in a thread. */ + async gotoChannelMessage(name: string, messageId: string, isThread = false): Promise { + await this.navigateTo(`/channel/${name}?msg=${messageId}`, async () => { + await this.content.waitForChannel(); + + if (isThread) { + await this.content.waitForThread(); + } + }); + } + + /** Opens a thread by the id of the message that started it. */ + async gotoChannelThread(name: string, threadMessageId: string): Promise { + await this.navigateTo(`/channel/${name}/thread/${threadMessageId}`, async () => { + await this.content.waitForChannel(); + await this.content.waitForThread(); + }); + } + + /** Opens a channel with the Prune Messages panel open. */ + async gotoChannelCleanHistory(name: string): Promise { + await this.navigateTo(`/channel/${name}/clean-history`, this.tabs.pruneMessages.root); + } + + get btnContextualbarClose(): Locator { + return this.page.locator('[data-qa="ContextualbarActionClose"]'); + } + + get userCardToolbar(): Locator { + return this.page.locator('[role=toolbar][aria-label="User card actions"]'); + } + + getRoomHeaderFavoriteBtn(isEnterprise: boolean): Locator { + return isEnterprise ? this.btnCategorySelector : this.roomHeaderFavoriteBtn; + } + + private get roomHeaderFavoriteBtn(): Locator { + return this.page.getByRole('main').getByRole('button', { name: 'Favorite' }); + } + + private get btnCategorySelector(): Locator { + return this.page.getByRole('main').getByRole('button', { name: 'Move to', exact: true }); + } + + // TODO: this button should name open room info or something instead of the room name + getBtnOpenRoomInfo(roomName: string): Locator { + return this.page.getByRole('main').getByRole('button', { name: roomName, exact: true }); + } + + get roomHeaderToolbar(): Locator { + return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]'); + } + + get markUnread(): Locator { + return this.page.locator('role=menuitem[name="Mark Unread"]'); + } + + get dialogEnterE2EEPassword(): Locator { + return this.page.getByRole('dialog', { name: 'Enter E2EE password' }); + } + + get dialogSaveE2EEPassword(): Locator { + return this.page.getByRole('dialog', { name: 'Save your new E2EE password' }); + } + + get btnRoomSaveE2EEPassword(): Locator { + return this.page.getByRole('main').getByRole('button', { name: 'Save E2EE password' }); + } + + get btnRoomEnterE2EEPassword(): Locator { + return this.page.getByRole('main').getByRole('button', { name: 'Enter your E2E password' }); + } + + get btnSavedMyPassword(): Locator { + return this.dialogSaveE2EEPassword.getByRole('button', { name: 'I saved my password' }); + } + + get bannerSaveEncryptionPassword(): Locator { + return this.page.getByRole('button', { name: 'Save your new E2EE password' }); + } + + get bannerEnterE2EEPassword(): Locator { + return this.page.getByRole('button', { name: 'Enter your E2E password' }); + } + + get audioRecorder(): Locator { + return this.page.getByRole('group', { name: 'Audio recorder', exact: true }); + } + + get homepageHeader(): Locator { + return this.page.locator('main').getByRole('heading', { name: 'Home' }); + } + + get dialogEmojiPicker(): Locator { + return this.page.getByRole('dialog', { name: 'Emoji picker' }); + } + + get scrollerEmojiPicker(): Locator { + return this.dialogEmojiPicker.locator('[data-overlayscrollbars]'); + } + + get btnJoinChannel(): Locator { + return this.content.btnJoinChannel; + } + + getEmojiPickerTabByName(name: string) { + return this.dialogEmojiPicker.locator(`role=tablist >> role=tab[name="${name}"]`); + } + + getEmojiByName(name: string) { + return this.dialogEmojiPicker.locator(`role=tabpanel >> [data-emoji="${name}"]`); + } + + async pickEmoji(emoji: string, section = 'Smileys & People') { + await this.composer.btnEmoji.click(); + await this.getEmojiPickerTabByName(section).click(); + await this.getEmojiByName(emoji).click(); + } + + async waitForHome(): Promise { + await this.waitForReady(); + } + + async waitForRoomLoad(): Promise { + await this.roomHeaderToolbar.waitFor({ state: 'visible' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/home-discussion.ts b/apps/meteor/tests/e2e/page-objects/home-discussion.ts new file mode 100644 index 0000000..7764fad --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/home-discussion.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test'; + +import { RoomInfoFlexTab } from './fragments'; +import { HomeChannel } from './home-channel'; + +export class HomeDiscussion extends HomeChannel { + constructor(page: Page) { + super(page); + this.tabs.room = new RoomInfoFlexTab(page.getByRole('dialog', { name: 'Discussion Info' })); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/home-omnichannel.ts b/apps/meteor/tests/e2e/page-objects/home-omnichannel.ts new file mode 100644 index 0000000..62bb924 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/home-omnichannel.ts @@ -0,0 +1,52 @@ +import type { Locator, Page } from '@playwright/test'; + +import { HomeOmnichannelContent, OmnichannelQuickActionsRoomToolbar, OmnichannelRoomToolbar, OmnichannelSidebar } from './fragments'; +import { OmnichannelEditRoomFlexTab } from './fragments/flextabs'; +import { OmnichannelRoomInfoFlexTab } from './fragments/flextabs/room-info-flextab'; +import { HomeChannel } from './home-channel'; +import { + OmnichannelCannedResponses, + OmnichannelTranscript, + OmnichannelContactCenterContacts, + OmnichannelContactCenterChats, +} from './omnichannel'; + +export class HomeOmnichannel extends HomeChannel { + readonly omnisidenav: OmnichannelSidebar; + + readonly transcript: OmnichannelTranscript; + + readonly cannedResponses: OmnichannelCannedResponses; + + readonly contacts: OmnichannelContactCenterContacts; + + readonly chats: OmnichannelContactCenterChats; + + readonly roomInfo: OmnichannelRoomInfoFlexTab; + + readonly editRoomInfo: OmnichannelEditRoomFlexTab; + + readonly quickActionsRoomToolbar: OmnichannelQuickActionsRoomToolbar; + + override readonly content: HomeOmnichannelContent; + + override readonly roomToolbar: OmnichannelRoomToolbar; + + constructor(page: Page) { + super(page); + this.omnisidenav = new OmnichannelSidebar(page); + this.transcript = new OmnichannelTranscript(page); + this.cannedResponses = new OmnichannelCannedResponses(page); + this.contacts = new OmnichannelContactCenterContacts(page); + this.chats = new OmnichannelContactCenterChats(page); + this.roomInfo = new OmnichannelRoomInfoFlexTab(page); + this.quickActionsRoomToolbar = new OmnichannelQuickActionsRoomToolbar(page); + this.content = new HomeOmnichannelContent(page); + this.roomToolbar = new OmnichannelRoomToolbar(page); + this.editRoomInfo = new OmnichannelEditRoomFlexTab(page); + } + + get btnContactInfo(): Locator { + return this.page.getByRole('button', { name: 'Contact Information' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/home-team.ts b/apps/meteor/tests/e2e/page-objects/home-team.ts new file mode 100644 index 0000000..3fc7d1c --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/home-team.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; + +import { EditTeamFlexTab, TeamInfoFlexTab, TeamToolbar } from './fragments'; +import { HomeChannel } from './home-channel'; + +export class HomeTeam extends HomeChannel { + readonly headerToolbar: TeamToolbar; + + constructor(page: Page) { + super(page); + this.headerToolbar = new TeamToolbar(page); + } + + override get tabs() { + return { + ...super.tabs, + room: new TeamInfoFlexTab(this.page), + editRoom: new EditTeamFlexTab(this.page), + }; + } +} diff --git a/apps/meteor/tests/e2e/page-objects/index.ts b/apps/meteor/tests/e2e/page-objects/index.ts new file mode 100644 index 0000000..5982ffc --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/index.ts @@ -0,0 +1,26 @@ +export * from './account-accessibility'; +export * from './account-feature-preview'; +export * from './account-omnichannel'; +export * from './account-profile'; +export * from './account-tokens'; +export * from './account-security'; +export * from './admin-email-inboxes'; +export * from './admin-rooms'; +export * from './admin-users'; +export * from './admin-settings'; +export * from './admin-engagement'; +export * from './admin-info'; +export * from './admin-integrations'; +export * from './admin-permissions'; +export * from './admin-third-party-login'; +export * from './admin-emojis'; +export * from './admin-imports'; +export * from './admin-mailer'; +export * from './admin-moderation'; +export * from './admin'; +export * from './auth'; +export * from './home-channel'; +export * from './home-discussion'; +export * from './home-team'; +export * from './home-omnichannel'; +export * from './marketplace'; diff --git a/apps/meteor/tests/e2e/page-objects/login.ts b/apps/meteor/tests/e2e/page-objects/login.ts new file mode 100644 index 0000000..56fb562 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/login.ts @@ -0,0 +1,61 @@ +import type { Page } from '@playwright/test'; +import type { IUser } from '@rocket.chat/core-typings'; +import { MongoClient } from 'mongodb'; + +import * as constants from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; +import { expect } from '../utils/test'; + +export class LoginPage { + constructor(protected readonly page: Page) {} + + get loginButton() { + return this.page.getByRole('button', { name: 'Login', exact: true }); + } + + /** + * Ideally the previous action should ensure the user is logged out and we should just assume to be at the login page + * */ + async waitForIt() { + await this.loginButton.waitFor(); + } + + /** Navigates to `url` and waits for the login screen — for routes that bounce an anonymous visitor. */ + async goto(url = '/home'): Promise { + await this.page.goto(url); + await this.waitForIt(); + } + + protected async waitForLogin() { + await expect(this.loginButton).not.toBeVisible(); + await expect(this.page.getByRole('main')).toBeVisible(); + } + + async loginByUserState(userState: IUserState, options: { except: string[] } = { except: [] }) { + // Creates a login token for the user + const connection = await MongoClient.connect(constants.URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne( + { username: userState.data.username }, + { $addToSet: { 'services.resume.loginTokens': { when: userState.data.loginExpire, hashedToken: userState.data.hashedToken } } }, + ); + + await connection.close(); + + const localStorageItems = userState.state.origins[0].localStorage.filter((item) => options.except.indexOf(item.name) === -1); + + // Injects the login token to the local storage + await this.page.evaluate((items) => { + items.forEach(({ name, value }) => { + window.localStorage.setItem(name, value); + }); + + require('meteor/accounts-base').Accounts._pollStoredLoginToken(); + }, localStorageItems); + + await this.waitForLogin(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/marketplace.ts b/apps/meteor/tests/e2e/page-objects/marketplace.ts new file mode 100644 index 0000000..b82eb57 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/marketplace.ts @@ -0,0 +1,51 @@ +import type { Locator } from '@playwright/test'; + +import { RoutedPage } from './routed-page'; + +export class Marketplace extends RoutedPage { + protected readonly route = '/marketplace/private'; + + async waitForReady(): Promise { + await this.btnUploadPrivateApp.waitFor({ state: 'visible' }); + } + + get btnUploadPrivateApp(): Locator { + return this.page.locator('role=button[name="Upload private app"]'); + } + + get btnInstallPrivateApp(): Locator { + return this.page.locator('role=button[name="Install"]'); + } + + get btnUploadPrivateAppFile(): Locator { + return this.page.locator('role=button[name="Browse Files"]'); + } + + get appStatusTag(): Locator { + return this.page.locator('[data-qa-type="app-status-tag"]'); + } + + get btnConfirmAppUploadModal(): Locator { + return this.page.locator('role=button[name="Upload anyway"]'); + } + + get lastAppRow(): Locator { + return this.page.locator('[data-qa-type="app-row"]').last(); + } + + get appMenu(): Locator { + return this.page.getByTitle('More options'); + } + + get btnEnableApp(): Locator { + return this.page.getByRole('menuitem', { name: 'Enable' }); + } + + get btnDisableApp(): Locator { + return this.page.getByRole('menuitem', { name: 'Disable' }); + } + + get btnConfirmAppUpdate(): Locator { + return this.page.locator('role=button[name="Yes"]'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/index.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/index.ts new file mode 100644 index 0000000..3816f1d --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/index.ts @@ -0,0 +1,19 @@ +export * from './omnichannel-agents'; +export * from './omnichannel-departments'; +export * from './omnichannel-canned-responses'; +export * from './omnichannel-contact-center'; +export * from './omnichannel-livechat'; +export * from './omnichannel-livechat-appearance'; +export * from './omnichannel-livechat-embedded'; +export * from './omnichannel-manager'; +export * from './omnichannel-custom-fields'; +export * from './omnichannel-units'; +export * from './omnichannel-monitors'; +export * from './omnichannel-settings'; +export * from './omnichannel-business-hours'; +export * from './omnichannel-tags'; +export * from './omnichannel-transcript'; +export * from './omnichannel-triggers'; +export * from './omnichannel-priorities'; +export * from './omnichannel-reports'; +export * from './omnichannel-sla-policies'; diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-admin.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-admin.ts new file mode 100644 index 0000000..16c7765 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-admin.ts @@ -0,0 +1,98 @@ +import type { Locator, Page } from '@playwright/test'; + +import { expect } from '../../utils/test'; +import { OmnichannelSidebar, ToastMessages } from '../fragments'; +import { ConfirmDeleteModal } from '../fragments/modals'; +import { Table } from '../fragments/table'; +import { RoutedPage } from '../routed-page'; + +export enum OmnichannelSectionsHref { + currentChats = '/omnichannel/current/chats', + currentContacts = '/omnichannel/current/contacts', + agents = '/omnichannel/agents', + managers = '/omnichannel/managers', + monitors = '/omnichannel/monitors', + departments = '/omnichannel/departments', + units = '/omnichannel/units', + customFields = '/omnichannel/customfields', + cannedResponses = '/omnichannel/canned-responses', + triggers = '/omnichannel/triggers', + tags = '/omnichannel/tags', + priorities = '/omnichannel/priorities', + slaPolicies = '/omnichannel/sla-policies', + businessHours = '/omnichannel/businessHours', + appearance = '/omnichannel/appearance', + livechatAppearance = '/omnichannel/livechat-appearance', + analytics = '/omnichannel/analytics', + reports = '/omnichannel/reports', + realtimeMonitoring = '/omnichannel/realtime-monitoring', + securityPrivacy = '/omnichannel/security-privacy', +} + +/** + * A page under `/omnichannel/*`. Subclasses declare `route` and `title` and get + * `goto()`, `pageHeader` and `table` for free. + */ +export abstract class OmnichannelAdmin extends RoutedPage { + protected abstract override readonly route: OmnichannelSectionsHref; + + /** Text of the page's main heading. */ + protected abstract readonly title: string; + + /** Accessible name of the page's main table, when it differs from `title`. */ + protected readonly tableName?: string; + + protected readonly toastMessage: ToastMessages; + + readonly sidebar: OmnichannelSidebar; + + readonly deleteModal: ConfirmDeleteModal; + + constructor(page: Page) { + super(page); + this.sidebar = new OmnichannelSidebar(page); + this.toastMessage = new ToastMessages(page); + this.deleteModal = new ConfirmDeleteModal(page.getByRole('dialog', { name: 'Are you sure?' })); + } + + get inputSearch() { + return this.page.getByRole('main').getByRole('textbox', { name: 'Search' }); + } + + get btnSaveChanges(): Locator { + return this.page.getByRole('button', { name: 'Save changes' }); + } + + getButtonByType(type: 'unit' | 'SLA policy' | 'tag' | 'trigger' | 'department' | 'custom field'): Locator { + return this.page.locator('header').getByRole('button', { name: `Create ${type}` }); + } + + async search(text: string) { + await this.inputSearch.fill(text); + } + + async clearSearch() { + await this.inputSearch.fill(''); + } + + get emptyState() { + return this.page.getByRole('group', { name: 'No results found', exact: true }); + } + + waitForEmptyState() { + return expect(this.emptyState).toBeVisible(); + } + + get pageHeader(): Locator { + return this.page.locator('main').getByRole('heading', { name: this.title, exact: true }); + } + + get table(): Table { + return new Table(this.page.getByRole('table', { name: this.tableName ?? this.title })); + } + + async waitForReady(): Promise { + await this.pageHeader.waitFor({ state: 'visible' }); + await this.table.waitForDisplay(this.emptyState); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-agents.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-agents.ts new file mode 100644 index 0000000..e4152ce --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-agents.ts @@ -0,0 +1,111 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; +import { Listbox } from '../fragments/listbox'; + +class OmnichannelEditAgentFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Edit User' })); + this.listbox = new Listbox(page); + } + + get inputMaxChats(): Locator { + return this.root.locator('input[name="maxNumberSimultaneousChat"]'); + } + + get inputDepartment(): Locator { + return this.root.getByLabel('Departments').getByRole('textbox'); + } + + getDepartmentOption(name: string) { + return this.listbox.getOption(name); + } + + async selectDepartment(name: string) { + await this.inputDepartment.click(); + await this.inputDepartment.fill(name); + await this.listbox.selectOption(name); + // TODO: This is necessary due to the PaginatedMultiSelectFiltered not closing the list when an option is selected nor when close is clicked. + // The line below can be removed once the component is adjusted in Fuselage + await this.root.click(); + } + + findSelectedDepartment(name: string) { + return this.root.getByLabel('Departments', { exact: true }).getByRole('option', { name }); + } + + private get inputStatus(): Locator { + return this.root.getByText('Status', { exact: true }); + } + + async selectStatus(status: string) { + await this.inputStatus.click(); + await this.listbox.selectOption(status); + } +} + +class OmnichannelAgentInfoFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'User Info' })); + } + + get btnEdit(): Locator { + return this.root.getByRole('button', { name: 'Edit', exact: true }); + } + + get btnRemove(): Locator { + return this.root.getByRole('button', { name: 'Remove', exact: true }); + } +} + +export class OmnichannelAgents extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.agents; + + protected readonly title = 'Agents'; + + readonly editAgent: OmnichannelEditAgentFlexTab; + + readonly agentInfo: OmnichannelAgentInfoFlexTab; + + readonly listbox: Listbox; + + constructor(page: Page) { + super(page); + this.editAgent = new OmnichannelEditAgentFlexTab(page); + this.agentInfo = new OmnichannelAgentInfoFlexTab(page); + this.listbox = new Listbox(page); + } + + get inputUsername(): Locator { + return this.page.getByRole('textbox', { name: 'Username' }); + } + + get btnAddAgent(): Locator { + return this.page.getByRole('button', { name: 'Add agent', exact: true }); + } + + async deleteAgent(name: string) { + await this.search(name); + await this.table.findRowByName(name).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } + + get scrollContainer(): Locator { + return this.page.locator('#position-container').getByTestId('virtuoso-scroller'); + } + + scrollToListBottom() { + return this.scrollContainer.evaluate((el) => { + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event('scroll')); + }); + } + + async selectUsername(username: string) { + await this.inputUsername.fill(username); + await this.listbox.selectOption(username); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-business-hours.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-business-hours.ts new file mode 100644 index 0000000..bd4e84a --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-business-hours.ts @@ -0,0 +1,60 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { Listbox } from '../fragments/listbox'; + +export class OmnichannelBusinessHours extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.businessHours; + + protected readonly title = 'Business Hours'; + + readonly listbox: Listbox; + + constructor(page: Page) { + super(page); + this.listbox = new Listbox(page); + } + + get btnCreateBusinessHour(): Locator { + return this.page.locator('header').getByRole('button', { name: 'New', exact: true }); + } + + get btnSave(): Locator { + return this.page.getByRole('button', { name: 'Save', exact: true }); + } + + get btnBack(): Locator { + return this.page.locator('header').getByRole('button', { name: 'Back', exact: true }); + } + + get inputName(): Locator { + return this.page.getByRole('textbox', { name: 'Name', exact: true }); + } + + get fieldDepartment(): Locator { + return this.page.getByLabel('Departments', { exact: true }); + } + + get inputDepartments(): Locator { + return this.fieldDepartment.getByRole('textbox'); + } + + async deleteBusinessHour(name: string) { + await this.table.findRowByName(name).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } + + getCheckboxByLabel(name: string): Locator { + return this.page.locator('label', { has: this.page.getByRole('checkbox', { name }) }); + } + + findDepartmentsChipOption(name: string) { + return this.fieldDepartment.getByRole('option', { name, exact: true }); + } + + async selectDepartment(name: string) { + await this.inputDepartments.click(); + await this.inputDepartments.fill(name); + await this.listbox.selectOption(name); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-canned-responses.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-canned-responses.ts new file mode 100644 index 0000000..2317fa6 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-canned-responses.ts @@ -0,0 +1,54 @@ +import type { Locator } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; + +export class OmnichannelCannedResponses extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.cannedResponses; + + protected readonly title = 'Canned Responses'; + + get inputShortcut() { + return this.page.getByRole('textbox', { name: 'Shortcut', exact: true }); + } + + get inputMessage() { + return this.page.getByRole('textbox', { name: 'Message', exact: true }); + } + + get radioPublic() { + return this.page.locator('label', { has: this.page.getByRole('radio', { name: 'Public' }) }); + } + + get radioPrivate() { + return this.page.locator('label', { has: this.page.getByRole('radio', { name: 'Private' }) }); + } + + get inputTags() { + return this.page.getByRole('textbox', { name: 'Tags', exact: true }); + } + + get btnAddTag() { + return this.page.getByRole('button', { name: 'Add', exact: true }); + } + + listItem(name: string) { + return this.page.getByText(`!${name}`, { exact: true }); + } + + async addTag(tag: string) { + await this.inputTags.fill(tag); + await this.btnAddTag.click(); + } + + get btnEdit() { + return this.page.getByRole('button', { name: 'Edit', exact: true }); + } + + get btnSave(): Locator { + return this.page.getByRole('button', { name: 'Save', exact: true }); + } + + get btnNew(): Locator { + return this.page.locator('role=button[name="Create canned response"]').first(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/index.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/index.ts new file mode 100644 index 0000000..03b3709 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/index.ts @@ -0,0 +1,2 @@ +export * from './omnichannel-contact-center-chats'; +export * from './omnichannel-contact-center-contacts'; diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-chats.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-chats.ts new file mode 100644 index 0000000..d225184 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-chats.ts @@ -0,0 +1,182 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelContactCenter } from './omnichannel-contact-center'; +import { FlexTab } from '../../fragments/flextabs/flextab'; +import { Listbox } from '../../fragments/listbox'; +import { OmnichannelConfirmRemoveChat } from '../../fragments/modals'; +import { Table } from '../../fragments/table'; +import { OmnichannelSectionsHref } from '../omnichannel-admin'; + +class OmnichannelConversationFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Conversation' })); + } + + private get btnOpenChat(): Locator { + return this.root.getByRole('button', { name: 'Open chat' }); + } + + async openChat() { + await this.btnOpenChat.click(); + await this.waitForDismissal(); + } +} + +export class OmnichannelChatsFilters extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Filters' })); + this.listbox = new Listbox(page); + } + + get inputFrom(): Locator { + return this.root.locator('input[name="from"]'); + } + + get inputTo(): Locator { + return this.root.locator('input[name="to"]'); + } + + get btnApply(): Locator { + return this.root.getByRole('button', { name: 'Apply' }); + } + + get inputServedBy(): Locator { + return this.root.getByLabel('Served By').locator('input'); + } + + get inputDepartment(): Locator { + return this.root.getByLabel('Department').locator('input'); + } + + get selectStatusContainer(): Locator { + return this.root.getByRole('button', { name: 'Status' }); + } + + get inputTags(): Locator { + return this.root.getByLabel('Tags').locator('input'); + } + + get inputUnits(): Locator { + return this.root.getByLabel('Units').locator('input'); + } + + get btnClearFilters(): Locator { + return this.root.getByRole('button', { name: 'Clear filters' }); + } + + async selectServedBy(option: string) { + await this.inputServedBy.click(); + await this.inputServedBy.fill(option); + await this.listbox.selectOption(option); + await this.btnApply.click(); + } + + async selectStatus(option: string) { + await this.selectStatusContainer.click(); + await this.listbox.selectOption(option); + await this.btnApply.click(); + } + + async selectDepartment(option: string) { + await this.inputDepartment.click(); + await this.inputDepartment.fill(option); + await this.listbox.selectOption(option); + await this.inputDepartment.click(); + await this.btnApply.click(); + } + + async selectTag(option: string) { + await this.inputTags.click(); + await this.inputTags.fill(option); + await this.listbox.selectOption(option); + await this.inputTags.click(); + await this.btnApply.click(); + } + + async removeTag(option: string) { + await this.inputTags.click(); + await this.listbox.selectOption(option); + await this.inputTags.click(); + await this.btnApply.click(); + } + + async selectUnit(unitName: string) { + await this.inputUnits.click(); + await this.inputUnits.fill(unitName); + await this.listbox.selectOption(unitName); + await this.btnApply.click(); + } + + async addTag(option: string) { + await this.inputTags.click(); + await this.listbox.selectOption(option); + await this.inputTags.click(); + } +} + +class OmnichannelContactCenterChatsTable extends Table { + btnRemoveByName(name: string): Locator { + return this.findRowByName(name).getByRole('button', { name: 'Remove' }); + } +} + +export class OmnichannelContactCenterChats extends OmnichannelContactCenter { + protected readonly route = OmnichannelSectionsHref.currentChats; + + protected override readonly tableName = 'Omnichannel Contact Center Chats'; + + readonly filters: OmnichannelChatsFilters; + + readonly confirmRemoveChatModal: OmnichannelConfirmRemoveChat; + + readonly conversation: OmnichannelConversationFlexTab; + + constructor(page: Page) { + super(page); + this.filters = new OmnichannelChatsFilters(page); + this.confirmRemoveChatModal = new OmnichannelConfirmRemoveChat(page); + this.conversation = new OmnichannelConversationFlexTab(page); + } + + override get table(): OmnichannelContactCenterChatsTable { + return new OmnichannelContactCenterChatsTable(this.page.getByRole('table', { name: this.tableName })); + } + + async removeChatByName(name: string) { + await this.table.btnRemoveByName(name).click(); + await this.confirmRemoveChatModal.confirm(); + } + + async openChat(name: string) { + await this.inputSearch.fill(name); + await this.table.findRowByName(name).click(); + await this.conversation.openChat(); + await this.page.locator('#main-content').waitFor(); + } + + get btnFilters(): Locator { + return this.page.getByRole('button', { name: 'Filters' }); + } + + btnStatusChip(name: string): Locator { + return this.page.getByRole('button', { name: `Status: ${name}` }); + } + + btnServedByChip(name: string): Locator { + return this.page.getByRole('button', { name: `Served by: ${name}` }); + } + + btnDepartmentChip(name: string): Locator { + return this.page.getByRole('button', { name: `Department: ${name}` }); + } + + btnSearchChip(name: string): Locator { + return this.page.getByRole('button', { name: `Text: ${name}` }); + } + + btnUnitsChip(name: string): Locator { + return this.page.getByRole('button', { name: `Units: ${name}` }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-contacts.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-contacts.ts new file mode 100644 index 0000000..509f224 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center-contacts.ts @@ -0,0 +1,40 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelContactInfo } from '../omnichannel-info'; +import { OmnichannelContactCenter } from './omnichannel-contact-center'; +import { MenuMoreActions } from '../../fragments'; +import { OmnichannelEditContactFlexTab } from '../../fragments/flextabs'; +import { OmnichannelDeleteContactModal } from '../../fragments/modals'; +import { OmnichannelSectionsHref } from '../omnichannel-admin'; + +export class OmnichannelContactCenterContacts extends OmnichannelContactCenter { + protected readonly route = OmnichannelSectionsHref.currentContacts; + + protected override readonly tableName = 'Omnichannel Contact Center Contacts'; + + readonly contactInfo: OmnichannelContactInfo; + + readonly editContact: OmnichannelEditContactFlexTab; + + readonly deleteContactModal: OmnichannelDeleteContactModal; + + readonly menu: MenuMoreActions; + + constructor(page: Page) { + super(page); + this.contactInfo = new OmnichannelContactInfo(page); + this.editContact = new OmnichannelEditContactFlexTab(page); + this.deleteContactModal = new OmnichannelDeleteContactModal(page); + this.menu = new MenuMoreActions(page); + } + + get btnNewContact(): Locator { + return this.page.getByRole('button', { name: 'New contact' }); + } + + async deleteContact(contactName: string) { + await this.table.findRowByName(contactName).getByRole('button', { name: 'More Actions' }).click(); + await this.menu.selectMenuItem('Delete'); + await this.deleteContactModal.waitForDisplay(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center.ts new file mode 100644 index 0000000..5354d90 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-contact-center/omnichannel-contact-center.ts @@ -0,0 +1,15 @@ +import type { Locator } from '@playwright/test'; + +import { OmnichannelAdmin } from '../omnichannel-admin'; + +export abstract class OmnichannelContactCenter extends OmnichannelAdmin { + protected readonly title = 'Omnichannel Contact Center'; + + get tabContacts(): Locator { + return this.page.getByRole('tab', { name: 'Contacts' }); + } + + get tabChats(): Locator { + return this.page.getByRole('tab', { name: 'Chats' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-custom-fields.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-custom-fields.ts new file mode 100644 index 0000000..6a76d87 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-custom-fields.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; + +class OmnichannelManageCustomFieldsFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Custom Field' })); + } + + get inputField(): Locator { + return this.root.getByRole('textbox', { name: 'Field', exact: true }); + } + + get inputLabel(): Locator { + return this.root.getByRole('textbox', { name: 'Label', exact: true }); + } + + get labelVisible(): Locator { + return this.root.getByText('Visible'); + } +} + +export class OmnichannelCustomFields extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.customFields; + + protected readonly title = 'Custom Fields'; + + readonly manageCustomFields: OmnichannelManageCustomFieldsFlexTab; + + constructor(page: Page) { + super(page); + this.manageCustomFields = new OmnichannelManageCustomFieldsFlexTab(page); + } + + async createNew() { + await this.getButtonByType('custom field').click(); + } + + async deleteCustomField(fieldName: string) { + await this.table.findRowByName(fieldName).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-departments.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-departments.ts new file mode 100644 index 0000000..0c3678a --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-departments.ts @@ -0,0 +1,127 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { MenuOptions } from '../fragments'; +import { Listbox } from '../fragments/listbox'; +import { OmnichannelUpsellDepartmentsModal, ConfirmDeleteDepartmentModal } from '../fragments/modals'; +import { Table } from '../fragments/table'; + +export class OmnichannelDepartments extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.departments; + + protected readonly title = 'Departments'; + + readonly agentsTable: Table; + + readonly upsellDepartmentsModal: OmnichannelUpsellDepartmentsModal; + + readonly listbox: Listbox; + + readonly menOptions: MenuOptions; + + override readonly deleteModal: ConfirmDeleteDepartmentModal; + + constructor(page: Page) { + super(page); + this.agentsTable = new Table(page.getByRole('table', { name: 'Agents' })); + this.upsellDepartmentsModal = new OmnichannelUpsellDepartmentsModal(page); + this.listbox = new Listbox(page); + this.menOptions = new MenuOptions(page); + this.deleteModal = new ConfirmDeleteDepartmentModal(page); + } + + async createNew() { + await this.getButtonByType('department').click(); + } + + get labelEnabled() { + return this.page.locator('label', { hasText: 'Enabled' }); + } + + get inputName() { + return this.page.getByRole('textbox', { name: 'Name', exact: true }); + } + + get inputEmail() { + return this.page.getByRole('textbox', { name: 'Email', exact: true }); + } + + get inputConversationClosingTags() { + return this.page.getByRole('textbox', { name: 'Conversation closing tags', exact: true }); + } + + get btnAddTags() { + return this.page.getByText('Conversation closing tags', { exact: true }).locator('..').getByRole('button', { name: 'Add' }); + } + + get btnSave() { + return this.page.getByRole('button', { name: 'Save', exact: true }); + } + + get tabArchivedDepartments() { + return this.page.getByRole('tab', { name: 'Archived' }); + } + + getDepartmentMenuByName(name: string) { + return this.table.findRowByName(name).getByRole('button', { name: 'Options' }); + } + + get menuEditOption() { + return this.menOptions.getMenuItem('Edit'); + } + + get menuDeleteOption() { + return this.menOptions.getMenuItem('Delete'); + } + + get menuArchiveOption() { + return this.menOptions.getMenuItem('Archive'); + } + + get menuUnarchiveOption() { + return this.menOptions.getMenuItem('Unarchive'); + } + + async archiveDepartmentByName(name: string) { + await this.getDepartmentMenuByName(name).click(); + await this.menuArchiveOption.click(); + await this.toastMessage.waitForDisplay(); + } + + get inputUnit(): Locator { + return this.page.getByLabel('Unit').getByRole('textbox', { name: 'Select an option' }); + } + + btnTag(tagName: string) { + return this.page.locator('button', { hasText: tagName }); + } + + errorMessage(message: string): Locator { + return this.page.locator(`[role="alert"] >> text="${message}"`); + } + + findOption(optionText: string) { + return this.listbox.getOption(optionText); + } + + async selectUnit(unitName: string) { + await this.inputUnit.click(); + await this.listbox.selectOption(unitName); + } + + get inputAgents() { + return this.page.getByRole('group', { name: 'Agents' }).getByRole('textbox'); + } + + get btnAddAgent() { + return this.page.getByRole('group', { name: 'Agents' }).getByRole('button', { name: 'Add', exact: true }); + } + + async createDepartment(departmentName: string, email: string) { + await this.labelEnabled.click(); + await this.inputName.fill(departmentName); + await this.inputEmail.fill(email); + await this.btnSave.click(); + await this.toastMessage.dismissToast(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts new file mode 100644 index 0000000..5da1a03 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-info.ts @@ -0,0 +1,58 @@ +import type { Locator, Page } from '@playwright/test'; + +import { FlexTab } from '../fragments/flextabs/flextab'; +import { OmnichannelContactReviewModal } from '../fragments/modals'; + +export class OmnichannelContactInfo extends FlexTab { + readonly contactReviewModal: OmnichannelContactReviewModal; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Contact' })); + this.contactReviewModal = new OmnichannelContactReviewModal(page); + } + + get infoContactEmail(): Locator { + return this.root.getByRole('list', { name: 'Email' }).getByRole('listitem').first().locator('p'); + } + + get btnEdit(): Locator { + return this.root.locator('role=button[name="Edit"]'); + } + + get tabHistory(): Locator { + return this.root.getByRole('tab', { name: 'History' }); + } + + get historyItem(): Locator { + return this.root.getByRole('listitem').first(); + } + + get historyMessage(): Locator { + return this.root.getByRole('listitem').first(); + } + + get btnOpenChat(): Locator { + return this.root.getByRole('button', { name: 'Open chat' }); + } + + get btnSeeConflicts(): Locator { + return this.root.getByRole('button', { name: 'See conflicts' }); + } + + private get customFieldsGroup() { + return this.root.getByRole('group', { name: 'Custom Fields' }); + } + + getInfoByValue(value: string): Locator { + return this.root.getByText(value, { exact: true }); + } + + getVisitorCustomField(label: string): Locator { + return this.customFieldsGroup.getByLabel(label); + } + + async solveConflict(field: string, value: string) { + await this.btnSeeConflicts.click(); + await this.contactReviewModal.solveConfirmation(field, value); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-appearance.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-appearance.ts new file mode 100644 index 0000000..b355c4e --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-appearance.ts @@ -0,0 +1,37 @@ +import type { Locator } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; + +export class OmnichannelLivechatAppearance extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.appearance; + + protected readonly title = 'Appearance'; + + override async waitForReady(): Promise { + await this.pageHeader.waitFor({ state: 'visible' }); + } + + get inputHideSystemMessages(): Locator { + return this.page.locator('label', { hasText: 'Hide system messages' }); + } + + get inputLivechatBackground(): Locator { + return this.page.locator('[name="Livechat_background"]'); + } + + get inputLivechatTitle(): Locator { + return this.page.locator('[name="Livechat_title"]'); + } + + get inputHideExpandChat(): Locator { + return this.page.getByRole('checkbox', { name: 'Hide "Expand chat"' }); + } + + get labelHideExpandChat(): Locator { + return this.page.locator('label', { has: this.inputHideExpandChat }); + } + + findHideSystemMessageOption(option: string): Locator { + return this.page.locator(`[role="option"][value="${option}"]`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-embedded.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-embedded.ts new file mode 100644 index 0000000..87377fa --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat-embedded.ts @@ -0,0 +1,113 @@ +import type { Locator } from '@playwright/test'; + +import { RoutedPage } from '../routed-page'; + +/** The widget as embedded on a host page, driven through `window.RocketChat.livechat`. */ +export class OmnichannelLiveChatEmbedded extends RoutedPage { + protected readonly route = '/packages/rocketchat_livechat/assets/demo.html'; + + async waitForReady(): Promise { + await this.btnOpenLiveChat().waitFor({ state: 'visible' }); + } + + btnOpenLiveChat(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`[data-qa-id="chat-button"]`); + } + + btnFinishOfflineMessage(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`button[aria-label="OK"]`); + } + + get btnOptions(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`button >> text="Options"`); + } + + get btnCloseChat(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`button >> text="Finish this chat"`); + } + + get btnCloseChatConfirm(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`button >> text="Yes"`); + } + + get headerTitle(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[data-qa="header-title"]'); + } + + get btnNewChat(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`role=button[name="New Chat"]`); + } + + get messageList(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[data-qa="message-list"]'); + } + + get messageListBackground(): Promise { + return this.messageList.evaluate((el) => window.getComputedStyle(el).getPropertyValue('background-color')); + } + + messageBubble(message: string): Locator { + return this.page + .frameLocator('#rocketchat-iframe') + .locator('[data-qa="message-bubble"]', { has: this.page.frameLocator('#rocketchat-iframe').locator(`div >> text="${message}"`) }); + } + + messageBubbleBackground(message: string): Promise { + return this.messageBubble(message) + .last() + .evaluate((el) => window.getComputedStyle(el).getPropertyValue('background-color')); + } + + txtChatMessage(message: string): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`li >> text="${message}"`); + } + + imgAvatar(username: string): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`img[alt="${username}"]`).last(); + } + + async openLiveChat(): Promise { + await this.btnOpenLiveChat().click(); + } + + get inputName(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[name="name"]'); + } + + get inputEmail(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[name="email"]'); + } + + get textAreaMessage(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[name="message"]'); + } + + btnSendMessage(btnText: string): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`role=button[name="${btnText}"]`); + } + + get onlineAgentMessage(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('[contenteditable="true"]'); + } + + get btnSendMessageToOnlineAgent(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator('footer div div div:nth-child(3) button'); + } + + get btnExpandChat(): Locator { + return this.page.frameLocator('#rocketchat-iframe').getByRole('button', { name: 'Expand chat', exact: true }); + } + + public async sendMessage(liveChatUser: { name: string; email: string }, isOffline = true): Promise { + const buttonLabel = isOffline ? 'Send' : 'Start chat'; + await this.inputName.fill(liveChatUser.name); + await this.inputEmail.fill(liveChatUser.email); + if (isOffline) { + await this.textAreaMessage.fill('any_message'); + await this.btnSendMessage(buttonLabel).click(); + return this.btnFinishOfflineMessage().click(); + } + await this.btnSendMessage(buttonLabel).click(); + await this.page.frameLocator('#rocketchat-iframe').locator('[data-qa="livechat-composer"]').waitFor(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat.ts new file mode 100644 index 0000000..feb96f0 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-livechat.ts @@ -0,0 +1,243 @@ +import fs from 'fs/promises'; + +import type { Page, Locator, APIResponse } from '@playwright/test'; + +import { expect } from '../../utils/test'; +import { RoutedPage } from '../routed-page'; + +export class OmnichannelLiveChat extends RoutedPage { + protected readonly route = '/livechat'; + + constructor( + page: Page, + private readonly api: { get(url: string): Promise }, + ) { + super(page); + } + + async waitForReady(): Promise { + await this.btnOpenLiveChat.waitFor({ state: 'visible' }); + } + + btnOpenOnlineLiveChat(label: string): Locator { + return this.page.locator(`role=button[name="${label}"]`); + } + + get btnOpenLiveChat(): Locator { + return this.page.locator(`[data-qa-id="chat-button"]`); + } + + get btnNewChat(): Locator { + return this.page.locator(`role=button[name="New Chat"]`); + } + + get btnOptions(): Locator { + return this.page.locator(`button >> text="Options"`); + } + + get btnCloseChat(): Locator { + return this.page.locator(`button >> text="Finish this chat"`); + } + + get btnChangeDepartment(): Locator { + return this.page.locator(`button >> text="Change department"`); + } + + get btnCloseChatConfirm(): Locator { + return this.page.locator(`button >> text="Yes"`); + } + + get btnExpandChat(): Locator { + return this.page.getByRole('button', { name: 'Expand chat' }); + } + + get txtHeaderTitle(): Locator { + return this.page.locator('div >> text="Chat Finished"'); + } + + get btnChatNow(): Locator { + return this.page.locator('[type="button"] >> text="Chat now"'); + } + + get headerTitle(): Locator { + return this.page.locator('[data-qa="header-title"]'); + } + + get txtWatermark(): Locator { + return this.page.locator('[data-qa="livechat-watermark"]'); + } + + get imgLogo(): Locator { + return this.btnOpenLiveChat.locator('img[alt="Livechat"]'); + } + + alertMessage(message: string): Locator { + return this.page.getByRole('alert').locator(`text="${message}"`); + } + + txtChatMessage(message: string): Locator { + return this.page.locator(`[data-qa="message-bubble"] >> text="${message}"`); + } + + async closeChat(): Promise { + await this.btnOptions.click(); + await this.btnCloseChat.click(); + await this.btnCloseChatConfirm.click(); + } + + async openLiveChat(): Promise { + const { value: siteName } = await (await this.api.get('/settings/Livechat_title')).json(); + await this.btnOpenOnlineLiveChat(siteName).click(); + } + + // TODO: replace openLivechat with this method and create a new method for openOnlineLivechat + // as openLivechat only opens a chat that is in the 'online' state + async openAnyLiveChat(): Promise { + await this.btnOpenLiveChat.click(); + } + + async startNewChat(): Promise { + await this.btnNewChat.click(); + } + + async openAnyLiveChatAndSendMessage(params: { + liveChatUser: { name: string; email: string }; + message: string; + isOffline?: boolean; + department?: string; + }): Promise { + const { liveChatUser, message, isOffline, department } = params; + await this.openAnyLiveChat(); + await this.sendMessage(liveChatUser, isOffline, department); + await this.onlineAgentMessage.fill(message); + await this.btnSendMessageToOnlineAgent.click(); + } + + unreadMessagesBadge(count: number): Locator { + const name = count === 1 ? `${count} unread message` : `${count} unread messages`; + + return this.page.locator(`role=status[name="${name}"]`); + } + + get inputName(): Locator { + return this.page.locator('[name="name"]'); + } + + get inputEmail(): Locator { + return this.page.locator('[name="email"]'); + } + + get selectDepartment(): Locator { + return this.page.locator('[name="department"]'); + } + + get textAreaMessage(): Locator { + return this.page.locator('[name="message"]'); + } + + btnSendMessage(btnText: string): Locator { + return this.page.locator(`role=button[name="${btnText}"]`); + } + + get btnOk(): Locator { + return this.page.locator('role=button[name="OK"]'); + } + + get btnYes(): Locator { + return this.page.locator('role=button[name="Yes"]'); + } + + get onlineAgentMessage(): Locator { + return this.page.locator('[contenteditable="true"]'); + } + + get btnSendMessageToOnlineAgent(): Locator { + return this.page.locator('footer div div div:nth-child(3) button'); + } + + get livechatModal(): Locator { + return this.page.locator('[data-qa-type="modal-overlay"]'); + } + + livechatModalText(text: string): Locator { + return this.page.locator(`[data-qa-type="modal-overlay"] >> text=${text}`); + } + + get fileUploadTarget(): Locator { + return this.page.locator('#files-drop-target'); + } + + findUploadedFileLink(fileName: string): Locator { + return this.page.getByRole('link', { name: fileName }); + } + + get typingIndicatorForVisitor(): Locator { + return this.page.getByRole('status', { name: /typing/i }); + } + + public async sendMessage(liveChatUser: { name: string; email: string }, isOffline = true, department?: string): Promise { + const buttonLabel = isOffline ? 'Send' : 'Start chat'; + await this.inputName.fill(liveChatUser.name); + await this.inputEmail.fill(liveChatUser.email); + + if (department) { + await this.selectDepartment.selectOption({ label: department }); + } + + if (isOffline) { + await this.textAreaMessage.type('any_message'); + } + + await this.btnSendMessage(buttonLabel).click(); + await this.page.waitForSelector('[data-qa="livechat-composer"]'); + } + + public async sendMessageAndCloseChat( + liveChatUser: { name: string; email: string }, + message = 'this_a_test_message_from_user', + ): Promise { + await this.openAnyLiveChat(); + await this.sendMessage(liveChatUser, false); + await this.onlineAgentMessage.fill(message); + await this.btnSendMessageToOnlineAgent.click(); + await expect(this.txtChatMessage(message)).toBeVisible(); + await expect(this.page.locator('[data-qa="message-bubble"] >> text="Chat started"')).toBeVisible(); + await this.closeChat(); + } + + async dragAndDropTxtFile(): Promise { + const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'any_file.txt', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + await this.fileUploadTarget.dispatchEvent('dragenter', { dataTransfer }); + + await this.fileUploadTarget.dispatchEvent('drop', { dataTransfer }); + } + + async dragAndDropLstFile(): Promise { + const contract = await fs.readFile('./tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'lst-test.lst', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + await this.fileUploadTarget.dispatchEvent('dragenter', { dataTransfer }); + + await this.fileUploadTarget.dispatchEvent('drop', { dataTransfer }); + } + + queuePosition(position: number): Locator { + return this.page.locator(`div[role='alert'] >> text=Your spot is #${position}`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts new file mode 100644 index 0000000..95a07cb --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { Listbox } from '../fragments/listbox'; + +export class OmnichannelManager extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.managers; + + protected readonly title = 'Managers'; + + readonly listbox: Listbox; + + constructor(page: Page) { + super(page); + this.listbox = new Listbox(page); + } + + get inputUsername(): Locator { + return this.page.getByRole('main').getByLabel('Username'); + } + + async selectUsername(username: string) { + await this.inputUsername.fill(username); + await this.listbox.selectOption(username); + } + + get btnAddManager(): Locator { + return this.page.getByRole('button', { name: 'Add manager' }); + } + + async removeManager(name: string) { + await this.table.findRowByName(name).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-monitors.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-monitors.ts new file mode 100644 index 0000000..32ea418 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-monitors.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { Listbox } from '../fragments/listbox'; + +export class OmnichannelMonitors extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.monitors; + + protected readonly title = 'Monitors'; + + readonly listbox: Listbox; + + constructor(page: Page) { + super(page); + this.listbox = new Listbox(page); + } + + private get btnAddMonitor(): Locator { + return this.page.getByRole('button', { name: 'Add monitor' }); + } + + get inputMonitor(): Locator { + return this.page.locator('input[name="monitor"]'); + } + + private btnRemoveByName(name: string): Locator { + return this.table.findRowByName(name).getByRole('button', { name: 'Remove' }); + } + + private async selectMonitor(name: string) { + await this.inputMonitor.fill(name); + await this.listbox.selectOption(name); + } + + async removeMonitor(name: string) { + await this.btnRemoveByName(name).click(); + await this.deleteModal.confirmDelete(); + } + + async addMonitor(name: string) { + await this.selectMonitor(name); + await this.btnAddMonitor.click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-priorities.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-priorities.ts new file mode 100644 index 0000000..faddd43 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-priorities.ts @@ -0,0 +1,45 @@ +import type { Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { ToastMessages } from '../fragments'; +import { FlexTab } from '../fragments/flextabs/flextab'; +import { OmnichannelResetPrioritiesModal } from '../fragments/modals'; + +class OmnichannelEditPriorityFlexTab extends FlexTab { + readonly toastMessage: ToastMessages; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'Priority' })); + this.toastMessage = new ToastMessages(page); + } +} + +export class OmnichannelPriorities extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.priorities; + + protected readonly title = 'Priorities'; + + readonly editPriority: OmnichannelEditPriorityFlexTab; + + readonly resetPrioritiesModal: OmnichannelResetPrioritiesModal; + + constructor(page: Page) { + super(page); + this.resetPrioritiesModal = new OmnichannelResetPrioritiesModal(page); + this.editPriority = new OmnichannelEditPriorityFlexTab(page); + } + + get btnReset() { + return this.page.getByRole('button', { name: 'Reset' }); + } + + async resetPriorities() { + await this.btnReset.click(); + await this.resetPrioritiesModal.reset(); + await this.toastMessage.dismissToast(); + } + + findPriority(name: string) { + return this.table.findRowByName(name); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-reports.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-reports.ts new file mode 100644 index 0000000..b0beb5f --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-reports.ts @@ -0,0 +1,84 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; + +class OmnichannelReportsSection { + private readonly page: Page; + + private readonly section: Locator; + + constructor(page: Page, sectionId: string) { + this.page = page; + this.section = page.locator(`[data-qa=${sectionId}]`); + } + + get element() { + return this.section; + } + + get inputPeriodSelector() { + return this.section.locator('button[aria-label="Select period"]'); + } + + get txtStateTitle() { + return this.section.locator('.rcx-states__title'); + } + + get txtStateSubtitle() { + return this.section.locator('.rcx-states__subtitle'); + } + + get btnRetry() { + return this.section.locator('role=button[name="Retry"]'); + } + + get txtSummary() { + return this.section.locator('[data-qa="report-summary"]'); + } + + findRowByName(name: string) { + return this.section.locator('tr', { has: this.page.locator(`td >> text="${name}"`) }); + } + + chartItem(label: string, value: number) { + return this.section.locator(`rect[aria-label="${label}"] + text >> text=${value}`); + } + + legendItem(text: string) { + return this.section.locator(`text='${text}'`); + } + + async selectPeriod(period: string) { + await this.inputPeriodSelector.click(); + await this.page.locator(`li.rcx-option[data-key="${period}"]`).click(); + } +} + +export class OmnichannelReports extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.reports; + + protected readonly title = 'Reports'; + + readonly statusSection: OmnichannelReportsSection; + + readonly channelsSection: OmnichannelReportsSection; + + readonly departmentsSection: OmnichannelReportsSection; + + readonly tagsSection: OmnichannelReportsSection; + + readonly agentsSection: OmnichannelReportsSection; + + constructor(page: Page) { + super(page); + this.statusSection = new OmnichannelReportsSection(page, 'conversations-by-status'); + this.channelsSection = new OmnichannelReportsSection(page, 'conversations-by-channel'); + this.departmentsSection = new OmnichannelReportsSection(page, 'conversations-by-department'); + this.tagsSection = new OmnichannelReportsSection(page, 'conversations-by-tags'); + this.agentsSection = new OmnichannelReportsSection(page, 'conversations-by-agent'); + } + + override async waitForReady(): Promise { + await this.pageHeader.waitFor({ state: 'visible' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-settings.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-settings.ts new file mode 100644 index 0000000..8e5b595 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-settings.ts @@ -0,0 +1,34 @@ +import type { Locator } from '@playwright/test'; + +import { AdminSectionsHref } from '../admin'; +import { AdminSettingsSection } from '../admin-settings'; + +export class OmnichannelSettings extends AdminSettingsSection { + protected readonly route = `${AdminSectionsHref.settings}/Omnichannel` as const; + + protected readonly title = 'Omnichannel'; + + get labelLivechatLogo(): Locator { + return this.page.locator('//label[@title="Assets_livechat_widget_logo"]'); + } + + get imgLivechatLogoPreview(): Locator { + return this.page.locator('//label[@title="Assets_livechat_widget_logo"]/following-sibling::span >> role=img[name="Asset preview"]'); + } + + get inputLivechatLogo(): Locator { + return this.page.locator('//label[@title="Assets_livechat_widget_logo"]/following-sibling::span >> input[type="file"]'); + } + + get btnDeleteLivechatLogo(): Locator { + return this.page.locator('//label[@title="Assets_livechat_widget_logo"]/following-sibling::span >> role=button[name="Delete"]'); + } + + group(sectionName: string): Locator { + return this.page.locator(`[data-qa-section="${sectionName}"] h2 >> text="${sectionName}"`); + } + + get labelHideWatermark(): Locator { + return this.page.locator('label').getByText('Hide "powered by Rocket.Chat"'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-sla-policies.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-sla-policies.ts new file mode 100644 index 0000000..ab8e895 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-sla-policies.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; + +class OmnichannelManageSlaPolicyFlexTab extends FlexTab { + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'SLA Policy' })); + } + + get inputDescription(): Locator { + return this.root.getByRole('textbox', { name: 'Description' }); + } + + get inputEstimatedWaitTime(): Locator { + return this.root.getByRole('spinbutton', { name: 'Estimated wait time (time in minutes)', exact: true }); + } +} + +export class OmnichannelSlaPolicies extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.slaPolicies; + + protected readonly title = 'SLA Policies'; + + readonly manageSlaPolicy: OmnichannelManageSlaPolicyFlexTab; + + constructor(page: Page) { + super(page); + this.manageSlaPolicy = new OmnichannelManageSlaPolicyFlexTab(page); + } + + btnRemove(name: string) { + return this.table.findRowByName(name).getByRole('button', { name: 'Remove' }); + } + + async removeSLA(name: string) { + await this.btnRemove(name).click(); + await this.deleteModal.confirmDelete(); + } + + async createNew() { + await this.getButtonByType('SLA policy').click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-tags.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-tags.ts new file mode 100644 index 0000000..94b48fb --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-tags.ts @@ -0,0 +1,50 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; +import { Listbox } from '../fragments/listbox'; + +class OmnichannelEditTagFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'tag' })); + this.listbox = new Listbox(page); + } + + get inputDepartments(): Locator { + return this.root.getByLabel('Departments').getByRole('textbox'); + } + + async selectDepartment(name: string) { + await this.inputDepartments.click(); + await this.inputDepartments.fill(name); + await this.listbox.selectOption(name); + } + + findSelectedDepartment(name: string) { + return this.root.getByLabel('Departments').getByRole('option', { name }); + } +} + +export class OmnichannelTags extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.tags; + + protected readonly title = 'Tags'; + + readonly editTag: OmnichannelEditTagFlexTab; + + constructor(page: Page) { + super(page); + this.editTag = new OmnichannelEditTagFlexTab(page); + } + + async createNew() { + await this.getButtonByType('tag').click(); + } + + async deleteTag(name: string) { + await this.table.findRowByName(name).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts new file mode 100644 index 0000000..34cc735 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-transcript.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export class OmnichannelTranscript { + constructor(private readonly page: Page) {} + + get contactCenterChats(): Locator { + return this.page.locator('//button[contains(.,"Chats")]'); + } + + get contactCenterSearch(): Locator { + return this.page.locator('[placeholder="Search"]'); + } + + get firstRow(): Locator { + return this.page.locator('//tr[1]//td[1]'); + } + + get btnOpenChat(): Locator { + return this.page.getByRole('dialog').getByRole('button', { name: 'Open chat', exact: true }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-triggers.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-triggers.ts new file mode 100644 index 0000000..0440cda --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-triggers.ts @@ -0,0 +1,121 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; +import { Listbox } from '../fragments/listbox'; + +type TriggerConditions = 'Visitor page URL' | 'Visitor time on site' | 'Chat opened by the visitor' | 'After guest registration'; + +class OmnichannelEditTriggerFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'trigger' })); + this.listbox = new Listbox(page); + } + + private get inputDescription(): Locator { + return this.root.getByRole('textbox', { name: 'Description', exact: true }); + } + + private get conditionLabel(): Locator { + return this.root.getByText('Condition', { exact: true }); + } + + private get senderLabel(): Locator { + return this.root.getByText('Sender', { exact: true }); + } + + private async selectCondition(condition: string) { + await this.conditionLabel.click(); + await this.listbox.selectOption(condition); + } + + private async selectSender(sender: 'queue' | 'custom') { + await this.senderLabel.click(); + await this.listbox.selectOption(sender); + } + + private get inputAgentName(): Locator { + return this.root.locator('input[name="actions.0.params.name"]'); + } + + private get inputConditionValue(): Locator { + return this.root.locator('input[name="conditions.0.value"]'); + } + + private get inputTriggerMessage(): Locator { + return this.root.locator('textarea[name="actions.0.params.msg"]'); + } + + async fillTriggerForm( + data: Partial<{ + name: string; + description: string; + condition: TriggerConditions; + conditionValue?: string | number; + sender: 'queue' | 'custom'; + agentName?: string; + triggerMessage: string; + }>, + ) { + data.name && (await this.inputName.fill(data.name)); + data.description && (await this.inputDescription.fill(data.description)); + data.condition && (await this.selectCondition(data.condition)); + + if (data.conditionValue) { + await this.inputConditionValue.fill(data.conditionValue.toString()); + } + + data.sender && (await this.selectSender(data.sender)); + if (data.sender === 'custom' && !data.agentName) { + throw new Error('A custom agent is required for this action'); + } else { + data.agentName && (await this.inputAgentName.fill(data.agentName)); + } + + data.triggerMessage && (await this.inputTriggerMessage.fill(data.triggerMessage)); + } +} + +export class OmnichannelTriggers extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.triggers; + + protected readonly title = 'Livechat Triggers'; + + readonly editTrigger: OmnichannelEditTriggerFlexTab; + + constructor(page: Page) { + super(page); + this.editTrigger = new OmnichannelEditTriggerFlexTab(page); + } + + async removeTrigger(name: string) { + await this.table.findRowByName(name).getByRole('button', { name: 'Remove' }).click(); + await this.deleteModal.confirmDelete(); + } + + public async createTrigger(triggersName: string, triggerMessage: string, condition: TriggerConditions, conditionValue?: number | string) { + await this.getButtonByType('trigger').click(); + await this.editTrigger.fillTriggerForm({ + name: triggersName, + description: 'Creating a fresh trigger', + condition, + conditionValue, + triggerMessage, + }); + await this.editTrigger.save(); + } + + public async updateTrigger(name: string, triggerMessage: string, condition: TriggerConditions = 'Chat opened by the visitor') { + await this.editTrigger.fillTriggerForm({ + name, + description: 'Updating the existing trigger', + condition, + sender: 'custom', + agentName: 'Rocket.cat', + triggerMessage, + }); + await this.editTrigger.save(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-units.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-units.ts new file mode 100644 index 0000000..4dadd9b --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-units.ts @@ -0,0 +1,97 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelAdmin, OmnichannelSectionsHref } from './omnichannel-admin'; +import { FlexTab } from '../fragments/flextabs/flextab'; +import { Listbox } from '../fragments/listbox'; +import { Table } from '../fragments/table'; + +export class OmnichannelUnitFlexTab extends FlexTab { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page.getByRole('dialog', { name: 'unit' })); + this.listbox = new Listbox(page); + } + + private get fieldDepartments() { + return this.root.getByLabel('Departments'); + } + + get inputDepartments() { + return this.fieldDepartments.getByRole('textbox'); + } + + private get fieldMonitors() { + return this.root.getByLabel('Monitors'); + } + + get inputMonitors() { + return this.fieldMonitors.getByRole('textbox'); + } + + get inputVisibility(): Locator { + return this.root.getByText('Visibility'); + } + + findDepartmentsChipOption(name: string) { + return this.fieldDepartments.getByRole('option', { name, exact: true }); + } + + findMonitorChipOption(name: string) { + return this.fieldMonitors.getByRole('option', { name, exact: true }); + } + + async selectDepartment(name: string) { + await this.inputDepartments.click(); + await this.inputDepartments.fill(name); + await this.listbox.selectOption(name); + await this.inputDepartments.click(); + } + + async selectMonitor(option: string) { + await this.inputMonitors.click(); + await this.listbox.selectOption(option); + await this.inputMonitors.click(); + } + + async removeMonitor(option: string) { + await this.findMonitorChipOption(option).click(); + } + + async selectVisibility(option: string) { + await this.inputVisibility.click(); + await this.listbox.selectOption(option); + } +} + +class OmnichannelUnitsTable extends Table { + deleteUnitByName(name: string) { + return this.findRowByName(name).getByRole('button', { name: 'Remove' }); + } +} + +export class OmnichannelUnits extends OmnichannelAdmin { + protected readonly route = OmnichannelSectionsHref.units; + + protected readonly title = 'Units'; + + readonly manageUnit: OmnichannelUnitFlexTab; + + constructor(page: Page) { + super(page); + this.manageUnit = new OmnichannelUnitFlexTab(page); + } + + override get table() { + return new OmnichannelUnitsTable(this.page.getByRole('table', { name: this.title })); + } + + async createNew() { + await this.getButtonByType('unit').click(); + } + + async deleteUnit(name: string) { + await this.table.deleteUnitByName(name).click(); + await this.deleteModal.confirmDelete(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/routed-page.ts b/apps/meteor/tests/e2e/page-objects/routed-page.ts new file mode 100644 index 0000000..746ba67 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/routed-page.ts @@ -0,0 +1,62 @@ +import type { Locator, Page } from '@playwright/test'; + +/** + * Base class for page objects that own a route. + * + * Subclasses declare the `route` they live at and how to tell that route has finished rendering, and + * get `goto()` for free: it navigates and waits for the page to be usable before returning, so tests + * never have to follow a raw `page.goto()` with an ad-hoc wait. + * + * Pages that own more than one route (admin settings sections, account tabs, ...) expose extra + * `gotoSomething()` methods built on top of `navigateTo()`. + */ +export abstract class RoutedPage { + /** Path this page lives at, relative to the workspace base URL. */ + protected abstract readonly route: string; + + constructor(public readonly page: Page) {} + + /** Navigates to the page's route and waits for it to be ready. */ + async goto(): Promise { + await this.navigateTo(this.route); + } + + /** + * Navigates to the page's route and waits for `until` instead of the page's default ready state. + * + * For routes that can legitimately settle on something else — an upsell dialog, a "not authorized" + * message, a redirect to login — so the wait still matches what the test expects. + */ + async gotoExpecting(until: Locator): Promise { + await this.navigateTo(this.route, until); + } + + /** Waits until the route has finished rendering and the page is usable, without navigating. */ + abstract waitForReady(): Promise; + + get notAuthorizedMessage(): Locator { + return this.page.getByText('You are not authorized to view this page.', { exact: true }); + } + + /** + * Navigates to one of the page's routes and waits until it is ready. + * + * @param route path to navigate to + * @param until element or waiter to use, when the route settles on something other than its ready state + */ + protected async navigateTo(route: string, until?: Locator | (() => Promise)): Promise { + await this.page.goto(route); + + if (!until) { + await this.waitForReady(); + return; + } + + if (typeof until === 'function') { + await until(); + return; + } + + await until.waitFor({ state: 'visible' }); + } +} diff --git a/apps/meteor/tests/e2e/permissions.spec.ts b/apps/meteor/tests/e2e/permissions.spec.ts new file mode 100644 index 0000000..14e6502 --- /dev/null +++ b/apps/meteor/tests/e2e/permissions.spec.ts @@ -0,0 +1,211 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.user2.state }); + +test.describe.serial('permissions', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.describe.serial('Edit message', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowEditing', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option(edit) not be visible', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + + await poHomeChannel.content.sendMessage('expect option(edit) not be visible'); + + await expect(page.locator('.rcx-message', { hasText: 'expect option(edit) not be visible' })).not.toHaveAttribute( + 'aria-busy', + 'true', + ); + await poHomeChannel.content.openLastMessageMenu(); + await expect(poHomeChannel.content.btnOptionEditMessage).toBeHidden(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowEditing', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Delete message', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowDeleting', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option(delete) not be visible', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('expect option(delete) not be visible'); + + await expect(page.locator('.rcx-message', { hasText: 'expect option(delete) not be visible' })).not.toHaveAttribute( + 'aria-busy', + 'true', + ); + + await poHomeChannel.content.openLastMessageMenu(); + + await expect(poHomeChannel.content.btnOptionDeleteMessage).toBeHidden(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowDeleting', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Pin message', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowPinning', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option(pin) not be visible', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('expect option(pin) not be visible'); + + await expect(page.locator('.rcx-message', { hasText: 'expect option(pin) not be visible' })).not.toHaveAttribute('aria-busy', 'true'); + await poHomeChannel.content.openLastMessageMenu(); + + await expect(poHomeChannel.content.btnOptionPinMessage).toBeHidden(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowPinning', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + // FIXME: Wrong behavior in Rocket.chat, currently it shows the button + // and after a click a "not allowed" alert pops up + test.describe.skip('Star message', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowStarring', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option(star) not be visible', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('expect option(star) not be visible'); + + await expect(page.locator('.rcx-message', { hasText: 'expect option(star) not be visible' })).not.toHaveAttribute( + 'aria-busy', + 'true', + ); + await poHomeChannel.content.openLastMessageMenu(); + + await expect(poHomeChannel.content.btnOptionStarMessage).toBeHidden(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowStarring', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Upload file', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/FileUpload_Enabled', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option (upload file) not be visible', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.composer.btnOptionFileUpload).toBeDisabled(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/FileUpload_Enabled', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Upload audio', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AudioRecorderEnabled', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option (upload audio) not be visible', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.composer.btnAudioMessage).toBeDisabled(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AudioRecorderEnabled', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Upload video', () => { + test.beforeAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_VideoRecorderEnabled', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + + test('expect option (upload video) not be visible', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.composer.btnVideoMessage).toBeDisabled(); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_VideoRecorderEnabled', { value: true })).status(); + + await expect(statusCode).toBe(200); + }); + }); + + test.describe.serial('Filter words', () => { + test.beforeAll(async ({ api }) => { + const statusCode1 = (await api.post('/settings/Message_AllowBadWordsFilter', { value: true })).status(); + const statusCode2 = (await api.post('/settings/Message_BadWordsFilterList', { value: 'badword' })).status(); + + await expect(statusCode1).toBe(200); + await expect(statusCode2).toBe(200); + }); + + test('expect badword be censored', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('badword'); + + await expect(poHomeChannel.content.lastUserMessage).toContainText('*'.repeat(7)); + }); + + test.afterAll(async ({ api }) => { + const statusCode = (await api.post('/settings/Message_AllowBadWordsFilter', { value: false })).status(); + + await expect(statusCode).toBe(200); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/presence.spec.ts b/apps/meteor/tests/e2e/presence.spec.ts new file mode 100644 index 0000000..522a172 --- /dev/null +++ b/apps/meteor/tests/e2e/presence.spec.ts @@ -0,0 +1,252 @@ +import { faker } from '@faker-js/faker'; + +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { AccountProfile, HomeChannel, Login } from './page-objects'; +import { test, expect } from './utils/test'; + +test.describe.serial('Presence', () => { + let poLogin: Login; + let poHomeChannel: HomeChannel; + let poAccountProfile: AccountProfile; + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + poHomeChannel = new HomeChannel(page); + poAccountProfile = new AccountProfile(page); + }); + + test.describe('Login using default settings', () => { + test('should user be online after log in', async () => { + await poLogin.goto(); + await poLogin.login('user1', DEFAULT_USER_CREDENTIALS.password); + + await expect(poHomeChannel.navbar.btnUserMenu).toBeVisible(); + }); + }); + + test.describe('Custom status', () => { + const customStatus = faker.string.alpha(10); + test.use({ storageState: Users.admin.state }); + + test.beforeEach(async () => { + await poHomeChannel.goto(); + }); + + test('should user custom status be reactive', async ({ browser }) => { + await test.step('user1 custom status should be empty', async () => { + await poHomeChannel.navbar.openChat('user1'); + + await expect(poHomeChannel.content.channelHeader).not.toContainText(customStatus); + }); + + await test.step('update user1 custom status', async () => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeChannel(user1Page); + await user1Channel.goto(); + + await user1Channel.navbar.changeUserCustomStatus(customStatus); + await user1Page.close(); + }); + + await test.step('should user1 custom status be updated', async () => { + await poHomeChannel.navbar.openChat('user1'); + + await expect(poHomeChannel.content.channelHeader).toContainText(customStatus); + }); + }); + + test('should be able to erase custom status', async ({ page }) => { + await poHomeChannel.navbar.changeUserCustomStatus(customStatus); + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).toContainText(customStatus); + await page.keyboard.press('Escape'); + + await poHomeChannel.navbar.changeUserCustomStatus(''); + + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(async () => expect(poHomeChannel.navbar.userMenu).not.toContainText(customStatus)).toPass(); + }); + + test('should not save custom status as `undefined` if nothing changes', async ({ page }) => { + await poHomeChannel.navbar.openEditStatusModal(); + + const { editStatusModal } = poHomeChannel.navbar; + + await test.step('Save stays disabled while the form is untouched', async () => { + await expect(editStatusModal.btnSubmit).toBeDisabled(); + }); + + await test.step('closing without changes leaves stored status untouched', async () => { + await editStatusModal.close(); + expect(await page.evaluate(() => localStorage.getItem('fuselage-localStorage-Local_Custom_Status'))).not.toBe('undefined'); + }); + }); + + test('should show both Custom Status action and active status row when a custom status is set', async ({ page }) => { + const text = faker.string.alpha(8); + + await test.step('Custom Status entry visible while no status is set', async () => { + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.btnCustomStatus).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await test.step('after setting a status, Custom Status entry stays visible alongside the active row', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(text); + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.btnCustomStatus).toBeVisible(); + await expect(poHomeChannel.navbar.userMenu).toContainText(text); + await page.keyboard.press('Escape'); + }); + + await test.step('cleanup', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(''); + }); + }); + }); + + test.describe('Status expiration', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeEach(async () => { + await poHomeChannel.goto(); + }); + + test('should toggle custom date/time inputs when selecting duration', async () => { + await poHomeChannel.navbar.openEditStatusModal(); + + const { editStatusModal } = poHomeChannel.navbar; + + await editStatusModal.setStatusMessage('available'); + + await editStatusModal.selectDuration('Choose date and time'); + await expect(editStatusModal.customDateInput).toBeVisible(); + await expect(editStatusModal.customTimeInput).toBeVisible(); + + await editStatusModal.selectDuration("Don't clear"); + await expect(editStatusModal.customDateInput).not.toBeVisible(); + await expect(editStatusModal.customTimeInput).not.toBeVisible(); + + await editStatusModal.close(); + }); + + test('should reject custom expiration in the past', async () => { + await poHomeChannel.navbar.openEditStatusModal(); + + const { editStatusModal } = poHomeChannel.navbar; + + await editStatusModal.setStatusMessage('available'); + + await editStatusModal.selectDuration('Choose date and time'); + + const today = new Date().toLocaleDateString('en-CA'); + + await editStatusModal.customDateInput.fill(today); + await editStatusModal.customTimeInput.fill('00:00'); + await editStatusModal.btnSubmit.click(); + + await expect(editStatusModal.durationError).toBeVisible(); + await editStatusModal.close(); + }); + + test('should reject custom expiration when date/time is missing', async () => { + await poHomeChannel.navbar.openEditStatusModal(); + + const { editStatusModal } = poHomeChannel.navbar; + + await editStatusModal.setStatusMessage('available'); + + await editStatusModal.selectDuration('Choose date and time'); + await editStatusModal.customDateInput.fill(''); + await editStatusModal.btnSubmit.click(); + + await expect(editStatusModal.durationMissingError).toBeVisible(); + await editStatusModal.close(); + }); + + test('should set status with custom date/time expiration', async ({ page }) => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toLocaleDateString('en-CA'); + const timeStr = '12:00'; + + await test.step('set status with custom expiration', async () => { + await poHomeChannel.navbar.changeUserCustomStatusWithExpiration({ + message: 'custom deadline', + duration: 'Choose date and time', + customDate: dateStr, + customTime: timeStr, + }); + }); + + await test.step('verify expiration is displayed in user menu', async () => { + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).toContainText('custom deadline'); + await expect(poHomeChannel.navbar.userMenu).toContainText('Until'); + await page.keyboard.press('Escape'); + }); + + await test.step('clear status', async () => { + await poHomeChannel.navbar.changeUserStatus('online'); + }); + }); + + test('should set status with expiration, show it, and clear it', async ({ page }) => { + await test.step('set busy status with 30-minute expiration', async () => { + await poHomeChannel.navbar.changeUserCustomStatusWithExpiration({ + message: 'focus time', + statusType: 'Busy', + duration: '30 minutes', + }); + }); + + await test.step('verify expiration is displayed in user menu', async () => { + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).toContainText('focus time'); + await expect(poHomeChannel.navbar.userMenu).toContainText('Until'); + await page.keyboard.press('Escape'); + }); + + await test.step('clear status and verify expiration is removed', async () => { + await poHomeChannel.navbar.changeUserStatus('online'); + + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).not.toContainText('Until'); + await expect(poHomeChannel.navbar.userMenu).not.toContainText('focus time'); + }); + }); + }); + + test.describe('Status set from account profile', () => { + test.use({ storageState: Users.admin.state }); + + test('should save status with expiration from the profile form and surface it in the user menu', async ({ page }) => { + const text = faker.string.alpha(10); + + await test.step('fill Status field + Clear-after on /account/profile and save', async () => { + await poAccountProfile.goto(); + await poAccountProfile.inputStatusText.fill(text); + await poAccountProfile.chooseClearStatusAfter('30 minutes'); + await Promise.all([ + page.waitForResponse((r) => r.url().endsWith('/v1/users.setStatus') && r.ok()), + page.waitForResponse((r) => r.url().endsWith('/v1/users.updateOwnBasicInfo') && r.ok()), + poAccountProfile.btnSaveChanges.click(), + ]); + await expect(page.getByText('Profile saved successfully')).toBeVisible(); + }); + + await test.step('status with expiration appears in user menu', async () => { + await poHomeChannel.goto(); + await poHomeChannel.navbar.btnUserMenu.click(); + await expect(poHomeChannel.navbar.userMenu).toContainText(text); + await expect(poHomeChannel.navbar.userMenu).toContainText('Until'); + await page.keyboard.press('Escape'); + }); + + await test.step('cleanup', async () => { + await poHomeChannel.navbar.changeUserCustomStatus(''); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/preview-public-channel.spec.ts b/apps/meteor/tests/e2e/preview-public-channel.spec.ts new file mode 100644 index 0000000..eabb920 --- /dev/null +++ b/apps/meteor/tests/e2e/preview-public-channel.spec.ts @@ -0,0 +1,120 @@ +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { Directory } from './page-objects/directory'; +import { createDirectMessage, createTargetChannel, sendTargetChannelMessage } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('Preview public channel', () => { + let poHomeChannel: HomeChannel; + let poDirectory: Directory; + let targetChannel: string; + let targetChannelMessage: string; + + // Each test navigates to /home by itself after its API setup is done, otherwise the API mutations + // can race the client boot (initial cached stores sync) and leave the UI stuck on the loading screen + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + poDirectory = new Directory(page); + }); + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + targetChannelMessage = await sendTargetChannelMessage(api, targetChannel, { msg: 'This message' }); + + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + }); + + test.describe('User', () => { + test.use({ storageState: Users.user1.state }); + + test('should let user preview public rooms messages', async () => { + await poDirectory.goto(); + await poDirectory.openChannel(targetChannel); + + await expect(poHomeChannel.content.lastUserMessageBody).toContainText(targetChannelMessage); + }); + + test('should disable all composer toolbar actions during channel preview', async () => { + await poDirectory.goto(); + await poDirectory.openChannel(targetChannel); + await poHomeChannel.content.waitForChannel(); + + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + + const actions = await poHomeChannel.composer.allPrimaryActions.all(); + await Promise.all( + actions.map(async (action) => { + await expect(action).toBeDisabled(); + }), + ); + }); + + test('should let user view direct rooms', async ({ api }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] }); + await createDirectMessage(api); + + await poHomeChannel.goto(); + + await poHomeChannel.navbar.openChat(Users.user2.data.username); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + + test('should not let user role preview public rooms', async ({ api }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] }); + + await poDirectory.goto(); + await poDirectory.openChannel(targetChannel); + + await expect(poHomeChannel.btnJoinChannel).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).not.toBeVisible(); + }); + }); + + test.describe('App', () => { + test.skip(!IS_EE, 'Premium Only'); + test.use({ storageState: Users.userNotAllowedByApp.state }); + + test('should prevent user from join the room', async ({ api, page }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + + await poDirectory.goto(); + await poDirectory.openChannel(targetChannel); + + await expect(poHomeChannel.content.lastUserMessageBody).toContainText(targetChannelMessage); + + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect( + page.locator('[role="alert"]', { + hasText: 'TEST OF NOT ALLOWED USER', + }), + ).toBeVisible(); + }); + + test('should prevent user from join the room without preview permission', async ({ api, page }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] }); + + await poDirectory.goto(); + await poDirectory.openChannel(targetChannel); + await expect(poHomeChannel.content.lastUserMessageBody).not.toBeVisible(); + + await poHomeChannel.btnJoinChannel.click(); + + await expect( + page.locator('[role="alert"]', { + hasText: 'TEST OF NOT ALLOWED USER', + }), + ).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/prune-messages.spec.ts b/apps/meteor/tests/e2e/prune-messages.spec.ts new file mode 100644 index 0000000..2dc5dcf --- /dev/null +++ b/apps/meteor/tests/e2e/prune-messages.spec.ts @@ -0,0 +1,169 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects/home-channel'; +import { sendTargetChannelMessage } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('prune-messages', () => { + let poHomeChannel: HomeChannel; + let targetChannel: IRoom; + + test.beforeAll('create target channel', async ({ api }) => { + targetChannel = (await (await api.post('/channels.create', { name: Random.id() })).json()).channel; + }); + + test.afterAll('delete target channel', async ({ api }) => { + expect((await api.post('/channels.delete', { roomId: targetChannel._id })).status()).toBe(200); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannelCleanHistory(targetChannel.fname as string); + }); + + test( + 'should reset form after pruning messages', + { + tag: '@channel', + annotation: { + type: 'issue', + description: 'https://rocketchat.atlassian.net/browse/CORE-1146', + }, + }, + async ({ api }) => { + const { + content, + tabs: { pruneMessages }, + toastMessage, + } = poHomeChannel; + + await content.sendFileMessage('any_file.txt'); + await expect(content.composer.getFileByName('any_file.txt')).toBeVisible(); + await poHomeChannel.composer.btnSend.click(); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); + + await sendTargetChannelMessage(api, targetChannel.fname as string, { + msg: 'a message without files', + }); + + await sendTargetChannelMessage(api, targetChannel.fname as string, { + msg: 'a pinned message without files', + pinned: true, + }); + + await test.step('prune files only not pinned', async () => { + await pruneMessages.labelDoNotPrunePinned.click(); + await pruneMessages.labelFilesOnly.click(); + await pruneMessages.prune(); + await toastMessage.waitForDisplay({ type: 'success', message: '1 file pruned' }); + await toastMessage.dismissToast(); + await expect(pruneMessages.labelFilesOnly, 'Checkbox is reset after success').not.toBeChecked(); + await expect(pruneMessages.labelDoNotPrunePinned, 'Checkbox is reset after success').not.toBeChecked(); + }); + + await test.step('prune files only again', async () => { + await pruneMessages.labelDoNotPrunePinned.click(); + await pruneMessages.labelFilesOnly.click(); + await pruneMessages.prune(); + await toastMessage.waitForDisplay({ type: 'error', message: 'No files found to prune' }); + await toastMessage.dismissToast('error'); + await expect(pruneMessages.labelFilesOnly, 'Checkbox retains value after error').toBeChecked(); + await expect(pruneMessages.labelDoNotPrunePinned, 'Checkbox retains value after error').toBeChecked(); + }); + + await test.step('uncheck files only', async () => { + await pruneMessages.labelFilesOnly.click(); + await pruneMessages.prune(); + await toastMessage.waitForDisplay({ type: 'success', message: '2 messages pruned' }); + await toastMessage.dismissToast(); + await expect(pruneMessages.labelFilesOnly, 'Checkbox is reset after success').not.toBeChecked(); + }); + + await test.step('prune remained pinned message', async () => { + await pruneMessages.prune(); + await toastMessage.waitForDisplay({ type: 'success', message: '1 message pruned' }); + await toastMessage.dismissToast(); + await expect(content.lastUserMessage).not.toBeVisible(); + }); + }, + ); + + test( + 'should update message list on pruning files only', + { + tag: '@channel', + annotation: { + type: 'issue', + description: 'https://rocketchat.atlassian.net/browse/CORE-1168', + }, + }, + async () => { + const { + content, + tabs: { pruneMessages }, + toastMessage, + } = poHomeChannel; + + await content.sendFileMessage('any_file.txt'); + await poHomeChannel.composer.btnSend.click(); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); + + await test.step('prune files only', async () => { + await pruneMessages.labelFilesOnly.click(); + await pruneMessages.prune(); + await toastMessage.waitForDisplay({ type: 'success', message: '1 file pruned' }); + await toastMessage.dismissToast(); + await expect(pruneMessages.labelFilesOnly, 'Checkbox is reset after success').not.toBeChecked(); + await expect(pruneMessages.labelDoNotPrunePinned, 'Checkbox is reset after success').not.toBeChecked(); + }); + + await test.step('check message list for prune message-attachment', async () => { + await expect(content.getLastMessageByFileName('any_file.txt')).not.toBeVisible(); + await expect(content.lastMessageTextAttachment, 'Prune message attachment replaces file attachment').toHaveText( + 'File removed by prune', + ); + }); + }, + ); + + test( + 'should update main thread message attachment on pruning files only', + { + tag: '@channel', + annotation: { + type: 'issue', + description: 'https://rocketchat.atlassian.net/browse/CORE-1168', + }, + }, + async ({ api }) => { + const { content } = poHomeChannel; + + await content.sendFileMessage('any_file.txt'); + await poHomeChannel.composer.btnSend.click(); + await expect(content.getLastMessageByFileName('any_file.txt')).toBeVisible(); + + await content.openReplyInThread(); + expect( + ( + await api.post('/rooms.cleanHistory', { + roomId: targetChannel._id, + filesOnly: true, + latest: new Date(Date.now() + 30 * 24 * 3600), // in future + oldest: new Date(Date.now() - 30 * 24 * 3600), // in past + }) + ).status(), + ).toBe(200); + + await test.step('check main thread message for prune message-attachment', async () => { + await expect(content.getLastThreadMessageByFileName('any_file.txt')).not.toBeVisible(); + await expect(content.lastThreadMessageTextAttachment, 'Prune message attachment replaces file attachment').toHaveText( + 'File removed by prune', + ); + }); + }, + ); +}); diff --git a/apps/meteor/tests/e2e/quote-attachment.spec.ts b/apps/meteor/tests/e2e/quote-attachment.spec.ts new file mode 100644 index 0000000..9c3bbf1 --- /dev/null +++ b/apps/meteor/tests/e2e/quote-attachment.spec.ts @@ -0,0 +1,126 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +const fileDescription = `Message for quote - ${Date.now()}`; +const quotedMessage = 'Quoting the attachment'; +const threadQuoteMessage = 'Quoting in thread'; + +test.describe.parallel('Quote Attachment', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + }); + + test('should show file preview and description when quoting a message with attachment', async () => { + const imageFileName = 'test-image.jpeg'; + await test.step('Send message with attachment in the channel', async () => { + await poHomeChannel.content.sendFileMessage(imageFileName); + await poHomeChannel.composer.inputMessage.fill(fileDescription); + await poHomeChannel.composer.btnSend.click(); + + // Wait for the file to be uploaded and message to be sent + await expect(poHomeChannel.content.lastUserMessage).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toContainText(fileDescription); + }); + await test.step('Quote the message with attachment', async () => { + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + + // Verify the quote preview shows both file and description + await expect(poHomeChannel.content.quotePreview).toBeVisible(); + await expect(poHomeChannel.content.quotePreview).toContainText(fileDescription); + await expect(poHomeChannel.content.quotePreview).toContainText(imageFileName); + + // Send the quoted message + await poHomeChannel.content.sendMessage(quotedMessage); + }); + await test.step('Verify the quoted message appears correctly', async () => { + await expect(poHomeChannel.content.quotedFileDescription(fileDescription)).toBeVisible(); + await expect(poHomeChannel.content.quotedFileName(imageFileName)).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(quotedMessage); + }); + }); + + test('should show file preview and description when quoting attachment file within a thread', async ({ page }) => { + const textFileName = 'any_file.txt'; + + await test.step('Send initial message in channel', async () => { + await poHomeChannel.content.sendMessage('Initial message for thread test'); + }); + + await test.step('Create thread and send message with attachment', async () => { + // Open thread + await poHomeChannel.content.openReplyInThread(); + // Send file in thread + await expect(page).toHaveURL(/.*thread/); + + await poHomeChannel.content.dragAndDropTxtFileToThread(); + await poHomeChannel.content.sendMessageInThread(fileDescription); + + await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText(fileDescription); + await expect(poHomeChannel.content.getLastThreadMessageByFileName(textFileName)).toBeVisible(); + }); + + await test.step('Quote the message with attachment in thread', async () => { + await poHomeChannel.content.lastUserThreadMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + + // Verify the quote preview shows both file and description + await expect(poHomeChannel.content.threadQuotePreview).toBeVisible(); + await expect(poHomeChannel.content.threadQuotePreview).toContainText(fileDescription); + await expect(poHomeChannel.content.threadQuotePreview).toContainText(textFileName); + + // Send the quoted message in thread + await poHomeChannel.content.sendMessageInThread(threadQuoteMessage); + await poHomeChannel.content.threadQuotePreview.waitFor({ state: 'hidden' }); + }); + + await test.step('Verify the quoted message appears correctly in thread', async () => { + await expect(poHomeChannel.content.lastUserThreadMessage).toBeVisible(); + await expect(poHomeChannel.content.threadMessageQuotedFileDescription(fileDescription)).toBeVisible(); + await expect(poHomeChannel.content.threadMessageQuotedFileName(textFileName)).toBeVisible(); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText(threadQuoteMessage); + }); + }); + + test('should show link preview when quoting a link with preview', async () => { + const testLink = 'https://rocket.chat'; + + await test.step('Send link message in channel', async () => { + await poHomeChannel.content.sendMessage(testLink); + await expect(poHomeChannel.content.lastUserMessage).toContainText(testLink); + await expect(poHomeChannel.content.linkPreview).toBeVisible(); + }); + + await test.step('Quote the link message', async () => { + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + + // Verify the quote preview shows the link + await expect(poHomeChannel.content.quotePreview).toBeVisible(); + await expect(poHomeChannel.content.quotePreview).toContainText(testLink); + + // Send the quoted message + await poHomeChannel.content.sendMessage(quotedMessage); + }); + + await test.step('Verify the quoted message appears correctly', async () => { + await expect(poHomeChannel.content.quotedLinkText(testLink)).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(quotedMessage); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/quote-messages.spec.ts b/apps/meteor/tests/e2e/quote-messages.spec.ts new file mode 100644 index 0000000..1c98af0 --- /dev/null +++ b/apps/meteor/tests/e2e/quote-messages.spec.ts @@ -0,0 +1,209 @@ +import { faker } from '@faker-js/faker'; +import type { Page, BrowserContext } from 'playwright-core'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, sendMessage, createDiscussion, createDirectMessageRoom } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.serial('Quote Messages', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetChannelId: string; + let page: Page; + let context: BrowserContext; + + test.beforeAll(async ({ browser, api }) => { + targetChannel = await createTargetChannel(api); + + const infoResponse = await api.get(`/channels.info?roomName=${targetChannel}`); + const infoData = await infoResponse.json(); + + if (!infoResponse.ok() || !infoData?.channel?._id) { + throw new Error( + `Channel lookup failed for "${targetChannel}". Status: ${infoResponse.status()}, Response Body: ${JSON.stringify(infoData)}`, + ); + } + + targetChannelId = infoData.channel._id; + + context = await browser.newContext(); + page = await context.newPage(); + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); + await page.close(); + await context.close(); + }); + + test('should quote a message containing plain text, emoji, markdown, and code blocks', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const quoteText = `Quote with :smile:, *bold*, _italics_, and \`\`\`javascript\nconsole.log("Hello");\n\`\`\``; + + await test.step('Send initial message via API and quote it', async () => { + await sendMessage(api, targetChannelId, messageText); + await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText); + await poHomeChannel.content.quoteMessage(quoteText, messageText); + }); + + await test.step('Verify quoted message and all formatting appears', async () => { + const lastMessage = poHomeChannel.content.lastUserMessage; + await expect(lastMessage).toBeVisible(); + await expect(lastMessage.locator('blockquote')).toBeVisible(); + await expect(lastMessage).toContainText('Quote with'); + await expect(lastMessage.locator('strong')).toBeVisible(); + await expect(lastMessage.locator('em')).toBeVisible(); + await expect(lastMessage).toContainText('console.log'); + await expect(poHomeChannel.content.lastMessageTextAttachmentEqualsText).toHaveText(messageText); + }); + }); + + test('should edit a quoted message', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const quoteText = faker.lorem.sentence(); + const editedQuoteText = faker.lorem.sentence(); + + await test.step('Send initial message via API and quote it', async () => { + await sendMessage(api, targetChannelId, messageText); + await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText); + await poHomeChannel.content.quoteMessage(quoteText, messageText); + }); + + await test.step('Edit the quoted message', async () => { + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.btnOptionEditMessage.click(); + await poHomeChannel.composer.inputMessage.fill(editedQuoteText); + await page.keyboard.press('Enter'); + }); + + await test.step('Verify edited message appears', async () => { + await expect(poHomeChannel.content.lastUserMessage).toContainText(editedQuoteText); + await expect(poHomeChannel.content.lastUserMessage).not.toContainText(quoteText); + }); + }); + + test('should delete a quoted message', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const quoteText = faker.lorem.sentence(); + + await test.step('Send initial message via API and quote it', async () => { + await sendMessage(api, targetChannelId, messageText); + await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText); + await poHomeChannel.content.quoteMessage(quoteText, messageText); + }); + + await test.step('Delete the quoted message', async () => { + await poHomeChannel.content.deleteLastMessage(); + }); + + await test.step('Verify message is deleted', async () => { + await expect(poHomeChannel.content.lastUserMessage).not.toContainText(quoteText); + }); + }); + + test('should cancel quote preview', async ({ api }) => { + const messageText = faker.lorem.sentence(); + + await test.step('Send initial message via API', async () => { + await sendMessage(api, targetChannelId, messageText); + await expect(poHomeChannel.content.lastUserMessage).toContainText(messageText); + }); + + await test.step('cancel quote', async () => { + await poHomeChannel.content.lastUserMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + await expect(poHomeChannel.content.quotePreview).toBeVisible(); + await poHomeChannel.content.btnCancelQuotePreview.click(); + }); + + await test.step('Verify quote preview is cancelled', async () => { + await expect(poHomeChannel.content.quotePreview).not.toBeVisible(); + }); + }); + + test('should quote message in Direct Message', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const quoteText = faker.lorem.sentence(); + + await test.step('Setup DM and initial message via API', async () => { + const dmRoomId = await createDirectMessageRoom(api, Users.user1.data.username); + await sendMessage(api, dmRoomId, messageText); + }); + + await test.step('Open DM and quote message', async () => { + await poHomeChannel.navbar.openChat(Users.user1.data.username); + await expect(poHomeChannel.content.channelHeader).toContainText(Users.user1.data.username); + await poHomeChannel.content.quoteMessage(quoteText, messageText); + }); + + await test.step('Verify quoted message appears in DM', async () => { + const lastMessage = poHomeChannel.content.lastUserMessage; + await expect(lastMessage).toBeVisible(); + await expect(lastMessage.locator('blockquote')).toBeVisible(); + await expect(lastMessage).toContainText(quoteText); + await expect(poHomeChannel.content.lastMessageTextAttachmentEqualsText).toHaveText(messageText); + }); + }); + + test('should quote message in Discussion', async ({ api }) => { + const originalMessage = faker.lorem.sentence(); + const discussionMessage = faker.lorem.sentence(); + const quoteText = faker.lorem.sentence(); + const discussionName = `Discussion-${Date.now()}`; + + await test.step('Setup Discussion and messages via API', async () => { + const parentMsgId = await sendMessage(api, targetChannelId, originalMessage); + const discussionRoomId = await createDiscussion(api, targetChannelId, parentMsgId, discussionName); + await sendMessage(api, discussionRoomId, discussionMessage); + }); + + await test.step('Open discussion and quote message', async () => { + await poHomeChannel.navbar.openChat(discussionName); + await expect(poHomeChannel.content.channelHeader).toContainText(discussionName); + await poHomeChannel.content.quoteMessage(quoteText, discussionMessage); + }); + + await test.step('Verify quoted message appears in discussion', async () => { + const lastMessage = poHomeChannel.content.lastUserMessage; + await expect(lastMessage).toBeVisible(); + await expect(lastMessage.locator('blockquote')).toBeVisible(); + await expect(lastMessage).toContainText(quoteText); + await expect(poHomeChannel.content.lastMessageTextAttachmentEqualsText).toHaveText(discussionMessage); + }); + }); + + test('should quote message with thread in DM', async ({ api }) => { + const messageText = faker.lorem.sentence(); + const threadMessage = faker.lorem.sentence(); + const quoteText = faker.lorem.sentence(); + + await test.step('Setup DM thread and messages via API', async () => { + const dmRoomId = await createDirectMessageRoom(api, Users.user2.data.username); + const parentMsgId = await sendMessage(api, dmRoomId, messageText); + await sendMessage(api, dmRoomId, threadMessage, parentMsgId); + }); + + await test.step('Open DM thread and quote message', async () => { + await poHomeChannel.navbar.openChat(Users.user2.data.username); + await poHomeChannel.content.openReplyInThread(); + + await poHomeChannel.content.lastUserThreadMessage.hover(); + await poHomeChannel.content.btnQuoteMessage.click(); + await expect(poHomeChannel.content.threadQuotePreview).toBeVisible(); + await poHomeChannel.content.sendMessageInThread(quoteText); + }); + + await test.step('Verify quoted message appears in DM thread', async () => { + const lastThreadMessage = poHomeChannel.content.lastUserThreadMessage; + await expect(lastThreadMessage).toBeVisible(); + await expect(lastThreadMessage.locator('blockquote')).toBeVisible(); + await expect(lastThreadMessage).toContainText(quoteText); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts b/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts new file mode 100644 index 0000000..8b83c72 --- /dev/null +++ b/apps/meteor/tests/e2e/read-receipts-deactivated-users.spec.ts @@ -0,0 +1,119 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import type { IUserState } from './fixtures/userStates'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel, setSettingValueById } from './utils'; +import { expect, test } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser, loginTestUser } from './utils/user-helpers'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('read-receipts-deactivated-users', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let user1Context: { page: Page; poHomeChannel: HomeChannel } | undefined; + let user2Context: { page: Page; poHomeChannel: HomeChannel } | undefined; + let testUser1: ITestUser; + let testUser2: ITestUser; + let testUser1State: IUserState; + let testUser2State: IUserState; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + [testUser1, testUser2] = await Promise.all([createTestUser(api), createTestUser(api)]); + + [testUser1State, testUser2State] = await Promise.all([loginTestUser(api, testUser1), loginTestUser(api, testUser2)]); + + targetChannel = await createTargetChannel(api, { members: [testUser1.data.username, testUser2.data.username] }); + await Promise.all([ + setSettingValueById(api, 'Message_Read_Receipt_Enabled', true), + setSettingValueById(api, 'Message_Read_Receipt_Store_Users', true), + ]); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Message_Read_Receipt_Enabled', false), + setSettingValueById(api, 'Message_Read_Receipt_Store_Users', false), + ]); + + await deleteChannel(api, targetChannel); + await Promise.all([testUser1?.delete(), testUser2?.delete()]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test.afterEach(async () => { + await Promise.all([user1Context?.page.close(), user2Context?.page.close()]); + user1Context = undefined; + user2Context = undefined; + }); + + test('should correctly handle read receipts as users are deactivated', async ({ browser, api, page }) => { + const { page: page1 } = await createAuxContext(browser, testUser1State); + const user1Ctx = { page: page1, poHomeChannel: new HomeChannel(page1) }; + user1Context = user1Ctx; + + const { page: page2 } = await createAuxContext(browser, testUser2State); + const user2Ctx = { page: page2, poHomeChannel: new HomeChannel(page2) }; + user2Context = user2Ctx; + + await Promise.all([ + poHomeChannel.navbar.openChat(targetChannel), + user1Ctx.poHomeChannel.navbar.openChat(targetChannel), + user2Ctx.poHomeChannel.navbar.openChat(targetChannel), + ]); + + await test.step('when all users are active', async () => { + await poHomeChannel.content.sendMessage('Message 1: All three users active'); + + await Promise.all([ + expect(user1Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(), + expect(user2Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(), + ]); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(3); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await test.step('when some users are deactivated', async () => { + await api.post('/users.setActiveStatus', { userId: testUser1.data._id, activeStatus: false }); + + await poHomeChannel.content.sendMessage('Message 2: User1 deactivated, two active users'); + + await expect(user2Ctx.poHomeChannel.content.lastUserMessage).toBeVisible(); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(2); + await page.getByRole('button', { name: 'Close' }).click(); + }); + + await test.step('when only one user remains active (user alone in room)', async () => { + await api.post('/users.setActiveStatus', { userId: testUser2.data._id, activeStatus: false }); + + await poHomeChannel.content.sendMessage('Message 3: Only admin active'); + + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(1); + await page.getByRole('button', { name: 'Close' }).click(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/read-receipts-thread.spec.ts b/apps/meteor/tests/e2e/read-receipts-thread.spec.ts new file mode 100644 index 0000000..81d4413 --- /dev/null +++ b/apps/meteor/tests/e2e/read-receipts-thread.spec.ts @@ -0,0 +1,78 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, setSettingValueById } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('read-receipts-thread', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let auxContext: { page: Page; poHomeChannel: HomeChannel } | undefined; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', true); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', true); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', false); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', false); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test.afterEach(async () => { + if (auxContext) { + await auxContext.page.close(); + } + auxContext = undefined; + }); + + test('should show read receipt as viewed in thread when both users have the thread open', async ({ browser }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('thread parent message'); + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendMessageInThread('first thread reply'); + + const { page: auxPage } = await createAuxContext(browser, Users.user1); + auxContext = { page: auxPage, poHomeChannel: new HomeChannel(auxPage) }; + await auxContext.poHomeChannel.navbar.openChat(targetChannel); + await auxContext.poHomeChannel.content.openReplyInThread(); + + // the read receipt only flips once user1 has actually read the reply, so wait for it to render on their side first + await expect(auxContext.poHomeChannel.content.lastUserThreadMessage).toContainText('first thread reply'); + + await expect(poHomeChannel.content.lastUserThreadMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + }); + + test('should show read receipt as viewed when the last unread user opens the thread', async ({ browser }) => { + const { page: auxPage } = await createAuxContext(browser, Users.user1); + auxContext = { page: auxPage, poHomeChannel: new HomeChannel(auxPage) }; + await auxContext.poHomeChannel.navbar.openChat(targetChannel); + + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('thread for delayed read'); + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendMessageInThread('reply in thread'); + + await expect(poHomeChannel.content.lastUserThreadMessage.getByRole('status', { name: 'Message sent' })).toBeVisible(); + + await expect(auxContext.poHomeChannel.content.lastThreadMessagePreview).toContainText('reply in thread'); + + await auxContext.poHomeChannel.content.openReplyInThread(); + + await expect(poHomeChannel.content.lastUserThreadMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + await expect(auxContext.poHomeChannel.content.lastUserThreadMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/read-receipts.spec.ts b/apps/meteor/tests/e2e/read-receipts.spec.ts new file mode 100644 index 0000000..5836973 --- /dev/null +++ b/apps/meteor/tests/e2e/read-receipts.spec.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, setSettingValueById } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('read-receipts', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.describe('read receipt settings disabled', async () => { + test('should not show read receipts item menu', async ({ page }) => { + await poHomeChannel.content.sendMessage('hello world'); + await poHomeChannel.content.openLastMessageMenu(); + await expect(page.locator('role=menuitem[name="Read receipts"]')).not.toBeVisible(); + }); + }); + + test.describe('read receipts enabled', async () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', true); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', true); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Message_Read_Receipt_Enabled', false); + await setSettingValueById(api, 'Message_Read_Receipt_Store_Users', false); + }); + + let auxContext: { page: Page; poHomeChannel: HomeChannel } | undefined; + + test.afterEach(async () => { + if (auxContext) { + await auxContext.page.close(); + } + auxContext = undefined; + }); + + test('should show read receipts message sent status in the sent message', async ({ browser }) => { + const { page } = await createAuxContext(browser, Users.user1); + auxContext = { page, poHomeChannel: new HomeChannel(page) }; + await auxContext.poHomeChannel.gotoChannel(targetChannel); + await auxContext.poHomeChannel.content.sendMessage('hello admin'); + + await expect(auxContext.poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message sent' })).toBeVisible(); + }); + + test('should show read receipts message viewed status in the sent message', async () => { + await expect(poHomeChannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); + }); + + test('should show the reads receipt modal with the users who read the message', async ({ page }) => { + await poHomeChannel.content.openLastMessageMenu(); + await page.locator('role=menuitem[name="Read receipts"]').click(); + + await expect(page.getByRole('dialog').getByRole('listitem')).toHaveCount(2); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/register.spec.ts b/apps/meteor/tests/e2e/register.spec.ts new file mode 100644 index 0000000..031f87d --- /dev/null +++ b/apps/meteor/tests/e2e/register.spec.ts @@ -0,0 +1,223 @@ +import { faker } from '@faker-js/faker'; + +import { Authenticated, Registration } from './page-objects'; +import { request } from '../data/api-data'; +import { test, expect } from './utils/test'; + +test.describe.parallel('register', () => { + let poRegistration: Registration; + let poAuth: Authenticated; + + test.beforeEach(({ page }) => { + poRegistration = new Registration(page); + poAuth = new Authenticated(page); + }); + + test.describe('Registration default flow', async () => { + test('should complete the registration flow', async () => { + await test.step('expect trigger a validation error if no data is provided on register', async () => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + + await poRegistration.btnRegister.click(); + + await expect(poRegistration.inputName).toBeInvalid(); + await expect(poRegistration.inputEmail).toBeInvalid(); + await expect(poRegistration.inputPassword).toBeInvalid(); + await expect(poRegistration.username).toBeInvalid(); + }); + + await test.step('expect trigger a validation error if different password is provided on register', async () => { + await poRegistration.inputName.fill(faker.person.firstName()); + await poRegistration.inputEmail.fill(faker.internet.email()); + await poRegistration.username.fill(faker.internet.userName()); + await poRegistration.inputPassword.fill('P@ssw0rd1234.!'); + await poRegistration.inputPasswordConfirm.fill('Password1235.!'); + await poRegistration.btnRegister.click(); + + await expect(poRegistration.inputPasswordConfirm).toBeInvalid(); + }); + + await test.step('expect successfully register a new user', async () => { + await poRegistration.inputPasswordConfirm.fill('P@ssw0rd1234.!'); + await poRegistration.btnRegister.click(); + await poAuth.waitForDisplay(); + }); + }); + + test.describe('should complete registration without account confirmation password set', async () => { + test.beforeEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_RequirePasswordConfirmation', { value: false }); + + await expect(result.ok()).toBeTruthy(); + }); + + test.beforeEach(async () => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + }); + + test.afterEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_RequirePasswordConfirmation', { + value: true, + }); + + await expect(result.ok()).toBeTruthy(); + }); + + test('should register a user without password confirmation', async () => { + await test.step('expect to not have password confirmation field', async () => { + await expect(poRegistration.inputPasswordConfirm).toBeHidden(); + }); + + await test.step('expect to found no errors after submit the form', async () => { + await poRegistration.inputName.fill(faker.person.firstName()); + await poRegistration.inputEmail.fill(faker.internet.email()); + await poRegistration.username.fill(faker.internet.userName()); + await poRegistration.inputPassword.fill('P@ssw0rd1234.!'); + + await poRegistration.btnRegister.click(); + await poAuth.waitForDisplay(); + }); + }); + }); + + test.describe('should complete registration with manually confirmation enabled', async () => { + test.beforeEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_ManuallyApproveNewUsers', { value: true }); + + await expect(result.ok()).toBeTruthy(); + }); + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + }); + + test.afterEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_ManuallyApproveNewUsers', { + value: false, + }); + await expect(result.ok()).toBeTruthy(); + }); + + test('should expect to have a textbox asking the reason for the registration', async () => { + await test.step('expect to have a textbox asking the reason for the registration', async () => { + await expect(poRegistration.inputReason).toBeVisible(); + }); + + await test.step('expect to show and error if no reason is provided', async () => { + await poRegistration.btnRegister.click(); + await expect(poRegistration.inputReason).toBeInvalid(); + }); + }); + }); + + test.describe('Registration form Disabled', async () => { + test.beforeEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_RegistrationForm', { value: 'Disabled' }); + await expect(result.ok()).toBeTruthy(); + }); + + test.afterEach(async ({ api }) => { + await api.post('/settings/Accounts_RegistrationForm', { value: 'Public' }); + }); + + test('should expect a message warning that registration is disabled', async () => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + await expect(poRegistration.registrationDisabledCallout).toBeVisible(); + }); + }); + + // FIXME: better to check for violations through unit tests rather than end-to-end tests + test('should not have any accessibility violations', async ({ makeAxeBuilder }) => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + + const results = await makeAxeBuilder().analyze(); + + expect(results.violations).toEqual([]); + }); + }); + + test.describe('Registration form validation', async () => { + test('should not allow registration with an already registered email', async ({ page }) => { + const email = faker.internet.email(); + await request.post('/api/v1/users.register').set('Content-Type', 'application/json').send({ + name: faker.person.firstName(), + email, + username: faker.internet.userName(), + pass: 'P@ssw0rd1234.!', + }); + + await test.step('Attempt registration with the same email', async () => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + await poRegistration.inputName.fill(faker.person.firstName()); + await poRegistration.inputEmail.fill(email); + await poRegistration.username.fill(faker.internet.userName()); + await poRegistration.inputPassword.fill('P@ssw0rd1234.!'); + await poRegistration.inputPasswordConfirm.fill('P@ssw0rd1234.!'); + await poRegistration.btnRegister.click(); + + await expect(page.getByRole('alert').filter({ hasText: 'Email already in use' })).toBeVisible(); + }); + }); + }); + + test.describe('Registration for secret password', async () => { + test.beforeEach(async ({ api }) => { + const result = await api.post('/settings/Accounts_RegistrationForm', { value: 'Secret URL' }); + await api.post('/settings/Accounts_RegistrationForm_SecretURL', { value: 'secret' }); + await expect(result.ok()).toBeTruthy(); + }); + + test.afterAll(async ({ api }) => { + const result = await api.post('/settings/Accounts_RegistrationForm', { value: 'Public' }); + await expect(result.ok()).toBeTruthy(); + }); + + test('should expect a message warning that registration is disabled', async () => { + await poRegistration.goto(); + await poRegistration.goToRegister.click(); + await expect(poRegistration.registrationDisabledCallout).toBeVisible(); + }); + + test.describe('Using an invalid secret password', async () => { + test.beforeEach(async () => { + await poRegistration.gotoWithSecret('invalid_secret', poRegistration.registrationInvalidUrlCallout); + }); + + test('should expect a invalid page informing that the secret password is invalid', async () => { + await expect(poRegistration.registrationInvalidUrlCallout).toBeVisible(); + }); + }); + + test('should register a user if the right secret password is provided', async () => { + await poRegistration.gotoWithSecret('secret', poRegistration.inputName); + await poRegistration.inputName.fill(faker.person.firstName()); + await poRegistration.inputEmail.fill(faker.internet.email()); + await poRegistration.username.fill(faker.internet.userName()); + await poRegistration.inputPassword.fill('P@ssw0rd1234.!'); + await poRegistration.inputPasswordConfirm.fill('P@ssw0rd1234.!'); + await poRegistration.btnRegister.click(); + await poAuth.waitForDisplay(); + }); + }); + + test.describe('Registration by Secret is disabled url should fail', async () => { + test.beforeAll(async ({ api }) => { + const result = await api.post('/settings/Accounts_RegistrationForm', { value: 'Public' }); + await api.post('/settings/Accounts_RegistrationForm_SecretURL', { value: 'secret' }); + await expect(result.ok()).toBeTruthy(); + }); + + test('should show an invalid page informing that the url is not valid', async () => { + await poRegistration.gotoWithSecret('secret', poRegistration.registrationInvalidUrlCallout); + await expect(poRegistration.registrationInvalidUrlCallout).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/report-message.spec.ts b/apps/meteor/tests/e2e/report-message.spec.ts new file mode 100644 index 0000000..860d1ec --- /dev/null +++ b/apps/meteor/tests/e2e/report-message.spec.ts @@ -0,0 +1,124 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { Users } from './fixtures/userStates'; +import { AdminModeration, HomeChannel } from './page-objects'; +import { ReportMessageModal } from './page-objects/fragments'; +import { createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('report message', () => { + let poHomeChannel: HomeChannel; + let adminHomeChannel: HomeChannel; + let targetChannel: string; + let adminPage: Page; + let reportModal: ReportMessageModal; + + test.beforeAll(async ({ api, browser }) => { + targetChannel = await createTargetChannel(api, { members: ['user1', 'admin'] }); + adminPage = await browser.newPage({ storageState: Users.admin.state }); + reportModal = new ReportMessageModal(adminPage); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/moderation.user.deleteReportedMessages', { + userId: 'user1', + }), + deleteChannel(api, targetChannel), + adminPage.close(), + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + adminHomeChannel = new HomeChannel(adminPage); + + await poHomeChannel.gotoChannel(targetChannel); + await adminHomeChannel.gotoChannel(targetChannel); + }); + + test('should show report message option in message menu for other users messages', async () => { + await test.step('send message as user1', async () => { + const testMessage = faker.lorem.sentence(); + await poHomeChannel.content.sendMessage(testMessage); + }); + + await test.step('verify report option is visible for the other user', async () => { + await adminHomeChannel.content.openLastMessageMenu(); + await expect(adminPage.getByRole('menuitem', { name: 'Report' })).toBeVisible(); + }); + }); + + test('should not show report message option in message menu for own messages', async ({ page }) => { + await test.step('send message as user1', async () => { + const testMessage = faker.lorem.sentence(); + await poHomeChannel.content.sendMessage(testMessage); + }); + + await test.step('verify report option is not visible for own message', async () => { + await poHomeChannel.content.openLastMessageMenu(); + await expect(page.getByRole('menuitem', { name: 'Report' })).not.toBeVisible(); + }); + }); + + test('should validate empty report description', async () => { + await test.step('send message as user1', async () => { + const testMessage = faker.lorem.sentence(); + await poHomeChannel.content.sendMessage(testMessage); + }); + + await test.step('try to submit empty report', async () => { + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.submitReport(); + }); + }); + + test('should be able to cancel reporting a message', async () => { + await test.step('send message as user1', async () => { + const testMessage = faker.lorem.sentence(); + await poHomeChannel.content.sendMessage(testMessage); + }); + + await test.step('open and cancel report modal', async () => { + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.cancelReport(); + }); + }); + + test('should successfully report a message and verify its appearance in moderation console', async () => { + let testMessage: string; + let reportDescription: string; + + await test.step('send message as user1', async () => { + testMessage = faker.lorem.sentence(); + await poHomeChannel.content.sendMessage(testMessage); + }); + + await test.step('report message as the other user', async () => { + reportDescription = faker.lorem.sentence(); + + await adminHomeChannel.content.openLastMessageMenu(); + await adminPage.getByRole('menuitem', { name: 'Report' }).click(); + await reportModal.submitReport(reportDescription); + }); + + await test.step('verify report in moderation console', async () => { + await new AdminModeration(adminPage).gotoMessages(); + + await expect(adminPage.getByRole('tab', { name: 'Reported messages' })).toBeVisible(); + const reportedUserLink = adminPage.getByRole('table').getByRole('link', { name: 'user1' }); + await expect(reportedUserLink).toBeVisible(); + await reportedUserLink.click(); + + await expect(adminPage.getByText(testMessage)).toBeVisible(); + + await adminPage.getByRole('button', { name: 'Show reports' }).click(); + await expect(adminPage.getByText(reportDescription)).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/reset-password.spec.ts b/apps/meteor/tests/e2e/reset-password.spec.ts new file mode 100644 index 0000000..f4ab179 --- /dev/null +++ b/apps/meteor/tests/e2e/reset-password.spec.ts @@ -0,0 +1,35 @@ +import { Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe.parallel('Reset Password', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ api, page }) => { + poRegistration = new Registration(page); + await setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', true); + + await poRegistration.gotoResetPassword('someToken'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', true); + }); + + test('should confirm password be invalid', async () => { + await poRegistration.inputPassword.fill('P@ssw0rd1234.!'); + await poRegistration.inputPasswordConfirm.fill('Password4321.!'); + await poRegistration.btnReset.click(); + await expect(poRegistration.inputPasswordConfirm).toBeInvalid(); + }); + + test('should confirm password not be visible', async ({ api }) => { + await setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', false); + await expect(poRegistration.inputPasswordConfirm).not.toBeVisible(); + }); + + test('should not have any accessibility violations', async ({ makeAxeBuilder }) => { + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/apps/meteor/tests/e2e/retention-policy.spec.ts b/apps/meteor/tests/e2e/retention-policy.spec.ts new file mode 100644 index 0000000..36fdd67 --- /dev/null +++ b/apps/meteor/tests/e2e/retention-policy.spec.ts @@ -0,0 +1,215 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel, HomeTeam } from './page-objects'; +import { createTargetTeam, createTargetPrivateChannel, getSettingValueById, setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; +import { timeUnitToMs, TIMEUNIT } from '../../client/lib/convertTimeUnit'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('retention-policy', () => { + let poHomeChannel: HomeChannel; + const targetChannel = faker.string.uuid(); + let targetTeam: string; + let targetGroup: string; + + test.beforeAll(async ({ api }) => { + const response = await api.post('/channels.create', { name: targetChannel, members: ['user1'] }); + const { channel } = await response.json(); + await api.post('/channels.addOwner', { roomId: channel._id, userId: Users.user1.data._id }); + + targetGroup = await createTargetPrivateChannel(api); + targetTeam = await createTargetTeam(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.describe('retention policy disabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'RetentionPolicy_Enabled', false); + }); + + test('should not show prune banner in channel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + }); + + test('should not show prune banner in team', async () => { + await poHomeChannel.navbar.openChat(targetTeam); + + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + }); + + test('should not show prune section on edit channel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + + await expect(poHomeChannel.tabs.editRoom.pruneAccordion).not.toBeVisible(); + }); + }); + + test.describe('retention policy enabled', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'RetentionPolicy_Enabled', true); + }); + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'RetentionPolicy_Enabled', false); + await setSettingValueById(api, 'RetentionPolicy_AppliesToChannels', false); + await setSettingValueById(api, 'RetentionPolicy_AppliesToGroups', false); + await setSettingValueById(api, 'RetentionPolicy_AppliesToDMs', false); + await setSettingValueById(api, 'RetentionPolicy_TTL_Channels', timeUnitToMs(TIMEUNIT.days, 30)); + }); + + test('should not show prune banner even with retention policy setting enabled in any type of room', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + + await poHomeChannel.navbar.openChat(targetTeam); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + + await poHomeChannel.navbar.openChat(targetGroup); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + + await poHomeChannel.navbar.openChat('user1'); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).not.toBeVisible(); + }); + + test('should show prune section in edit channel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + + await expect(poHomeChannel.tabs.editRoom.pruneAccordion).toBeVisible(); + }); + + test.describe('edit-room-retention-policy permission', async () => { + let auxContext: { page: Page; poHomeChannel: HomeChannel }; + test.beforeEach(async ({ browser }) => { + const { page } = await createAuxContext(browser, Users.user1); + auxContext = { page, poHomeChannel: new HomeChannel(page) }; + await auxContext.poHomeChannel.navbar.openChat(targetChannel); + await auxContext.poHomeChannel.roomToolbar.openRoomInfo(); + await auxContext.poHomeChannel.tabs.room.btnEdit.click(); + }); + test.afterEach(async () => { + await auxContext.page.close(); + }); + test('should not show prune section in edit channel for users without permission', async () => { + await expect(poHomeChannel.tabs.editRoom.pruneAccordion).not.toBeVisible(); + }); + + test('users without permission should be able to edit the channel', async () => { + await auxContext.poHomeChannel.tabs.editRoom.advancedSettingsAccordion.click(); + await auxContext.poHomeChannel.tabs.editRoom.checkboxReadOnly.check(); + await auxContext.poHomeChannel.tabs.editRoom.btnSave.click(); + + await expect(auxContext.poHomeChannel.content.getSystemMessageByText('set room to read only')).toBeVisible(); + }); + }); + + test.describe('retention policy applies enabled by default', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'RetentionPolicy_AppliesToChannels', true); + await setSettingValueById(api, 'RetentionPolicy_AppliesToGroups', true); + await setSettingValueById(api, 'RetentionPolicy_AppliesToDMs', true); + }); + + test('should prune old messages checkbox enabled by default in channel and show retention policy banner', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).toBeVisible(); + + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.pruneAccordion.click(); + await expect(poHomeChannel.tabs.editRoom.checkboxPruneMessages).toBeChecked(); + }); + + test('should prune old messages checkbox enabled by default in team and show retention policy banner', async ({ page }) => { + const poHomeTeam = new HomeTeam(page); + await poHomeTeam.navbar.openChat(targetTeam); + await expect(poHomeTeam.content.channelRetentionPolicyWarning).toBeVisible(); + + await poHomeTeam.headerToolbar.openTeamInfo(); + await poHomeTeam.tabs.room.btnEdit.click(); + await poHomeTeam.tabs.editRoom.pruneAccordion.click(); + await expect(poHomeTeam.tabs.editRoom.checkboxPruneMessages).toBeChecked(); + }); + + test('should prune old messages checkbox enabled by default in group and show retention policy banner', async () => { + await poHomeChannel.navbar.openChat(targetGroup); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).toBeVisible(); + + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.pruneAccordion.click(); + await expect(poHomeChannel.tabs.editRoom.checkboxPruneMessages).toBeChecked(); + }); + + test('should show retention policy banner in DMs', async () => { + await poHomeChannel.navbar.openChat('user1'); + await expect(poHomeChannel.content.channelRetentionPolicyWarning).toBeVisible(); + }); + }); + + test.describe('retention policy override', () => { + let ignoreThreadsSetting: boolean; + + test.beforeAll(async ({ api }) => { + ignoreThreadsSetting = (await getSettingValueById(api, 'RetentionPolicy_DoNotPruneThreads')) as boolean; + expect((await setSettingValueById(api, 'RetentionPolicy_TTL_Channels', timeUnitToMs(TIMEUNIT.days, 15))).status()).toBe(200); + }); + + test.beforeEach(async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.pruneAccordion.click(); + }); + + test('should display the default max age in edit channel', async () => { + await poHomeChannel.tabs.editRoom.checkboxOverrideGlobalRetention.click(); + + await expect(poHomeChannel.tabs.editRoom.getMaxAgeLabel('15')).toBeVisible(); + }); + + test('should display overridden retention max age value', async () => { + await poHomeChannel.tabs.editRoom.checkboxOverrideGlobalRetention.click(); + await poHomeChannel.tabs.editRoom.inputRetentionMaxAge.fill('365'); + await poHomeChannel.tabs.editRoom.btnSave.click(); + await poHomeChannel.toastMessage.dismissToast(); + + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.pruneAccordion.click(); + + await expect(poHomeChannel.tabs.editRoom.getMaxAgeLabel('15')).toBeVisible(); + await expect(poHomeChannel.tabs.editRoom.inputRetentionMaxAge).toHaveValue('365'); + }); + + test('should ignore threads be checked accordingly with the global default value', async () => { + await expect(poHomeChannel.tabs.editRoom.checkboxIgnoreThreads).toBeChecked({ checked: ignoreThreadsSetting }); + }); + + test('should override ignore threads default value', async () => { + await poHomeChannel.tabs.editRoom.checkboxIgnoreThreads.click(); + await poHomeChannel.tabs.editRoom.btnSave.click(); + await poHomeChannel.toastMessage.dismissToast(); + + await poHomeChannel.roomToolbar.openRoomInfo(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.editRoom.pruneAccordion.click(); + + await expect(poHomeChannel.tabs.editRoom.checkboxIgnoreThreads).toBeChecked({ checked: !ignoreThreadsSetting }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/rooms-join.spec.ts b/apps/meteor/tests/e2e/rooms-join.spec.ts new file mode 100644 index 0000000..cf6c5ac --- /dev/null +++ b/apps/meteor/tests/e2e/rooms-join.spec.ts @@ -0,0 +1,156 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom } from '@rocket.chat/core-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { + createTargetChannel, + createTargetDiscussion, + createTargetGroupAndReturnFullRoom, + deleteRoom, + sendTargetChannelMessage, +} from './utils'; +import { test, expect } from './utils/test'; + +test.describe.serial('Join rooms', () => { + test.use({ storageState: Users.user1.state }); + + test.describe('public channels without preview-c-room', () => { + let targetChannel: string; + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ api }) => { + targetChannel = await createTargetChannel(api); + await sendTargetChannelMessage(api, targetChannel, { msg: 'message from a channel the user has not joined' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + }); + + test.beforeAll(async ({ api }) => { + // restrict preview to admin so a regular user lands on the "not subscribed" + // screen, whose "Join channel" button drives /v1/rooms.join via useJoinRoom + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + }); + + test('should let a non-member join a public channel', async () => { + await expect(poHomeChannel.btnJoinChannel).toBeVisible(); + await poHomeChannel.btnJoinChannel.click(); + await expect(poHomeChannel.btnJoinChannel).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('public channel with preview-c-room', () => { + let targetChannel: string; + let poHomeChannel: HomeChannel; + + // Set the precondition explicitly instead of relying on the previous block's `afterAll` + // to have restored the permission. That cross-block coupling is racy: if the restore + // hasn't propagated, the user lands on the "not subscribed" screen and the composer's + // Join button never renders. + test.beforeAll(async ({ api }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + }); + + test.beforeEach(async ({ api }) => { + targetChannel = await createTargetChannel(api); + await sendTargetChannelMessage(api, targetChannel, { msg: 'message from a channel the user has not joined' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + // `gotoChannel` waits for the room to finish loading, so the Join-button assertion + // below doesn't race the preview render. + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + }); + + test('should let a non-member join a public channel', async () => { + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + await poHomeChannel.composer.btnJoinRoom.click(); + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('discussion with preview-c-room', () => { + test.use({ storageState: Users.user1.state }); + + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + let discussion: Record; + + test.beforeAll(async ({ api }) => { + // Don't depend on an earlier block's `afterAll` to restore preview-c-room (racy). + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + discussion = await createTargetDiscussion(api); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, discussion._id); + }); + + test('should let a non-member join a discussion', async () => { + await poHomeChannel.gotoChannel(discussion.name); + + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('discussion inside a private channel', () => { + let poHomeChannel: HomeChannel; + let group: IRoom; + let discussion: Record; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test.beforeAll(async ({ api }) => { + // The user is a member of the private parent but NOT of the discussion. + // The discussion is type `p` and its access is inherited from the parent, + // so `channels.join` could not resolve it but `rooms.join` can. + ({ group } = await createTargetGroupAndReturnFullRoom(api, { members: [Users.user1.data.username] })); + const response = await api.post('/rooms.createDiscussion', { prid: group._id, t_name: faker.string.uuid() }); + ({ discussion } = await response.json()); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, discussion._id); + await api.post('/groups.delete', { roomId: group._id }); + }); + + test('should let a parent member join a discussion in a private channel', async () => { + await poHomeChannel.gotoGroup(discussion.name); + + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts new file mode 100644 index 0000000..cef4b84 --- /dev/null +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -0,0 +1,875 @@ +import child_process from 'child_process'; +import path from 'path'; + +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; +import { v2 as compose } from 'docker-compose'; +import { MongoClient } from 'mongodb'; + +import * as constants from './config/constants'; +import { createUserFixture } from './fixtures/collections/users'; +import { Users } from './fixtures/userStates'; +import { Registration } from './page-objects'; +import { convertHexToRGB } from './utils/convertHexToRGB'; +import { createCustomRole, deleteCustomRole } from './utils/custom-role'; +import { getUserInfo } from './utils/getUserInfo'; +import { parseMeteorResponse } from './utils/parseMeteorResponse'; +import { saveSettings } from './utils/saveSettings'; +import { setSettingValueById } from './utils/setSettingValueById'; +import type { BaseTest } from './utils/test'; +import { test, expect } from './utils/test'; + +const KEY = 'fuselage-sessionStorage-saml_invite_token'; + +const resetTestData = async ({ api, cleanupOnly = false }: { api?: any; cleanupOnly?: boolean } = {}) => { + // Reset saml users' data on mongo in the beforeAll hook to allow re-running the tests within the same playwright session + // This is needed because those tests will modify this data and running them a second time would trigger different code paths + const connection = await MongoClient.connect(constants.URL_MONGODB); + + const usernamesToDelete = [ + ...[ + Users.userForSamlMerge, + Users.userForSamlMerge2, + Users.samluser1, + Users.samluser2, + Users.samluser4, + Users.samluser5, + Users.samluser6, + Users.samluser7, + Users.samlusernoname, + Users.samlusernoname2, + ].map(({ data: { username } }) => username), + 'custom_saml_username', + 'custom_saml_username2', + ]; + + await connection + .db() + .collection('users') + .deleteMany({ + username: { + $in: usernamesToDelete, + }, + }); + + if (cleanupOnly) { + return; + } + + const usersFixtures = [Users.userForSamlMerge, Users.userForSamlMerge2].map((user) => createUserFixture(user)); + await Promise.all( + usersFixtures.map((user) => + connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), + ), + ); + + const settings = [ + { _id: 'Accounts_AllowAnonymousRead', value: false }, + { _id: 'Accounts_AllowUsernameChange', value: true }, + { _id: 'SAML_Custom_Default_logout_behaviour', value: 'SAML' }, + { _id: 'SAML_Custom_Default_immutable_property', value: 'EMail' }, + { _id: 'SAML_Custom_Default_mail_overwrite', value: false }, + { _id: 'SAML_Custom_Default_name_overwrite', value: false }, + { _id: 'SAML_Custom_Default', value: false }, + ...(constants.IS_EE + ? [ + { _id: 'SAML_Custom_Default_role_attribute_sync', value: true }, + { _id: 'SAML_Custom_Default_role_attribute_name', value: 'role' }, + ] + : []), + { _id: 'SAML_Custom_Default_user_data_fieldmap', value: '{"username":"username", "email":"email", "name": "cn"}' }, + { _id: 'SAML_Custom_Default_provider', value: 'test-sp' }, + { _id: 'SAML_Custom_Default_issuer', value: 'http://localhost:3000/_saml/metadata/test-sp' }, + { _id: 'SAML_Custom_Default_entry_point', value: 'http://localhost:8080/simplesaml/saml2/idp/SSOService.php' }, + { _id: 'SAML_Custom_Default_idp_slo_redirect_url', value: 'http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php' }, + { _id: 'SAML_Custom_Default_button_label_text', value: 'SAML test login button' }, + { _id: 'SAML_Custom_Default_button_color', value: '#185925' }, + { _id: 'SAML_Custom_Default_signature_validation_type', value: 'All' }, + { _id: 'SAML_Custom_Default_validate_logout_request_signature', value: true }, + { _id: 'SAML_Custom_Default_validate_logout_response_signature', value: true }, + { + _id: 'SAML_Custom_Default_cert', + value: `MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ==`, + }, + ]; + + await saveSettings(api, settings); +}; + +const setupCustomRole = async (api: BaseTest['api']) => { + const roleResponse = await createCustomRole(api, { name: 'saml-role' }); + expect(roleResponse.status()).toBe(200); + + const { role } = await roleResponse.json(); + return role._id; +}; + +test.describe('SAML', () => { + let poRegistration: Registration; + let samlRoleId: string; + let targetInviteGroupId: string; + let targetInviteGroupName: string; + let inviteId: string; + let targetChannel: string; + let autoCreatedChannel: string; + + const containerPath = path.join(__dirname, 'containers', 'saml'); + + test.beforeAll(async ({ api }) => { + await resetTestData({ api }); + + // Only one setting updated through the API to avoid refreshing the service configurations several times + await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); + + // Create a new custom role + if (constants.IS_EE) { + samlRoleId = await setupCustomRole(api); + } + + await compose.buildOne('testsamlidp_idp', { + cwd: containerPath, + }); + + await compose.upOne('testsamlidp_idp', { + cwd: containerPath, + }); + }); + + test.beforeAll(async ({ api }) => { + const [groupResponse, channelResponse] = await Promise.all([ + api.post('/groups.create', { name: faker.string.uuid() }), + api.post('/channels.create', { name: 'saml-channel-1' }), + ]); + + expect(groupResponse.status()).toBe(200); + expect(channelResponse.status()).toBe(200); + + const { group } = await groupResponse.json(); + targetInviteGroupId = group._id; + targetInviteGroupName = group.name; + targetChannel = 'saml-channel-1'; + + const inviteResponse = await api.post('/findOrCreateInvite', { rid: targetInviteGroupId, days: 1, maxUses: 0 }); + expect(inviteResponse.status()).toBe(200); + const { _id } = await inviteResponse.json(); + inviteId = _id; + }); + + test.afterAll(async ({ api }) => { + await compose.down({ + cwd: containerPath, + }); + + // the compose CLI doesn't have any way to remove images, so try to remove it with a direct call to the docker cli, but ignore errors if it fails. + try { + child_process.spawn('docker', ['rmi', 'saml-testsamlidp_idp'], { + cwd: containerPath, + }); + } catch { + // ignore errors here + } + + // Remove saml test users so they don't interfere with other tests + await resetTestData({ cleanupOnly: true }); + + // Remove created custom role + if (constants.IS_EE) { + expect((await deleteCustomRole(api, 'saml-role')).status()).toBe(200); + } + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/groups.delete', { roomId: targetInviteGroupId }).then((response) => expect(response.status()).toBe(200)), + api.post('/channels.delete', { roomName: targetChannel }).then((response) => expect(response.status()).toBe(200)), + api.post('/channels.delete', { roomName: autoCreatedChannel }), + ]); + }); + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await poRegistration.goto(); + }); + + test('Login', async ({ page, api }) => { + await test.step('expect to have SAML login button available', async () => { + await expect(poRegistration.btnLoginWithSaml).toBeVisible({ timeout: 10000 }); + }); + + await test.step('expect to have SAML login button to have the required background color', async () => { + await expect(poRegistration.btnLoginWithSaml).toHaveCSS('background-color', convertHexToRGB('#185925')); + }); + + await test.step('expect to be redirected to the IdP for login', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + }); + + await test.step('expect to be redirected back on successful login', async () => { + await page.getByLabel('Username').fill('samluser1'); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + await expect(page).toHaveURL('/home'); + }); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser1'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser1'); + expect(user?.name).toBe('Saml User 1'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser1@example.com'); + }); + }); + + test('Allow password change for OAuth users', async ({ api }) => { + await test.step("should not send password reset mail if 'Allow Password Change for OAuth Users' setting is disabled", async () => { + expect((await setSettingValueById(api, 'Accounts_AllowPasswordChangeForOAuthUsers', false)).status()).toBe(200); + + const response = await api.post('/method.call/sendForgotPasswordEmail', { + message: JSON.stringify({ msg: 'method', id: 'id', method: 'sendForgotPasswordEmail', params: ['samluser1@example.com'] }), + }); + const mailSent = await parseMeteorResponse(response); + expect(response.status()).toBe(200); + expect(mailSent).toBe(false); + }); + + await test.step("should send password reset mail if 'Allow Password Change for OAuth Users' setting is enabled", async () => { + expect((await setSettingValueById(api, 'Accounts_AllowPasswordChangeForOAuthUsers', true)).status()).toBe(200); + + const response = await api.post('/method.call/sendForgotPasswordEmail', { + message: JSON.stringify({ msg: 'method', id: 'id', method: 'sendForgotPasswordEmail', params: ['samluser1@example.com'] }), + }); + const mailSent = await parseMeteorResponse(response); + expect(response.status()).toBe(200); + expect(mailSent).toBe(true); + }); + }); + + const doChangeSetting = async (api: any, settingId: string, settingValue: any) => { + await expect((await setSettingValueById(api, settingId, settingValue)).status()).toBe(200); + }; + + const doLoginStep = async (page: Page, username: string, redirectUrl: string | null = '/home') => { + await test.step('expect successful login', async () => { + await poRegistration.btnLoginWithSaml.click(); + // Redirect to Idp + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + // Fill username and password + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + // Redirect back to rocket.chat + if (redirectUrl) { + await expect(page).toHaveURL(redirectUrl); + await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); + } + }); + }; + + const doLoginStepWithUsernameSelection = async (page: Page, username: string) => { + await test.step('expect successful login without username to show username selection screen', async () => { + await poRegistration.btnLoginWithSaml.click(); + // Redirect to Idp + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + // Fill username and password + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + // Should redirect to username selection screen + await expect(poRegistration.username).toBeVisible(); + }); + }; + + const doUsernameSelection = async (customUsername: string) => { + await poRegistration.username.fill(customUsername); + await poRegistration.btnRegisterConfirmUsername.click(); + }; + + const doLogoutStep = async (page: Page) => { + await test.step('logout', async () => { + await page.getByRole('button', { name: 'User menu' }).click(); + await page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click(); + + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + }); + }; + + const expectLoggedOutAfterLogoutRequest = async (page: Page) => { + await test.step('expect user to be logged out from Rocket.Chat', async () => { + // The logout response echoes back the RelayState from the request (per the SAML spec), so the browser + // lands on the IdP after SLO instead of returning here; navigate back to confirm the session ended. + await poRegistration.goto(); + await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + }); + }; + + test('Logout - Rocket.Chat only', async ({ page, api }) => { + await test.step('Configure logout to only logout from Rocket.Chat', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'Local')).status()).toBe(200); + }); + + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to redirect back automatically on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL('/home'); + }); + }); + + test('Logout - Single Sign Out', async ({ page, api }) => { + await test.step('Configure logout to terminate SAML session', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'SAML')).status()).toBe(200); + }); + + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to show login form on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + await expect(page.getByLabel('Username')).toBeVisible(); + }); + }); + + test('Logout - From IdP', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + // This should trigger a logout request from the IdP, with a redirect to our home on success + await page.goto('http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php?ReturnTo=http://localhost:3000'); + + await test.step('expect user to be logged out from Rocket.Chat', async () => { + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + }); + }); + + test('User Merge - By Email', async ({ page, api }) => { + await test.step('Configure SAML to identify users by email', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'EMail')).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser2'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge'); + expect(user?.username).toBe('samluser2'); + expect(user?.name).toBe('user_for_saml_merge'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge@email.com'); + }); + }); + + test('User Merge - By Email with Name Override', async ({ page, api }) => { + await test.step('Configure SAML to identify users by email', async () => { + expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'EMail')).status()).toBe(200); + expect((await setSettingValueById(api, 'SAML_Custom_Default_name_overwrite', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser2'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge'); + expect(user?.username).toBe('samluser2'); + expect(user?.name).toBe('Saml User 2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge@email.com'); + }); + }); + + test('User Merge - By Username', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + expect((await setSettingValueById(api, 'SAML_Custom_Default_name_overwrite', false)).status()).toBe(200); + expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', false)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('user_for_saml_merge2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge2@email.com'); + }); + }); + + test('User Merge - By Username with Email Override', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + expect((await setSettingValueById(api, 'SAML_Custom_Default_name_overwrite', false)).status()).toBe(200); + expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('user_for_saml_merge2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser3@example.com'); + }); + }); + + test('User Merge - By Username with Name Override', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + await expect((await setSettingValueById(api, 'SAML_Custom_Default_name_overwrite', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('Saml User 3'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser3@example.com'); + }); + }); + + test('User Mapping - Custom Role', async ({ page, api }) => { + test.skip(!constants.IS_EE); + + await doLoginStep(page, 'samluser4'); + + await test.step('expect users role to have been mapped correctly', async () => { + const user = await getUserInfo(api, 'samluser4'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser4'); + expect(user?.name).toBe('Saml User 4'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser4@example.com'); + expect(user?.roles).toBeDefined(); + expect(user?.roles?.length).toBe(1); + expect(user?.roles).toContain(samlRoleId); + }); + }); + + test('Redirect to a specific group after login when using a valid invite link', async ({ page }) => { + await poRegistration.gotoInvite(inviteId); + await page.getByRole('link', { name: 'Back to Login' }).click(); + + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual(JSON.stringify(inviteId)); + + await doLoginStep(page, 'samluser1', null); + + await test.step('expect to be redirected to the invited room after succesful login', async () => { + await expect(page).toHaveURL(`/group/${targetInviteGroupName}`); + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual('null'); + }); + }); + + test('Remove invite token from session storage if invite is not used', async ({ page }) => { + await poRegistration.gotoInvite(inviteId); + await page.getByRole('link', { name: 'Back to Login' }).click(); + + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual(JSON.stringify(inviteId)); + + await poRegistration.goto(); + await doLoginStep(page, 'samluser2'); + + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual('null'); + }); + + test('Redirect to home after login when no redirectUrl is provided', async ({ page }) => { + await doLoginStep(page, 'samluser2'); + + await test.step('expect to be redirected to the homepage after succesful login', async () => { + await expect(page).toHaveURL('/home'); + }); + }); + + test('Respect redirectUrl on multiple parallel logins', async ({ page, browser }) => { + const page2 = await browser.newPage(); + const poRegistration2 = new Registration(page2); + + await poRegistration2.goto(); + await expect(page2).toHaveURL('/home'); + + await poRegistration.gotoInvite(inviteId); + await page.getByRole('link', { name: 'Back to Login' }).click(); + + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual(JSON.stringify(inviteId)); + expect(await page2.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual('null'); + + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + await poRegistration.btnLoginWithSaml.click(); + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + await expect(page2.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + await expect(poRegistration2.btnLoginWithSaml).toBeVisible(); + await poRegistration2.btnLoginWithSaml.click(); + await expect(page2).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + await page.getByLabel('Username').fill('samluser1'); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + await page2.getByLabel('Username').fill('samluser2'); + await page2.getByLabel('Password').fill('password'); + await page2.locator('role=button[name="Login"]').click(); + + await expect(page).toHaveURL(`/group/${targetInviteGroupName}`); + await expect(page2).toHaveURL('/home'); + + expect(await page.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual('null'); + expect(await page2.evaluate((key) => sessionStorage.getItem(key), KEY)).toEqual('null'); + + await page2.close(); + }); + + test('Login - User without username can set initial username during first login', async ({ page, api }) => { + await doLoginStepWithUsernameSelection(page, 'samlusernoname2'); + + await test.step('expect to be redirected to the username selection page and set the username', async () => { + await expect(poRegistration.btnRegisterConfirmUsername).toBeVisible(); + await doUsernameSelection('custom_saml_username2'); + }); + + await test.step('expect to be redirected to the homepage after succesful login', async () => { + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); + }); + + await test.step('expect user data to have been created with custom username', async () => { + const user = await getUserInfo(api, 'custom_saml_username2'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('custom_saml_username2'); + expect(user?.name).toBe('Saml User No Username 2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samlusernoname2@example.com'); + }); + }); + + test.describe('Login - Username selection when allow username change is disabled', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_AllowUsernameChange', false)).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_AllowUsernameChange', true)).status()).toBe(200); + }); + + test('User without username can set initial username when username changes are disabled', async ({ page, api }) => { + await doLoginStepWithUsernameSelection(page, 'samlusernoname'); + + await test.step('expect to be redirected to the username selection page and set the username', async () => { + await expect(poRegistration.btnRegisterConfirmUsername).toBeVisible(); + await doUsernameSelection('custom_saml_username'); + }); + + await test.step('expect to be redirected to the homepage after succesful login', async () => { + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); + }); + + await test.step('expect user data to have been created with the custom username', async () => { + const user = await getUserInfo(api, 'custom_saml_username'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('custom_saml_username'); + expect(user?.name).toBe('Saml User No Username'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samlusernoname@example.com'); + }); + }); + }); + + test.fixme('User Merge - By Custom Identifier', async () => { + // Test user merge with a custom identifier configured in the fieldmap + }); + + test.describe('Logout Request Signature Validation', () => { + // This is a valid logout request for user samluser1 + const logoutRequest = + 'http://localhost:3000/_saml/logout/test-sp/?SAMLRequest=lZJBi9swEIX%2FitE9sWRbTiIS00AoBLa7bVN66CWMpVHXYEuuNIb8%2FMpOF7YtXehNPM379GZG%2BwhDP6oH%2F91P9Bl%2FTBgpuw29i2q5ObApOOUhdlE5GDAq0upy%2FPCgijVXY%2FDkte%2FZK8vbDogRA3Xesex8OrBrKTd8V1UaWiNbu%2BPSbrmQtUCorN5IgwWgMHYDBcu%2BYojJeWAJlOwxTnh2kcBRknhRr7hc8eqLqBUvlRTfWHZK3XQOaHE9E40qz3uvoX%2F2kVTJOc%2Bvc%2BYkzv3nlOpXccxZ9ujpyT2FoyUMf9G3C73Zz1a15AjNW%2FQBCQwQvPD3%2BWvnHfOYRnU%2BZZeP8%2BHTBH1nu%2Fnp%2F%2BGy7L0PA9C%2FVyDWYlE6s7JLqcIBuv5oTMAYWTNjp7Qh8Q5vMIw9rrUffsW9J7zHHdUl1aexnp3BW3MtSmnLtkBbbrGuQYPQG2NbY2SlUeykgLLd6dYU8g77w%2F8i%2FvYNm58%3D&RelayState=_658ecfea419894f995dd3d5bc1dedc511ab252a0ad&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256'; + const logoutRequestSignature = + 'MM9fh6cesrjADYxkq9dr0aoyRxjG%2Fsi7lFyqjNex0F80sosmjZedwx%2FZC4iIr1DfK9ac7EuloXpp2sn%2BgLbLrsoo2kqY3AS5%2FQ6zQVpIdg%2BhAk5CcpGXmY%2B8CqvL2QsUnMv9nHuemXzgoSjwnvGeTlpBiuGCHFmcfFFBUVgq1sZO4JM43d5JUojhVI4nEAGPhbOiHFSvKB09pE1pOu6sDhSepb6sAbBIrRfZPW7DLcSGe1DoY1x6vRw%2F3hV88Z9WwZJmogJ69lhQOuv%2BbZwC%2BEWVIgre38GnbLkXOiifMUJnkxkHcCvku7ALhUD%2BPwPEaslFwjIb2eIN0PJUZcKJCg%3D%3D'; + + test.describe('Require Signature', () => { + test.beforeAll(async ({ api }) => { + await doChangeSetting(api, 'SAML_Custom_Default_validate_logout_request_signature', true); + }); + + test('Reject Invalid Signature on Logout Request', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + await page.goto(`${logoutRequest}&Signature=invalid`); + + await test.step('expect to be redirected back to rocket.chat without being logged out', async () => { + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); + }); + }); + + test('Reject Missing Signature on Logout Request', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + await page.goto(logoutRequest); + + await test.step('expect to be redirected back to rocket.chat without being logged out', async () => { + await expect(page).toHaveURL('/home'); + await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); + }); + }); + + test('Accept Valid Signature on Logout Request', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + await page.goto(`${logoutRequest}&Signature=${logoutRequestSignature}`); + + await expectLoggedOutAfterLogoutRequest(page); + }); + }); + + test.describe('Do not require Signature', () => { + test.beforeAll(async ({ api }) => { + await doChangeSetting(api, 'SAML_Custom_Default_validate_logout_request_signature', false); + }); + + test('Ignore Invalid Signature on Logout Request', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + await page.goto(`${logoutRequest}&Signature=invalid`); + + await expectLoggedOutAfterLogoutRequest(page); + }); + + test('Ignore Missing Signature on Logout Request', async ({ page }) => { + await poRegistration.goto(); + await doLoginStep(page, 'samluser1'); + + await page.goto(logoutRequest); + + await expectLoggedOutAfterLogoutRequest(page); + }); + }); + }); + + test.fixme('Login - User without username', async () => { + // Test login with a SAML user with no username + // Test different variations of the Immutable Property setting + }); + + test.fixme('Login - User without email', async () => { + // Test login with a SAML user with no email + // Test different variations of the Immutable Property setting + }); + + test('Login - User without name', async ({ page, api }) => { + await test.step('expect user name to fallback to username when displayName is not provided', async () => { + await doLoginStep(page, 'samluser5'); + + const user = await getUserInfo(api, 'samluser5'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser5'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser5@example.com'); + // When displayName is not provided, it should fall back to username + expect(user?.name).toBe('samluser5'); + await doLogoutStep(page); + }); + + await test.step('expect user name to fallback to displayName when provided', async () => { + await doLoginStep(page, 'samluser6'); + + const user = await getUserInfo(api, 'samluser6'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser6'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser6@example.com'); + // When displayName is provided, it should fall back to displayName + expect(user?.name).toBe('Saml User 6 Display Name'); + }); + }); + + test('Login - User with channels attribute', async ({ page, api }) => { + autoCreatedChannel = 'saml-channel-2'; + + await test.step('Configure SAML to enable channels attribute updates', async () => { + expect((await setSettingValueById(api, 'SAML_Custom_Default_channels_update', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser7'); + + await test.step('expect user data to have been mapped correctly', async () => { + const user = await getUserInfo(api, 'samluser7'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser7'); + expect(user?.name).toBe('Saml User 7'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser7@example.com'); + }); + + await test.step('expect auto-created channel to be created automatically', async () => { + const channelInfoResponse = await api.get(`/channels.info?roomName=${autoCreatedChannel}`); + expect(channelInfoResponse.status()).toBe(200); + const { channel } = await channelInfoResponse.json(); + expect(channel).toBeDefined(); + expect(channel.name).toBe(autoCreatedChannel); + }); + + await test.step('expect user to be member of existing channel', async () => { + const existingChannelMembersResponse = await api.get(`/channels.members?roomName=${targetChannel}`); + expect(existingChannelMembersResponse.status()).toBe(200); + const { members: existingMembers } = await existingChannelMembersResponse.json(); + expect(existingMembers).toBeDefined(); + expect(existingMembers.some((member: { username: string }) => member.username === 'samluser7')).toBe(true); + }); + + await test.step('expect user to be member of auto-created channel', async () => { + const autoCreatedChannelMembersResponse = await api.get(`/channels.members?roomName=${autoCreatedChannel}`); + expect(autoCreatedChannelMembersResponse.status()).toBe(200); + const { members: autoCreatedMembers } = await autoCreatedChannelMembersResponse.json(); + expect(autoCreatedMembers).toBeDefined(); + expect(autoCreatedMembers.some((member: { username: string }) => member.username === 'samluser7')).toBe(true); + }); + + await doLogoutStep(page); + + await doLoginStep(page, 'samluser1'); + + await test.step('expect user without channels attribute to not be added to existing channel', async () => { + const existingChannelMembersResponse = await api.get(`/channels.members?roomName=${targetChannel}`); + expect(existingChannelMembersResponse.status()).toBe(200); + const { members: existingMembers } = await existingChannelMembersResponse.json(); + expect(existingMembers).toBeDefined(); + expect(existingMembers.some((member: { username: string }) => member.username === 'samluser1')).toBe(false); + }); + + await test.step('expect user without channels attribute to not be added to auto-created channel', async () => { + const autoCreatedChannelMembersResponse = await api.get(`/channels.members?roomName=${autoCreatedChannel}`); + expect(autoCreatedChannelMembersResponse.status()).toBe(200); + const { members: autoCreatedMembers } = await autoCreatedChannelMembersResponse.json(); + expect(autoCreatedMembers).toBeDefined(); + expect(autoCreatedMembers.some((member: { username: string }) => member.username === 'samluser1')).toBe(false); + }); + }); + + test.describe('SAML Login handoff', () => { + // When a native client starts the login it passes `loginClient`, and the web client must hand the SAML + // credential token over to the app instead of logging itself in, so that only one session is ever created. + const findCredentialToken = async (credentialToken: string) => { + const connection = await MongoClient.connect(constants.URL_MONGODB); + try { + return await connection + .db() + .collection<{ _id: string; userInfo?: { profile?: Record } }>('rocketchat_credential_tokens') + .findOne({ _id: credentialToken }); + } finally { + await connection.close(); + } + }; + + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_OAuth_Use_Modern_Flow', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_OAuth_Use_Modern_Flow', { value: false }); + }); + + test('Hand the credential token to the desktop client without logging in the browser', async ({ page }) => { + await poRegistration.goto('/home?loginClient=desktop'); + + await expect(page).toHaveURL(/loginClient=desktop/); + + // Passing null skips the logged-in assertions, since the browser must not get a session here. + await doLoginStep(page, 'samluser1', null); + + let credentialToken: string | null = null; + + await test.step('expect to land on the SAML handoff route carrying the credential token', async () => { + // SAMLLoginRoute redirects to rocketchat://auth from here. The browser has no handler for that + // scheme, so the page stays put and we can inspect exactly what would have been handed over. + await expect(page).toHaveURL(/\/saml\/[^?]+\?.*loginClient=desktop/); + + credentialToken = new URL(page.url()).searchParams.get('saml_idp_credentialToken'); + expect(credentialToken).toBeTruthy(); + }); + + await test.step('expect the credential to be stored server side, ready for the app to redeem', async () => { + const storedCredential = await findCredentialToken(credentialToken as string); + + expect(storedCredential).not.toBeNull(); + expect(storedCredential?.userInfo?.profile).toBeDefined(); + expect(storedCredential?.userInfo?.profile?.email).toBe('samluser1@example.com'); + }); + + await test.step('expect the browser to remain unauthenticated', async () => { + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + + // Reload to prove no session was persisted for this browser. + await poRegistration.goto(); + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + }); + }); + }); + + test.fixme('Data Sync - Custom Field Map', async () => { + // Test the data sync using a custom fieldmap setting + }); +}); diff --git a/apps/meteor/tests/e2e/search-discussion.spec.ts b/apps/meteor/tests/e2e/search-discussion.spec.ts new file mode 100644 index 0000000..2eded8a --- /dev/null +++ b/apps/meteor/tests/e2e/search-discussion.spec.ts @@ -0,0 +1,45 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetDiscussion, deleteRoom } from './utils'; +import { getSettingValueById } from './utils/getSettingValueById'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('search-discussion', () => { + let settingDefaultValue: unknown; + let poHomeChannel: HomeChannel; + let discussion: Record; + + test.beforeAll(async ({ api }) => { + settingDefaultValue = await getSettingValueById(api, 'UI_Allow_room_names_with_special_chars'); + }); + + test.beforeEach(async ({ page, api }) => { + discussion = await createTargetDiscussion(api); + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'UI_Allow_room_names_with_special_chars', settingDefaultValue); + await deleteRoom(api, discussion._id); + }); + + const testDiscussionSearch = async () => { + await poHomeChannel.navbar.typeSearch(discussion.fname); + const targetSearchItem = poHomeChannel.navbar.getSearchRoomByName(discussion.fname); + await expect(targetSearchItem).toBeVisible(); + }; + + test('expect search discussion to show fname when UI_Allow_room_names_with_special_chars=true', async ({ api }) => { + await setSettingValueById(api, 'UI_Allow_room_names_with_special_chars', true); + await testDiscussionSearch(); + }); + + test('expect search discussion to show fname when UI_Allow_room_names_with_special_chars=false', async ({ api }) => { + await setSettingValueById(api, 'UI_Allow_room_names_with_special_chars', false); + await testDiscussionSearch(); + }); +}); diff --git a/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts new file mode 100644 index 0000000..59f8da6 --- /dev/null +++ b/apps/meteor/tests/e2e/session-expiration-redirect.spec.ts @@ -0,0 +1,116 @@ +import type { Page } from '@playwright/test'; +import { MongoClient } from 'mongodb'; + +import { URL_MONGODB } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import type { IUserState } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { test, expect } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser, loginTestUser } from './utils/user-helpers'; + +const removeTokensFromdb = async (username: string): Promise => { + const connection = await MongoClient.connect(URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne({ username }, { $set: { 'services.resume.loginTokens': [] } }); + + await connection.close(); +}; + +test.describe('Session Expiration Redirect', () => { + let targetChannel: string; + let sessions: { page: Page; poHomeChannel: HomeChannel }[] = []; + let expiringUser1: ITestUser; + let expiringUser2: ITestUser; + let expiringUser1State: IUserState; + let expiringUser2State: IUserState; + + test.beforeAll(async ({ api }) => { + [expiringUser1, expiringUser2] = await Promise.all([createTestUser(api), createTestUser(api)]); + [expiringUser1State, expiringUser2State] = await Promise.all([loginTestUser(api, expiringUser1), loginTestUser(api, expiringUser2)]); + + targetChannel = await createTargetChannel(api, { members: [expiringUser1.data.username, expiringUser2.data.username] }); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + await Promise.all([expiringUser1?.delete(), expiringUser2?.delete()]); + }); + + test.afterEach(async () => { + await Promise.all(sessions.map(({ page }) => page.close())); + sessions = []; + }); + + test('should redirect to login page when server-side token is deleted and user tries to interact', async ({ browser }) => { + const { page } = await createAuxContext(browser, expiringUser1State, `/channel/${targetChannel}`); + const poHomeChannel = new HomeChannel(page); + sessions.push({ page, poHomeChannel }); + + await test.step('expect user to be logged in initially', async () => { + await poHomeChannel.content.waitForChannel(); + await expect(page.locator('#main-content')).toBeVisible(); + + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).not.toBeNull(); + expect(loginToken).not.toBeNull(); + }); + + await test.step('delete login tokens from database (simulating server-side expiration)', async () => { + await removeTokensFromdb(expiringUser1.data.username); + }); + + await test.step('open room search messages (without page reload)', async () => { + await poHomeChannel.roomToolbar.btnSearchMessages.click(); + }); + + await test.step('should redirect to login page', async () => { + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible(); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + const loginTokenExpires = await page.evaluate(() => localStorage.getItem('Meteor.loginTokenExpires')); + + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + expect(loginTokenExpires).toBeNull(); + }); + }); + + test('should redirect to login page when trying to send message with expired token', async ({ browser }) => { + const { page } = await createAuxContext(browser, expiringUser2State, `/channel/${targetChannel}`); + const poHomeChannel = new HomeChannel(page); + sessions.push({ page, poHomeChannel }); + + await test.step('type message', async () => { + await poHomeChannel.content.waitForChannel(); + await poHomeChannel.composer.inputMessage.fill('Test message'); + }); + + await test.step('delete login tokens from database', async () => { + await removeTokensFromdb(expiringUser2.data.username); + }); + + await test.step('try to send a message (should trigger auth error)', async () => { + await poHomeChannel.composer.btnSend.click(); + }); + + await test.step('expect automatic redirect to login page', async () => { + await expect(page.getByRole('form', { name: 'Login' })).toBeVisible(); + }); + + await test.step('verify localStorage was cleared', async () => { + const userId = await page.evaluate(() => localStorage.getItem('Meteor.userId')); + const loginToken = await page.evaluate(() => localStorage.getItem('Meteor.loginToken')); + expect(userId).toBeNull(); + expect(loginToken).toBeNull(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/settings-assets.spec.ts b/apps/meteor/tests/e2e/settings-assets.spec.ts new file mode 100644 index 0000000..69e43b0 --- /dev/null +++ b/apps/meteor/tests/e2e/settings-assets.spec.ts @@ -0,0 +1,64 @@ +import { Users } from './fixtures/userStates'; +import { AdminSettings } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('settings-assets', () => { + let poAdminSettings: AdminSettings; + + test.beforeEach(async ({ page }) => { + poAdminSettings = new AdminSettings(page); + await poAdminSettings.goto(); + + await poAdminSettings.btnAssetsSettings.click(); + + await expect(page.getByRole('main').getByRole('heading', { level: 1, name: 'Assets', exact: true })).toBeVisible(); + }); + + test('expect upload and delete logo asset and label should be visible', async ({ page }) => { + await expect(page.locator('[title="Assets_logo"]')).toHaveText('logo (svg, png, jpg)'); + + await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + + await expect(page.locator('role=img[name="Asset preview"]')).toBeVisible(); + + await poAdminSettings.btnDeleteAssetsLogo.click(); + + await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); + }); + + test('expect upload and delete logo asset for dark theme and label should be visible', async ({ page }) => { + await expect(page.locator('[title="Assets_logo_dark"]')).toHaveText('logo - dark theme (svg, png, jpg)'); + + await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + + await expect(page.locator('role=img[name="Asset preview"]')).toBeVisible(); + + await poAdminSettings.btnDeleteAssetsLogo.click(); + + await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); + }); + test('expect upload and delete background asset and label should be visible', async ({ page }) => { + await expect(page.locator('[title="Assets_background"]')).toHaveText('login background (svg, png, jpg)'); + + await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + + await expect(page.locator('role=img[name="Asset preview"]')).toBeVisible(); + + await poAdminSettings.btnDeleteAssetsLogo.click(); + + await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); + }); + test('expect upload and delete background asset for dark theme and label should be visible', async ({ page }) => { + await expect(page.locator('[title="Assets_background_dark"]')).toHaveText('login background - dark theme (svg, png, jpg)'); + + await poAdminSettings.inputAssetsLogo.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); + + await expect(page.locator('role=img[name="Asset preview"]')).toBeVisible(); + + await poAdminSettings.btnDeleteAssetsLogo.click(); + + await expect(page.locator('role=img[name="Asset preview"]')).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/settings-persistence-on-ui-navigation.spec.ts b/apps/meteor/tests/e2e/settings-persistence-on-ui-navigation.spec.ts new file mode 100644 index 0000000..8465e13 --- /dev/null +++ b/apps/meteor/tests/e2e/settings-persistence-on-ui-navigation.spec.ts @@ -0,0 +1,48 @@ +import { Users } from './fixtures/userStates'; +import { AdminSettings } from './page-objects'; +import { setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('settings-persistence-on-ui-navigation', () => { + test.beforeAll(({ api }) => setSettingValueById(api, 'Hide_System_Messages', [])); + + test.beforeEach(async ({ page }) => { + await new AdminSettings(page).gotoSection('Message'); + + // Intercept the API call and delay its response + await page.route('/api/v1/method.call/saveSettings', async (route) => { + const response = await route.fetch(); + await new Promise((resolve) => setTimeout(resolve, 2000)); // Delay the response by 2 seconds + return route.fulfill({ + response, + status: response.status(), + headers: response.headers(), + body: await response.body(), + }); + }); + }); + + test.afterAll(({ api }) => setSettingValueById(api, 'Hide_System_Messages', [])); + + test.skip('expect settings to persist in ui when navigating back and forth', async ({ page }) => { + const settingInput = page.getByLabel('Hide_System_Messages').getByRole('textbox'); + + await settingInput.pressSequentially('User joined'); + await settingInput.press('Enter'); + + const responsePromise = page.waitForResponse( + (response) => response.url().includes('/api/v1/method.call/saveSettings') && response.status() === 200, + ); + + await page.getByRole('button', { name: 'Save changes' }).click(); + await page.getByRole('button', { name: 'Back' }).click(); + + await responsePromise; + + await page.locator('a[href="/admin/settings/Message"] >> text=Open').click(); + + await expect(page.getByLabel('Hide_System_Messages').getByRole('option', { name: 'User joined' })).toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/sidebar-administration-menu.spec.ts b/apps/meteor/tests/e2e/sidebar-administration-menu.spec.ts new file mode 100644 index 0000000..c8f1eeb --- /dev/null +++ b/apps/meteor/tests/e2e/sidebar-administration-menu.spec.ts @@ -0,0 +1,37 @@ +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeDiscussion } from './page-objects'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('sidebar-administration-menu', () => { + let poHomeDiscussion: HomeDiscussion; + + test.beforeEach(async ({ page }) => { + poHomeDiscussion = new HomeDiscussion(page); + + await poHomeDiscussion.goto(); + }); + + test.describe('admin user', () => { + test.skip(!IS_EE, 'Enterprise only'); + test('should open workspace page', async ({ page }) => { + await poHomeDiscussion.navbar.openManageMenuItem('Workspace'); + await expect(page).toHaveURL('admin/info'); + }); + + test('should open omnichannel page', async ({ page }) => { + await poHomeDiscussion.navbar.openManageMenuItem('Omnichannel'); + await expect(page).toHaveURL('omnichannel/current'); + }); + }); + + test.describe('regular user', () => { + test.use({ storageState: Users.user1.state }); + + test('expect to not render administration menu when no permission', async () => { + await expect(poHomeDiscussion.navbar.btnManageWorkspace).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts b/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts new file mode 100644 index 0000000..311ea4e --- /dev/null +++ b/apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts @@ -0,0 +1,181 @@ +import { faker } from '@faker-js/faker'; + +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { deleteChannel, setUserPreferences } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('sidebar custom categories', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetChannelId: string; + + test.skip(!IS_EE, 'Enterprise Only'); + + const uniqueName = (prefix: string) => `${prefix}-${faker.string.uuid().slice(0, 8)}`; + + const createCategory = async (api: Parameters[0], name: string): Promise => { + await setUserPreferences(api, { sidebarCategories: [{ _id: faker.string.uuid(), name }] }); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toBeVisible(); + }; + + const roomBelongsToGroup = async (groupName: string) => { + const present = await poHomeChannel.content.header.checkRoomBelongsToGroup(`Remove from ${groupName}`); + return present; + }; + + test.beforeAll(async ({ api }) => { + const name = faker.string.uuid(); + const created = await (await api.post('/channels.create', { name })).json(); + targetChannel = name; + targetChannelId = created.channel._id; + + await setUserPreferences(api, { sidebarCategories: [] }); + }); + + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { sidebarCategories: [] }); + await api.post('/rooms.favorite', { roomId: targetChannelId, favorite: false }); + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ api, page }) => { + await setUserPreferences(api, { sidebarCategories: [] }); + await api.post('/rooms.favorite', { roomId: targetChannelId, favorite: false }); + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + }); + + test.describe('create (+) menu', () => { + test('should expose a "Category" entry', async () => { + await poHomeChannel.navbar.btnCreateNew.click(); + await expect(poHomeChannel.navbar.createNewMenuItem('Category')).toBeVisible(); + }); + + test('should create an empty category (rendered as a header with no rooms)', async () => { + const name = uniqueName('cat'); + await poHomeChannel.navbar.createNewCategory(name); + await expect(poHomeChannel.sidebar.getCategoryCollapser(name)).toBeVisible(); + }); + }); + + test.describe('category actions (custom category)', () => { + const category = uniqueName('ren'); + + test.beforeEach(async ({ api }) => { + await createCategory(api, category); + }); + + test('should rename a category', async () => { + const renamed = `${category}-renamed`; + + await poHomeChannel.sidebar.renameCategory(category, renamed); + await expect(poHomeChannel.sidebar.getCategoryCollapser(renamed)).toBeVisible(); + await expect(poHomeChannel.sidebar.getCategoryCollapser(category)).toHaveCount(0); + }); + + test('should delete a category and return its rooms to the system group', async () => { + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, category); + await poHomeChannel.sidebar.deleteCategory(category); + + await expect(poHomeChannel.sidebar.getCategoryCollapser(category)).toHaveCount(0); + await expect(poHomeChannel.sidebar.getSidebarItemByName(targetChannel)).toBeVisible(); + }); + + test('should toggle "Always display"', async () => { + await poHomeChannel.sidebar.openCategoryMenu(category); + const toggle = poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Always display' }); + await expect(toggle).toBeVisible(); + await expect(toggle.getByRole('checkbox')).toBeChecked({ checked: false }); + await toggle.click(); + await expect(toggle.getByRole('checkbox')).toBeChecked({ checked: true }); + await poHomeChannel.page.keyboard.press('Escape'); + }); + + test('should open the Create channel modal via the "Create new" submenu', async () => { + await poHomeChannel.sidebar.openCategoryMenu(category); + await poHomeChannel.page.getByRole('menuitem', { name: 'Create new', exact: true }).hover(); + await poHomeChannel.page.getByRole('menuitem', { name: 'Channel', exact: true }).click(); + + await poHomeChannel.navbar.modals.Channel.waitForDisplay(); + await poHomeChannel.page.keyboard.press('Escape'); + }); + }); + + test.describe('system group actions (reduced menu)', () => { + test('should show a reduced menu without Rename / Delete / New channel / New category / Create new', async () => { + await poHomeChannel.sidebar.openCategoryMenu('Channels'); + + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Move up', exact: true })).toBeVisible(); + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Move down', exact: true })).toBeVisible(); + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Always display' })).toBeVisible(); + + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Manage', exact: true })).toHaveCount(0); + await expect(poHomeChannel.page.getByRole('menuitemcheckbox', { name: 'Delete', exact: true })).toHaveCount(0); + await expect(poHomeChannel.page.getByRole('menuitem', { name: 'Create new', exact: true })).toHaveCount(0); + await poHomeChannel.page.keyboard.press('Escape'); + }); + }); + + test.describe('manage category in room actions', () => { + const category = uniqueName('ren'); + + test.beforeEach(async ({ api }) => { + await createCategory(api, category); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test('should move a room into a category', async () => { + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, category); + expect(await roomBelongsToGroup(category)).toBe(true); + }); + + test('should move a room to Favorites and back', async () => { + await poHomeChannel.sidebar.moveRoomToFavorites(targetChannel); + expect(await roomBelongsToGroup('Favorites')).toBe(true); + + await poHomeChannel.sidebar.removeRoomFromFavorites(targetChannel); + expect(await roomBelongsToGroup('Favorites')).toBe(false); + }); + + test('should remove a room from a category', async () => { + await poHomeChannel.sidebar.moveRoomToCategory(targetChannel, category); + expect(await roomBelongsToGroup(category)).toBe(true); + + await poHomeChannel.sidebar.removeRoomFromCategory(targetChannel, category); + expect(await roomBelongsToGroup(category)).toBe(false); + }); + + test('should create a category and move the room into it in one step', async () => { + const newCat = uniqueName('cat'); + await poHomeChannel.sidebar.createCategoryFromRoom(targetChannel, newCat); + + await expect(poHomeChannel.sidebar.getCategoryCollapser(newCat)).toBeVisible(); + expect(await roomBelongsToGroup(newCat)).toBe(true); + }); + + test('should move the room into a category from the header', async () => { + const newCat = uniqueName('cat'); + await poHomeChannel.content.header.createCategory(newCat); + + expect(await roomBelongsToGroup(newCat)).toBe(true); + }); + + test('should favorite the room from the header and remove it', async () => { + await poHomeChannel.content.header.pickCategoryMenuItem('Favorites'); + expect(await roomBelongsToGroup('Favorites')).toBe(true); + + await poHomeChannel.content.header.pickCategoryMenuItem('Remove from Favorites'); + expect(await roomBelongsToGroup('Favorites')).toBe(false); + }); + + test('should move the room into an existing category from the header', async () => { + await poHomeChannel.content.header.pickCategoryMenuItem(category); + + expect(await roomBelongsToGroup(category)).toBe(true); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/sidebar-menu.spec.ts b/apps/meteor/tests/e2e/sidebar-menu.spec.ts new file mode 100644 index 0000000..cbb71d4 --- /dev/null +++ b/apps/meteor/tests/e2e/sidebar-menu.spec.ts @@ -0,0 +1,35 @@ +import { Users } from './fixtures/userStates'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('sidebar-menu', () => { + test('expect popover to stay open after home loads', async ({ page }) => { + await page.route('**/__meteor__/dynamic-import/fetch', async (route, request) => { + if (request.postData()?.includes('HomePage.tsx')) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate network delay + } + await route.continue(); + }); + + await page.goto('/home', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Create new' }).waitFor(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + + await page.waitForURL('/directory/channels'); + + const textContent = await page.evaluate(() => document.activeElement?.textContent); + expect(textContent).toMatch(/Directory/); + + await page.getByRole('button', { name: 'Create new' }).click(); + await expect(page.getByRole('menu', { name: 'Create new' })).toBeVisible(); + await page.getByRole('menuitem', { name: 'Direct message' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click(); + }); +}); diff --git a/apps/meteor/tests/e2e/sidebar.spec.ts b/apps/meteor/tests/e2e/sidebar.spec.ts new file mode 100644 index 0000000..6118d60 --- /dev/null +++ b/apps/meteor/tests/e2e/sidebar.spec.ts @@ -0,0 +1,246 @@ +import { Users } from './fixtures/userStates'; +import { AdminInfo, HomeChannel } from './page-objects'; +import { deleteChannel, createTargetChannel } from './utils'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Sidebar', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: ['user1'] }); + }); + + test.afterAll(async ({ api }) => { + await deleteChannel(api, targetChannel); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.describe('global header', async () => { + test('should display recent chats when navbar search is clicked', async () => { + await poHomeChannel.navbar.searchInput.click(); + await expect(poHomeChannel.navbar.searchList).toBeVisible(); + await poHomeChannel.navbar.searchInput.blur(); + }); + + test('should display home and directory button', async () => { + await expect(poHomeChannel.navbar.btnHome).toBeVisible(); + await expect(poHomeChannel.navbar.btnDirectory).toBeVisible(); + }); + + test('should display home and directory inside a menu in smaller views', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 767 }); + await expect(poHomeChannel.navbar.btnMenuPages).toBeVisible(); + }); + + test('should display voice and omnichannel items inside a menu and sidebar toggler in mobile view', async ({ page }) => { + await page.setViewportSize({ width: 767, height: 510 }); + await expect(poHomeChannel.navbar.btnVoiceAndOmnichannel).toBeVisible(); + await expect(poHomeChannel.navbar.btnSidebarToggler()).toBeVisible(); + }); + + test('should hide everything else when navbar search is focused in mobile view', async ({ page }) => { + await page.setViewportSize({ width: 767, height: 510 }); + await poHomeChannel.navbar.searchInput.click(); + + await expect(poHomeChannel.navbar.btnMenuPages).not.toBeVisible(); + await expect(poHomeChannel.navbar.btnSidebarToggler()).not.toBeVisible(); + await expect(poHomeChannel.navbar.btnVoiceAndOmnichannel).not.toBeVisible(); + await expect(poHomeChannel.navbar.groupHistoryNavigation).not.toBeVisible(); + }); + test('expect navbar toolbar buttons to be enabled in tablet view', async ({ page }) => { + await page.setViewportSize({ width: 1023, height: 767 }); + + await expect(poHomeChannel.navbar.btnDisplay).toBeEnabled(); + await expect(poHomeChannel.navbar.btnCreateNew).toBeEnabled(); + }); + test('should navigate on navbar toolbar pressing tab', async ({ page }) => { + await poHomeChannel.navbar.btnHome.focus(); + await expect(poHomeChannel.navbar.btnHome).toBeFocused(); + + await test.step('move focus to directory button', async () => { + await page.keyboard.press('Tab'); + await expect(poHomeChannel.navbar.btnDirectory).toBeFocused(); + }); + + await test.step('move focus to marketplace button', async () => { + await page.keyboard.press('Tab'); + await expect(poHomeChannel.navbar.btnMarketplace).toBeFocused(); + }); + + await test.step('move focus to display button', async () => { + await page.keyboard.press('Tab'); + await expect(poHomeChannel.navbar.btnDisplay).toBeFocused(); + }); + + await test.step('move focus to create new button', async () => { + await page.keyboard.press('Tab'); + await expect(poHomeChannel.navbar.btnCreateNew).toBeFocused(); + }); + + await test.step('move focus to search input', async () => { + await page.keyboard.press('Tab'); + await expect(poHomeChannel.navbar.searchInput).toBeFocused(); + }); + }); + }); + + test.describe('Display menu keyboard accessibility', () => { + const defaultPreferences = { + sidebarViewMode: 'extended', + sidebarDisplayAvatar: true, + sidebarSortby: 'activity', + sidebarShowUnread: false, + }; + + test.beforeEach(async ({ api }) => { + await api.post('/users.setPreferences', { data: defaultPreferences }); + }); + + test.afterEach(async ({ api }) => { + await api.post('/users.setPreferences', { data: defaultPreferences }); + }); + + test('should change view mode using keyboard', async ({ page }) => { + await poHomeChannel.navbar.btnDisplay.focus(); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.navbar.menuDisplay).toBeVisible(); + + await poHomeChannel.navbar.getDisplayMenuItem('Medium').focus(); + + await page.keyboard.press('Space'); + + await expect(poHomeChannel.navbar.getDisplayMenuItem('Medium').getByRole('radio')).toBeChecked(); + await page.keyboard.press('Escape'); + }); + + test('should toggle Avatars using keyboard', async ({ page }) => { + await poHomeChannel.navbar.btnDisplay.click(); + const avatarsItem = poHomeChannel.navbar.getDisplayMenuItem('Avatars'); + + await avatarsItem.focus(); + await page.keyboard.press('Space'); + + const newAvatarsItem = poHomeChannel.navbar.getDisplayMenuItem('Avatars'); + await expect(newAvatarsItem.getByRole('checkbox')).not.toBeChecked(); + await page.keyboard.press('Escape'); + }); + + test('should change sort mode using keyboard', async ({ page }) => { + await poHomeChannel.navbar.btnDisplay.focus(); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.navbar.menuDisplay).toBeVisible(); + await poHomeChannel.navbar.getSortMenuItem('Activity').focus(); + await page.keyboard.press('ArrowDown'); + await expect(poHomeChannel.navbar.getSortMenuItem('Name')).toBeFocused(); + await page.keyboard.press('Space'); + + await expect(poHomeChannel.navbar.getSortMenuItem('Name').getByRole('radio')).toBeChecked(); + await page.keyboard.press('Escape'); + }); + + test('should toggle grouping using keyboard', async ({ page }) => { + await poHomeChannel.navbar.btnDisplay.focus(); + await page.keyboard.press('Enter'); + + const unreadItem = poHomeChannel.navbar.getGroupByMenuItem('Unread'); + await expect(unreadItem.getByRole('checkbox')).not.toBeChecked(); + + await unreadItem.focus(); + await page.keyboard.press('Space'); + + await expect(poHomeChannel.navbar.getGroupByMenuItem('Unread').getByRole('checkbox')).toBeChecked(); + await page.keyboard.press('Escape'); + }); + }); + + test.describe('sidebar', async () => { + test.afterEach(async ({ page }) => { + await page.evaluate(() => localStorage.removeItem('sidebarGroups')); + }); + + test('should navigate on sidebar items using arrow keys and restore focus', async ({ page }) => { + // focus should be on the next item + await poHomeChannel.sidebar.channelsList.getByRole('link').first().focus(); + await page.keyboard.press('ArrowDown'); + await expect(poHomeChannel.sidebar.channelsList.getByRole('link').first()).not.toBeFocused(); + + // shouldn't focus the first item + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await expect(poHomeChannel.sidebar.channelsList.getByRole('link').first()).not.toBeFocused(); + }); + + test('should expand/collapse sidebar groups', async () => { + const collapser = poHomeChannel.sidebar.firstCollapser; + + await collapser.click(); + await expect(collapser).toHaveAttribute('aria-expanded', 'false'); + + await collapser.click(); + await expect(collapser).toHaveAttribute('aria-expanded', 'true'); + }); + + test('should expand/collapse sidebar groups with keyboard', async ({ page }) => { + const collapser = poHomeChannel.sidebar.firstCollapser; + + await collapser.focus(); + await expect(collapser).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(collapser).toHaveAttribute('aria-expanded', 'false'); + + await collapser.focus(); + await expect(collapser).toBeFocused(); + await page.keyboard.press('Space'); + await expect(collapser).toHaveAttribute('aria-expanded', 'true'); + }); + + test('should persist collapsed/expanded groups after page reload', async ({ page }) => { + const collapser = poHomeChannel.sidebar.firstCollapser; + const initialState = await collapser.getAttribute('aria-expanded'); + const expectedState = initialState === 'true' ? 'false' : 'true'; + + await collapser.click(); + await expect(collapser).toHaveAttribute('aria-expanded', expectedState); + + await page.reload(); + await expect(collapser).toHaveAttribute('aria-expanded', expectedState); + }); + + test('should show unread badge on collapser when group is collapsed and has unread items', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello world'); + + const item = poHomeChannel.sidebar.getSidebarItemByName(targetChannel); + await poHomeChannel.sidebar.markItemAsUnread(item); + + const collapser = poHomeChannel.sidebar.getCollapseGroupByName('Channels'); + await collapser.click(); + await expect(poHomeChannel.sidebar.getItemUnreadBadge(collapser)).toBeVisible(); + }); + }); + + test.describe('embedded layout', async () => { + test('should not show Navbar', async ({ page }) => { + await poHomeChannel.gotoChannel(targetChannel); + await expect(page.locator('role=navigation[name="header"]')).toBeVisible(); + const embeddedLayoutURL = `${page.url()}?layout=embedded`; + await page.goto(embeddedLayoutURL); + await expect(page.locator('role=navigation[name="header"]')).not.toBeVisible(); + }); + + test('should show burger menu', async ({ page }) => { + await new AdminInfo(page).gotoEmbedded(); + await page.setViewportSize({ width: 767, height: 510 }); + + await expect(poHomeChannel.content.burgerButton).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/system-messages.spec.ts b/apps/meteor/tests/e2e/system-messages.spec.ts new file mode 100644 index 0000000..2967f7a --- /dev/null +++ b/apps/meteor/tests/e2e/system-messages.spec.ts @@ -0,0 +1,82 @@ +import { faker } from '@faker-js/faker'; +import type { Locator, Page } from '@playwright/test'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const userData = { + username: faker.string.uuid(), + name: faker.person.firstName(), + email: faker.internet.email(), + password: faker.internet.password(), +}; + +const findSysMes = (page: Page, id: string): Locator => + page.locator(`[role="listitem"][aria-roledescription="system message"][data-system-message-type="${id}"]`); + +// There currently are over 33 system messages. Testing only a couple due to test being too slow right now. +// Ideally, we should test all. + +test.describe.serial('System Messages', () => { + let poHomeChannel: HomeChannel; + let user: IUser; + let group: IRoom; + + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Hide_System_Messages', [])).status()).toBe(200); + + const groupResult = await api.post('/groups.create', { name: faker.string.uuid() }); + await expect(groupResult.status()).toBe(200); + + group = (await groupResult.json()).group; + + const result = await api.post('/users.create', userData); + expect(result.status()).toBe(200); + + user = (await result.json()).user; + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.goto(); + + if (!group?.name) { + return; + } + + await poHomeChannel.navbar.openChat(group.name); + }); + + test.afterAll(async ({ api }) => { + await expect((await api.post('/groups.delete', { roomId: group._id })).status()).toBe(200); + }); + + test('expect "User added" system message to be visible', async ({ page, api }) => { + await expect((await api.post('/groups.invite', { roomId: group._id, userId: user._id })).status()).toBe(200); + + await expect(findSysMes(page, 'au')).toBeVisible(); + }); + + test('expect "User added" system message to be hidden', async ({ page, api }) => { + await expect((await setSettingValueById(api, 'Hide_System_Messages', ['au'])).status()).toBe(200); + + await expect(findSysMes(page, 'au')).not.toBeVisible(); + }); + + test('expect "User removed" system message to be visible', async ({ page, api }) => { + await expect((await api.post('/groups.kick', { roomId: group._id, userId: user._id })).status()).toBe(200); + + await expect(findSysMes(page, 'ru')).toBeVisible(); + }); + + test('expect "User removed" system message to be hidden', async ({ page, api }) => { + await expect((await setSettingValueById(api, 'Hide_System_Messages', ['ru'])).status()).toBe(200); + + await expect(findSysMes(page, 'ru')).not.toBeVisible(); + }); +}); diff --git a/apps/meteor/tests/e2e/team-management.spec.ts b/apps/meteor/tests/e2e/team-management.spec.ts new file mode 100644 index 0000000..c4e496a --- /dev/null +++ b/apps/meteor/tests/e2e/team-management.spec.ts @@ -0,0 +1,517 @@ +import { faker } from '@faker-js/faker'; + +import { Users } from './fixtures/userStates'; +import { HomeTeam } from './page-objects'; +import { CreateNewTeamModal, CreateNewChannelModal } from './page-objects/fragments/modals'; +import { createTargetChannel, deleteTeam, isChannelMember, updatePermissions } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('teams-management-permissions', () => { + let poHomeTeam: HomeTeam; + let newTeamModal: CreateNewTeamModal; + + test.beforeEach(async ({ page }) => { + poHomeTeam = new HomeTeam(page); + newTeamModal = new CreateNewTeamModal(page); + }); + + test.afterEach(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'create-p', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-c', roles: ['admin', 'owner', 'moderator'] }, + ]); + }); + + test('should not allow to create public team if user does not have the create-c permission', async ({ api }) => { + await updatePermissions(api, [{ _id: 'create-c', roles: [] }]); + await poHomeTeam.goto(); + + await poHomeTeam.navbar.openCreate('Team'); + + await expect(newTeamModal.checkboxPrivate).toBeDisabled(); + await expect(newTeamModal.checkboxPrivate).toBeChecked(); + }); + + test('should not allow to create private team if user does not have the create-p permission', async ({ api }) => { + await updatePermissions(api, [{ _id: 'create-p', roles: [] }]); + await poHomeTeam.goto(); + + await poHomeTeam.navbar.openCreate('Team'); + + await expect(newTeamModal.checkboxPrivate).toBeDisabled(); + await expect(newTeamModal.checkboxPrivate).not.toBeChecked(); + }); + + test('should not allow to create team if user does not have both create-p and create-c permissions', async ({ api }) => { + await updatePermissions(api, [ + { _id: 'create-p', roles: [] }, + { _id: 'create-c', roles: [] }, + ]); + await poHomeTeam.goto(); + + await poHomeTeam.navbar.btnCreateNew.click(); + await expect(poHomeTeam.navbar.createNewMenu).toBeVisible(); + await expect(poHomeTeam.navbar.createNewMenuItem('Team')).not.toBeVisible(); + }); +}); + +test.describe.serial('teams-management', () => { + let poHomeTeam: HomeTeam; + let targetChannel: string; + let newTeamModal: CreateNewTeamModal; + let newChannelModal: CreateNewChannelModal; + + const targetTeam = faker.string.uuid(); + const targetTeamNonPrivate = faker.string.uuid(); + const targetTeamReadOnly = faker.string.uuid(); + const targetGroupNameInTeam = faker.string.uuid(); + const targetChannelNameInTeam = faker.string.uuid(); + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.afterAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-c', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'create-p', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] }, + { _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] }, + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeTeam = new HomeTeam(page); + newChannelModal = new CreateNewChannelModal(page); + + await poHomeTeam.goto(); + }); + + test('should create targetTeam private', async ({ page }) => { + await poHomeTeam.navbar.createNew('Team', targetTeam, { + private: true, + members: ['user1'], + }); + + await expect(page).toHaveURL(`/group/${targetTeam}`); + }); + + test('should create targetTeamNonPrivate non private', async ({ page }) => { + await poHomeTeam.navbar.createNew('Team', targetTeamNonPrivate, { + private: false, + members: ['user1'], + }); + + await expect(page).toHaveURL(`/channel/${targetTeamNonPrivate}`); + }); + + test('should create targetTeamReadOnly readonly', async ({ page }) => { + await poHomeTeam.navbar.createNew('Team', targetTeamReadOnly, { + readOnly: true, + members: ['user1'], + }); + + await expect(page).toHaveURL(`/group/${targetTeamReadOnly}`); + }); + + test('should throw validation error if team name already exists', async ({ page }) => { + newTeamModal = new CreateNewTeamModal(page); + await poHomeTeam.navbar.createNew('Team', targetTeam); + await expect(newTeamModal.inputName).toHaveAttribute('aria-invalid', 'true'); + }); + + test('should send hello in the targetTeam and reply in a thread', async ({ page }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.content.sendMessage('hello'); + await poHomeTeam.content.openReplyInThread(); + await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetTeam}"]`).type('any-reply-message'); + await page.keyboard.press('Enter'); + await expect(poHomeTeam.content.lastUserThreadMessage).toHaveText('any-reply-message'); + }); + + test('should set targetTeam as readonly', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamInfo(); + await poHomeTeam.tabs.room.btnEdit.click(); + await poHomeTeam.tabs.editRoom.advancedSettingsAccordion.click(); + await poHomeTeam.tabs.editRoom.checkboxReadOnly.click(); + await poHomeTeam.tabs.editRoom.btnSave.click(); + + await expect(poHomeTeam.content.getSystemMessageByText('set room to read only')).toBeVisible(); + }); + + test.describe('without move-room-to-team permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'move-room-to-team', roles: ['moderator'] }]); + }); + + test('should not allow moving room to team if move-room-to-team permission has not been granted', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.btnAddExisting).not.toBeVisible(); + }); + }); + + test.describe('without create-team-channel and create-team-group permissions', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'create-team-channel', roles: ['moderator'] }, + { _id: 'create-team-group', roles: ['moderator'] }, + ]); + }); + + test('should not allow creating a room in a team if both create-team-channel and create-team-group permissions have not been granted', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.btnCreateNew).not.toBeVisible(); + }); + }); + + test.describe('with create-team-channel permission only', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'create-team-channel', roles: ['admin'] }, + { _id: 'create-team-group', roles: ['moderator'] }, + ]); + }); + + test('should allow creating a channel in a team if user has the create-team-channel permission, but not the create-team-group permission', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.btnCreateNew).toBeVisible(); + await poHomeTeam.tabs.channels.btnCreateNew.click(); + + await newChannelModal.inputName.fill(targetChannelNameInTeam); + await expect(newChannelModal.checkboxPrivate).not.toBeChecked(); + await expect(newChannelModal.checkboxPrivate).toBeDisabled(); + await newChannelModal.btnCreate.click(); + + await expect(poHomeTeam.tabs.channels.channelsList).toContainText(targetChannelNameInTeam); + }); + }); + + test.describe('with create-team-group permission only', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'create-team-group', roles: ['admin'] }, + { _id: 'create-team-channel', roles: ['moderator'] }, + ]); + }); + + test('should allow creating a group in a team if user has the create-team-group permission, but not the create-team-channel permission', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.btnCreateNew).toBeVisible(); + await poHomeTeam.tabs.channels.btnCreateNew.click(); + + await newChannelModal.inputName.fill(targetGroupNameInTeam); + const { checkboxPrivate } = newChannelModal; + await expect(checkboxPrivate).toBeChecked(); + await expect(checkboxPrivate).toBeDisabled(); + await newChannelModal.btnCreate.click(); + + await expect(poHomeTeam.tabs.channels.channelsList).toContainText(targetGroupNameInTeam); + }); + }); + + test.describe('with move-room-to-team permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'move-room-to-team', roles: ['owner'] }]); + }); + + test('should move targetChannel to targetTeam', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.addExistingChannel(targetChannel); + + await expect(poHomeTeam.tabs.channels.channelsList).toContainText(targetChannel); + }); + }); + + test('should access team channel through targetTeam header', async ({ page }) => { + await poHomeTeam.navbar.openChat(targetChannel); + await poHomeTeam.getBtnOpenRoomInfo(targetChannel).focus(); + await expect(poHomeTeam.getBtnOpenRoomInfo(targetChannel)).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Space'); + + await expect(page).toHaveURL(`/group/${targetTeam}`); + }); + + test.describe('without remove-team-channel permission for groups', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'remove-team-channel', roles: ['moderator'] }]); + }); + + test('should not allow removing a targetGroup from targetTeam if user does not have the remove-team-channel permission', async ({ + page, + }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetGroupNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' })).not.toBeVisible(); + }); + }); + + test.describe('with remove-team-channel permission for groups', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'remove-team-channel', roles: ['owner'] }]); + }); + + test('should allow removing a targetGroup from targetTeam if user has the remove-team-channel permission', async ({ page }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetGroupNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' })).toBeVisible(); + await page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' }).click(); + await poHomeTeam.tabs.channels.confirmRemoveChannel(); + + await expect(poHomeTeam.tabs.channels.channelsList).not.toContainText(targetGroupNameInTeam); + }); + }); + + test.describe('without delete-team-group permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'delete-team-group', roles: ['moderator'] }, + { _id: 'move-room-to-team', roles: ['owner'] }, + ]); + }); + + test('should not allow deleting a targetGroup from targetTeam if the group owner does not have the delete-team-group permission', async ({ + page, + }) => { + // re-add channel to team + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.addExistingChannel(targetGroupNameInTeam); + await expect(poHomeTeam.tabs.channels.channelsList).toContainText(targetGroupNameInTeam); + + // try to delete group in team + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetGroupNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' })).not.toBeVisible(); + }); + }); + + test.describe('with delete-team-group permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'delete-team-group', roles: ['owner'] }]); + }); + + test('should allow deleting a targetGroup from targetTeam if the group owner also has the delete-team-group permission', async ({ + page, + }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetGroupNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' })).toBeVisible(); + await page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' }).click(); + await poHomeTeam.tabs.channels.confirmDeleteRoom(); + + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.channelsList).not.toContainText(targetGroupNameInTeam); + }); + }); + + test.describe('without remove-team-channel permission for channels', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'remove-team-channel', roles: ['moderator'] }]); + }); + + test('should not allow removing a targetChannel from targetTeam if user does not have the remove-team-channel permission', async ({ + page, + }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetChannelNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' })).not.toBeVisible(); + }); + }); + + test.describe('with remove-team-channel permission for channels', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'remove-team-channel', roles: ['owner'] }]); + }); + + test('should allow removing a targetChannel from targetTeam if user has the remove-team-channel permission', async ({ page }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetChannelNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' })).toBeVisible(); + await page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' }).click(); + await poHomeTeam.tabs.channels.confirmRemoveChannel(); + + await expect(poHomeTeam.tabs.channels.channelsList).not.toContainText(targetChannelNameInTeam); + }); + }); + + test.describe('without delete-team-channel permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [ + { _id: 'delete-team-channel', roles: ['moderator'] }, + { _id: 'move-room-to-team', roles: ['owner'] }, + ]); + }); + + test('should not allow deleting a targetChannel from targetTeam if the channel owner does not have the delete-team-channel permission', async ({ + page, + }) => { + // re-add channel to team + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.addExistingChannel(targetChannelNameInTeam); + await expect(poHomeTeam.tabs.channels.channelsList).toContainText(targetChannelNameInTeam); + + // try to delete channel in team + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetChannelNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' })).not.toBeVisible(); + }); + }); + + test.describe('with delete-team-channel permission', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'delete-team-channel', roles: ['owner'] }]); + }); + + test('should allow deleting a targetChannel from targetTeam if the channel owner also has the delete-team-channel permission', async ({ + page, + }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetChannelNameInTeam); + await expect(page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' })).toBeVisible(); + await page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Delete' }).click(); + await poHomeTeam.tabs.channels.confirmDeleteRoom(); + + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await expect(poHomeTeam.tabs.channels.channelsList).not.toContainText(targetChannelNameInTeam); + }); + }); + + test.describe('with remove-team-channel permission for cleanup', () => { + test.beforeAll(async ({ api }) => { + await updatePermissions(api, [{ _id: 'remove-team-channel', roles: ['owner'] }]); + }); + + test('should remove targetChannel from targetTeam', async ({ page }) => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamChannels(); + await poHomeTeam.tabs.channels.openChannelOptionMoreActions(targetChannel); + await page.getByRole('menu', { exact: true }).getByRole('menuitem', { name: 'Remove from team' }).click(); + await poHomeTeam.tabs.channels.confirmRemoveChannel(); + + await expect(poHomeTeam.tabs.channels.channelsList).not.toBeVisible(); + }); + }); + + test('should remove user1 from targetTeamNonPrivate', async () => { + await poHomeTeam.navbar.openChat(targetTeamNonPrivate); + await poHomeTeam.headerToolbar.openMoreOptions(); + await poHomeTeam.headerToolbar.openTeamMembers(); + await poHomeTeam.tabs.members.showAllUsers(); + await poHomeTeam.tabs.members.openMemberOptionMoreActions('user1'); + await poHomeTeam.tabs.members.getMenuItemAction('Remove from team').click(); + await poHomeTeam.tabs.members.confirmRemoveUser(); + await expect(poHomeTeam.tabs.members.memberOption('user1')).not.toBeVisible(); + }); + + test('should delete targetTeamNonPrivate', async () => { + await poHomeTeam.navbar.openChat(targetTeamNonPrivate); + await poHomeTeam.headerToolbar.openTeamInfo(); + await poHomeTeam.tabs.room.deleteTeam(); + + await poHomeTeam.navbar.typeSearch(targetTeamNonPrivate); + await expect(poHomeTeam.navbar.getSearchRoomByName(targetTeamNonPrivate)).not.toBeVisible(); + }); + + test('should user1 leave from targetTeam', async ({ browser }) => { + const user1Page = await browser.newPage({ storageState: Users.user1.state }); + const user1Channel = new HomeTeam(user1Page); + await user1Channel.gotoGroup(targetTeam); + + await user1Channel.headerToolbar.openTeamInfo(); + await user1Channel.tabs.room.leaveRoom(); + await user1Page.close(); + + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openMoreOptions(); + await poHomeTeam.headerToolbar.openTeamMembers(); + await poHomeTeam.tabs.members.showAllUsers(); + await expect(poHomeTeam.tabs.members.memberOption('user1')).not.toBeVisible(); + }); + + test('should convert team into a channel', async () => { + await poHomeTeam.navbar.openChat(targetTeam); + await poHomeTeam.headerToolbar.openTeamInfo(); + await poHomeTeam.tabs.room.convertIntoChannel(); + + // TODO: improve this locator and check the action reactivity + await expect(poHomeTeam.content.getSystemMessageByText(`converted #${targetTeam} to channel`)).toBeVisible(); + }); +}); + +test.describe('teams-management-remove-member-channel-selection', () => { + let poHomeTeam: HomeTeam; + + const targetTeamWithChannels = faker.string.uuid(); + let selectedChannel: string; + let unselectedChannel: string; + + test.beforeAll(async ({ api }) => { + const teamResponse = await api.post('/teams.create', { name: targetTeamWithChannels, type: 1, members: ['user1'] }); + await expect(teamResponse).toBeOK(); + const { team } = await teamResponse.json(); + + [selectedChannel, unselectedChannel] = await Promise.all([ + createTargetChannel(api, { members: ['user1'], extraData: { teamId: team._id } }), + createTargetChannel(api, { members: ['user1'], extraData: { teamId: team._id } }), + ]); + }); + + test.afterAll(async ({ api }) => { + await deleteTeam(api, targetTeamWithChannels); + }); + + test.beforeEach(async ({ page }) => { + poHomeTeam = new HomeTeam(page); + + await poHomeTeam.goto(); + }); + + test('should load the channel selection modal when removing a member that belongs to team channels', async ({ api }) => { + expect(await isChannelMember(api, selectedChannel, 'user1')).toBe(true); + expect(await isChannelMember(api, unselectedChannel, 'user1')).toBe(true); + + await poHomeTeam.navbar.openChat(targetTeamWithChannels); + await poHomeTeam.headerToolbar.openMoreOptions(); + await poHomeTeam.headerToolbar.openTeamMembers(); + await poHomeTeam.tabs.members.showAllUsers(); + await poHomeTeam.tabs.members.openMemberOptionMoreActions('user1'); + await poHomeTeam.tabs.members.getMenuItemAction('Remove from team').click(); + + await poHomeTeam.tabs.members.removeTeamMemberModal.waitForDisplay(); + await expect(poHomeTeam.tabs.members.removeTeamMemberModal.description).toBeVisible(); + await expect(poHomeTeam.tabs.members.removeTeamMemberModal.channel(selectedChannel)).toBeVisible(); + await expect(poHomeTeam.tabs.members.removeTeamMemberModal.channel(unselectedChannel)).toBeVisible(); + + await poHomeTeam.tabs.members.removeTeamMemberModal.selectChannel(selectedChannel); + await expect(poHomeTeam.tabs.members.removeTeamMemberModal.channelCheckbox(selectedChannel)).toBeChecked(); + await expect(poHomeTeam.tabs.members.removeTeamMemberModal.channelCheckbox(unselectedChannel)).not.toBeChecked(); + + await poHomeTeam.tabs.members.removeTeamMemberModal.continue(); + await poHomeTeam.tabs.members.confirmRemoveUser(); + + await expect(poHomeTeam.tabs.members.memberOption('user1')).not.toBeVisible(); + await expect.poll(() => isChannelMember(api, selectedChannel, 'user1')).toBe(false); + expect(await isChannelMember(api, unselectedChannel, 'user1')).toBe(true); + }); +}); diff --git a/apps/meteor/tests/e2e/thread-persistence-on-navigation.spec.ts b/apps/meteor/tests/e2e/thread-persistence-on-navigation.spec.ts new file mode 100644 index 0000000..7e40cb8 --- /dev/null +++ b/apps/meteor/tests/e2e/thread-persistence-on-navigation.spec.ts @@ -0,0 +1,61 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannelAndReturnFullRoom } from './utils'; +import { sendFillerMessages } from './utils/sendMessage'; +import type { BaseTest } from './utils/test'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe.serial('Thread persistence on navigation', () => { + let poHomeChannel: HomeChannel; + let targetChannel: IRoom; + let threadParentMessageId: string; + + const setupChannelData = async (api: BaseTest['api']) => { + const rid = targetChannel._id; + + const { message: parentMessage } = await (await api.post('/chat.postMessage', { roomId: rid, text: 'Thread parent message' })).json(); + threadParentMessageId = parentMessage._id; + + await api.post('/chat.postMessage', { + roomId: rid, + text: 'Thread reply that should persist after navigation', + tmid: parentMessage._id, + }); + + await sendFillerMessages(api, rid); + }; + + test.beforeAll(async ({ api }) => { + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = channel; + await setupChannelData(api); + }); + + test.afterAll(({ api }) => api.post('/channels.delete', { roomId: targetChannel._id })); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test('expect thread content to persist after clicking Jump to recent messages', async ({ page }) => { + await poHomeChannel.gotoChannelMessage(targetChannel.name as string, threadParentMessageId, true); + + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.threadMessageList).toBeVisible(); + const threadMessageCount = await poHomeChannel.content.threadMessageListItems.count(); + expect(threadMessageCount).toBeGreaterThan(0); + + const jumpToRecentButton = page.getByRole('button', { name: 'Jump to recent messages' }); + await expect(jumpToRecentButton).toBeVisible(); + await jumpToRecentButton.click(); + + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.threadMessageList).toBeVisible(); + await expect(poHomeChannel.content.threadMessageListItems).toHaveCount(threadMessageCount); + await expect(poHomeChannel.content.threadMessageList).toContainText('Thread reply that should persist after navigation'); + }); +}); diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts new file mode 100644 index 0000000..b91ac19 --- /dev/null +++ b/apps/meteor/tests/e2e/threads.spec.ts @@ -0,0 +1,263 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, createTargetChannelAndReturnFullRoom, deleteChannel, markRoomAsRead, sendMessage } from './utils'; +import { sendFillerMessages } from './utils/sendMessage'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.serial('Threads', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + }); + + test.afterAll(async ({ api }) => deleteChannel(api, targetChannel)); + + test('expect no unread banner when replying to a thread in a fresh channel', async ({ page }) => { + await poHomeChannel.content.sendMessage('parent for unread-banner test'); + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendMessageInThread('first thread reply'); + + await page.waitForTimeout(200); + await expect(page.getByTitle('Mark as read')).not.toBeVisible(); + }); + + test('expect thread message preview if alsoSendToChannel checkbox is checked', async ({ page }) => { + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.content.openReplyInThread(); + + await expect(page).toHaveURL(/.*thread/); + + await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); + await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is a thread message also sent in channel'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is a thread message also sent in channel'); + await expect(poHomeChannel.content.lastThreadMessagePreview).toContainText('This is a thread message also sent in channel'); + }); + test.describe('hideFlexTab Preference enabled for threads', () => { + test.beforeAll(async ({ api }) => { + await expect( + (await api.post('/users.setPreferences', { userId: 'rocketchat.internal.admin.test', data: { hideFlexTab: true } })).status(), + ).toBe(200); + }); + test.afterAll(async ({ api }) => { + await expect( + (await api.post('/users.setPreferences', { userId: 'rocketchat.internal.admin.test', data: { hideFlexTab: false } })).status(), + ).toBe(200); + }); + test('should close thread contextual bar on clicking outside of it', async ({ page }) => { + await test.step('open threads contextual bar when clicked on thread preview', async () => { + await poHomeChannel.content.lastThreadMessagePreviewText.click(); + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is a thread message also sent in channel', { + timeout: 15_000, + }); + }); + await expect(page).not.toHaveURL(/[?&]msg=/); + + await poHomeChannel.content.lastUserMessage.click(); + await expect(page).not.toHaveURL(/.*thread/); + }); + test('expect not to close thread contextual bar when performing some action', async ({ page }) => { + await poHomeChannel.content.lastThreadMessagePreviewText.click(); + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is a thread message also sent in channel', { + timeout: 15_000, + }); + + await poHomeChannel.content.openLastThreadMessageMenu(); + await page.locator('role=menuitem[name="Copy text"]').click(); + + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('This is a thread message also sent in channel'); + }); + }); + test('should send a file with name updated in thread', async ({ page }) => { + const updatedFileName = 'any_file1.txt'; + await poHomeChannel.content.lastThreadMessagePreviewText.click(); + + await expect(page).toHaveURL(/.*thread/); + + await poHomeChannel.content.dragAndDropTxtFileToThread(); + await poHomeChannel.threadComposer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.threadComposer.inputMessage.fill('any_description'); + await poHomeChannel.threadComposer.btnSend.click(); + + await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.getLastThreadMessageByFileName(updatedFileName)).toBeVisible(); + }); + + test.describe('thread message actions', () => { + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel); + await poHomeChannel.content.sendMessage('this is a message for reply'); + await poHomeChannel.content.openReplyInThread(); + }); + + test('expect delete the thread message and close thread if has only one message', async ({ page }) => { + await poHomeChannel.content.openLastThreadMessageMenu(); + await expect(page).toHaveURL(/.*thread/); + + await page.locator('role=menuitem[name="Delete"]').click(); + await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); + + await expect(page).not.toHaveURL(/.*thread/); + }); + test('expect delete the thread message and keep thread open if has more than one message', async ({ page }) => { + await page.locator('.rcx-vertical-bar').locator(`role=textbox[name="Message #${targetChannel}"]`).type('another reply message'); + await page.keyboard.press('Enter'); + await poHomeChannel.content.openLastThreadMessageMenu(); + await expect(page).toHaveURL(/.*thread/); + + await page.locator('role=menuitem[name="Delete"]').click(); + await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); + + await expect(page).toHaveURL(/.*thread/); + }); + + test('expect edit the thread message', async ({ page }) => { + await poHomeChannel.content.openLastThreadMessageMenu(); + await page.locator('role=menuitem[name="Edit"]').click(); + await page.locator('[name="msg"]').last().fill('this message was edited'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this message was edited'); + }); + + test('expect quote the thread message', async ({ page }) => { + await poHomeChannel.content.lastUserThreadMessage.hover(); + await poHomeChannel.content.lastUserThreadMessage.getByRole('button', { name: 'Quote' }).click(); + await page.locator('[name="msg"]').last().fill('this is a quote message'); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastThreadMessageTextAttachmentEqualsText).toContainText('this is a message for reply'); + }); + + test('expect star the thread message', async () => { + await poHomeChannel.content.openLastThreadMessageMenu(); + await poHomeChannel.content.btnOptionStarMessage.click(); + await poHomeChannel.content.btnToolbarOptions.click(); + await poHomeChannel.content.starredMessagesMenuOption.click(); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this is a message for reply'); + }); + + test('expect copy the thread message content to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await poHomeChannel.content.openLastThreadMessageMenu(); + await page.locator('role=menuitem[name="Copy text"]').click(); + + const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + expect(clipboardText).toBe('this is a message for reply'); + }); + + test('expect copy the thread message link to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await poHomeChannel.content.openLastThreadMessageMenu(); + await page.locator('role=menuitem[name="Copy link"]').click(); + + const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + expect(clipboardText).toContain('http'); + }); + + test('expect close thread if has only one message and user press escape', async ({ page }) => { + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toBeVisible(); + await expect(page.locator('[name="msg"]').last()).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page).not.toHaveURL(/.*thread/); + }); + + test('expect reset the thread composer to original message if user presses escape', async ({ page }) => { + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toBeVisible(); + + await expect(page.locator('[name="msg"]').last()).toBeFocused(); + await page.locator('[name="msg"]').last().fill('message to be edited'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowUp'); + + await expect(page.locator('[name="msg"]').last()).toHaveValue('message to be edited'); + await page.locator('[name="msg"]').last().fill('this message was edited'); + + await page.keyboard.press('Escape'); + await expect(page.locator('[name="msg"]').last()).toHaveValue('message to be edited'); + await expect(page).toHaveURL(/.*thread/); + }); + + test('expect clean composer and keep the thread open if user is editing message and presses escape', async ({ page }) => { + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toBeVisible(); + await expect(page.locator('[name="msg"]').last()).toBeFocused(); + + await page.locator('[name="msg"]').last().fill('message to be edited'); + await page.keyboard.press('Enter'); + + await page.keyboard.press('ArrowUp'); + await expect(page.locator('[name="msg"]').last()).toHaveValue('message to be edited'); + + await page.keyboard.press('Escape'); + await expect(page.locator('[name="msg"]').last()).toHaveValue(''); + await expect(page).toHaveURL(/.*thread/); + }); + }); +}); + +test.describe.serial('Threads - small screens', () => { + let poHomeChannel: HomeChannel; + let targetChannel: { name: string; _id: string }; + + test.beforeAll(async ({ api }) => { + const { channel } = await createTargetChannelAndReturnFullRoom(api); + targetChannel = { name: channel.name as string, _id: channel._id }; + + await sendFillerMessages(api, targetChannel._id, 120); + const parentId = await sendMessage(api, targetChannel._id, 'thread parent'); + await sendMessage(api, targetChannel._id, 'thread reply', parentId); + // Without this the room opens at the first unread message and loads the whole history at + // once, leaving no older page for the hidden list to drain. + await markRoomAsRead(api, targetChannel._id); + }); + + test.afterAll(async ({ api }) => deleteChannel(api, targetChannel.name)); + + test('should not load older messages while the message list is hidden behind the full-width thread view', async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await poHomeChannel.gotoChannel(targetChannel.name); + // Below the "sm" breakpoint (600px) the contextual bar takes the full room width and the + // message list is hidden behind it while staying mounted. + await page.setViewportSize({ width: 599, height: 700 }); + + await poHomeChannel.content.mainMessageListScroller.hover(); + await page.mouse.wheel(0, -100); + + // Role-based locators cannot see the list once it is display:none — count DOM nodes instead + // (thread messages carry a different aria-roledescription, so they never match). + const mainMessageListItems = page.locator('[role="listitem"][aria-roledescription="message"]'); + const loadedMessages = await mainMessageListItems.count(); + // 121 user messages were seeded — an unloaded older page must remain or there is nothing to drain + expect(loadedMessages).toBeLessThan(121); + + await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'View thread' }).click(); + await expect(page).toHaveURL(/.*thread/); + await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('thread reply'); + await expect(poHomeChannel.content.mainMessageListScroller).toBeHidden(); + + // A hidden-history drain re-triggers ~every 100ms, so with 71 older messages unloaded it pushes + // the count past 100 within the first sample. Hide-transition jitter can at most re-render the + // single already-loaded 50-message page, which stays under the +50 margin — the two outcomes + // cannot overlap. + for (let i = 0; i < 5; i++) { + await page.waitForTimeout(500); + expect(await mainMessageListItems.count()).toBeLessThan(loadedMessages + 50); + } + }); +}); diff --git a/apps/meteor/tests/e2e/translations.spec.ts b/apps/meteor/tests/e2e/translations.spec.ts new file mode 100644 index 0000000..05854fc --- /dev/null +++ b/apps/meteor/tests/e2e/translations.spec.ts @@ -0,0 +1,55 @@ +import { Users } from './fixtures/userStates'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { setUserPreferences } from './utils/setUserPreferences'; +import { test, expect } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +test.describe('Translations', () => { + test.beforeAll(async ({ api }) => { + expect((await setUserPreferences(api, { language: '' })).status()).toBe(200); + expect((await setSettingValueById(api, 'Language', 'en')).status()).toBe(200); + expect((await setSettingValueById(api, 'Site_Name', 'Rocket.Chat')).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await setUserPreferences(api, { language: '' })).status()).toBe(200); + expect((await setSettingValueById(api, 'Language', 'en')).status()).toBe(200); + }); + + test("expect to display text in the user's preference language", async ({ page, api }) => { + await page.goto('/home'); + await page.waitForTimeout(5000); + await expect(page.locator('h2')).toHaveText('Welcome to Rocket.Chat'); + + const response = page.waitForResponse('**/i18n/pt-BR.json'); + expect((await setUserPreferences(api, { language: 'pt-BR' })).status()).toBe(200); + await response; + await expect(page.locator('h2')).toHaveText('Bem-vindo ao Rocket.Chat'); + expect((await setUserPreferences(api, { language: '' })).status()).toBe(200); + }); + + test('expect to keep chosen language after refresh', async ({ page, api }) => { + await page.goto('/home'); + await expect(page.locator('h2')).toHaveText('Welcome to Rocket.Chat'); + + const response = page.waitForResponse('**/i18n/pt-BR.json'); + expect((await setUserPreferences(api, { language: 'pt-BR' })).status()).toBe(200); + await response; + await expect(page.locator('h2')).toHaveText('Bem-vindo ao Rocket.Chat'); + + // Test if selected language remaing after refresh + await page.goto('/home'); + await expect(page.locator('h2')).toHaveText('Bem-vindo ao Rocket.Chat'); + + expect((await setUserPreferences(api, { language: '' })).status()).toBe(200); + }); + + test.describe('Browser', async () => { + test.use({ locale: 'pt-BR' }); + test("expect to display text in the browser's language", async ({ page }) => { + await page.goto('/home'); + await expect(page.locator('h2')).toHaveText('Bem-vindo ao Rocket.Chat'); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts new file mode 100644 index 0000000..dc83fc1 --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-member.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user3.state }); + +test.describe.parallel('Mention User Card [To Member]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username, Users.user3.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); + + test('should show correct userinfo actions for a non-member of the room to a non-privileged member', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts new file mode 100644 index 0000000..654e575 --- /dev/null +++ b/apps/meteor/tests/e2e/user-card-info-actions-by-room-owner.spec.ts @@ -0,0 +1,90 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel, deleteChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); +test.describe.parallel('Mention User Card [To Room Owner]', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api, { members: [Users.user1.data.username] }); + + await api.post(`/chat.postMessage`, { + text: `Hello @${Users.user1.data.username} @${Users.user2.data.username}`, + channel: targetChannel, + }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test.afterAll(({ api }) => deleteChannel(api, targetChannel)); + + test('should show correct userinfo actions for a member of the room to the room owner', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user1.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeFalsy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeTruthy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeTruthy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeTruthy(); + }); + + test('should show correct userinfo actions for a non-member of the room to the room owner', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + const mentionSpan = page.locator(`span[title="Mentions user"][data-uid="${Users.user2.data.username}"]`); + await mentionSpan.click(); + + await expect(page.locator('div[aria-label="User card actions"]')).toBeVisible(); + const moreButton = await page.locator('div[aria-label="User card actions"] button[title="More"]'); + if (await moreButton.isVisible()) { + await moreButton.click(); + } + + const isAddToRoomVisible = + (await page.locator('button[title="Add to room"]').isVisible()) || (await page.locator('label[data-key="Add to room"]').isVisible()); + await expect(isAddToRoomVisible).toBeTruthy(); + + const isRemoveFromRoomVisible = + (await page.locator('button[title="Remove from room"]').isVisible()) || + (await page.locator('label[data-key="Remove from room"]').isVisible()); + await expect(isRemoveFromRoomVisible).toBeFalsy(); + + const isSetAsLeaderVisible = + (await page.locator('button[title="Set as leader"]').isVisible()) || + (await page.locator('label[data-key="Set as leader"]').isVisible()); + await expect(isSetAsLeaderVisible).toBeFalsy(); + + const isSetAsModeratorVisible = + (await page.locator('button[title="Set as moderator"]').isVisible()) || + (await page.locator('label[data-key="Set as moderator"]').isVisible()); + await expect(isSetAsModeratorVisible).toBeFalsy(); + }); +}); diff --git a/apps/meteor/tests/e2e/user-required-password-change.spec.ts b/apps/meteor/tests/e2e/user-required-password-change.spec.ts new file mode 100644 index 0000000..2c67953 --- /dev/null +++ b/apps/meteor/tests/e2e/user-required-password-change.spec.ts @@ -0,0 +1,166 @@ +import { faker } from '@faker-js/faker'; + +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Login, Registration } from './page-objects'; +import { Navbar, RoomSidebar } from './page-objects/fragments'; +import { getSettingValueById, setSettingValueById } from './utils'; +import { test, expect } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser } from './utils/user-helpers'; + +test.describe('User - Password change required', () => { + let poLogin: Login; + let poRegistration: Registration; + let navbar: Navbar; + let sidebar: RoomSidebar; + let userRequiringPasswordChange: ITestUser; + let userNotRequiringPasswordChange: ITestUser; + let userNotAbleToLogin: ITestUser; + let settingDefaultValue: unknown; + + test.beforeAll(async ({ api }) => { + settingDefaultValue = await getSettingValueById(api, 'Accounts_RequirePasswordConfirmation'); + await setSettingValueById(api, 'Accounts_Password_Policy_Enabled', false); + await setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', true); + userRequiringPasswordChange = await createTestUser(api, { data: { requirePasswordChange: true } }); + userNotRequiringPasswordChange = await createTestUser(api, { data: { requirePasswordChange: false } }); + userNotAbleToLogin = await createTestUser(api, { data: { requirePasswordChange: true } }); + }); + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + poRegistration = new Registration(page); + navbar = new Navbar(page); + sidebar = new RoomSidebar(page); + await poLogin.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', settingDefaultValue), + userRequiringPasswordChange.delete(), + userNotRequiringPasswordChange.delete(), + userNotAbleToLogin.delete(), + setSettingValueById(api, 'Accounts_Password_Policy_Enabled', true), + ]); + }); + + test('should redirect to home after successful password change for new user', async ({ page }) => { + await test.step('login with temporary password', async () => { + await poLogin.login(userRequiringPasswordChange.data.username, DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('should be redirected to password change screen', async () => { + await expect(poRegistration.inputPassword).toBeVisible(); + await expect(poRegistration.inputPasswordConfirm).toBeVisible(); + }); + + await test.step('enter new password and confirm', async () => { + const newPassword = faker.internet.password(); + await poRegistration.inputPassword.fill(newPassword); + await poRegistration.inputPasswordConfirm.fill(newPassword); + }); + + await test.step('click reset button', async () => { + await poRegistration.btnReset.click(); + }); + + await test.step('verify password reset form is not visible', async () => { + await page.waitForURL('/home'); + await expect(poRegistration.inputPassword).not.toBeVisible(); + await expect(poRegistration.inputPasswordConfirm).not.toBeVisible(); + }); + + await test.step('verify user is properly logged in', async () => { + await expect(navbar.btnUserMenu).toBeVisible(); + await expect(sidebar.channelsList).toBeVisible(); + }); + }); + + test('should show error when password confirmation does not match', async () => { + await test.step('login with temporary password', async () => { + await poLogin.login(userNotAbleToLogin.data.username, DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('should be redirected to password change screen', async () => { + await expect(poRegistration.inputPassword).toBeVisible(); + await expect(poRegistration.inputPasswordConfirm).toBeVisible(); + }); + + await test.step('enter different passwords in password and confirm fields', async () => { + await poRegistration.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.inputPasswordConfirm.fill(faker.internet.password()); + }); + + await test.step('attempt to submit password change', async () => { + await poRegistration.btnReset.click(); + }); + + await test.step('verify error message appears for password mismatch', async () => { + await expect(poRegistration.inputPasswordConfirm).toBeInvalid(); + }); + }); + + test('should show error when user tries to use the same password as current', async () => { + await test.step('login with temporary password', async () => { + await poLogin.login(userNotAbleToLogin.data.username, DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('should be redirected to password change screen', async () => { + await expect(poRegistration.inputPassword).toBeVisible(); + await expect(poRegistration.inputPasswordConfirm).toBeVisible(); + }); + + await test.step('enter the same password as current password', async () => { + await poRegistration.inputPassword.fill(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.inputPasswordConfirm.fill(DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('attempt to submit password change', async () => { + await poRegistration.btnReset.click(); + }); + + await test.step('verify error message appears for same password', async () => { + await expect(poRegistration.inputPassword).toBeInvalid(); + }); + }); +}); + +test.describe('User - Password change not required', () => { + let poLogin: Login; + let navbar: Navbar; + let sidebar: RoomSidebar; + let userNotRequiringPasswordChange: ITestUser; + let settingDefaultValue: unknown; + + test.beforeAll(async ({ api }) => { + settingDefaultValue = await getSettingValueById(api, 'Accounts_RequirePasswordConfirmation'); + userNotRequiringPasswordChange = await createTestUser(api, { data: { requirePasswordChange: false } }); + }); + + test.beforeEach(async ({ page }) => { + poLogin = new Login(page); + navbar = new Navbar(page); + sidebar = new RoomSidebar(page); + await poLogin.goto(); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_RequirePasswordConfirmation', settingDefaultValue), + userNotRequiringPasswordChange.delete(), + ]); + }); + + test('should not require password change if the requirePasswordChange is disabled', async ({ page }) => { + await test.step('login with user not requiring password change', async () => { + await poLogin.login(userNotRequiringPasswordChange.data.username, DEFAULT_USER_CREDENTIALS.password); + }); + + await test.step('verify user is properly logged in', async () => { + await page.waitForURL('/home'); + await expect(navbar.btnUserMenu).toBeVisible(); + await expect(sidebar.channelsList).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/utils/apps.ts b/apps/meteor/tests/e2e/utils/apps.ts new file mode 100644 index 0000000..bbdb293 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/apps.ts @@ -0,0 +1,57 @@ +import fs from 'fs'; + +import { request } from '@playwright/test'; +import type { Endpoints } from '@rocket.chat/rest-typings'; + +import { expect, type BaseTest } from './test'; +import { APP_URL } from '../../data/apps/apps-data'; +import { BASE_API_URL, BASE_URL } from '../config/constants'; +import { Users } from '../fixtures/userStates'; + +export async function insertDefaultTestApp(): Promise { + const api = await request.newContext(); + + const headers = { + 'X-Auth-Token': Users.admin.data.loginToken, + 'X-User-Id': Users.admin.data._id, + }; + + await api.post(`${BASE_URL}/api/apps`, { data: { url: APP_URL }, headers }); + await api.post(`${BASE_API_URL}/settings/VideoConf_Default_Provider`, { data: { value: 'test' }, headers }); +} + +export async function installLocalTestPackage(packagePath: string): Promise<{ app: { id: string } }> { + const api = await request.newContext(); + + const headers = { + 'X-Auth-Token': Users.admin.data.loginToken, + 'X-User-Id': Users.admin.data._id, + }; + + const response = await api.post(`${BASE_URL}/api/apps`, { multipart: { app: fs.createReadStream(packagePath) }, headers }); + + await expect(response).toBeOK(); + + return response.json(); +} + +export async function uninstallApp(appId: string): Promise { + const api = await request.newContext(); + + const headers = { + 'X-Auth-Token': Users.admin.data.loginToken, + 'X-User-Id': Users.admin.data._id, + }; + + const response = await api.delete(`${BASE_URL}/api/apps/${appId}`, { headers }); + + await expect(response).toBeOK(); +} + +export async function getAppLogs(api: BaseTest['api'], appId: string): Promise> { + const response = await api.get(`/apps/${appId}/logs`, undefined, '/api'); + + await expect(response).toBeOK(); + + return response.json(); +} diff --git a/apps/meteor/tests/e2e/utils/convertHexToRGB.ts b/apps/meteor/tests/e2e/utils/convertHexToRGB.ts new file mode 100644 index 0000000..9b20671 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/convertHexToRGB.ts @@ -0,0 +1,9 @@ +export const convertHexToRGB = (hex: string) => { + hex = hex.replace(/^#/, ''); + + const red = parseInt(hex.substring(0, 2), 16); + const green = parseInt(hex.substring(2, 4), 16); + const blue = parseInt(hex.substring(4, 6), 16); + + return `rgb(${red}, ${green}, ${blue})`; +}; diff --git a/apps/meteor/tests/e2e/utils/create-target-channel.ts b/apps/meteor/tests/e2e/utils/create-target-channel.ts new file mode 100644 index 0000000..4a1d7f6 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/create-target-channel.ts @@ -0,0 +1,208 @@ +import { faker } from '@faker-js/faker'; +import { request as baseRequest } from '@playwright/test'; +import type { IRoom, IMessage } from '@rocket.chat/core-typings'; +import type { ChannelsCreateProps, GroupsCreateProps } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; +import { BASE_API_URL } from '../config/constants'; + +/** + * createTargetChannel: + * - Usefull to create a target channel for message related tests + */ +export async function createTargetChannel(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/channels.create', { name, ...options }); + + return name; +} + +export async function createTargetChannelAndReturnFullRoom( + api: BaseTest['api'], + options?: Omit, +): Promise<{ channel: IRoom }> { + const name = faker.string.uuid(); + return (await api.post('/channels.create', { name, ...options })).json(); +} + +export async function sendTargetChannelMessage(api: BaseTest['api'], roomName: string, options?: Partial) { + const response = await api.get(`/channels.info?roomName=${roomName}`); + + const { + channel: { _id: rid }, + }: { channel: IRoom } = await response.json(); + + await api.post('/chat.sendMessage', { + message: { + rid, + msg: options?.msg || 'simple message', + ...options, + }, + }); + + return options?.msg || 'simple message'; +} + +export async function deleteChannel(api: BaseTest['api'], roomName: string): Promise { + await api.post('/channels.delete', { roomName }); +} + +export async function isChannelMember(api: BaseTest['api'], roomName: string, username: string): Promise { + const response = await api.get(`/channels.members?roomName=${roomName}`); + const { members }: { members: { username: string }[] } = await response.json(); + return members.some((member) => member.username === username); +} + +export async function deleteRoom(api: BaseTest['api'], roomId: string): Promise { + await api.post('/rooms.delete', { roomId }); +} + +export async function deletePrivateRoomsByName(credentials: { username: string; password: string }, roomNames: string[]): Promise { + const userApi = await baseRequest.newContext(); + + try { + const loginResponse = await userApi.post(`${BASE_API_URL}/login`, { data: credentials }); + + if (!loginResponse.ok()) { + return; + } + + const { data } = await loginResponse.json(); + await Promise.allSettled( + roomNames.map((roomName) => + userApi.post(`${BASE_API_URL}/groups.delete`, { + data: { roomName }, + headers: { + 'X-Auth-Token': data.authToken, + 'X-User-Id': data.userId, + }, + }), + ), + ); + } finally { + await userApi.dispose(); + } +} + +export async function markRoomAsRead(api: BaseTest['api'], roomId: string): Promise { + await api.post('/subscriptions.read', { rid: roomId }); +} + +export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/groups.create', { name, ...options }); + + return name; +} + +export async function createTargetTeam(api: BaseTest['api'], options?: Omit): Promise { + const name = faker.string.uuid(); + await api.post('/teams.create', { name, type: 1, members: ['user2', 'user1'], ...options }); + + return name; +} + +export async function deleteTeam(api: BaseTest['api'], teamName: string): Promise { + await api.post('/teams.delete', { teamName }); +} + +export async function createDirectMessage(api: BaseTest['api']): Promise { + await api.post('/dm.create', { + usernames: 'user1,user2', + }); +} + +export async function createTargetDiscussion(api: BaseTest['api']): Promise> { + const channelName = faker.string.uuid(); + const discussionName = faker.string.uuid(); + + const channelResponse = await api.post('/channels.create', { name: channelName }); + const { channel } = await channelResponse.json(); + const discussionResponse = await api.post('/rooms.createDiscussion', { t_name: discussionName, prid: channel._id }); + const { discussion } = await discussionResponse.json(); + + if (!discussion) { + throw new Error('Discussion not created'); + } + + return discussion; +} + +export async function createChannelWithTeam(api: BaseTest['api']): Promise> { + const channelName = faker.string.uuid(); + const teamName = faker.string.uuid(); + + const teamResponse = await api.post('/teams.create', { name: teamName, type: 1, members: ['user2'] }); + const { team } = await teamResponse.json(); + + await api.post('/channels.create', { name: channelName, members: ['user1'], extraData: { teamId: team._id } }); + + return { channelName, teamName }; +} + +export async function createArchivedChannel(api: BaseTest['api']): Promise { + const { channel } = await createTargetChannelAndReturnFullRoom(api); + + try { + await api.post('/channels.archive', { roomId: channel._id }); + } catch (error) { + throw new Error(`Error archiving the channel: ${error}`); + } + + if (!channel.name) { + throw new Error('Invalid channel was created'); + } + + return channel.name; +} + +export async function createTargetGroupAndReturnFullRoom( + api: BaseTest['api'], + options?: Omit, +): Promise<{ group: IRoom }> { + const name = faker.string.uuid(); + return (await api.post('/groups.create', { name, ...options })).json(); +} + +export async function sendMessage(api: BaseTest['api'], roomId: string, msg: string, threadId?: string): Promise { + const payload: { message: { rid: string; msg: string; tmid?: string } } = { message: { rid: roomId, msg } }; + if (threadId) { + payload.message.tmid = threadId; + } + + const response = await api.post('/chat.sendMessage', payload); + const data: { success?: boolean; message?: { _id: string } } = await response.json(); + + if (!data.success || !data.message?._id) { + throw new Error(`Error sending message: ${JSON.stringify(data)}`); + } + + return data.message._id; +} + +export async function createDiscussion(api: BaseTest['api'], parentRoomId: string, parentMessageId: string, name: string): Promise { + const response = await api.post('/rooms.createDiscussion', { + prid: parentRoomId, + pmid: parentMessageId, + t_name: name, + }); + + const data: { success?: boolean; discussion?: { _id: string } } = await response.json(); + + if (!data.discussion?._id) { + throw new Error(`Error creating discussion: ${JSON.stringify(data)}`); + } + + return data.discussion._id; +} + +export async function createDirectMessageRoom(api: BaseTest['api'], username: string): Promise { + const response = await api.post('/im.create', { username }); + const data: { success?: boolean; room?: { _id: string } } = await response.json(); + + if (!data.room?._id) { + throw new Error(`Error creating direct message room: ${JSON.stringify(data)}`); + } + + return data.room._id; +} diff --git a/apps/meteor/tests/e2e/utils/custom-role.ts b/apps/meteor/tests/e2e/utils/custom-role.ts new file mode 100644 index 0000000..b2b7c5e --- /dev/null +++ b/apps/meteor/tests/e2e/utils/custom-role.ts @@ -0,0 +1,11 @@ +import type { Endpoints } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export async function createCustomRole(api: BaseTest['api'], data: Parameters[0]) { + return api.post('/roles.create', data); +} + +export async function deleteCustomRole(api: BaseTest['api'], roleId: string) { + return api.post('/roles.delete', { roleId }); +} diff --git a/apps/meteor/tests/e2e/utils/expectPollUserStatus.ts b/apps/meteor/tests/e2e/utils/expectPollUserStatus.ts new file mode 100644 index 0000000..68c795a --- /dev/null +++ b/apps/meteor/tests/e2e/utils/expectPollUserStatus.ts @@ -0,0 +1,7 @@ +import { getUserInfo } from './getUserInfo'; +import type { BaseTest } from './test'; +import { expect } from './test'; + +export const expectPollUserStatus = async (api: BaseTest['api'], username: string, status: 'online' | 'offline' | 'away' | 'busy') => { + await expect.poll(async () => (await getUserInfo(api, username))?.status).toBe(status); +}; diff --git a/apps/meteor/tests/e2e/utils/getPermissionRoles.ts b/apps/meteor/tests/e2e/utils/getPermissionRoles.ts new file mode 100644 index 0000000..8625d2c --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getPermissionRoles.ts @@ -0,0 +1,7 @@ +import type { BaseTest } from './test'; + +export const getPermissionRoles = async (api: BaseTest['api'], permissionId: string): Promise => { + const res = await api.get('/permissions.listAll'); + const { update } = await res.json(); + return update.find((p: { _id: string }) => p._id === permissionId)?.roles ?? []; +}; diff --git a/apps/meteor/tests/e2e/utils/getSettingValueById.ts b/apps/meteor/tests/e2e/utils/getSettingValueById.ts new file mode 100644 index 0000000..a473702 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getSettingValueById.ts @@ -0,0 +1,7 @@ +import type { BaseTest } from './test'; + +export const getSettingValueById = async (api: BaseTest['api'], settingId: string): Promise => { + const response = await api.get(`/settings/${settingId}`); + const { value } = await response.json(); + return value; +}; diff --git a/apps/meteor/tests/e2e/utils/getUserInfo.ts b/apps/meteor/tests/e2e/utils/getUserInfo.ts new file mode 100644 index 0000000..a8a60a9 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getUserInfo.ts @@ -0,0 +1,15 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; + +export const getUserInfo = async (api: BaseTest['api'], username: string): Promise => { + const response = await api.get(`/users.info?username=${username}`); + + if (response.status() !== 200) { + throw new Error('Failed to get user info.'); + } + + const data = await response.json(); + + return data.user; +}; diff --git a/apps/meteor/tests/e2e/utils/index.ts b/apps/meteor/tests/e2e/utils/index.ts new file mode 100644 index 0000000..3ad0333 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/index.ts @@ -0,0 +1,7 @@ +export * from './create-target-channel'; +export * from './setSettingValueById'; +export * from './getSettingValueById'; +export * from './getPermissionRoles'; +export * from './updatePermissions'; +export * from './setUserPreferences'; +export * from './updateOwnUserInfo'; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/agents.ts b/apps/meteor/tests/e2e/utils/omnichannel/agents.ts new file mode 100644 index 0000000..b8d275d --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/agents.ts @@ -0,0 +1,30 @@ +import type { BaseTest } from '../test'; + +export const makeAgentAvailable = async (api: BaseTest['api'], agentId: string) => { + await api.post('/users.setStatus', { + userId: agentId, + message: '', + status: 'online', + }); + + return api.post('/livechat/agent.status', { + agentId, + status: 'available', + }); +}; + +export const createAgent = async (api: BaseTest['api'], username: string) => { + const response = await api.post('/livechat/users/agent', { username }); + + if (response.status() !== 200) { + throw new Error(`Failed to create agent [http status: ${response.status()}]`); + } + + const { user: agent } = await response.json(); + + return { + response, + data: agent, + delete: async () => api.delete(`/livechat/users/agent/${agent._id}`), + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/businessHours.ts b/apps/meteor/tests/e2e/utils/omnichannel/businessHours.ts new file mode 100644 index 0000000..c876fd7 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/businessHours.ts @@ -0,0 +1,36 @@ +import type { BaseTest } from '../test'; + +type CreateBusinessHoursParams = { + id?: string | null; + name?: string; + description?: string; + departments?: { departmentId: string }[]; +}; + +export const createBusinessHour = async (api: BaseTest['api'], { name, departments = [] }: CreateBusinessHoursParams = {}) => { + const departmentIds = departments.join(','); + + return api.post('/livechat/business-hours.save', { + name, + timezoneName: 'America/Sao_Paulo', + daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + daysTime: [ + { day: 'Monday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }, + { day: 'Tuesday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }, + { day: 'Wednesday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }, + { day: 'Thursday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }, + { day: 'Friday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }, + ], + departmentsToApplyBusinessHour: departmentIds, + active: true, + type: 'custom', + timezone: 'America/Sao_Paulo', + workHours: [ + { day: 'Monday', start: '08:00', finish: '18:00', open: true }, + { day: 'Tuesday', start: '08:00', finish: '18:00', open: true }, + { day: 'Wednesday', start: '08:00', finish: '18:00', open: true }, + { day: 'Thursday', start: '08:00', finish: '18:00', open: true }, + { day: 'Friday', start: '08:00', finish: '18:00', open: true }, + ], + }); +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts b/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts new file mode 100644 index 0000000..7aa2f91 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/custom-field.ts @@ -0,0 +1,60 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; + +import type { BaseTest } from '../test'; + +type CustomField = Omit & { field: string }; + +export const removeCustomField = (api: BaseTest['api'], id: string) => { + return api.post('/livechat/custom-fields.delete', { + customFieldId: id, + }); +}; + +export const setVisitorCustomFieldValue = async ( + api: BaseTest['api'], + params: { token: string; customFieldId: string; value: string; overwrite?: boolean }, +) => { + const response = await api.post('/livechat/custom.field', { + token: params.token, + key: params.customFieldId, + value: params.value, + overwrite: params.overwrite ?? true, + }); + if (!response.ok()) { + throw new Error(`Failed to set visitor custom field [http status: ${response.status()}]`); + } + const { field } = await response.json(); + return { response, field }; +}; + +export const createCustomField = async (api: BaseTest['api'], overwrites: Partial) => { + const response = await api.post('/livechat/custom-fields.save', { + customFieldId: null, + customFieldData: { + field: overwrites.field, + label: overwrites.label || overwrites.field, + visibility: 'visible', + scope: 'visitor', + searchable: false, + regexp: '', + type: 'input', + required: false, + defaultValue: '', + options: '', + public: false, + ...overwrites, + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to create custom field [http status: ${response.status()}]`); + } + + const { customField } = await response.json(); + + return { + response, + customField, + delete: () => removeCustomField(api, customField._id), + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts new file mode 100644 index 0000000..e268ff8 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts @@ -0,0 +1,93 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; + +import type { BaseTest } from '../test'; + +type CreateDepartmentParams = { + name?: string; + enabled?: boolean; + description?: string; + showOnRegistration?: boolean; + showOnOfflineForm?: boolean; + requestTagBeforeClosingChat?: boolean; + email?: string; + chatClosingTags?: string[]; + offlineMessageChannelName?: string; + abandonedRoomsCloseCustomMessage?: string; + waitingQueueMessage?: string; + departmentsAllowedToForward?: string[]; + fallbackForwardDepartment?: string; + maxNumberSimultaneousChat?: number; + allowReceiveForwardOffline?: boolean; +}; + +export const createDepartment = async ( + api: BaseTest['api'], + { + name = '', + enabled = true, + description = '', + showOnRegistration = false, + showOnOfflineForm = false, + requestTagBeforeClosingChat = false, + email = '', + chatClosingTags = [], + offlineMessageChannelName = '', + abandonedRoomsCloseCustomMessage = '', + waitingQueueMessage = '', + departmentsAllowedToForward = [], + fallbackForwardDepartment = '', + maxNumberSimultaneousChat, + allowReceiveForwardOffline, + }: CreateDepartmentParams = {}, +) => { + const response = await api.post('/livechat/department', { + department: { + name: name || faker.string.uuid(), + enabled, + description, + showOnRegistration, + showOnOfflineForm, + requestTagBeforeClosingChat, + email: email || faker.internet.email(), + chatClosingTags, + offlineMessageChannelName, + abandonedRoomsCloseCustomMessage, + waitingQueueMessage, + departmentsAllowedToForward, + fallbackForwardDepartment, + maxNumberSimultaneousChat, + allowReceiveForwardOffline, + }, + }); + + if (response.status() !== 200) { + throw Error(`Unable to create department [http status: ${response.status()}]`); + } + + const { department } = await response.json(); + + return { + response, + data: department, + delete: async () => api.delete(`/livechat/department/${department._id}`), + }; +}; + +export const addAgentToDepartment = async ( + api: BaseTest['api'], + { department, agentId, username }: { department: ILivechatDepartment; agentId: string; username?: string }, +) => + api.post(`/livechat/department/${department._id}/agents`, { + remove: [], + upsert: [ + { + agentId, + username: username || agentId, + count: 0, + order: 0, + }, + ], + }); + +export const deleteDepartment = async (api: BaseTest['api'], { id }: { id: string }) => api.delete(`/livechat/department/${id}`); diff --git a/apps/meteor/tests/e2e/utils/omnichannel/managers.ts b/apps/meteor/tests/e2e/utils/omnichannel/managers.ts new file mode 100644 index 0000000..f32ecad --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/managers.ts @@ -0,0 +1,19 @@ +import type { BaseTest } from '../test'; + +export const createManager = async (api: BaseTest['api'], username: string) => { + const response = await api.post('/livechat/users/manager', { + username, + }); + + if (response.status() !== 200) { + throw Error(`Unable to create manager [http status: ${response.status()}]`); + } + + const { user: manager } = await response.json(); + + return { + response, + data: manager, + delete: async () => api.delete(`/livechat/users/manager/${manager._id}`), + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/monitors.ts b/apps/meteor/tests/e2e/utils/omnichannel/monitors.ts new file mode 100644 index 0000000..caa7bfe --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/monitors.ts @@ -0,0 +1,19 @@ +import type { BaseTest } from '../test'; + +const deleteMonitor = async (api: BaseTest['api'], username: string) => api.post('/livechat/monitors.delete', { username }); + +export const createMonitor = async (api: BaseTest['api'], username: string) => { + const response = await api.post('/livechat/monitors.create', { username }); + + if (response.status() !== 200) { + throw new Error(`Failed to create monitor [http status: ${response.status()}]`); + } + + const data = await response.json(); + + return { + response, + data, + delete: async () => deleteMonitor(api, data.username), + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/priority.ts b/apps/meteor/tests/e2e/utils/omnichannel/priority.ts new file mode 100644 index 0000000..a7c0178 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/priority.ts @@ -0,0 +1,16 @@ +import type { ILivechatPriority } from '@rocket.chat/core-typings'; + +import type { BaseTest } from '../test'; +import { expect } from '../test'; + +export const getPriorityByi18nLabel = async (api: BaseTest['api'], i18n: string): Promise => { + const allPriorityResp = await api.get('/livechat/priorities'); + expect(allPriorityResp.status()).toBe(200); + const { priorities } = (await allPriorityResp.json()) as { priorities: ILivechatPriority[] }; + const priority = priorities.find((p) => p.i18n === i18n); + if (!priority) { + throw new Error('Could not find priority with i18n "High"'); + } + + return priority; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/rooms.ts b/apps/meteor/tests/e2e/utils/omnichannel/rooms.ts new file mode 100644 index 0000000..4e5281e --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/rooms.ts @@ -0,0 +1,123 @@ +import { faker } from '@faker-js/faker'; + +import { createFakeVisitor } from '../../../mocks/data'; +import type { BaseTest } from '../test'; + +type UpdateRoomParams = { roomId: string; visitorId: string; tags: string[] }; + +type CloseRoomParams = { roomId: string; visitorToken: string }; + +type CreateRoomParams = { tags?: string[]; visitorToken: string; agentId?: string }; + +type CreateVisitorParams = { token: string; departmentId?: string; name?: string; email?: string }; + +type CreateConversationParams = { visitorName?: string; visitorToken?: string; agentId?: string; departmentId?: string }; + +export const updateRoom = async (api: BaseTest['api'], { roomId, visitorId, tags }: UpdateRoomParams) => { + if (!roomId) { + throw Error('Unable to update room info, missing room id'); + } + + if (!visitorId) { + throw Error('Unable to update room info, missing visitor id'); + } + + const response = await api.post('/livechat/room.saveInfo', { + guestData: { _id: visitorId }, + roomData: { _id: roomId, tags }, + }); + + if (response.status() !== 200) { + throw Error(`Unable to update room info [http status: ${response.status()}]`); + } + + return response; +}; + +export const closeRoom = async (api: BaseTest['api'], { roomId, visitorToken }: CloseRoomParams) => + api.post('/livechat/room.close', { rid: roomId, token: visitorToken }); + +export const createRoom = async (api: BaseTest['api'], { visitorToken, agentId }: CreateRoomParams) => { + const response = await api.get('/livechat/room', { + token: visitorToken, + agentId, + }); + + if (response.status() !== 200) { + throw Error(`Unable to create room [http status: ${response.status()}]`, { cause: await response.json() }); + } + + const { room } = await response.json(); + + return { + response, + data: room, + async delete() { + await closeRoom(api, { roomId: room._id, visitorToken }); + return api.post('/livechat/rooms.delete', { roomId: room._id }); + }, + }; +}; + +export const createVisitor = async (api: BaseTest['api'], { name, token, departmentId }: CreateVisitorParams) => { + const fakeVisitor = createFakeVisitor(); + const response = await api.post('/livechat/visitor', { + visitor: { + name: name || fakeVisitor.name, + email: fakeVisitor.email, + token, + ...(departmentId && { department: departmentId }), + }, + }); + + if (response.status() !== 200) { + throw Error(`Unable to create visitor [http status: ${response.status()}]`); + } + + const { visitor } = await response.json(); + + return { + response, + data: visitor, + delete: async () => api.delete(`/livechat/visitor/${token}`), + }; +}; + +export const sendMessageToRoom = async ( + api: BaseTest['api'], + { visitorToken, roomId, message }: { visitorToken: string; roomId: string; message?: string }, +) => { + const response = await api.post(`/livechat/message`, { + token: visitorToken, + rid: roomId, + msg: message || faker.lorem.sentence(), + }); + + if (response.status() !== 200) { + throw Error(`Unable to send message to room [http status: ${response.status()}]`); + } + + return response; +}; + +export const createConversation = async ( + api: BaseTest['api'], + { visitorName, visitorToken, agentId, departmentId }: CreateConversationParams = {}, +) => { + const token = visitorToken || faker.string.uuid(); + + const { data: visitor, delete: deleteVisitor } = await createVisitor(api, { token, name: visitorName, departmentId }); + const { data: room, delete: deleteRoom } = await createRoom(api, { visitorToken: token, agentId }); + await sendMessageToRoom(api, { visitorToken: token, roomId: room._id }); + + return { + data: { + room, + visitor, + }, + delete: async () => { + await deleteRoom(); + await deleteVisitor(); + }, + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/sla.ts b/apps/meteor/tests/e2e/utils/omnichannel/sla.ts new file mode 100644 index 0000000..42424a0 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/sla.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { type IOmnichannelServiceLevelAgreements, DEFAULT_SLA_CONFIG } from '@rocket.chat/core-typings'; + +import type { BaseTest } from '../test'; +import { expect } from '../test'; + +export const generateRandomSLAData = (): Omit => ({ + name: `${faker.person.firstName()}-${faker.database.mongodbObjectId()}`, + description: faker.lorem.sentence(), + dueTimeInMinutes: faker.number.int({ min: 10, max: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE }), +}); + +export const createSLA = async ( + api: BaseTest['api'], + slaData?: Omit, +): Promise> => { + const response = await api.post('/livechat/sla', slaData ?? generateRandomSLAData()); + expect(response.status()).toBe(200); + + const { sla } = (await response.json()) as { sla: Omit }; + return sla; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/tags.ts b/apps/meteor/tests/e2e/utils/omnichannel/tags.ts new file mode 100644 index 0000000..3c537cc --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/tags.ts @@ -0,0 +1,36 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatTag } from '@rocket.chat/core-typings'; + +import type { BaseTest } from '../test'; + +type CreateTagParams = { + id?: string | null; + name?: string; + description?: string; + departments?: string[]; +}; + +const removeTag = async (api: BaseTest['api'], id: string) => api.post('/livechat/tags.delete', { id }); + +export const createTag = async (api: BaseTest['api'], { id = null, name, description, departments = [] }: CreateTagParams = {}) => { + const response = await api.post('/livechat/tags.save', { + _id: id, + tagData: { + name: name ?? faker.string.alpha({ length: 8 }), + description: description ?? faker.string.alpha({ length: 16 }), + }, + ...(departments.length > 0 && { tagDepartments: departments }), + }); + + if (response.status() !== 200) { + throw new Error(`Failed to create tag [http status: ${response.status()}]`); + } + + const data: ILivechatTag = await response.json(); + + return { + response, + data, + delete: async () => removeTag(api, data._id), + }; +}; diff --git a/apps/meteor/tests/e2e/utils/omnichannel/units.ts b/apps/meteor/tests/e2e/utils/omnichannel/units.ts new file mode 100644 index 0000000..e302475 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/omnichannel/units.ts @@ -0,0 +1,56 @@ +import { faker } from '@faker-js/faker'; + +import type { BaseTest } from '../test'; + +type CreateUnitParams = { + id?: string | null; + name?: string; + visibility?: 'public' | 'private'; + monitors?: { monitorId: string; username: string }[]; + departments?: { departmentId: string }[]; +}; + +const removeUnit = async (api: BaseTest['api'], id: string) => api.delete(`/livechat/units/${id}`); + +export const createOrUpdateUnit = async ( + api: BaseTest['api'], + { id = null, name, visibility, monitors, departments }: CreateUnitParams = {}, +) => { + const unitPayload = { + unitData: { + name: name ?? faker.string.uuid(), + visibility: visibility ?? 'public', + }, + unitMonitors: monitors, + unitDepartments: departments, + }; + + const response = id ? await api.post(`/livechat/units/${id}`, unitPayload) : await api.post('/livechat/units', unitPayload); + + if (response.status() !== 200) { + throw new Error(`Failed to create or update unit [http status: ${response.status()}]`); + } + + const data = await response.json(); + + return { + response, + data, + delete: async () => removeUnit(api, data?._id), + }; +}; + +export const fetchUnitMonitors = async (api: BaseTest['api'], unitId: string) => { + const response = await api.get(`/livechat/units/${unitId}/monitors`); + + if (response.status() !== 200) { + throw new Error(`Failed to fetch unit monitors [http status: ${response.status()}]`); + } + + const { monitors } = await response.json(); + + return { + response, + data: monitors, + }; +}; diff --git a/apps/meteor/tests/e2e/utils/parseMeteorResponse.ts b/apps/meteor/tests/e2e/utils/parseMeteorResponse.ts new file mode 100644 index 0000000..24adc15 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/parseMeteorResponse.ts @@ -0,0 +1,18 @@ +import type { APIResponse } from '@playwright/test'; +import type { Serialized } from '@rocket.chat/core-typings'; + +export const parseMeteorResponse = async (response: APIResponse): Promise> => { + const { message, success } = await response.json(); + + if (!success) { + throw new Error(message); + } + + const { result, error } = JSON.parse(message); + + if (error) { + throw new Error(JSON.stringify(error)); + } + + return result; +}; diff --git a/apps/meteor/tests/e2e/utils/preserveSettings.ts b/apps/meteor/tests/e2e/utils/preserveSettings.ts new file mode 100644 index 0000000..8521fcb --- /dev/null +++ b/apps/meteor/tests/e2e/utils/preserveSettings.ts @@ -0,0 +1,19 @@ +import { getSettingValueById } from './getSettingValueById'; +import { test } from './test'; + +export function preserveSettings(settingsList: string[]) { + const originalSettings: Record = {}; + + test.beforeAll(async ({ api }) => { + const settingValues = await Promise.all(settingsList.map((setting) => getSettingValueById(api, setting))); + settingsList.forEach((setting, index) => { + originalSettings[setting] = settingValues[index]; + }); + }); + + test.afterAll(async ({ api }) => { + await Promise.all(settingsList.map((setting) => api.post(`/settings/${setting}`, { value: originalSettings[setting] }))); + }); + + return originalSettings; +} diff --git a/apps/meteor/tests/e2e/utils/saveSettings.ts b/apps/meteor/tests/e2e/utils/saveSettings.ts new file mode 100644 index 0000000..e78f826 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/saveSettings.ts @@ -0,0 +1,12 @@ +import type { APIResponse } from '@playwright/test'; +import type { ISetting } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; + +export const saveSettings = ( + api: BaseTest['api'], + changes: { + _id: ISetting['_id']; + value: ISetting['value']; + }[], +): Promise => api.post('/settings', { settings: changes }); diff --git a/apps/meteor/tests/e2e/utils/seedTranslatedMessage.ts b/apps/meteor/tests/e2e/utils/seedTranslatedMessage.ts new file mode 100644 index 0000000..6e1f126 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/seedTranslatedMessage.ts @@ -0,0 +1,26 @@ +import { MongoClient } from 'mongodb'; + +import { URL_MONGODB } from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; + +type SeedTranslatedMessageParams = { + rid: string; + msg: string; + author: IUserState; + translations: Record; +}; + +// No translation provider is reachable from CI, so translations are seeded straight into the message. +// The author is reassigned as well because auto-translate skips messages sent by the user reading them. +export const seedTranslatedMessage = async ({ rid, msg, author, translations }: SeedTranslatedMessageParams): Promise => { + const connection = await MongoClient.connect(URL_MONGODB); + + try { + await connection + .db() + .collection('rocketchat_message') + .updateOne({ rid, msg }, { $set: { u: { _id: author.data._id, username: author.data.username }, translations } }); + } finally { + await connection.close(); + } +}; diff --git a/apps/meteor/tests/e2e/utils/sendMessage.ts b/apps/meteor/tests/e2e/utils/sendMessage.ts new file mode 100644 index 0000000..b1e22ad --- /dev/null +++ b/apps/meteor/tests/e2e/utils/sendMessage.ts @@ -0,0 +1,27 @@ +import type { APIRequestContext } from 'playwright-core'; + +import type { BaseTest } from './test'; +import { BASE_API_URL } from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; + +export const sendMessageFromUser = async (request: APIRequestContext, user: IUserState, rid: string, message: string) => { + return request + .post(`${BASE_API_URL}/chat.postMessage`, { + headers: { + 'X-Auth-Token': user.data.loginToken, + 'X-User-Id': user.data._id, + }, + data: { + roomId: rid, + text: message, + }, + }) + .then((response) => response.json()); +}; + +export const sendFillerMessages = async (api: BaseTest['api'], rid: string, count = 50, batchSize = 10) => { + const messages = Array.from({ length: count }, (_, i) => `filler message ${i + 1}`); + for (let i = 0; i < messages.length; i += batchSize) { + await Promise.all(messages.slice(i, i + batchSize).map((text) => api.post('/chat.postMessage', { roomId: rid, text }))); + } +}; diff --git a/apps/meteor/tests/e2e/utils/setSettingValueById.ts b/apps/meteor/tests/e2e/utils/setSettingValueById.ts new file mode 100644 index 0000000..f635933 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/setSettingValueById.ts @@ -0,0 +1,6 @@ +import type { APIResponse } from '@playwright/test'; + +import type { BaseTest } from './test'; + +export const setSettingValueById = (api: BaseTest['api'], settingId: string, value: unknown): Promise => + api.post(`/settings/${settingId}`, { value }); diff --git a/apps/meteor/tests/e2e/utils/setUserPreferences.ts b/apps/meteor/tests/e2e/utils/setUserPreferences.ts new file mode 100644 index 0000000..2e55f22 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/setUserPreferences.ts @@ -0,0 +1,7 @@ +import type { APIResponse } from '@playwright/test'; +import type { UsersSetPreferencesParamsPOST } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export const setUserPreferences = (api: BaseTest['api'], preferences: UsersSetPreferencesParamsPOST['data']): Promise => + api.post(`/users.setPreferences`, { data: preferences }); diff --git a/apps/meteor/tests/e2e/utils/test.ts b/apps/meteor/tests/e2e/utils/test.ts new file mode 100644 index 0000000..98f462f --- /dev/null +++ b/apps/meteor/tests/e2e/utils/test.ts @@ -0,0 +1,175 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +import AxeBuilder from '@axe-core/playwright'; +import type { Locator, APIResponse, APIRequestContext } from '@playwright/test'; +import { test as baseTest, request as baseRequest } from '@playwright/test'; + +import { BASE_API_URL, API_PREFIX, ADMIN_CREDENTIALS } from '../config/constants'; +import { Users } from '../fixtures/userStates'; + +const PATH_NYC_OUTPUT = path.join(process.cwd(), '.nyc_output'); + +export type AnyObj = { [key: string]: any }; + +export type BaseTest = { + api: { + recreateContext(): Promise; + login(credentials: { username: string; password: string }): Promise; + get(uri: string, params?: AnyObj, prefix?: string): Promise; + post(uri: string, data: AnyObj, prefix?: string): Promise; + put(uri: string, data: AnyObj, prefix?: string): Promise; + delete(uri: string, params?: AnyObj, prefix?: string): Promise; + }; + makeAxeBuilder: () => AxeBuilder; +}; +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + collectIstanbulCoverage: (coverageJSON: string) => void; + __coverage__: Record; + } +} + +let apiContext: APIRequestContext; + +const cacheFromCredentials = new Map(); + +export const test = baseTest.extend({ + context: async ({ context }, use) => { + if (!process.env.E2E_COVERAGE) { + await use(context); + await context.close(); + + return; + } + + await context.addInitScript(() => + window.addEventListener('beforeunload', () => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))), + ); + + await fs.promises.mkdir(PATH_NYC_OUTPUT, { recursive: true }); + + await context.exposeFunction('collectIstanbulCoverage', (coverageJSON: string) => { + if (coverageJSON) { + fs.writeFileSync(path.join(PATH_NYC_OUTPUT, `playwright_coverage_${randomUUID()}.json`), coverageJSON); + } + }); + + await use(context); + + await Promise.all( + context.pages().map(async (page) => { + await page.evaluate(() => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))); + await page.close(); + }), + ); + }, + + api: async ({ request }, use) => { + const newContext = async (token: string, userId: string) => + baseRequest.newContext({ + baseURL: BASE_API_URL, + extraHTTPHeaders: { + 'X-Auth-Token': token, + 'X-User-Id': userId, + }, + }); + + const login = async (credentials: { username: string; password: string }): Promise => { + if (credentials.username === Users.admin.data.username) { + return newContext(Users.admin.data.loginToken, Users.admin.data.username); + } + + if (cacheFromCredentials.has(credentials.username + credentials.password)) { + const token = cacheFromCredentials.get(credentials.username + credentials.password); + return newContext(token!, credentials.username); + } + + const resp = await request.post(`${BASE_API_URL}/login`, { data: credentials }); + const json = await resp.json(); + + cacheFromCredentials.set(credentials.username + credentials.password, json.data.authToken); + + return newContext(json.data.authToken, json.data.userId); + }; + + const recreateContext = async () => { + apiContext = await login(ADMIN_CREDENTIALS); + }; + + await recreateContext(); + + await use({ + recreateContext, + login, + get(uri: string, params?: AnyObj, prefix = API_PREFIX) { + return apiContext.get(prefix + uri, { params }); + }, + post(uri: string, data: AnyObj, prefix = API_PREFIX) { + return apiContext.post(prefix + uri, { data }); + }, + put(uri: string, data: AnyObj, prefix = API_PREFIX) { + return apiContext.put(prefix + uri, { data }); + }, + delete(uri: string, params?: AnyObj, prefix = API_PREFIX) { + return apiContext.delete(prefix + uri, { params }); + }, + }); + }, + makeAxeBuilder: async ({ page }, use) => { + const SELECT_KNOW_ISSUES = ['aria-hidden-focus', 'nested-interactive']; + + const makeAxeBuilder = () => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .include('body') + .disableRules([...SELECT_KNOW_ISSUES]); + await use(makeAxeBuilder); + }, +}); + +export const { expect } = test; + +expect.extend({ + async toBeInvalid(received: Locator) { + try { + await expect(received).toHaveAttribute('aria-invalid', 'true'); + + return { + message: () => `expected ${received} to be invalid`, + pass: true, + }; + } catch (error) { + return { + message: () => `expected ${received} to be invalid`, + pass: false, + }; + } + }, + async toBeBusy(received: Locator) { + try { + await expect(received).toHaveAttribute('aria-busy', 'true'); + + return { + message: () => `expected ${received} to be busy`, + pass: true, + }; + } catch (error) { + return { + message: () => `expected ${received} to be busy`, + pass: false, + }; + } + }, + async hasAttribute(received: Locator, attribute: string) { + const pass = await received.evaluate((node, attribute) => node.hasAttribute(attribute), attribute); + + return { + message: () => `expected ${received} to have attribute \`${attribute}\``, + pass, + }; + }, +}); diff --git a/apps/meteor/tests/e2e/utils/updateOwnUserInfo.ts b/apps/meteor/tests/e2e/utils/updateOwnUserInfo.ts new file mode 100644 index 0000000..5344ee5 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/updateOwnUserInfo.ts @@ -0,0 +1,19 @@ +import crypto from 'crypto'; + +import type { APIResponse } from '@playwright/test'; +import type { UsersUpdateOwnBasicInfoParamsPOST } from '@rocket.chat/rest-typings'; + +import type { BaseTest } from './test'; + +export const updateOwnUserPassword = async ( + api: BaseTest['api'], + { newPassword, currentPassword }: { newPassword: string; currentPassword: string }, +): Promise => { + /** + * SHA-256 is used here for client-side password hashing before transmission (2FA verification), not for password storage. The server expects this specific format for password verification. This is a requirement of the Rocket.Chat API for the 2FA flow. */ + const encryptedPassword = crypto.createHash('sha256').update(currentPassword, 'utf8').digest('hex'); + return updateOwnUserInfo(api, { newPassword, currentPassword: encryptedPassword }); +}; + +const updateOwnUserInfo = (api: BaseTest['api'], data: UsersUpdateOwnBasicInfoParamsPOST['data']): Promise => + api.post(`/users.updateOwnBasicInfo`, { data }); diff --git a/apps/meteor/tests/e2e/utils/updatePermissions.ts b/apps/meteor/tests/e2e/utils/updatePermissions.ts new file mode 100644 index 0000000..e6bde8b --- /dev/null +++ b/apps/meteor/tests/e2e/utils/updatePermissions.ts @@ -0,0 +1,7 @@ +import { expect, type BaseTest } from './test'; + +type PermissionUpdate = { _id: string; roles: string[] }; + +export const updatePermissions = async (api: BaseTest['api'], permissions: PermissionUpdate[]): Promise => { + await expect(await api.post('/permissions.update', { permissions })).toBeOK(); +}; diff --git a/apps/meteor/tests/e2e/utils/user-helpers.ts b/apps/meteor/tests/e2e/utils/user-helpers.ts new file mode 100644 index 0000000..10e5e4d --- /dev/null +++ b/apps/meteor/tests/e2e/utils/user-helpers.ts @@ -0,0 +1,135 @@ +import { faker } from '@faker-js/faker'; +import type { APIResponse } from '@playwright/test'; +import type { IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; +import { BASE_URL, DEFAULT_USER_CREDENTIALS } from '../config/constants'; +import type { IUserState } from '../fixtures/userStates'; + +export interface ICreateUserOptions { + username?: string; + email?: string; + name?: string; + password?: string; + roles?: string[]; + data?: Record; +} + +export interface ITestUser { + response: APIResponse; + data: IUser & { username: string }; + deleted: boolean; + delete: () => Promise; + markAsDeleted: () => void; +} + +/** + * Creates a test user with optional customizations + */ +export async function createTestUser(api: BaseTest['api'], options: ICreateUserOptions = {}): Promise { + const userData = { + email: options.email || faker.internet.email(), + name: options.name || faker.person.fullName(), + password: options.password || DEFAULT_USER_CREDENTIALS.password, + username: options.username || `test-user-${faker.string.uuid()}`, + roles: options.roles || ['user'], + ...options.data, + }; + + const response = await api.post('/users.create', userData); + + if (response.status() !== 200) { + throw new Error(`Failed to create user: ${response.status()}, response: ${await response.text()}`); + } + + const { user } = await response.json(); + + return { + response, + data: user, + deleted: false, + markAsDeleted(this: ITestUser) { + this.deleted = true; + }, + async delete(this: ITestUser) { + if (this.deleted) { + return; + } + + const response = await api.post('/users.delete', { userId: user._id }); + this.markAsDeleted(); + return response; + }, + }; +} + +/** + * Logs in a test user via the REST API and returns an IUserState + * suitable for use with createAuxContext. + * + * Use this instead of the pre-baked Users.userN fixtures whenever the test + * will deactivate (or otherwise invalidate the session of) the user, so that + * shared fixture tokens are never corrupted. + */ +export async function loginTestUser(api: BaseTest['api'], user: ITestUser): Promise { + const response = await api.post('/login', { + username: user.data.username, + password: DEFAULT_USER_CREDENTIALS.password, + }); + const { + data: { userId, authToken }, + } = await response.json(); + + const expires = new Date(); + expires.setFullYear(expires.getFullYear() + 1); + + return { + data: { + _id: userId, + username: user.data.username, + loginToken: authToken, + loginExpire: expires, + hashedToken: '', + }, + state: { + cookies: [ + { sameSite: 'Lax', name: 'rc_uid', value: userId, domain: 'localhost', path: '/', expires: -1, httpOnly: false, secure: false }, + { + sameSite: 'Lax', + name: 'rc_token', + value: authToken, + domain: 'localhost', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + }, + ], + origins: [ + { + origin: BASE_URL, + localStorage: [ + { name: 'userLanguage', value: 'en-US' }, + { name: 'Meteor.loginToken', value: authToken }, + { name: 'Meteor.loginTokenExpires', value: expires.toISOString() }, + { name: 'Meteor.userId', value: userId }, + ], + }, + ], + }, + }; +} + +/** + * Creates multiple test users at once + */ +export async function createTestUsers(api: BaseTest['api'], count: number, options: ICreateUserOptions = {}): Promise { + const promises = Array.from({ length: count }, (_, i) => + createTestUser(api, { + ...options, + username: options.username ? `${options.username}-${i + 1}` : undefined, + }), + ); + + return Promise.all(promises); +} diff --git a/apps/meteor/tests/e2e/video-conference-ring.spec.ts b/apps/meteor/tests/e2e/video-conference-ring.spec.ts new file mode 100644 index 0000000..80ebfbf --- /dev/null +++ b/apps/meteor/tests/e2e/video-conference-ring.spec.ts @@ -0,0 +1,51 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel, AccountProfile } from './page-objects'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('video conference ringing', () => { + let poHomeChannel: HomeChannel; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + let auxContext: { page: Page; poHomeChannel: HomeChannel; poAccountProfile: AccountProfile }; + test.beforeEach(async ({ browser }) => { + const { page } = await createAuxContext(browser, Users.user2); + auxContext = { page, poHomeChannel: new HomeChannel(page), poAccountProfile: new AccountProfile(page) }; + }); + + test.afterEach(async () => { + await auxContext.page.close(); + }); + + test('should display call ringing in direct message', async () => { + await poHomeChannel.navbar.openChat('user2'); + + await auxContext.poHomeChannel.navbar.openChat('user1'); + await test.step('should user1 calls user2', async () => { + await poHomeChannel.content.btnVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); + + await expect(poHomeChannel.content.getVideoConfPopup('Calling user2')).toBeVisible(); + await expect(auxContext.poHomeChannel.content.getVideoConfPopup('Incoming call from user1')).toBeVisible(); + + await auxContext.poHomeChannel.content.btnDeclineVideoCall.click(); + }); + + await test.step('should user1 be able to call user2 again ', async () => { + await poHomeChannel.content.videoConfMessageBlock.last().getByRole('button', { name: 'Call again' }).click(); + await expect(poHomeChannel.content.getVideoConfPopup('Start a call with user2')).toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/video-conference.spec.ts b/apps/meteor/tests/e2e/video-conference.spec.ts new file mode 100644 index 0000000..4119caa --- /dev/null +++ b/apps/meteor/tests/e2e/video-conference.spec.ts @@ -0,0 +1,156 @@ +import { IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { + createArchivedChannel, + createTargetChannel, + setUserPreferences, + createTargetTeam, + createDirectMessage, + deleteChannel, + deleteTeam, +} from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('video conference', () => { + test.skip(!IS_EE, 'Premium Only'); + let poHomeChannel: HomeChannel; + let targetChannel: string; + let targetReadOnlyChannel: string; + let targetArchivedChannel: string; + let targetTeam: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + targetReadOnlyChannel = await createTargetChannel(api, { readOnly: true }); + targetArchivedChannel = await createArchivedChannel(api); + targetTeam = await createTargetTeam(api); + await createDirectMessage(api); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + deleteChannel(api, targetChannel), + deleteChannel(api, targetArchivedChannel), + deleteChannel(api, targetReadOnlyChannel), + deleteTeam(api, targetTeam), + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await poHomeChannel.goto(); + }); + + test('should create video conference in targetChannel using keyboard', async ({ page }) => { + await poHomeChannel.navbar.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello video conference'); + await poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE).focus(); + await expect(poHomeChannel.getRoomHeaderFavoriteBtn(IS_EE)).toBeFocused(); + + await test.step('opens video conference popup', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + + await expect(poHomeChannel.content.getVideoConfPopup(`Start a call in ${targetChannel}`)).toBeVisible(); + await expect(poHomeChannel.content.btnVideoConfMic).toBeFocused(); + }); + + await test.step('dispatch start call button', async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Space'); + }); + + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + + test.describe('video conference message block', async () => { + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await setUserPreferences(api, { displayAvatars: false }); + }); + + test.afterAll(async ({ api }) => { + await setUserPreferences(api, { displayAvatars: true }); + }); + + test('should NOT render avatars in video conference message block', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + + await expect(poHomeChannel.content.videoConfMessageBlock.last().getByRole('figure')).toHaveCount(0); + }); + }); + + test.describe('verify if user2 received a invite call in targetChannel', async () => { + test.use({ storageState: Users.user2.state }); + test('should display a message block in a targetChannel', async () => { + await poHomeChannel.navbar.openChat(targetChannel); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + }); + + test('should create video conference in a direct room', async () => { + await poHomeChannel.navbar.openChat('user2'); + + await poHomeChannel.content.btnVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + + test.describe('verify if user received from a direct', async () => { + test.use({ storageState: Users.user2.state }); + test('verify if user received a call invite in direct', async () => { + await poHomeChannel.navbar.openChat('user1'); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + }); + + test('should create video conference in targetTeam', async () => { + await poHomeChannel.navbar.openChat(targetTeam); + + await poHomeChannel.content.btnVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + + test.describe('verify if user2 received from a targetTeam', async () => { + test.use({ storageState: Users.user2.state }); + test('should display a message block in a targetTeam', async () => { + await poHomeChannel.navbar.openChat(targetTeam); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + }); + + test('should create video conference in a direct multiple', async () => { + await poHomeChannel.navbar.openChat('rocketchat.internal.admin.test, user2'); + + await poHomeChannel.content.btnVideoCall.click(); + await poHomeChannel.content.btnStartVideoCall.click(); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + + test.describe('received in a direct multiple', async () => { + test.use({ storageState: Users.user2.state }); + test('should display a message block in a direct multiple', async () => { + await poHomeChannel.navbar.openChat('rocketchat.internal.admin.test, user1'); + await expect(poHomeChannel.content.videoConfMessageBlock.last()).toBeVisible(); + }); + }); + + test('should NOT create video conference in a targetReadOnlyChannel', async () => { + await poHomeChannel.navbar.openChat(targetReadOnlyChannel); + + await expect(poHomeChannel.content.btnVideoCall).toBeDisabled(); + }); + + test('should NOT be able to create video conference in targetArchivedChannel', async () => { + await poHomeChannel.navbar.openChat(targetArchivedChannel); + + await expect(poHomeChannel.content.btnVideoCall).toBeDisabled(); + }); +}); diff --git a/apps/meteor/tests/e2e/voice-calls-ee.spec.ts b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts new file mode 100644 index 0000000..cbbfab3 --- /dev/null +++ b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts @@ -0,0 +1,439 @@ +import type { Page } from '@playwright/test'; + +import { IS_EE } from './config/constants'; +import { createAuxContext } from './fixtures/createAuxContext'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { setSettingValueById } from './utils'; +import { expect, test } from './utils/test'; + +test.describe('Internal Voice Calls - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled', false); + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled', true); + await Promise.all([...sessions.map(({ page }) => page.close())]); + }); + + test('should initiate voice call from direct message', async () => { + const [user1, user2] = sessions; + + await test.step('establish call connection', async () => { + await user1.poHomeChannel.navbar.btnNewVoiceCall.click(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(Users.user2.data.username); + }); + + await test.step('user2 accepts the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user2 ends the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); + + test('should handle call controls during active call', async () => { + const [user1, user2] = sessions; + await test.step('establish call connection', async () => { + await user1.poHomeChannel.navbar.btnNewVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(Users.user2.data.username); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('should mute/unmute microphone from user1', async () => { + await user1.poHomeChannel.voiceCalls.widget.muteSelf(); + + await user1.poHomeChannel.voiceCalls.widget.unmuteSelf(); + }); + + await test.step('should put call on hold from user1', async () => { + await user1.poHomeChannel.voiceCalls.widget.holdSelf(); + + await user1.poHomeChannel.voiceCalls.widget.resumeSelf(); + }); + + await test.step('should access dialpad during call', async () => { + await user1.poHomeChannel.voiceCalls.widget.openDialpad(); + + await user1.poHomeChannel.voiceCalls.widget.closeDialpad(); + }); + + await test.step('should not display screen share button', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.controls.shareScreen).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.controls.shareScreen).not.toBeVisible(); + }); + + await test.step('should end the call from user1', async () => { + await user1.poHomeChannel.voiceCalls.widget.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); + + test('should transfer call to another user', async ({ browser, api }) => { + const [user1, user2] = sessions; + + // Create user3 session only for this test + await api.post('/users.setStatus', { status: 'online', username: 'user3' }); + + const user3Context = await createAuxContext(browser, Users.user3); + const user3 = { page: user3Context.page, poHomeChannel: new HomeChannel(user3Context.page) }; + + await test.step('establish call between user1 and user2', async () => { + await user1.poHomeChannel.navbar.btnNewVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(Users.user2.data.username); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user1 transfers call to user3', async () => { + await user1.poHomeChannel.voiceCalls.widget.transferCall('user3'); + await user1.poHomeChannel.toastMessage.waitForDisplay({ type: 'success' }); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Transferring call.../gi); + }); + + await test.step('user3 receives transferred call', async () => { + await expect(user3.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Incoming call transfer.../gi); + await user3.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user3 ends the call', async () => { + await user3.poHomeChannel.voiceCalls.widget.hangup(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + + await user3.page.close(); + }); + + test('should decline incoming voice call', async () => { + const [user1, user2] = sessions; + + await test.step('user1 initiates call to user2', async () => { + await user1.poHomeChannel.navbar.btnNewVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(Users.user2.data.username); + }); + + await test.step('user2 declines the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.reject(); + }); + + await test.step('Verify call widget disappears', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); +}); + +test.describe('Internal Voice Calls - In-room view - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async () => { + await Promise.all([...sessions.map(({ page }) => page.close())]); + }); + + test('should show in-room view when navigating to peer DM during call', async () => { + const [user1, user2] = sessions; + + await test.step('establish call from user1 DM with user2', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('widget should be hidden and room view should be visible', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + }); + + await test.step('user cards should have correct names', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.peerCard('user1')).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.peerCard('user2')).toBeVisible(); + }); + + await test.step('mute/unmute and hold/resume', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.muteSelf(); + await user1.poHomeChannel.voiceCalls.roomSection.unmuteSelf(); + + await user1.poHomeChannel.voiceCalls.roomSection.holdSelf(); + await user1.poHomeChannel.voiceCalls.roomSection.resumeSelf(); + }); + + await test.step('should change chat visibility', async () => { + // Visible by default + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.otherControls.toggleChat).toHaveAccessibleName(/Hide chat/i); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + // Hide + await user1.poHomeChannel.voiceCalls.roomSection.hideChat(); + await expect(user1.poHomeChannel.composer.inputMessage).not.toBeVisible(); + + // Show + await user1.poHomeChannel.voiceCalls.roomSection.showChat(); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); + + test('should switch views when navigating into and away from peer DM', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and navigate to peer DM', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('navigate user1 away from peer DM', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + const initialRoomViewTimer = await user1.poHomeChannel.voiceCalls.roomSection.getTimerContentInSeconds(); + + await user1.poHomeChannel.navbar.openChat('general'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + + const widgetTimer = await user1.poHomeChannel.voiceCalls.widget.getTimerContentInSeconds(); + expect(widgetTimer).toBeGreaterThanOrEqual(initialRoomViewTimer); + }); + + await test.step('return to peer DM (go to dm button)', async () => { + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + const initialWidgetTimer = await user1.poHomeChannel.voiceCalls.widget.getTimerContentInSeconds(); + + await user1.poHomeChannel.voiceCalls.widget.goToDm(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + + const roomTimer = await user1.poHomeChannel.voiceCalls.roomSection.getTimerContentInSeconds(); + expect(roomTimer).toBeGreaterThanOrEqual(initialWidgetTimer); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); + + // Maybe flaky + test('Screen sharing', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and navigate to peer DM', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + }); + + await test.step('start screen sharing with both users', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.shareScreen(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.widget.shareScreen(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(2); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(2); + }); + + await test.step('stop sharing with user1 through room view', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(1); + }); + + await test.step('stop sharing with user2 through widget', async () => { + await user2.poHomeChannel.voiceCalls.widget.stopSharing(); + + await expect(user1.poHomeChannel.voiceCalls.roomSection.allScreenShareVideos).toHaveCount(0); + await expect(user2.poHomeChannel.voiceCalls.widget.allScreenShareVideos).toHaveCount(0); + }); + + await test.step('end call to clean up', async () => { + await user1.poHomeChannel.voiceCalls.roomSection.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); +}); + +test.describe('Internal Voice Calls - Popout view - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async () => { + await Promise.all([...sessions.map(({ page }) => page.close())]); + }); + + test('should show popout view', async () => { + const [user1, user2] = sessions; + + await test.step('establish call from user1 DM with user2', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('should open popout for both users', async () => { + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user2.poHomeChannel.voiceCalls.openPopoutWidget(); + await expect(user2.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + }); + + await test.step('user cards should have correct names', async () => { + await expect(user1.poHomeChannel.voiceCalls.popout.peerCard('user1')).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.popout.peerCard('user2')).toBeVisible(); + + await expect(user2.poHomeChannel.voiceCalls.popout.peerCard('user1')).toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.popout.peerCard('user2')).toBeVisible(); + }); + + await test.step('mute/unmute and hold/resume', async () => { + await user1.poHomeChannel.voiceCalls.popout.muteSelf(); + await user2.poHomeChannel.voiceCalls.popout.muteSelf(); + + await user1.poHomeChannel.voiceCalls.popout.unmuteSelf(); + await user2.poHomeChannel.voiceCalls.popout.unmuteSelf(); + + await user1.poHomeChannel.voiceCalls.popout.holdSelf(); + await user2.poHomeChannel.voiceCalls.popout.holdSelf(); + + await user1.poHomeChannel.voiceCalls.popout.resumeSelf(); + await user2.poHomeChannel.voiceCalls.popout.resumeSelf(); + }); + + await test.step('Widget "Show call here" button', async () => { + await expect(user2.poHomeChannel.voiceCalls.widget.btnShowCallHere).toBeVisible(); + await user2.poHomeChannel.voiceCalls.widget.showCallHere(); + }); + + await test.step('Room "Show call here" button', async () => { + await expect(user1.poHomeChannel.voiceCalls.roomSection.btnShowCallHere).toBeVisible(); + await user1.poHomeChannel.voiceCalls.roomSection.showCallHere(); + }); + + await test.step('end call from within popup', async () => { + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user1.poHomeChannel.voiceCalls.popout.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); + + // Maybe flaky + test('Screen sharing', async () => { + const [user1, user2] = sessions; + + await test.step('establish call and open popout', async () => { + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).toBeVisible(); + + await user1.poHomeChannel.voiceCalls.openPopoutRoom(); + await expect(user1.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + + await user2.poHomeChannel.voiceCalls.openPopoutWidget(); + await expect(user2.poHomeChannel.voiceCalls.popout.content).toBeVisible(); + }); + + await test.step('start screen sharing with both users', async () => { + await user1.poHomeChannel.voiceCalls.popout.shareScreen(); + + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.popout.shareScreen(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(2); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(2); + }); + + await test.step('stop sharing with both users through popout', async () => { + await user1.poHomeChannel.voiceCalls.popout.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(1); + + await user2.poHomeChannel.voiceCalls.popout.stopSharing(); + await expect(user1.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(0); + await expect(user2.poHomeChannel.voiceCalls.popout.allScreenShareVideos).toHaveCount(0); + }); + + await test.step('end call with user2 from the popout', async () => { + await user2.poHomeChannel.voiceCalls.popout.hangup(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/34-engagement-dashboard.ts b/apps/meteor/tests/end-to-end/api/34-engagement-dashboard.ts new file mode 100644 index 0000000..7b063c9 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/34-engagement-dashboard.ts @@ -0,0 +1,313 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { sendSimpleMessage } from '../../data/chat.helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; + +describe('[Engagement Dashboard]', function () { + this.retries(0); + + const isEnterprise = Boolean(process.env.IS_EE); + + before((done) => getCredentials(done)); + + before(() => updatePermission('view-engagement-dashboard', ['admin'])); + + after(() => updatePermission('view-engagement-dashboard', ['admin'])); + + (isEnterprise ? describe : describe.skip)('[/engagement-dashboard/channels/list]', () => { + let testRoom: IRoom; + let emptyRoom: IRoom; + + before(async () => { + testRoom = (await createRoom({ type: 'c', name: `channel.test.engagement.${Date.now()}-${Math.random()}` })).body.channel; + emptyRoom = (await createRoom({ type: 'c', name: `channel.test.engagement.empty.${Date.now()}-${Math.random()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testRoom._id }); + await deleteRoom({ type: 'c', roomId: emptyRoom._id }); + }); + + it('should fail if user does not have the view-engagement-dashboard permission', async () => { + await updatePermission('view-engagement-dashboard', []); + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date().toISOString(), + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + offset: 0, + count: 25, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should fail if start param is not a valid date', async () => { + await updatePermission('view-engagement-dashboard', ['admin']); + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + start: 'invalid-date', + end: new Date().toISOString(), + offset: 0, + count: 25, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Match error: Failed Match.Where validation in field start'); + }); + }); + + it('should fail if end param is not a valid date', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + end: 'invalid-date', + offset: 0, + count: 25, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Match error: Failed Match.Where validation in field end'); + }); + }); + + it('should fail if start param is not provided', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("Match error: Missing key 'start'"); + }); + }); + + it('should fail if end param is not provided', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("Match error: Missing key 'end'"); + }); + }); + + it('should succesfuly return results', async () => { + await sendSimpleMessage({ roomId: testRoom._id }); + + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date().toISOString(), + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + expect(res.body.channels).to.be.an('array').that.is.not.empty; + + expect(res.body.channels[0]).to.be.an('object').that.is.not.empty; + expect(res.body.channels[0]).to.have.property('messages').that.is.a('number'); + expect(res.body.channels[0]).to.have.property('lastWeekMessages').that.is.a('number'); + expect(res.body.channels[0]).to.have.property('diffFromLastWeek').that.is.a('number'); + expect(res.body.channels[0].room).to.be.an('object').that.is.not.empty; + + expect(res.body.channels[0].room).to.have.property('_id').that.is.a('string'); + expect(res.body.channels[0].room).to.have.property('name').that.is.a('string'); + expect(res.body.channels[0].room).to.have.property('ts').that.is.a('string'); + expect(res.body.channels[0].room).to.have.property('t').that.is.a('string'); + expect(res.body.channels[0].room).to.have.property('_updatedAt').that.is.a('string'); + }); + }); + + it('should not return empty rooms', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date().toISOString(), + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + const channelRecord = res.body.channels.find(({ room }: { room: { _id: string } }) => room._id === emptyRoom._id); + expect(channelRecord).to.be.undefined; + }); + }); + + it('should correctly count messages diff compared to last week when there are messages in a room', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date().toISOString(), + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + expect(res.body.channels).to.be.an('array').that.is.not.empty; + + const channelRecord = res.body.channels.find(({ room }: { room: { _id: string } }) => room._id === testRoom._id); + expect(channelRecord).not.to.be.undefined; + + expect(channelRecord).to.be.an('object').that.is.not.empty; + expect(channelRecord).to.have.property('messages', 1); + expect(channelRecord).to.have.property('lastWeekMessages', 0); + expect(channelRecord).to.have.property('diffFromLastWeek', 1); + expect(channelRecord.room).to.be.an('object').that.is.not.empty; + + expect(channelRecord.room).to.have.property('_id', testRoom._id); + expect(channelRecord.room).to.have.property('name', testRoom.name); + expect(channelRecord.room).to.have.property('ts', testRoom.ts); + expect(channelRecord.room).to.have.property('t', testRoom.t); + }); + }); + + it('should correctly count messages diff compared to last week when there are messages in a room', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date().toISOString(), + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + expect(res.body.channels).to.be.an('array').that.is.not.empty; + + const channelRecord = res.body.channels.find(({ room }: { room: { _id: string } }) => room._id === testRoom._id); + expect(channelRecord).not.to.be.undefined; + + expect(channelRecord).to.be.an('object').that.is.not.empty; + expect(channelRecord).to.have.property('messages', 1); + expect(channelRecord).to.have.property('lastWeekMessages', 0); + expect(channelRecord).to.have.property('diffFromLastWeek', 1); + expect(channelRecord.room).to.be.an('object').that.is.not.empty; + + expect(channelRecord.room).to.have.property('_id', testRoom._id); + expect(channelRecord.room).to.have.property('name', testRoom.name); + expect(channelRecord.room).to.have.property('ts', testRoom.ts); + expect(channelRecord.room).to.have.property('t', testRoom.t); + }); + }); + + it('should correctly count messages from last week and diff when moving to the next week', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(), + start: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + expect(res.body.channels).to.be.an('array').that.is.not.empty; + + const channelRecord = res.body.channels.find(({ room }: { room: { _id: string } }) => room._id === testRoom._id); + expect(channelRecord).not.to.be.undefined; + + expect(channelRecord).to.be.an('object').that.is.not.empty; + expect(channelRecord).to.have.property('messages', 0); + expect(channelRecord).to.have.property('lastWeekMessages', 1); + expect(channelRecord).to.have.property('diffFromLastWeek', -1); + expect(channelRecord.room).to.be.an('object').that.is.not.empty; + + expect(channelRecord.room).to.have.property('_id', testRoom._id); + expect(channelRecord.room).to.have.property('name', testRoom.name); + expect(channelRecord.room).to.have.property('ts', testRoom.ts); + expect(channelRecord.room).to.have.property('t', testRoom.t); + }); + }); + + it('should correctly count messages from last week and diff when moving to the next week', async () => { + await request + .get(api('engagement-dashboard/channels/list')) + .set(credentials) + .query({ + end: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(), + start: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels'); + expect(res.body.channels).to.be.an('array').that.is.not.empty; + + const channelRecord = res.body.channels.find(({ room }: { room: { _id: string } }) => room._id === testRoom._id); + expect(channelRecord).not.to.be.undefined; + + expect(channelRecord).to.be.an('object').that.is.not.empty; + expect(channelRecord).to.have.property('messages', 0); + expect(channelRecord).to.have.property('lastWeekMessages', 1); + expect(channelRecord).to.have.property('diffFromLastWeek', -1); + expect(channelRecord.room).to.be.an('object').that.is.not.empty; + + expect(channelRecord.room).to.have.property('_id', testRoom._id); + expect(channelRecord.room).to.have.property('name', testRoom.name); + expect(channelRecord.room).to.have.property('ts', testRoom.ts); + expect(channelRecord.room).to.have.property('t', testRoom.t); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/LDAP.ts b/apps/meteor/tests/end-to-end/api/LDAP.ts new file mode 100644 index 0000000..156e1c4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/LDAP.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('LDAP', function () { + this.retries(0); + before((done) => getCredentials(done)); + + describe('[/ldap.syncNow]', () => { + it('should throw an error containing totp-required error when not running EE', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (process.env.IS_EE) { + this.skip(); + } + await request + .post(api('ldap.syncNow')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'totp-required'); + }); + }); + + it('should throw an error of LDAP disabled when running EE', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + await request + .post(api('ldap.syncNow')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'LDAP_disabled'); + }); + }); + }); + + describe('[/ldap.testSearch]', () => { + before(async () => { + return updatePermission('test-admin-options', ['admin']); + }); + + after(async () => { + return updatePermission('test-admin-options', ['admin']); + }); + + it('should not allow testing LDAP search if user does NOT have the test-admin-options permission', async () => { + await updatePermission('test-admin-options', []); + await request + .post(api('ldap.testSearch')) + .set(credentials) + .send({ + username: 'test-search', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/ldap.testConnection]', () => { + before(async () => { + return updatePermission('test-admin-options', ['admin']); + }); + + after(async () => { + return updatePermission('test-admin-options', ['admin']); + }); + + it('should not allow testing LDAP connection if user does NOT have the test-admin-options permission', async () => { + await updatePermission('test-admin-options', []); + await request + .post(api('ldap.testConnection')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/SAML.ts b/apps/meteor/tests/end-to-end/api/SAML.ts new file mode 100644 index 0000000..fce4e03 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/SAML.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('SAML', () => { + before((done) => getCredentials(done)); + + describe('[/saml.parseMetadata]', () => { + after(() => updatePermission('test-admin-options', ['admin'])); + + it('should fail without the test-admin-options permission', async () => { + await updatePermission('test-admin-options', []); + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({ url: 'https://example.com/metadata.xml' }) + .expect('Content-Type', 'application/json') + .expect(403); + await updatePermission('test-admin-options', ['admin']); + }); + + it('should reject a body without url', async () => { + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should reject a URL blocked by SSRF protection', async () => { + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({ url: 'http://169.254.169.254/latest/meta-data' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('SAML_Metadata_url_blocked'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts new file mode 100644 index 0000000..82ba8fe --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -0,0 +1,4616 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { api, getCredentials, request, credentials, methodCall } from '../../data/api-data'; +import { sleep } from '../../data/livechat/utils'; +import { + mockServerHealthy, + mockServerReset, + mockServerSet, + mockServerSetMany, + seedBulkDecisionByEntity, + seedDefaultMocks, + seedGetDecisionBulk, + seedGetEntitlements, +} from '../../data/mock-server.helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { deleteTeam } from '../../data/teams.helper'; +import { adminEmail, adminUsername, password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +// NOTE: This manipulates the DB directly to add ABAC attributes to a user +// The idea is to avoid having to go through LDAP to add info to the user +let connection: MongoClient; +const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: IAbacAttributeDefinition[]) => { + await connection.db().collection('users').updateOne( + { + // @ts-expect-error - collection types for _id + _id: userId, + }, + { $set: { abacAttributes } }, + ); +}; + +(IS_EE ? describe : describe.skip)('[ABAC] (Enterprise Only)', function () { + this.retries(0); + + let testRoom: IRoom; + let unauthorizedUser: IUser; + let unauthorizedCredentials: Credentials; + + const initialKey = `attr_${Date.now()}`; + const updatedKey = `${initialKey}_renamed`; + const anotherKey = `${initialKey}_another`; + let attributeId: string; + let page1AttributeIds: string[] = []; + + before((done) => getCredentials(done)); + + before(async () => { + connection = await MongoClient.connect(URL_MONGODB); + + await Promise.all([ + updatePermission('abac-management', ['admin']), + updatePermission('manage-abac-admin-settings', ['admin']), + updatePermission('manage-abac-admin-room-attributes', ['admin']), + updatePermission('manage-abac-admin-rooms', ['admin']), + updatePermission('view-abac-admin-audit', ['admin']), + ]); + await updateSetting('ABAC_Enabled', true); + + testRoom = (await createRoom({ type: 'p', name: `abac-test-${Date.now()}` })).body.group; + + unauthorizedUser = await createUser(); + unauthorizedCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: testRoom._id }); + await deleteUser(unauthorizedUser); + await updateSetting('ABAC_Enabled', false); + + await connection.close(); + }); + + const v1 = '/api/v1'; + + describe('Permission & Authentication', () => { + it('GET /api/v1/abac/attributes should return 401 when not authenticated', async () => { + await request.get(`${v1}/abac/attributes`).expect(401); + }); + + it('POST /api/v1/abac/attributes should return 403 for user without abac-management permission', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(unauthorizedCredentials) + .send({ key: 'nokey', values: ['v1'] }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('Granular admin permissions (with abac-management, missing the granular)', () => { + const dummyKey = 'granular_perm_dummy_key'; + const dummyId = 'granular_perm_dummy_id'; + + after(async () => { + await Promise.all([ + updatePermission('manage-abac-admin-settings', ['admin']), + updatePermission('manage-abac-admin-room-attributes', ['admin']), + updatePermission('manage-abac-admin-rooms', ['admin']), + updatePermission('view-abac-admin-audit', ['admin']), + ]); + }); + + describe('without manage-abac-admin-rooms', () => { + before(async () => { + await updatePermission('manage-abac-admin-rooms', []); + }); + + after(async () => { + await updatePermission('manage-abac-admin-rooms', ['admin']); + }); + + it('POST /abac/rooms/:rid/attributes should return 403', async () => { + await request.post(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).send({ attributes: {} }).expect(403); + }); + + it('DELETE /abac/rooms/:rid/attributes should return 403', async () => { + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).expect(403); + }); + + it('POST /abac/rooms/:rid/attributes/:key should return 403', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(403); + }); + + it('PUT /abac/rooms/:rid/attributes/:key should return 403', async () => { + await request + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(403); + }); + + it('DELETE /abac/rooms/:rid/attributes/:key should return 403', async () => { + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`).set(credentials).expect(403); + }); + + it('GET /abac/rooms should return 403', async () => { + await request.get(`${v1}/abac/rooms`).set(credentials).expect(403); + }); + }); + + describe('without manage-abac-admin-room-attributes', () => { + before(async () => { + await updatePermission('manage-abac-admin-room-attributes', []); + }); + + after(async () => { + await updatePermission('manage-abac-admin-room-attributes', ['admin']); + }); + + it('GET /abac/attributes should return 403', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).expect(403); + }); + + it('POST /abac/attributes should return 403', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: dummyKey, values: ['v1'] }) + .expect(403); + }); + + it('PUT /abac/attributes/:_id should return 403', async () => { + await request.put(`${v1}/abac/attributes/${dummyId}`).set(credentials).send({ key: dummyKey }).expect(403); + }); + + it('GET /abac/attributes/:_id should return 403', async () => { + await request.get(`${v1}/abac/attributes/${dummyId}`).set(credentials).expect(403); + }); + + it('DELETE /abac/attributes/:_id should return 403', async () => { + await request.delete(`${v1}/abac/attributes/${dummyId}`).set(credentials).expect(403); + }); + + it('GET /abac/attributes/:key/is-in-use should return 403', async () => { + await request.get(`${v1}/abac/attributes/${dummyKey}/is-in-use`).set(credentials).expect(403); + }); + + it('POST /abac/users/sync should return 403', async () => { + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ usernames: ['x'] }) + .expect(403); + }); + }); + + describe('without manage-abac-admin-settings', () => { + before(async () => { + await updatePermission('manage-abac-admin-settings', []); + }); + + after(async () => { + await updatePermission('manage-abac-admin-settings', ['admin']); + }); + + it('GET /abac/pdp/health should return 403', async () => { + await request.get(`${v1}/abac/pdp/health`).set(credentials).expect(403); + }); + }); + + describe('without view-abac-admin-audit', () => { + before(async () => { + await updatePermission('view-abac-admin-audit', []); + }); + + after(async () => { + await updatePermission('view-abac-admin-audit', ['admin']); + }); + + it('GET /abac/audit should return 403', async () => { + await request.get(`${v1}/abac/audit`).set(credentials).expect(403); + }); + }); + }); + }); + + describe('Attribute Definition - Validations & CRUD', () => { + it('POST should fail with invalid key pattern (space)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: 'invalid key with space', values: ['one'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should fail with duplicate values', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: anotherKey, values: ['dup', 'dup'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should fail with empty values array', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${anotherKey}_empty`, values: [] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should create a valid attribute definition', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: initialKey, values: ['red', 'green', 'blue'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('POST should allow more than 10 values for an attribute definition', async () => { + const manyValues = ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12']; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${anotherKey}_many_values`, values: manyValues }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: `${anotherKey}_many_values` }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const found = res.body.attributes.find((a: any) => a.key === `${anotherKey}_many_values`); + expect(found).to.exist; + expect(found).to.have.property('values').that.deep.equals(manyValues); + }); + }); + + it('POST should fail creating duplicate key', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: initialKey, values: ['another'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('GET should list attributes including the created one', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: initialKey }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const found = res.body.attributes.find((a: any) => a.key === initialKey); + expect(found).to.exist; + expect(found).to.have.property('_id'); + attributeId = found._id; + }); + }); + + it('GET should paginate attributes (page 1)', async () => { + const paginationBase = `pg_${Date.now()}`; + await Promise.all( + ['a', 'b', 'c'].map((suffix) => + request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${paginationBase}_${suffix}`, values: ['one'] }) + .expect(200), + ), + ); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 2, offset: 0 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4); + expect(res.body).to.have.property('attributes').that.is.an('array').with.lengthOf(2); + page1AttributeIds = res.body.attributes.map((a: any) => a._id); + }); + }); + + it('GET should paginate attributes (page 2)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 2, offset: 2 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 2); + expect(res.body).to.have.property('count').that.is.a('number'); + expect(res.body.count).to.be.at.most(2); + expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const page2Ids = res.body.attributes.map((a: any) => a._id); + page2Ids.forEach((id: string) => { + expect(page1AttributeIds).to.not.include(id); + }); + }); + }); + + it('GET by id should retrieve attribute definition', async () => { + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('key', initialKey); + expect(res.body).to.have.property('values').that.is.an('array'); + }); + }); + + it('PUT should fail with empty body (needs key or values)', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('PUT should update values only', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({ values: ['cyan', 'magenta', 'yellow'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.values).to.deep.equal(['cyan', 'magenta', 'yellow']); + }); + }); + + it('PUT should allow updating an attribute definition to have more than 10 values', async () => { + const manyValues = ['cyan', 'magenta', 'yellow', 'w4', 'w5', 'w6', 'w7', 'w8', 'w9', 'w10', 'w11', 'w12']; + + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({ values: manyValues }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.values).to.deep.equal(manyValues); + }); + }); + + it('PUT should update key only', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({ key: updatedKey }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('key', updatedKey); + }); + }); + }); + + describe('Room Attribute Operations', () => { + it('GET is-in-use should initially be false (no room usage yet)', async () => { + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inUse', false); + }); + }); + + it('POST room attribute should fail with duplicate values', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['dup', 'dup'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST room attribute should add values and reflect inUse=true', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['cyan'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // inUse endpoint should now be true + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + }); + + it('should throw an error when trying to audit the messages of an abac managed room', async () => { + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: '', + msg: 'test1234', + startDate: { $date: new Date() }, + endDate: { $date: new Date() }, + rid: testRoom._id, + users: [], + }, + ], + id: '14', + msg: 'method', + }), + }) + .expect(400) + .expect((res) => { + const result = JSON.parse(res.body.message); + expect(result).to.have.property('error'); + expect(result.error).to.have.property('error', `Room doesn't exist`); + }); + }); + + describe('Audit messages by user and ABAC-managed rooms', () => { + let auditUser: IUser; + let auditUserCreds: Credentials; + let auditRoom: IRoom; + const auditAttrKey = `audit_attr_${Date.now()}`; + let startDate: Date; + let endDate: Date; + + before(async () => { + startDate = new Date(); + endDate = new Date(startDate.getTime() + 1000 * 60); + + auditUser = await createUser(); + auditUserCreds = await login(auditUser.username, password); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: auditAttrKey, values: ['v1'] }) + .expect(200); + + await addAbacAttributesToUserDirectly(auditUser._id, [{ key: auditAttrKey, values: ['v1'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: auditAttrKey, values: ['v1'] }]); + + const roomRes = await createRoom({ type: 'p', name: `abac-audit-user-room-${Date.now()}`, members: [auditUser.username!] }); + auditRoom = roomRes.body.group as IRoom; + + await request + .post(`${v1}/abac/rooms/${auditRoom._id}/attributes/${auditAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: auditRoom._id }); + await deleteUser(auditUser); + }); + + it("should return no messages when auditing a user that's part of an ABAC-managed room", async () => { + await request + .post(`${v1}/chat.sendMessage`) + .set(auditUserCreds) + .send({ message: { rid: auditRoom._id, msg: 'audit message in abac room' } }) + .expect(200); + + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: 'u', + msg: 'audit message in abac room', + startDate: { $date: startDate }, + endDate: { $date: endDate }, + rid: '', + users: [auditUser.username], + visitor: '', + agent: '', + }, + ], + id: 'abac-audit-1', + msg: 'method', + }), + }) + .expect(200) + .expect((res) => { + const parsed = JSON.parse(res.body.message); + expect(parsed).to.have.property('result'); + expect(parsed.result).to.be.an('array').that.is.empty; + }); + }); + + it('should return no messages when auditing a user that WAS part of an ABAC-managed room', async () => { + await request + .post(`${v1}/chat.sendMessage`) + .set(auditUserCreds) + .send({ message: { rid: auditRoom._id, msg: 'audit message before removal' } }) + .expect(200); + + await request.post(`${v1}/groups.kick`).set(credentials).send({ roomId: auditRoom._id, username: auditUser.username }).expect(200); + + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: 'u', + msg: 'audit message before removal', + startDate: { $date: startDate }, + endDate: { $date: endDate }, + rid: '', + users: [auditUser.username], + visitor: '', + agent: '', + }, + ], + id: 'abac-audit-2', + msg: 'method', + }), + }) + .expect(200) + .expect((res) => { + const parsed = JSON.parse(res.body.message); + expect(parsed).to.have.property('result'); + expect(parsed.result).to.be.an('array').that.is.empty; + }); + }); + + it("should return messages when auditing a user that is part of a room that's no longer ABAC-managed", async () => { + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: auditRoom._id, usernames: [auditUser.username] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${auditRoom._id}/attributes/${auditAttrKey}`).set(credentials).expect(200); + + await request + .post(`${v1}/chat.sendMessage`) + .set(auditUserCreds) + .send({ message: { rid: auditRoom._id, msg: 'audit message after room no longer abac' } }) + .expect(200); + + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: 'u', + msg: 'audit message after room no longer abac', + startDate: { $date: startDate }, + endDate: { $date: endDate }, + rid: '', + users: [auditUser.username], + visitor: '', + agent: '', + }, + ], + id: 'abac-audit-3', + msg: 'method', + }), + }) + .expect(200) + .expect((res) => { + const parsed = JSON.parse(res.body.message); + expect(parsed).to.have.property('result'); + expect(parsed.result).to.be.an('array').with.lengthOf.greaterThan(0); + }); + }); + }); + + it('PUT room attribute should replace values and keep inUse=true', async () => { + await request + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['magenta', 'yellow'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // inUse should remain true + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + }); + + describe('DELETE room attribute key (dedicated room)', () => { + let dedicatedRoom: IRoom; + + before(async () => { + dedicatedRoom = (await createRoom({ type: 'p', name: `abac-dedicated-${Date.now()}` })).body.group; + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: dedicatedRoom._id }); + }); + + it('should succeed and remove only the specified key (key-removed)', async () => { + const key1 = `${initialKey}_room1`; + const key2 = `${initialKey}_room2`; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: key1, values: ['value1'] }) + .expect(200); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: key2, values: ['value2'] }) + .expect(200); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [ + { key: key1, values: ['value1'] }, + { key: key2, values: ['value2'] }, + ]); + + await request + .post(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [key1]: ['value1'], [key2]: ['value2'] } }) + .expect(200); + + await request + .delete(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes/${key1}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roomRes = await request.get(`${v1}/rooms.info?roomId=${dedicatedRoom._id}`).set(credentials); + expect(roomRes.status).to.equal(200); + const abacAttrs = roomRes.body.room?.abacAttributes || []; + expect(abacAttrs).to.be.an('array').that.has.lengthOf(1); + expect(abacAttrs[0]).to.have.property('key', key2); + }); + }); + + it('DELETE room attribute key should succeed and clear usage/inUse=false', async () => { + await request + .delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.false; + }); + }); + + it('DELETE all room attributes should succeed even if ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .delete(`${v1}/abac/rooms/${testRoom._id}/attributes`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await updateSetting('ABAC_Enabled', true); + }); + }); + + describe('Default and Team Default Room Restrictions', () => { + let privateDefaultRoomId: string; + let teamId: string; + let teamPrivateRoomId: string; + let teamDefaultRoomId: string; + const localAbacKey = `default_team_test_${Date.now()}`; + let mainRoomIdSaveSettings: string; + const teamName = `abac-team-${Date.now()}`; + const teamNameMainRoom = `abac-team-main-save-settings-${Date.now()}`; + + before('create team main room for rooms.saveRoomSettings default restriction test', async () => { + const createTeamMain = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameMainRoom, type: 1 }) + .expect(200); + + mainRoomIdSaveSettings = createTeamMain.body.team?.roomId; + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: true }).expect(200); + }); + + before('create local ABAC attribute definition for tests', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: localAbacKey, values: ['red', 'green'] }) + .expect(200); + }); + + before('create private room and try to set it as default', async () => { + const res = await createRoom({ + type: 'p', + name: `abac-default-room-${Date.now()}`, + }); + privateDefaultRoomId = res.body.group._id; + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: true }).expect(200); + }); + + before('create private team, private room inside it and set as team default', async () => { + const createTeamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200); + teamId = createTeamRes.body.team._id; + + const roomRes = await createRoom({ + type: 'p', + name: `abac-team-room-${Date.now()}`, + extraData: { teamId }, + }); + teamPrivateRoomId = roomRes.body.group._id; + + const setDefaultRes = await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it + .send({ teamId, roomId: teamPrivateRoomId, isDefault: true }) + .expect(200); + + if (setDefaultRes.body?.room?.teamDefault) { + teamDefaultRoomId = teamPrivateRoomId; + } + }); + + it('should fail adding ABAC attribute to private default room', async () => { + await request + .post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + }); + + it('should fail adding ABAC attribute to team default private room', async () => { + await request + .post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + }); + + it('should allow adding ABAC attribute after removing default flag from private room', async () => { + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: false }).expect(200); + + await request + .post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should allow adding ABAC attribute after removing team default flag', async () => { + await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it + .send({ teamId, roomId: teamDefaultRoomId, isDefault: false }) + .expect(200); + + await request + .post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['green'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should enforce restriction on team main room when default using rooms.saveRoomSettings', async () => { + await request + .post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: false }).expect(200); + + await request + .post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('DELETE attribute definition should remove the key (key-removed)', async () => { + const testKey = `${initialKey}_delete_test`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: testKey, values: ['del1', 'del2'] }) + .expect(200); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: testKey }).set(credentials).expect(200); + + const attr = listRes.body.attributes.find((a: any) => a.key === testKey); + expect(attr).to.exist; + const attrId = attr._id; + + await request + .delete(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-attribute-not-found'); + }); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: privateDefaultRoomId }); + await deleteTeam(credentials, teamName); + await deleteTeam(credentials, teamNameMainRoom); + }); + }); + + describe('Usage & Deletion', () => { + it('POST add room usage for attribute (re-add after clearing) and expect delete while in use to fail', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['cyan'] }) + .expect(200); + + // Attempt to delete attribute while in use should fail + await request + .delete(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-attribute-in-use'); + }); + }); + + it('GET is-in-use should reflect usage true before clearing, then false after removal', async () => { + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + + // Remove room attribute again + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`).set(credentials).expect(200); + + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.false; + }); + }); + + it('DELETE attribute definition should now succeed (no room usage)', async () => { + await request + .delete(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('ABAC Managed Room Default Conversion Restrictions', () => { + const conversionAttrKey = `conversion_test_${Date.now()}`; + const teamName = `abac-conversion-team-${Date.now()}`; + let abacRoomId: string; + let teamIdForConversion: string; + let teamRoomId: string; + + before('create attribute definition and ABAC-managed private room', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: conversionAttrKey, values: ['alpha', 'beta'] }) + .expect(200); + + const roomRes = await createRoom({ + type: 'p', + name: `abac-conversion-room-${Date.now()}`, + }); + abacRoomId = roomRes.body.group._id; + + await request + .post(`${v1}/abac/rooms/${abacRoomId}/attributes/${conversionAttrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + before('create team, private room inside, add ABAC attribute', async () => { + // Public team + const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200); + teamIdForConversion = teamRes.body.team._id; + + const teamRoomRes = await createRoom({ + type: 'p', + name: `abac-team-conversion-room-${Date.now()}`, + extraData: { teamId: teamIdForConversion }, + }); + teamRoomId = teamRoomRes.body.group._id; + + await request + .post(`${v1}/abac/rooms/${teamRoomId}/attributes/${conversionAttrKey}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteTeam(credentials, teamName), deleteRoom({ type: 'p', roomId: abacRoomId })]); + }); + + it('should fail converting ABAC-managed private room into default room', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: abacRoomId, default: true }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('Setting an ABAC managed room as default is not allowed [error-action-not-allowed]'); + }); + }); + + it('should fail converting ABAC-managed team room into team default room', async () => { + await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it + .send({ teamId: teamIdForConversion, roomId: teamRoomId, isDefault: true }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-room-is-abac-managed'); + }); + }); + }); + + describe('Extended Validations & Edge Cases', () => { + let secondAttributeId: string; + const firstKey = `${initialKey}_first`; + const secondKey = `${initialKey}_second`; + const bulkAttrPrefix = `bulk_${Date.now()}`; + const tempKeyForPattern = `Tmp${Date.now()}`; + const invalidCharKey = `bad*key`; + const invalidCharValue = `bad*value`; + const randomId = 'nonExistingId1234567890'; + + it('POST should create a second attribute definition for conflict tests', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: secondKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: secondKey }) + .expect(200) + .expect((res) => { + const found = res.body.attributes.find((a: any) => a.key === secondKey); + expect(found).to.exist; + secondAttributeId = found._id; + }); + }); + + it('POST should create an attribute definition for conflicts', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: firstKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: firstKey }) + .expect(200) + .expect((res) => { + const found = res.body.attributes.find((a: any) => a.key === firstKey); + expect(found).to.exist; + }); + }); + + it('PUT attribute should fail when renaming to an existing key (duplicate key)', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ key: firstKey }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + }); + }); + + it('PUT attribute should fail when values array has duplicates', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ values: ['alpha', 'alpha'] }) + .expect(400); + }); + + it('PUT attribute should fail when values array empty', async () => { + await request.put(`${v1}/abac/attributes/${secondAttributeId}`).set(credentials).send({ values: [] }).expect(400); + }); + + it('GET attribute by invalid/non-existing id should fail', async () => { + await request.get(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400); + }); + + it('DELETE attribute by invalid/non-existing id should fail', async () => { + await request.delete(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400); + }); + + it('PUT attribute by invalid/non-existing id should fail', async () => { + await request + .put(`${v1}/abac/attributes/${randomId}`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_X` }) + .expect(400); + }); + + it('POST attribute should fail with invalid key pattern (special char)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: invalidCharKey, values: ['ok'] }) + .expect(400); + }); + + it('POST attribute should fail with invalid value pattern (special char in value)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_values`, values: [invalidCharValue] }) + .expect(400); + }); + + it('POST attribute should succeed with exactly 10 values (boundary)', async () => { + const tenValues = Array.from({ length: 10 }, (_, i) => `b${i}`); + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_maxvals`, values: tenValues }) + .expect(200); + }); + + it('GET attributes with count=100 should succeed (boundary)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 100 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('count'); + }); + }); + + it('GET attributes should fail with negative offset', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).query({ offset: -1 }).expect(400); + }); + + it('GET attributes should fail with unexpected extra query parameter', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).query({ extraneous: 'param' }).expect(400); + }); + + it('GET attributes should fail with invalid value pattern in values filter', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ values: [invalidCharValue] }) + .expect(400); + }); + + it('GET attributes should filter by values when valid (expect subset or empty)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ values: 'magenta' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + describe('Room attribute bulk operations validations', () => { + it('POST bulk room attributes should fail when more than 10 distinct attribute keys provided', async () => { + const tooMany: Record = {}; + for (let i = 0; i < 11; i++) tooMany[`${bulkAttrPrefix}_${i}`] = ['v1']; + await request.post(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).send({ attributes: tooMany }).expect(400); + }); + + it('POST bulk room attributes should fail when one attribute has >10 values', async () => { + const bigValues = Array.from({ length: 11 }, (_, i) => `v${i}`); + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [`${bulkAttrPrefix}_k1`]: bigValues } }) + .expect(400); + }); + + it('POST bulk room attributes should fail when using a value not in attribute definition', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [secondKey]: ['alpha', 'delta'] } }) + .expect(400); + }); + + it('POST bulk room attributes should succeed when providing valid subset for existing definitions', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) + .set(credentials) + .send({ + attributes: { + [secondKey]: ['alpha', 'beta'], + }, + }) + .expect(200); + }); + }); + + describe('Room attribute key/value validation edge cases', () => { + it('POST single room attribute should fail with >10 values', async () => { + const eleven = Array.from({ length: 11 }, (_, i) => `x${i}`); + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: eleven }) + .expect(400); + }); + + it('POST single room attribute should fail when value not allowed by definition', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha', 'zzz'] }) + .expect(400); + }); + + it('PUT single room attribute should fail with value not in definition', async () => { + await request + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['gamma', 'invalid'] }) + .expect(400); + }); + }); + + describe('Room attribute limits (max 10 attribute keys)', () => { + it('Reset room attributes before limit test and populate with 10 keys', async () => { + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).expect(200); + + const timestamp = Date.now(); + const keys = Array.from({ length: 10 }, (_, i) => `limitk_${timestamp}_${i}`); + await Promise.all( + keys.map((k) => + request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: k, values: ['v1'] }) + .expect(200) + .then(() => + request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${k}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200), + ), + ), + ); + }); + + it('Adding an 11th attribute key to room should fail', async () => { + const extraKey = `limitk_extra_${Date.now()}`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: extraKey, values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${extraKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(400); + }); + }); + + describe('Permission & Auth extended checks', () => { + it('POST /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(unauthorizedCredentials) + .send({ values: ['alpha'] }) + .expect(403); + }); + + it('PUT /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(unauthorizedCredentials) + .send({ values: ['alpha'] }) + .expect(403); + }); + + it('DELETE /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`).set(unauthorizedCredentials).expect(403); + }); + + it('GET /abac/attributes/:key/is-in-use should return 403 for unauthorized user', async () => { + await request.get(`${v1}/abac/attributes/${secondKey}/is-in-use`).set(unauthorizedCredentials).expect(403); + }); + }); + + describe('ABAC Disabled behavior for protected endpoints', () => { + before(async () => { + await updateSetting('ABAC_Enabled', false); + }); + + it('POST /abac/attributes should fail with error-abac-not-enabled', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `disabled_${Date.now()}`, values: ['one'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('PUT /abac/attributes/:_id should fail while disabled', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('POST /abac/rooms/:rid/attributes/:key should fail while disabled', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('PUT /abac/rooms/:rid/attributes/:key should fail while disabled', async () => { + await request + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('POST /abac/rooms/:rid/attributes (bulk replace) should fail while disabled', async () => { + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [secondKey]: ['alpha'] } }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('DELETE /abac/rooms/:rid/attributes/:key succeeds while disabled (endpoint has no enabled-check)', async () => { + await request + .delete(`${v1}/abac/rooms/${testRoom._id}/attributes/never_added_${Date.now()}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('POST /abac/users/sync should fail with error-abac-not-enabled', async () => { + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ ids: ['no-such-user-id'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', true); + }); + }); + + describe('ABAC Room Type Conversion', () => { + const attrKey = `type_conversion_${Date.now()}`; + + let roomNoAttr: string; + let roomWithAttr: string; + let roomWithAttrAbacDisabled: string; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: attrKey, values: ['val1', 'val2'] }) + .expect(200); + + roomNoAttr = (await createRoom({ type: 'p', name: `abac-type-room-no-attr-${Date.now()}` })).body.group._id; + + roomWithAttr = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-${Date.now()}` })).body.group._id; + await request + .post(`${v1}/abac/rooms/${roomWithAttr}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val1'] }) + .expect(200); + + roomWithAttrAbacDisabled = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-disabled-${Date.now()}` })).body.group + ._id; + await request + .post(`${v1}/abac/rooms/${roomWithAttrAbacDisabled}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val2'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', false); + await Promise.all([ + deleteRoom({ type: 'c', roomId: roomNoAttr }), + deleteRoom({ type: 'p', roomId: roomWithAttr }), + deleteRoom({ type: 'c', roomId: roomWithAttrAbacDisabled }), + ]); + }); + + it('should convert private room without ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomNoAttr, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should fail converting ABAC managed private room to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomWithAttr, roomType: 'c' }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed'); + }); + }); + + it('should allow converting ABAC managed private room to public when ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomWithAttrAbacDisabled, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should strip abacAttributes when converting private room to public while ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', true); + const roomId = (await createRoom({ type: 'p', name: `abac-type-strip-${Date.now()}` })).body.group._id; + await request + .post(`${v1}/abac/rooms/${roomId}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val1'] }) + .expect(200); + + await updateSetting('ABAC_Enabled', false); + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: roomId, roomType: 'c' }).expect(200); + + const roomRes = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid: roomId }).expect(200); + expect(roomRes.body).to.not.have.property('abacAttributes'); + + await deleteRoom({ type: 'c', roomId }); + }); + + it('should keep abacAttributes when room stays private while ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', true); + const roomId = (await createRoom({ type: 'p', name: `abac-type-keep-${Date.now()}` })).body.group._id; + await request + .post(`${v1}/abac/rooms/${roomId}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val2'] }) + .expect(200); + + await updateSetting('ABAC_Enabled', false); + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: roomId, roomTopic: 'still private' }).expect(200); + + const roomRes = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid: roomId }).expect(200); + expect(roomRes.body).to.have.property('abacAttributes').that.is.an('array').with.lengthOf(1); + expect(roomRes.body.abacAttributes[0]).to.have.property('key', attrKey); + + await deleteRoom({ type: 'p', roomId }); + }); + }); + + describe('ABAC Team Type Conversion', () => { + const attrKeyTeam = `team_type_conversion_${Date.now()}`; + const teamNameWithAttr = `abac-team-with-attr-${Date.now()}`; + const teamNameWithAttrAbacDisabled = `abac-team-with-attr-disabled-${Date.now()}`; + + let teamNameNoAttr: string; + let mainRoomIdNoAttr: string; + let mainRoomIdWithAttr: string; + let mainRoomIdWithAttrAbacDisabled: string; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: attrKeyTeam, values: ['alpha', 'beta'] }) + .expect(200); + + teamNameNoAttr = `abac-team-no-attr-${Date.now()}`; + const teamNoAttrRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamNameNoAttr, type: 1 }).expect(200); + mainRoomIdNoAttr = teamNoAttrRes.body.team.roomId; + + const teamWithAttrRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameWithAttr, type: 1 }) + .expect(200); + mainRoomIdWithAttr = teamWithAttrRes.body.team.roomId; + await request + .post(`${v1}/abac/rooms/${mainRoomIdWithAttr}/attributes/${attrKeyTeam}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + + const teamWithAttrDisRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameWithAttrAbacDisabled, type: 1 }) + .expect(200); + mainRoomIdWithAttrAbacDisabled = teamWithAttrDisRes.body.team.roomId; + await request + .post(`${v1}/abac/rooms/${mainRoomIdWithAttrAbacDisabled}/attributes/${attrKeyTeam}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', false); + await Promise.all([ + deleteTeam(credentials, teamNameNoAttr), + deleteTeam(credentials, teamNameWithAttr), + deleteTeam(credentials, teamNameWithAttrAbacDisabled), + ]); + }); + + it('should convert private team (main room) without ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdNoAttr, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should fail converting private team (main room) with ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdWithAttr, roomType: 'c' }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + // Ideally this should say "Changing an ABAC managed private team to public is not allowed" but the room check is done before the team check + // And it fails there + expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed'); + }); + }); + + it('should allow converting private team (main room) with ABAC attributes to public when ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdWithAttrAbacDisabled, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + + const roomRes = await request.get(`${v1}/channels.info?roomId=${mainRoomIdWithAttrAbacDisabled}`).set(credentials).expect(200); + expect(roomRes.body.channel).to.not.have.property('abacAttributes'); + }); + }); + + describe('Invite links & ABAC management', () => { + const inviteAttrKey = `invite_attr_${Date.now()}`; + const validateAttrKey = `invite_val_attr_${Date.now()}`; + let managedRoomId: string; + let plainRoomId: string; + let plainRoomInviteToken: string; + const createdInviteIds: string[] = []; + + before(async () => { + await updatePermission('create-invite-links', ['admin']); + await updateSetting('ABAC_Enabled', true); + }); + + it('should create an invite link for a private room without ABAC attributes when ABAC is enabled', async () => { + const plainRoom = (await createRoom({ type: 'p', name: `invite-plain-${Date.now()}` })).body.group; + plainRoomId = plainRoom._id; + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: plainRoomId, days: 1, maxUses: 0 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid', plainRoomId); + expect(res.body).to.have.property('days', 1); + expect(res.body).to.have.property('maxUses', 0); + plainRoomInviteToken = res.body._id; + createdInviteIds.push(plainRoomInviteToken); + }); + }); + + it('validateInviteToken should return valid=true for token from non-ABAC managed room', async () => { + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', true); + }); + }); + + it('validateInviteToken should return valid=false for random invalid token', async () => { + await request + .post(`${v1}/validateInviteToken`) + .send({ token: 'invalid123' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }); + }); + + it('validateInviteToken should return valid=false after room becomes ABAC managed', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: validateAttrKey, values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: validateAttrKey, values: ['one'] }]); + + await request + .post(`${v1}/abac/rooms/${plainRoomId}/attributes/${validateAttrKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }); + }); + + it('validateInviteToken should return valid=true again after disabling ABAC', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', true); + }); + + await updateSetting('ABAC_Enabled', true); + }); + + it('should fail creating an invite link for an ABAC managed room while ABAC is enabled', async () => { + const managedRoom = (await createRoom({ type: 'p', name: `invite-managed-${Date.now()}` })).body.group; + managedRoomId = managedRoom._id; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: inviteAttrKey, values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: inviteAttrKey, values: ['one'] }]); + + await request + .post(`${v1}/abac/rooms/${managedRoomId}/attributes/${inviteAttrKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: managedRoomId, days: 1, maxUses: 0 }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + expect(res.body).to.have.property('error').that.includes('Room is ABAC managed'); + }); + }); + + it('should allow creating an invite link for previously ABAC managed room after disabling ABAC', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: managedRoomId, days: 1, maxUses: 0 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid', managedRoomId); + createdInviteIds.push(res.body._id); + }); + }); + + after(async () => { + await Promise.all(createdInviteIds.map((id) => request.delete(`${v1}/removeInvite/${id}`).set(credentials))); + + await deleteRoom({ type: 'p', roomId: plainRoomId }); + await deleteRoom({ type: 'p', roomId: managedRoomId }); + }); + }); + }); + + describe('POST /abac/users/sync (strategy-agnostic)', () => { + before(async () => { + await updateSetting('ABAC_Enabled', true); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', false); + }); + + it('responds 200 with success:true when ABAC_Enabled=true and PDP type=local (no-match id)', async () => { + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ ids: ['no-such-user-id'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('Room access (invite, addition)', () => { + let roomWithoutAttr: IRoom; + let roomWithAttr: IRoom; + const accessAttrKey = `access_attr_${Date.now()}`; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: accessAttrKey, values: ['v1'] }) + .expect(200); + + // We have to add them directly cause otherwise the abac engine would kick the user from the room after the attribute is added + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: accessAttrKey, values: ['v1'] }]); + + // Create two private rooms: one will stay without attributes, the other will get the attribute + roomWithoutAttr = (await createRoom({ type: 'p', name: `abac-access-noattr-${Date.now()}` })).body.group; + roomWithAttr = (await createRoom({ type: 'p', name: `abac-access-withattr-${Date.now()}` })).body.group; + + // Assign the attribute to the second room + await request + .post(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: roomWithoutAttr._id }); + await deleteRoom({ type: 'p', roomId: roomWithAttr._id }); + }); + + it('INVITE: user without attributes invited to room without attributes succeeds', async () => { + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: roomWithoutAttr._id, usernames: [unauthorizedUser.username] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('INVITE: user without attributes invited to room with attributes should fail', async () => { + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms'); + }); + }); + + it('INVITE: after room loses attributes user without attributes can be invited', async () => { + await request.delete(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`).set(credentials).expect(200); + + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + describe('ABAC disabled with ABAC-managed room', () => { + let enabledAccessAttrKey: string; + let enabledUser: IUser; + let managedRoom: IRoom; + + before(async () => { + enabledAccessAttrKey = `${accessAttrKey}_disabled_case`; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: enabledAccessAttrKey, values: ['v1'] }) + .expect(200); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: enabledAccessAttrKey, values: ['v1'] }]); + + managedRoom = (await createRoom({ type: 'p', name: `abac-access-disabled-${Date.now()}` })).body.group; + + await request + .post(`${v1}/abac/rooms/${managedRoom._id}/attributes/${enabledAccessAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const username = `abac-enabled-user-${Date.now()}`; + const createUserRes = await request + .post(`${v1}/users.create`) + .set(credentials) + .send({ + email: `${username}@example.com`, + name: username, + username, + password: 'pass@123', + }) + .expect(200); + + enabledUser = createUserRes.body.user; + await addAbacAttributesToUserDirectly(enabledUser._id, [{ key: enabledAccessAttrKey, values: ['v1'] }]); + + await updateSetting('ABAC_Enabled', false); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', true); + + await deleteRoom({ type: 'p', roomId: managedRoom._id }); + await deleteUser(enabledUser); + }); + + it('INVITE: should fail adding user to ABAC-managed private room when ABAC is disabled', async () => { + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: managedRoom._id, usernames: [enabledUser.username] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-is-abac-managed'); + }); + }); + + it('INVITE: should still fail after user loses attributes when ABAC is disabled', async () => { + await addAbacAttributesToUserDirectly(enabledUser._id, [{ key: enabledAccessAttrKey, values: [] }]); + + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: managedRoom._id, usernames: [enabledUser.username] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-is-abac-managed'); + }); + }); + }); + }); + + describe('Room access (after subscribed)', () => { + let cacheRoom: IRoom; + const cacheAttrKey = `access_cache_attr_${Date.now()}`; + let cacheUser: IUser; + let cacheUserCreds: Credentials; + const ttlSeconds = 5; + + before(async function () { + this.timeout(10000); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: cacheAttrKey, values: ['on'] }) + .expect(200); + + cacheRoom = (await createRoom({ type: 'p', name: `abac-cache-room-${Date.now()}` })).body.group; + + cacheUser = await createUser(); + cacheUserCreds = await login(cacheUser.username, password); + await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: cacheAttrKey, values: ['on'] }]); + + await request + .post(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`) + .set(credentials) + .send({ values: ['on'] }) + .expect(200); + + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: cacheRoom._id, usernames: [cacheUser.username] }) + .expect(200); + + await updateSetting('Abac_Cache_Decision_Time_Seconds', ttlSeconds); + + await request + .post(`${v1}/chat.sendMessage`) + .set(credentials) + .send({ message: { rid: cacheRoom._id, msg: 'Seed message for cache access test' } }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: cacheRoom._id }); + await deleteUser(cacheUser); + await updateSetting('Abac_Cache_Decision_Time_Seconds', 300); + }); + + it('ACCESS: user can retrieve messages after subscription (initial compliant)', async () => { + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + }); + + it('ACCESS: user retains access within cache after losing attributes', async () => { + await addAbacAttributesToUserDirectly(cacheUser._id, []); + + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('ACCESS: user loses access after cache expiry', async () => { + await updateSetting('Abac_Cache_Decision_Time_Seconds', 0); + + await request + .get(`${v1}/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + + it('ACCESS: user is removed from the room when the access check fails', async () => { + const roomInfoRes = await request + .get(`${v1}/rooms.membersOrderedByRole`) + .set(credentials) + .query({ roomId: cacheRoom._id }) + .expect(200); + const { members } = roomInfoRes.body; + + expect(members.find((m: IUser) => m.username === cacheUser.username)).to.be.undefined; + }); + + it('ACCESS: user can be re invited to the room and access history', async () => { + await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]); + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: cacheRoom._id, usernames: [cacheUser.username] }) + .expect(200); + + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('ACCESS: keeps access once room attributes are removed', async () => { + await request.delete(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`).set(credentials).expect(200); + + await request + .get(`${v1}/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('Room access via projection-path endpoints (groups.messages)', () => { + // Unlike groups.history (which re-loads the full room), groups.messages gates access + // solely through the shared `roomAccessAttributes` projection. That projection must carry + // `abacAttributes`, otherwise canAccessRoom falls back to the subscription check and a + // non-compliant but still-subscribed member can read messages. + let projRoom: IRoom; + const projAttrKey = `proj_access_attr_${Date.now()}`; + let projUser: IUser; + let projUserCreds: Credentials; + + before(async function () { + this.timeout(10000); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: projAttrKey, values: ['on'] }) + .expect(200); + + projRoom = (await createRoom({ type: 'p', name: `abac-proj-room-${Date.now()}` })).body.group; + + projUser = await createUser(); + projUserCreds = await login(projUser.username, password); + await addAbacAttributesToUserDirectly(projUser._id, [{ key: projAttrKey, values: ['on'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: projAttrKey, values: ['on'] }]); + + await request + .post(`${v1}/abac/rooms/${projRoom._id}/attributes/${projAttrKey}`) + .set(credentials) + .send({ values: ['on'] }) + .expect(200); + + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: projRoom._id, usernames: [projUser.username] }) + .expect(200); + + await request + .post(`${v1}/chat.sendMessage`) + .set(credentials) + .send({ message: { rid: projRoom._id, msg: 'Seed message for projection access test' } }) + .expect(200); + + // Evaluate every access check against the PDP, no cached decisions + await updateSetting('Abac_Cache_Decision_Time_Seconds', 0); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: projRoom._id }); + await deleteUser(projUser); + await updateSetting('Abac_Cache_Decision_Time_Seconds', 300); + }); + + it('PROJECTION: compliant member can read messages', async () => { + await request + .get(`${v1}/groups.messages`) + .set(projUserCreds) + .query({ roomId: projRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + }); + + it('PROJECTION: member that lost its attributes is denied while still subscribed', async () => { + await addAbacAttributesToUserDirectly(projUser._id, []); + + await request + .get(`${v1}/groups.messages`) + .set(projUserCreds) + .query({ roomId: projRoom._id }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + }); + + describe('list abac rooms', () => { + const listAttrKey1 = `list_attr_1_${Date.now()}`; + const listAttrKey2 = `list_attr_2_${Date.now()}`; + const listRoomName1 = `abac-list-room-1-${Date.now()}`; + const listRoomName2 = `abac-list-room-2-${Date.now()}`; + const listRoomNoAttrName = `abac-list-room-noattr-${Date.now()}`; + + let listRoomId1: string; + let listRoomId2: string; + let listRoomNoAttrId: string; + + before('ensure ABAC enabled and create attribute definitions & rooms', async () => { + await updateSetting('ABAC_Enabled', true); + + // Create attribute definitions + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: listAttrKey1, values: ['alpha', 'beta'] }) + .expect(200); + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: listAttrKey2, values: ['x', 'y'] }) + .expect(200); + + // Create private rooms + listRoomId1 = (await createRoom({ type: 'p', name: listRoomName1 })).body.group._id; + listRoomId2 = (await createRoom({ type: 'p', name: listRoomName2 })).body.group._id; + listRoomNoAttrId = (await createRoom({ type: 'p', name: listRoomNoAttrName })).body.group._id; + + // Assign attributes to first two rooms + await request + .post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${listRoomId2}/attributes/${listAttrKey2}`) + .set(credentials) + .send({ values: ['x'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: listRoomId1 }), + deleteRoom({ type: 'p', roomId: listRoomId2 }), + deleteRoom({ type: 'p', roomId: listRoomNoAttrId }), + ]); + }); + + it('should list only private rooms with ABAC attributes (baseline)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').that.is.an('array'); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomNoAttrId); + }); + }); + + it('should NOT list a newly created private room without attributes', async () => { + const tempNoAttrRoomId = (await createRoom({ type: 'p', name: `abac-list-temp-noattr-${Date.now()}` })).body.group._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(tempNoAttrRoomId); + await deleteRoom({ type: 'p', roomId: tempNoAttrRoomId }); + }); + + it('should NOT list a public room (cannot be ABAC managed)', async () => { + const publicRoomId = (await createRoom({ type: 'c', name: `abac-list-public-${Date.now()}` })).body.channel._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(publicRoomId); + await deleteRoom({ type: 'c', roomId: publicRoomId }); + }); + + it('should NOT list a team private room without attributes', async () => { + // Create a private team and a private room inside it (not main room) + const teamRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: `abac-list-team-noattr-${Date.now()}`, type: 0 }) + .expect(200); + const teamIdLocal = teamRes.body.team._id; + const teamRoomRes = await createRoom({ type: 'p', name: `abac-list-team-room-${Date.now()}`, extraData: { teamId: teamIdLocal } }); + const teamPrivateRoomId = teamRoomRes.body.group._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamPrivateRoomId); + await deleteRoom({ type: 'p', roomId: teamPrivateRoomId }); + await deleteTeam(credentials, teamRes.body.team.name); + }); + + it('should stop listing a room after its ABAC attributes are removed', async () => { + // Ensure room 1 currently listed + const resBefore = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const idsBefore = resBefore.body.rooms.map((r: any) => r._id); + expect(idsBefore).to.include(listRoomId1); + + // Remove its only attribute key (listAttrKey1) - value was 'alpha' + await request.delete(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`).set(credentials).expect(200); + + const resAfter = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const idsAfter = resAfter.body.rooms.map((r: any) => r._id); + expect(idsAfter).to.not.include(listRoomId1); + + // Re-add attribute so other tests relying on it remain stable + await request + .post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + it('should NOT list a default private room even if attempt to add attribute fails', async () => { + const defaultRoomId = (await createRoom({ type: 'p', name: `abac-list-default-${Date.now()}` })).body.group._id; + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: true }).expect(200); + const defKey = `list_def_attr_${Date.now()}`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: defKey, values: ['one'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${defaultRoomId}/attributes/${defKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(400); + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(defaultRoomId); + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: false }).expect(200); + await deleteRoom({ type: 'p', roomId: defaultRoomId }); + }); + + it('should filter by room name (filterType=roomName)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'abac-list-room-1', filterType: 'roomName' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.not.include(listRoomId2); + }); + }); + + it('should filter by attribute key (filterType=attribute)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: listAttrKey2, filterType: 'attribute' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomId1); + }); + }); + + it('should filter by attribute value (filterType=value)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'alpha', filterType: 'value' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.not.include(listRoomId2); + }); + }); + + it('should match across name, key and values when filterType=all (default)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'x', filterType: 'all' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomId1); + }); + }); + + it('should paginate results (count=1 offset=0)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ count: 1, offset: 0 }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count', 1); + expect(res.body.rooms).to.have.lengthOf(1); + }); + }); + + it('should paginate results (count=1 offset=1)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ count: 1, offset: 1 }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body).to.have.property('offset', 1); + expect(res.body).to.have.property('count').that.is.at.most(1); + expect(res.body.rooms).to.have.length.at.most(1); + }); + }); + + it('should return empty list when filter does not match anything', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'nonexistent-filter-xyz', filterType: 'roomName' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body.rooms).to.be.an('array').with.lengthOf(0); + }); + }); + + it('should reject unauthorized user (403)', async () => { + await request.get(`${v1}/abac/rooms`).set(unauthorizedCredentials).expect(403); + }); + + describe('ABAC managed private team main rooms listing', () => { + const teamListAttrKey = `team_list_attr_${Date.now()}`; + const teamListName = `abac-list-team-${Date.now()}`; + let teamListMainRoomId: string; + + before('create private team (no attributes yet) and attribute definition', async () => { + const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamListName, type: 1 }).expect(200); + + teamListMainRoomId = teamRes.body.team.roomId; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: teamListAttrKey, values: ['red', 'blue'] }) + .expect(200); + }); + + it('baseline: team main private room without attributes should NOT appear in /abac/rooms list', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamListMainRoomId); + }); + }); + + it('after adding ABAC attribute to team main room it SHOULD appear in /abac/rooms list', async () => { + await request + .post(`${v1}/abac/rooms/${teamListMainRoomId}/attributes/${teamListAttrKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200); + + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=attribute should return team main room when filtering by its attribute key', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: teamListAttrKey, filterType: 'attribute' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=value should return team main room when filtering by an assigned attribute value', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'red', filterType: 'value' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=value should NOT return team main room when filtering by a non-existent value', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'nonexistent-team-value', filterType: 'value' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamListMainRoomId); + }); + }); + + it('pagination should include team main room on a page where it falls (count=1 offset varies)', async () => { + // Get full list to determine index + const fullRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const allIds: string[] = fullRes.body.rooms.map((r: any) => r._id); + const index = allIds.indexOf(teamListMainRoomId); + expect(index).to.not.equal(-1); + + // Request the page containing the team main room + const pageRes = await request.get(`${v1}/abac/rooms`).set(credentials).query({ count: 1, offset: index }).expect(200); + expect(pageRes.body.rooms.map((r: any) => r._id)).to.include(teamListMainRoomId); + }); + + after(async () => { + await deleteTeam(credentials, teamListName); + }); + }); + }); + + describe('LDAP integration', () => { + before(async () => { + await Promise.all([ + updateSetting('LDAP_Enable', true), + updateSetting('ABAC_Enabled', true), + updateSetting('LDAP_Background_Sync', true), + updateSetting('LDAP_Background_Sync_Import_New_Users', true), + updateSetting('LDAP_Host', 'openldap'), + updateSetting('LDAP_Port', 1389), + updateSetting('LDAP_Authentication', true), + updateSetting('LDAP_Authentication_UserDN', 'cn=admin,dc=space,dc=air'), + updateSetting('LDAP_Authentication_Password', 'adminpassword'), + updateSetting('LDAP_BaseDN', 'ou=users,dc=space,dc=air'), + updateSetting('LDAP_AD_User_Search_Field', 'uid'), + updateSetting('LDAP_AD_Username_Field', 'uid'), + updateSetting('LDAP_Background_Sync_ABAC_Attributes', true), + updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', '0 0 * * *'), + updateSetting( + 'LDAP_ABAC_AttributeMap', + JSON.stringify({ + departmentNumber: 'department', + telephoneNumber: 'phone', + }), + ), + ]); + }); + + before(async function () { + this.timeout(10000); + // ldap.syncNow now also syncs ABAC attributes for all users + await request.post(`${v1}/ldap.syncNow`).set(credentials); + await sleep(5000); + }); + + it('should sync LDAP user john.young with mapped ABAC attributes', async () => { + const res = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'john.young' }).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + const user = res.body.user as IUser; + + expect(user).to.have.property('abacAttributes'); + expect(user.abacAttributes).to.be.an('array'); + + const departmentAttr = user?.abacAttributes?.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + + expect(departmentAttr).to.exist; + expect(departmentAttr?.values || []).to.be.an('array').that.is.not.empty; + }); + + it('should sync ABAC attributes for SOME users via /abac/users/sync', async () => { + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + usernames: ['david.scott', 'gene.cernan'], + }) + .expect(200); + + const usersToCheck = ['david.scott', 'gene.cernan']; + + const results = await Promise.all( + usersToCheck.map(async (username) => { + const res = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + return res.body.user as IUser; + }), + ); + + for (const user of results) { + const attrs = user.abacAttributes || []; + expect(attrs).to.be.an('array').that.is.not.empty; + + const dept = attrs.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + expect(dept).to.exist; + expect(dept?.values || []).to.be.an('array').that.is.not.empty; + } + }); + + // Hard limits for the endpoint - max 100 identifiers per type, 400 total per request + it('should fail /abac/users/sync when more than 100 usernames are provided', async () => { + const usernames = Array.from({ length: 101 }, (_, i) => `user_${i}@example.com`); + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + usernames, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail /abac/users/sync when more than 100 ids are provided', async () => { + const ids = Array.from({ length: 101 }, (_, i) => `id_${i}`); + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + ids, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail /abac/users/sync when more than 100 emails are provided', async () => { + const emails = Array.from({ length: 101 }, (_, i) => `user_${i}@example.com`); + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + emails, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail /abac/users/sync when more than 100 ldapIds are provided', async () => { + const ldapIds = Array.from({ length: 101 }, (_, i) => `ldap_${i}`); + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + ldapIds, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should succeed /abac/users/sync when exactly 100 usernames are provided (boundary)', async () => { + const usernames = Array.from({ length: 100 }, (_, i) => `boundary_user_${i}`); + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + usernames, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + describe('LDAP ABAC room membership sync', () => { + let roomIdWithAbac: string; + + before(async () => { + // Ensure the ABAC attribute definition for department exists + const attrsRes = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); + const attrs = attrsRes.body.attributes as IAbacAttributeDefinition[]; + const existingDept = attrs.find((attr) => attr.key === 'department'); + + if (!existingDept) { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ + key: 'department', + values: ['lifeSupport', 'lifeSupport2', 'navControl'], + }) + .expect(200); + } + + // Create a private room and add LDAP users that will later lose ABAC compliance + const roomRes = await createRoom({ + type: 'p', + name: `ldapAbacRoom-${Date.now()}`, + }); + + roomIdWithAbac = roomRes.body.group._id; + + const davidUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200); + const sergeiUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'sergei.krikalev' }).expect(200); + + await addAbacAttributesToUserDirectly(davidUser.body.user._id, [{ key: 'department', values: ['navControl'] }]); + await addAbacAttributesToUserDirectly(sergeiUser.body.user._id, [{ key: 'department', values: ['navControl'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: 'department', values: ['navControl'] }]); + + await request + .post(`${v1}/abac/rooms/${roomIdWithAbac}/attributes/department`) + .set(credentials) + .send({ + values: ['navControl'], + }) + .expect(200); + + // Invite two LDAP users that will initially match the room attributes + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ + roomId: roomIdWithAbac, + usernames: ['david.scott', 'sergei.krikalev'], + }); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: roomIdWithAbac }); + }); + + it('should remove users from room after LDAP sync changes their ABAC attributes', async () => { + const initialDept = 'navControl'; + + const davidInitialAttrs = [{ key: 'department', values: [initialDept] }]; + const sergeiInitialAttrs = [{ key: 'department', values: [initialDept] }]; + + expect(davidInitialAttrs[0].values).to.include(initialDept); + expect(sergeiInitialAttrs[0].values).to.include(initialDept); + + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ + usernames: ['david.scott', 'sergei.krikalev'], + }) + .expect(200); + + const afterMembersRes = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: roomIdWithAbac }).expect(200); + + const afterMembers = afterMembersRes.body.members as IUser[]; + const afterUsernames = afterMembers.map((u) => u.username); + + expect(afterUsernames).to.not.include('david.scott'); + expect(afterUsernames).to.not.include('sergei.krikalev'); + + const userInfoDavidAfter = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200); + const userInfoSergeiAfter = await request + .get(`${v1}/users.info`) + .set(credentials) + .query({ username: 'sergei.krikalev' }) + .expect(200); + + const davidAfter = userInfoDavidAfter.body.user as IUser; + const sergeiAfter = userInfoSergeiAfter.body.user as IUser; + + const davidDeptAfter = (davidAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + const sergeiDeptAfter = (sergeiAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + + expect(davidDeptAfter?.values || []).to.not.deep.equal(davidInitialAttrs[0].values); + expect(sergeiDeptAfter?.values || []).to.not.deep.equal(sergeiInitialAttrs[0].values); + }); + }); + + after(async () => { + await Promise.all([ + updateSetting('LDAP_Enable', false), + updateSetting('ABAC_Enabled', false), + updateSetting('LDAP_Background_Sync', false), + updateSetting('LDAP_Background_Sync_Import_New_Users', false), + updateSetting('LDAP_Host', ''), + updateSetting('LDAP_Authentication', false), + updateSetting('LDAP_Authentication_UserDN', ''), + updateSetting('LDAP_Authentication_Password', ''), + updateSetting('LDAP_BaseDN', ''), + updateSetting('LDAP_AD_User_Search_Field', ''), + updateSetting('LDAP_AD_Username_Field', ''), + updateSetting('LDAP_Background_Sync_ABAC_Attributes', false), + updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', ''), + updateSetting('LDAP_ABAC_AttributeMap', ''), + ]); + }); + }); + + describe('[Admin Rooms - Sensitive Fields] (ABAC managed rooms)', () => { + const sensitiveAttrKey = `attr_sensitive_${Date.now()}`; + const createdRids: string[] = []; + let sensitiveAttrId: string; + let owner: IUser; + let ownerCredentials: Credentials; + + const sensitiveFields = [ + { name: 'announcement', settingKey: 'roomAnnouncement', roomKey: 'announcement' }, + { name: 'topic', settingKey: 'roomTopic', roomKey: 'topic' }, + { name: 'description', settingKey: 'roomDescription', roomKey: 'description' }, + ] as const; + + const createPrivateRoomWithField = async ( + settingKey: (typeof sensitiveFields)[number]['settingKey'], + value: string | undefined, + ): Promise<{ rid: string; name: string }> => { + const name = `abac-sensitive-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const created = (await createRoom({ type: 'p', name })).body.group as IRoom; + if (value !== undefined) { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: created._id, [settingKey]: value }) + .expect(200); + } + createdRids.push(created._id); + return { rid: created._id, name }; + }; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: sensitiveAttrKey, values: ['v1'] }) + .expect(200); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: sensitiveAttrKey }).set(credentials).expect(200); + const attr = listRes.body.attributes.find((a: { _id: string; key: string }) => a.key === sensitiveAttrKey); + expect(attr, 'attribute should have been created').to.exist; + sensitiveAttrId = attr._id; + + owner = await createUser(); + ownerCredentials = await login(owner.username, password); + }); + + after(async () => { + await Promise.all(createdRids.map((rid) => request.delete(`${v1}/abac/rooms/${rid}/attributes`).set(credentials).expect(200))); + await Promise.all(createdRids.map((rid) => deleteRoom({ type: 'p', roomId: rid }))); + await request.delete(`${v1}/abac/attributes/${sensitiveAttrId}`).set(credentials).expect(200); + await deleteUser(owner); + await updateSetting('ABAC_Enabled', false); + }); + + it('should strip announcement, topic and description from rooms.adminRooms.getRoom on ABAC managed room', async () => { + const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'SECRET_ANN'); + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, roomTopic: 'SECRET_TOPIC', roomDescription: 'SECRET_DESC' }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid }).expect(200); + + expect(res.body).to.not.have.property('announcement'); + expect(res.body).to.not.have.property('topic'); + expect(res.body).to.not.have.property('description'); + }); + + it('should return announcement, topic and description from rooms.adminRooms.getRoom on non-ABAC private room (control)', async () => { + const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'VISIBLE_ANN'); + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, roomTopic: 'VISIBLE_TOPIC', roomDescription: 'VISIBLE_DESC' }) + .expect(200); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid }).expect(200); + + expect(res.body).to.have.property('announcement', 'VISIBLE_ANN'); + expect(res.body).to.have.property('topic', 'VISIBLE_TOPIC'); + expect(res.body).to.have.property('description', 'VISIBLE_DESC'); + }); + + it('should strip announcement, topic and description from rooms.adminRooms list on ABAC managed rows', async () => { + const { rid, name } = await createPrivateRoomWithField('roomAnnouncement', 'LIST_SECRET_ANN'); + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, roomTopic: 'LIST_SECRET_TOPIC', roomDescription: 'LIST_SECRET_DESC' }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request.get(`${v1}/rooms.adminRooms`).set(credentials).query({ 'filter': name, 'types[]': 'p' }).expect(200); + + const found = (res.body.rooms as Array).find((r) => r._id === rid); + expect(found, 'created room should appear in admin list').to.exist; + expect(found).to.not.have.property('announcement'); + expect(found).to.not.have.property('topic'); + expect(found).to.not.have.property('description'); + }); + + it('should strip announcement, topic and description from rooms.adminRooms.privateRooms list on ABAC managed rows', async () => { + const { rid, name } = await createPrivateRoomWithField('roomAnnouncement', 'PRIVATE_SECRET_ANN'); + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, roomTopic: 'PRIVATE_SECRET_TOPIC', roomDescription: 'PRIVATE_SECRET_DESC' }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request.get(`${v1}/rooms.adminRooms.privateRooms`).set(credentials).query({ filter: name }).expect(200); + + const found = (res.body.rooms as Array).find((r) => r._id === rid); + expect(found, 'created room should appear in admin private rooms list').to.exist; + expect(found).to.not.have.property('announcement'); + expect(found).to.not.have.property('topic'); + expect(found).to.not.have.property('description'); + }); + + sensitiveFields.forEach(({ name, settingKey, roomKey }) => { + describe(`${name}`, () => { + it(`should reject ${settingKey} save on ABAC managed room (admin)`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, 'original'); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: 'changed' }) + .expect(400); + + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + + it(`should accept idempotent ${settingKey} re-submit on ABAC managed room`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, 'keep-me'); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: 'keep-me' }) + .expect(200); + }); + + it(`should accept ${settingKey} save on non-ABAC private room (control)`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, 'original'); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: 'changed' }) + .expect(200); + }); + + it(`should reject empty-string unset on ABAC managed room with ${roomKey} set`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, 'X'); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: '' }) + .expect(400); + + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + + it(`should accept idempotent empty submit on ABAC room with no ${roomKey} set (direct API contract)`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, undefined); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: '' }) + .expect(200); + }); + + it(`should accept ${settingKey} save after the last ABAC attribute is removed (transition)`, async () => { + const { rid } = await createPrivateRoomWithField(settingKey, 'initial'); + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: 'changed' }) + .expect(400); + + await request.delete(`${v1}/abac/rooms/${rid}/attributes`).set(credentials).expect(200); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid, [settingKey]: 'changed' }) + .expect(200); + }); + }); + }); + + it('should reject roomAnnouncement save on ABAC managed room (non-admin owner)', async () => { + const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'original'); + + await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: rid, userId: owner._id }).expect(200); + await request.post(`${v1}/groups.addOwner`).set(credentials).send({ roomId: rid, userId: owner._id }).expect(200); + + await request + .post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + + const res = await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(ownerCredentials) + .send({ rid, roomAnnouncement: 'owner-changed' }) + .expect(400); + + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); +}); + +(IS_EE ? describe : describe.skip)('[ABAC] External PDP (mock-server)', function () { + this.retries(0); + + const attrKey = `ext_pdp_attr_${Date.now()}`; + + before((done) => { + getCredentials(done); + }); + + before(async function () { + this.timeout(15000); + + connection = await MongoClient.connect(URL_MONGODB); + + const healthy = await mockServerHealthy(); + expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; + + await Promise.all([ + updatePermission('abac-management', ['admin']), + updatePermission('manage-abac-admin-settings', ['admin']), + updatePermission('manage-abac-admin-room-attributes', ['admin']), + updatePermission('manage-abac-admin-rooms', ['admin']), + updatePermission('view-abac-admin-audit', ['admin']), + ]); + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'virtru'); + await Promise.all([ + updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'), + updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'), + updateSetting('ABAC_Virtru_Client_ID', 'mock-client'), + updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'), + updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'), + updateSetting('ABAC_Virtru_Attribute_Namespace', 'example.com'), + updateSetting('Abac_Cache_Decision_Time_Seconds', 0), + ]); + + await request + .post('/api/v1/abac/attributes') + .set(credentials) + .send({ key: attrKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + }); + + after(async function () { + this.timeout(10000); + + await mockServerReset(); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Enabled', false); + + await connection.close(); + }); + + describe('PERMIT all: users remain when PDP permits everyone', () => { + let room: IRoom; + let user: IUser; + let userCreds: Credentials; + + before(async function () { + this.timeout(15000); + + user = await createUser({ verified: true }); + userCreds = await login(user.username, password); + + room = (await createRoom({ type: 'p', name: `extpdp-permit-${Date.now()}` })).body.group; + + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([], 'DECISION_PERMIT'); + + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('room creator remains in the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + + it('user remains in the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(user.username); + }); + + it('user can access room history when PDP returns PERMIT', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + await request + .get('/api/v1/groups.history') + .set(userCreds) + .query({ roomId: room._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + }); + }); + + describe('Access check: PDP DENY removes user on room access', () => { + let room: IRoom; + let user: IUser; + let userCreds: Credentials; + + before(async function () { + this.timeout(15000); + + user = await createUser({ verified: true }); + userCreds = await login(user.username, password); + + room = (await createRoom({ type: 'p', name: `extpdp-access-${Date.now()}` })).body.group; + + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('user loses access when PDP flips to DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + await request + .get('/api/v1/groups.history') + .set(userCreds) + .query({ roomId: room._id }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + + it('user is removed from room after access DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + }); + + describe('Invite to ABAC room: PDP decides who can join', () => { + let room: IRoom; + let permitUser: IUser; + let denyUser: IUser; + + before(async function () { + this.timeout(10000); + + permitUser = await createUser({ verified: true }); + denyUser = await createUser({ verified: true }); + + room = (await createRoom({ type: 'p', name: `extpdp-invite-${Date.now()}` })).body.group; + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(permitUser), deleteUser(denyUser)]); + }); + + it('should allow invite when PDP returns PERMIT', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [permitUser.username] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('invited user is a member of the room after PERMIT', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(permitUser.username); + }); + + it('should reject invite when PDP returns DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [denyUser.username] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms'); + }); + }); + + it('denied user is not a member of the room', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(denyUser.username); + }); + + it('room creator remains after invite operations', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + }); + + describe('PDP unavailability: fail-closed behavior', () => { + let room: IRoom; + let user: IUser; + let userCredentials: Credentials; + + before(async function () { + this.timeout(10000); + + user = await createUser({ verified: true }); + userCredentials = await login(user.username, password); + + room = (await createRoom({ type: 'p', name: `extpdp-failclose-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await mockServerReset(); + await seedDefaultMocks(); + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('should deny access when PDP health check returns NOT_SERVING', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + + await request + .get('/api/v1/groups.history') + .set(userCredentials) + .query({ roomId: room._id }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + + it('should deny invite when PDP is unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + + const newUser = await createUser({ verified: true }); + + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [newUser.username!] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await deleteUser(newUser); + }); + + it('should deny room attribute changes when PDP is unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + }); + }); + + describe('Selective DENY: only non-permitted users are removed', () => { + let room: IRoom; + let user: IUser; + + before(async function () { + this.timeout(15000); + + user = await createUser({ verified: true }); + room = (await createRoom({ type: 'p', name: `extpdp-selective-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); + + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('admin (permitted) remains in the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + + it('user (denied) was removed from the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + }); + + describe('Tightening attributes: selective DENY removes only denied users', () => { + let room: IRoom; + let user: IUser; + + before(async function () { + this.timeout(15000); + + user = await createUser({ verified: true }); + room = (await createRoom({ type: 'p', name: `extpdp-tighten-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('user is removed when attributes are tightened and PDP denies them', async function () { + this.timeout(10000); + + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); + + await request + .put(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha', 'beta'] }) + .expect(200); + + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + + it('admin remains in the room after tightening', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + }); + + describe('Re-evaluation via POST /abac/users/sync', () => { + describe('PDP DENY removes the synced user', () => { + let room: IRoom; + let user: IUser; + const username = `abac-sync-deny-${Date.now()}`; + const email = `${username}@rocket.chat`; + + before(async function () { + this.timeout(15000); + + user = await createUser({ username, email, verified: true }); + room = (await createRoom({ type: 'p', name: `extpdp-sync-deny-${Date.now()}` })).body.group; + await request + .post(api('groups.invite')) + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY'); + + await request + .post(api(`abac/rooms/${room._id}/attributes/${attrKey}`)) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('keeps the user before re-evaluation', async () => { + const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(user.username); + }); + + it('removes the user when the Virtru PDP returns DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); + + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ usernames: [user.username] }) + .expect(200); + + const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + + it('keeps the room creator (permitted) after re-evaluation', async () => { + const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + }); + + describe('PDP PERMIT keeps the synced user', () => { + let room: IRoom; + let user: IUser; + const username = `abac-sync-permit-${Date.now()}`; + const email = `${username}@rocket.chat`; + + before(async function () { + this.timeout(15000); + + user = await createUser({ username, email, verified: true }); + room = (await createRoom({ type: 'p', name: `extpdp-sync-permit-${Date.now()}` })).body.group; + await request + .post(api('groups.invite')) + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY'); + + await request + .post(api(`abac/rooms/${room._id}/attributes/${attrKey}`)) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); + + it('keeps the user when the Virtru PDP returns PERMIT', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY'); + + await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ usernames: [user.username] }) + .expect(200); + + const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(user.username); + }); + }); + }); + + describe('[GET] /abac/pdp/health', () => { + beforeEach(async () => { + await mockServerReset(); + }); + + after(async () => { + await mockServerReset(); + }); + + it('returns ABAC_PDP_Health_Platform_Failed when platform /healthz is unavailable', async () => { + await mockServerSetMany([ + { method: 'GET', path: '/healthz', body: { status: 'NOT_SERVING' }, statusCode: 503 }, + { + method: 'POST', + path: '/auth/realms/mock/protocol/openid-connect/token', + body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 }, + }, + ]); + + const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(400); + + expect(res.body).to.deep.include({ + success: false, + available: false, + message: 'ABAC_PDP_Health_Platform_Failed', + }); + }); + + it('returns ABAC_PDP_Health_IdP_Failed when platform is OK but the OIDC token endpoint fails', async () => { + await mockServerSetMany([ + { method: 'GET', path: '/healthz', body: { status: 'SERVING' } }, + { + method: 'POST', + path: '/auth/realms/mock/protocol/openid-connect/token', + body: { error: 'invalid_client' }, + statusCode: 401, + }, + ]); + + const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(400); + + expect(res.body).to.deep.include({ + success: false, + available: false, + message: 'ABAC_PDP_Health_IdP_Failed', + }); + }); + + it('returns 200 ABAC_PDP_Health_OK when platform, IdP, and authorization all succeed', async () => { + await seedDefaultMocks(); + await seedGetEntitlements({}); + + const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(200); + + expect(res.body).to.deep.include({ + success: true, + available: true, + message: 'ABAC_PDP_Health_OK', + }); + }); + }); + + describe('users.info abacAttributes visibility', () => { + let userWithAttrs: IUser; + + before(async () => { + userWithAttrs = await createUser({ verified: true }); + await addAbacAttributesToUserDirectly(userWithAttrs._id, [{ key: attrKey, values: ['alpha'] }]); + }); + + after(async () => { + await deleteUser(userWithAttrs); + }); + + it('omits abacAttributes from /v1/users.info when PDP type is virtru', async () => { + const res = await request.get('/api/v1/users.info').set(credentials).query({ userId: userWithAttrs._id }).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.not.have.property('abacAttributes'); + }); + }); + + (IS_EE ? describe : describe.skip)('[ABAC] virtru-attribute-store (mock-server)', function () { + this.retries(0); + + const v1 = '/api/v1'; + const NS = 'example.com'; + const fqn = (key: string, value: string) => `https://${NS}/attr/${key}/value/${value}`; + let storeConnection: MongoClient; + + const makeAdmin = async (slug: string) => { + const u = await createUser({ + roles: ['admin'], + username: `vstore-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + verified: true, + }); + const creds = await login(u.username, password); + return { user: u, creds }; + }; + + before((done) => getCredentials(done)); + + before(async function () { + this.timeout(20000); + + const healthy = await mockServerHealthy(); + expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; + + storeConnection = await MongoClient.connect(URL_MONGODB); + + await Promise.all([ + updatePermission('abac-management', ['admin']), + updatePermission('manage-abac-admin-settings', ['admin']), + updatePermission('manage-abac-admin-room-attributes', ['admin']), + updatePermission('manage-abac-admin-rooms', ['admin']), + updatePermission('view-abac-admin-audit', ['admin']), + ]); + + await Promise.all([ + updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'), + updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'), + updateSetting('ABAC_Virtru_Client_ID', 'mock-client'), + updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'), + updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'), + updateSetting('ABAC_Virtru_Attribute_Namespace', NS), + updateSetting('Abac_Cache_Decision_Time_Seconds', 0), + ]); + + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'virtru'); + await updateSetting('ABAC_Attribute_Store', 'virtru'); + }); + + after(async function () { + this.timeout(15000); + + await mockServerReset(); + await updateSetting('ABAC_Attribute_Store', 'local'); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Enabled', false); + await storeConnection.close(); + }); + + describe('redactor / single-room (spec §5.0/§5.0a)', () => { + let room: IRoom; + let adminA: { user: IUser; creds: Credentials }; + let adminB: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(20000); + + adminA = await makeAdmin('redA'); + adminB = await makeAdmin('redB'); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]); + + room = (await createRoom({ type: 'p', name: `vstore-red-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminA.user); + await deleteUser(adminB.user); + }); + + it('admin A (PERMIT): /abac/rooms returns room with abacAttributes populated, no redacted flag', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'room must be in list for permit admin').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(found).to.not.have.property('abacAttributesRedacted'); + }); + + it('admin A (PERMIT): rooms.adminRooms.getRoom returns room with abacAttributes populated, no flag', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminA.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(res.body).to.not.have.property('abacAttributesRedacted'); + }); + + it('admin A can POST a value they possess and gets 200', async () => { + const postRoom = (await createRoom({ type: 'p', name: `vstore-red-post-${Date.now()}` })).body.group; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] }, + ]); + + await request + .post(`${v1}/abac/rooms/${postRoom._id}/attributes/clearance`) + .set(adminA.creds) + .send({ values: ['secret'] }) + .expect(200); + }); + + it('admin B (DENY): list contains the room redacted + flagged, same pagination shape as A', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('other', 'value')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const resB = await request.get(`${v1}/abac/rooms`).set(adminB.creds).expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const resA = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + + expect(resB.body.total).to.equal(resA.body.total); + expect(resB.body.offset).to.equal(resA.body.offset); + + const denied = (resB.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(denied, 'room must still appear in deny admin list').to.exist; + expect(denied?.abacAttributes).to.deep.equal([]); + expect(denied).to.have.property('abacAttributesRedacted', true); + + for (const r of resB.body.rooms as Array) { + expect(r).to.not.have.property('restricted'); + } + expect(resB.body).to.not.have.property('restricted'); + }); + + it('admin B (DENY): rooms.adminRooms.getRoom returns room redacted + flagged', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('other', 'value')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminB.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([]); + expect(res.body).to.have.property('abacAttributesRedacted', true); + }); + + it('admin B (DENY): non-empty POST → 400 error-abac-not-authorized-to-modify-room', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminB.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-not-authorized-to-modify-room'); + }); + + it('Virtru unreachable on list → 200 with rooms attribute-redacted+flagged (not 4xx/5xx)', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'room remains in list when PDP is unreachable').to.exist; + expect(found?.abacAttributes).to.deep.equal([]); + expect(found).to.have.property('abacAttributesRedacted', true); + }); + }); + + describe('write guard / validateAssignable (spec §4.1)', () => { + let room: IRoom; + let adminWG: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminWG = await makeAdmin('wg'); + room = (await createRoom({ type: 'p', name: `vstore-wg-${Date.now()}` })).body.group; + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminWG.user); + }); + + it('assigning a key the admin lacks → 400 error-invalid-attribute-values', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminWG.creds) + .send({ values: ['blue'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-attribute-values'); + }); + + it('assigning a value of a key they have but not that specific value → 400 error-invalid-attribute-values', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminWG.creds) + .send({ values: ['topsecret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-attribute-values'); + }); + }); + + describe('bypass-abac-store-validation permission (spec §4.2)', () => { + let adminBypass: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminBypass = await makeAdmin('bypass'); + await updatePermission('bypass-abac-store-validation', ['admin']); + }); + + after(async () => { + await mockServerReset(); + await updatePermission('bypass-abac-store-validation', []); + await deleteUser(adminBypass.user); + }); + + it('skips validateAssignable: assigns a value the admin is not entitled to → 200', async () => { + const room = (await createRoom({ type: 'p', name: `vstore-bypass-wg-${Date.now()}` })).body.group; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminBypass.creds) + .send({ values: ['blue'] }) + .expect(200); + + await deleteRoom({ type: 'p', roomId: room._id }); + }); + + it('skips assertCanModifyRoom: edits an attribute on a room the PDP denies → 200', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]); + + const room = (await createRoom({ type: 'p', name: `vstore-bypass-mod-${Date.now()}` })).body.group; + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminBypass.creds) + .send({ values: ['blue'] }) + .expect(200); + + await deleteRoom({ type: 'p', roomId: room._id }); + }); + }); + + describe('hierarchy expansion (spec §3.3)', () => { + let room: IRoom; + let adminH: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminH = await makeAdmin('hier'); + room = (await createRoom({ type: 'p', name: `vstore-hier-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminH.user); + }); + + it('picker returns hierarchy-expanded set (secret + topsecret) when entitled to topsecret', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ + [fqn('clearance', 'secret')]: {}, + [fqn('clearance', 'topsecret')]: {}, + }); + + const res = await request.get(`${v1}/abac/attributes`).set(adminH.creds).expect(200); + const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance'); + expect(clearance, 'clearance attribute should be present').to.exist; + expect(clearance?.values).to.include.members(['secret', 'topsecret']); + }); + + it('a room using a lower-rank value (secret) is in-scope for an actor entitled to the higher rank', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ + [fqn('clearance', 'secret')]: {}, + [fqn('clearance', 'topsecret')]: {}, + }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request.get(`${v1}/abac/rooms`).set(adminH.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'lower-rank room must be in scope when entitled to higher rank').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(found).to.not.have.property('abacAttributesRedacted'); + + await request + .put(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminH.creds) + .send({ values: ['secret'] }) + .expect(200); + }); + }); + + describe('catalog-400 in virtru store (spec §5.1, corrected to 400)', () => { + let adminC: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminC = await makeAdmin('cat'); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + }); + + after(async () => { + await mockServerReset(); + await deleteUser(adminC.user); + }); + + it('POST /abac/attributes → 400 error-abac-attribute-store-external', async () => { + const res = await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `vstore_cat_${Date.now()}`, values: ['v1'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('PUT /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request + .put(`${v1}/abac/attributes/__nonexistent__`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('DELETE /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request.delete(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request.get(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes/:key/is-in-use → 400 error-abac-attribute-store-external', async () => { + const res = await request.get(`${v1}/abac/attributes/clearance/is-in-use`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes (picker) → 200 served from store.list(actor)', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + + const res = await request.get(`${v1}/abac/attributes`).set(adminC.creds).expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance'); + expect(clearance?.values).to.deep.equal(['secret']); + }); + + it('POST /abac/users/sync is NOT blocked by external attribute store (no error-abac-attribute-store-external)', async () => { + const res = await request + .post(api('abac/users/sync')) + .set(credentials) + .send({ usernames: ['no-such-user-vstore'] }); + expect(res.body?.error).to.not.equal('error-abac-attribute-store-external'); + }); + }); + + describe('picker / validateAssignable unreachable', () => { + let room: IRoom; + let adminU: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminU = await makeAdmin('unr'); + room = (await createRoom({ type: 'p', name: `vstore-unr-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminU.user); + }); + + it('Virtru unreachable: picker GET /abac/attributes → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/abac/attributes`).set(adminU.creds).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + + it('Virtru unreachable: write POST /abac/rooms/:rid/attributes/:key → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminU.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + + it('decision unreachable on rooms.adminRooms.getRoom → 200 with abacAttributes redacted+flagged', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminU.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([]); + expect(res.body).to.have.property('abacAttributesRedacted', true); + }); + + it('write assertCanModifyRoom unreachable → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { error: 'pdp-down' }, 503); + + const res = await request + .put(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminU.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + }); + + describe('wipe on transition local → virtru (spec §0.2)', () => { + let wipeRoom1: IRoom; + let wipeRoom2: IRoom; + let memberUser: IUser; + + before(async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'local'); + + const wipeAttrKey = `vstore_wipe_attr_${Date.now()}`; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn(wipeAttrKey, 'v1')]: {} }); + + wipeRoom1 = (await createRoom({ type: 'p', name: `vstore-wipe-1-${Date.now()}` })).body.group; + wipeRoom2 = (await createRoom({ type: 'p', name: `vstore-wipe-2-${Date.now()}` })).body.group; + + memberUser = await createUser({ verified: true }); + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: wipeRoom1._id, usernames: [memberUser.username] }) + .expect(200); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: wipeAttrKey, values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/abac/rooms/${wipeRoom1._id}/attributes/${wipeAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${wipeRoom2._id}/attributes/${wipeAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Attribute_Store', 'virtru'); + await deleteRoom({ type: 'p', roomId: wipeRoom1._id }); + await deleteRoom({ type: 'p', roomId: wipeRoom2._id }); + await deleteUser(memberUser); + }); + + it('switching ABAC_Attribute_Store local → virtru wipes abacAttributes from existing rooms', async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'virtru'); + + const rooms = storeConnection.db().collection('rocketchat_room'); + let r1: any = null; + let r2: any = null; + for (let i = 0; i < 30; i++) { + r1 = await rooms.findOne({ _id: wipeRoom1._id as any }); + r2 = await rooms.findOne({ _id: wipeRoom2._id as any }); + if (!r1?.abacAttributes && !r2?.abacAttributes) { + break; + } + await sleep(200); + } + expect(r1?.abacAttributes, 'room1 abacAttributes must be unset after transition').to.be.undefined; + expect(r2?.abacAttributes, 'room2 abacAttributes must be unset after transition').to.be.undefined; + }); + + it('emits at-least-one abac.attribute.store.switched audit event with from=local, to=virtru, roomsAffected>=1', async function () { + this.timeout(15000); + + let switched: Array<{ data: Array<{ key: string; value: unknown }> }> = []; + for (let i = 0; i < 30; i++) { + const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 100 }).expect(200); + switched = (res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>).filter( + (e) => e.t === 'abac.attribute.store.switched', + ); + if (switched.length) { + break; + } + await sleep(200); + } + expect(switched.length, 'at least one switched audit event').to.be.at.least(1); + const ev = switched[0]; + const pick = (k: string) => ev.data.find((d) => d.key === k)?.value; + expect(pick('from')).to.equal('local'); + expect(pick('to')).to.equal('virtru'); + expect(pick('roomsAffected')).to.be.a('number').and.to.be.at.least(1); + }); + + it('members of pre-existing ABAC rooms are NOT evicted by the wipe', async () => { + const res = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: wipeRoom1._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(memberUser.username); + }); + }); + + describe('audit / logs (spec §5.2 — settled, NOT redacted)', () => { + let auditRoom: IRoom; + const auditAttrKey = `vstore_audit_${Date.now()}`; + + before(async function () { + this.timeout(15000); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn(auditAttrKey, 'v1')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }, + ]); + + auditRoom = (await createRoom({ type: 'p', name: `vstore-audit-${Date.now()}` })).body.group; + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: auditRoom._id }); + }); + + it('GET /abac/audit returns events with attribute key/value payloads visible (not redacted) and includes abac.attribute.store.switched', async () => { + const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + + const events = res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>; + expect(events).to.be.an('array').that.is.not.empty; + + const types = new Set(events.map((e) => e.t)); + expect(types.has('abac.attribute.store.switched'), 'attribute_store.switched event included by endpoint').to.be.true; + + const switched = events.find((e) => e.t === 'abac.attribute.store.switched'); + expect(switched, 'switched event must exist').to.exist; + expect(switched?.data.find((d) => d.key === 'from')?.value, 'from value visible').to.be.a('string'); + expect(switched?.data.find((d) => d.key === 'to')?.value, 'to value visible').to.be.a('string'); + expect(switched?.data.find((d) => d.key === 'roomsAffected')?.value, 'roomsAffected visible').to.be.a('number'); + }); + }); + + describe('GET /abac/audit query filters', () => { + it('scopes events to the actor passed in the query', async () => { + const mine = await request + .get(`${v1}/abac/audit`) + .set(credentials) + .query({ count: 100, actor: { username: adminUsername } }) + .expect(200); + expect(mine.body.events).to.be.an('array').that.is.not.empty; + (mine.body.events as Array<{ actor?: { username?: string } }>).forEach((e) => { + expect(e.actor?.username).to.equal(adminUsername); + }); + + const other = await request + .get(`${v1}/abac/audit`) + .set(credentials) + .query({ count: 100, actor: { _id: 'no-such-actor-xyz' } }) + .expect(200); + expect(other.body.events).to.be.an('array').that.is.empty; + expect(other.body.total).to.equal(0); + }); + + it('returns no events when start is in the future', async () => { + const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 100, start: future }).expect(200); + expect(res.body.events).to.be.an('array').that.is.empty; + expect(res.body.total).to.equal(0); + }); + + it('returns no events when end precedes all events', async () => { + const res = await request + .get(`${v1}/abac/audit`) + .set(credentials) + .query({ count: 100, end: new Date(1).toISOString() }) + .expect(200); + expect(res.body.events).to.be.an('array').that.is.empty; + expect(res.body.total).to.equal(0); + }); + }); + + describe('local-mode regression (ABAC_Attribute_Store=local)', () => { + const localKey = `vstore_local_${Date.now()}`; + let localRoom: IRoom; + let localAttrId: string; + + before(async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'local'); + + await mockServerReset(); + await seedDefaultMocks(); + + const createRes = await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: localKey, values: ['v1', 'v2'] }) + .expect(200); + expect(createRes.body).to.have.property('success', true); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: localKey }).set(credentials).expect(200); + const attr = (listRes.body.attributes as Array<{ _id: string; key: string }>).find((a) => a.key === localKey); + expect(attr, 'created local attribute should be retrievable via picker').to.exist; + if (!attr) { + throw new Error('local attribute not found'); + } + localAttrId = attr._id; + + localRoom = (await createRoom({ type: 'p', name: `vstore-local-${Date.now()}` })).body.group; + await request + .post(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await request.delete(`${v1}/abac/rooms/${localRoom._id}/attributes`).set(credentials).expect(200); + await deleteRoom({ type: 'p', roomId: localRoom._id }); + await request.delete(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200); + await updateSetting('ABAC_Attribute_Store', 'virtru'); + }); + + it('catalog CRUD works (PUT 200)', async () => { + await request + .put(`${v1}/abac/attributes/${localAttrId}`) + .set(credentials) + .send({ values: ['v1', 'v2', 'v3'] }) + .expect(200); + }); + + it('GET /abac/attributes/:_id returns 200 (no virtru block)', async () => { + const res = await request.get(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200); + expect(res.body).to.have.property('success', true); + }); + + it('GET /abac/attributes/:key/is-in-use returns 200 in local mode', async () => { + const res = await request.get(`${v1}/abac/attributes/${localKey}/is-in-use`).set(credentials).expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inUse').that.is.a('boolean'); + }); + + it('list /abac/rooms returns rooms with full attributes, no abacAttributesRedacted flags', async () => { + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === localRoom._id); + expect(found, 'local-mode room must be in list').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: localKey, values: ['v1'] }]); + for (const r of res.body.rooms as IRoom[]) { + expect(r).to.not.have.property('abacAttributesRedacted'); + } + }); + + it('write guard is permissive in local mode (admin without entitlements still succeeds)', async () => { + await request + .put(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`) + .set(credentials) + .send({ values: ['v2'] }) + .expect(200); + }); + + it('local→local no-op setting write does NOT run the wipe (no new switched audit event)', async function () { + this.timeout(10000); + + const before = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + const beforeCount = (before.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length; + + await updateSetting('ABAC_Attribute_Store', 'local'); + + const after = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + const afterCount = (after.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length; + + expect(afterCount).to.equal(beforeCount); + + const roomRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const stillThere = (roomRes.body.rooms as IRoom[]).find((r) => r._id === localRoom._id); + expect(stillThere?.abacAttributes, 'no-op local→local must not wipe').to.deep.equal([{ key: localKey, values: ['v2'] }]); + }); + }); + }); +}); + +(IS_EE ? describe : describe.skip)('[ABAC] Classification banners config schema validation on save', () => { + const validConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'classification', + source: 'clearance.level', + label: 'Classification level', + showInBanner: true, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: true, + values: [ + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + ], + }; + + const saveBannersConfig = (value: string) => + request.post(api('settings/ABAC_Classification_Banners_Config')).set(credentials).send({ value }); + + before((done) => getCredentials(done)); + + after(() => updateSetting('ABAC_Classification_Banners_Config', '')); + + it('should reject a value that is not valid JSON', async () => { + await saveBannersConfig('not json {') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject a JSON document violating the schema', async () => { + await saveBannersConfig(JSON.stringify({ ...validConfig, banner: { ...validConfig.banner, fallbackColor: 'red' } })) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should accept a document matching the schema', async () => { + await saveBannersConfig(JSON.stringify(validConfig)) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should accept an empty value, leaving the setting unconfigured', async () => { + await saveBannersConfig('') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/ai-search.ts b/apps/meteor/tests/end-to-end/api/ai-search.ts new file mode 100644 index 0000000..d7664c4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/ai-search.ts @@ -0,0 +1,83 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { api, credentials, getCredentials, request } from '../../data/api-data'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('AI Search', () => { + before((done) => getCredentials(done)); + + describe('[/ai.search]', () => { + it('should fail when query is missing', async () => { + const response = await request.get(api('ai.search')).set(credentials); + + expect(response.status).to.equal(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + }); + + it('should return the AI search response shape for authenticated users', async () => { + const response = await request.get(api('ai.search')).query({ query: adminUsername, intelligentCount: 8 }).set(credentials); + + expect(response.status).to.equal(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('intelligent').that.is.an('array'); + expect(response.body).to.have.property('meta'); + expect(response.body.meta).to.include.keys(['intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured']); + }); + }); + + describe('[/ai.search.answer]', () => { + it('should fail when not authenticated', async () => { + const response = await request + .post(api('ai.search.answer')) + .send({ query: 'example question', messages: [{ _id: 'example-message-id' }] }); + + expect(response.status).to.equal(401); + }); + + it('should reject answer generation without source messages', async () => { + const response = await request.post(api('ai.search.answer')).set(credentials).send({ query: 'example question', messages: [] }); + + expect(response.status).to.equal(400); + expect(response.body).to.have.property('success', false); + }); + }); + + describe('[/ai.llm.models]', () => { + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(() => deleteUser(user)); + + it('should fail when not authenticated', async () => { + const response = await request.get(api('ai.llm.models')); + + expect(response.status).to.equal(401); + }); + + it('should fail for users without settings permissions', async () => { + const response = await request.get(api('ai.llm.models')).set(userCredentials); + + expect(response.status).to.equal(403); + expect(response.body).to.have.property('success', false); + }); + + it('should return model options for authorized users', async () => { + const response = await request.get(api('ai.llm.models')).set(credentials); + + expect(response.status).to.equal(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('data').that.is.an('array'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/assets.ts b/apps/meteor/tests/end-to-end/api/assets.ts new file mode 100644 index 0000000..63de4dc --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/assets.ts @@ -0,0 +1,15 @@ +import { describe, it } from 'mocha'; + +import { request } from '../../data/api-data'; + +describe('assets', () => { + it('should always have CORS headers for assets', async () => { + await request.get('/assets/favicon.svg').expect('Content-Type', 'image/svg+xml').expect('Access-Control-Allow-Origin', '*').expect(200); + + await request + .get('/fonts/rocketchat.woff2') + .expect('Content-Type', 'font/woff2') + .expect('Access-Control-Allow-Origin', '*') + .expect(200); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/assets2.ts b/apps/meteor/tests/end-to-end/api/assets2.ts new file mode 100644 index 0000000..46f078e --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/assets2.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { imgURL } from '../../data/interactions'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('[Assets]', () => { + before((done) => getCredentials(done)); + + before(() => updatePermission('manage-assets', ['admin'])); + + after(() => updatePermission('manage-assets', ['admin'])); + + describe('[/assets.setAsset]', () => { + it('should set the "logo" asset', async () => { + const res = await request + .post(api('assets.setAsset')) + .set(credentials) + .attach('asset', imgURL) + .field({ + assetName: 'logo', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should throw an error when we try set an invalid asset', async () => { + const res = await request + .post(api('assets.setAsset')) + .set(credentials) + .attach('invalidAsset', imgURL) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + + describe('[/assets.unsetAsset]', () => { + it('should unset the "logo" asset', async () => { + const res = await request + .post(api('assets.unsetAsset')) + .set(credentials) + .send({ + assetName: 'logo', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should throw an error when we try set an invalid asset', async () => { + const res = await request + .post(api('assets.unsetAsset')) + .set(credentials) + .send({ + assetName: 'invalidAsset', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/audit.ts b/apps/meteor/tests/end-to-end/api/audit.ts new file mode 100644 index 0000000..3b7ff18 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/audit.ts @@ -0,0 +1,439 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import EJSON from 'ejson'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Audit Panel', () => { + let testChannel: IRoom; + let testPrivateChannel: IRoom; + let dummyUser: IUser; + let auditor: IUser; + let auditorCredentials: Credentials; + before((done) => getCredentials(done)); + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + testPrivateChannel = (await createRoom({ type: 'p', name: `chat.api-test-${Date.now()}` })).body.group; + dummyUser = await createUser(); + auditor = await createUser({ roles: ['user', 'auditor'] }); + + auditorCredentials = await login(auditor.username, password); + + // the auditor role has can-audit but not can-audit-log by default; grant it suite-wide for the audit.auditions cases + await updatePermission('can-audit-log', ['admin', 'auditor']); + }); + after(async () => { + await updatePermission('can-audit-log', ['admin']); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteUser({ _id: dummyUser._id }); + await deleteUser({ _id: auditor._id }); + await deleteRoom({ type: 'p', roomId: testPrivateChannel._id }); + }); + + describe('audit/rooms.members [no permissions]', () => { + before(async () => { + await updatePermission('view-members-list-all-rooms', []); + }); + after(async () => { + await updatePermission('view-members-list-all-rooms', ['admin', 'auditor']); + }); + it('should fail if user does not have view-members-list-all-rooms permission', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect(403); + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: 'GENERAL', + }) + .expect(403); + }); + }); + + describe('audit/rooms.members', () => { + it('should fail if user is not logged in', async () => { + await request + .get(api('audit/rooms.members')) + .query({ + roomId: 'GENERAL', + }) + .expect(401); + }); + it('should fail if roomId is invalid', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: Random.id(), + }) + .expect(404); + }); + it('should fail if roomId is not present', async () => { + await request.get(api('audit/rooms.members')).set(credentials).query({}).expect(400); + }); + it('should fail if roomId is an empty string', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: '', + }) + .expect(400); + }); + it('should fetch the members of a room', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + }); + it('should persist a log entry', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + + await request + .post(methodCall('auditGetAuditions')) + .set(credentials) + .send({ + message: EJSON.stringify({ + method: 'auditGetAuditions', + params: [{ startDate: new Date(Date.now() - 86400000), endDate: new Date() }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const message = JSON.parse(res.body.message); + + expect(message.result).to.be.an('array').with.lengthOf.greaterThan(1); + const entry = message.result.find((audition: any) => { + return audition.fields?.rids?.includes(testChannel._id); + }); + expect(entry).to.have.property('u').that.is.an('object').deep.equal({ + _id: 'rocketchat.internal.admin.test', + username: 'rocketchat.internal.admin.test', + name: 'RocketChat Internal Admin Test', + }); + expect(entry).to.have.property('fields').that.is.an('object'); + const { fields } = entry; + + expect(fields).to.have.property('msg', 'Room_members_list'); + expect(fields).to.have.property('rids').that.is.an('array').with.lengthOf(1); + }); + }); + it('should fetch the members of a room with offset and count', async () => { + await request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: testChannel._id, users: [dummyUser.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 1, + count: 1, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + expect(res.body.total).to.be.equal(2); + expect(res.body.offset).to.be.equal(1); + expect(res.body.count).to.be.equal(1); + }); + }); + + it('should filter by username', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: dummyUser.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + }); + }); + + it('should filter by user name', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: dummyUser.name, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].name).to.be.equal(dummyUser.name); + }); + }); + + it('should sort by username', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: '{ "username": -1 }', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(2); + expect(res.body.members[1].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + }); + }); + + it('should not allow nosqlinjection on filter param', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: '{ "$ne": "rocketchat.internal.admin.test" }', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(0); + }); + + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: { username: 'rocketchat.internal.admin.test' }, + }) + .expect(400); + }); + + it('should allow to fetch info even if user is not in the room', async () => { + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members[0].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.members[1].username).to.be.equal(dummyUser.username); + expect(res.body.total).to.be.equal(2); + }); + }); + + it('should allow to fetch info from private rooms', async () => { + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: testPrivateChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members[0].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.total).to.be.equal(1); + }); + }); + }); + + describe('[/audit.auditions]', () => { + const startDate = new Date(0).toISOString(); + const endDate = new Date().toISOString(); + + it('should fail if user is not logged in', () => request.get(api('audit.auditions')).query({ startDate, endDate }).expect(401)); + + it('should fail with 400 when startDate/endDate are missing', () => + request.get(api('audit.auditions')).set(auditorCredentials).query({}).expect(400)); + + it('should fail with 400 when a date is invalid', () => + request.get(api('audit.auditions')).set(auditorCredentials).query({ startDate: 'not-a-date', endDate }).expect(400)); + + it('should fail with 403 when the user lacks the can-audit-log permission', async () => { + await updatePermission('can-audit-log', []); + await request.get(api('audit.auditions')).set(auditorCredentials).query({ startDate, endDate }).expect(403); + await updatePermission('can-audit-log', ['admin', 'auditor']); + }); + + it('should return the list of auditions', () => + request + .get(api('audit.auditions')) + .set(auditorCredentials) + .query({ startDate, endDate }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('auditions').that.is.an('array'); + })); + }); + + describe('[/audit.messages]', () => { + const startDate = new Date(0).toISOString(); + const endDate = new Date().toISOString(); + + it('should fail if user is not logged in', () => + request.post(api('audit.messages')).send({ startDate, endDate, users: [], msg: '', type: 'u' }).expect(401)); + + it('should fail with 400 when required params are missing', () => + request.post(api('audit.messages')).set(auditorCredentials).send({ startDate, endDate }).expect(400)); + + it('should fail with 400 when a date is invalid', () => + request + .post(api('audit.messages')) + .set(auditorCredentials) + .send({ startDate: 'not-a-date', endDate, users: [], msg: '', type: 'u' }) + .expect(400)); + + it('should fail with 403 when the user lacks the can-audit permission', async () => { + await updatePermission('can-audit', []); + await request + .post(api('audit.messages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'u' }) + .expect(403); + await updatePermission('can-audit', ['admin', 'auditor']); + }); + + it('should return matching messages', () => + request + .post(api('audit.messages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'u' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + })); + + it('should persist an audit log entry', async () => { + // query auditions with a future endDate so the entry created by the request below (ts = now) is in range + const auditionsRange = { startDate, endDate: new Date(Date.now() + 60000).toISOString() }; + const before = await request.get(api('audit.auditions')).set(auditorCredentials).query(auditionsRange); + await request + .post(api('audit.messages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'u' }) + .expect(200); + const after = await request.get(api('audit.auditions')).set(auditorCredentials).query(auditionsRange); + expect(after.body.auditions.length).to.be.greaterThan(before.body.auditions.length); + }); + }); + + describe('[/audit.omnichannelMessages]', () => { + const startDate = new Date(0).toISOString(); + const endDate = new Date().toISOString(); + + it('should fail if user is not logged in', () => + request.post(api('audit.omnichannelMessages')).send({ startDate, endDate, users: [], msg: '', type: 'l' }).expect(401)); + + it('should fail with 400 when required params are missing', () => + request.post(api('audit.omnichannelMessages')).set(auditorCredentials).send({ startDate, endDate }).expect(400)); + + it('should fail with 400 when a date is invalid', () => + request + .post(api('audit.omnichannelMessages')) + .set(auditorCredentials) + .send({ startDate: 'not-a-date', endDate, users: [], msg: '', type: 'l' }) + .expect(400)); + + it('should fail with 403 when the user lacks the can-audit permission', async () => { + await updatePermission('can-audit', []); + await request + .post(api('audit.omnichannelMessages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'l' }) + .expect(403); + await updatePermission('can-audit', ['admin', 'auditor']); + }); + + it('should return matching omnichannel messages', () => + request + .post(api('audit.omnichannelMessages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'l' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + })); + + it('should persist an audit log entry', async () => { + // query auditions with a future endDate so the entry created by the request below (ts = now) is in range + const auditionsRange = { startDate, endDate: new Date(Date.now() + 60000).toISOString() }; + const before = await request.get(api('audit.auditions')).set(auditorCredentials).query(auditionsRange); + await request + .post(api('audit.omnichannelMessages')) + .set(auditorCredentials) + .send({ startDate, endDate, users: [], msg: '', type: 'l' }) + .expect(200); + const after = await request.get(api('audit.auditions')).set(auditorCredentials).query(auditionsRange); + expect(after.body.auditions.length).to.be.greaterThan(before.body.auditions.length); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/autotranslate.ts b/apps/meteor/tests/end-to-end/api/autotranslate.ts new file mode 100644 index 0000000..4ee4556 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/autotranslate.ts @@ -0,0 +1,726 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, after, it } from 'mocha'; + +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { sendSimpleMessage } from '../../data/chat.helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +const resetAutoTranslateDefaults = async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', false), + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', false), + updateSetting('Language', ''), + updatePermission('auto-translate', ['admin']), + ]); +}; + +const resetE2EDefaults = async () => { + await Promise.all([updateSetting('E2E_Enabled_Default_PrivateRooms', false), updateSetting('E2E_Enable', false)]); +}; + +describe('AutoTranslate', () => { + before((done) => getCredentials(done)); + + describe('[AutoTranslate]', () => { + describe('[/autotranslate.getSupportedLanguages', () => { + before(() => resetAutoTranslateDefaults()); + after(() => resetAutoTranslateDefaults()); + + it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', async () => { + const res = await request + .get(api('autotranslate.getSupportedLanguages')) + .set(credentials) + .query({ + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }); + it('should throw an error when the user does not have the "auto-translate" permission', async () => { + await updateSetting('AutoTranslate_Enabled', true); + + await updatePermission('auto-translate', []); + + const res = await request + .get(api('autotranslate.getSupportedLanguages')) + .set(credentials) + .query({ + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.errorType).to.be.equal('error-action-not-allowed'); + expect(res.body.error).to.be.equal('Auto-Translate is not allowed [error-action-not-allowed]'); + }); + + it('should return a list of languages', async () => { + await updatePermission('auto-translate', ['admin']); + await request + .get(api('autotranslate.getSupportedLanguages')) + .set(credentials) + .query({ + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.languages).to.be.an('array'); + }); + }); + }); + + describe('[/autotranslate.saveSettings', () => { + let testGroupId: IRoom['_id']; + let testChannelId: IRoom['_id']; + + before(async () => { + await Promise.all([ + resetAutoTranslateDefaults(), + updateSetting('E2E_Enable', true), + updateSetting('E2E_Enabled_Default_PrivateRooms', true), + ]); + + testGroupId = (await createRoom({ type: 'p', name: `e2etest-autotranslate-${Date.now()}` })).body.group._id; + testChannelId = (await createRoom({ type: 'c', name: `test-autotranslate-${Date.now()}` })).body.channel._id; + }); + + after(async () => { + await Promise.all([ + resetAutoTranslateDefaults(), + resetE2EDefaults(), + deleteRoom({ type: 'p', roomId: testGroupId }), + deleteRoom({ type: 'c', roomId: testChannelId }), + ]); + }); + + it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslate', + defaultLanguage: 'en', + value: true, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }); + it('should throw an error when the user does not have the "auto-translate" permission', async () => { + await updateSetting('AutoTranslate_Enabled', true); + + await updatePermission('auto-translate', []); + + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + defaultLanguage: 'en', + field: 'autoTranslateLanguage', + value: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.errorType).to.be.equal('error-action-not-allowed'); + expect(res.body.error).to.be.equal('Auto-Translate is not allowed [error-action-not-allowed]'); + }); + it('should throw an error when the bodyParam "roomId" is not provided', async () => { + await updatePermission('auto-translate', ['admin']); + + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "field" is not provided', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "value" is not provided', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslate', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "autoTranslate" is not a boolean', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslate', + value: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "autoTranslateLanguage" is not a string', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslateLanguage', + value: 12, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "field" is invalid', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'invalid', + value: 12, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "roomId" is invalid or the user is not subscribed', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: 'invalid', + field: 'autoTranslateLanguage', + value: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-subscription'); + expect(res.body.error).to.be.equal('Invalid subscription [error-invalid-subscription]'); + }); + it('should throw an error when E2E encryption is enabled', async () => { + await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testGroupId, + field: 'autoTranslate', + defaultLanguage: 'en', + value: true, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-e2e-enabled'); + }); + }); + it('should return success when the setting is saved correctly', async () => { + const res = await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslateLanguage', + value: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + }); + }); + + describe('[/autotranslate.translateMessage', () => { + let messageSent: IMessage; + let testChannelId: IRoom['_id']; + + before(async () => { + await resetAutoTranslateDefaults(); + + testChannelId = (await createRoom({ type: 'c', name: `test-autotranslate-message-${Date.now()}` })).body.channel._id; + const res = await sendSimpleMessage({ + roomId: testChannelId, + text: 'Isso é um teste', + }); + messageSent = res.body.message; + }); + + after(async () => { + await Promise.all([resetAutoTranslateDefaults(), deleteRoom({ type: 'c', roomId: testChannelId })]); + }); + + it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', async () => { + const res = await request + .post(api('autotranslate.translateMessage')) + .set(credentials) + .send({ + messageId: 'test', + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }); + it('should throw an error when the bodyParam "messageId" is not provided', async () => { + await updateSetting('AutoTranslate_Enabled', true); + + await updatePermission('auto-translate', ['admin']); + + const res = await request + .post(api('autotranslate.translateMessage')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + it('should throw an error when the bodyParam "messageId" is invalid', async () => { + const res = await request + .post(api('autotranslate.translateMessage')) + .set(credentials) + .send({ + messageId: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('Message not found.'); + }); + it('should return success when the translate is successful', async () => { + const res = await request + .post(api('autotranslate.translateMessage')) + .set(credentials) + .send({ + messageId: messageSent._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + }); + + describe('room access check', () => { + let userA: TestUser; + let userB: TestUser; + let credA: Credentials; + let credB: Credentials; + let privateRoom: IRoom; + let privateMessage: IMessage; + + before(async () => { + await updateSetting('AutoTranslate_Enabled', true); + + userA = await createUser(); + userB = await createUser(); + + credA = await login(userA.username, password); + credB = await login(userB.username, password); + + privateRoom = ( + await createRoom({ + type: 'p', + name: `test-autotranslate-access-${Date.now()}`, + credentials: credA, + }) + ).body.group; + + const msgRes = await sendSimpleMessage({ + roomId: privateRoom._id, + text: 'Isso é um teste', + userCredentials: credA, + }); + privateMessage = msgRes.body.message; + }); + + after(async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', false), + deleteUser(userA), + deleteUser(userB), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + ]); + }); + + it('should return 403 forbidden when the user is not a member of the room', async () => { + const res = await request + .post(api('autotranslate.translateMessage')) + .set(credB) + .send({ + messageId: privateMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.a.property('success', false); + }); + }); + }); + + describe('[autoTranslate.translateMessage method]', () => { + let userA: TestUser; + let userB: TestUser; + let credA: Credentials; + let credB: Credentials; + let privateRoom: IRoom; + let privateMessage: IMessage; + + before(async () => { + await updateSetting('AutoTranslate_Enabled', true); + + userA = await createUser(); + userB = await createUser(); + + credA = await login(userA.username, password); + credB = await login(userB.username, password); + + privateRoom = ( + await createRoom({ + type: 'p', + name: `test-autotranslate-method-${Date.now()}`, + credentials: credA, + }) + ).body.group; + + const msgRes = await sendSimpleMessage({ + roomId: privateRoom._id, + text: 'Isso é um teste', + userCredentials: credA, + }); + privateMessage = msgRes.body.message; + }); + + after(async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', false), + deleteUser(userA), + deleteUser(userB), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + ]); + }); + + it('should fail when messageId is not a string', async () => { + const res = await request + .post(methodCall('autoTranslate.translateMessage')) + .set(credA) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'autoTranslate.translateMessage', + params: [{ _id: { $gt: '' } }, 'en'], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.a.property('error'); + }); + + it('should return error-not-allowed when the caller is not a member of the room', async () => { + const res = await request + .post(methodCall('autoTranslate.translateMessage')) + .set(credB) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'autoTranslate.translateMessage', + params: [{ _id: privateMessage._id }, 'en'], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.a.property('error'); + expect(parsedBody.error).to.have.a.property('error', 'error-not-allowed'); + }); + }); + + describe('Autoenable setting', () => { + let userA: TestUser; + let userB: TestUser; + let credA: Credentials; + let credB: Credentials; + let channel: IRoom; + const channelsToRemove: IRoom[] = []; + + const createChannel = async (members: string[] | undefined, cred: Credentials) => + (await createRoom({ type: 'c', members, name: `channel-test-${Date.now()}`, credentials: cred })).body.channel; + + const setLanguagePref = async (language: string, cred: Credentials) => { + await request + .post(api('users.setPreferences')) + .set(cred) + .send({ data: { language } }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }; + + const getSub = async (roomId: IRoom['_id'], cred: Credentials) => + ( + await request + .get(api('subscriptions.getOne')) + .set(cred) + .query({ + roomId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + }) + ).body.subscription; + + before(async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', true), + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', true), + updateSetting('Language', 'pt-BR'), + ]); + + userA = await createUser(); + userB = await createUser(); + + credA = await login(userA.username, password); + credB = await login(userB.username, password); + + channel = await createChannel(undefined, credA); + + await setLanguagePref('en', credB); + channelsToRemove.push(channel); + }); + + after(async () => { + await Promise.all([ + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', false), + updateSetting('AutoTranslate_Enabled', false), + updateSetting('Language', ''), + deleteUser(userA), + deleteUser(userB), + channelsToRemove.map(() => deleteRoom({ type: 'c', roomId: channel._id })), + ]); + }); + + it("should do nothing if the user hasn't changed his language preference", async () => { + const sub = await getSub(channel._id, credA); + expect(sub).to.not.have.property('autoTranslate'); + expect(sub).to.not.have.property('autoTranslateLanguage'); + }); + + it("should do nothing if the user changed his language preference to be the same as the server's", async () => { + await setLanguagePref('pt-BR', credA); + + const sub = await getSub(channel._id, credA); + expect(sub).to.not.have.property('autoTranslate'); + expect(sub).to.not.have.property('autoTranslateLanguage'); + }); + + it('should enable autotranslate with the correct language when joining a room', async () => { + await request + .post(api('channels.join')) + .set(credB) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const sub = await getSub(channel._id, credB); + expect(sub).to.have.property('autoTranslate'); + expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + }); + + it('should enable autotranslate with the correct language when creating a new room', async () => { + await setLanguagePref('en', credA); + + const newChannel = await createChannel(undefined, credA); + const sub = await getSub(newChannel._id, credA); + expect(sub).to.have.property('autoTranslate'); + expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); + }); + + it('should enable autotranslate for all the members added to the room upon creation', async () => { + const newChannel = await createChannel([userA.username, userB.username], credA); + const subA = await getSub(newChannel._id, credA); + expect(subA).to.have.property('autoTranslate'); + expect(subA).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + + const subB = await getSub(newChannel._id, credB); + expect(subB).to.have.property('autoTranslate'); + expect(subB).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); + }); + + it('should enable autotranslate with the correct language when added to a room', async () => { + const newChannel = await createChannel(undefined, credA); + await request + .post(api('channels.invite')) + .set(credA) + .send({ + roomId: newChannel._id, + userId: userB._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const sub = await getSub(newChannel._id, credB); + expect(sub).to.have.property('autoTranslate'); + expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); + }); + + it('should change the auto translate language when the user changes his language preference', async () => { + await setLanguagePref('es', credA); + const newChannel = await createChannel(undefined, credA); + const subscription = await getSub(newChannel._id, credA); + + expect(subscription).to.have.property('autoTranslate', true); + expect(subscription).to.have.property('autoTranslateLanguage', 'es'); + channelsToRemove.push(newChannel); + }); + + it('should enable autotranslate on existing subscriptions when user changes from workspace language to a different one', async () => { + await setLanguagePref('pt-BR', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.not.have.property('autoTranslate'); + expect(subBefore).to.not.have.property('autoTranslateLanguage'); + + await setLanguagePref('en', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.have.property('autoTranslate', true); + expect(subAfter).to.have.property('autoTranslateLanguage', 'en'); + }); + + it("should enable autotranslate on existing subscriptions when user changes from 'default' language to a non-workspace language", async () => { + await setLanguagePref('default', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.not.have.property('autoTranslate'); + expect(subBefore).to.not.have.property('autoTranslateLanguage'); + + await setLanguagePref('en', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.have.property('autoTranslate', true); + expect(subAfter).to.have.property('autoTranslateLanguage', 'en'); + }); + + it('should disable autotranslate on existing subscriptions when user changes language to default', async () => { + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.have.property('autoTranslate', true); + + await setLanguagePref('default', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.not.have.property('autoTranslate'); + expect(subAfter).to.not.have.property('autoTranslateLanguage'); + }); + + it('should disable autotranslate on existing subscriptions when user changes language to the workspace default', async () => { + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subBefore = await getSub(newChannel._id, credA); + expect(subBefore).to.have.property('autoTranslate', true); + + await setLanguagePref('pt-BR', credA); + + const subAfter = await getSub(newChannel._id, credA); + expect(subAfter).to.not.have.property('autoTranslate'); + expect(subAfter).to.not.have.property('autoTranslateLanguage'); + }); + + it('should not re-enable autotranslate for rooms where the user has explicitly opted out', async () => { + await updatePermission('auto-translate', ['admin', 'user']); + + await setLanguagePref('en', credA); + const newChannel = await createChannel(undefined, credA); + channelsToRemove.push(newChannel); + + const subEnabled = await getSub(newChannel._id, credA); + expect(subEnabled).to.have.property('autoTranslate', true); + + await request + .post(api('autotranslate.saveSettings')) + .set(credA) + .send({ roomId: newChannel._id, field: 'autoTranslate', value: false, defaultLanguage: 'en' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subOptedOut = await getSub(newChannel._id, credA); + expect(subOptedOut).to.have.property('autoTranslate', false); + + await setLanguagePref('es', credA); + + const subAfterLangChange = await getSub(newChannel._id, credA); + expect(subAfterLangChange).to.have.property('autoTranslate', false); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/banners.ts b/apps/meteor/tests/end-to-end/api/banners.ts new file mode 100644 index 0000000..6c9c112 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/banners.ts @@ -0,0 +1,288 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { getMe } from '../../data/users.helper'; +import { URL_MONGODB } from '../../e2e/config/constants'; + +describe('banners', () => { + before((done) => getCredentials(done)); + + describe('[/banners.dismiss]', () => { + it('should fail if not logged in', async () => { + const res = await request + .post(api('banners.dismiss')) + .send({ + bannerId: '123', + }) + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if missing bannerId key', async () => { + const res = await request.post(api('banners.dismiss')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + + it('should fail if bannerId is empty', async () => { + const res = await request + .post(api('banners.dismiss')) + .set(credentials) + .send({ + bannerId: '', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should fail if bannerId is invalid', async () => { + const res = await request + .post(api('banners.dismiss')) + .set(credentials) + .send({ + bannerId: '123', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + describe('banners stored in the user record', () => { + let connection: MongoClient; + + const bannerId = 'alert-user-banner-test'; + + const getUserBanners = async () => (await getMe(credentials)).banners; + + before(async () => { + connection = await MongoClient.connect(URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne( + { _id: 'rocketchat.internal.admin.test' }, + { + $set: { + [`banners.${bannerId}`]: { + id: bannerId, + priority: 10, + title: 'Banner_Title', + text: 'Banner_Text', + textArguments: [], + modifiers: [], + link: 'https://rocket.chat', + }, + }, + }, + ); + }); + + after(async () => { + await connection + .db() + .collection('users') + .updateOne( + { _id: 'rocketchat.internal.admin.test' }, + { + $unset: { [`banners.${bannerId}`]: 1 }, + }, + ); + await connection.close(); + }); + + it('should mark the banner as read on the user record', async () => { + const res = await request + .post(api('banners.dismiss')) + .set(credentials) + .send({ + bannerId, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + + const banners = await getUserBanners(); + + expect(banners).to.have.nested.property(`${bannerId}.read`, true); + }); + + it('should succeed if the banner was already dismissed', async () => { + const res = await request + .post(api('banners.dismiss')) + .set(credentials) + .send({ + bannerId, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + + const banners = await getUserBanners(); + + expect(banners).to.have.nested.property(`${bannerId}.read`, true); + }); + + it('should not add an unknown banner to the user record', async () => { + const res = await request + .post(api('banners.dismiss')) + .set(credentials) + .send({ + bannerId: 'an-unknown-banner-id', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + + const banners = await getUserBanners(); + + expect(banners).to.not.have.property('an-unknown-banner-id'); + }); + }); + }); + + describe('[/banners]', () => { + it('should fail if not logged in', async () => { + return request + .get(api('banners')) + .query({ + platform: 'web', + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if missing platform', async () => { + return request + .get(api('banners')) + .set(credentials) + .query({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it('should fail if platform is invalid', async () => { + return request + .get(api('banners')) + .set(credentials) + .query({ + platform: 'invalid-platform', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it('should succesfully return web banners', async () => { + return request + .get(api('banners')) + .set(credentials) + .query({ + platform: 'web', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('banners').that.is.an('array'); + }); + }); + + it('should succesfully return mobile banners', async () => { + return request + .get(api('banners')) + .set(credentials) + .query({ + platform: 'mobile', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('banners').that.is.an('array'); + }); + }); + }); + + describe('[/banners/:id]', () => { + it('should fail if not logged in', async () => { + return request + .get(api('banners/some-id')) + .query({ + platform: 'web', + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if missing platform', async () => { + return request + .get(api('banners/some-id')) + .set(credentials) + .query({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it('should fail if platform is invalid', async () => { + return request + .get(api('banners/some-id')) + .set(credentials) + .query({ + platform: 'invalid-platform', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it('should succesfully return a web banner by id', async () => { + return request + .get(api('banners/some-id')) + .set(credentials) + .query({ + platform: 'web', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('banners').that.is.an('array'); + }); + }); + + it('should succesfully return a mobile banner by id', async () => { + return request + .get(api('banners/some-id')) + .set(credentials) + .query({ + platform: 'mobile', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('banners').that.is.an('array'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/calendar.ts b/apps/meteor/tests/end-to-end/api/calendar.ts new file mode 100644 index 0000000..f80f435 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/calendar.ts @@ -0,0 +1,720 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { sleep } from '../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { withTimeout } from '../../data/utils'; +import { IS_EE } from '../../e2e/config/constants'; + +describe('[Calendar Events]', () => { + let user2: IUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user2 = await createUser(); + userCredentials = await login(user2.username, password); + }); + + after(() => deleteUser(user2)); + + describe('[/calendar-events.create]', () => { + it('should successfully create an event in the calendar', async () => { + let eventId: string | undefined; + + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + eventId = res.body.id; + }); + + after(async () => { + await request.post(api('calendar-events.delete')).set(credentials).send({ eventId }); + }); + }); + + it('should fail to create an event without a start time', async () => { + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to create an event without a subject', async () => { + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + description: 'Description', + startTime: new Date().toISOString(), + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to create an event without a description', async () => { + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + subject: 'Subject', + startTime: new Date().toISOString(), + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should successfully create an event without reminder information', async () => { + let eventId: string | undefined; + + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + eventId = res.body.id; + }); + + after(async () => { + await request.post(api('calendar-events.delete')).set(credentials).send({ eventId }); + }); + }); + }); + + describe('[/calendar-events.list]', () => { + const testSubject = `calendar-events.list-${Date.now()}`; + const testSubject2 = `calendar-events.list-${Date.now()}`; + let eventId: string | undefined; + let eventId2: string | undefined; + let eventId3: string | undefined; + + before('create sample events', async () => { + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: testSubject, + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId = res.body.id; + }); + + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + startTime: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), + subject: testSubject2, + description: 'Future Event', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId2 = res.body.id; + }); + + await request + .post(api('calendar-events.create')) + .set(userCredentials) + .send({ + startTime: new Date().toISOString(), + subject: testSubject, + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId3 = res.body.id; + }); + }); + + after(() => + Promise.all([ + request.post(api('calendar-events.delete')).set(credentials).send({ eventId }), + request.post(api('calendar-events.delete')).set(credentials).send({ eventId: eventId2 }), + request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId: eventId3 }), + ]), + ); + + it('should list only the events with the same date', async () => { + await request + .get(api('calendar-events.list')) + .set(credentials) + .query({ + date: new Date().toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('data').that.is.an('array'); + + const events = res.body.data.map((event: any) => event._id); + expect(events).to.be.an('array').that.includes(eventId); + expect(events).to.not.includes(eventId2); + }); + }); + + it('should nost list events from other users', async () => { + await request + .get(api('calendar-events.list')) + .set(userCredentials) + .query({ + date: new Date().toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('data').that.is.an('array'); + + const events = res.body.data.map((event: any) => event._id); + expect(events).to.be.an('array').that.includes(eventId3); + expect(events).to.not.includes(eventId); + }); + }); + }); + + describe('[/calendar-events.info]', () => { + const testSubject = `calendar-events.info-${Date.now()}`; + let eventId: string | undefined; + let eventId2: string | undefined; + + before('create sample events', async () => { + await request + .post(api('calendar-events.create')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: testSubject, + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId = res.body.id; + }); + + await request + .post(api('calendar-events.create')) + .set(userCredentials) + .send({ + startTime: new Date().toISOString(), + subject: testSubject, + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId2 = res.body.id; + }); + }); + + after(() => + Promise.all([ + request.post(api('calendar-events.delete')).set(credentials).send({ eventId }), + request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId: eventId2 }), + ]), + ); + + it('should return the event information', async () => { + await request + .get(api('calendar-events.info')) + .set(credentials) + .query({ + id: eventId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', testSubject); + }); + }); + + it('should return the event information - regular user', async () => { + await request + .get(api('calendar-events.info')) + .set(userCredentials) + .query({ + id: eventId2, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', testSubject); + }); + }); + + it('should fail when querying an invalid event', async () => { + await request + .get(api('calendar-events.info')) + .set(credentials) + .query({ + id: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail when querying an event from another user', async () => { + await request + .get(api('calendar-events.info')) + .set(credentials) + .query({ + id: eventId2, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); + + describe('[/calendar-events.import]', () => { + const eventsToRemove: Record[] = []; + + after(() => + Promise.all([ + ...eventsToRemove.map(({ credentials, eventId }) => request.post(api('calendar-events.delete')).set(credentials).send({ eventId })), + ]), + ); + + it('should successfully import an event to the calendar', async () => { + let eventId: string | undefined; + const externalId = `calendar-events.import-${Date.now()}`; + + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + externalId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + eventId = res.body.id; + eventsToRemove.push({ credentials, eventId }); + }); + }); + + it('should fail to import an event without an external id', async () => { + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to import an event without a start time', async () => { + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to import an event without a subject', async () => { + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + description: 'Description', + startTime: new Date().toISOString(), + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to import an event without a description', async () => { + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + subject: 'Subject', + startTime: new Date().toISOString(), + reminderMinutesBeforeStart: 10, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should successfully import an event without reminder information', async () => { + let eventId: string | undefined; + + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + externalId: `calendar-events.import-external-id-${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + eventId = res.body.id; + }); + + after(async () => { + await request.post(api('calendar-events.delete')).set(credentials).send({ eventId }); + }); + }); + + it('should import a new event even if it was already imported by another user', async () => { + let eventId: string | undefined; + let eventId2: string | undefined; + const externalId = `calendar-events.import-${Date.now()}`; + + await request + .post(api('calendar-events.import')) + .set(userCredentials) + .send({ + startTime: new Date().toISOString(), + subject: 'First User', + description: 'Description', + reminderMinutesBeforeStart: 10, + externalId, + }) + .then((res) => { + eventId = res.body.id; + eventsToRemove.push({ credentials: userCredentials, eventId }); + }); + + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Second User', + description: 'Description', + reminderMinutesBeforeStart: 10, + externalId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.id).to.not.be.equal(eventId); + eventId2 = res.body.id; + eventsToRemove.push({ credentials, eventId: eventId2 }); + }); + + await request + .get(api('calendar-events.info')) + .set(userCredentials) + .query({ id: eventId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'First User'); + }); + + await request + .get(api('calendar-events.info')) + .set(credentials) + .query({ id: eventId2 }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'Second User'); + }); + }); + + it('should update an event that has the same external id', async () => { + let eventId: string | undefined; + const externalId = `calendar-events.import-twice-${Date.now()}`; + + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + externalId, + }) + .then((res) => { + eventId = res.body.id; + eventsToRemove.push({ credentials, eventId }); + }); + + await request + .post(api('calendar-events.import')) + .set(credentials) + .send({ + startTime: new Date().toISOString(), + subject: 'New Subject', + description: 'New Description', + reminderMinutesBeforeStart: 15, + externalId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('id', eventId); + }); + + await request + .get(api('calendar-events.info')) + .set(credentials) + .query({ id: eventId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object').with.property('subject', 'New Subject'); + }); + }); + }); + + describe('[/calendar-events.update]', () => { + const testSubject = `calendar-events.update-${Date.now()}`; + let eventId: string | undefined; + + before('create sample events', async () => { + await request + .post(api('calendar-events.create')) + .set(userCredentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Old Subject', + description: 'Old Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId = res.body.id; + }); + }); + + after(async () => { + await request.post(api('calendar-events.delete')).set(userCredentials).send({ eventId }); + }); + + it('should update the event with the new data', async () => { + await request + .post(api('calendar-events.update')) + .set(userCredentials) + .send({ + eventId, + startTime: new Date().toISOString(), + subject: testSubject, + description: 'New Description', + reminderMinutesBeforeStart: 15, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('calendar-events.info')) + .set(userCredentials) + .query({ id: eventId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('event').that.is.an('object'); + expect(res.body.event).to.have.property('subject', testSubject); + expect(res.body.event).to.have.property('description', 'New Description'); + }); + }); + + it('should fail to update an event that doesnt exist', async () => { + await request + .post(api('calendar-events.update')) + .set(userCredentials) + .send({ + eventId: 'something-random', + startTime: new Date().toISOString(), + subject: testSubject, + description: 'New Description', + reminderMinutesBeforeStart: 15, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to update an event from another user', async () => { + await request + .post(api('calendar-events.update')) + .set(credentials) + .send({ + eventId, + startTime: new Date().toISOString(), + subject: 'Another Subject', + description: 'Third Description', + reminderMinutesBeforeStart: 20, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); + + describe('[/calendar-events.delete]', () => { + let eventId: string | undefined; + + before('create sample events', async () => { + await request + .post(api('calendar-events.create')) + .set(userCredentials) + .send({ + startTime: new Date().toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 10, + }) + .then((res) => { + eventId = res.body.id; + }); + }); + + it('should fail to delete an event from another user', async () => { + await request + .post(api('calendar-events.delete')) + .set(credentials) + .send({ + eventId, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should delete the specified event', async () => { + await request + .post(api('calendar-events.delete')) + .set(userCredentials) + .send({ + eventId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('calendar-events.info')) + .set(userCredentials) + .query({ id: eventId }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail to delete an event that doesnt exist', async () => { + await request + .post(api('calendar-events.delete')) + .set(userCredentials) + .send({ + eventId: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); + + (IS_EE ? describe : describe.skip)('[Calendar Events Status Sync]', () => { + before(async () => { + await request.post('/api/v1/users.setStatus').set(userCredentials).send({ status: 'online' }).expect(200); + }); + + // claim apply/clear land asynchronously, so poll instead of asserting on a fixed sleep + const waitForStatusSource = ( + predicate: (source: string | undefined) => boolean, + { timeout = 15000, interval = 500 } = {}, + ): Promise => + withTimeout(async (signal) => { + for (;;) { + const res = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); + const source = res.body.statusSource; + if (predicate(source) || signal.aborted) { + return source; + } + await sleep(interval); + } + }, timeout); + + it('should apply a calendar (external) claim for an in-progress event and clear it when removed', async () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 1000); + const endTime = new Date(now.getTime() + 60 * 60 * 1000); + + const eventPayload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 1, + busy: true, + }; + + const createResponse = await request.post('/api/v1/calendar-events.create').set(userCredentials).send(eventPayload).expect(200); + const eventId = createResponse.body.id; + + // REST-only user has no DDP session, so assert the claim via statusSource, not display status + expect(await waitForStatusSource((source) => source === 'external')).to.equal('external'); + + // the claim must carry the event's endTime as its expiry, so the presence engine can auto-clear it + const status = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); + expect(new Date(status.body.statusExpiresAt).getTime()).to.equal(endTime.getTime()); + + await request.post('/api/v1/calendar-events.delete').set(userCredentials).send({ eventId }).expect(200); + + expect(await waitForStatusSource((source) => source === undefined)).to.be.undefined; + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/call-history.ts b/apps/meteor/tests/end-to-end/api/call-history.ts new file mode 100644 index 0000000..ef2bdd0 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/call-history.ts @@ -0,0 +1,560 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Call History]', () => { + let user2: IUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user2 = await createUser(); + userCredentials = await login(user2.username, password); + }); + + after(() => deleteUser(user2)); + + describe('[/call-history.list]', () => { + it('should list all history entries for that uid', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(6); + expect(res.body).to.have.property('total', 6); + expect(res.body).to.have.property('count', 6); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound.2'); + + const internalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.outbound'); + expect(internalItem1).to.have.property('callId', 'rocketchat.internal.call.test'); + expect(internalItem1).to.have.property('state', 'ended'); + expect(internalItem1).to.have.property('type', 'media-call'); + expect(internalItem1).to.have.property('duration', 10); + expect(internalItem1).to.have.property('external', false); + expect(internalItem1).to.have.property('direction', 'outbound'); + expect(internalItem1).to.have.property('contactId'); + expect(internalItem1).to.have.property('contactName', 'Pineapple'); + expect(internalItem1).to.have.property('contactUsername', 'fruit-001'); + + const internalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.inbound'); + expect(internalItem2).to.have.property('callId', 'rocketchat.internal.call.test.2'); + expect(internalItem2).to.have.property('state', 'not-answered'); + expect(internalItem2).to.have.property('type', 'media-call'); + expect(internalItem2).to.have.property('duration', 10); + expect(internalItem2).to.have.property('external', false); + expect(internalItem2).to.have.property('direction', 'inbound'); + expect(internalItem2).to.have.property('contactId'); + expect(internalItem2).to.have.property('contactName', 'Apple'); + expect(internalItem2).to.have.property('contactUsername', 'fruit-002'); + + const internalItem3 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.outbound.2'); + expect(internalItem3).to.have.property('callId', 'rocketchat.extra.call.test.1'); + expect(internalItem3).to.have.property('state', 'transferred'); + expect(internalItem3).to.have.property('direction', 'outbound'); + expect(internalItem3).to.have.property('contactName', 'Grapefruit 002'); + + const internalItem4 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.inbound.2'); + expect(internalItem4).to.have.property('callId', 'rocketchat.extra.call.test.2'); + expect(internalItem4).to.have.property('state', 'transferred'); + expect(internalItem4).to.have.property('direction', 'inbound'); + expect(internalItem4).to.have.property('contactName', 'Pasta 1'); + + const externalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.external.history.test.outbound'); + expect(externalItem1).to.have.property('callId', 'rocketchat.external.call.test.outbound'); + expect(externalItem1).to.have.property('state', 'failed'); + expect(externalItem1).to.have.property('type', 'media-call'); + expect(externalItem1).to.have.property('duration', 10); + expect(externalItem1).to.have.property('external', true); + expect(externalItem1).to.have.property('direction', 'outbound'); + expect(externalItem1).to.have.property('contactExtension', '1001'); + + const externalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.external.history.test.inbound'); + expect(externalItem2).to.have.property('callId', 'rocketchat.external.call.test.inbound'); + expect(externalItem2).to.have.property('state', 'ended'); + expect(externalItem2).to.have.property('type', 'media-call'); + expect(externalItem2).to.have.property('duration', 10); + expect(externalItem2).to.have.property('external', true); + expect(externalItem2).to.have.property('direction', 'inbound'); + expect(externalItem2).to.have.property('contactExtension', '1002'); + }); + }); + + it('should not list history entries from other users', async () => { + await request + .get(api('call-history.list')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(0); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('count', 0); + }); + }); + + it('should apply filter by state', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['ended'], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter by multiple states', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['failed', 'ended'], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(3); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('count', 3); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter by direction', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + direction: 'inbound', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(3); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('count', 3); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound.2'); + }); + }); + + it('should apply filter by state and direction', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['failed', 'ended'], + direction: 'inbound', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('count', 1); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should return item that match full contact name', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'Pineapple', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('count', 1); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + }); + }); + + it('should return items that match partial contact name', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'Apple', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + }); + }); + + it('should return item that match full contact username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit-001', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('count', 1); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + }); + }); + + it('should return items that match partial contact username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit-', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + }); + }); + + it('should return items that match partial contact name or username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(3); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('count', 3); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + }); + }); + + it('should return item that match full contact extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '1001', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('count', 1); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + }); + }); + + it('should return items that match partial contact extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '100', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should return items that match partial contact name, username or extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '002', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(3); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('count', 3); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter with falsy value', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '0', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(5); + expect(res.body).to.have.property('total', 5); + expect(res.body).to.have.property('count', 5); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + }); + + describe('[/call-history.info]', () => { + it('should return the history entry information', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + historyId: 'rocketchat.internal.history.test.outbound', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('item').that.is.an('object'); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { item, call } = res.body; + expect(item).to.have.property('_id', 'rocketchat.internal.history.test.outbound'); + expect(item).to.have.property('callId', 'rocketchat.internal.call.test'); + expect(item).to.have.property('state', 'ended'); + expect(item).to.have.property('type', 'media-call'); + expect(item).to.have.property('duration', 10); + expect(item).to.have.property('external', false); + expect(item).to.have.property('direction', 'outbound'); + expect(item).to.have.property('contactId'); + expect(item).to.have.property('contactName', 'Pineapple'); + expect(item).to.have.property('contactUsername', 'fruit-001'); + expect(item).to.have.property('ts'); + expect(item).to.have.property('endedAt'); + + expect(call).to.have.property('_id', 'rocketchat.internal.call.test'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should return the history entry information when searching by call id', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + callId: 'rocketchat.internal.call.test.2', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('item').that.is.an('object'); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { item, call } = res.body; + expect(item).to.have.property('_id', 'rocketchat.internal.history.test.inbound'); + expect(item).to.have.property('callId', 'rocketchat.internal.call.test.2'); + expect(item).to.have.property('state', 'not-answered'); + expect(item).to.have.property('type', 'media-call'); + expect(item).to.have.property('duration', 10); + expect(item).to.have.property('external', false); + expect(item).to.have.property('direction', 'inbound'); + expect(item).to.have.property('contactId'); + expect(item).to.have.property('contactName', 'Apple'); + expect(item).to.have.property('contactUsername', 'fruit-002'); + expect(item).to.have.property('ts'); + expect(item).to.have.property('endedAt'); + + expect(call).to.have.property('_id', 'rocketchat.internal.call.test.2'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should fail when querying an invalid entry', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + historyId: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an invalid entry by call id', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + callId: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an entry from another user', async () => { + await request + .get(api('call-history.info')) + .set(userCredentials) + .query({ + historyId: 'rocketchat.internal.history.test.outbound', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an entry from another user by call id', async () => { + await request + .get(api('call-history.info')) + .set(userCredentials) + .query({ + callId: 'rocketchat.internal.call.test.2', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts new file mode 100644 index 0000000..527f815 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/channels.ts @@ -0,0 +1,4776 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import { TeamType, type IIntegration, type IMessage, type IRoom, type ITeam, type IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect, assert } from 'chai'; +import { after, before, describe, it, beforeEach } from 'mocha'; + +import { getCredentials, api, request, credentials, reservedWords } from '../../data/api-data'; +import { pinMessage, sendMessage, starMessage, updateMessage } from '../../data/chat.helper'; +import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; +import { createIntegration, removeIntegration } from '../../data/integration.helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { testFileUploads } from '../../data/uploads.helper'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, login, deleteUser } from '../../data/users.helper'; + +function getRoomInfo(roomId: IRoom['_id']) { + return new Promise<{ channel: IRoom }>((resolve) => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId, + }) + .end((_err, req) => { + resolve(req.body); + }); + }); +} + +describe('[Channels]', () => { + let channel: Pick; + const apiPublicChannelName = `api-channel-test-${Date.now()}`; + + before((done) => getCredentials(done)); + + before('Creating channel', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: apiPublicChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + channel = { + _id: res.body.channel._id, + name: res.body.channel.name, + }; + }) + .end(done); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: channel._id }); + }); + + it('/channels.invite', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addModerator', async () => { + const res = await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.addModerator should fail with missing room Id', async () => { + const res = await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('/channels.addModerator should fail with missing user Id', async () => { + const res = await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('/channels.removeModerator', async () => { + const res = await request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.removeModerator should fail on invalid room id', async () => { + const res = await request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('/channels.removeModerator should fail on invalid user id', async () => { + const res = await request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('/channels.addOwner', async () => { + const res = await request + .post(api('channels.addOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.removeOwner', async () => { + const res = await request + .post(api('channels.removeOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.kick', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.kick')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.invite', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addOwner', async () => { + const res = await request + .post(api('channels.addOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.archive', async () => { + const res = await request + .post(api('channels.archive')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.unarchive', async () => { + const res = await request + .post(api('channels.unarchive')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.close', async () => { + const res = await request + .post(api('channels.close')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.close', async () => { + const res = await request + .post(api('channels.close')) + .set(credentials) + .send({ + roomName: apiPublicChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The channel, ${apiPublicChannelName}, is already closed to the sender`); + }); + + it('/channels.open', async () => { + const res = await request + .post(api('channels.open')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + describe('/channels.list', () => { + let testChannel: IRoom; + before(async () => { + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + await updatePermission('view-joined-room', ['guest', 'bot', 'app', 'anonymous']); + testChannel = (await createRoom({ type: 'c', name: `channels.messages.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + await updatePermission('view-joined-room', ['guest', 'bot', 'app', 'anonymous']); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should succesfully return a list of channels', async () => { + await request + .get(api('channels.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should correctly filter channel by id', async () => { + await request + .get(api('channels.list')) + .set(credentials) + .query({ + _id: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array').of.length(1); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total'); + }); + }); + + it('should not be succesful when user does NOT have the permission to view channels or joined rooms', async () => { + await updatePermission('view-c-room', []); + await updatePermission('view-joined-room', []); + await request + .get(api('channels.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should be succesful when user does NOT have the permission to view channels, but can view joined rooms', async () => { + await updatePermission('view-c-room', []); + await updatePermission('view-joined-room', ['admin']); + await request + .get(api('channels.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should be succesful when user does NOT have the permission to view joined rooms, but can view channels', async () => { + await updatePermission('view-c-room', ['admin']); + await updatePermission('view-joined-room', []); + await request + .get(api('channels.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should paginate', async () => { + const { + body: { channels: channels1 }, + } = await request.get(api('channels.list')).set(credentials); + const { + body: { channels: channels2 }, + } = await request.get(api('channels.list')).set(credentials).query({ offset: 1 }); + expect(channels1).to.not.deep.equal(channels2); + }); + + it('should accept the `fields` query param', async () => { + await request + .get(api('channels.list')) + .set(credentials) + .query({ + fields: JSON.stringify({ name: 1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should accept the `fields` query param alongside the other supported params', async () => { + await request + .get(api('channels.list')) + .set(credentials) + .query({ + _id: testChannel._id, + query: JSON.stringify({}), + fields: JSON.stringify({ name: 1 }), + sort: JSON.stringify({ name: 1 }), + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + }); + }); + + it('should reject unknown query params', async () => { + await request + .get(api('channels.list')) + .set(credentials) + .query({ + invalidParam: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'must NOT have additional properties'); + }); + }); + }); + + it('/channels.list.joined', async () => { + const res = await request + .get(api('channels.list.joined')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + + describe('[/channels.list.joined]', () => { + it('should accept the `fields` query param', async () => { + const res = await request + .get(api('channels.list.joined')) + .set(credentials) + .query({ + fields: JSON.stringify({ name: 1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + + it('should accept the `fields` query param alongside the other supported params', async () => { + const res = await request + .get(api('channels.list.joined')) + .set(credentials) + .query({ + roomId: channel._id, + roomName: channel.name, + _id: channel._id, + query: JSON.stringify({}), + fields: JSON.stringify({ name: 1 }), + sort: JSON.stringify({ name: 1 }), + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channels').that.is.an('array'); + }); + + it('should reject unknown query params', async () => { + await request + .get(api('channels.list.joined')) + .set(credentials) + .query({ + invalidParam: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'must NOT have additional properties'); + }); + }); + }); + + describe('/channels.counters', () => { + let room: IRoom; + let user1: IUser; + let user2: IUser; + let user1Creds: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before(async () => { + // Create two users + user1 = await createUser(); + user2 = await createUser(); + user1Creds = await login(user1.username, password); + + // Create a new public channel with both users as members + room = ( + await createRoom({ + type: 'c', + name: `counters-test-${Date.now()}`, + members: [user1.username as string, user2.username as string], + }) + ).body.channel; + }); + + after(async () => { + // Delete room first + await deleteRoom({ type: 'c', roomId: room._id }); + // Then delete users + await Promise.all([deleteUser(user1), deleteUser(user2)]); + }); + + it('should require auth', async () => { + await request + .get(api('channels.counters')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should require a roomId', async () => { + await request + .get(api('channels.counters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return counters for a channel with correct fields', async () => { + await request + .get(api('channels.counters')) + .set(user1Creds) + .query({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').that.is.a('number').and.equals(3); + expect(res.body).to.have.property('unreads').that.is.a('number'); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs').that.is.a('number'); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('joined', true); + }); + }); + + it('should not include deactivated users in members count', async () => { + // Deactivate the second user + await request.post(api('users.setActiveStatus')).set(credentials).send({ userId: user2._id, activeStatus: false }); + + const res = await request.get(api('channels.counters')).set(user1Creds).query({ roomId: room._id }); + + expect(res.status).to.equal(200); + expect(res.body.success).to.be.true; + // Only user1 and admin remain active + expect(res.body.members).to.equal(2); + }); + }); + + it('/channels.rename', async () => { + const roomInfo = await getRoomInfo(channel._id); + + function failRenameChannel(name: string) { + it(`should not rename a channel to the reserved name ${name}`, async () => { + const res = await request + .post(api('channels.rename')) + .set(credentials) + .send({ + roomId: channel._id, + name, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `${name} is already in use :( [error-field-unavailable]`); + }); + } + + reservedWords.forEach((name) => { + failRenameChannel(name); + }); + + return request + .post(api('channels.rename')) + .set(credentials) + .send({ + roomId: channel._id, + name: `EDITED${apiPublicChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addAll', async () => { + const res = await request + .post(api('channels.addAll')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + }); + + it('/channels.addLeader', async () => { + const res = await request + .post(api('channels.addLeader')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + }); + it('/channels.removeLeader', async () => { + const res = await request + .post(api('channels.removeLeader')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('/channels.setJoinCode', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.setJoinCode')) + .set(credentials) + .send({ + roomId: channel._id, + joinCode: '123', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); + }); + }); + + it('/channels.setReadOnly', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.setReadOnly')) + .set(credentials) + .send({ + roomId: channel._id, + readOnly: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + it('/channels.leave', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.leave')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + describe('[/channels.create]', () => { + let guestUser: TestUser; + let invitedUser: TestUser; + let invitedUserCredentials: Credentials; + let room: IRoom; + let teamId: ITeam['_id']; + + before(async () => { + guestUser = await createUser({ roles: ['guest'] }); + invitedUser = await createUser(); + invitedUserCredentials = await login(invitedUser.username, password); + + await updatePermission('create-team', ['admin', 'user']); + const teamCreateRes = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `team-${Date.now()}`, + type: 0, + members: [invitedUser.username], + }); + + teamId = teamCreateRes.body.team._id; + await updatePermission('create-team-channel', ['owner']); + }); + after(async () => { + await deleteUser(guestUser); + await deleteUser(invitedUser); + await updatePermission('create-team-channel', ['admin', 'owner', 'moderator']); + }); + + it(`should fail when trying to use an existing room's name`, async () => { + await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: 'general', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-duplicate-channel-name'); + }); + }); + + it('should not add guest users to more rooms than defined in the license', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!process.env.IS_EE) { + this.skip(); + } + + const promises = []; + for (let i = 0; i < maxRoomsPerGuest; i++) { + promises.push( + createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }), + ); + } + const channelIds = (await Promise.all(promises)).map((r) => r.body.channel).map((channel) => channel._id); + + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + room = res.body.group; + }) + .then(() => { + void request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + }); + + await Promise.all(channelIds.map((id) => deleteRoom({ type: 'c', roomId: id }))); + }); + + it('should successfully create a channel in a team', async () => { + await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `team-channel-${Date.now()}`, + extraData: { teamId }, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + expect(res.body.channel).to.have.property('teamId', teamId); + }); + }); + + it('should fail creating a channel in a team when member does not have the necessary permission', async () => { + await request + .post(api('channels.create')) + .set(invitedUserCredentials) + .send({ + name: `team-channel-${Date.now()}`, + extraData: { teamId }, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + describe('E2E forced encryption for private rooms', () => { + let createdRoomId: IRoom['_id'] | undefined; + + before(async () => { + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]); + }); + + after(async () => { + await Promise.all([ + updateSetting('E2E_Enable', false), + updateSetting('E2E_Force_Encryption_For_Private_Rooms', false), + ...(createdRoomId ? [deleteRoom({ type: 'c', roomId: createdRoomId })] : []), + ]); + }); + + it('should not force encryption on public channels when private room encryption is forced', async () => { + await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `forced-e2e-public-${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + createdRoomId = res.body.channel?._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.not.have.nested.property('channel.encrypted', true); + }); + }); + }); + }); + + describe('[/channels.info]', () => { + const testChannelName = `api-channel-test-${Date.now()}.${Random.id()}`; + let testChannel: IRoom; + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('creating new channel...', async () => { + const res = await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: testChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + testChannel = res.body.channel; + }); + it('should fail to create the same channel twice', async () => { + const res = await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: testChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.contain('error-duplicate-channel-name'); + }); + it('should return channel basic structure', async () => { + const res = await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', testChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }); + + let channelMessage: IMessage; + + it('sending a message...', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testChannel._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + channelMessage = res.body.message; + }); + it('REACTing with last message', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: channelMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('STARring last message', async () => { + const res = await request + .post(api('chat.starMessage')) + .set(credentials) + .send({ + messageId: channelMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('PINning last message', async () => { + const res = await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: channelMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return channel structure with "lastMessage" object including pin, reactions and star(should be an array) infos', async () => { + const res = await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel').and.to.be.an('object'); + const { channel } = res.body; + expect(channel).to.have.property('lastMessage').and.to.be.an('object'); + expect(channel.lastMessage).to.have.property('reactions').and.to.be.an('object'); + expect(channel.lastMessage).to.have.property('pinned').and.to.be.a('boolean'); + expect(channel.lastMessage).to.have.property('pinnedAt').and.to.be.a('string'); + expect(channel.lastMessage).to.have.property('pinnedBy').and.to.be.an('object'); + expect(channel.lastMessage).to.have.property('starred').and.to.be.an('array'); + }); + it('should return all channels messages where the last message of array should have the "star" array with USERS star ONLY', async () => { + const res = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const messages = res.body.messages as IMessage[]; + const lastMessage = messages.filter((message) => message._id === channelMessage._id)[0]; + expect(lastMessage).to.have.property('starred').and.to.be.an('array'); + expect(lastMessage.starred?.[0]._id).to.be.equal(adminUsername); + }); + it('should return all channels messages where the last message of array should have the "star" array with USERS star ONLY even requested with count and offset params', async () => { + const res = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const messages = res.body.messages as IMessage[]; + const lastMessage = messages.filter((message) => message._id === channelMessage._id)[0]; + expect(lastMessage).to.have.property('starred').and.to.be.an('array'); + expect(lastMessage.starred?.[0]._id).to.be.equal(adminUsername); + }); + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `channels.info.team.public.${Random.id()}`, TeamType.PUBLIC, [outsiderUser.username as string]), + createTeam(insideCredentials, `channels.info.team.private.${Random.id()}`, TeamType.PRIVATE, [outsiderUser.username as string]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `channels.info.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `channels.info.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room info by user not part of room', async () => { + await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch private room info by user who is part of the room', async () => { + const response = await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body.success).to.be.false; + }); + + it('should fetch public room info by user who is part of the room', async () => { + const response = await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('channel'); + }); + + it('should fetch public room info by user not part of room - because public', async () => { + const response = await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('channel'); + }); + + it('should not fetch a private channel info inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel info inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel info inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.info')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel info inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + }); + }); + + it('should fetch a public channel info inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + }); + }); + + it('should fetch a public channel info inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.info')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + }); + }); + + it('should fetch a public channel info inside a private team by someone part of the room', async () => { + await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + }); + }); + + it('should fetch a public channel info inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('channel'); + }); + }); + + it('should not fetch a public channel info inside a private team by someone not part of team', async () => { + await request + .get(api('channels.info')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel info inside a private team by someone part of the room', async () => { + await request + .get(api('channels.info')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel info inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.info')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel info inside a private team by someone not part of team', async () => { + await request + .get(api('channels.info')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('[/channels.online]', () => { + const createdChannels: IRoom[] = []; + const createdUsers: TestUser[] = []; + + const createUserAndChannel = async () => { + const testUser = await createUser(); + const testUserCredentials = await login(testUser.username, password); + createdUsers.push(testUser); + + await request.post(api('users.setStatus')).set(testUserCredentials).send({ + message: '', + status: 'online', + }); + + const roomName = `group-test-${Date.now()}`; + + const roomResponse = await createRoom({ + name: roomName, + type: 'c', + members: [testUser.username], + }); + createdChannels.push(roomResponse.body.channel); + + return { + testUser, + testUserCredentials, + room: roomResponse.body.channel, + }; + }; + + after(async () => { + await Promise.all([ + createdUsers.map((user) => deleteUser(user)), + createdChannels.map((channel) => deleteRoom({ type: 'c', roomId: channel._id })), + ]); + }); + + it('should return an error if no query', () => + void request + .get(api('channels.online')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid query'); + })); + + it('should return an error if passing an empty query', () => + void request + .get(api('channels.online')) + .set(credentials) + .query('query={}') + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid query'); + })); + + it('should return an array with online members', async () => { + const { testUser, testUserCredentials, room } = await createUserAndChannel(); + + return request + .get(api('channels.online')) + .set(testUserCredentials) + .query({ _id: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('online'); + + const expected = { + _id: testUser._id, + username: testUser.username, + }; + expect(res.body.online).to.deep.include(expected); + }); + }); + + it('should return an empty array if requesting user is not in channel', async () => { + const outsider = await createUser(); + const outsiderCredentials = await login(outsider.username, password); + + const { testUser, room } = await createUserAndChannel(); + + return request + .get(api('channels.online')) + .set(outsiderCredentials) + .query({ _id: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('online'); + + const expected = { + _id: testUser._id, + username: testUser.username, + }; + expect(res.body.online).to.deep.include(expected); + }); + }); + }); + + describe('[/channels.files]', async () => { + await testFileUploads('channels.files', 'c'); + }); + + describe('[/channels.join]', () => { + let testChannelNoCode: IRoom; + let testChannelWithCode: IRoom; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before('Create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + testChannelNoCode = (await createRoom({ type: 'c', credentials: testUserCredentials, name: `${apiPublicChannelName}-nojoincode` })) + .body.channel; + testChannelWithCode = ( + await createRoom({ type: 'c', credentials: testUserCredentials, name: `${apiPublicChannelName}-withjoincode` }) + ).body.channel; + await updatePermission('edit-room', ['admin', 'owner', 'moderator']); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: testChannelNoCode._id }), + deleteRoom({ type: 'c', roomId: testChannelWithCode._id }), + deleteUser(testUser), + updatePermission('edit-room', ['admin', 'owner', 'moderator']), + updatePermission('join-without-join-code', ['admin', 'bot', 'app']), + ]); + }); + + before('Set code for channel', (done) => { + void request + .post(api('channels.setJoinCode')) + .set(testUserCredentials) + .send({ + roomId: testChannelWithCode._id, + joinCode: '123', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('should fail if invalid channel', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + + describe('code-free channel', () => { + it('should succeed when joining code-free channel without join code', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelNoCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannelNoCode._id); + }); + }); + + describe('code-needed channel', () => { + describe('without join-without-join-code permission', () => { + before('set join-without-join-code permission to false', async () => { + await updatePermission('join-without-join-code', []); + }); + + it('should fail when joining code-needed channel without join code and no join-without-join-code permission', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-code-required'); + }); + + it('should fail when joining code-needed channel with incorrect join code and no join-without-join-code permission', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + joinCode: 'WRONG_CODE', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-code-invalid'); + }); + + it('should succeed when joining code-needed channel with join code', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + joinCode: '123', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); + }); + }); + + describe('with join-without-join-code permission', () => { + before('set join-without-join-code permission to true', async () => { + await updatePermission('join-without-join-code', ['admin']); + }); + + before('leave channel', (done) => { + void request + .post(api('channels.leave')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('should succeed when joining code-needed channel without join code and with join-without-join-code permission', async () => { + const res = await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); + }); + }); + }); + }); + + describe('/channels.setDescription', () => { + it('should set the description of the channel with a string', async () => { + const res = await request + .post(api('channels.setDescription')) + .set(credentials) + .send({ + roomId: channel._id, + description: 'this is a description for a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('description', 'this is a description for a channel for api tests'); + }); + it('should set the description of the channel with an empty string(remove the description)', async () => { + const res = await request + .post(api('channels.setDescription')) + .set(credentials) + .send({ + roomId: channel._id, + description: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('description', ''); + }); + }); + + describe('/channels.setTopic', () => { + it('should set the topic of the channel with a string', async () => { + const res = await request + .post(api('channels.setTopic')) + .set(credentials) + .send({ + roomId: channel._id, + topic: 'this is a topic of a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', 'this is a topic of a channel for api tests'); + }); + it('should set the topic of the channel with an empty string(remove the topic)', async () => { + const res = await request + .post(api('channels.setTopic')) + .set(credentials) + .send({ + roomId: channel._id, + topic: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', ''); + }); + }); + + describe('/channels.setAnnouncement', () => { + it('should set the announcement of the channel with a string', async () => { + const res = await request + .post(api('channels.setAnnouncement')) + .set(credentials) + .send({ + roomId: channel._id, + announcement: 'this is an announcement of a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('announcement', 'this is an announcement of a channel for api tests'); + }); + it('should set the announcement of the channel with an empty string(remove the announcement)', async () => { + const res = await request + .post(api('channels.setAnnouncement')) + .set(credentials) + .send({ + roomId: channel._id, + announcement: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('announcement', ''); + }); + }); + + describe('/channels.setPurpose', () => { + it('should set the purpose of the channel with a string', async () => { + const res = await request + .post(api('channels.setPurpose')) + .set(credentials) + .send({ + roomId: channel._id, + purpose: 'this is a purpose of a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('purpose', 'this is a purpose of a channel for api tests'); + }); + it('should set the announcement of channel with an empty string(remove the purpose)', async () => { + const res = await request + .post(api('channels.setPurpose')) + .set(credentials) + .send({ + roomId: channel._id, + purpose: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('purpose', ''); + }); + }); + + describe('/channels.history', () => { + it('should return an array of members by channel', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + + it('should return an array of members by channel even requested with count and offset params', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: channel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + + describe('inclusive parameter', () => { + let testChannel: IRoom; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + const channelRes = await request + .post(api('channels.create')) + .set(credentials) + .send({ name: `inclusive-test-channel-${Date.now()}` }); + testChannel = channelRes.body.channel; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testChannel._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testChannel._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testChannel._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testChannel?._id) { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('channels.history')) + .set(credentials) + .query({ + roomId: testChannel._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); + }); + + describe('/channels.members', () => { + let testUser: TestUser; + + before(async () => { + testUser = await createUser(); + await updateSetting('Accounts_SearchFields', 'username, name, bio, nickname'); + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._id, + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + after(async () => { + await Promise.all([updateSetting('Accounts_SearchFields', 'username, name, bio, nickname'), deleteUser(testUser)]); + }); + + it('should return an array of members by channel', async () => { + const res = await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + }); + + it('should return an array of members by channel even requested with count and offset params', async () => { + const res = await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: channel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + }); + + it('should return an filtered array of members by channel', async () => { + const res = await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: channel._id, + filter: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count', 1); + expect(res.body.members[0]._id).to.be.equal(testUser._id); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `channels.members.team.public.${Random.id()}`, TeamType.PUBLIC, [outsiderUser.username as string]), + createTeam(insideCredentials, `channels.members.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `rooms.members.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `rooms.members.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room members by user not part of room', async () => { + await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch private room members by user who is part of the room', async () => { + const response = await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body.success).to.be.false; + }); + + it('should fetch public room members by user who is part of the room', async () => { + const response = await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch public room members by user not part of room - because public', async () => { + await updatePermission('view-c-room', ['admin', 'user', 'guest']); + const response = await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should not fetch a private channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.members')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.members')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone part of the room', async () => { + await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a public channel members inside a private team by someone not part of team', async () => { + await request + .get(api('channels.members')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a private team by someone part of the room', async () => { + await request + .get(api('channels.members')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.members')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of team', async () => { + await request + .get(api('channels.members')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('/channels.getIntegrations', () => { + let integrationCreatedByAnUser: IIntegration; + let userCredentials: Credentials; + let createdChannel: IRoom; + let user: TestUser; + + before((done) => { + void createRoom({ name: `test-integration-channel-${Date.now()}`, type: 'c' }).end((_err, res) => { + createdChannel = res.body.channel; + void createUser().then((createdUser) => { + user = createdUser; + void login(user.username, password).then((credentials) => { + userCredentials = credentials; + void updatePermission('manage-incoming-integrations', ['user']).then(() => { + void updatePermission('manage-own-incoming-integrations', ['user']).then(() => { + void createIntegration( + { + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: `#${createdChannel.name}`, + }, + userCredentials, + ).then((integration) => { + integrationCreatedByAnUser = integration; + done(); + }); + }); + }); + }); + }); + }); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: createdChannel._id }), + removeIntegration(integrationCreatedByAnUser._id, 'incoming'), + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + deleteUser(user), + ]); + }); + + it('should return the list of integrations of created channel and it should contain the integration created by user when the admin DOES have the permission', async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + await request + .get(api('channels.getIntegrations')) + .set(credentials) + .query({ + roomId: createdChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + assert.isDefined(integrationCreated); + expect(integrationCreated).to.be.an('object'); + expect(integrationCreated._id).to.be.equal(integrationCreatedByAnUser._id); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return the list of integrations created by the user only', async () => { + await updatePermission('manage-own-incoming-integrations', ['admin']); + await updatePermission('manage-incoming-integrations', []); + await request + .get(api('channels.getIntegrations')) + .set(credentials) + .query({ + roomId: createdChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + expect(integrationCreated).to.be.equal(undefined); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return unauthorized error when the user does not have any integrations permissions', async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + ]); + await request + .get(api('channels.getIntegrations')) + .set(credentials) + .query({ + roomId: createdChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `channels.getIntegrations.team.public.${Random.id()}`, TeamType.PUBLIC, [ + outsiderUser.username as string, + ]), + createTeam(insideCredentials, `channels.getIntegrations.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `channels.getIntegrations.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `channels.getIntegrations.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + + await updatePermission('manage-incoming-integrations', ['admin', 'user']); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + + await updatePermission('manage-incoming-integrations', ['admin']); + }); + + it('should not fetch private room integrations by user who is part of the room', async () => { + const response = await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body.success).to.be.false; + }); + + it('should fetch public room integrations by user who is part of the room', async () => { + const response = await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('integrations'); + }); + + it('should fetch public room integrations by user not part of room - because public', async () => { + const response = await request + .get(api('channels.getIntegrations')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('integrations'); + }); + + it('should not fetch a private channel integrations inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel integrations inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel integrations inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.getIntegrations')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel integrations inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integrations'); + }); + }); + + it('should fetch a public channel integrations inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integrations'); + }); + }); + + it('should fetch a public channel integrations inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.getIntegrations')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integrations'); + }); + }); + + it('should fetch a public channel integrations inside a private team by someone part of the room', async () => { + await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integrations'); + }); + }); + + it('should fetch a public channel integrations inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integrations'); + }); + }); + + it('should not fetch a public channel integrations inside a private team by someone not part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel integrations inside a private team by someone part of the room', async () => { + await request + .get(api('channels.getIntegrations')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel integrations inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel integrations inside a private team by someone not part of team', async () => { + await request + .get(api('channels.getIntegrations')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('/channels.setCustomFields:', () => { + let withCFChannel: IRoom; + let withoutCFChannel: IRoom; + + after(async () => { + await deleteRoom({ type: 'c', roomId: withCFChannel._id }); + }); + + it('create channel with customFields', (done) => { + const customFields = { field0: 'value0' }; + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `channel.cf.${Date.now()}`, + customFields, + }) + .end((_err, res) => { + withCFChannel = res.body.channel; + done(); + }); + }); + it('get customFields using channels.info', async () => { + const res = await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: withCFChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.customFields.field0', 'value0'); + }); + it('change customFields', async () => { + const customFields = { field9: 'value9' }; + return request + .post(api('channels.setCustomFields')) + .set(credentials) + .send({ + roomId: withCFChannel._id, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', withCFChannel.name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.customFields.field9', 'value9'); + expect(res.body).to.have.not.nested.property('channel.customFields.field0', 'value0'); + }); + }); + it('get customFields using channels.info', async () => { + const res = await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: withCFChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.customFields.field9', 'value9'); + }); + it('delete channels with customFields', async () => { + const res = await request + .post(api('channels.delete')) + .set(credentials) + .send({ + roomName: withCFChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('create channel without customFields', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `channel.cf.${Date.now()}`, + }) + .end((_err, res) => { + withoutCFChannel = res.body.channel; + done(); + }); + }); + it('set customFields with one nested field', async () => { + const customFields = { field1: 'value1' }; + return request + .post(api('channels.setCustomFields')) + .set(credentials) + .send({ + roomId: withoutCFChannel._id, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.customFields.field1', 'value1'); + }); + }); + it('set customFields with multiple nested fields', async () => { + const customFields = { field2: 'value2', field3: 'value3', field4: 'value4' }; + + return request + .post(api('channels.setCustomFields')) + .set(credentials) + .send({ + roomName: withoutCFChannel.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.customFields.field2', 'value2'); + expect(res.body).to.have.nested.property('channel.customFields.field3', 'value3'); + expect(res.body).to.have.nested.property('channel.customFields.field4', 'value4'); + expect(res.body).to.have.not.nested.property('channel.customFields.field1', 'value1'); + }); + }); + it('set customFields to empty object', (done) => { + const customFields = {}; + + void request + .post(api('channels.setCustomFields')) + .set(credentials) + .send({ + roomName: withoutCFChannel.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.not.nested.property('channel.customFields.field2', 'value2'); + expect(res.body).to.have.not.nested.property('channel.customFields.field3', 'value3'); + expect(res.body).to.have.not.nested.property('channel.customFields.field4', 'value4'); + }) + .end(done); + }); + it('set customFields as a string -> should return 400', (done) => { + const customFields = ''; + + void request + .post(api('channels.setCustomFields')) + .set(credentials) + .send({ + roomName: withoutCFChannel.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + it('delete channel with empty customFields', async () => { + const res = await request + .post(api('channels.delete')) + .set(credentials) + .send({ + roomName: withoutCFChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/channels.setDefault', () => { + let testChannel: IRoom; + const name = `setDefault-${Date.now()}`; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should set channel as default', async () => { + const roomInfo = await getRoomInfo(testChannel._id); + + return request + .post(api('channels.setDefault')) + .set(credentials) + .send({ + roomId: testChannel._id, + default: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); + expect(res.body).to.have.nested.property('channel.default', true); + }); + }); + it('should unset channel as default', async () => { + const roomInfo = await getRoomInfo(testChannel._id); + + return request + .post(api('channels.setDefault')) + .set(credentials) + .send({ + roomId: testChannel._id, + default: false, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); + expect(res.body).to.have.nested.property('channel.default', false); + }); + }); + }); + + describe('auto-join default channels', () => { + let defaultChannel1: IRoom; + let defaultChannel2: IRoom; + let nonDefaultChannel: IRoom; + let userWithJoin: TestUser; + let userWithoutJoin: TestUser; + let userWithJoinCredentials: Credentials; + let userWithoutJoinCredentials: Credentials; + + before(async () => { + const timestamp = Date.now(); + defaultChannel1 = (await createRoom({ type: 'c', name: `auto-join-default-1-${timestamp}` })).body.channel; + defaultChannel2 = (await createRoom({ type: 'c', name: `auto-join-default-2-${timestamp}` })).body.channel; + nonDefaultChannel = (await createRoom({ type: 'c', name: `auto-join-non-default-${timestamp}` })).body.channel; + + await Promise.all([ + request.post(api('channels.setDefault')).set(credentials).send({ roomId: defaultChannel1._id, default: true }).expect(200), + request.post(api('channels.setDefault')).set(credentials).send({ roomId: defaultChannel2._id, default: true }).expect(200), + ]); + + [userWithJoin, userWithoutJoin] = await Promise.all([ + createUser({ joinDefaultChannels: true }), + createUser({ joinDefaultChannels: false }), + ]); + + [userWithJoinCredentials, userWithoutJoinCredentials] = await Promise.all([ + login(userWithJoin.username, password), + login(userWithoutJoin.username, password), + ]); + }); + + after(async () => { + await Promise.all([ + deleteUser(userWithJoin), + deleteUser(userWithoutJoin), + deleteRoom({ type: 'c', roomId: defaultChannel1._id }), + deleteRoom({ type: 'c', roomId: defaultChannel2._id }), + deleteRoom({ type: 'c', roomId: nonDefaultChannel._id }), + ]); + }); + + it('should automatically join new users to default channels when joinDefaultChannels is true', async () => { + const [subscription1Response, subscription2Response] = await Promise.all([ + request.get(api('subscriptions.getOne')).set(userWithJoinCredentials).query({ roomId: defaultChannel1._id }).expect(200), + request.get(api('subscriptions.getOne')).set(userWithJoinCredentials).query({ roomId: defaultChannel2._id }).expect(200), + ]); + + expect(subscription1Response.body).to.have.property('success', true); + expect(subscription1Response.body).to.have.property('subscription').that.is.an('object'); + expect(subscription1Response.body.subscription).to.have.property('rid', defaultChannel1._id); + + expect(subscription2Response.body).to.have.property('success', true); + expect(subscription2Response.body).to.have.property('subscription').that.is.an('object'); + expect(subscription2Response.body.subscription).to.have.property('rid', defaultChannel2._id); + }); + + it('should not auto-join new users to non-default channels', async () => { + const subscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(userWithJoinCredentials) + .query({ roomId: nonDefaultChannel._id }) + .expect(200); + + expect(subscriptionResponse.body).to.have.property('success', true); + expect(subscriptionResponse.body).to.have.property('subscription').that.is.null; + }); + + it('should not auto-join users when joinDefaultChannels is false', async () => { + const [subscription1Response, subscription2Response] = await Promise.all([ + request.get(api('subscriptions.getOne')).set(userWithoutJoinCredentials).query({ roomId: defaultChannel1._id }).expect(200), + request.get(api('subscriptions.getOne')).set(userWithoutJoinCredentials).query({ roomId: defaultChannel2._id }).expect(200), + ]); + + expect(subscription1Response.body).to.have.property('success', true); + expect(subscription1Response.body).to.have.property('subscription').that.is.null; + + expect(subscription2Response.body).to.have.property('success', true); + expect(subscription2Response.body).to.have.property('subscription').that.is.null; + }); + }); + + describe('/channels.setType', () => { + let testChannel: IRoom; + let testRegularChannel: IRoom; + let testTeamChannelForFailure: IRoom; + let testTeamChannelForSuccess: IRoom; + let team: ITeam; + let testUser: TestUser; + let testUserCredentials: Credentials; + const name = `setType-${Date.now()}`; + + beforeEach(async () => { + await updatePermission('create-p', ['admin', 'user']); + await updatePermission('create-team-group', ['admin', 'owner', 'moderator']); + }); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name })).body.channel; + + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + await updatePermission('create-team', ['admin', 'user']); + team = await createTeam(credentials, `team-setType-${Date.now()}`, TeamType.PUBLIC, [testUser.username]); + + await request.post(api('channels.addOwner')).set(credentials).send({ roomId: team.roomId, userId: testUser._id }); + + testRegularChannel = (await createRoom({ type: 'c', name: `regular-setType-${Date.now()}`, credentials: testUserCredentials })).body + .channel; + + testTeamChannelForFailure = (await createRoom({ type: 'c', name: `teamCh-fail-${Date.now()}`, extraData: { teamId: team._id } })).body + .channel; + testTeamChannelForSuccess = (await createRoom({ type: 'c', name: `teamCh-success-${Date.now()}`, extraData: { teamId: team._id } })) + .body.channel; + + await request.post(api('channels.invite')).set(credentials).send({ roomId: testTeamChannelForFailure._id, userId: testUser._id }); + await request.post(api('channels.addOwner')).set(credentials).send({ roomId: testTeamChannelForFailure._id, userId: testUser._id }); + await request.post(api('channels.invite')).set(credentials).send({ roomId: testTeamChannelForSuccess._id, userId: testUser._id }); + await request.post(api('channels.addOwner')).set(credentials).send({ roomId: testTeamChannelForSuccess._id, userId: testUser._id }); + }); + + after(async () => { + await Promise.all( + ['p', 'c'].map(async (type: any) => { + return Promise.all([ + deleteRoom({ type, roomId: testChannel._id }), + deleteRoom({ type, roomId: testRegularChannel._id }), + deleteRoom({ type, roomId: testTeamChannelForFailure._id }), + deleteRoom({ type, roomId: testTeamChannelForSuccess._id }), + ]); + }), + ); + await deleteTeam(credentials, team.name); + await deleteUser(testUser); + await updatePermission('create-p', ['admin', 'user']); + await updatePermission('create-team-group', ['admin', 'owner', 'moderator']); + }); + + it('should change the type public channel to private', async () => { + const roomInfo = await getRoomInfo(testChannel._id); + + void request + .post(api('channels.setType')) + .set(credentials) + .send({ + roomId: channel._id, + type: 'p', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', name); + expect(res.body).to.have.nested.property('channel.t', 'p'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('should fail to change a regular channel to private when user lacks create-p permission', async () => { + await updatePermission('create-p', ['admin']); + await request + .post(api('channels.setType')) + .set(testUserCredentials) + .send({ roomId: testRegularChannel._id, type: 'p' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team main room to private when user lacks create-p permission', async () => { + await updatePermission('create-p', ['admin']); + await request + .post(api('channels.setType')) + .set(testUserCredentials) + .send({ roomId: team.roomId, type: 'p' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team main room to private when user has create-team-group but lacks create-p', async () => { + await updatePermission('create-p', ['admin']); + await updatePermission('create-team-group', ['owner']); + await request + .post(api('channels.setType')) + .set(testUserCredentials) + .send({ roomId: team.roomId, type: 'p' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team channel to private when user lacks create-team-group permission', async () => { + await updatePermission('create-team-group', []); + await request + .post(api('channels.setType')) + .set(testUserCredentials) + .send({ roomId: testTeamChannelForFailure._id, type: 'p' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should succeed changing a team channel to private when user has create-team-group but not create-p', async () => { + await updatePermission('create-p', ['admin']); + await updatePermission('create-team-group', ['owner']); + await request + .post(api('channels.setType')) + .set(testUserCredentials) + .send({ roomId: testTeamChannelForSuccess._id, type: 'p' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.t', 'p'); + expect(res.body).to.have.nested.property('channel.teamId', team._id); + }); + }); + }); + + describe('/channels.delete', () => { + let testChannel: IRoom; + let testTeamChannel: IRoom; + let testModeratorTeamChannel: IRoom; + let invitedUser: TestUser; + let moderatorUser: TestUser; + let invitedUserCredentials: Credentials; + let moderatorUserCredentials: Credentials; + let teamId: ITeam['_id']; + let teamMainRoomId: IRoom['_id']; + + before(async () => { + testChannel = (await createRoom({ name: `channel.test.${Date.now()}`, type: 'c' })).body.channel; + invitedUser = await createUser(); + moderatorUser = await createUser(); + invitedUserCredentials = await login(invitedUser.username, password); + moderatorUserCredentials = await login(moderatorUser.username, password); + + await updatePermission('create-team', ['admin', 'user']); + const teamCreateRes = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `team-${Date.now()}`, + type: 0, + members: [invitedUser.username, moderatorUser.username], + }); + teamId = teamCreateRes.body.team._id; + teamMainRoomId = teamCreateRes.body.team.roomId; + + await updatePermission('delete-team-channel', ['owner', 'moderator']); + await updatePermission('create-team-channel', ['admin', 'owner', 'moderator', 'user']); + const teamChannelResponse = await createRoom({ + name: `channel.test.${Date.now()}`, + type: 'c', + extraData: { teamId }, + credentials: invitedUserCredentials, + }); + testTeamChannel = teamChannelResponse.body.channel; + + await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + userId: moderatorUser._id, + roomId: teamMainRoomId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + const teamModeratorChannelResponse = await createRoom({ + name: `channel.test.moderator.${Date.now()}`, + type: 'c', + extraData: { teamId }, + credentials: moderatorUserCredentials, + }); + testModeratorTeamChannel = teamModeratorChannelResponse.body.channel; + }); + after(async () => { + await deleteUser(invitedUser); + await deleteUser(moderatorUser); + await updatePermission('create-team-channel', ['admin', 'owner', 'moderator']); + await updatePermission('delete-team-channel', ['admin', 'owner', 'moderator']); + }); + it('should succesfully delete a channel', async () => { + await request + .post(api('channels.delete')) + .set(credentials) + .send({ + roomName: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + it(`should fail retrieving a channel's info after it's been deleted`, async () => { + await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + it(`should fail deleting a team's channel when member does not have the necessary permission in the team`, async () => { + await request + .post(api('channels.delete')) + .set(invitedUserCredentials) + .send({ + roomName: testTeamChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.a.property('error'); + expect(res.body).to.have.a.property('errorType'); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + }); + }); + it(`should fail deleting a team's channel when member has the necessary permission in the team, but not in the deleted room`, async () => { + await request + .post(api('channels.delete')) + .set(moderatorUserCredentials) + .send({ + roomName: testTeamChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.a.property('error'); + expect(res.body).to.have.a.property('errorType'); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + }); + }); + it(`should successfully delete a team's channel when member has both team and channel permissions`, async () => { + await request + .post(api('channels.delete')) + .set(moderatorUserCredentials) + .send({ + roomId: testModeratorTeamChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('/channels.getAllUserMentionsByChannel', () => { + it('should return an array of mentions by channel', async () => { + const res = await request + .get(api('channels.getAllUserMentionsByChannel')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('mentions').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + it('should return an array of mentions by channel even requested with count and offset params', async () => { + const res = await request + .get(api('channels.getAllUserMentionsByChannel')) + .set(credentials) + .query({ + roomId: channel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('mentions').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + + describe('/channels.roles', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.roles.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('/channels.invite', async () => { + const res = await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/channels.addModerator', async () => { + const res = await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/channels.addLeader', async () => { + const res = await request + .post(api('channels.addLeader')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return an array of role <-> user relationships in a channel', async () => { + const res = await request + .get(api('channels.roles')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('roles').that.is.an('array').that.has.lengthOf(2); + + expect(res.body.roles[0]).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[0]).to.have.a.property('rid').that.is.equal(testChannel._id); + expect(res.body.roles[0]).to.have.a.property('roles').that.is.an('array').that.includes('moderator', 'leader'); + expect(res.body.roles[0]).to.have.a.property('u').that.is.an('object'); + expect(res.body.roles[0].u).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[0].u).to.have.a.property('username').that.is.a('string'); + + expect(res.body.roles[1]).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[1]).to.have.a.property('rid').that.is.equal(testChannel._id); + expect(res.body.roles[1]).to.have.a.property('roles').that.is.an('array').that.includes('owner'); + expect(res.body.roles[1]).to.have.a.property('u').that.is.an('object'); + expect(res.body.roles[1].u).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[1].u).to.have.a.property('username').that.is.a('string'); + }); + }); + + describe('/channels.moderators', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.moderators.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('/channels.invite', async () => { + const res = await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/channels.addModerator', async () => { + const res = await request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return an array of moderators with rocket.cat as a moderator', async () => { + const res = await request + .get(api('channels.moderators')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('moderators').that.is.an('array').that.has.lengthOf(1); + expect(res.body.moderators[0].username).to.be.equal('rocket.cat'); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `channels.moderators.team.public.${Random.id()}`, TeamType.PUBLIC, [ + outsiderUser.username as string, + ]), + createTeam(insideCredentials, `channels.moderators.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `channels.moderators.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `channels.moderators.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room moderators by user not part of room', async () => { + await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch private room moderators by user who is part of the room', async () => { + const response = await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body.success).to.be.false; + }); + + it('should fetch public room moderators by user who is part of the room', async () => { + const response = await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('moderators'); + }); + + it('should fetch public room moderators by user not part of room - because public', async () => { + const response = await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body).to.have.property('moderators'); + }); + + it('should not fetch a private channel moderators inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel moderators inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel moderators inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.moderators')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel moderators inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('moderators'); + }); + }); + + it('should fetch a public channel moderators inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('moderators'); + }); + }); + + it('should fetch a public channel moderators inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.moderators')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('moderators'); + }); + }); + + it('should fetch a public channel moderators inside a private team by someone part of the room', async () => { + await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('moderators'); + }); + }); + + it('should fetch a public channel moderators inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('moderators'); + }); + }); + + it('should not fetch a public channel moderators inside a private team by someone not part of team', async () => { + await request + .get(api('channels.moderators')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel moderators inside a private team by someone part of the room', async () => { + await request + .get(api('channels.moderators')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel moderators inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.moderators')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel moderators inside a private team by someone not part of team', async () => { + await request + .get(api('channels.moderators')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('/channels.anonymousread', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.anonymousread.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await Promise.all([updateSetting('Accounts_AllowAnonymousRead', false), deleteRoom({ type: 'c', roomId: testChannel._id })]); + }); + + it('should return an error when the setting "Accounts_AllowAnonymousRead" is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + const res = await request + .get(api('channels.anonymousread')) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error', 'You must be logged in to do this.'); + }); + it('should return the messages list when the setting "Accounts_AllowAnonymousRead" is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('channels.anonymousread')) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('messages').that.is.an('array'); + }); + it('should return the messages list when the setting "Accounts_AllowAnonymousRead" is enabled even requested with count and offset params', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('channels.anonymousread')) + .query({ + roomId: testChannel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('messages').that.is.an('array'); + }); + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateTeam: ITeam; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a private team + privateTeam = await createTeam(insideCredentials, `channels.anonymousread.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]); + + const publicInPrivateResponse = await createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }); + + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + + await updateSetting('Accounts_AllowAnonymousRead', true); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }); + + await Promise.all([deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + + await updateSetting('Accounts_AllowAnonymousRead', false); + }); + + it('should fetch a public channel messages inside a private team by someone part of the room', async () => { + await request + .get(api('channels.anonymousread')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + }); + + it('should fetch a public channel messages inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.anonymousread')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + }); + + it('should not fetch a public channel messages inside a private team by someone not part of team', async () => { + await request + .get(api('channels.anonymousread')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a public channel messages inside a private team when unauthenticated', async () => { + await request + .get(api('channels.anonymousread')) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('/channels.convertToTeam', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.convertToTeam.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + assert.isDefined(testChannel.name); + await Promise.all([ + updatePermission('create-team', ['admin', 'user']), + updatePermission('edit-room', ['admin', 'owner', 'moderator']), + deleteTeam(credentials, testChannel.name), + ]); + }); + + it('should fail to convert channel if lacking create-team permission', async () => { + await updatePermission('create-team', []); + await updatePermission('edit-room', ['admin']); + + await request + .post(api('channels.convertToTeam')) + .set(credentials) + .send({ channelId: testChannel._id }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }); + }); + + it('should fail to convert channel if lacking edit-room permission', async () => { + await updatePermission('create-team', ['admin']); + await updatePermission('edit-room', []); + + await request + .post(api('channels.convertToTeam')) + .set(credentials) + .send({ channelId: testChannel._id }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }); + }); + + describe('when a user without edit-room permission on the channel tries to convert it to a team', () => { + let outsiderChannel: IRoom; + let outsiderUser: TestUser; + let outsiderCredentials: Credentials; + + before(async () => { + await updatePermission('create-team', ['admin', 'user']); + await updatePermission('edit-room', ['admin', 'owner', 'moderator']); + + outsiderChannel = (await createRoom({ type: 'c', name: `channel.convertToTeam.outsider.test.${Date.now()}` })).body.channel; + outsiderUser = await createUser(); + outsiderCredentials = await login(outsiderUser.username, password); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'c', roomId: outsiderChannel._id }), deleteUser(outsiderUser)]); + }); + + it('should return 403 when using channelName', async () => { + await request + .post(api('channels.convertToTeam')) + .set(outsiderCredentials) + .send({ channelName: outsiderChannel.name }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }); + }); + }); + + it(`should return an error when the channel's name and id are sent as parameter`, async () => { + const res = await request + .post(api('channels.convertToTeam')) + .set(credentials) + .send({ + channelName: testChannel.name, + channelId: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').include(`must match exactly one schema in oneOf`); + }); + + it(`should successfully convert a channel to a team when the channel's id is sent as parameter`, async () => { + await updatePermission('create-team', ['admin']); + await updatePermission('edit-room', ['admin']); + + await request + .post(api('channels.convertToTeam')) + .set(credentials) + .send({ channelId: testChannel._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + }); + + it(`should successfully convert a channel to a team when the channel's name is sent as parameter`, async () => { + await request + .post(api('teams.convertToChannel')) + .set(credentials) + .send({ teamName: testChannel.name }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + + await request + .post(api('channels.convertToTeam')) + .set(credentials) + .send({ channelName: testChannel.name }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }); + }); + + it('should fail to convert channel without the required parameters', (done) => { + void request.post(api('channels.convertToTeam')).set(credentials).send({}).expect(400).end(done); + }); + + it("should fail to convert channel if it's already taken", async () => { + const res = await request.post(api('channels.convertToTeam')).set(credentials).send({ channelId: testChannel._id }).expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + }); + + describe("Setting: 'Use Real Name': true", () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.anonymousread.test.${Date.now()}` })).body.channel; + }); + before(async () => { + await updateSetting('UI_Use_Real_Name', true); + + await request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannel._id); + }); + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testChannel._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await Promise.all([ + updateSetting('Accounts_AllowAnonymousRead', false), + updateSetting('UI_Use_Real_Name', false), + deleteRoom({ type: 'c', roomId: testChannel._id }), + ]); + }); + + it('should return the last message user real name', async () => { + const res = await request + .get(api('channels.info')) + .query({ + roomId: testChannel._id, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + const { channel } = res.body; + + expect(channel._id).to.be.equal(testChannel._id); + expect(channel).to.have.nested.property('lastMessage.u.name', 'RocketChat Internal Admin Test'); + }); + + it('/channels.list.joined', async () => { + const res = await request.get(api('channels.list.joined')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('channels').and.to.be.an('array'); + + const retChannel = (res.body.channels as IRoom[]).find(({ _id }) => _id === testChannel._id); + + expect(retChannel).to.have.nested.property('lastMessage.u.name', 'RocketChat Internal Admin Test'); + }); + + it('/channels.list.join should return empty list when member of no group', async () => { + const user = await createUser({ joinDefaultChannels: false }); + const newCreds = await login(user.username, password); + await request + .get(api('channels.list.joined')) + .set(newCreds) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').that.is.equal(0); + expect(res.body).to.have.property('total').that.is.equal(0); + expect(res.body).to.have.property('channels').and.to.be.an('array').and.that.has.lengthOf(0); + }); + }); + }); + + describe('[/channels.messages]', () => { + let testChannel: IRoom; + let emptyChannel: IRoom; + let firstUser: IUser; + let secondUser: IUser; + let pinnedMessageId: IMessage['_id']; + + before(async () => { + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + emptyChannel = (await createRoom({ type: 'c', name: `channels.messages.empty.test.${Date.now()}` })).body.channel; + testChannel = (await createRoom({ type: 'c', name: `channels.messages.test.${Date.now()}` })).body.channel; + + firstUser = await createUser({ joinDefaultChannels: false }); + secondUser = await createUser({ joinDefaultChannels: false }); + + const messages = [ + { + rid: testChannel._id, + msg: `@${firstUser.username} youre being mentioned`, + mentions: [{ username: firstUser.username, _id: firstUser._id, name: firstUser.name }], + }, + { + rid: testChannel._id, + msg: `@${secondUser.username} youre being mentioned`, + mentions: [{ username: secondUser.username, _id: secondUser._id, name: secondUser.name }], + }, + { + rid: testChannel._id, + msg: `A simple message`, + }, + { + rid: testChannel._id, + msg: `A pinned simple message`, + }, + ]; + + const [, , starredMessage, pinnedMessage] = await Promise.all(messages.map((message) => sendMessage({ message }))); + + await Promise.all([ + starMessage({ messageId: starredMessage.body.message._id }), + pinMessage({ messageId: pinnedMessage.body.message._id }), + ]); + pinnedMessageId = pinnedMessage.body.message._id; + }); + + after(async () => { + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should return an empty array of messages when inspecting a new room', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: emptyChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('count', 0); + expect(res.body).to.have.property('total', 0); + }); + }); + + it('should return an array of messages when inspecting a room with messages', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array').that.has.lengthOf(5); + expect(res.body).to.have.property('count', 5); + expect(res.body).to.have.property('total', 5); + + const pinnedMessage = res.body.messages.find((message: any) => message.t === 'message_pinned'); + expect(pinnedMessage).to.not.be.undefined; + }); + }); + + it('should not return message when the user does NOT have the necessary permission', async () => { + await updatePermission('view-c-room', []); + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + }); + + it('should return messages that mention a single user', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + mentionIds: firstUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('mentions').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body.messages[0].mentions[0]).to.have.property('_id', firstUser._id); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return messages that mention multiple users', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + mentionIds: `${firstUser._id},${secondUser._id}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(2); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('total', 2); + + const mentionIds = res.body.messages.map((message: any) => message.mentions[0]._id); + expect(mentionIds).to.include.members([firstUser._id, secondUser._id]); + }); + }); + + it('should return messages that are starred by a specific user', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + starredIds: 'rocketchat.internal.admin.test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('starred').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + // Return messages that are pinned + it('should return messages that are pinned', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('pinned').that.is.an('boolean').and.to.be.true; + expect(res.body.messages[0]).to.have.nested.property('pinnedBy').that.is.an('object'); + expect(res.body.messages[0].pinnedBy).to.have.property('_id', 'rocketchat.internal.admin.test'); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + describe('_hidden messages behavior when Message_KeepHistory is enabled', async () => { + before(async () => { + await updateSetting('Message_KeepHistory', true); + await pinMessage({ messageId: pinnedMessageId, unpin: true }); + }); + + after(async () => { + await updateSetting('Message_KeepHistory', false); + }); + + it('should return all messages, without any pinned messages', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('pinned', true); + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + + it('should return no pinned messages', async () => { + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: testChannel._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(0); + expect(res.body).to.have.property('count', 0); + expect(res.body).to.have.property('total', 0); + }); + }); + + it('should not return old message when updating a message', async () => { + await updateMessage({ msgId: pinnedMessageId, updatedMessage: 'message was unpinned', roomId: testChannel._id }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + + const updatedMessage = res.body.messages.find((msg: IMessage) => msg._id === pinnedMessageId); + + expect(updatedMessage).to.have.property('msg', 'message was unpinned'); + expect(updatedMessage).to.have.property('editedAt'); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `channels.messages.team.public.${Random.id()}`, TeamType.PUBLIC, [outsiderUser.username as string]), + createTeam(insideCredentials, `channels.messages.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `channels.messages.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `channels.messages.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room messages by user not part of room', async () => { + await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch private room messages by user who is part of the room', async () => { + const response = await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body.success).to.be.false; + }); + + it('should fetch public room messages by user who is part of the room', async () => { + const response = await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.messages).to.be.an('array'); + }); + + it('should fetch public room messages by user not part of room - because public', async () => { + await updatePermission('view-c-room', ['admin', 'user', 'guest']); + const response = await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.messages).to.be.an('array'); + }); + + it('should not fetch a private channel messages inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel messages inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel messages inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.messages')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel messages inside a public team by someone part of the room ', async () => { + await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + }); + }); + + it('should fetch a public channel messages inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + }); + }); + + it('should fetch a public channel messages inside a public team by someone not part of the team ', async () => { + await request + .get(api('channels.messages')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + }); + }); + + it('should fetch a public channel messages inside a private team by someone part of the room', async () => { + await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + }); + }); + + it('should fetch a public channel messages inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + }); + }); + + it('should not fetch a public channel messages inside a private team by someone not part of team', async () => { + await request + .get(api('channels.messages')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel messages inside a private team by someone part of the room', async () => { + await request + .get(api('channels.messages')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel messages inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('channels.messages')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel messages inside a private team by someone not part of team', async () => { + await request + .get(api('channels.messages')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts new file mode 100644 index 0000000..fa5f001 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -0,0 +1,5400 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IMessage, IRoom, ISubscription, IThreadMessage, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, beforeEach, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { retry } from './helpers/retry'; +import { sleep } from '../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials, apiUrl } from '../../data/api-data'; +import { followMessage, sendMessage, sendSimpleMessage, deleteMessage, updateMessage } from '../../data/chat.helper'; +import { imgURL } from '../../data/interactions'; +import { mockServerHealthy, mockServerReset, mockServerSet } from '../../data/mock-server.helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { addUserToRoom, createRoom, deleteRoom, getSubscriptionByRoomId } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +const pinMessage = ({ msgId }: { msgId: IMessage['_id'] }) => { + if (!msgId) { + throw new Error('"msgId" is required in "pinMessage" test helper'); + } + + return request.post(api('chat.pinMessage')).set(credentials).send({ + messageId: msgId, + }); +}; + +describe('[Chat]', () => { + let testChannel: IRoom; + let message: { _id: IMessage['_id'] }; + let protectedChannel: IRoom; + + before((done) => getCredentials(done)); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + protectedChannel = (await createRoom({ type: 'c', name: `chat.api-protected-test-${Date.now()}` })).body.channel; + + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: protectedChannel._id, + joinCode: 'super-secret-password', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteRoom({ type: 'c', roomId: protectedChannel._id }); + }); + + describe('/chat.postMessage', () => { + it('should throw an error when at least one of required parameters(channel, roomId) is not sent', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should throw an error when it has some properties with the wrong type(attachments.title_link_download, attachments.fields, message_link)', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + alias: 'Gruggy', + text: 'Sample message', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 12, + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: 'https://youtube.com', + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: '', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + describe('should throw an error when the sensitive properties contain malicious XSS values', () => { + it('attachment.message_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + alias: 'Gruggy', + text: 'Sample message', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.author_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + author_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.title_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.action.url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'https://youtube.com', + actions: [ + { + type: 'button', + text: 'Text', + url: 'javascript:alert("xss")', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('message.avatar', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + avatar: 'javascript:alert("xss")', + alias: 'Gruggy', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'https://youtube.com', + actions: [ + { + type: 'button', + text: 'Text', + url: 'https://youtube.com', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.action.image_url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'https://youtube.com', + actions: [ + { + type: 'button', + text: 'Text', + url: 'http://res.guggy.com/logo_128.png', + image_url: 'javascript:alert("xss")', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.thumb_url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + alias: 'Gruggy', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + thumb_url: 'javascript:alert("xss")', + title_link: 'http://res.guggy.com/logo_128.png', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.author_icon', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + alias: 'Gruggy', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + author_icon: 'javascript:alert("xss")', + title_link: 'http://res.guggy.com/logo_128.png', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.image_url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + image_url: 'javascript:alert("xss")', + title_link: 'http://res.guggy.com/logo_128.png', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + it('attachment.audio_url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + audio_url: 'javascript:alert("xss")', + title_link: 'http://res.guggy.com/logo_128.png', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + it('attachment.video_url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + alias: 'Gruggy', + text: 'Sample message', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + video_url: 'javascript:alert("xss")', + title_link: 'http://res.guggy.com/logo_128.png', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + }); + + it('should throw an error when the properties (attachments.fields.title, attachments.fields.value) are with the wrong type', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + alias: 'Gruggy', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 12, + value: false, + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should throw an error when the properties (attachments.fields.title) is missing', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + alias: 'Gruggy', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + value: 'This is attachment field value', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should throw an error when the properties (attachments.fields.value) is missing', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + alias: 'Gruggy', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 'This is attachment field title', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('attachment.fields should work fine when value and title are provided', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + attachments: [ + { + text: 'This is attachment field', + color: '#764FA5', + fields: [{ short: true, value: 'This is value', title: 'This is title' }], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + expect(res.body).to.have.nested.property('message.msg', 'Sample message'); + expect(res.body).to.have.nested.property('message.attachments').to.be.an('array'); + expect(res.body).to.have.nested.property('message.attachments[0].fields').to.be.an('array'); + expect(res.body).to.have.nested.property('message.attachments[0].fields[0].short', true); + expect(res.body).to.have.nested.property('message.attachments[0].fields[0].value', 'This is value'); + expect(res.body).to.have.nested.property('message.attachments[0].fields[0].title', 'This is title'); + }); + + it('should allow forwarding a message into the same password protected room', async () => { + const postResponse = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: [protectedChannel._id], + text: 'Message to be forwarded', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(postResponse.body).to.have.property('success', true); + const originalMessageId = postResponse.body.message._id as IMessage['_id']; + + const forwardResponse = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: [protectedChannel._id], + text: `[](http://localhost:3000/channel/${protectedChannel.name}?msg=${originalMessageId}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(forwardResponse.body).to.have.property('success', true); + expect(forwardResponse.body).to.have.nested.property('message.rid', protectedChannel._id); + }); + + it('should return statusCode 200 when postMessage successfully', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 'Test', + value: 'Testing out something or other', + }, + { + short: true, + title: 'Another Test', + value: '[Link](https://google.com/) something and this and that.', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'Sample message'); + message = { _id: res.body.message._id }; + }); + + it('should not parse urls when parseUrls=false is provided', async () => { + return request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: apiUrl, + parseUrls: false, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', apiUrl); + expect(res.body.message).to.not.have.property('urls'); + }); + }); + + it('should parse urls when parseUrls=true is provided', async () => { + return request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: apiUrl, + parseUrls: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', apiUrl); + expect(res.body.message).to.have.property('urls'); + }); + }); + + it('should parse urls when parseUrls is not provided', async () => { + return request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: apiUrl, + parseUrls: undefined, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', apiUrl); + expect(res.body.message).to.have.property('urls'); + }); + }); + + describe('text message allowed size', () => { + before(async () => { + await updateSetting('Message_MaxAllowedSize', 10); + }); + + after(async () => { + await updateSetting('Message_MaxAllowedSize', 5000); + }); + + it('should return an error if text parameter surpasses the maximum allowed size', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: '#general', + text: 'Text to test max limit allowed', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-message-size-exceeded'); + }); + + it('should return an error if text parameter in the first attachment surpasses the maximum allowed size', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Yay!', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Text to test max limit allowed', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-message-size-exceeded'); + }); + + it('should return an error if text parameter in any of the attachments surpasses the maximum allowed size', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Yay!', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Yay!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [], + }, + { + color: '#ff0000', + text: 'Text to large to test max limit allowed', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-message-size-exceeded'); + }); + + it('should pass if any text parameter length does not surpasses the maximum allowed size', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Sample', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 'Test', + value: 'Testing out something or other', + }, + { + short: true, + title: 'Another Test', + value: '[Link](https://google.com/) something and this and that.', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'Sample'); + }); + }); + + describe('Archived rooms', () => { + let archivedChannel: IRoom; + + before(async () => { + archivedChannel = (await createRoom({ type: 'c', name: `chat.api-archived-post-test-${Date.now()}` })).body.channel; + await request.post(api('channels.archive')).set(credentials).send({ roomId: archivedChannel._id }); + }); + + after(async () => { + await request.post(api('channels.unarchive')).set(credentials).send({ roomId: archivedChannel._id }); + await deleteRoom({ type: 'c', roomId: archivedChannel._id }); + }); + + it('should fail to post a message to an archived room', async () => { + const res = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: archivedChannel._id, + text: 'This message should not be posted', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'room_is_archived'); + }); + }); + }); + + describe('/chat.getMessage', () => { + it('should retrieve the message successfully', async () => { + const res = await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message._id', message._id); + }); + }); + + describe('/chat.sendMessage', () => { + it("should throw an error when the required param 'rid' is not sent", async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "The 'rid' property on the message object is missing."); + }); + + describe('should throw an error when the sensitive properties contain malicious XSS values', () => { + it('attachment.message_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + alias: 'Gruggy', + text: 'Sample message', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.author_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + author_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.title_link', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'javascript:alert("xss")', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + + it('attachment.action.url', () => + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + title: 'Attachment Example', + title_link: 'https://youtube.com', + actions: [ + { + type: 'button', + text: 'Text', + url: 'javascript:alert("xss")', + }, + ], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + })); + }); + + it('should throw an error when it has some properties with the wrong type(attachments.title_link_download, attachments.fields, message_link)', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + channel: testChannel.name, + text: 'Sample message', + alias: 'Gruggy', + emoji: ':smirk:', + avatar: 'http://res.guggy.com/logo_128.png', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 12, + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: 'https://youtube.com', + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: '', + }, + ], + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should send a message successfully', (done) => { + message._id = `id-${Date.now()}`; + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + _id: message._id, + rid: testChannel._id, + msg: 'Sample message', + emoji: ':smirk:', + attachments: [ + { + color: '#ff0000', + text: 'Yay for gruggy!', + ts: '2016-12-09T16:53:06.761Z', + thumb_url: 'http://res.guggy.com/logo_128.png', + message_link: 'https://google.com', + collapsed: false, + author_name: 'Bradley Hilton', + author_link: 'https://rocket.chat/', + author_icon: 'https://avatars.githubusercontent.com/u/850391?v=3', + title: 'Attachment Example', + title_link: 'https://youtube.com', + title_link_download: true, + image_url: 'http://res.guggy.com/logo_128.png', + audio_url: 'http://www.w3schools.com/tags/horse.mp3', + video_url: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 'Test', + value: 'Testing out something or other', + }, + { + short: true, + title: 'Another Test', + value: '[Link](https://google.com/) something and this and that.', + }, + ], + }, + ], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'Sample message'); + }) + .end(done); + }); + + describe('Bad words filter', () => { + before(() => + Promise.all([updateSetting('Message_AllowBadWordsFilter', true), updateSetting('Message_BadWordsFilterList', 'badword,badword2')]), + ); + + after(() => Promise.all([updateSetting('Message_AllowBadWordsFilter', false), updateSetting('Message_BadWordsFilterList', '')])); + + it('should censor bad words on send', async () => { + const badMessage = { + _id: Random.id(), + rid: testChannel._id, + msg: 'This message has badword badword2', + }; + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ message: badMessage }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + const { message } = res.body; + expect(message).to.have.property('msg', 'This message has ******* ********'); + expect(message).to.have.property('md').to.be.an('array').that.has.lengthOf(1); + const para = message.md[0]; + expect(para).to.have.property('value').to.be.an('array').that.has.lengthOf(1); + const text = para.value[0]; + expect(text).to.have.property('value', 'This message has ******* ********'); + }); + }); + }); + + describe('Archived rooms', () => { + let archivedChannel: IRoom; + + before(async () => { + archivedChannel = (await createRoom({ type: 'c', name: `chat.api-archived-test-${Date.now()}` })).body.channel; + await request.post(api('channels.archive')).set(credentials).send({ roomId: archivedChannel._id }); + }); + + after(async () => { + await request.post(api('channels.unarchive')).set(credentials).send({ roomId: archivedChannel._id }); + await deleteRoom({ type: 'c', roomId: archivedChannel._id }); + }); + + it('should fail to send a message to an archived room', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: archivedChannel._id, + msg: 'This message should not be sent', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'room_is_archived'); + }); + }); + + describe('oembed', () => { + // the TEST_MODE oembed provider (server/services/messages/lib/oembed/providers.ts) resolves /video/ URLs through GET /oembed on the mock-server + const mockVideoUrl = (id: string) => `http://mock-server.dev:8080/video/${id}`; + const mockPageUrl = (id: string) => `http://mock-server.dev:8080/page/${id}`; + const mockOembedEndpoint = { method: 'GET', path: '/oembed' }; + // the whole oembed pipeline runs against the local mock-server, so a fixed wait suffices — exceeding it means a CI perf problem worth surfacing + const oembedProcessingMs = 500; + + const youtubeOembedResponse = { + title: 'Rocket.Chat Demo', + author_name: 'Rocket.Chat', + author_url: 'https://www.youtube.com/@RocketChatApp', + type: 'video', + height: 150, + width: 200, + version: '1.0', + provider_name: 'YouTube', + provider_url: 'https://www.youtube.com/', + thumbnail_height: 360, + thumbnail_width: 480, + thumbnail_url: 'https://i.ytimg.com/vi/T2v29gK8fP4/hqdefault.jpg', + html: '', + }; + + const spotifyOembedResponse = { + html: '', + iframe_url: 'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed', + width: 456, + height: 152, + version: '1.0', + provider_name: 'Spotify', + provider_url: 'https://spotify.com', + type: 'rich', + title: 'Never Gonna Give You Up', + thumbnail_url: 'https://image-cdn-ak.spotifycdn.com/image/ab67616d00001e0215ebbedaacef61af244262a8', + thumbnail_width: 300, + thumbnail_height: 300, + }; + + let ytEmbedMsgId: IMessage['_id']; + let imgUrlMsgId: IMessage['_id']; + + before(async () => { + const healthy = await mockServerHealthy(); + expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; + await mockServerReset(); + }); + + // programmed responses queue per key and an infinite (times: 0) entry never expires, so each test starts from a clean mock + afterEach(() => mockServerReset()); + + before(() => + Promise.all([ + updateSetting('API_EmbedIgnoredHosts', ''), + updateSetting('API_EmbedSafePorts', '80, 443, 3000, 8080'), + updateSetting('SSRF_Allowlist', '127.0.0.1:3000, mock-server.dev'), + ]), + ); + + after(() => + Promise.all([ + updateSetting('API_EmbedIgnoredHosts', 'localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16'), + updateSetting('API_EmbedSafePorts', '80, 443'), + updateSetting('SSRF_Allowlist', ''), + ]), + ); + + before(async () => { + await mockServerSet(mockOembedEndpoint.method, mockOembedEndpoint.path, youtubeOembedResponse); + const ytEmbedMsgPayload = { + _id: `id-${Date.now()}`, + rid: testChannel._id, + msg: mockVideoUrl('embed-max-width'), + emoji: ':smirk:', + }; + const ytPostResponse = await request.post(api('chat.sendMessage')).set(credentials).send({ message: ytEmbedMsgPayload }); + ytEmbedMsgId = ytPostResponse.body.message._id; + }); + + before(async () => { + const imgUrlMsgPayload = { + _id: `id-${Date.now()}1`, + rid: testChannel._id, + msg: 'http://127.0.0.1:3000/images/logo/logo.png', + emoji: ':smirk:', + }; + + const imgUrlResponse = await request.post(api('chat.sendMessage')).set(credentials).send({ message: imgUrlMsgPayload }); + + imgUrlMsgId = imgUrlResponse.body.message._id; + }); + + it('should have an iframe oembed with style max-width', async () => { + await retry( + 'Oembed is generated async thats why the retry is required', + async () => { + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ + msgId: ytEmbedMsgId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.is.not.empty; + + expect(res.body.message.urls[0]) + .to.have.property('meta') + .to.have.property('oembedHtml') + .to.have.string('', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + describe('customFields', () => { + async function testMessageSending({ + customFields, + testCb, + statusCode, + }: { + customFields?: Record; + testCb: (res: Response) => any; + statusCode: number; + }) { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + customFields, + }, + }) + .expect('Content-Type', 'application/json') + .expect(statusCode) + .expect(testCb); + + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testChannel._id, + text: 'Sample message', + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(statusCode) + .expect(testCb); + } + describe('when disabled', () => { + it('should not allow sending custom fields', async () => { + await testMessageSending({ + customFields: { + field1: 'value1', + }, + statusCode: 400, + testCb: (res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Custom fields not enabled'); + }, + }); + }); + + it('should not allow update custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Custom fields not enabled'); + }); + }); + }); + + describe('when enabled', () => { + before(async () => { + await updateSetting('Message_CustomFields_Enabled', true); + await updateSetting( + 'Message_CustomFields', + JSON.stringify({ + properties: { + priority: { + type: 'string', + nullable: false, + enum: ['low', 'medium', 'high'], + }, + }, + required: ['priority'], + }), + ); + }); + + after(async () => { + await updateSetting('Message_CustomFields_Enabled', false); + }); + + it('should allow not sending custom fields', async () => { + await testMessageSending({ + statusCode: 200, + testCb: (res) => { + expect(res.body).to.have.property('success', true); + }, + }); + }); + + it('should not allow sending empty custom fields', async () => { + await testMessageSending({ + customFields: {}, + statusCode: 400, + testCb: (res) => { + expect(res.body).to.have.property('success', false); + }, + }); + }); + + it('should not allow sending wrong custom fields', async () => { + await testMessageSending({ + customFields: { + field1: 'value1', + }, + statusCode: 400, + testCb: (res) => { + expect(res.body).to.have.property('success', false); + }, + }); + }); + + it('should allow sending correct custom fields', async () => { + await testMessageSending({ + customFields: { + priority: 'low', + }, + statusCode: 200, + testCb: (res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' }); + }, + }); + }); + + it('should allow not sending custom fields on update', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should not allow update empty custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not allow update wrong custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + field1: 'value1', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should allow update correct custom fields', async () => { + const res = await sendSimpleMessage({ roomId: testChannel._id }); + const msgId = res.body.message._id; + + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + text: 'Sample message Updated', + customFields: { + priority: 'low', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' }); + }); + }); + }); + }); + + // TODO: Auto-close unclosed markdown code blocks on backend - Remove in 9.0.0 + // In 9.0.0, this behavior is handled entirely on the client side and should no longer be done on the backend. + it('should auto-close an unclosed code block when sending a message', async () => { + const unclosedMsg = '```\nsome code'; + const expectedMsg = '```\nsome code\n```'; + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: unclosedMsg, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', expectedMsg); + }); + }); + }); + + describe('/chat.update', () => { + const siteUrl = process.env.SITE_URL || process.env.TEST_API_URL || 'http://localhost:3000'; + let simpleMessageId: IMessage['_id']; + + before('should send simple message in room', async () => { + await updateSetting('Message_CustomFields_Enabled', true); + await updateSetting('Message_CustomFields', JSON.stringify({ properties: { test: { type: 'string' } } })); + const res = await sendSimpleMessage({ roomId: 'GENERAL' }); + simpleMessageId = res.body.message._id; + }); + + after(async () => { + await updateSetting('Message_CustomFields_Enabled', false); + await updateSetting('Message_CustomFields', ''); + }); + it('should fail updating a message if no room id is provided', () => { + return request + .post(api('chat.update')) + .set(credentials) + .send({ + msgId: message._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail updating a message if no message id is provided', () => { + return request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail updating a message if no text is provided', () => { + return request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail updating a message if an invalid message id is provided', () => { + return request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: 'invalid-id', + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'No message found with the id of "invalid-id".'); + }); + }); + + it('should fail updating a message if it is not in the provided room', () => { + return request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: 'invalid-room', + msgId: message._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The room id provided does not match where the message is from.'); + }); + }); + + it('should fail updating a message with "content" if it is not encrypted', async () => { + const res = await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + content: { + algorithm: 'rc.v1.aes-sha2', + ciphertext: 'U2FsdGVkX1+u3j0u2+oXg4o3kw5y4t7D9sdfsdff==', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Only encrypted messages can have content updated.'); + }); + + it('should update a message successfully', async () => { + const res = await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was edited via API'); + }); + + it('should add quote attachments to a message', async () => { + const quotedMsgLink = `${siteUrl}/channel/general?msg=${message._id}`; + void request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: `Testing quotes ${quotedMsgLink}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', `Testing quotes ${quotedMsgLink}`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(1); + expect(res.body.message.attachments[0]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it('should replace a quote attachment in a message', async () => { + const quotedMsgLink = `${siteUrl}/channel/general?msg=${simpleMessageId}`; + void request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: `Testing quotes ${quotedMsgLink}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', `Testing quotes ${quotedMsgLink}`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(1); + expect(res.body.message.attachments[0]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it('should add multiple quote attachments in a single message', async () => { + const quotedMsgLink = `${siteUrl}/channel/general?msg=${simpleMessageId}`; + const newQuotedMsgLink = `${siteUrl}/channel/general?msg=${message._id}`; + void request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: `${newQuotedMsgLink} Testing quotes ${quotedMsgLink}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', `Testing quotes ${quotedMsgLink}`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(2); + expect(res.body.message.attachments[0]).to.have.property('message_link', newQuotedMsgLink); + expect(res.body.message.attachments[1]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it('should erase old quote attachments when updating a message', async () => { + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was edited via API'); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(0); + }); + }); + + it('should do nothing if the message text hasnt changed and theres no custom fields', async () => { + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message was edited via API', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was edited via API'); + expect(res.body).to.not.have.nested.property('message.customFields'); + }); + }); + + it('should update message custom fields along with msg', async () => { + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message was edited via API 2', + customFields: { test: 'test' }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was edited via API 2'); + expect(res.body.message).to.have.property('customFields').that.is.an('object').that.deep.equals({ test: 'test' }); + }); + }); + + it('should update message custom fields without changes to msg', async () => { + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message was edited via API 2', + customFields: { test: 'test 2' }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was edited via API 2'); + expect(res.body.message).to.have.property('customFields').that.is.an('object').that.deep.equals({ test: 'test 2' }); + }); + }); + + describe('Bad words filter', () => { + before(() => + Promise.all([updateSetting('Message_AllowBadWordsFilter', true), updateSetting('Message_BadWordsFilterList', 'badword,badword2')]), + ); + + after(() => Promise.all([updateSetting('Message_AllowBadWordsFilter', false), updateSetting('Message_BadWordsFilterList', '')])); + + it('should censor bad words on update', async () => { + await request + .post(api('chat.update')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: message._id, + text: 'This message has badword badword2', + }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + const { message } = res.body; + expect(message).to.have.property('msg', 'This message has ******* ********'); + expect(message).to.have.property('md').to.be.an('array').that.has.lengthOf(1); + const para = message.md[0]; + expect(para).to.have.property('value').to.be.an('array').that.has.lengthOf(1); + const text = para.value[0]; + expect(text).to.have.property('value', 'This message has ******* ********'); + }); + }); + }); + }); + + describe('[/chat.delete]', () => { + let msgId: IMessage['_id']; + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(() => deleteUser(user)); + + beforeEach((done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + msgId = res.body.message._id; + }) + .end(done); + }); + it('should fail deleting a message if no message id is provided', async () => { + return request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + it('should fail deleting a message if no room id is provided', async () => { + return request + .post(api('chat.delete')) + .set(credentials) + .send({ + msgId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + it('should fail deleting a message if it is not in the provided room', async () => { + return request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: 'invalid-room', + msgId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The room id provided does not match where the message is from.'); + }); + }); + it('should fail deleting a message if an invalid id is provided', async () => { + return request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId: 'invalid-id', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `No message found with the id of "invalid-id".`); + }); + }); + it('should delete a message successfully', async () => { + const res = await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('sending message as another user...', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(userCredentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + msgId = res.body.message._id; + }); + it('should delete a message successfully when the user deletes a message send by another user', async () => { + const res = await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: testChannel._id, + msgId, + asUser: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + describe('when deleting by fileId', () => { + const uploadFile = async (): Promise => { + const { body } = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + expect(body).to.have.property('success', true); + return body.file._id; + }; + + it('should delete the message associated with the provided fileId', async () => { + const fileId = await uploadFile(); + + let fileMsgId: string | undefined; + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + fileMsgId = res.body.message._id; + }); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ msgId: fileMsgId }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail when the uploaded file has no associated message', async () => { + const fileId = await uploadFile(); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `No message found with the file id: "${fileId}".`); + }); + }); + + it('should fail when neither msgId/roomId nor fileId is provided', async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); + + describe('when deleting a thread message', () => { + let otherUser: TestUser; + let otherUserCredentials: Credentials; + let parentThreadId: IMessage['_id']; + + before(async () => { + const username = `user${+new Date()}`; + otherUser = await createUser({ username }); + otherUserCredentials = await login(otherUser.username, password); + parentThreadId = (await sendSimpleMessage({ roomId: testChannel._id })).body.message._id; + await addUserToRoom({ rid: testChannel._id, usernames: [otherUser.username] }); + }); + + after(() => Promise.all([deleteUser(otherUser), deleteMessage({ msgId: parentThreadId, roomId: testChannel._id })])); + + const expectNoUnreadThreadMessages = (s: ISubscription) => { + expect(s).to.have.property('tunread'); + expect(s.tunread).to.be.an('array'); + expect(s.tunread).to.deep.equal([]); + }; + + it('should reset the unread counter if the message was removed', async () => { + const { body } = await sendSimpleMessage({ roomId: testChannel._id, tmid: parentThreadId, userCredentials: otherUserCredentials }); + const childrenMessageId = body.message._id; + + await followMessage({ msgId: parentThreadId, requestCredentials: otherUserCredentials }); + await deleteMessage({ msgId: childrenMessageId, roomId: testChannel._id }); + + const userWhoCreatedTheThreadSubscription = await getSubscriptionByRoomId(testChannel._id); + + expectNoUnreadThreadMessages(userWhoCreatedTheThreadSubscription); + }); + + it('should reset the unread counter of users who followed the thread', async () => { + const { body } = await sendSimpleMessage({ roomId: testChannel._id, tmid: parentThreadId }); + const childrenMessageId = body.message._id; + + await followMessage({ msgId: parentThreadId, requestCredentials: otherUserCredentials }); + await deleteMessage({ msgId: childrenMessageId, roomId: testChannel._id }); + + const userWhoWasFollowingTheThreadSubscription = await getSubscriptionByRoomId(testChannel._id, otherUserCredentials); + + expectNoUnreadThreadMessages(userWhoWasFollowingTheThreadSubscription); + }); + }); + + describe('in read-only rooms with unmuted users', () => { + let readOnlyChannel: IRoom; + let unmutedUser: TestUser; + let unmutedUserCredentials: Credentials; + let notUnmutedUser: TestUser; + let notUnmutedUserCredentials: Credentials; + + before(async () => { + unmutedUser = await createUser(); + unmutedUserCredentials = await login(unmutedUser.username, password); + + notUnmutedUser = await createUser(); + notUnmutedUserCredentials = await login(notUnmutedUser.username, password); + + const channelResult = await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: `readonly-delete-test-${Date.now()}`, + readOnly: true, + }); + readOnlyChannel = channelResult.body.channel; + + await request.post(api('channels.invite')).set(credentials).send({ + roomId: readOnlyChannel._id, + userId: unmutedUser._id, + }); + await request.post(api('channels.invite')).set(credentials).send({ + roomId: readOnlyChannel._id, + userId: notUnmutedUser._id, + }); + + await request.post(api('rooms.unmuteUser')).set(credentials).send({ + roomId: readOnlyChannel._id, + username: unmutedUser.username, + }); + + await updatePermission('delete-message', ['user']); + }); + + after(async () => { + await Promise.all([ + readOnlyChannel && deleteRoom({ type: 'c', roomId: readOnlyChannel._id }), + unmutedUser && deleteUser(unmutedUser), + notUnmutedUser && deleteUser(notUnmutedUser), + updatePermission('delete-message', ['admin', 'owner', 'moderator']), + ]); + }); + + it('should allow unmuted user to delete message from another user in read-only room', async () => { + const messageResult = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: readOnlyChannel._id, + msg: 'Message to be deleted by unmuted user', + }, + }); + + const deleteMsgId = messageResult.body.message._id; + + await request + .post(api('chat.delete')) + .set(unmutedUserCredentials) + .send({ + roomId: readOnlyChannel._id, + msgId: deleteMsgId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should NOT allow non-unmuted user to delete message in read-only room (regression test)', async () => { + const messageResult = await request + .post(api('chat.sendMessage')) + .set(unmutedUserCredentials) + .send({ + message: { + rid: readOnlyChannel._id, + msg: 'Message from unmuted user', + }, + }); + + const deleteMsgId = messageResult.body.message._id; + + await request + .post(api('chat.delete')) + .set(notUnmutedUserCredentials) + .send({ + roomId: readOnlyChannel._id, + msgId: deleteMsgId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "You can't delete messages because the room is readonly."); + }); + }); + + it('should NOT allow unmuted user without delete-message permission to delete message', async () => { + await updatePermission('delete-message', []); + + const messageResult = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: readOnlyChannel._id, + msg: 'Message that unmuted user without permission cannot delete', + }, + }); + + const deleteMsgId = messageResult.body.message._id; + + await request + .post(api('chat.delete')) + .set(unmutedUserCredentials) + .send({ + roomId: readOnlyChannel._id, + msgId: deleteMsgId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await updatePermission('delete-message', ['user']); + }); + }); + }); + + describe('/chat.search', () => { + before(async () => { + const sendMessage = (text: string) => + request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: text, + }, + }); + + await sendMessage('msg1'); + await sendMessage('msg1'); + await sendMessage('msg1'); + await sendMessage('msg1'); + await sendMessage('msg1'); + await sendMessage('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!'); + }); + + it('should return a list of messages when execute successfully', async () => { + const res = await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: 'msg1', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + it('should return a list of messages(length=1) when is provided "count" query parameter execute successfully', async () => { + const res = await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: 'msg1', + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + expect(res.body.messages.length).to.equal(1); + }); + it('should return a list of messages(length=3) when is provided "count" and "offset" query parameters are executed successfully', async () => { + const res = await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: 'msg1', + offset: 1, + count: 3, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + expect(res.body.messages.length).to.equal(3); + }); + + it('should return a empty list of messages when is provided a huge offset value', async () => { + const res = await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: 'msg1', + offset: 9999, + count: 3, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + expect(res.body.messages.length).to.equal(0); + }); + + it('should return an empty array of messages with status 200 if the regexp starts with an invalid quantifier', async () => { + await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: '/*test/', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.deep.equal([]); + }); + }); + + it('should return an empty array of messages with status 200 if the regexp has an unclosed parenthesis', async () => { + await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: '/(test/', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.deep.equal([]); + }); + }); + + it('should return an empty array of messages with status 200 if the regexp has an unclosed character class', async () => { + await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: '/[a-z/', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.deep.equal([]); + }); + }); + + it('should not cause catastrophic backtracking when using a malicious regexp', async () => { + await request + .get(api('chat.search')) + .set(credentials) + .query({ + roomId: testChannel._id, + searchText: '/(a+)+$/', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.deep.equal([]); + }); + }); + }); + + describe('[/chat.react]', () => { + it("should return statusCode: 200 and success when try unreact a message that's no reacted yet", async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: message._id, + shouldReact: false, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should react a message successfully', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: 'smile', + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return statusCode: 200 when the emoji is valid', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it("should return statusCode: 200 and success when try react a message that's already reacted", async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: message._id, + shouldReact: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return statusCode: 200 when unreact a message with flag, shouldReact: false', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: message._id, + shouldReact: false, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return statusCode: 200 when react a message with flag, shouldReact: true', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: message._id, + shouldReact: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return statusCode: 200 when the emoji is valid and has no colons', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: 'bee', + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return statusCode: 200 for reaction property when the emoji is valid', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + reaction: 'ant', + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('[/chat.getMessageReadReceipts]', () => { + const isEnterprise = typeof process.env.IS_EE === 'string' ? process.env.IS_EE === 'true' : !!process.env.IS_EE; + describe('when execute successfully', () => { + it('should return statusCode: 200 and an array of receipts when running EE', function (done) { + if (!isEnterprise) { + this.skip(); + } + + void request + .get(api(`chat.getMessageReadReceipts`)) + .set(credentials) + .query({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('receipts').and.to.be.an('array'); + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + + describe('when an error occurs', () => { + it('should throw error-action-not-allowed error when not running EE', function (done) { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (isEnterprise) { + this.skip(); + } + void request + .get(api(`chat.getMessageReadReceipts`)) + .set(credentials) + .query({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'This is an enterprise feature [error-action-not-allowed]'); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }) + .end(done); + }); + + it('should return statusCode: 400 and an error when no messageId is provided', function (done) { + if (!isEnterprise) { + this.skip(); + } + + void request + .get(api('chat.getMessageReadReceipts')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).not.have.property('receipts'); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }) + .end(done); + }); + }); + }); + + describe('[/chat.reportMessage]', () => { + describe('when execute successfully', () => { + it('should return the statusCode 200', async () => { + const res = await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400 and an error', async () => { + const res = await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + + describe('[/chat.getDeletedMessages]', () => { + let roomId: IRoom['_id']; + + before(async () => { + roomId = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel._id; + const msgId = (await sendSimpleMessage({ roomId })).body.message._id; + await deleteMessage({ roomId, msgId }); + }); + + after(() => deleteRoom({ type: 'c', roomId })); + + describe('when execute successfully', () => { + it('should return a list of deleted messages', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + roomId, + since: new Date('20 December 2018 17:51 UTC').toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + it('should return a list of deleted messages when the user sets count query parameter', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + roomId, + since: new Date('20 December 2018 17:51 UTC').toISOString(), + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + it('should return a list of deleted messages when the user sets count and offset query parameters', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + roomId, + since: new Date('20 December 2018 17:51 UTC').toISOString(), + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + since: new Date('20 December 2018 17:51 UTC').toISOString(), + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should return statusCode 400 and an error when "since" is not provided', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + roomId, + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should return statusCode 400 and an error when "since" is provided but it is invalid ISODate', async () => { + const res = await request + .get(api('chat.getDeletedMessages')) + .set(credentials) + .query({ + roomId, + since: 'InvalidaDate', + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/chat.pinMessage]', () => { + after(() => + Promise.all([updateSetting('Message_AllowPinning', true), updatePermission('pin-message', ['owner', 'moderator', 'admin'])]), + ); + + it('should return an error when pinMessage is not allowed in this server', async () => { + await updateSetting('Message_AllowPinning', false); + + const res = await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should return an error when pinMessage is allowed in server but user dont have permission', async () => { + await updateSetting('Message_AllowPinning', true); + + await updatePermission('pin-message', []); + + const res = await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should return an error when messageId does not exist', async () => { + await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should pin Message successfully', async () => { + await updatePermission('pin-message', ['admin']); + + const res = await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + }); + + it('should return message when its already pinned', async () => { + await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + }); + }); + }); + + describe('[/chat.unPinMessage]', () => { + after(() => + Promise.all([updateSetting('Message_AllowPinning', true), updatePermission('pin-message', ['owner', 'moderator', 'admin'])]), + ); + + it('should return an error when pinMessage is not allowed in this server', async () => { + await updateSetting('Message_AllowPinning', false); + + const res = await request + .post(api('chat.unPinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should return an error when pinMessage is allowed in server but users dont have permission', async () => { + await updateSetting('Message_AllowPinning', true); + + await updatePermission('pin-message', []); + + const res = await request + .post(api('chat.unPinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should unpin Message successfully', async () => { + await updatePermission('pin-message', ['admin']); + + const res = await request + .post(api('chat.unPinMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + }); + }); + + describe('[/chat.unStarMessage]', () => { + after(() => updateSetting('Message_AllowStarring', true)); + + it('should return an error when starMessage is not allowed in this server', async () => { + await updateSetting('Message_AllowStarring', false); + + const res = await request + .post(api('chat.unStarMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should unstar Message successfully', async () => { + await updateSetting('Message_AllowStarring', true); + + const res = await request + .post(api('chat.unStarMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + }); + }); + + describe('[/chat.starMessage]', () => { + after(() => updateSetting('Message_AllowStarring', true)); + + it('should return an error when starMessage is not allowed in this server', async () => { + await updateSetting('Message_AllowStarring', false); + + const res = await request + .post(api('chat.starMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should star Message successfully', async () => { + await updateSetting('Message_AllowStarring', true); + + const res = await request + .post(api('chat.starMessage')) + .set(credentials) + .send({ + messageId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('error'); + }); + }); + + describe('[/chat.ignoreUser]', () => { + let user: IUser; + let testDM: IRoom & { rid: IRoom['_id'] }; + + before(async () => { + user = await createUser(); + await request + .post(api('im.create')) + .set(credentials) + .send({ + username: user.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testDM = res.body.room; + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'd', roomId: testDM.rid }), deleteUser(user)])); + + it('should fail if invalid roomId', (done) => { + void request + .get(api('chat.ignoreUser')) + .set(credentials) + .query({ + rid: 'invalid', + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-subscription'); + }) + .end(() => { + done(); + }); + }); + it('should fail if invalid userId', (done) => { + void request + .get(api('chat.ignoreUser')) + .set(credentials) + .query({ + rid: testDM.rid, + userId: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-subscription'); + }) + .end(() => { + done(); + }); + }); + it('should successfully ignore user', (done) => { + void request + .get(api('chat.ignoreUser')) + .set(credentials) + .query({ + rid: testDM.rid, + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(() => { + done(); + }); + }); + it('should successfully unignore user', (done) => { + void request + .get(api('chat.ignoreUser')) + .set(credentials) + .query({ + rid: testDM.rid, + userId: user._id, + ignore: false, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(() => { + done(); + }); + }); + }); + + describe('[/chat.getPinnedMessages]', () => { + let roomId: IRoom['_id']; + + before(async () => { + roomId = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel._id; + + const msgId = (await sendSimpleMessage({ roomId })).body.message._id; + + await pinMessage({ msgId }); + }); + + after(() => deleteRoom({ type: 'c', roomId })); + + describe('when execute successfully', () => { + it('should return a list of pinned messages', async () => { + const res = await request + .get(api('chat.getPinnedMessages')) + .set(credentials) + .query({ + roomId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + it('should return a list of pinned messages when the user sets count query parameter', async () => { + const res = await request + .get(api('chat.getPinnedMessages')) + .set(credentials) + .query({ + roomId, + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + it('should return a list of pinned messages when the user sets count and offset query parameters', async () => { + const res = await request + .get(api('chat.getPinnedMessages')) + .set(credentials) + .query({ + roomId, + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages.length).to.be.equal(1); + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400 and an error when "roomId" is not provided', async () => { + const res = await request + .get(api('chat.getPinnedMessages')) + .set(credentials) + .query({ + count: 1, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/chat.getMentionedMessages]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return an error when the required "roomId" parameter is not sent', async () => { + const res = await request + .get(api('chat.getMentionedMessages')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + }); + + it('should return an error when the roomId is invalid', async () => { + const res = await request + .get(api('chat.getMentionedMessages')) + .query({ roomId: 'invalid-room' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-allowed'); + }); + + it('should return the mentioned messages', async () => { + const res = await request + .get(api('chat.getMentionedMessages')) + .query({ roomId: testChannel._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + + describe('[/chat.getStarredMessages]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}`, + }) + ).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return an error when the required "roomId" parameter is not sent', async () => { + const res = await request.get(api('chat.getStarredMessages')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + }); + + it('should return an error when the roomId is invalid', async () => { + const res = await request + .get(api('chat.getStarredMessages')) + .query({ roomId: 'invalid-room' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-allowed'); + }); + + it('should return the starred messages', async () => { + const res = await request + .get(api('chat.getStarredMessages')) + .query({ roomId: testChannel._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + + describe('[/chat.getDiscussions]', () => { + const messageText = 'Message to create discussion'; + let testChannel: IRoom; + let discussionRoom: IRoom & { rid: IRoom['_id'] }; + const messageWords = [ + ...messageText.split(' '), + ...messageText.toUpperCase().split(' '), + ...messageText.toLowerCase().split(' '), + messageText, + messageText.charAt(0), + ' ', + ]; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.getDiscussions.${Date.now()}` })).body.channel; + + discussionRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: 'Message to create discussion', + }) + .expect('Content-Type', 'application/json') + .expect(200) + ).body.discussion; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return an error when the required "roomId" parameter is not sent', async () => { + const res = await request.get(api('chat.getDiscussions')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return an error when the roomId is invalid', async () => { + const res = await request + .get(api('chat.getDiscussions')) + .query({ roomId: 'invalid-room' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-allowed'); + }); + + it('should return the discussions of a room', async () => { + const res = await request + .get(api('chat.getDiscussions')) + .query({ roomId: testChannel._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + it('should return the discussions of a room even requested with count and offset params', async () => { + const res = await request + .get(api('chat.getDiscussions')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + + function filterDiscussionsByText(text: string) { + it(`should return the room's discussion list filtered by the text '${text}'`, async () => { + const res = await request + .get(api('chat.getDiscussions')) + .set(credentials) + .query({ + roomId: testChannel._id, + text, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0].drid).to.be.equal(discussionRoom.rid); + }); + + it(`should return the room's discussion list filtered by the text '${text}' even requested with count and offset params`, async () => { + const res = await request + .get(api('chat.getDiscussions')) + .set(credentials) + .query({ + roomId: testChannel._id, + text, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0].drid).to.be.equal(discussionRoom.rid); + }); + } + + messageWords.forEach((text) => { + filterDiscussionsByText(text); + }); + }); + + describe('[/chat.syncMessages]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.syncMessages.${Date.now()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return an error when the required "roomId" parameter is not sent', async () => { + const res = await request.get(api('chat.syncMessages')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.include(`must have required property 'roomId'`); + }); + + it('should return an error when the neither "lastUpdate" or "type" parameter is sent', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-param-required'); + expect(res.body.error).to.include('The "type" or "lastUpdate" parameters must be provided'); + }); + + it('should return an error when the "lastUpdate" parameter is invalid', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: 'invalid-room', lastUpdate: 'invalid-date' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-lastUpdate-param-invalid'); + expect(res.body.error).to.include('The "lastUpdate" query parameter must be a valid date'); + }); + + it('should return an error when user provides an invalid roomId', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: 'invalid-room', lastUpdate: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + expect(res.body.error).to.include('Not allowed'); + }); + + it('should return an error when the "type" parameter is not supported', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, type: 'invalid-type', next: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.include('must be equal to one of the allowed values'); + }); + + it('should return an error when the "next" or "previous" parameter is sent without the "type" parameter', async () => { + const nextResponse = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, next: new Date().toISOString() }); + + const previousResponse = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, previous: new Date().toISOString() }); + + expect(nextResponse.statusCode).to.equal(400); + expect(nextResponse.body).to.have.property('success', false); + expect(nextResponse.body.errorType).to.be.equal('error-param-required'); + expect(nextResponse.body.error).to.include('The "type" or "lastUpdate" parameters must be provided'); + + expect(previousResponse.statusCode).to.equal(400); + expect(previousResponse.body).to.have.property('success', false); + expect(previousResponse.body.errorType).to.be.equal('error-param-required'); + expect(previousResponse.body.error).to.include('The "type" or "lastUpdate" parameters must be provided'); + }); + + it('should return an error when both "next" and "previous" are sent', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, type: 'UPDATED', next: new Date().toISOString(), previous: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-cursor-conflict'); + expect(res.body.error).to.include('You cannot provide both "next" and "previous" parameters'); + }); + + it('should return an error when both "next" or "previous" and "lastUpdate" are sent', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, type: 'UPDATED', next: new Date().toISOString(), lastUpdate: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-cursor-and-lastUpdate-conflict'); + expect(res.body.error).to.include('The attributes "next", "previous" and "lastUpdate" cannot be used together'); + }); + + it('should return an error when neither "type" or "lastUpdate" are sent', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-param-required'); + expect(res.body.error).to.include('The "type" or "lastUpdate" parameters must be provided'); + }); + + it('should return an empty response when there are no messages to sync', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.result).to.have.property('updated').and.to.be.an('array'); + expect(res.body.result).to.have.property('deleted').and.to.be.an('array'); + expect(res.body.result.updated).to.have.lengthOf(0); + expect(res.body.result.deleted).to.have.lengthOf(0); + }); + + it('should return all updated and deleted messages since "lastUpdate" parameter date', async () => { + const lastUpdate = new Date().toISOString(); + + // Create two messages isolated to avoid ts conflict + const firstMessage = await sendSimpleMessage({ roomId: testChannel._id, text: 'First Message' }); + const secondMessage = await sendSimpleMessage({ roomId: testChannel._id, text: 'Second Message' }); + + const response = await request.get(api('chat.syncMessages')).set(credentials).query({ roomId: testChannel._id, lastUpdate }); + + expect(response.body.result.updated).to.have.lengthOf(2); + expect(response.body.result.updated[0]._id).to.be.equal(secondMessage.body.message._id); + expect(response.body.result.updated[1]._id).to.be.equal(firstMessage.body.message._id); + expect(response.body.result.deleted).to.have.lengthOf(0); + + await deleteMessage({ roomId: testChannel._id, msgId: firstMessage.body.message._id }); + + const responseAfterDelete = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate }); + + expect(responseAfterDelete.body.result.updated).to.have.lengthOf(1); + expect(responseAfterDelete.body.result.updated[0]._id).to.be.equal(secondMessage.body.message._id); + expect(responseAfterDelete.body.result.deleted).to.have.lengthOf(1); + expect(responseAfterDelete.body.result.deleted[0]._id).to.be.equal(firstMessage.body.message._id); + + await deleteMessage({ roomId: testChannel._id, msgId: secondMessage.body.message._id }); + }); + + it('should return an error when the "fromTs" parameter is invalid', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate: new Date().toISOString(), fromTs: 'invalid-date' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + }); + + it('should return an error when "fromTs" is sent along with cursor pagination', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, type: 'UPDATED', next: `${Date.now()}`, fromTs: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-fromTs-requires-lastUpdate'); + expect(res.body.error).to.include('The "fromTs" parameter can only be used together with "lastUpdate"'); + }); + + it('should return an error when "fromTs" is sent without "lastUpdate"', async () => { + const res = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, type: 'UPDATED', fromTs: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-fromTs-requires-lastUpdate'); + }); + + describe('"fromTs" parameter', () => { + let olderMessage: IMessage; + let newerMessage: IMessage; + let lastUpdate: string; + + // the server filters with `ts: { $gte: fromTs }`, so a boundary taken from the newer message only + // excludes the older one when the two land on distinct milliseconds + const sendBoundaryMessages = async (olderText: string, newerText: string): Promise<[IMessage, IMessage]> => { + const older: IMessage = (await sendSimpleMessage({ roomId: testChannel._id, text: olderText })).body.message; + await sleep(5); + const newer: IMessage = (await sendSimpleMessage({ roomId: testChannel._id, text: newerText })).body.message; + + expect(new Date(newer.ts).getTime()).to.be.greaterThan(new Date(older.ts).getTime()); + + return [older, newer]; + }; + + before(async () => { + [olderMessage, newerMessage] = await sendBoundaryMessages('Older Message', 'Newer Message'); + + // every edit below happens after this point, so both messages are candidates on `_updatedAt` + lastUpdate = new Date().toISOString(); + + await updateMessage({ roomId: testChannel._id, msgId: olderMessage._id, updatedMessage: 'Older Message edited' }); + await updateMessage({ roomId: testChannel._id, msgId: newerMessage._id, updatedMessage: 'Newer Message edited' }); + }); + + after(() => + Promise.all([ + deleteMessage({ roomId: testChannel._id, msgId: olderMessage._id }), + deleteMessage({ roomId: testChannel._id, msgId: newerMessage._id }), + ]), + ); + + it('should return every edited message when "fromTs" is not provided', async () => { + const response = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate }) + .expect(200); + + expect(response.body.result.updated).to.have.lengthOf(2); + expect(response.body.result.updated.map((msg: IMessage) => msg._id)).to.have.members([olderMessage._id, newerMessage._id]); + }); + + it('should omit messages older than "fromTs" while still returning the ones inside the window', async () => { + const response = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate, fromTs: newerMessage.ts }) + .expect(200); + + expect(response.body.result.updated).to.have.lengthOf(1); + expect(response.body.result.updated[0]._id).to.be.equal(newerMessage._id); + expect(response.body.result.updated[0].msg).to.be.equal('Newer Message edited'); + }); + + it('should omit deletions of messages older than "fromTs"', async () => { + const [droppedMessage, windowStart] = await sendBoundaryMessages('To Be Deleted', 'Window Start'); + + const deletionLastUpdate = new Date().toISOString(); + await deleteMessage({ roomId: testChannel._id, msgId: droppedMessage._id }); + + const scoped = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate: deletionLastUpdate, fromTs: windowStart.ts }) + .expect(200); + + expect(scoped.body.result.deleted).to.have.lengthOf(0); + + const unscoped = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, lastUpdate: deletionLastUpdate }) + .expect(200); + + expect(unscoped.body.result.deleted).to.have.lengthOf(1); + expect(unscoped.body.result.deleted[0]._id).to.be.equal(droppedMessage._id); + + await deleteMessage({ roomId: testChannel._id, msgId: windowStart._id }); + }); + }); + + it('should return all updated messages with a cursor when "type" parameter is "UPDATED"', async () => { + const lastUpdate = new Date(); + const firstMessage = await sendSimpleMessage({ roomId: testChannel._id, text: 'First Message' }); + const secondMessage = await sendSimpleMessage({ roomId: testChannel._id, text: 'Second Message' }); + const thirdMessage = await sendSimpleMessage({ roomId: testChannel._id, text: 'Third Message' }); + + const response = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ + roomId: testChannel._id, + next: new Date(lastUpdate).getTime().toString(), + type: 'UPDATED', + count: 2, + }); + + expect(response.body.result.updated).to.have.lengthOf(2); + expect(response.body.result.updated[0]._id).to.be.equal(firstMessage.body.message._id); + expect(response.body.result.updated[1]._id).to.be.equal(secondMessage.body.message._id); + expect(response.body.result.cursor) + .to.have.property('next') + .and.to.equal(new Date(response.body.result.updated[response.body.result.updated.length - 1]._updatedAt).getTime().toString()); + expect(response.body.result.cursor) + .to.have.property('previous') + .and.to.equal(new Date(response.body.result.updated[0]._updatedAt).getTime().toString()); + expect(response.body.result.cursor) + .to.have.property('previous') + .and.to.equal(new Date(firstMessage.body.message._updatedAt).getTime().toString()); + + const responseWithNext = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: testChannel._id, next: response.body.result.cursor.next, type: 'UPDATED', count: 2 }); + + expect(responseWithNext.body.result.updated).to.have.lengthOf(1); + expect(responseWithNext.body.result.updated[0]._id).to.be.equal(thirdMessage.body.message._id); + expect(responseWithNext.body.result.cursor).to.have.property('next').and.to.be.null; + expect(responseWithNext.body.result.cursor) + .to.have.property('previous') + .and.to.equal(new Date(thirdMessage.body.message._updatedAt).getTime().toString()); + + await Promise.all([ + deleteMessage({ roomId: testChannel._id, msgId: firstMessage.body.message._id }), + deleteMessage({ roomId: testChannel._id, msgId: secondMessage.body.message._id }), + deleteMessage({ roomId: testChannel._id, msgId: thirdMessage.body.message._id }), + ]); + }); + + it('should return all deleted messages with a cursor when "type" parameter is "DELETED"', async () => { + const newChannel = (await createRoom({ type: 'c', name: `channel.test.syncMessages.${Date.now()}` })).body.channel; + const lastUpdate = new Date(); + const firstMessage = (await sendSimpleMessage({ roomId: newChannel._id, text: 'First Message' })).body.message; + const secondMessage = (await sendSimpleMessage({ roomId: newChannel._id, text: 'Second Message' })).body.message; + const thirdMessage = (await sendSimpleMessage({ roomId: newChannel._id, text: 'Third Message' })).body.message; + + const response = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: newChannel._id, next: lastUpdate.getTime().toString(), type: 'DELETED', count: 2 }); + + expect(response.body.result.deleted).to.have.lengthOf(0); + expect(response.body.result.cursor).to.have.property('next').and.to.be.null; + expect(response.body.result.cursor).to.have.property('previous').and.to.be.null; + + const firstDeletedMessage = (await deleteMessage({ roomId: newChannel._id, msgId: firstMessage._id })).body.message; + const secondDeletedMessage = (await deleteMessage({ roomId: newChannel._id, msgId: secondMessage._id })).body.message; + const thirdDeletedMessage = (await deleteMessage({ roomId: newChannel._id, msgId: thirdMessage._id })).body.message; + + const responseAfterDelete = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: newChannel._id, next: lastUpdate.getTime().toString(), type: 'DELETED', count: 2 }); + + expect(responseAfterDelete.body.result.deleted).to.have.lengthOf(2); + expect(responseAfterDelete.body.result.deleted[0]._id).to.be.equal(firstDeletedMessage._id); + expect(responseAfterDelete.body.result.deleted[1]._id).to.be.equal(secondDeletedMessage._id); + expect(responseAfterDelete.body.result.cursor) + .to.have.property('next') + .and.to.equal( + new Date(responseAfterDelete.body.result.deleted[responseAfterDelete.body.result.deleted.length - 1]._deletedAt) + .getTime() + .toString(), + ); + expect(responseAfterDelete.body.result.cursor) + .to.have.property('previous') + .and.to.equal(new Date(responseAfterDelete.body.result.deleted[0]._deletedAt).getTime().toString()); + + const responseAfterDeleteWithPrevious = await request + .get(api('chat.syncMessages')) + .set(credentials) + .query({ roomId: newChannel._id, next: responseAfterDelete.body.result.cursor.next, type: 'DELETED', count: 2 }); + + expect(responseAfterDeleteWithPrevious.body.result.deleted).to.have.lengthOf(1); + expect(responseAfterDeleteWithPrevious.body.result.deleted[0]._id).to.be.equal(thirdDeletedMessage._id); + expect(responseAfterDeleteWithPrevious.body.result.cursor).to.have.property('next').and.to.be.null; + expect(responseAfterDeleteWithPrevious.body.result.cursor) + .to.have.property('previous') + .and.to.equal(new Date(responseAfterDeleteWithPrevious.body.result.deleted[0]._deletedAt).getTime().toString()); + + await deleteRoom({ type: 'c', roomId: newChannel._id }); + }); + }); +}); + +describe('Threads', () => { + let testThreadChannel: IRoom; + + before((done) => getCredentials(done)); + + before(async () => { + testThreadChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + + await updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']); + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteRoom({ type: 'c', roomId: testThreadChannel._id }), + ]), + ); + + describe('[/chat.getThreadsList]', () => { + const messageText = 'Message to create thread'; + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let user: TestUser; + const messageWords = [ + ...messageText.split(' '), + ...messageText.toUpperCase().split(' '), + ...messageText.toLowerCase().split(' '), + messageText, + messageText.charAt(0), + ' ', + ]; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` })).body.channel; + const { message } = ( + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Message to create thread', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + ).body; + + threadMessage = ( + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Thread message', + tmid: message._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + + it('should return an error for chat.getThreadsList when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Threads Disabled [error-not-allowed]'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updateSetting('Threads_enabled', true).then(() => { + void updatePermission('view-c-room', []).then(() => { + void request + .get(api('chat.getThreadsList')) + .set(userCredentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Not Allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + }); + + it("should return the room's thread list", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(1); + expect(res.body.threads[0]._id).to.be.equal(threadMessage.tmid); + }); + + it("should fail returning a room's thread list if no roomId is provided", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + return request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it("should fail returning a room's thread list if an invalid type is provided", async () => { + return request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + type: 'invalid-type', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + }); + + it("should return the room's thread list", async () => { + return request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(1); + expect(res.body.threads[0]._id).to.be.equal(threadMessage.tmid); + }); + }); + + it("should return the room's thread list even requested with count and offset params", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(1); + expect(res.body.threads[0]._id).to.be.equal(threadMessage.tmid); + }); + + function filterThreadsByText(text: string) { + it(`should return the room's thread list filtered by the text '${text}'`, async () => { + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + text, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(1); + expect(res.body.threads[0]._id).to.be.equal(threadMessage.tmid); + }); + it(`should return the room's thread list filtered by the text '${text}' even requested with count and offset params`, async () => { + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + text, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(1); + expect(res.body.threads[0]._id).to.be.equal(threadMessage.tmid); + }); + } + + messageWords.forEach((text) => { + filterThreadsByText(text); + }); + + it('should return an empty thread list', async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + text: 'missing', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.threads).to.have.lengthOf(0); + }); + }); + + describe('[/chat.syncThreadsList]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let user: TestUser; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `.threads.sync.${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + + it('should return an error for chat.getThreadsList when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .get(api('chat.getThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Threads Disabled [error-not-allowed]'); + }); + + it('should return an error when the required param "rid" is missing', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadsList')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the required param "updatedSince" is missing', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the param "updatedSince" is an invalid date', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + updatedSince: 'invalid-date', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updatePermission('view-c-room', []).then(() => { + void request + .get(api('chat.syncThreadsList')) + .set(userCredentials) + .query({ + rid: testChannel._id, + updatedSince: new Date().toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Not Allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + + it("should return the room's thread synced list", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.syncThreadsList')) + .set(credentials) + .query({ + rid: testChannel._id, + updatedSince: new Date('2019-04-01').toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('threads').and.to.be.an('object'); + expect(res.body.threads).to.have.property('update').and.to.be.an('array'); + expect(res.body.threads).to.have.property('remove').and.to.be.an('array'); + expect(res.body.threads.update).to.have.lengthOf(1); + expect(res.body.threads.remove).to.have.lengthOf(0); + expect(res.body.threads.update[0]._id).to.be.equal(threadMessage.tmid); + }); + }); + + describe('[/chat.getThreadMessages]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let createdThreadMessage: IThreadMessage; + let user: TestUser; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.${Date.now()}` })).body.channel; + createdThreadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }) + ).body.message; + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: createdThreadMessage._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + + it('should return an error for chat.getThreadMessages when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .get(api('chat.getThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Threads Disabled [error-not-allowed]'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updateSetting('Threads_enabled', true).then(() => { + void updatePermission('view-c-room', []).then(() => { + void request + .get(api('chat.getThreadMessages')) + .set(userCredentials) + .query({ + tmid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Not Allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + }); + + it("should return the thread's message list", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.getThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body).to.have.property('total').and.to.be.equal(1); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0].tmid).to.be.equal(createdThreadMessage._id); + }); + + describe('Message_KeepHistory', () => { + describe('when enabled', () => { + let secondThreadMessage: IThreadMessage; + + before(async () => { + await updateSetting('Message_KeepHistory', true); + await updateMessage({ + msgId: threadMessage._id, + updatedMessage: 'Edited thread message', + roomId: testChannel._id, + }); + secondThreadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Second thread message', + tmid: createdThreadMessage._id, + }) + ).body.message; + }); + after(() => updateSetting('Message_KeepHistory', false)); + + it('should exclude hidden message edit history from offset pagination', async () => { + const res = await request + .get(api('chat.getThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + count: 1, + offset: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.include({ success: true, total: 2, count: 1, offset: 1 }); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.property('_id', secondThreadMessage._id); + }); + + it('should exclude hidden message edit history when resolving an aroundId offset', async () => { + const res = await request + .get(api('chat.getThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + aroundId: secondThreadMessage._id, + count: 2, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.include({ success: true, total: 2, count: 2, offset: 0 }); + expect(res.body.messages.map(({ _id }: IMessage) => _id)).to.deep.equal([threadMessage._id, secondThreadMessage._id]); + }); + }); + }); + }); + + describe('[/chat.readThread]', () => { + let testChannel: IRoom; + let parentMessage: IMessage; + let replier: TestUser; + let replierCredentials: Credentials; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.readThread.${Date.now()}` })).body.channel; + parentMessage = (await sendSimpleMessage({ roomId: testChannel._id, text: 'Message to create thread' })).body.message; + + replier = await createUser(); + replierCredentials = await login(replier.username, password); + await addUserToRoom({ usernames: [replier.username], rid: testChannel._id }); + }); + + after(() => + Promise.all([updateSetting('Threads_enabled', true), deleteRoom({ type: 'c', roomId: testChannel._id }), deleteUser(replier)]), + ); + + it('should fail when threads are disabled', async () => { + await updateSetting('Threads_enabled', false); + + try { + await request + .post(api('chat.readThread')) + .set(credentials) + .send({ tmid: parentMessage._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + } finally { + await updateSetting('Threads_enabled', true); + } + }); + + it('should fail when tmid does not match any message', async () => { + await request + .post(api('chat.readThread')) + .set(credentials) + .send({ tmid: 'invalid-message-id' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-message'); + }); + }); + + it('should fail when tmid is missing', async () => { + await request + .post(api('chat.readThread')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should clear the unread thread flag of the caller subscription', async () => { + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread reply', + tmid: parentMessage._id, + userCredentials: replierCredentials, + }); + + await retry('caller subscription has the thread as unread', async () => { + const subscription = await getSubscriptionByRoomId(testChannel._id); + expect(subscription.tunread).to.include(parentMessage._id); + }); + + await request + .post(api('chat.readThread')) + .set(credentials) + .send({ tmid: parentMessage._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await getSubscriptionByRoomId(testChannel._id); + expect(subscription.tunread ?? []).to.not.include(parentMessage._id); + }); + }); + + describe('[/chat.syncThreadMessages]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let createdThreadMessage: IThreadMessage; + let user: TestUser; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `message.threads.${Date.now()}` })).body.channel; + createdThreadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }) + ).body.message; + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: createdThreadMessage._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + + it('should return an error for chat.syncThreadMessages when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .get(api('chat.syncThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + updatedSince: new Date().toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Threads Disabled [error-not-allowed]'); + }); + + it('should return an error when the required param "tmid" is missing', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadMessages')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the required param "updatedSince" is missing', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the param "updatedSince" is an invalid date', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .get(api('chat.syncThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + updatedSince: 'invalid-date', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updatePermission('view-c-room', []).then(() => { + void request + .get(api('chat.syncThreadMessages')) + .set(userCredentials) + .query({ + tmid: threadMessage.tmid, + updatedSince: new Date().toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'Not Allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + + it("should return the thread's message list", async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .get(api('chat.syncThreadMessages')) + .set(credentials) + .query({ + tmid: threadMessage.tmid, + updatedSince: new Date('2019-04-01').toISOString(), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('object'); + expect(res.body.messages).to.have.property('update').and.to.be.an('array'); + expect(res.body.messages).to.have.property('remove').and.to.be.an('array'); + expect(res.body.messages.update).to.have.lengthOf(1); + expect(res.body.messages.remove).to.have.lengthOf(0); + expect(res.body.messages.update[0].id).to.be.equal(createdThreadMessage.tmid); + }); + }); + + describe('[/chat.followMessage]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let user: TestUser; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.follow${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + + it('should return an error for chat.followMessage when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .post(api('chat.followMessage')) + .set(credentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'not-allowed [error-not-allowed]'); + }); + + it('should return an error when the message does not exist', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .post(api('chat.followMessage')) + .set(credentials) + .send({ + mid: 'invalid-message-id', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-message'); + expect(res.body).to.have.property('error', 'Invalid message [error-invalid-message]'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updatePermission('view-c-room', []).then(() => { + void request + .post(api('chat.followMessage')) + .set(userCredentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'not-allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + + it('should return success: true when it execute successfully', async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .post(api('chat.followMessage')) + .set(credentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('[/chat.unfollowMessage]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + let user: TestUser; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.threads.unfollow.${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + updateSetting('Threads_enabled', true), + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + deleteUser(user), + ]), + ); + it('should return an error for chat.unfollowMessage when threads are not allowed in this server', async () => { + await updateSetting('Threads_enabled', false); + + const res = await request + .post(api('chat.unfollowMessage')) + .set(credentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'not-allowed [error-not-allowed]'); + }); + + it('should return an error when the message does not exist', async () => { + await updateSetting('Threads_enabled', true); + + const res = await request + .post(api('chat.unfollowMessage')) + .set(credentials) + .send({ + mid: 'invalid-message-id', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-message'); + expect(res.body).to.have.property('error', 'Invalid message [error-invalid-message]'); + }); + + it('should return an error when the user is not allowed access the room', (done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(createdUser.username, password).then((userCredentials) => { + void updatePermission('view-c-room', []).then(() => { + void request + .post(api('chat.unfollowMessage')) + .set(userCredentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'not-allowed [error-not-allowed]'); + }) + .end(done); + }); + }); + }); + }); + + it('should return success: true when it execute successfully', async () => { + await updatePermission('view-c-room', ['admin', 'user']); + + const res = await request + .post(api('chat.unfollowMessage')) + .set(credentials) + .send({ + mid: threadMessage.tmid, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('[/chat.getURLPreview]', () => { + const url = 'https://www.youtube.com/watch?v=no050HN4ojo'; + it('should return the URL preview with metadata and headers', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: testThreadChannel._id, + url, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('urlPreview').and.to.be.an('object').that.is.not.empty; + expect(res.body.urlPreview).to.have.property('url', url); + expect(res.body.urlPreview).to.have.property('headers').and.to.be.an('object').that.is.not.empty; + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400 and an error when "roomId" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return statusCode 400 and an error when "url" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: testThreadChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return statusCode 400 and an error when "roomId" is provided but user is not in the room', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'undefined', + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + }); + }); + }); + }); + + describe('[/chat.getMessages]', () => { + let firstRoom: IRoom; + let secondRoom: IRoom; + let privateRoom: IRoom; + let firstMessageId: IMessage['_id']; + let secondMessageId: IMessage['_id']; + let privateMessageId: IMessage['_id']; + let outsider: TestUser; + let outsiderCredentials: Credentials; + + before(async () => { + firstRoom = (await createRoom({ type: 'c', name: `chat.getMessages-first-${Date.now()}` })).body.channel; + secondRoom = (await createRoom({ type: 'c', name: `chat.getMessages-second-${Date.now()}` })).body.channel; + privateRoom = (await createRoom({ type: 'p', name: `chat.getMessages-private-${Date.now()}` })).body.group; + + const [first, second, priv] = await Promise.all([ + sendMessage({ message: { rid: firstRoom._id, msg: 'first room message' } }), + sendMessage({ message: { rid: secondRoom._id, msg: 'second room message' } }), + sendMessage({ message: { rid: privateRoom._id, msg: 'private room message' } }), + ]); + + firstMessageId = first.body.message._id; + secondMessageId = second.body.message._id; + privateMessageId = priv.body.message._id; + + outsider = await createUser(); + outsiderCredentials = await login(outsider.username, password); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: firstRoom._id }), + deleteRoom({ type: 'c', roomId: secondRoom._id }), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteUser(outsider), + ]); + }); + + it('should return the requested messages', async () => { + const res = await request + .post(api('chat.getMessages')) + .set(credentials) + .send({ messageIds: [firstMessageId] }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.property('_id', firstMessageId); + expect(res.body.messages[0]).to.have.property('msg', 'first room message'); + }); + + it('should resolve a batch spanning several rooms in a single request', async () => { + const res = await request + .post(api('chat.getMessages')) + .set(credentials) + .send({ messageIds: [firstMessageId, secondMessageId] }) + .expect(200); + + expect(res.body.messages.map((message: IMessage) => message._id)).to.have.members([firstMessageId, secondMessageId]); + }); + + it('should omit ids that do not resolve to a message', async () => { + const res = await request + .post(api('chat.getMessages')) + .set(credentials) + .send({ messageIds: [firstMessageId, 'does-not-exist'] }) + .expect(200); + + expect(res.body.messages.map((message: IMessage) => message._id)).to.deep.equal([firstMessageId]); + }); + + it('should reject the whole batch when any message belongs to an unreadable room', async () => { + const res = await request + .post(api('chat.getMessages')) + .set(outsiderCredentials) + .send({ messageIds: [firstMessageId, privateMessageId] }) + .expect(403); + + expect(res.body).to.have.property('success', false); + }); + + it('should resolve the readable subset for the same user when the unreadable id is dropped', async () => { + const res = await request + .post(api('chat.getMessages')) + .set(outsiderCredentials) + .send({ messageIds: [firstMessageId] }) + .expect(200); + + expect(res.body.messages).to.have.lengthOf(1); + }); + + it('should fail when messageIds is empty', async () => { + const res = await request.post(api('chat.getMessages')).set(credentials).send({ messageIds: [] }).expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should fail when unauthenticated', async () => { + await request + .post(api('chat.getMessages')) + .send({ messageIds: [firstMessageId] }) + .expect(401); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/cloud.ts b/apps/meteor/tests/end-to-end/api/cloud.ts new file mode 100644 index 0000000..1909af7 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/cloud.ts @@ -0,0 +1,321 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('[Cloud]', function () { + this.retries(0); + + before((done) => getCredentials(done)); + + describe('[/cloud.manualRegister]', () => { + before(async () => { + return updatePermission('register-on-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('register-on-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .post(api('cloud.manualRegister')) + .expect('Content-Type', 'application/json') + .send({ + cloudBlob: 'test-blob', + }) + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + it('should fail when cloudBlob property is not provided', async () => { + return request + .post(api('cloud.manualRegister')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'cloudBlob'"); + }); + }); + + it('should fail when user does not have the register-on-cloud permission', async () => { + await updatePermission('register-on-cloud', []); + return request + .post(api('cloud.manualRegister')) + .set(credentials) + .send({ + cloudBlob: 'test-blob', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/cloud.createRegistrationIntent]', () => { + before(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .post(api('cloud.createRegistrationIntent')) + .expect('Content-Type', 'application/json') + .send({ + email: 'test-mail@example.com', + resend: true, + }) + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + it('should fail when resend property is not provided', async () => { + return request + .post(api('cloud.createRegistrationIntent')) + .set(credentials) + .send({ + email: 'test-mail@example.com', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'resend'"); + }); + }); + + it('should fail when email property is not provided', async () => { + return request + .post(api('cloud.createRegistrationIntent')) + .set(credentials) + .send({ + resend: true, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email'"); + }); + }); + + it('should fail when user does not have the manage-cloud permission', async () => { + await updatePermission('manage-cloud', []); + return request + .post(api('cloud.createRegistrationIntent')) + .set(credentials) + .send({ + email: 'test-mail@example.com', + resend: true, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/cloud.confirmationPoll]', () => { + before(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .get(api('cloud.confirmationPoll')) + .query({ + deviceCode: 'test-code', + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + it('should fail when deviceCode property is not provided', async () => { + return request + .get(api('cloud.confirmationPoll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'deviceCode'"); + }); + }); + + it('should fail when user does not have the manage-cloud permission', async () => { + await updatePermission('manage-cloud', []); + return request + .get(api('cloud.confirmationPoll')) + .set(credentials) + .query({ + deviceCode: 'test-code', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/cloud.registrationStatus]', () => { + before(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .get(api('cloud.registrationStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + it('should fail when user does not have the manage-cloud permission', async () => { + await updatePermission('manage-cloud', []); + return request + .get(api('cloud.registrationStatus')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return registration status when user has the manage-cloud permission', async () => { + await updatePermission('manage-cloud', ['admin']); + return request + .get(api('cloud.registrationStatus')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('registrationStatus'); + }); + }); + }); + + describe('[/cloud.workspaceRegisterData]', () => { + before(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .get(api('cloud.workspaceRegisterData')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should fail when user does not have the manage-cloud permission', async () => { + await updatePermission('manage-cloud', []); + return request + .get(api('cloud.workspaceRegisterData')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return a base64 encoded registration payload when user has the manage-cloud permission', async () => { + await updatePermission('manage-cloud', ['admin']); + return request + .get(api('cloud.workspaceRegisterData')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('registerData').that.is.a('string'); + + const decoded = JSON.parse(Buffer.from(res.body.registerData, 'base64').toString()); + expect(decoded).to.have.property('uniqueId'); + expect(decoded).to.have.property('workspaceId'); + }); + }); + }); + + describe('[/cloud.connectWorkspace]', () => { + before(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + after(async () => { + return updatePermission('manage-cloud', ['admin']); + }); + + it('should fail if user is not authenticated', async () => { + return request + .post(api('cloud.connectWorkspace')) + .send({ token: 'invalid-token' }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should fail when user does not have the manage-cloud permission', async () => { + await updatePermission('manage-cloud', []); + return request + .post(api('cloud.connectWorkspace')) + .set(credentials) + .send({ token: 'invalid-token' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/commands.ts b/apps/meteor/tests/end-to-end/api/commands.ts new file mode 100644 index 0000000..6fb8b00 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/commands.ts @@ -0,0 +1,581 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IThreadMessage, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { sendSimpleMessage } from '../../data/chat.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Commands]', () => { + before((done) => getCredentials(done)); + + describe('[/commands.get]', () => { + it('should return an error when call the endpoint without "command" required parameter', async () => { + const res = await request.get(api('commands.get')).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal(`must have required property 'command'`); + }); + it('should return an error when call the endpoint with an invalid command', async () => { + const res = await request + .get(api('commands.get')) + .set(credentials) + .query({ + command: 'invalid-command', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('There is no command in the system by the name of: invalid-command'); + }); + it('should return success when parameters are correct', async () => { + const res = await request + .get(api('commands.get')) + .set(credentials) + .query({ + command: 'help', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('command'); + expect(res.body.command).to.have.property('command'); + expect(res.body.command).to.have.property('description'); + expect(res.body.command).to.have.property('clientOnly'); + expect(res.body.command).to.have.property('providesPreview'); + }); + }); + + describe('[/commands.list]', () => { + it('should return a list of commands', async () => { + const res = await request.get(api('commands.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('commands').and.to.be.an('array'); + }); + it('should return a list of commands even requested with count and offset params', async () => { + const res = await request + .get(api('commands.list')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('commands').and.to.be.an('array'); + }); + }); + + describe('[/commands.run]', () => { + let testChannel: IRoom; + let threadMessage: IThreadMessage; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.commands.${Date.now()}` })).body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + + threadMessage = ( + await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Thread Message', + tmid: message._id, + }) + ).body.message; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return an error when call the endpoint without "command" required parameter', async () => { + const res = await request.post(api('commands.run')).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'command'"); + }); + + it('should coerce non-string "params" to string via ajv coercion', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'help', + roomId: 'GENERAL', + params: true, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return an error when call the endpoint without "roomId" required parameter', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'help', + params: 'params', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'roomId'"); + }); + it('should coerce non-string "tmid" to string via ajv coercion and fail with invalid thread', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'help', + params: 'params', + roomId: 'GENERAL', + tmid: true, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Invalid thread.'); + }); + it('should return an error when call the endpoint with the invalid "command" param', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'invalid-command', + params: 'params', + roomId: 'GENERAL', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('The command provided does not exist (or is disabled).'); + }); + it('should return an error when call the endpoint with an invalid thread id', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'tableflip', + params: 'params', + roomId: 'GENERAL', + tmid: 'invalid-thread', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Invalid thread.'); + }); + it('should return an error when call the endpoint with a valid thread id of wrong channel', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'tableflip', + params: 'params', + roomId: 'GENERAL', + tmid: threadMessage.tmid, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Invalid thread.'); + }); + it('should return success when parameters are correct', async () => { + const res = await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'tableflip', + params: 'params', + roomId: threadMessage.rid, + tmid: threadMessage.tmid, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('Command archive', function () { + describe('unauthorized cases', () => { + let user: TestUser; + let credentials: Credentials; + + this.beforeAll(async () => { + user = await createUser({ + joinDefaultChannels: true, + }); + credentials = await login(user.username, password); + }); + + this.afterAll(async () => { + await deleteUser(user); + }); + + it('should return an error when the user is not logged in', async () => { + await request + .post(api('commands.run')) + .send({ command: 'archive', roomId: 'GENERAL' }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return an error when the user has not enough permissions', async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'archive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-authorized'); + }); + }); + }); + + describe('authorized cases', function () { + this.afterAll(async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'unarchive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return a success when the user has enough permissions', async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'archive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + }); + + describe('Command unarchive', function () { + describe('unauthorized cases', () => { + let user: TestUser; + let credentials: Credentials; + this.beforeAll(async () => { + user = await createUser({ + joinDefaultChannels: true, + }); + credentials = await login(user.username, password); + }); + + this.afterAll(async () => { + await deleteUser(user); + }); + + it('should return an error when the user is not logged in', async () => { + await request + .post(api('commands.run')) + .send({ command: 'unarchive', roomId: 'GENERAL' }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return an error when the user has not enough permissions', async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'unarchive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-authorized'); + }); + }); + }); + + describe('authorized cases', () => { + before(async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'archive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + it('should return a success when the user has enough permissions', async () => { + await request + .post(api('commands.run')) + .set(credentials) + .send({ + command: 'unarchive', + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + }); + + describe('Command "invite-all-from"', function () { + let group: IRoom; + let group1: IRoom; + let channel: IRoom; + let user1: TestUser; + let user2: TestUser; + let user1Credentials: Credentials; + let user2Credentials: Credentials; + + this.beforeAll(async () => { + user1 = await createUser(); + user2 = await createUser(); + + [user1Credentials, user2Credentials] = await Promise.all([login(user1.username, password), login(user2.username, password)]); + }); + + this.beforeAll(async () => { + const [response1, response2, response3] = await Promise.all([ + createRoom({ type: 'p', name: `room1-${Date.now()}.${Random.id()}`, credentials: user1Credentials }), + createRoom({ type: 'c', name: `room2-${Date.now()}.${Random.id()}`, credentials: user2Credentials }), + createRoom({ type: 'p', name: `room3-${Date.now()}.${Random.id()}` }), + ]); + group = response1.body.group; + channel = response2.body.channel; + group1 = response3.body.group; + }); + + this.afterAll(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: group._id }), + deleteRoom({ type: 'c', roomId: channel._id }), + deleteRoom({ type: 'p', roomId: group1._id }), + ]); + await Promise.all([deleteUser(user1), deleteUser(user2)]); + }); + + it('should not add users from group which is not accessible by current user', async () => { + await request + .post(api('commands.run')) + .set(user2Credentials) + .send({ + roomId: channel._id, + command: 'invite-all-from', + params: `#${group.name}`, + triggerId: Random.id(), + }) + .expect(200) + .expect(async (res) => { + expect(res.body).to.have.a.property('success', true); + }); + + await request + .get(api('channels.members')) + .query({ roomId: channel._id }) + .set(user2Credentials) + .expect(200) + .expect((res) => { + const isUser1Added = res.body.members.some((member: IUser) => member.username === user1.username); + expect(isUser1Added).to.be.false; + }); + }); + + it('should not add users to a room that is not accessible by the current user', async () => { + await request + .post(api('commands.run')) + .set(user1Credentials) + .send({ + roomId: group1._id, + command: 'invite-all-from', + params: `#${group.name}`, + triggerId: Random.id(), + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('error', 'unauthorized'); + }); + }); + + it('should add users from group which is accessible by current user', async () => { + await request + .post(api('groups.invite')) + .set(user1Credentials) + .send({ + roomId: group._id, + userId: user2._id, + }) + .expect(200); + + await request + .post(api('commands.run')) + .set(user2Credentials) + .send({ + roomId: channel._id, + command: 'invite-all-from', + params: `#${group.name}`, + triggerId: Random.id(), + }) + .expect(200) + .expect(async (res) => { + expect(res.body).to.have.a.property('success', true); + }); + + await request + .get(api('channels.members')) + .set(user2Credentials) + .query({ roomId: channel._id }) + .expect(200) + .expect((res) => { + const isUser1Added = res.body.members.some((member: IUser) => member.username === user1.username); + expect(isUser1Added).to.be.true; + }); + }); + }); + describe('Command "kick"', function () { + let directMessageRoom: IRoom; + let user1: TestUser; + let user1Credentials: Credentials; + this.beforeAll(async () => { + user1 = await createUser(); + + [user1Credentials] = await Promise.all([login(user1.username, password)]); + }); + + this.beforeAll(async () => { + const [response1] = await Promise.all([ + createRoom({ type: 'd', name: `room1-${Date.now()}.${Random.id()}`, username: user1.username }), + ]); + directMessageRoom = response1.body.room; + }); + + this.afterAll(async () => { + await Promise.all([deleteRoom({ type: 'd', roomId: directMessageRoom._id })]); + await Promise.all([deleteUser(user1)]); + }); + + it('should fail when trying to kick a user from a direct message room', async () => { + const res = await request + .post(api('commands.run')) + .set(user1Credentials) + .send({ command: 'kick', roomId: directMessageRoom._id, params: user1.username }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.is.a('string'); + }); + }); + describe('Command "leave"', function () { + let directMessageRoom: IRoom; + let user1: TestUser; + let user2: TestUser; + let user1Credentials: Credentials; + this.beforeAll(async () => { + user1 = await createUser(); + user2 = await createUser(); + + [user1Credentials] = await Promise.all([login(user1.username, password)]); + }); + + this.beforeAll(async () => { + const [response1] = await Promise.all([ + createRoom({ type: 'd', name: `room1-${Date.now()}.${Random.id()}`, username: user1.username }), + ]); + directMessageRoom = response1.body.room; + }); + + this.afterAll(async () => { + await Promise.all([deleteRoom({ type: 'd', roomId: directMessageRoom._id })]); + await Promise.all([deleteUser(user1), deleteUser(user2)]); + }); + + it('should fail when trying to leave a direct message room', async () => { + const res = await request + .post(api('commands.run')) + .set(user1Credentials) + .send({ command: 'leave', roomId: directMessageRoom._id }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.is.a('string'); + }); + }); + + // TODO: invite never fails + describe.skip('Command "invite"', function () { + let directMessageRoom: IRoom; + let user1: TestUser; + let user2: TestUser; + let user1Credentials: Credentials; + this.beforeAll(async () => { + user1 = await createUser(); + user2 = await createUser(); + + [user1Credentials] = await Promise.all([login(user1.username, password)]); + }); + + this.beforeAll(async () => { + const [response1] = await Promise.all([ + createRoom({ type: 'd', name: `room1-${Date.now()}.${Random.id()}`, username: user1.username }), + ]); + directMessageRoom = response1.body.room; + }); + + this.afterAll(async () => { + await Promise.all([deleteRoom({ type: 'd', roomId: directMessageRoom._id })]); + await Promise.all([deleteUser(user1), deleteUser(user2)]); + }); + + it('should fail when trying to invite a user to a direct message room', async () => { + const res = await request + .post(api('commands.run')) + .set(user1Credentials) + .send({ command: 'invite', roomId: directMessageRoom._id, params: 'g1' }) + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.is.a('string'); + expect(res.body.error).to.equal('unauthorized'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/cors.ts b/apps/meteor/tests/end-to-end/api/cors.ts new file mode 100644 index 0000000..c4ab68d --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/cors.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request } from '../../data/api-data'; +import { updateSetting } from '../../data/permissions.helper'; + +const getHash = () => + request + .get('/') + .expect(200) + .expect('Content-Type', 'text/html; charset=utf-8') + .then((res) => { + const hashMatch = res.text.match(/meteor_runtime_config\.js\?hash=([^"']+)/); + expect(hashMatch).to.not.be.null; + const hash = hashMatch![1]; + return hash; + }); + +describe('[CORS]', () => { + before((done) => getCredentials(done)); + after(async () => { + await updateSetting('Site_Url', 'http://localhost:3000'); + }); + + describe('[/meteor_runtime_config.js]', () => { + it('should return 404 when hash is missing', async () => { + await request.get('/meteor_runtime_config.js').expect(404); + }); + + it('should return 404 when hash is invalid', async () => { + await request.get('/meteor_runtime_config.js').query({ hash: 'invalid_hash' }).expect(404); + }); + + it('should return runtime config when hash matches', async () => { + // First get the root page to extract the hash + const hash = await getHash(); + + // Now request with the extracted hash + await request + .get('/meteor_runtime_config.js') + .query({ hash }) + .expect(200) + .expect('Content-Type', 'application/javascript; charset=UTF-8') + .expect((res) => { + expect(res.text).to.include('__meteor_runtime_config__'); + expect(res.text).to.include('http://localhost:3000'); + }); + }); + + it('should return 404 when hash does not match', async () => { + // First get the root page to extract the hash + await request.get('/meteor_runtime_config.js').query({ hash: 'invalidHash' }).expect(404); + }); + + it('should update hash when ROOT_URL changes', async () => { + const originalHash = getHash(); + await updateSetting('Site_Url', 'http://new-url:3000'); + const newHash = await getHash(); + expect(newHash).to.not.equal(originalHash); + // it should return if the new hash is valid + await request + .get('/meteor_runtime_config.js') + .query({ hash: newHash }) + .expect(200) + .expect('Content-Type', 'application/javascript; charset=UTF-8') + .expect((res) => { + expect(res.text).to.include('__meteor_runtime_config__'); + expect(res.text).to.include('http://new-url:3000'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts new file mode 100644 index 0000000..0273111 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -0,0 +1,747 @@ +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updateSetting } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +async function createCustomSound(fileName: string, filePath: string): Promise { + let fileId = ''; + + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', filePath) + .field('name', fileName) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound'); + fileId = res.body.sound._id; + }); + + return fileId; +} + +async function deleteCustomSound(_id: string) { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); +} + +describe('[CustomSounds]', () => { + const fileName = `test-file-${randomUUID()}`; + const mockWavAudioPath = path.resolve(__dirname, '../../mocks/files/audio_mock.wav'); + const mockMp3AudioPath = path.resolve(__dirname, '../../mocks/files/audio_mock.mp3'); + + let fileId: string; + let fileId2: string; + let uploadDate: string | undefined; + + before((done) => getCredentials(done)); + + before(async () => { + fileId = await createCustomSound(fileName, mockWavAudioPath); + fileId2 = await createCustomSound(`${fileName}-2`, mockWavAudioPath); + }); + + after(async () => { + if (fileId) { + await deleteCustomSound(fileId); + } + if (fileId2) { + await deleteCustomSound(fileId2); + } + }); + + describe('[/custom-sounds.create]', () => { + let fileId3: string; + + after(async () => { + if (fileId3) { + await deleteCustomSound(fileId3); + } + }); + + it('should successfully create a new custom sound and return its _id', async () => { + const response = await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', `happy-path-sound-${randomUUID()}`) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('_id').and.to.be.a('string'); + }); + fileId3 = response.body.sound._id; + }); + + it('should not be able to create two sounds with the same name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', fileName) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The custom sound name is already in use [Custom_Sound_Error_Name_Already_In_Use]'); + }); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.create')).expect(401); + }); + + it('should fail if the file exceeds the 5MB size limit', async () => { + const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'a'); + + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', largeBuffer, { filename: 'large.wav', contentType: 'audio/wav' }) + .field('name', 'large-sound') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error').and.to.be.equal('[error-file-too-large]'); + }); + }); + + it('should fail if the file is an invalid mime type', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', Buffer.from('this is not audio'), { filename: 'test.txt', contentType: 'text/plain' }) + .field('name', 'invalid-sound') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('MIME type not allowed'); + }); + }); + + it('should reject injection of invalid characters and symbols in name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', '') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include('is not a valid name'); + }); + }); + + it('should reject NoSQL regex injection in name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', '{"$regex":".*"}') + .expect(400); + }); + + describe('without manage-sounds permission', () => { + let unauthorizedUser: IUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return forbidden if user does not have the manage-sounds permission', async () => { + await request + .post(api('custom-sounds.create')) + .set(unauthorizedUserCredentials) + .attach('sound', mockWavAudioPath) + .field('name', `forbidden-sound-${randomUUID()}`) + .expect(403); + }); + }); + }); + + describe('[/custom-sounds.update]', () => { + let previousFileName: string; + + before(async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('sound').and.to.be.an('object'); + previousFileName = res.body.sound.name; + }); + }); + + after(async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', fileId) + .field('name', previousFileName) + .attach('sound', mockWavAudioPath) + .expect(200); + }); + + it('should successfully update only the name of the sound without sending the file again', async () => { + const newSoundName = `${fileName}-updated`; + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', fileId) + .field('name', newSoundName) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('name').and.to.be.equal(newSoundName); + }); + }); + + it('should successfully update the sound file and name', async () => { + const newSoundName = `${fileName}-2-updated`; + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockMp3AudioPath) + .field('_id', fileId) + .field('name', newSoundName) + .expect(200); + + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('name').and.to.be.equal(newSoundName); + expect(res.body.sound).to.have.property('extension').and.to.be.equal('mp3'); + }); + }); + + it('should not be able to update sounds name if the name was already taken', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', `${fileName}-2`) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The custom sound name is already in use [Custom_Sound_Error_Name_Already_In_Use]'); + }); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.update')).expect(401); + }); + + it('should reject injection of invalid characters and symbols in name', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', '') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include('is not a valid name'); + }); + }); + + it('should reject NoSQL regex injection in name', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', '{"$regex":".*"}') + .expect(400); + }); + + it('should fail if the file exceeds the 5MB size limit', async () => { + const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'a'); + + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', largeBuffer, { filename: 'large.wav', contentType: 'audio/wav' }) + .field('_id', fileId) + .field('name', 'large-sound') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error').and.to.be.equal('[error-file-too-large]'); + }); + }); + + it('should return an error when trying to update a non-existent sound', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', 'invalid-id-123') + .field('name', 'new-name') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('Custom Sound not found.'); + }); + }); + + it('should fail if attempting to update with an invalid mime type file', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', Buffer.from('fake-audio'), { filename: 'test.mp4', contentType: 'video/mp4' }) + .field('_id', fileId) + .field('name', fileName) + .expect(400) + .expect((res) => { + expect(res.body.error).to.equal('MIME type not allowed'); + }); + }); + + describe('without manage-sounds permission', async () => { + let unauthorizedUser: IUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return forbidden if user does not have the manage-sounds permission', async () => { + await request + .post(api('custom-sounds.update')) + .set(unauthorizedUserCredentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', `forbidden-case`) + .expect(403); + }); + }); + + describe('mutating file on failed validation', () => { + let soundAId: string; + let soundBId: string; + const soundAName = `sound-a-${randomUUID()}`; + const soundBName = `sound-b-${randomUUID()}`; + + before(async () => { + soundAId = await createCustomSound(soundAName, mockWavAudioPath); + soundBId = await createCustomSound(soundBName, mockMp3AudioPath); + }); + + after(async () => { + await deleteCustomSound(soundAId); + await deleteCustomSound(soundBId); + }); + + it('should not mutate the underlying file or metadata if the update fails validation (e.g., name collision)', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockMp3AudioPath) + .field('_id', soundAId) + .field('name', soundBName) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The custom sound name is already in use [Custom_Sound_Error_Name_Already_In_Use]'); + }); + + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: soundAId }) + .expect(200) + .expect((res) => { + expect(res.body.sound).to.have.property('name', soundAName); + expect(res.body.sound).to.have.property('extension', 'wav'); + }); + + const originalWavBuffer = fs.readFileSync(mockWavAudioPath); + + await request + .get(`/custom-sounds/${soundAId}.wav`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.headers).to.have.property('content-type', 'audio/wav'); + expect(Buffer.isBuffer(res.body)).to.be.true; + expect(originalWavBuffer.equals(res.body)).to.be.true; + }); + + await request.get(`/custom-sounds/${soundAId}.mp3`).set(credentials).expect(404); + }); + }); + }); + + describe('[/custom-sounds.list]', () => { + it('should return custom sounds', async () => { + const res = await request.get(api('custom-sounds.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('sounds').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + it('should return custom sounds even requested with count and offset params', async () => { + const res = await request.get(api('custom-sounds.list')).set(credentials).expect(200).query({ + count: 5, + offset: 0, + }); + + expect(res.body).to.have.property('sounds').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + it('should return custom sounds filtering it using the `name` parameter', async () => { + const res = await request + .get(api('custom-sounds.list')) + .set(credentials) + .expect(200) + .query({ + name: `${fileName}-2`, + count: 5, + offset: 0, + }); + + expect(res.body).to.have.property('sounds').and.to.be.an('array'); + expect(res.body).to.have.property('total').to.equal(1); + expect(res.body).to.have.property('offset').to.equal(0); + expect(res.body).to.have.property('count').to.equal(1); + expect(res.body.sounds[0]._id).to.be.equal(fileId2); + }); + }); + + describe('[/custom-sounds.delete]', () => { + let soundToDeleteId: string; + let soundDeleted: boolean = false; + + before(async () => { + soundToDeleteId = await createCustomSound(`sound-to-delete-${randomUUID()}`, mockWavAudioPath); + }); + + after(async () => { + if (soundToDeleteId && !soundDeleted) { + await deleteCustomSound(soundToDeleteId); + } + }); + + it('should return unauthorized if the user is not authenticated', async () => { + await request.post(api('custom-sounds.delete')).send({ _id: soundToDeleteId }).expect(401); + }); + + it('should return a 400 if attempting to delete a sound that does not exist', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: 'invalid-non-existent-id' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('Custom_Sound_Error_Invalid_Sound'); + }); + }); + + it('should reject requests with invalid parameter types', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: { $ne: null } }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + describe('without manage-sounds permission', async () => { + let unauthorizedUser: IUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return forbidden if user does not have the manage-sounds permission', async () => { + await request.post(api('custom-sounds.delete')).set(unauthorizedUserCredentials).send({ _id: soundToDeleteId }).expect(403); + }); + }); + + it('should successfully delete a custom sound when providing a valid _id', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: soundToDeleteId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + soundDeleted = true; + }); + }); + + describe('Accessing custom sounds', () => { + it('should return forbidden if the there is no fileId on the url', async () => { + const res = await request.get('/custom-sounds/').set(credentials).expect(403); + + expect(res.text).to.be.equal('Forbidden'); + }); + + it('should return not found if the the requested file does not exists', async () => { + const res = await request.get('/custom-sounds/invalid.mp3').set(credentials).expect(404); + + expect(res.text).to.be.equal('Not found'); + }); + + it('should return success if the the requested exists', async () => { + const res = await request.get(`/custom-sounds/${fileId}.wav`).set(credentials).expect(200); + + expect(res.headers).to.have.property('last-modified'); + expect(res.headers).to.have.property('content-type', 'audio/wav'); + expect(res.headers).to.have.property('cache-control', 'public, max-age=0'); + expect(res.headers).to.have.property('expires', '-1'); + uploadDate = res.headers['last-modified']; + }); + + it('should return not modified if the the requested file contains a valid-since equal to the upload date', async () => { + const res = await request + .get(`/custom-sounds/${fileId}.wav`) + .set(credentials) + .set({ + 'if-modified-since': uploadDate, + }) + .expect(304); + + expect(res.headers).to.have.property('last-modified', uploadDate); + expect(res.headers).not.to.have.property('content-type'); + expect(res.headers).not.to.have.property('cache-control'); + expect(res.headers).not.to.have.property('expires'); + }); + }); + + describe('[/custom-sounds.delete]', () => { + let toDeleteFileId: string; + + before(async () => { + toDeleteFileId = await createCustomSound(`${fileName}-delete-${randomUUID()}`, mockWavAudioPath); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.delete')).send({ _id: toDeleteFileId }).expect(401); + }); + + it('should return bad request when _id is missing', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when _id is an empty string', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({ _id: '' }).expect(400); + }); + + it('should fail when the user does not have manage-sounds permission', async () => { + const testUser = await createUser(); + const testUserCredentials = await login(testUser.username, password); + try { + await request.post(api('custom-sounds.delete')).set(testUserCredentials).send({ _id: toDeleteFileId }).expect(403); + } finally { + await deleteUser(testUser); + } + }); + + it('should delete the sound successfully and remove it from the list', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: toDeleteFileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: toDeleteFileId }).expect(404); + }); + + it('should fail when the sound id does not exist', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: 'non-existent-sound-id' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/custom-sounds.getOne]', () => { + it('should return unauthorized if not authenticated', async () => { + await request.get(api('custom-sounds.getOne')).query({ _id: fileId }).expect(401); + }); + + it('should return not found if custom sound does not exist', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: 'invalid-id' }).expect(404); + }); + + it('should return bad request if the _id length is not more than one', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: '' }).expect(400); + }); + + it('should return the custom sound successfully', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('_id', fileId); + expect(res.body.sound).to.have.property('name').and.to.be.a('string'); + expect(res.body.sound).to.have.property('extension').and.to.be.a('string'); + }); + }); + + it('should reject regex injection via query object', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ + _id: { $regex: '.*' }, + }) + .expect(400); + }); + + it('should reject regex injection via bracket syntax', async () => { + await request.get(api('custom-sounds.getOne')).set(credentials).query('_id[$regex]=.*').expect(400); + }); + + it('should reject encoded regex injection attempt', async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ + _id: '{"$regex":".*"}', + }) + .expect(404); // valid string, but it doesn't exist + }); + }); + + describe('Sounds storage settings reactivity', () => { + let fsFileId: string; + let gridFsFileId: string; + + before(async () => { + await updateSetting('CustomSounds_FileSystemPath', '', false); + + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + fsFileId = await createCustomSound(`${fileName}-3`, mockWavAudioPath); + + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + gridFsFileId = await createCustomSound(`${fileName}-4`, mockWavAudioPath); + }); + + after(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem', false); + await updateSetting('CustomSounds_FileSystemPath', ''); + await deleteCustomSound(fsFileId); + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + await deleteCustomSound(gridFsFileId); + }); + + describe('CustomSounds_Storage_Type', () => { + describe('when storage is GridFS', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'GridFS'); + }); + + it('should resolve GridFS files only', async () => { + await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(200); + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + }); + }); + + describe('when storage is FileSystem', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + }); + + it('should resolve FileSystem files only', async () => { + await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(404); + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + }); + }); + }); + + describe('CustomSounds_FileSystemPath', () => { + before(async () => { + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); + }); + + describe('when file system path is the default one', () => { + it('should resolve files', async () => { + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + }); + }); + + describe('when file system path is NOT the default one', () => { + before(async () => { + await updateSetting('CustomSounds_FileSystemPath', '~/sounds'); + }); + + after(async () => { + await updateSetting('CustomSounds_FileSystemPath', ''); + }); + + it('should NOT resolve files', async () => { + await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/custom-user-status.ts b/apps/meteor/tests/end-to-end/api/custom-user-status.ts new file mode 100644 index 0000000..5df7d6b --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/custom-user-status.ts @@ -0,0 +1,382 @@ +import { expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +async function createCustomUserStatus(name: string, statusType?: string): Promise { + const res = await request.post(api('custom-user-status.create')).set(credentials).send({ name, statusType }).expect(200); + return res.body.customUserStatus._id; +} + +async function deleteCustomUserStatus(id: string): Promise { + await request.post(api('custom-user-status.delete')).set(credentials).send({ customUserStatusId: id }).expect(200); +} + +describe('[CustomUserStatus]', () => { + let unauthorizedUser: any; + let unauthorizedUserCredentials: any; + + before((done) => { + getCredentials(done); + }); + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(() => Promise.all([updatePermission('manage-user-status', ['admin']), deleteUser(unauthorizedUser)])); + + describe('[/custom-user-status.list]', () => { + let customUserStatusId: string; + let customUserStatusName: string; + + before(async () => { + customUserStatusName = `test-${Date.now()}`; + customUserStatusId = await createCustomUserStatus(customUserStatusName); + }); + + after(async () => { + await deleteCustomUserStatus(customUserStatusId); + }); + + it('should return custom user status', async () => { + const res = await request.get(api('custom-user-status.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('statuses').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + + it('should return custom user status even requested with count and offset params', async () => { + const res = await request.get(api('custom-user-status.list')).set(credentials).expect(200).query({ + count: 5, + offset: 0, + }); + + expect(res.body).to.have.property('statuses').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + + it('should return one custom user status when requested with id param', async () => { + const res = await request.get(api('custom-user-status.list')).set(credentials).expect(200).query({ + _id: customUserStatusId, + }); + + expect(res.body).to.have.property('statuses').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('total').and.to.equal(1); + expect(res.body).to.have.property('offset').and.to.equal(0); + expect(res.body).to.have.property('count').and.to.equal(1); + }); + + it('should return empty array when requested with an existing name param', async () => { + const res = await request.get(api('custom-user-status.list')).set(credentials).expect(200).query({ + name: customUserStatusName, + }); + + expect(res.body).to.have.property('statuses').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('total').and.to.equal(1); + expect(res.body).to.have.property('offset').and.to.equal(0); + expect(res.body).to.have.property('count').and.to.equal(1); + }); + + it('should return empty array when requested with unknown name param', async () => { + const res = await request.get(api('custom-user-status.list')).set(credentials).expect(200).query({ + name: 'testxxx', + }); + + expect(res.body).to.have.property('statuses').and.to.be.an('array').and.to.have.lengthOf(0); + expect(res.body).to.have.property('total').and.to.equal(0); + expect(res.body).to.have.property('offset').and.to.equal(0); + expect(res.body).to.have.property('count').and.to.equal(0); + }); + }); + + describe('[/custom-user-status.create]', () => { + let customUserStatusId: string; + + afterEach(async () => { + await updatePermission('manage-user-status', ['admin']); + + if (customUserStatusId) { + await deleteCustomUserStatus(customUserStatusId); + customUserStatusId = ''; + } + }); + + it('should throw an error if not authenticated', async () => { + await request + .post(api('custom-user-status.create')) + .send({ name: 'test-status' }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should throw an error if user does not have manage-user-status permission', async () => { + await updatePermission('manage-user-status', []); + + await request + .post(api('custom-user-status.create')) + .set(unauthorizedUserCredentials) + .send({ name: 'test-status' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + }); + + it('should create a custom user status successfully', async () => { + const statusName = `test-create-${Date.now()}`; + + await request + .post(api('custom-user-status.create')) + .set(credentials) + .send({ name: statusName, statusType: 'busy' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customUserStatus'); + expect(res.body.customUserStatus).to.have.property('_id'); + expect(res.body.customUserStatus).to.have.property('name', statusName); + expect(res.body.customUserStatus).to.have.property('statusType', 'busy'); + customUserStatusId = res.body.customUserStatus._id; + }); + }); + + it('should throw an error if name already exists', async () => { + const statusName = `test-duplicate-${Date.now()}`; + customUserStatusId = await createCustomUserStatus(statusName); + + await request + .post(api('custom-user-status.create')) + .set(credentials) + .send({ name: statusName }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'Custom_User_Status_Error_Name_Already_In_Use'); + }); + }); + + it('should throw an error if statusType is invalid', async () => { + await request + .post(api('custom-user-status.create')) + .set(credentials) + .send({ name: `test-invalid-status-type-${Date.now()}`, statusType: 'invalid' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-input-is-not-a-valid-field'); + }); + }); + }); + + describe('[/custom-user-status.update]', () => { + let customUserStatusId: string; + let customUserStatusName: string; + + before(async () => { + customUserStatusName = `test-update-${Date.now()}`; + customUserStatusId = await createCustomUserStatus(customUserStatusName); + }); + + afterEach(async () => { + await updatePermission('manage-user-status', ['admin']); + }); + + after(async () => { + if (customUserStatusId) { + await deleteCustomUserStatus(customUserStatusId); + } + await updatePermission('manage-user-status', ['admin']); + }); + + it('should throw an error if not authenticated', async () => { + await request + .post(api('custom-user-status.update')) + .send({ _id: customUserStatusId, name: 'updated-name' }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should throw an error if user does not have manage-user-status permission', async () => { + await updatePermission('manage-user-status', []); + + await request + .post(api('custom-user-status.update')) + .set(unauthorizedUserCredentials) + .send({ _id: customUserStatusId, name: 'updated-name' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + }); + + it('should throw an error if custom user status does not exist', async () => { + await request + .post(api('custom-user-status.update')) + .set(credentials) + .send({ _id: 'invalid-id', name: 'updated-name' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should update custom user status successfully', async () => { + const newName = `test-updated-${Date.now()}`; + + await request + .post(api('custom-user-status.update')) + .set(credentials) + .send({ _id: customUserStatusId, name: newName, statusType: 'away' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customUserStatus'); + expect(res.body.customUserStatus).to.have.property('_id', customUserStatusId); + expect(res.body.customUserStatus).to.have.property('name', newName); + expect(res.body.customUserStatus).to.have.property('statusType', 'away'); + }); + + customUserStatusName = newName; + }); + + it('should throw an error if status name already exists', async () => { + const existingStatusName = `test-update-duplicate-${Date.now()}`; + const existingStatusId = await createCustomUserStatus(existingStatusName); + + await request + .post(api('custom-user-status.update')) + .set(credentials) + .send({ _id: customUserStatusId, name: existingStatusName }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'Custom_User_Status_Error_Name_Already_In_Use'); + }); + + await deleteCustomUserStatus(existingStatusId); + }); + + it('should throw an error if statusType is invalid', async () => { + await request + .post(api('custom-user-status.update')) + .set(credentials) + .send({ _id: customUserStatusId, name: customUserStatusName, statusType: 'invalid' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-input-is-not-a-valid-field'); + }); + }); + }); + + describe('[/custom-user-status.delete]', () => { + let customUserStatusId: string; + + beforeEach(async () => { + const statusName = `test-delete-${Date.now()}`; + customUserStatusId = await createCustomUserStatus(statusName); + }); + + afterEach(async () => { + await updatePermission('manage-user-status', ['admin']); + + if (customUserStatusId) { + await deleteCustomUserStatus(customUserStatusId); + customUserStatusId = ''; + } + }); + + it('should throw an error if not authenticated', async () => { + await request + .post(api('custom-user-status.delete')) + .send({ customUserStatusId }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should throw an error if user does not have manage-user-status permission', async () => { + await updatePermission('manage-user-status', []); + + await request + .post(api('custom-user-status.delete')) + .set(unauthorizedUserCredentials) + .send({ customUserStatusId }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + }); + + it('should throw an error if customUserStatusId is not provided', async () => { + await request + .post(api('custom-user-status.delete')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "must have required property 'customUserStatusId'"); + }); + }); + + it('should throw an error if custom user status does not exist', async () => { + await request + .post(api('custom-user-status.delete')) + .set(credentials) + .send({ customUserStatusId: 'invalid-id' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'Custom_User_Status_Error_Invalid_User_Status'); + }); + }); + + it('should delete custom user status successfully', async () => { + await request + .post(api('custom-user-status.delete')) + .set(credentials) + .send({ customUserStatusId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + customUserStatusId = ''; + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/direct-message.ts b/apps/meteor/tests/end-to-end/api/direct-message.ts new file mode 100644 index 0000000..4e48d7a --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/direct-message.ts @@ -0,0 +1,1399 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials, apiUsername, apiEmail, methodCall } from '../../data/api-data'; +import { pinMessage, sendMessage, starMessage, updateMessage } from '../../data/chat.helper'; +import { updateSetting, updatePermission } from '../../data/permissions.helper'; +import { deleteRoom } from '../../data/rooms.helper'; +import { testFileUploads } from '../../data/uploads.helper'; +import { password, adminUsername } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login, setUserStatus } from '../../data/users.helper'; + +describe('[Direct Messages]', () => { + let testDM: IRoom & { rid: IRoom['_id'] }; + let user: TestUser; + let directMessage: { _id: IRoom['_id'] }; + + before((done) => getCredentials(done)); + + before(async () => { + user = await createUser(); + const cred = await login(user.username, password); + await setUserStatus(cred); + await request + .post(api('im.create')) + .set(credentials) + .send({ + username: user.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testDM = res.body.room; + }); + }); + + before('/chat.postMessage', (done) => { + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: testDM.rid, + text: 'This message was sent using the API', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was sent using the API'); + expect(res.body).to.have.nested.property('message.rid'); + directMessage = { _id: res.body.message.rid }; + }) + .end(done); + }); + + after(() => deleteUser(user)); + + describe('/im.blockUser', () => { + const fetchOwnSubscription = async (roomId: IRoom['_id']) => { + const res = await request.get(api('subscriptions.getOne')).set(credentials).query({ roomId }).expect(200); + return res.body.subscription; + }; + + it('should return unauthorized when not authenticated', async () => { + await request.post(api('im.blockUser')).send({ roomId: directMessage._id, block: true }).expect(401); + }); + + it('should return bad request when roomId is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ block: true }).expect(400); + }); + + it('should return bad request when block flag is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ roomId: directMessage._id }).expect(400); + }); + + it('should block the other DM participant and flip the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: true }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(subscription).to.have.property('blocker', true); + }); + + it('should unblock the other DM participant and clear the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: false }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(Boolean(subscription.blocker)).to.equal(false); + }); + + it('should fail when called on a non-DM room', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: 'GENERAL', block: true }) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('/im.setTopic', () => { + it('should set the topic of the DM with a string', async () => { + const res = await request + .post(api('im.setTopic')) + .set(credentials) + .send({ + roomId: directMessage._id, + topic: `a direct message with ${user.username}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', `a direct message with ${user.username}`); + }); + it('should set the topic of DM with an empty string(remove the topic)', async () => { + const res = await request + .post(api('im.setTopic')) + .set(credentials) + .send({ + roomId: directMessage._id, + topic: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', ''); + }); + }); + + describe('Testing DM info', () => { + let dmMessage: IMessage; + + it('sending a message...', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testDM._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + dmMessage = res.body.message; + }); + it('REACTing with last message', async () => { + const res = await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: dmMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('STARring last message', async () => { + const res = await request + .post(api('chat.starMessage')) + .set(credentials) + .send({ + messageId: dmMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('PINning last message', async () => { + const res = await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: dmMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return all DM messages where the last message of array should have the "star" array with USERS star ONLY', async () => { + const res = await request + .get(api('im.messages')) + .set(credentials) + .query({ + roomId: testDM._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const messages = res.body.messages as IMessage[]; + const lastMessage = messages.filter((message) => message._id === dmMessage._id)[0]; + expect(lastMessage).to.have.property('starred').and.to.be.an('array'); + expect(lastMessage.starred?.[0]._id).to.be.equal(adminUsername); + }); + }); + + it('/im.history', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + + describe('/im.history inclusive parameter', () => { + let testDMRoom: IRoom; + let testUser2: TestUser; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + testUser2 = await createUser(); + const dmRes = await request.post(api('im.create')).set(credentials).send({ username: testUser2.username }); + testDMRoom = dmRes.body.room; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testDMRoom._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testDMRoom?._id) { + await deleteRoom({ type: 'd', roomId: testDMRoom._id }); + } + if (testUser2) { + await deleteUser(testUser2); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('im.history')) + .set(credentials) + .query({ + roomId: testDMRoom._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); + + it('/im.list', async () => { + const res = await request.get(api('im.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('ims').and.to.be.an('array'); + const im = (res.body.ims as IRoom[]).find((dm) => dm._id === testDM._id); + expect(im).to.have.property('_id'); + expect(im).to.have.property('t').and.to.be.eq('d'); + expect(im).to.have.property('msgs').and.to.be.a('number'); + expect(im).to.have.property('usernames').and.to.be.an('array'); + expect(im).to.have.property('lm'); + expect(im).to.have.property('_updatedAt'); + expect(im).to.have.property('ts'); + expect(im).to.have.property('lastMessage'); + }); + + describe('/im.list.everyone', () => { + before(async () => { + return updatePermission('view-room-administration', ['admin']); + }); + + after(async () => { + return updatePermission('view-room-administration', ['admin']); + }); + + it('should succesfully return a list of direct messages', async () => { + await request + .get(api('im.list.everyone')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('ims').and.to.be.an('array'); + const im = res.body.ims[0]; + expect(im).to.have.property('_id'); + expect(im).to.have.property('t').and.to.be.eq('d'); + expect(im).to.have.property('msgs').and.to.be.a('number'); + expect(im).to.have.property('usernames').and.to.be.an('array'); + expect(im).to.have.property('ro'); + expect(im).to.have.property('sysMes'); + expect(im).to.have.property('_updatedAt'); + expect(im).to.have.property('ts'); + expect(im).to.have.property('lastMessage'); + }); + }); + + it('should fail if user does NOT have the view-room-administration permission', async () => { + await updatePermission('view-room-administration', []); + await request + .get(api('im.list.everyone')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe("Setting: 'Use Real Name': true", () => { + before(async () => updateSetting('UI_Use_Real_Name', true)); + after(async () => updateSetting('UI_Use_Real_Name', false)); + + it('/im.list', async () => { + const res = await request.get(api('im.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('ims').and.to.be.an('array'); + + const im = (res.body.ims as IRoom[]).find((dm) => dm._id === testDM._id) as IRoom; + + expect(im).to.have.property('_id'); + expect(im).to.have.property('t').and.to.be.eq('d'); + expect(im).to.have.property('msgs').and.to.be.a('number'); + expect(im).to.have.property('usernames').and.to.be.an('array'); + expect(im).to.have.property('lm'); + expect(im).to.have.property('_updatedAt'); + expect(im).to.have.property('ts'); + expect(im).to.have.property('lastMessage'); + + const { lastMessage } = im; + + expect(lastMessage).to.have.nested.property('u.name', 'RocketChat Internal Admin Test'); + }); + + it('/im.list.everyone', async () => { + const res = await request.get(api('im.list.everyone')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('ims').and.to.be.an('array'); + const im = (res.body.ims as IRoom[]).find((dm) => dm._id === testDM._id) as IRoom; + expect(im).to.have.property('_id'); + expect(im).to.have.property('t').and.to.be.eq('d'); + expect(im).to.have.property('msgs').and.to.be.a('number'); + expect(im).to.have.property('usernames').and.to.be.an('array'); + expect(im).to.have.property('ro'); + expect(im).to.have.property('sysMes'); + expect(im).to.have.property('_updatedAt'); + expect(im).to.have.property('ts'); + expect(im).to.have.property('lastMessage'); + + const { lastMessage } = im; + + expect(lastMessage).to.have.nested.property('u.name', 'RocketChat Internal Admin Test'); + }); + }); + + it('/im.open', async () => { + const res = await request + .post(api('im.open')) + .set(credentials) + .send({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + describe('/im.counters', () => { + it('should require auth', async () => { + await request + .get(api('im.counters')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + it('should require a roomId', async () => { + await request + .get(api('im.counters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should work with all params right', async () => { + const res = await request + .get(api('im.counters')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('joined', true); + expect(res.body).to.have.property('members'); + expect(res.body).to.have.property('unreads'); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs'); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('userMentions'); + }); + + describe('with valid room id', () => { + let testDM: IRoom & { rid: IRoom['_id'] }; + let user2: TestUser; + let userCreds: Credentials; + + before(async () => { + user2 = await createUser(); + userCreds = await login(user2.username, password); + await request + .post(api('im.create')) + .set(credentials) + .send({ + username: user2.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testDM = res.body.room; + }); + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testDM._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await request + .post(api('im.delete')) + .set(credentials) + .send({ + roomId: testDM._id, + }) + .expect(200); + + await deleteUser(user2); + }); + + it('should properly return counters before opening the dm', async () => { + await request + .get(api('im.counters')) + .set(userCreds) + .query({ + roomId: testDM._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('joined', true); + expect(res.body).to.have.property('members').and.to.be.a('number').and.to.be.eq(2); + expect(res.body).to.have.property('unreads').and.to.be.a('number').and.to.be.eq(1); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs').and.to.be.a('number').and.to.be.eq(1); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('userMentions').and.to.be.a('number').and.to.be.eq(0); + }); + }); + }); + + describe('with deactived users', async () => { + before(() => request.post(api('users.setActiveStatus')).set(credentials).send({ userId: user._id, activeStatus: false })); + after(() => request.post(api('users.setActiveStatus')).set(credentials).send({ userId: user._id, activeStatus: true })); + it('should not include deactivated users in members count', async () => { + // Deactivate the second user + + const res = await request.get(api('im.counters')).set(credentials).query({ roomId: directMessage._id }); + + expect(res.status).to.equal(200); + expect(res.body.success).to.be.true; + // Only admin remain active + expect(res.body.members).to.equal(1); + }); + }); + }); + + describe('[/im.files]', async () => { + await testFileUploads('im.files', 'd', 'invalid-channel'); + }); + + describe('/im.messages', () => { + let testUser: IUser; + let testUser2: IUser; + let testUserDMRoom: IRoom; + let testUserCredentials: Credentials; + let testUser2Credentials: Credentials; + let pinnedMessageId: IMessage['_id']; + + let messages: Pick[] = []; + + before(async () => { + [testUser, testUser2] = await Promise.all([ + createUser({ joinDefaultChannels: false, roles: ['admin'], username: `a_${Random.id()}` }), + createUser({ joinDefaultChannels: false, username: `b_${Random.id()}` }), + ]); + + [testUserCredentials, testUser2Credentials] = await Promise.all([ + login(testUser.username, password), + login(testUser2.username, password), + ]); + await setUserStatus(testUserCredentials); + + testUserDMRoom = ( + await request + .post(api('im.create')) + .set(testUserCredentials) + .send({ username: `${testUser2.username}` }) + ).body.room; + + messages = [ + { + rid: testUserDMRoom._id, + msg: `@${adminUsername} youre being mentioned`, + mentions: [{ username: adminUsername, _id: adminUsername, name: adminUsername }], + }, + { + rid: testUserDMRoom._id, + msg: `@${testUser.username} youre being mentioned`, + mentions: [{ username: testUser.username, _id: testUser._id, name: testUser.name }], + }, + { + rid: testUserDMRoom._id, + msg: `A simple message`, + }, + { + rid: testUserDMRoom._id, + msg: `A pinned simple message`, + }, + ]; + + /** + * We are not using `Promise.all` here because we want to ensure that each message is sent sequentially. + * This approach helps in maintaining the order of messages by ts. + */ + const starredMessage = await sendMessage({ message: messages[0], requestCredentials: testUserCredentials }); + const pinnedMessage = await sendMessage({ message: messages[1], requestCredentials: testUser2Credentials }); + await sendMessage({ message: messages[2], requestCredentials: testUserCredentials }); + await sendMessage({ message: messages[3], requestCredentials: testUser2Credentials }); + + await Promise.all([ + starMessage({ messageId: starredMessage.body.message._id, requestCredentials: testUserCredentials }), + pinMessage({ messageId: pinnedMessage.body.message._id, requestCredentials: testUserCredentials }), + ]); + pinnedMessageId = pinnedMessage.body.message._id; + }); + + after(async () => Promise.all([deleteUser(testUser), deleteUser(testUser2)])); + + it('should return all DM messages that were sent to yourself using your username', async () => { + const res = await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + }); + + it('should sort by ts by default', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages.map((m: IMessage) => m.u.username)).to.deep.equal( + res.body.messages + .sort((a: IMessage, b: IMessage) => new Date(b.ts).getTime() - new Date(a.ts).getTime()) + .map((m: IMessage) => m.u.username), + ); + }); + }); + + it('should allow custom sorting', async () => { + const { messages } = ( + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + ).body; + + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + sort: '{"u.username":-1}', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages.map((m: IMessage) => m.u.username)).to.deep.equal( + messages.map((m: IMessage) => m.u.username).sort((a: string, b: string) => b.localeCompare(a)), + ); + }); + }); + + it('should return an error when trying to access a DM that does not belong to the current user', async () => { + await request + .get(api('im.messages')) + .set(credentials) + .query({ roomId: testUserDMRoom._id }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + it('should return messages that mention a single user', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + mentionIds: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('mentions').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body.messages[0].mentions[0]).to.have.property('_id', adminUsername); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return messages that mention multiple users', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + mentionIds: `${adminUsername},${testUser._id}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(2); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('total', 2); + + const mentionIds = res.body.messages.map((message: any) => message.mentions[0]._id); + expect(mentionIds).to.include.members([adminUsername, testUser._id]); + }); + }); + + it('should return messages that are starred by a specific user', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + starredIds: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('starred').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return messages that are pinned', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('pinned').that.is.an('boolean').and.to.be.true; + expect(res.body.messages[0]).to.have.nested.property('pinnedBy').that.is.an('object'); + expect(res.body.messages[0].pinnedBy).to.have.property('_id', testUser._id); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + describe('_hidden messages behavior when Message_KeepHistory is enabled', async () => { + before(async () => { + await updateSetting('Message_KeepHistory', true); + await pinMessage({ messageId: pinnedMessageId, unpin: true, requestCredentials: testUserCredentials }); + }); + + after(async () => { + await updateSetting('Message_KeepHistory', false); + }); + + it('should return all messages, without any pinned messages', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ roomId: testUserDMRoom._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('pinned', true); + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + + it('should return no pinned messages', async () => { + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ + roomId: testUserDMRoom._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(0); + expect(res.body).to.have.property('count', 0); + expect(res.body).to.have.property('total', 0); + }); + }); + + it('should not return old message when updating a message', async () => { + await updateMessage({ + msgId: pinnedMessageId, + updatedMessage: 'message was unpinned', + roomId: testUserDMRoom._id, + requestCredentials: testUser2Credentials, + }); + + await request + .get(api('im.messages')) + .set(testUserCredentials) + .query({ roomId: testUserDMRoom._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + const updatedMessage = res.body.messages.find((msg: IMessage) => msg._id === pinnedMessageId); + expect(updatedMessage).to.have.property('msg', 'message was unpinned'); + expect(updatedMessage).to.have.property('editedAt'); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + }); + }); + + describe('/im.messages.others', () => { + it('should fail when the endpoint is disabled and the user has permissions', async () => { + await updateSetting('API_Enable_Direct_Message_History_EndPoint', false); + await request + .get(api('im.messages.others')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-endpoint-disabled'); + }); + }); + it('should fail when the endpoint is disabled and the user doesnt have permission', async () => { + await updateSetting('API_Enable_Direct_Message_History_EndPoint', false); + await updatePermission('view-room-administration', []); + await request + .get(api('im.messages.others')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + it('should fail when the endpoint is enabled but the user doesnt have permission', async () => { + await updateSetting('API_Enable_Direct_Message_History_EndPoint', true); + await updatePermission('view-room-administration', []); + await request + .get(api('im.messages.others')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + it('should succeed when the endpoint is enabled and user has permission', async () => { + await updateSetting('API_Enable_Direct_Message_History_EndPoint', true); + await updatePermission('view-room-administration', ['admin']); + await request + .get(api('im.messages.others')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + }); + + it('/im.close', async () => { + const res = await request + .post(api('im.close')) + .set(credentials) + .send({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + describe('fname property', () => { + const username = `fname_${apiUsername}`; + const name = `Name fname_${apiUsername}`; + const updatedName = `Updated Name fname_${apiUsername}`; + const email = `fname_${apiEmail}`; + let userId: IUser['_id']; + let directMessageId: IMessage['_id']; + let user: TestUser; + + before((done) => { + void request + .post(api('users.create')) + .set(credentials) + .send({ + email, + name, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }) + .expect((res) => { + user = res.body.user; + userId = res.body.user._id; + }) + .end(done); + }); + + before((done) => { + void request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: `@${username}`, + text: 'This message was sent using the API', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message.msg', 'This message was sent using the API'); + expect(res.body).to.have.nested.property('message.rid'); + directMessageId = res.body.message.rid; + }) + .end(done); + }); + + after(async () => deleteUser(user)); + + it('should have fname property', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: directMessageId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('name', username); + expect(res.body.subscription).to.have.property('fname', name); + }); + + it("should update user's name", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId, + data: { + name: updatedName, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.user).to.have.property('name', updatedName); + }); + + it('should have fname property updated', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: directMessageId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('name', username); + expect(res.body.subscription).to.have.property('fname', updatedName); + }); + }); + + describe('/im.members', () => { + it('should return and array with two members', async () => { + const res = await request + .get(api('im.members')) + .set(credentials) + .query({ + roomId: directMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').and.to.be.equal(2); + expect(res.body).to.have.property('offset').and.to.be.equal(0); + expect(res.body).to.have.property('total').and.to.be.equal(2); + expect(res.body).to.have.property('members').and.to.have.lengthOf(2); + }); + it('should return and array with one member', async () => { + const res = await request + .get(api('im.members')) + .set(credentials) + .query({ + username: user.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').and.to.be.equal(2); + expect(res.body).to.have.property('offset').and.to.be.equal(0); + expect(res.body).to.have.property('total').and.to.be.equal(2); + expect(res.body).to.have.property('members').and.to.have.lengthOf(2); + }); + it('should return and array with one member queried by status', async () => { + const res = await request + .get(api('im.members')) + .set(credentials) + .query({ + 'roomId': directMessage._id, + 'status[]': ['online'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').and.to.be.equal(1); + expect(res.body).to.have.property('offset').and.to.be.equal(0); + expect(res.body).to.have.property('total').and.to.be.equal(1); + expect(res.body).to.have.property('members').and.to.have.lengthOf(1); + }); + }); + + describe('/im.create', () => { + let user: TestUser; + let userCredentials: Credentials; + + let otherUser: TestUser; + let otherUserCredentials: Credentials; + + let thirdUser: TestUser; + let thirdUserCredentials: Credentials; + + let roomIds: Record = {}; + + // Names have to be in alfabetical order so we can test the room's fullname + const userFullName = 'User A'; + const otherUserFullName = 'User B'; + const thirdUserFullName = 'User C'; + + before(async () => { + user = await createUser({ name: userFullName }); + otherUser = await createUser({ name: otherUserFullName }); + thirdUser = await createUser({ name: thirdUserFullName }); + + userCredentials = await login(user.username, password); + otherUserCredentials = await login(otherUser.username, password); + thirdUserCredentials = await login(thirdUser.username, password); + }); + + after(async () => { + await Promise.all(Object.values(roomIds).map((roomId) => deleteRoom({ type: 'd', roomId }))); + await deleteUser(user); + await deleteUser(otherUser); + await deleteUser(thirdUser); + }); + + it('creates a DM between two other parties (including self)', async () => { + const res = await request + .post(api('im.create')) + .set(userCredentials) + .send({ + usernames: [otherUser.username, thirdUser.username].join(','), + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.property('usernames').and.to.have.members([thirdUser.username, user.username, otherUser.username]); + roomIds = { ...roomIds, multipleDm: res.body.room._id }; + }); + + it('creates a DM between two other parties (excluding self)', async () => { + const res = await request + .post(api('im.create')) + .set(credentials) + .send({ + usernames: [user.username, otherUser.username].join(','), + excludeSelf: true, + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.property('usernames').and.to.have.members([user.username, otherUser.username]); + roomIds = { ...roomIds, dm: res.body.room._id }; + }); + + it('should create a self-DM', async () => { + const res = await request + .post(api('im.create')) + .set(userCredentials) + .send({ + username: user.username, + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.property('usernames').and.to.have.members([user.username]); + roomIds = { ...roomIds, self: res.body.room._id }; + }); + + describe('should create dm with correct notification preferences', () => { + let user: TestUser; + let userCredentials: Credentials; + let userPrefRoomId: IRoom['_id']; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(async () => { + if (userPrefRoomId) { + await deleteRoom({ type: 'd', roomId: userPrefRoomId }); + } + await deleteUser(user); + }); + + it('should save user preferences', async () => { + await request + .post(methodCall('saveUserPreferences')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'saveUserPreferences', + params: [{ emailNotificationMode: 'nothing' }], + }), + }) + .expect(200); + }); + + it('should create a DM', async () => { + const res = await request + .post(api('im.create')) + .set(userCredentials) + .send({ + usernames: [user.username, otherUser.username].join(','), + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.property('usernames').and.to.have.members([user.username, otherUser.username]); + userPrefRoomId = res.body.room._id; + }); + + it('should return the right user notification preferences in the dm', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(userCredentials) + .query({ + roomId: userPrefRoomId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body).to.have.nested.property('subscription.emailNotifications').and.to.be.equal('nothing'); + }); + }); + + async function testRoomFNameForUser(testCredentials: Credentials, roomId: IRoom['_id'], fullName: string) { + return request + .get(api('subscriptions.getOne')) + .set(testCredentials) + .query({ roomId }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body.subscription).to.have.property('fname', fullName); + }); + } + + describe('Rooms fullName', () => { + it("should be own user's name for self DM", async () => { + await testRoomFNameForUser(userCredentials, roomIds.self, userFullName); + }); + + it("should be other user's name concatenated for multiple users's DM for every user", async () => { + await testRoomFNameForUser(userCredentials, roomIds.multipleDm, [otherUserFullName, thirdUserFullName].join(', ')); + await testRoomFNameForUser(otherUserCredentials, roomIds.multipleDm, [userFullName, thirdUserFullName].join(', ')); + await testRoomFNameForUser(thirdUserCredentials, roomIds.multipleDm, [userFullName, otherUserFullName].join(', ')); + }); + + it("should be other user's name for DM for both users", async () => { + await testRoomFNameForUser(userCredentials, roomIds.dm, otherUserFullName); + await testRoomFNameForUser(otherUserCredentials, roomIds.dm, userFullName); + }); + }); + }); + + describe('/im.delete', () => { + let testDM: IRoom; + + it('/im.create', async () => { + const res = await request + .post(api('im.create')) + .set(credentials) + .send({ + username: user.username, + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + testDM = res.body.room; + }); + + it('/im.delete', async () => { + const res = await request + .post(api('im.delete')) + .set(credentials) + .send({ + username: user.username, + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', true); + }); + + it('/im.open', async () => { + const res = await request + .post(api('im.open')) + .set(credentials) + .send({ + roomId: testDM._id, + }) + .expect(403) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + + describe('when authenticated as a non-admin user', () => { + let otherUser: TestUser; + let otherCredentials: Credentials; + + before(async () => { + otherUser = await createUser(); + otherCredentials = await login(otherUser.username, password); + }); + + after(async () => { + await deleteUser(otherUser); + }); + + it('/im.create', async () => { + const res = await request + .post(api('im.create')) + .set(credentials) + .send({ + username: otherUser.username, + }) + .expect(200) + .expect('Content-Type', 'application/json'); + + testDM = res.body.room; + }); + + it('/im.delete', async () => { + const res = await request + .post(api('im.delete')) + .set(otherCredentials) + .send({ + roomId: testDM._id, + }) + .expect(400) + .expect('Content-Type', 'application/json'); + + expect(res.body).to.have.property('success', false); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/emoji-custom.ts b/apps/meteor/tests/end-to-end/api/emoji-custom.ts new file mode 100644 index 0000000..31127ba --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/emoji-custom.ts @@ -0,0 +1,539 @@ +import type { IEmojiCustom } from '@rocket.chat/core-typings'; +import { assert, expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { imgURL } from '../../data/interactions'; +import { updateSetting } from '../../data/permissions.helper'; + +describe('[EmojiCustom]', () => { + const customEmojiName = `my-custom-emoji-${Date.now()}`; + + let withoutAliases: IEmojiCustom; + + before((done) => getCredentials(done)); + + after(() => + request.post(api('emoji-custom.delete')).set(credentials).send({ + emojiId: withoutAliases._id, + }), + ); + + describe('[/emoji-custom.create]', () => { + it('should create new custom emoji', async () => { + const res = await request + .post(api('emoji-custom.create')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + name: customEmojiName, + aliases: `${customEmojiName}-alias`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should create new custom emoji without optional parameter "aliases"', async () => { + const res = await request + .post(api('emoji-custom.create')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + name: `${customEmojiName}-without-aliases`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should throw an error when the filename is wrong', async () => { + const res = await request + .post(api('emoji-custom.create')) + .set(credentials) + .attach('emojiwrong', imgURL) + .field({ + _id: 'invalid-id', + name: 'my-custom-emoji-without-aliases', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-field'); + }); + }); + + let createdCustomEmoji: IEmojiCustom; + + describe('[/emoji-custom.update]', () => { + before((done) => { + void request + .get(api('emoji-custom.list')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('emojis').and.to.be.a('object'); + expect(res.body.emojis).to.have.property('update').and.to.be.a('array').and.to.not.have.lengthOf(0); + expect(res.body.emojis).to.have.property('remove').and.to.be.a('array').and.to.have.lengthOf(0); + + const _createdCustomEmoji = (res.body.emojis.update as IEmojiCustom[]).find((emoji) => emoji.name === customEmojiName); + const _withoutAliases = (res.body.emojis.update as IEmojiCustom[]).find( + (emoji) => emoji.name === `${customEmojiName}-without-aliases`, + ); + + assert.isDefined(_createdCustomEmoji); + assert.isDefined(_withoutAliases); + + createdCustomEmoji = _createdCustomEmoji; + withoutAliases = _withoutAliases; + }) + .end(done); + }); + describe('successfully:', () => { + it('should update the custom emoji without a file', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .field({ + _id: createdCustomEmoji._id, + name: customEmojiName, + aliases: 'alias-my-custom-emoji', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should update the custom emoji without optional parameter "aliases"', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .field({ + _id: createdCustomEmoji._id, + name: customEmojiName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should update the custom emoji with all parameters and with a file', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + _id: createdCustomEmoji._id, + name: customEmojiName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should change the etag when the custom emoji image is updated', async () => { + const prevEtag = createdCustomEmoji.etag; + + await request + .post(api('emoji-custom.update')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + _id: createdCustomEmoji._id, + name: customEmojiName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const emojis = await request.get(api(`emoji-custom.all`)).set(credentials).expect(200); + const updatedCustomEmoji = emojis.body.emojis.find((emoji: IEmojiCustom) => emoji._id === createdCustomEmoji._id); + expect(updatedCustomEmoji.etag).not.to.be.equal(prevEtag); + }); + }); + + describe('should throw error when:', () => { + it('the fields does not include "_id"', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + name: 'my-custom-emoji-without-aliases', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('The required "_id" query param is missing.'); + }); + it('the custom emoji does not exists', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .attach('emoji', imgURL) + .field({ + _id: 'invalid-id', + name: 'my-custom-emoji-without-aliases', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('Emoji not found.'); + }); + it('the emoji file field is wrong', async () => { + const res = await request + .post(api('emoji-custom.update')) + .set(credentials) + .attach('emojiwrong', imgURL) + .field({ + _id: createdCustomEmoji._id, + name: 'my-custom-emoji-without-aliases', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-field'); + }); + }); + }); + + describe('[/emoji-custom.list]', () => { + it('should return emojis', async () => { + const res = await request.get(api('emoji-custom.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('emojis').and.to.be.a('object'); + expect(res.body.emojis).to.have.property('update').and.to.be.a('array').and.to.not.have.lengthOf(0); + expect(res.body.emojis).to.have.property('remove').and.to.be.a('array').and.to.have.lengthOf(0); + }); + it('should return emojis when use "query" query parameter', async () => { + const res = await request.get(api('emoji-custom.list')).query({ _updatedAt: new Date().toISOString() }).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('emojis').and.to.be.a('object'); + expect(res.body.emojis).to.have.property('update').and.to.be.a('array').and.to.have.lengthOf(0); + expect(res.body.emojis).to.have.property('remove').and.to.be.a('array').and.to.have.lengthOf(0); + }); + it('should return emojis when use "updateSince" query parameter', async () => { + const res = await request + .get(api('emoji-custom.list')) + .query({ updatedSince: new Date().toISOString() }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('emojis').and.to.be.a('object'); + expect(res.body.emojis).to.have.property('update').and.to.be.a('array').and.to.have.lengthOf(0); + expect(res.body.emojis).to.have.property('remove').and.to.be.a('array').and.to.have.lengthOf(0); + }); + it('should return emojis when use both, "updateSince" and "query" query parameter', async () => { + const res = await request + .get(api('emoji-custom.list')) + .query({ _updatedAt: new Date().toISOString(), updatedSince: new Date().toISOString() }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('emojis').and.to.be.a('object'); + expect(res.body.emojis).to.have.property('update').and.to.be.a('array').and.to.have.lengthOf(0); + expect(res.body.emojis).to.have.property('remove').and.to.be.a('array').and.to.have.lengthOf(0); + }); + it('should return an error when the "updateSince" query parameter is a invalid date', async () => { + const res = await request.get(api('emoji-custom.list')).query({ updatedSince: 'invalid-date' }).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-roomId-param-invalid'); + }); + }); + + describe('[/emoji-custom.all]', () => { + it('should return emojis', async () => { + const res = await request.get(api('emoji-custom.all')).set(credentials).expect(200); + + expect(res.body).to.have.property('emojis').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + it('should return emojis even requested with count and offset params', async () => { + const res = await request + .get(api('emoji-custom.all')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect(200); + + expect(res.body).to.have.property('emojis').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + it('should return only filtered by name emojis', async () => { + const res = await request + .get(api('emoji-custom.all')) + .set(credentials) + .query({ + name: `${customEmojiName}-without-aliases`, + }) + .expect(200); + + expect(res.body).to.have.property('emojis').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }); + }); + + describe('Accessing custom emojis', () => { + let uploadDate: string | undefined; + + it('should return forbidden if the there is no fileId on the url', async () => { + const res = await request.get('/emoji-custom/').set(credentials).expect(403); + + expect(res.text).to.be.equal('Forbidden'); + }); + + it('should return success if the file does not exists with some specific headers', async () => { + const res = await request.get('/emoji-custom/invalid').set(credentials).expect(200); + + expect(res.headers).to.have.property('last-modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); + expect(res.headers).to.have.property('content-type', 'image/svg+xml'); + expect(res.headers).to.have.property('cache-control', 'public, max-age=0'); + expect(res.headers).to.have.property('expires', '-1'); + expect(res.headers).to.have.property('content-disposition', 'inline'); + }); + + it('should return not modified if the file does not exists and if-modified-since is equal to the Thu, 01 Jan 2015 00:00:00 GMT', async () => { + const res = await request + .get('/emoji-custom/invalid') + .set(credentials) + .set({ + 'if-modified-since': 'Thu, 01 Jan 2015 00:00:00 GMT', + }) + .expect(304); + + expect(res.headers).to.have.property('last-modified', 'Thu, 01 Jan 2015 00:00:00 GMT'); + }); + + it('should return success if the the requested exists', async () => { + const res = await request.get(`/emoji-custom/${customEmojiName}.png`).set(credentials).expect(200); + + expect(res.headers).to.have.property('last-modified'); + expect(res.headers).to.have.property('content-type', 'image/png'); + expect(res.headers).to.have.property('cache-control', 'public, max-age=31536000'); + expect(res.headers).to.have.property('content-disposition', 'inline'); + uploadDate = res.headers['last-modified']; + }); + + it('should return not modified if the the requested file contains a valid-since equal to the upload date', async () => { + const res = await request + .get(`/emoji-custom/${customEmojiName}.png`) + .set(credentials) + .set({ + 'if-modified-since': uploadDate, + }) + .expect(304); + + expect(res.headers).to.have.property('last-modified', uploadDate); + expect(res.headers).not.to.have.property('content-type'); + expect(res.headers).not.to.have.property('content-length'); + expect(res.headers).not.to.have.property('cache-control'); + }); + + it('should return the emoji even when no etag is passed (for old emojis)', async () => { + const res = await request.get(`/emoji-custom/${createdCustomEmoji.name}.png`).set(credentials).expect(200); + + expect(res.headers).to.have.property('content-type', 'image/png'); + expect(res.headers).to.have.property('cache-control', 'public, max-age=31536000'); + expect(res.headers).to.have.property('content-disposition', 'inline'); + }); + + it('should return success if the etag is invalid', async () => { + const res = await request + .get(`/emoji-custom/${createdCustomEmoji.name}.png?etag=1234`) + .set(credentials) + .set({ + 'if-none-match': 'invalid-etag', + }) + .expect(200); + + expect(res.headers).to.have.property('content-type', 'image/png'); + expect(res.headers).to.have.property('cache-control', 'public, max-age=31536000'); + expect(res.headers).to.have.property('content-disposition', 'inline'); + }); + }); + + describe('[/emoji-custom.delete]', () => { + it('should throw an error when trying delete custom emoji without the required param "emojid"', async () => { + const res = await request + .post(api('emoji-custom.delete')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'emojiId'"); + }); + it('should throw an error when trying delete custom emoji that does not exists', async () => { + const res = await request + .post(api('emoji-custom.delete')) + .set(credentials) + .send({ + emojiId: 'invalid-id', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('Custom_Emoji_Error_Invalid_Emoji'); + }); + it('should delete the custom emoji created before successfully', async () => { + const res = await request + .post(api('emoji-custom.delete')) + .set(credentials) + .send({ + emojiId: createdCustomEmoji._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('Emoji storage settings reactivity', () => { + const normalizeSvg = (svg: string) => svg.replace(/\r\n/g, '\n').trim(); + + const now = Date.now(); + const fsEmojiName = `emoji-fs-${now}`; + const gridFsEmojiName = `emoji-gridfs-${now}`; + const svgFallback = normalizeSvg(` + + + ? + +`); + + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', '', false); + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + await request.post(api('emoji-custom.create')).set(credentials).attach('emoji', imgURL).field({ name: fsEmojiName }).expect(200); + + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + await request.post(api('emoji-custom.create')).set(credentials).attach('emoji', imgURL).field({ name: gridFsEmojiName }).expect(200); + }); + + after(async () => { + const list = await request.get(api('emoji-custom.all')).set(credentials); + const fsEmoji = list.body.emojis.find((e: IEmojiCustom) => e.name === fsEmojiName); + const gridEmoji = list.body.emojis.find((e: IEmojiCustom) => e.name === gridFsEmojiName); + + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem', false); + await updateSetting('EmojiUpload_FileSystemPath', ''); + if (fsEmoji) { + await request.post(api('emoji-custom.delete')).set(credentials).send({ emojiId: fsEmoji._id }); + } + + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + if (gridEmoji) { + await request.post(api('emoji-custom.delete')).set(credentials).send({ emojiId: gridEmoji._id }); + } + }); + + describe('EmojiUpload_Storage_Type', () => { + describe('when storage is GridFs', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'GridFS'); + }); + + it('should resolve GridFS files only', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + await request + .get(`/emoji-custom/${gridFsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + }); + }); + + describe('when storage is FileSystem', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + }); + + it('should resolve FileSystem files only', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + await request + .get(`/emoji-custom/${gridFsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + }); + }); + }); + + describe('EmojiUpload_FileSystemPath', () => { + before(async () => { + await updateSetting('EmojiUpload_Storage_Type', 'FileSystem'); + }); + + describe('when file system path is the default one', () => { + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', ''); + }); + + it('should resolve files', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => expect(res.headers).to.have.property('content-type', 'image/png')); + }); + }); + + describe('when file system path is NOT the default one', () => { + before(async () => { + await updateSetting('EmojiUpload_FileSystemPath', '~/emoji-test'); + }); + + after(async () => { + await updateSetting('CustomSounds_FileSystemPath', ''); + }); + + it('should NOT resolve files', async () => { + await request + .get(`/emoji-custom/${fsEmojiName}.png`) + .set(credentials) + .expect(200) + .expect((res) => { + const received = normalizeSvg(res.body.toString()); + expect(received).to.equal(svgFallback); + }); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/failed-login-attempts.ts b/apps/meteor/tests/end-to-end/api/failed-login-attempts.ts new file mode 100644 index 0000000..ebdc173 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/failed-login-attempts.ts @@ -0,0 +1,217 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, beforeEach, afterEach, describe, it } from 'mocha'; + +import { sleep } from '../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updateSetting, updatePermission } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser } from '../../data/users.helper'; + +describe('[Failed Login Attempts]', () => { + const maxAttemptsByUser = 2; + const maxAttemptsByIp = 4; + const userBlockSeconds = 3; + const ipBlockSeconds = 8; + + before((done) => getCredentials(done)); + + before(() => + Promise.all([ + updateSetting('Block_Multiple_Failed_Logins_Enabled', true), + updateSetting('Block_Multiple_Failed_Logins_By_Ip', true), + updateSetting('Block_Multiple_Failed_Logins_By_User', true), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', maxAttemptsByUser), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', userBlockSeconds / 60), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', maxAttemptsByIp), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', ipBlockSeconds / 60), + updatePermission('logout-other-user', ['admin']), + ]), + ); + + after(() => + Promise.all([ + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User', 10), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes', 5), + updateSetting('Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip', 50), + updateSetting('Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes', 5), + updateSetting('Block_Multiple_Failed_Logins_Enabled', true), + updateSetting('Block_Multiple_Failed_Logins_By_Ip', true), + updateSetting('Block_Multiple_Failed_Logins_By_User', true), + updatePermission('logout-other-user', ['admin']), + ]), + ); + + async function shouldFailLoginWithUser(username: string, password: string) { + await request + .post(api('login')) + .send({ + user: username, + password, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'Unauthorized'); + }); + } + + async function shouldSuccesfullyLoginWithUser(username: string, password: string) { + await request + .post(api('login')) + .send({ + user: username, + password, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('status', 'success'); + expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body.data).to.have.property('userId'); + expect(res.body.data).to.have.property('authToken'); + }); + } + + async function shouldLogoutUser(uid: string) { + await request.post(api('users.logout')).set(credentials).send({ userId: uid }).expect('Content-Type', 'application/json').expect(200); + } + + async function shouldBlockLogin(username: string, password: string, reason: 'user' | 'ip') { + await request + .post(api('login')) + .send({ + user: username, + password, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('error', `error-login-blocked-for-${reason}`); + }); + } + + async function failMaxAttempts(username: string, password: string) { + const promises = []; + for (let i = 0; i < maxAttemptsByUser; i++) { + promises.push(shouldFailLoginWithUser(username, password)); + } + await Promise.all(promises); + } + + describe('[Block by User]', () => { + let user: TestUser; + + before(async () => { + await updateSetting('Block_Multiple_Failed_Logins_By_Ip', false); + }); + + after(async () => { + await updateSetting('Block_Multiple_Failed_Logins_By_Ip', true); + }); + + beforeEach(async () => { + user = await createUser(); + }); + + afterEach(async () => { + await deleteUser(user); + }); + + it('should block by User when the limit amount of failed attempts is reached', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + + await shouldBlockLogin(user.username, password, 'user'); + }); + + it('should unblock user after block time', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + + await shouldBlockLogin(user.username, password, 'user'); + await sleep(userBlockSeconds * 1000); + await shouldSuccesfullyLoginWithUser(user.username, password); + }); + + it('should reset counter of failed attempts after a successful login', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + + await shouldBlockLogin(user.username, password, 'user'); + await sleep(userBlockSeconds * 1000); + + await shouldFailLoginWithUser(user.username, `${password}-incorrect`); + await shouldSuccesfullyLoginWithUser(user.username, password); + await shouldLogoutUser(user._id); + await failMaxAttempts(user.username, `${password}-incorrect`); + }); + + it('should count failed attempts by user', async () => { + const newUser = await createUser(); + + await failMaxAttempts(user.username, `${password}-incorrect`); + await shouldFailLoginWithUser(newUser.username, `${password}-incorrect`); + await shouldSuccesfullyLoginWithUser(newUser.username, password); + + await deleteUser(newUser); + }); + }); + + describe('[Block by IP]', () => { + let user: TestUser; + let user2: TestUser; + let userLogin: TestUser; + + beforeEach(async () => { + user = await createUser(); + user2 = await createUser(); + userLogin = await createUser(); + }); + + afterEach(() => Promise.all([deleteUser(user), deleteUser(user2), deleteUser(userLogin)])); + + afterEach(async () => { + // reset counter + await sleep(ipBlockSeconds * 1000); + }); + + it('should block by IP when trying to login with one user and the limit amount of failed attempts is reached', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + await sleep(userBlockSeconds * 1000); + await failMaxAttempts(user.username, `${password}-incorrect`); + + await shouldBlockLogin(user.username, password, 'ip'); + }); + + it('should block by IP when trying to login with multiple users and the limit amount of failed attempts is reached', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + await failMaxAttempts(user2.username, `${password}-incorrect`); + + await shouldBlockLogin(userLogin.username, password, 'ip'); + }); + + it('should unblock IP after block time', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + await failMaxAttempts(user2.username, `${password}-incorrect`); + + await shouldBlockLogin(userLogin.username, password, 'ip'); + await sleep(ipBlockSeconds * 1000); + await shouldSuccesfullyLoginWithUser(userLogin.username, password); + }).timeout(20000); + + it('should reset counter of failed attempts after a successful login', async () => { + await failMaxAttempts(user.username, `${password}-incorrect`); + await failMaxAttempts(user2.username, `${password}-incorrect`); + + await sleep(ipBlockSeconds * 1000); + await shouldFailLoginWithUser(userLogin.username, `${password}-incorrect`); + await shouldSuccesfullyLoginWithUser(userLogin.username, password); + await shouldLogoutUser(userLogin._id); + + await failMaxAttempts(user.username, `${password}-incorrect`); + await failMaxAttempts(user2.username, `${password}-incorrect`); + await shouldBlockLogin(userLogin.username, password, 'ip'); + }).timeout(20000); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/federation.ts b/apps/meteor/tests/end-to-end/api/federation.ts new file mode 100644 index 0000000..df31b2c --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/federation.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request } from '../../data/api-data'; +import { updateSetting } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('federation', () => { + before((done) => getCredentials(done)); + + describe('well-known', () => { + describe('when matrix disabled', () => { + before(async () => { + await updateSetting('Federation_Service_Enabled', false, false); + await updateSetting('Federation_Service_Domain', 'localhost', false); + }); + + after(async () => { + await updateSetting('Federation_Service_Enabled', false, false); + }); + + it('should return 404 not found', async () => { + await request.get('/.well-known/matrix/server').expect(403); + }); + }); + + describe('when enabled', () => { + before(async () => { + await updateSetting('Federation_Service_Enabled', true); + await updateSetting('Federation_Service_Domain', 'rc.host'); + }); + + after(async () => { + await updateSetting('Federation_Service_Enabled', false); + await updateSetting('Federation_Service_Domain', ''); + }); + + it('should return matrix information', async () => { + await request + .get('/.well-known/matrix/server') + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('m.server', 'rc.host:443'); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/file-upload-image-rotation.ts b/apps/meteor/tests/end-to-end/api/file-upload-image-rotation.ts new file mode 100644 index 0000000..af30772 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/file-upload-image-rotation.ts @@ -0,0 +1,145 @@ +import path from 'path'; + +import type { Credentials } from '@rocket.chat/api-client'; +import type { ImageAttachmentProps, IRoom, IUser, SettingValue } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import sharp from 'sharp'; + +import { getCredentials, request } from '../../data/api-data'; +import { uploadFileToRC } from '../../data/file.helper'; +import { getSettingValueById, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import type { IRequestConfig, TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +const testImagePath = path.join(__dirname, '../../mocks/files/exif-orientation-6.jpg'); +const testImageName = 'exif-orientation-6.jpg'; + +const downloadBuffer = async (url: string, auth: Credentials): Promise => { + const response = await request.get(url).set(auth).buffer(true).expect(200); + return response.body as Buffer; +}; + +describe('[File Upload - Image Rotation]', () => { + before((done) => getCredentials(done)); + + let user: TestUser; + const userPassword = `pass${Date.now()}`; + let userCredentials: Credentials; + let testRoom: IRoom; + let rotateImagesSetting: SettingValue; + let stripExifSetting: SettingValue; + let thumbnailsEnabledSetting: SettingValue; + let thumbnailsWidthSetting: SettingValue; + let thumbnailsHeightSetting: SettingValue; + + before(async () => { + user = await createUser({ joinDefaultChannels: false, password: userPassword }); + userCredentials = await login(user.username, userPassword); + testRoom = (await createRoom({ type: 'p', name: `rotate-upload-${Date.now()}`, members: [user.username] })).body.group; + + rotateImagesSetting = await getSettingValueById('FileUpload_RotateImages'); + stripExifSetting = await getSettingValueById('Message_Attachments_Strip_Exif'); + thumbnailsEnabledSetting = await getSettingValueById('Message_Attachments_Thumbnails_Enabled'); + thumbnailsWidthSetting = await getSettingValueById('Message_Attachments_Thumbnails_Width'); + thumbnailsHeightSetting = await getSettingValueById('Message_Attachments_Thumbnails_Height'); + + await updateSetting('FileUpload_RotateImages', true, false); + await updateSetting('Message_Attachments_Strip_Exif', true, false); + await updateSetting('Message_Attachments_Thumbnails_Enabled', true); + }); + + after(async () => { + await Promise.all([ + updateSetting('FileUpload_RotateImages', rotateImagesSetting), + updateSetting('Message_Attachments_Strip_Exif', stripExifSetting), + updateSetting('Message_Attachments_Thumbnails_Enabled', thumbnailsEnabledSetting), + deleteRoom({ type: 'p', roomId: testRoom._id }), + deleteUser(user), + ]); + }); + + it('should rotate pixels and strip EXIF orientation', async () => { + const fixtureMetadata = await sharp(testImagePath).metadata(); + expect(fixtureMetadata.width).to.equal(719); + expect(fixtureMetadata.height).to.equal(479); + expect(fixtureMetadata.orientation).to.equal(6); + + const requestConfig: IRequestConfig = { request, credentials: userCredentials }; + const { message } = await uploadFileToRC(testRoom._id, testImagePath, 'rotation-exif-test', requestConfig); + + expect(message).to.have.property('attachments'); + const attachment = message.attachments?.find((item) => item.title === testImageName); + expect(attachment).to.be.an('object'); + + const fileUrl = (attachment as { title_link?: string }).title_link; + const thumbUrl = (attachment as ImageAttachmentProps).image_url; + + expect(fileUrl).to.be.a('string'); + expect(thumbUrl).to.be.a('string'); + + const originalBuffer = await downloadBuffer(fileUrl as string, userCredentials); + const originalMetadata = await sharp(originalBuffer).metadata(); + + expect(originalMetadata.width).to.equal(479); + expect(originalMetadata.height).to.equal(719); + expect(originalMetadata.exif).to.be.undefined; + }); + + it('should generate thumbnail from rotated image', async () => { + const requestConfig: IRequestConfig = { request, credentials: userCredentials }; + const { message } = await uploadFileToRC(testRoom._id, testImagePath, 'rotation-thumb-test', requestConfig); + + expect(message).to.have.property('attachments'); + const attachment = message.attachments?.find((item) => item.title === testImageName); + expect(attachment).to.be.an('object'); + + const fileUrl = (attachment as { title_link?: string }).title_link; + const thumbUrl = (attachment as ImageAttachmentProps).image_url; + + expect(fileUrl).to.be.a('string'); + expect(thumbUrl).to.be.a('string'); + + const originalBuffer = await downloadBuffer(fileUrl as string, userCredentials); + const originalMetadata = await sharp(originalBuffer).metadata(); + const thumbBuffer = await downloadBuffer(thumbUrl as string, userCredentials); + const thumbMetadata = await sharp(thumbBuffer).metadata(); + const thumbWidthSetting = Number(thumbnailsWidthSetting); + const thumbHeightSetting = Number(thumbnailsHeightSetting); + const originalWidth = originalMetadata.width as number; + const originalHeight = originalMetadata.height as number; + const resizeFactor = Math.min(thumbWidthSetting / originalWidth, thumbHeightSetting / originalHeight); + + expect(thumbMetadata.width).to.be.closeTo(Math.round(originalWidth * resizeFactor), 1); + expect(thumbMetadata.height).to.be.closeTo(Math.round(originalHeight * resizeFactor), 1); + }); + + describe('when FileUpload_RotateImages is disabled', () => { + before(async () => { + await updateSetting('FileUpload_RotateImages', false); + }); + + after(async () => { + await updateSetting('FileUpload_RotateImages', rotateImagesSetting); + }); + + it('should NOT rotate pixels', async () => { + const requestConfig: IRequestConfig = { request, credentials: userCredentials }; + const { message } = await uploadFileToRC(testRoom._id, testImagePath, 'no-rotation-test', requestConfig); + + expect(message).to.have.property('attachments'); + const attachment = message.attachments?.find((item) => item.title === testImageName); + expect(attachment).to.be.an('object'); + + const fileUrl = (attachment as { title_link?: string }).title_link; + expect(fileUrl).to.be.a('string'); + + const originalBuffer = await downloadBuffer(fileUrl as string, userCredentials); + const originalMetadata = await sharp(originalBuffer).metadata(); + + expect(originalMetadata.width).to.equal(719); + expect(originalMetadata.height).to.equal(479); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/groups.ts b/apps/meteor/tests/end-to-end/api/groups.ts new file mode 100644 index 0000000..35fe1d5 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/groups.ts @@ -0,0 +1,2922 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import { TeamType, type IIntegration, type IMessage, type IRoom, type ITeam, type IUser } from '@rocket.chat/core-typings'; +import { assert, expect } from 'chai'; +import { after, before, describe, it, beforeEach } from 'mocha'; + +import { getCredentials, api, request, credentials, apiPrivateChannelName } from '../../data/api-data'; +import { pinMessage, starMessage, sendMessage, updateMessage } from '../../data/chat.helper'; +import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; +import { createGroup, deleteGroup } from '../../data/groups.helper'; +import { createIntegration, removeIntegration } from '../../data/integration.helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { testFileUploads } from '../../data/uploads.helper'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, login, deleteUser } from '../../data/users.helper'; + +function getRoomInfo(roomId: IRoom['_id']) { + return new Promise<{ group: IRoom }>((resolve) => { + void request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId, + }) + .end((_err, req) => { + resolve(req.body); + }); + }); +} + +describe('[Groups]', () => { + let group: { + _id: string; + name: string; + }; + + before((done) => getCredentials(done)); + + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: apiPrivateChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + group = { + _id: res.body.group._id, + name: res.body.group.name, + }; + }); + }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + describe('/groups.create', () => { + let guestUser: TestUser; + let invitedUser: TestUser; + let invitedUserCredentials: Credentials; + let room: IRoom; + let teamId: ITeam['_id']; + + before(async () => { + guestUser = await createUser({ roles: ['guest'] }); + invitedUser = await createUser(); + invitedUserCredentials = await login(invitedUser.username, password); + + await updatePermission('create-team', ['admin', 'user']); + const teamCreateRes = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `team-${Date.now()}`, + type: 0, + members: [invitedUser.username], + }); + + teamId = teamCreateRes.body.team._id; + await updatePermission('create-team-group', ['owner']); + }); + + after(async () => { + await deleteUser(guestUser); + await deleteUser(invitedUser); + await updatePermission('create-team-group', ['admin', 'owner', 'moderator']); + }); + + describe('guest users', () => { + it('should not add guest users to more rooms than defined in the license', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!process.env.IS_EE) { + this.skip(); + } + const promises = []; + + for (let i = 0; i < maxRoomsPerGuest; i++) { + promises.push( + createRoom({ + type: 'p', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }), + ); + } + await Promise.all(promises); + + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + room = res.body.group; + }); + + await request + .get(api('groups.members')) + .set(credentials) + .query({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + }); + }); + + describe('validate E2E rooms', () => { + before(async () => { + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Allow_Unencrypted_Messages', false)]); + }); + + after(async () => { + await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Allow_Unencrypted_Messages', true)]); + }); + + let rid: IRoom['_id']; + + it('should create a new encrypted group', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `encrypted-${apiPrivateChannelName}`, + extraData: { + encrypted: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.name', `encrypted-${apiPrivateChannelName}`); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + expect(res.body).to.have.nested.property('group.encrypted', true); + rid = res.body.group._id; + }); + }); + + it('should send an encrypted message in encrypted group', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Encrypted Message', + t: 'e2e', + e2e: 'pending', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body).to.have.nested.property('message.text', 'Encrypted Message'); + expect(res.body).to.have.nested.property('message.t', 'e2e'); + expect(res.body).to.have.nested.property('message.e2e', 'pending'); + }); + }); + + it('should give an error on sending un-encrypted message in encrypted room', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Unencrypted Message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.is.a('string'); + }); + }); + + it('should allow sending un-encrypted messages in encrypted room when setting is enabled', async () => { + await updateSetting('E2E_Allow_Unencrypted_Messages', true); + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Unencrypted Message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body).to.have.nested.property('message.text', 'Unencrypted Message'); + }); + }); + }); + + describe('E2E enabled by default', () => { + before(async () => { + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Enabled_Default_PrivateRooms', true)]); + }); + + after(async () => { + await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Enabled_Default_PrivateRooms', false)]); + }); + + it('should create the encrypted room by default', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `default-encrypted-${apiPrivateChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.name', `default-encrypted-${apiPrivateChannelName}`); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + expect(res.body).to.have.nested.property('group.encrypted', true); + }); + }); + }); + + describe('E2E forced encryption for private rooms', () => { + const createdRoomIds: IRoom['_id'][] = []; + + before(async () => { + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]); + }); + + after(async () => { + await Promise.all([ + updateSetting('E2E_Enable', false), + updateSetting('E2E_Force_Encryption_For_Private_Rooms', false), + ...createdRoomIds.map((roomId) => deleteRoom({ type: 'p', roomId })), + ]); + }); + + it('should reject creating a private room with encrypted=false when private room encryption is forced', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `forced-unencrypted-${apiPrivateChannelName}`, + extraData: { + encrypted: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-encrypted-private-rooms-enforced'); + }); + }); + + it('should create an encrypted private room when encrypted is omitted and private room encryption is forced', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `forced-default-${apiPrivateChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + if (res.body.group?._id) { + createdRoomIds.push(res.body.group._id); + } + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.encrypted', true); + }); + }); + + it('should allow creating a private room with encrypted=true when private room encryption is forced', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `forced-encrypted-${apiPrivateChannelName}`, + extraData: { + encrypted: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + if (res.body.group?._id) { + createdRoomIds.push(res.body.group._id); + } + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.encrypted', true); + }); + }); + + it('should not enforce encryption on federated private rooms', async () => { + // Federated rooms do not support E2EE, so the forced-encryption policy exempts them. + // Depending on the environment the creation may still fail further down the pipeline + // (federation unavailable), but it must never fail with the forced-encryption error, + // and if it succeeds the room must not have been force-encrypted. + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `forced-federated-${apiPrivateChannelName}`, + extraData: { + broadcast: false, + encrypted: false, + federated: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + if (res.body.group?._id) { + createdRoomIds.push(res.body.group._id); + } + expect(res.body).to.not.have.property('errorType', 'error-encrypted-private-rooms-enforced'); + if (res.body.success) { + expect(res.body).to.not.have.nested.property('group.encrypted', true); + } + }); + }); + }); + + it(`should fail when trying to use an existing room's name`, async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: 'general', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-duplicate-channel-name'); + }); + }); + + it('should successfully create a group in a team', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `team-group-${Date.now()}`, + extraData: { teamId }, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('group'); + expect(res.body.group).to.have.property('teamId', teamId); + }); + }); + + it('should fail creating a group in a team when member does not have the necessary permission', async () => { + await request + .post(api('groups.create')) + .set(invitedUserCredentials) + .send({ + name: `team-group-${Date.now()}`, + extraData: { teamId }, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + }); + }); + + describe('/groups.info', () => { + let testGroup: IRoom; + + const newGroupInfoName = `info-private-channel-test-${Date.now()}`; + + before('creating new group...', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: newGroupInfoName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testGroup = res.body.group; + }); + }); + + after('deleting group...', async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: newGroupInfoName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should return group basic structure', async () => { + await request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', newGroupInfoName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + }); + }); + + let groupMessage: IMessage; + + it('sending a message...', async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: testGroup._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + groupMessage = res.body.message; + }); + }); + + it('REACTing with last message', async () => { + await request + .post(api('chat.react')) + .set(credentials) + .send({ + emoji: ':squid:', + messageId: groupMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('STARring last message', async () => { + await request + .post(api('chat.starMessage')) + .set(credentials) + .send({ + messageId: groupMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('PINning last message', async () => { + await request + .post(api('chat.pinMessage')) + .set(credentials) + .send({ + messageId: groupMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return group structure with "lastMessage" object including pin, reaction and star(should be an array) infos', async () => { + await request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('group').and.to.be.an('object'); + const { group } = res.body; + expect(group).to.have.property('lastMessage').and.to.be.an('object'); + expect(group.lastMessage).to.have.property('reactions').and.to.be.an('object'); + expect(group.lastMessage).to.have.property('pinned').and.to.be.a('boolean'); + expect(group.lastMessage).to.have.property('pinnedAt').and.to.be.a('string'); + expect(group.lastMessage).to.have.property('pinnedBy').and.to.be.an('object'); + expect(group.lastMessage).to.have.property('starred').and.to.be.an('array'); + }); + }); + it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const messages = res.body.messages as IMessage[]; + const lastMessage = messages.filter((message) => message._id === groupMessage._id)[0]; + expect(lastMessage).to.have.property('starred').and.to.be.an('array'); + expect(lastMessage.starred?.[0]._id).to.be.equal(adminUsername); + }); + }); + + it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY even requested with count and offset params', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const messages = res.body.messages as IMessage[]; + const lastMessage = messages.filter((message) => message._id === groupMessage._id)[0]; + expect(lastMessage).to.have.property('starred').and.to.be.an('array'); + expect(lastMessage.starred?.[0]._id).to.be.equal(adminUsername); + }); + }); + }); + + describe('/groups.messages', () => { + let testGroup: IRoom; + let firstUser: IUser; + let secondUser: IUser; + let pinnedMessageId: IMessage['_id']; + + before(async () => { + testGroup = (await createGroup({ name: `test-group-${Date.now()}` })).body.group; + firstUser = await createUser({ joinDefaultChannels: false }); + secondUser = await createUser({ joinDefaultChannels: false }); + + const messages = [ + { + rid: testGroup._id, + msg: `@${firstUser.username} youre being mentioned`, + mentions: [{ username: firstUser.username, _id: firstUser._id, name: firstUser.name }], + }, + { + rid: testGroup._id, + msg: `@${secondUser.username} youre being mentioned`, + mentions: [{ username: secondUser.username, _id: secondUser._id, name: secondUser.name }], + }, + { + rid: testGroup._id, + msg: `A simple message`, + }, + { + rid: testGroup._id, + msg: `A pinned simple message`, + }, + ]; + + const [, , starredMessage, pinnedMessage] = await Promise.all(messages.map((message) => sendMessage({ message }))); + + await Promise.all([ + starMessage({ messageId: starredMessage.body.message._id }), + pinMessage({ messageId: pinnedMessage.body.message._id }), + ]); + + pinnedMessageId = pinnedMessage.body.message._id; + }); + + after(async () => { + await deleteGroup({ roomName: testGroup.name }); + }); + + it('should return all messages from a group', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: testGroup._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + }); + }); + + it('should return messages that mention a single user', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + mentionIds: firstUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('mentions').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body.messages[0].mentions[0]).to.have.property('_id', firstUser._id); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return messages that mention multiple users', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + mentionIds: `${firstUser._id},${secondUser._id}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(2); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('total', 2); + + const mentionIds = res.body.messages.map((message: any) => message.mentions[0]._id); + expect(mentionIds).to.include.members([firstUser._id, secondUser._id]); + }); + }); + + it('should return messages that are starred by a specific user', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + starredIds: 'rocketchat.internal.admin.test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('starred').that.is.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return messages that are pinned', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(1); + expect(res.body.messages[0]).to.have.nested.property('pinned').that.is.an('boolean').and.to.be.true; + expect(res.body.messages[0]).to.have.nested.property('pinnedBy').that.is.an('object'); + expect(res.body.messages[0].pinnedBy).to.have.property('_id', 'rocketchat.internal.admin.test'); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total', 1); + }); + }); + + it('should return all messages from a group using roomName parameter', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomName: testGroup.name }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + }); + }); + + it('should return error if both roomId and roomName are provided', async () => { + const thirdGroup = (await createGroup({ name: `test-priority-${Date.now()}` })).body.group; + const secondGroup = (await createGroup({ name: `test-priority-${Date.now()}` })).body.group; + + try { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: thirdGroup._id, + roomName: secondGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + }); + } finally { + await Promise.all([deleteGroup({ roomName: secondGroup.name }), deleteGroup({ roomName: thirdGroup.name })]); + } + }); + + describe('_hidden messages behavior when Message_KeepHistory is enabled', async () => { + before(async () => { + await updateSetting('Message_KeepHistory', true); + await pinMessage({ messageId: pinnedMessageId, unpin: true }); + }); + + after(async () => { + await updateSetting('Message_KeepHistory', false); + }); + + it('should return all messages, without any pinned messages', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: testGroup._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('pinned', true); + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + + it('should return no pinned messages', async () => { + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: testGroup._id, + pinned: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(0); + expect(res.body).to.have.property('count', 0); + expect(res.body).to.have.property('total', 0); + }); + }); + + it('should not return old message when updating a message', async () => { + await updateMessage({ msgId: pinnedMessageId, updatedMessage: 'message was unpinned', roomId: testGroup._id }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: testGroup._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(res.body.messages).to.have.lengthOf(5); + + const updatedMessage = res.body.messages.find((msg: IMessage) => msg._id === pinnedMessageId); + + expect(updatedMessage).to.have.property('msg', 'message was unpinned'); + expect(updatedMessage).to.have.property('editedAt'); + + res.body.messages.forEach((msg: IMessage) => { + expect(msg).to.not.have.property('_hidden'); + }); + }); + }); + }); + }); + + describe('/groups.invite', async () => { + let roomInfo: { group: IRoom }; + + before(async () => { + roomInfo = await getRoomInfo(group._id); + }); + + it('should invite user to group', async () => { + await request + .post(api('groups.invite')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); + }); + }); + }); + + describe('/groups.addModerator', () => { + it('should make user a moderator', async () => { + const res = await request + .post(api('groups.addModerator')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.removeModerator', () => { + it('should remove user from moderator', async () => { + const res = await request + .post(api('groups.removeModerator')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.addOwner', () => { + it('should add user as owner', async () => { + const res = await request + .post(api('groups.addOwner')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.removeOwner', () => { + it('should remove user from owner', async () => { + const res = await request + .post(api('groups.removeOwner')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.addLeader', () => { + it('should add user as leader', async () => { + const res = await request + .post(api('groups.addLeader')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + }); + }); + + describe('/groups.removeLeader', () => { + it('should remove user from leader', async () => { + const res = await request + .post(api('groups.removeLeader')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.kick', () => { + let testUserModerator: TestUser; + let credsModerator: Credentials; + let testUserOwner: TestUser; + let credsOwner: Credentials; + let testUserMember: TestUser; + let groupTest: IRoom; + + const inviteUser = async (userId: IUser['_id']) => { + await request + .post(api('groups.invite')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }; + + before(async () => { + // had to do them in serie because calling them with Promise.all was failing some times + testUserModerator = await createUser(); + testUserOwner = await createUser(); + testUserMember = await createUser(); + + credsModerator = await login(testUserModerator.username, password); + credsOwner = await login(testUserOwner.username, password); + + await request + .post(api('groups.create')) + .set(credsOwner) + .send({ + name: `kick-test-group-${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + groupTest = res.body.group; + }); + + await inviteUser(testUserModerator._id); + + await request + .post(api('groups.addModerator')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserModerator._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + after(async () => { + await Promise.all([ + request + .post(api('groups.delete')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + }) + .expect('Content-Type', 'application/json') + .expect(200), + updatePermission('kick-user-from-any-p-room', []), + updatePermission('remove-user', ['admin', 'owner', 'moderator']), + deleteUser(testUserModerator), + deleteUser(testUserOwner), + deleteUser(testUserMember), + ]); + }); + + it("should return an error when user is not a member of the group and doesn't have permission", async () => { + await updatePermission('kick-user-from-any-p-room', []); + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + + it('should allow a moderator to remove user from group', async () => { + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsModerator) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should allow an owner to remove user from group', async () => { + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should kick user from group if not a member of the room but has the required permission', async () => { + await updatePermission('kick-user-from-any-p-room', ['admin']); + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: group._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it("should return an error when the owner doesn't have the required permission", async () => { + await updatePermission('remove-user', ['admin', 'moderator']); + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + }); + + it('should return an error when trying to kick the last owner from a group', async () => { + await updatePermission('kick-user-from-any-p-room', ['admin']); + + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: groupTest._id, + userId: testUserOwner._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-you-are-last-owner'); + }); + }); + + it('should return an error when trying to kick user that does not exist'); + it('should return an error when trying to kick user from a group that does not exist'); + it('should return an error when trying to kick user from a group that the user is not in the room'); + }); + + describe('/groups.setDescription', () => { + it('should set the description of the group with a string', async () => { + const res = await request + .post(api('groups.setDescription')) + .set(credentials) + .send({ + roomId: group._id, + description: 'this is a description for a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('description', 'this is a description for a channel for api tests'); + }); + it('should set the description of the group with an empty string(remove the description)', async () => { + const res = await request + .post(api('groups.setDescription')) + .set(credentials) + .send({ + roomId: group._id, + description: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('description', ''); + }); + }); + + describe('/groups.setTopic', () => { + it('should set the topic of the group with a string', async () => { + const res = await request + .post(api('groups.setTopic')) + .set(credentials) + .send({ + roomId: group._id, + topic: 'this is a topic of a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', 'this is a topic of a channel for api tests'); + }); + it('should set the topic of the group with an empty string(remove the topic)', async () => { + const res = await request + .post(api('groups.setTopic')) + .set(credentials) + .send({ + roomId: group._id, + topic: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('topic', ''); + }); + }); + + describe('/groups.setPurpose', () => { + it('should set the purpose of the group with a string', async () => { + const res = await request + .post(api('groups.setPurpose')) + .set(credentials) + .send({ + roomId: group._id, + purpose: 'this is a purpose of a channel for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('purpose', 'this is a purpose of a channel for api tests'); + }); + it('should set the purpose of the group with an empty string(remove the purpose)', async () => { + const res = await request + .post(api('groups.setPurpose')) + .set(credentials) + .send({ + roomId: group._id, + purpose: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('purpose', ''); + }); + }); + + describe('/groups.history', () => { + it('should return groups history when searching by roomId', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + it('should return groups history when searching by roomId even requested with count and offset params', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: group._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages'); + }); + + describe('inclusive parameter', () => { + let testGroup: IRoom; + let oldestMessage: IMessage; + let middleMessage: IMessage; + let latestMessage: IMessage; + + before(async () => { + const groupRes = await request + .post(api('groups.create')) + .set(credentials) + .send({ name: `inclusive-test-group-${Date.now()}` }); + testGroup = groupRes.body.group; + + // Send messages with small delays to ensure distinct timestamps + const msg1 = await sendMessage({ message: { rid: testGroup._id, msg: 'oldest message' } }); + oldestMessage = msg1.body.message; + + // Small delay to ensure timestamps are different + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg2 = await sendMessage({ message: { rid: testGroup._id, msg: 'middle message' } }); + middleMessage = msg2.body.message; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const msg3 = await sendMessage({ message: { rid: testGroup._id, msg: 'latest message' } }); + latestMessage = msg3.body.message; + }); + + after(async () => { + if (testGroup?._id) { + await request.post(api('groups.delete')).set(credentials).send({ roomId: testGroup._id }); + } + }); + + it('should include boundary messages when inclusive=true', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'true', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.include(oldestMessage._id, 'oldest message should be included'); + expect(messageIds).to.include(latestMessage._id, 'latest message should be included'); + }); + + it('should exclude boundary messages when inclusive=false', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + inclusive: 'false', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded'); + // Middle message should still be included if it exists in the range + expect(messageIds).to.include(middleMessage._id, 'middle message should be included'); + }); + + it('should exclude boundary messages by default (no inclusive param)', async () => { + const res = await request + .get(api('groups.history')) + .set(credentials) + .query({ + roomId: testGroup._id, + oldest: oldestMessage.ts, + latest: latestMessage.ts, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.not.include(oldestMessage._id, 'oldest message should be excluded by default'); + expect(messageIds).to.not.include(latestMessage._id, 'latest message should be excluded by default'); + }); + }); + }); + + describe('/groups.archive', () => { + it('should archive the group', async () => { + const res = await request + .post(api('groups.archive')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.unarchive', () => { + it('should unarchive the group', async () => { + const res = await request + .post(api('groups.unarchive')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.close', () => { + it('should close the group', async () => { + const res = await request + .post(api('groups.close')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when trying to close a private group that is already closed', async () => { + const res = await request + .post(api('groups.close')) + .set(credentials) + .send({ + roomName: apiPrivateChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${apiPrivateChannelName}, is already closed to the sender`); + }); + }); + + describe('/groups.open', () => { + it('should open the group', async () => { + const res = await request + .post(api('groups.open')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('group access as a regular member', () => { + let testGroup: IRoom; + let member: TestUser; + let memberCredentials: Credentials; + let outsider: TestUser; + let outsiderCredentials: Credentials; + const memberGroupName = `member-access-group-${Date.now()}-${Math.random()}`; + + before(async () => { + member = await createUser(); + memberCredentials = await login(member.username, password); + outsider = await createUser(); + outsiderCredentials = await login(outsider.username, password); + + const result = await createRoom({ type: 'p', name: memberGroupName, members: [member.username] }); + testGroup = result.body.group; + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: testGroup._id }); + await Promise.all([deleteUser(member), deleteUser(outsider)]); + }); + + it('should return the group info to a regular member', async () => { + const res = await request + .get(api('groups.info')) + .set(memberCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id', testGroup._id); + expect(res.body).to.have.nested.property('group.name', memberGroupName); + expect(res.body).to.have.nested.property('group.t', 'p'); + }); + + it('should not return the group info to a non-member', async () => { + const res = await request + .get(api('groups.info')) + .set(outsiderCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + + it('should return the group history to a regular member', async () => { + const res = await request + .get(api('groups.history')) + .set(memberCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + + it('should close the group for a regular member', async () => { + await request + .post(api('groups.close')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should report the group as already closed on a second close (subscription open flag is read)', async () => { + await request + .post(api('groups.close')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${memberGroupName}, is already closed to the sender`); + }); + }); + + it('should open the group back for a regular member', async () => { + await request + .post(api('groups.open')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should report the group as already open on a second open (subscription open flag is read)', async () => { + await request + .post(api('groups.open')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${memberGroupName}, is already open for the sender`); + }); + }); + }); + + describe('/groups.list', () => { + it('should list the groups the caller is part of', async () => { + const res = await request.get(api('groups.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('groups').and.to.be.an('array'); + }); + + it('should return a list of zero length if not a member of any group', async () => { + const user = await createUser(); + const newCreds = await login(user.username, password); + await request + .get(api('groups.list')) + .set(newCreds) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').and.to.equal(0); + expect(res.body).to.have.property('total').and.to.equal(0); + expect(res.body).to.have.property('groups').and.to.be.an('array').and.that.has.lengthOf(0); + }); + }); + }); + + describe('/groups.online', () => { + const createUserAndChannel = async (setAsOnline = true) => { + const testUser = await createUser(); + const testUserCredentials = await login(testUser.username, password); + + if (setAsOnline) { + await request.post(api('users.setStatus')).set(testUserCredentials).send({ + message: '', + status: 'online', + }); + } + + const roomName = `group-test-${Date.now()}`; + + const roomResponse = await createRoom({ + name: roomName, + type: 'p', + members: [testUser.username], + credentials: testUserCredentials, + }); + + return { + testUser, + testUserCredentials, + room: roomResponse.body.group, + }; + }; + + it('should return an error if no query', () => + request + .get(api('groups.online')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid query'); + })); + + it('should return an error if passing an empty query', () => + request + .get(api('groups.online')) + .set(credentials) + .query('query={}') + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid query'); + })); + + it('should return an array with online members', async () => { + const { testUser, testUserCredentials, room } = await createUserAndChannel(); + + const response = await request.get(api('groups.online')).set(testUserCredentials).query({ + _id: room._id, + }); + + const { body } = response; + + const expected = { + _id: testUser._id, + username: testUser.username, + }; + + expect(body.online).to.deep.include(expected); + }); + + it('should return an empty array if members are offline', async () => { + const { testUserCredentials, room } = await createUserAndChannel(false); + + const response = await request.get(api('groups.online')).set(testUserCredentials).query({ + _id: room._id, + }); + + const { body } = response; + + expect(body.online).to.deep.equal([]); + }); + + it('should return an error if requesting user is not in group', async () => { + const outsider = await createUser(); + const outsiderCredentials = await login(outsider.username, password); + + const { room } = await createUserAndChannel(); + + await request + .get(api('groups.online')) + .set(outsiderCredentials) + .query({ + _id: room._id, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + }); + }); + + describe('/groups.members', () => { + it('should return group members when searching by roomId', async () => { + const res = await request + .get(api('groups.members')) + .set(credentials) + .query({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('members').and.to.be.an('array'); + }); + it('should return group members when searching by roomId even requested with count and offset params', async () => { + const res = await request + .get(api('groups.members')) + .set(credentials) + .query({ + roomId: group._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('members').and.to.be.an('array'); + }); + }); + + describe('/groups.files', async () => { + await testFileUploads('groups.files', 'p'); + }); + + describe('/groups.listAll', () => { + before(async () => { + return updatePermission('view-room-administration', ['admin']); + }); + + after(async () => { + return updatePermission('view-room-administration', ['admin']); + }); + + it('should succeed if user has view-room-administration permission', async () => { + await request + .get(api('groups.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('groups').and.to.be.an('array'); + }); + }); + + it('should fail if the user doesnt have view-room-administration permission', async () => { + await updatePermission('view-room-administration', []); + await request + .get(api('groups.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('/groups.counters', () => { + let room: IRoom; + let user1: IUser; + let user2: IUser; + let user1Creds: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before(async () => { + // Create two users + user1 = await createUser(); + user2 = await createUser(); + user1Creds = await login(user1.username, password); + + // Create a new public channel with both users as members + room = ( + await createRoom({ + type: 'p', + name: `counters-test-${Date.now()}`, + members: [user1.username as string, user2.username as string], + }) + ).body.group; + }); + + after(async () => { + // Delete room first + await deleteRoom({ type: 'p', roomId: room._id }); + // Then delete users + await Promise.all([deleteUser(user1), deleteUser(user2)]); + }); + + it('should require auth', async () => { + await request + .get(api('groups.counters')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should require a roomId', async () => { + await request + .get(api('groups.counters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return counters for a channel with correct fields', async () => { + await request + .get(api('groups.counters')) + .set(user1Creds) + .query({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').that.is.a('number').and.equals(3); + expect(res.body).to.have.property('unreads').that.is.a('number'); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs').that.is.a('number'); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('joined', true); + }); + }); + + it('should not include deactivated users in members count', async () => { + // Deactivate the second user + await request.post(api('users.setActiveStatus')).set(credentials).send({ userId: user2._id, activeStatus: false }); + + const res = await request.get(api('groups.counters')).set(user1Creds).query({ roomId: room._id }); + + expect(res.status).to.equal(200); + expect(res.body.success).to.be.true; + // Only user1 and admin remain active + expect(res.body.members).to.equal(2); + }); + }); + + describe('/groups.rename', async () => { + let roomInfo: { group: IRoom }; + before(async () => { + roomInfo = await getRoomInfo(group._id); + }); + + it('should return the group rename with an additional message', async () => { + await request + .post(api('groups.rename')) + .set(credentials) + .send({ + roomId: group._id, + name: `EDITED${apiPrivateChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', `EDITED${apiPrivateChannelName}`); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); + }); + }); + }); + + describe('/groups.getIntegrations', () => { + let integrationCreatedByAnUser: IIntegration; + let createdGroup: IRoom; + + before(async () => { + const resRoom = await createRoom({ name: `test-integration-group-${Date.now()}`, type: 'p' }); + + createdGroup = resRoom.body.group; + + const user = await createUser(); + + const userCredentials = await login(user.username, password); + + await Promise.all([ + updatePermission('manage-incoming-integrations', ['user']), + updatePermission('manage-own-incoming-integrations', ['user']), + ]); + + integrationCreatedByAnUser = await createIntegration( + { + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: `#${createdGroup.name}`, + }, + userCredentials, + ); + }); + + after(async () => { + await removeIntegration(integrationCreatedByAnUser._id, 'incoming'); + + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + ]); + }); + + it('should return the list of integrations of create group and it should contain the integration created by user when the admin DOES have the permission', async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + assert.isDefined(integrationCreated); + expect(integrationCreated).to.be.an('object'); + expect(integrationCreated._id).to.be.equal(integrationCreatedByAnUser._id); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return the list of integrations created by the user only', async () => { + await Promise.all([ + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-incoming-integrations', []), + ]); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + expect(integrationCreated).to.be.equal(undefined); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return unauthorized error when the user does not have any integrations permissions', async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + ]); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('/groups.setReadOnly', () => { + it('should set the group as read only', async () => { + const res = await request + .post(api('groups.setReadOnly')) + .set(credentials) + .send({ + roomId: group._id, + readOnly: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe.skip('/groups.leave', () => { + it('should allow the user to leave the group', async () => { + const res = await request + .post(api('groups.leave')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('/groups.setAnnouncement', () => { + it('should set the announcement of the group with a string', async () => { + const res = await request + .post(api('groups.setAnnouncement')) + .set(credentials) + .send({ + roomId: group._id, + announcement: 'this is an announcement of a group for api tests', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('announcement', 'this is an announcement of a group for api tests'); + }); + it('should set the announcement of the group with an empty string(remove the announcement)', async () => { + const res = await request + .post(api('groups.setAnnouncement')) + .set(credentials) + .send({ + roomId: group._id, + announcement: '', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('announcement', ''); + }); + }); + + describe('/groups.setType', () => { + let roomTypeId: IRoom['_id']; + let testRegularGroup: IRoom; + let testTeamGroupForFailure: IRoom; + let testTeamGroupForSuccess: IRoom; + let team: ITeam; + let testUser: TestUser; + let testUserCredentials: Credentials; + + beforeEach(async () => { + await updatePermission('create-c', ['admin', 'user']); + await updatePermission('create-team-channel', ['admin', 'owner', 'moderator']); + }); + + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.type.${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + roomTypeId = res.body.group._id; + }); + + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + await updatePermission('create-team', ['admin', 'user']); + team = await createTeam(credentials, `team-setType-${Date.now()}`, TeamType.PRIVATE, [testUser.username]); + + await request.post(api('groups.addOwner')).set(credentials).send({ roomId: team.roomId, userId: testUser._id }); + + testRegularGroup = (await createRoom({ type: 'p', name: `regular-setType-${Date.now()}`, credentials: testUserCredentials })).body + .group; + + testTeamGroupForFailure = (await createRoom({ type: 'p', name: `teamGrp-fail-${Date.now()}`, extraData: { teamId: team._id } })).body + .group; + testTeamGroupForSuccess = (await createRoom({ type: 'p', name: `teamGrp-success-${Date.now()}`, extraData: { teamId: team._id } })) + .body.group; + + await request.post(api('groups.invite')).set(credentials).send({ roomId: testTeamGroupForFailure._id, userId: testUser._id }); + await request.post(api('groups.addOwner')).set(credentials).send({ roomId: testTeamGroupForFailure._id, userId: testUser._id }); + await request.post(api('groups.invite')).set(credentials).send({ roomId: testTeamGroupForSuccess._id, userId: testUser._id }); + await request.post(api('groups.addOwner')).set(credentials).send({ roomId: testTeamGroupForSuccess._id, userId: testUser._id }); + }); + + after(async () => { + await request + .post(api('channels.delete')) + .set(credentials) + .send({ roomId: roomTypeId }) + .expect('Content-Type', 'application/json') + .expect(200); + await Promise.all( + ['p', 'c'].map(async (type: any) => { + return Promise.all([ + deleteRoom({ type, roomId: testRegularGroup._id }), + deleteRoom({ type, roomId: testTeamGroupForFailure._id }), + deleteRoom({ type, roomId: testTeamGroupForSuccess._id }), + ]); + }), + ); + await deleteTeam(credentials, team.name); + await deleteUser(testUser); + await updatePermission('create-c', ['admin', 'user']); + await updatePermission('create-team-channel', ['admin', 'owner', 'moderator']); + }); + + it('should change the type of the group to a channel', async () => { + await request + .post(api('groups.setType')) + .set(credentials) + .send({ + roomId: roomTypeId, + type: 'c', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.t', 'c'); + }); + }); + + it('should fail to change a regular group to channel when user lacks create-c permission', async () => { + await updatePermission('create-c', ['admin']); + await request + .post(api('groups.setType')) + .set(testUserCredentials) + .send({ roomId: testRegularGroup._id, type: 'c' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team main room to channel when user lacks create-c permission', async () => { + await updatePermission('create-c', ['admin']); + await request + .post(api('groups.setType')) + .set(testUserCredentials) + .send({ roomId: team.roomId, type: 'c' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team main room to channel when user has create-team-channel but lacks create-c', async () => { + await updatePermission('create-c', ['admin']); + await updatePermission('create-team-channel', ['owner']); + await request + .post(api('groups.setType')) + .set(testUserCredentials) + .send({ roomId: team.roomId, type: 'c' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should fail to change a team group to channel when user lacks create-team-channel permission', async () => { + await updatePermission('create-team-channel', []); + await request + .post(api('groups.setType')) + .set(testUserCredentials) + .send({ roomId: testTeamGroupForFailure._id, type: 'c' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should succeed changing a team group to channel when user has create-team-channel but not create-c', async () => { + await updatePermission('create-c', ['admin']); + await updatePermission('create-team-channel', ['owner']); + await request + .post(api('groups.setType')) + .set(testUserCredentials) + .send({ roomId: testTeamGroupForSuccess._id, type: 'c' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.t', 'c'); + expect(res.body).to.have.nested.property('group.teamId', team._id); + }); + }); + }); + + describe('/groups.setCustomFields', () => { + let cfchannel: IRoom; + let groupWithoutCustomFields: IRoom; + + before('create group with customFields', async () => { + const customFields = { field0: 'value0' }; + + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.cf.${Date.now()}`, + customFields, + }) + .expect((res) => { + cfchannel = res.body.group; + }); + + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.cf.${Date.now()}`, + }) + .expect((res) => { + groupWithoutCustomFields = res.body.group; + }); + }); + + after('delete group with customFields', async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: cfchannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: groupWithoutCustomFields.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('get customFields using groups.info', async () => { + const res = await request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId: cfchannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.customFields.field0', 'value0'); + }); + it('change customFields', async () => { + const customFields = { field9: 'value9' }; + await request + .post(api('groups.setCustomFields')) + .set(credentials) + .send({ + roomId: cfchannel._id, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', cfchannel.name); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.customFields.field9', 'value9'); + expect(res.body).to.have.not.nested.property('group.customFields.field0', 'value0'); + }); + }); + it('get customFields using groups.info', async () => { + const res = await request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId: cfchannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.customFields.field9', 'value9'); + }); + + it('set customFields with one nested field', async () => { + const customFields = { field1: 'value1' }; + await request + .post(api('groups.setCustomFields')) + .set(credentials) + .send({ + roomId: groupWithoutCustomFields._id, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.customFields.field1', 'value1'); + }); + }); + it('set customFields with multiple nested fields', async () => { + const customFields = { field2: 'value2', field3: 'value3', field4: 'value4' }; + + await request + .post(api('groups.setCustomFields')) + .set(credentials) + .send({ + roomName: groupWithoutCustomFields.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.customFields.field2', 'value2'); + expect(res.body).to.have.nested.property('group.customFields.field3', 'value3'); + expect(res.body).to.have.nested.property('group.customFields.field4', 'value4'); + expect(res.body).to.have.not.nested.property('group.customFields.field1', 'value1'); + }); + }); + it('set customFields to empty object', async () => { + const customFields = {}; + + await request + .post(api('groups.setCustomFields')) + .set(credentials) + .send({ + roomName: groupWithoutCustomFields.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.not.nested.property('group.customFields.field2', 'value2'); + expect(res.body).to.have.not.nested.property('group.customFields.field3', 'value3'); + expect(res.body).to.have.not.nested.property('group.customFields.field4', 'value4'); + }); + }); + it('set customFields as a string -> should return 400', (done) => { + const customFields = ''; + + void request + .post(api('groups.setCustomFields')) + .set(credentials) + .send({ + roomName: groupWithoutCustomFields.name, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + }); + + describe('/groups.delete', () => { + let testGroup: IRoom; + let testTeamGroup: IRoom; + let testModeratorTeamGroup: IRoom; + let invitedUser: TestUser; + let moderatorUser: TestUser; + let invitedUserCredentials: Credentials; + let moderatorUserCredentials: Credentials; + let teamId: ITeam['_id']; + let teamMainRoomId: IRoom['_id']; + + before(async () => { + testGroup = (await createRoom({ name: `group.test.${Date.now()}`, type: 'p' })).body.group; + invitedUser = await createUser(); + moderatorUser = await createUser(); + invitedUserCredentials = await login(invitedUser.username, password); + moderatorUserCredentials = await login(moderatorUser.username, password); + + const teamCreateRes = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `team-${Date.now()}`, + type: 1, + members: [invitedUser.username, moderatorUser.username], + }); + teamId = teamCreateRes.body.team._id; + teamMainRoomId = teamCreateRes.body.team.roomId; + + await updatePermission('delete-team-group', ['owner', 'moderator']); + await updatePermission('create-team-group', ['admin', 'owner', 'moderator', 'user']); + const teamGroupResponse = await createRoom({ + name: `group.test.${Date.now()}`, + type: 'p', + extraData: { teamId }, + credentials: invitedUserCredentials, + }); + testTeamGroup = teamGroupResponse.body.group; + + await request + .post(api('groups.addModerator')) + .set(credentials) + .send({ + userId: moderatorUser._id, + roomId: teamMainRoomId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + const teamModeratorGroupResponse = await createRoom({ + name: `group.test.moderator.${Date.now()}`, + type: 'p', + extraData: { teamId }, + credentials: moderatorUserCredentials, + }); + testModeratorTeamGroup = teamModeratorGroupResponse.body.group; + }); + after(async () => { + await deleteUser(invitedUser); + await deleteUser(moderatorUser); + await updatePermission('create-team-group', ['admin', 'owner', 'moderator']); + await updatePermission('delete-team-group', ['admin', 'owner', 'moderator']); + }); + it('should succesfully delete a group', async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + it(`should fail retrieving a group's info after it's been deleted`, async () => { + await request + .get(api('groups.info')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + it(`should fail deleting a team's group when member does not have the necessary permission in the team`, async () => { + await request + .post(api('groups.delete')) + .set(invitedUserCredentials) + .send({ + roomName: testTeamGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.a.property('error'); + expect(res.body).to.have.a.property('errorType', 'error-not-allowed'); + }); + }); + it(`should fail deleting a team's group when member has the necessary permission in the team, but not in the deleted room`, async () => { + await request + .post(api('groups.delete')) + .set(moderatorUserCredentials) + .send({ + roomName: testTeamGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.a.property('error'); + expect(res.body).to.have.a.property('errorType', 'error-room-not-found'); + }); + }); + it(`should successfully delete a team's group when member has both team and group permissions`, async () => { + await request + .post(api('groups.delete')) + .set(moderatorUserCredentials) + .send({ + roomId: testModeratorTeamGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('/groups.roles', () => { + let testGroup: IRoom; + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `group.roles.test.${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testGroup = res.body.group; + }); + }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('/groups.invite', async () => { + const res = await request + .post(api('groups.invite')) + .set(credentials) + .send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/groups.addModerator', async () => { + const res = await request + .post(api('groups.addModerator')) + .set(credentials) + .send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/groups.addLeader', async () => { + const res = await request + .post(api('groups.addLeader')) + .set(credentials) + .send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return an array of roles <-> user relationships in a private group', async () => { + const res = await request + .get(api('groups.roles')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('roles').that.is.an('array').that.has.lengthOf(2); + + expect(res.body.roles[0]).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[0]).to.have.a.property('rid').that.is.equal(testGroup._id); + expect(res.body.roles[0]).to.have.a.property('roles').that.is.an('array').that.includes('moderator', 'leader'); + expect(res.body.roles[0]).to.have.a.property('u').that.is.an('object'); + expect(res.body.roles[0].u).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[0].u).to.have.a.property('username').that.is.a('string'); + + expect(res.body.roles[1]).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[1]).to.have.a.property('rid').that.is.equal(testGroup._id); + expect(res.body.roles[1]).to.have.a.property('roles').that.is.an('array').that.includes('owner'); + expect(res.body.roles[1]).to.have.a.property('u').that.is.an('object'); + expect(res.body.roles[1].u).to.have.a.property('_id').that.is.a('string'); + expect(res.body.roles[1].u).to.have.a.property('username').that.is.a('string'); + }); + }); + + describe('/groups.moderators', () => { + let testGroup: IRoom; + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `group.roles.test.${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testGroup = res.body.group; + }); + }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('/groups.invite', async () => { + const res = await request + .post(api('groups.invite')) + .set(credentials) + .send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('/groups.addModerator', async () => { + const res = await request + .post(api('groups.addModerator')) + .set(credentials) + .send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return an array of moderators with rocket.cat as a moderator', async () => { + const res = await request + .get(api('groups.moderators')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('moderators').that.is.an('array').that.has.lengthOf(1); + expect(res.body.moderators[0].username).to.be.equal('rocket.cat'); + }); + }); + + describe('/groups.setEncrypted', () => { + let testGroup: IRoom; + + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `group.encrypted.test.${Date.now()}`, + }) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + + testGroup = res.body.group; + }); + + await updateSetting('E2E_Enable', true); + }); + + after(async () => { + await updateSetting('E2E_Enable', false); + + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should return an error when passing no boolean param', async () => { + const res = await request + .post(api('groups.setEncrypted')) + .set(credentials) + .send({ + roomId: testGroup._id, + encrypted: 'no-boolean', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'must be boolean'); + }); + + it('should set group as encrypted correctly and return the new data', async () => { + const res = await request + .post(api('groups.setEncrypted')) + .set(credentials) + .send({ + roomId: testGroup._id, + encrypted: true, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('group'); + expect(res.body.group).to.have.property('_id', testGroup._id); + expect(res.body.group).to.have.property('encrypted', true); + }); + + it('should return the updated room encrypted', async () => { + const roomInfo = await getRoomInfo(testGroup._id); + expect(roomInfo).to.have.a.property('success', true); + expect(roomInfo.group).to.have.a.property('_id', testGroup._id); + expect(roomInfo.group).to.have.a.property('encrypted', true); + }); + + it('should set group as unencrypted correctly and return the new data', async () => { + const res = await request + .post(api('groups.setEncrypted')) + .set(credentials) + .send({ + roomId: testGroup._id, + encrypted: false, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('group'); + expect(res.body.group).to.have.property('_id', testGroup._id); + expect(res.body.group).to.have.property('encrypted', false); + }); + + it('should return the updated room unencrypted', async () => { + const roomInfo = await getRoomInfo(testGroup._id); + expect(roomInfo).to.have.a.property('success', true); + expect(roomInfo.group).to.have.a.property('_id', testGroup._id); + expect(roomInfo.group).to.have.a.property('encrypted', false); + }); + }); + + describe('/groups.convertToTeam', () => { + let newGroup: IRoom; + + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ name: `group-${Date.now()}` }) + .expect(200) + .expect((response) => { + newGroup = response.body.group; + }); + }); + + after(() => { + assert.isDefined(newGroup.name); + return Promise.all([deleteTeam(credentials, newGroup.name), updatePermission('create-team', ['admin', 'user'])]); + }); + + it('should fail to convert group if lacking edit-room permission', async () => { + await updatePermission('create-team', []); + + await updatePermission('edit-room', ['admin']); + + const res = await request.post(api('groups.convertToTeam')).set(credentials).send({ roomId: newGroup._id }).expect(403); + + expect(res.body).to.have.a.property('success', false); + }); + + it('should fail to convert group if lacking create-team permission', async () => { + await updatePermission('create-team', ['admin']); + + await updatePermission('edit-room', []); + + const res = await request.post(api('groups.convertToTeam')).set(credentials).send({ roomId: newGroup._id }).expect(403); + + expect(res.body).to.have.a.property('success', false); + }); + + it('should successfully convert a group to a team', async () => { + await updatePermission('create-team', ['admin']); + + await updatePermission('edit-room', ['admin']); + + const res = await request.post(api('groups.convertToTeam')).set(credentials).send({ roomId: newGroup._id }).expect(200); + + expect(res.body).to.have.a.property('success', true); + }); + + it('should fail to convert group without the required parameters', (done) => { + void request.post(api('groups.convertToTeam')).set(credentials).send({}).expect(400).end(done); + }); + + it("should fail to convert group if it's already taken", async () => { + const res = await request.post(api('groups.convertToTeam')).set(credentials).send({ roomId: newGroup._id }).expect(400); + + expect(res.body).to.have.a.property('success', false); + }); + }); + + describe("Setting: 'Use Real Name': true", () => { + let realNameGroup: IRoom; + + before(async () => { + await updateSetting('UI_Use_Real_Name', true); + + await request + .post(api('groups.create')) + .set(credentials) + .send({ name: `group-${Date.now()}` }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + + realNameGroup = res.body.group; + }); + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid: realNameGroup._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await updateSetting('UI_Use_Real_Name', false); + + await request + .post(api('groups.delete')) + .set(credentials) + .send({ roomId: realNameGroup._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return the last message user real name', async () => { + const res = await request + .get(api('groups.info')) + .query({ + roomId: realNameGroup._id, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + const { group } = res.body; + + expect(group._id).to.be.equal(realNameGroup._id); + expect(group).to.have.nested.property('lastMessage.u.name', 'RocketChat Internal Admin Test'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/guest-permissions.ts b/apps/meteor/tests/end-to-end/api/guest-permissions.ts new file mode 100644 index 0000000..4aa4a58 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/guest-permissions.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { restorePermissionToRoles } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[Guest Permissions]', () => { + const guestPermissions = ['view-d-room', 'view-joined-room', 'view-p-room', 'start-discussion', 'mobile-upload-file']; + + before((done) => getCredentials(done)); + + after(() => Promise.all(guestPermissions.map((permissionName) => restorePermissionToRoles(permissionName)))); + + function succeedRemoveGuestPermission(permissionName: string) { + it(`should allow removing the whitelisted permission ${permissionName} from the guest role`, async () => { + const res = await request + .post(methodCall('authorization:removeRoleFromPermission')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'authorization:removeRoleFromPermission', + params: [permissionName, 'guest'], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + const message = JSON.parse(res.body.message); + expect(message).to.not.have.property('error'); + + const permissionsListRes = await request + .get(api('permissions.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(permissionsListRes.body).to.have.property('success', true); + expect(permissionsListRes.body).to.have.property('update').and.to.be.an('array'); + expect(permissionsListRes.body).to.have.property('remove').and.to.be.an('array'); + + const updatedPermission = permissionsListRes.body.update.find((permission: any) => permission._id === permissionName); + expect(updatedPermission).to.have.property('_id', permissionName); + expect(updatedPermission).to.have.property('roles').and.to.be.an('array').that.does.not.include('guest'); + }); + } + + function succeedAddGuestPermission(permissionName: string) { + it(`should allow granting the whitelisted permission ${permissionName} to the guest role`, async () => { + const res = await request + .post(methodCall('authorization:addPermissionToRole')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'authorization:addPermissionToRole', + params: [permissionName, 'guest'], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + const message = JSON.parse(res.body.message); + expect(message).to.not.have.property('error'); + + const permissionsListRes = await request + .get(api('permissions.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(permissionsListRes.body).to.have.property('success', true); + expect(permissionsListRes.body).to.have.property('update').and.to.be.an('array'); + expect(permissionsListRes.body).to.have.property('remove').and.to.be.an('array'); + + const updatedPermission = permissionsListRes.body.update.find((permission: any) => permission._id === permissionName); + expect(updatedPermission).to.have.property('_id', permissionName); + expect(updatedPermission).to.have.property('roles').and.to.be.an('array').that.includes('guest'); + }); + } + + describe('Default guest roles', () => { + guestPermissions.forEach((permissionName) => { + succeedRemoveGuestPermission(permissionName); + }); + + guestPermissions.forEach((permissionName) => { + succeedAddGuestPermission(permissionName); + }); + + it('should not allow adding a non whitelisted permission to the guest role', async () => { + const res = await request + .post(methodCall('authorization:addPermissionToRole')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'authorization:addPermissionToRole', + params: ['create-c', 'guest'], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('message'); + const message = JSON.parse(res.body.message); + expect(message).to.have.property('error'); + expect(message.error).to.have.property('reason', 'Permission is restricted'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/helpers/retry.ts b/apps/meteor/tests/end-to-end/api/helpers/retry.ts new file mode 100644 index 0000000..97b2e89 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/helpers/retry.ts @@ -0,0 +1,31 @@ +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Retry a given function N times until it succeeds. + * It should not be as workaround for eventual consistency, but in cases api calls have to be retried because of async tasks, it must be intentionally used. + * + * @param {Function} fn - Function that performs the assert/check. + * @param {number} retries - Number of retries. + * @param {number} delayMs - Delay between retries (ms). + */ +export async function retry(_description: string, fn: () => Promise | void, options: { retries?: number; delayMs?: number } = {}) { + const { retries = 3, delayMs = 10 } = options; + let lastError; + + await delay(delayMs); + for (let i = 0; i <= retries; i++) { + try { + const result = fn(); + if (result instanceof Promise) { + await result; + } + return; + } catch (err) { + lastError = err; + if (i < retries && delayMs > 0) { + await delay(delayMs); + } + } + } + + throw lastError; +} diff --git a/apps/meteor/tests/end-to-end/api/http-response-truncation.ts b/apps/meteor/tests/end-to-end/api/http-response-truncation.ts new file mode 100644 index 0000000..1a6a07a --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/http-response-truncation.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { apiUrl } from '../../data/api-data'; + +// Regression test for the dynamic-import response truncation flake (PR #41399). +// +// Through traefik with `serverstransport.maxidleconnsperhost=-1` (upstream +// keep-alive disabled), Go's transport rarely closes its upstream connection +// while the ReverseProxy is still copying a chunked gzip response body +// ("use of closed network connection"), truncating the response mid-stream: +// the browser gets 200 headers and then ERR_INCOMPLETE_CHUNKED_ENCODING, and +// the meteor module loader hangs the page. Rate is ~1.6e-4 per request under +// load, so this test replays the exact request shape (real dynamic-import +// bodies captured from a failing CI run — the fatal one is the same +// AppLayoutThemeWrapper.tsx request seen in every reproduction) at high +// volume and concurrency: red on the broken proxy config, green once +// upstream keep-alive is restored. +// +// The bodies are build-dependent fixtures: if the app tree renames these +// modules the responses shrink below the gzip threshold and lose the +// chunked+gzip shape the race needs, so the test asserts that shape first. + +const FETCH_URL = `${apiUrl}/__meteor__/dynamic-import/fetch`; + +const BODIES = [ + '{"client":{"components":{"AppLayoutThemeWrapper.tsx":1}}}', + '{"client":{"meteor":{"login":{"index.ts":1,"cas.ts":1,"crowd.ts":1,"facebook.ts":1,"oauth.ts":1,"LoginCancelledError.ts":1,"google.ts":1,"ldap.ts":1,"meteorDeveloperAccount.ts":1,"password.ts":1,"saml.ts":1,"twitter.ts":1}},"lib":{"2fa":{"overrideLoginMethod.ts":1},"wrapRequestCredentialFn.ts":1,"loginServices.ts":1}},"node_modules":{"@rocket.chat":{"string-helpers":{"package.json":1,"dist":{"esm":{"index.js":1}}}}}}', +]; + +const TOTAL_REQUESTS = 30000; +const CONCURRENCY = 64; + +const fetchModuleTree = async (body: string): Promise<{ error?: string }> => { + try { + const response = await fetch(FETCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body, + }); + if (response.status !== 200) { + return { error: `unexpected status ${response.status}` }; + } + await response.text(); + return {}; + } catch (error) { + return { error: `${error} (cause: ${(error as { cause?: unknown }).cause})` }; + } +}; + +describe('dynamic-import response delivery through the proxy', () => { + it('should keep the chunked gzip response shape the race depends on', async () => { + const response = await fetch(FETCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body: BODIES[1], + }); + const text = await response.text(); + expect(response.status).to.equal(200); + expect( + text.length, + 'fixture went stale: response too small to be gzip-chunked, update BODIES from a fresh browser trace', + ).to.be.greaterThan(10000); + }); + + it(`should deliver ${TOTAL_REQUESTS} concurrent dynamic-import responses without a single truncation`, async function () { + this.timeout(10 * 60 * 1000); + + const failures: string[] = []; + let issued = 0; + + const worker = async (): Promise => { + while (issued < TOTAL_REQUESTS && failures.length === 0) { + const current = issued++; + const { error } = await fetchModuleTree(BODIES[current % BODIES.length]); + if (error) { + failures.push(`request #${current}: ${error}`); + } + } + }; + + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + + expect(failures).to.deep.equal([]); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/import.spec.ts b/apps/meteor/tests/end-to-end/api/import.spec.ts new file mode 100644 index 0000000..055a862 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/import.spec.ts @@ -0,0 +1,216 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { sleep } from '../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { mockServerHealthy, mockServerReset, mockServerSet } from '../../data/mock-server.helper'; +import { getSettingValueById, updateSetting } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, login, deleteUser } from '../../data/users.helper'; +import { withTimeout } from '../../data/utils'; + +const IMPORT_MOCK_SERVER_URL = process.env.IMPORT_MOCK_SERVER_URL ?? 'http://mock-server.dev:8080'; + +describe('Imports', () => { + before((done) => getCredentials(done)); + + describe('[/getCurrentImportOperation]', () => { + it('should return the current import operation', async () => { + await request + .get(api('getCurrentImportOperation')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + expect(res.body.operation).not.be.null; + }); + }); + }); + describe('[/downloadPendingFiles]', () => { + it('should return the number of pending files', async () => { + await request + .post(api('downloadPendingFiles')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + expect(res.body.count).to.be.greaterThanOrEqual(0); + }); + }); + }); + describe('[/downloadPendingAvatars]', () => { + it('should return the number of pending avatars', async () => { + await request + .post(api('downloadPendingAvatars')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + expect(res.body.count).to.be.greaterThanOrEqual(0); + }); + }); + }); + describe('[/getLatestImportOperations]', () => { + let testUser: any = {}; + before(async () => { + testUser = await createUser(); + }); + let testCredentials: any = {}; + before(async () => { + testCredentials = await login(testUser.username, password); + }); + after(async () => { + await deleteUser(testUser); + testUser = undefined; + }); + + it('should fail if the user is not authorized', async () => { + await request + .get(api('getLatestImportOperations')) + .set(testCredentials) + .expect(403) + .expect((res: Response) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should return the latest import operation', async () => { + await request + .get(api('getLatestImportOperations')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.be.an('array'); + }); + }); + }); + + describe('[/getImportProgress]', () => { + it('should return the import progress', async () => { + await request + .get(api('getImportProgress')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + expect(res.body.key).to.be.an('string'); + expect(res.body.name).to.be.an('string'); + expect(res.body.step).to.be.an('string'); + expect(res.body.count).to.be.an('object'); + }); + }); + }); + + describe('[/getImportFileData]', () => { + it('should return the import file data', async () => { + await request + .get(api('getImportFileData')) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + expect(res.body.users).to.be.an('array'); + expect(res.body.channels).to.be.an('array'); + expect(res.body.message_count).to.greaterThanOrEqual(0); + }); + }); + }); + + describe('[/downloadPublicImportFile]', () => { + let previousSsrfAllowlist: Awaited>; + + const waitForImportStep = (expectedStep: string): Promise => + withTimeout(async (signal) => { + let step = ''; + while (!signal.aborted) { + const res = await request.get(api('getImportProgress')).set(credentials).expect(200); + step = res.body.step; + if (step === expectedStep || step === 'importer_import_failed') { + return step; + } + await sleep(100); + } + return step; + }, 10_000); + + before(async () => { + expect(await mockServerHealthy(), 'mock-server is not reachable — ensure it is running').to.be.true; + previousSsrfAllowlist = await getSettingValueById('SSRF_Allowlist'); + await Promise.all([mockServerReset(), updateSetting('SSRF_Allowlist', '')]); + }); + + after(async () => { + await Promise.all([ + mockServerReset(), + previousSsrfAllowlist !== undefined ? updateSetting('SSRF_Allowlist', previousSsrfAllowlist) : Promise.resolve(), + ]); + }); + + it('should reject a private target that is not on the SSRF allowlist and mark the operation as failed', async () => { + await request + .post(api('downloadPublicImportFile')) + .set(credentials) + .send({ fileUrl: 'http://127.0.0.1:3000/api/v1/info', importerKey: 'slack-users' }) + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.equal('error-ssrf-validation-failed'); + }); + + const progress = await request.get(api('getImportProgress')).set(credentials).expect(200); + expect(progress.body.step).to.equal('importer_import_failed'); + }); + + it('should download a file from an allowlisted private target', async () => { + await Promise.all([ + mockServerSet('GET', '/import-test.zip', { marker: 'downloaded-by-server-fetch' }), + updateSetting('SSRF_Allowlist', new URL(IMPORT_MOCK_SERVER_URL).host), + ]); + + await request + .post(api('downloadPublicImportFile')) + .set(credentials) + .send({ fileUrl: `${IMPORT_MOCK_SERVER_URL}/import-test.zip`, importerKey: 'csv' }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.true; + }); + + expect(await waitForImportStep('importer_file_loaded')).to.equal('importer_file_loaded'); + }); + }); + + describe('[/uploadImportFile]', () => { + let testUser: any = {}; + before(async () => { + testUser = await createUser(); + }); + let testCredentials: any = {}; + before(async () => { + testCredentials = await login(testUser.username, password); + }); + after(async () => { + await deleteUser(testUser); + testUser = undefined; + }); + + it('should fail if the user is not authorized', async () => { + await request + .post(api('uploadImportFile')) + .set(testCredentials) + .send({ + binaryContent: 'ZXJzLmNzdlBLBQYAAAAAAQABADcAAAAmAQAAAAA=', + contentType: 'application/zip', + fileName: 'users11.zip', + importerKey: 'csv', + }) + .expect(403) + .expect((res: Response) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts new file mode 100644 index 0000000..5a518c1 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -0,0 +1,1731 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import { TeamType } from '@rocket.chat/core-typings'; +import type { AtLeast, IIntegration, IMessage, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { assert, expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createIntegration, removeIntegration } from '../../data/integration.helper'; +import { addPermissions, updatePermission, updateSetting } from '../../data/permissions.helper'; +import { assignRoleToUser } from '../../data/roles.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login, removeRoleFromUser } from '../../data/users.helper'; + +describe('[Incoming Integrations]', () => { + let integration: IIntegration; + let integrationCreatedByAnUser: IIntegration; + let user: TestUser; + let userCredentials: Credentials; + let channel: IRoom; + let testChannelName: string; + + before((done) => getCredentials(done)); + + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + updatePermission('manage-outgoing-integrations', []), + ]); + + testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + + channel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + deleteRoom({ type: 'c', roomId: channel._id }), + deleteUser(user), + ]); + }); + + describe('[/integrations.create]', () => { + describe('Permission checks', () => { + before(async () => { + await Promise.all([updatePermission('manage-incoming-integrations', []), updatePermission('manage-own-incoming-integrations', [])]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should return an error when the user DOES NOT have the permission "manage-incoming-integrations" to add an incoming integration', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + + it('should return an error when the user DOES NOT have the permission "manage-own-incoming-integrations" to add an incoming integration', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + }); + + describe('With manage-incoming-integrations permission', () => { + let tempIntegrationId: IIntegration['_id']; + + before(async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + }); + + after(async () => { + if (tempIntegrationId) { + await removeIntegration(tempIntegrationId, 'incoming'); + } + }); + + it('should add the integration successfully when the user ONLY has the permission "manage-incoming-integrations" to add an incoming integration', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + tempIntegrationId = res.body.integration._id; + }); + + it('should set overrideDestinationChannelEnabled setting to false when it is not provided', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + expect(res.body.integration).to.have.property('overrideDestinationChannelEnabled', false); + const integrationId = res.body.integration._id; + await removeIntegration(integrationId, 'incoming'); + }); + }); + + describe('With manage-own-incoming-integrations permission', () => { + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should add the integration successfully when the user ONLY has the permission "manage-own-incoming-integrations" to add an incoming integration', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test 2', + enabled: true, + alias: 'test2', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integration = res.body.integration; + }); + }); + + describe('With both manage-own-incoming-integrations and manage-incoming-integrations', () => { + it('should not allow users without a role with the message-impersonate permission to be assigned to new incoming integrations', async () => { + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: adminUsername, + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + "User selected for the incoming integration lacks the 'message-impersonate' permission. [error-user-lacks-message-impersonate-permission]", + ); + }); + }); + }); + + describe('Incoming Integration execution', () => { + it('should return an error when the user sends an invalid type of integration', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming-invalid', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: '#general', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid integration type.'); + }); + + it('should execute the incoming integration', async () => { + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + }) + .expect(200); + }); + + it("should return an error when sending 'channel' field telling its configuration is disabled", async () => { + const res = await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + channel: [testChannelName], + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'overriding destination channel is disabled for this integration'); + }); + + it("should return an error when sending 'roomId' field telling its configuration is disabled", async () => { + const res = await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + roomId: channel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'overriding destination channel is disabled for this integration'); + }); + it('should send a message for a channel that is specified in the webhooks configuration', (done) => { + const successfulMessage = `Message sent successfully at #${Date.now()}`; + void request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: successfulMessage, + }) + .expect(200) + .end(() => { + return request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.true; + }) + .end(done); + }); + }); + it('should send a message for a channel that is not specified in the webhooks configuration', async () => { + await request + .put(api('integrations.update')) + .set(credentials) + .send({ + type: 'webhook-incoming', + overrideDestinationChannelEnabled: true, + integrationId: integration._id, + username: 'rocket.cat', + channel: '#general', + scriptEnabled: true, + enabled: true, + name: integration.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration.overrideDestinationChannelEnabled).to.be.equal(true); + }); + const successfulMessage = `Message sent successfully at #${Date.now()}`; + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: successfulMessage, + channel: [testChannelName], + }) + .expect(200); + + return request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.true; + }); + }); + + it('should send a message if the payload is a application/x-www-form-urlencoded JSON', async () => { + const payload = { msg: `Message as x-www-form-urlencoded JSON sent successfully at #${Date.now()}` }; + + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(`payload=${JSON.stringify(payload)}`) + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === payload.msg)).to.be.true; + }); + + describe('When the user does not have a role with the message-impersonate permission', () => { + before(async () => { + await addPermissions({ + 'message-impersonate': ['user'], + }); + }); + + after(async () => { + await addPermissions({ + 'message-impersonate': ['bot', 'app'], + }); + }); + + it('should return an error when executing incoming integration', async () => { + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: 'Example message', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-lacks-message-impersonate-permission'); + }); + }); + }); + }); + + describe('Script integration tests', () => { + let withScript: IIntegration; + let withScriptDefaultContentType: IIntegration; + let withSkipTranspile: IIntegration; + + const sloppyModeScript = + 'const buildMessage = (obj) => {\n' + + ' \n' + + ' const template = `[#VALUE](${ obj.test })`;\n' + + ' \n' + + ' return {\n' + + ' text: template\n' + + ' };\n' + + ' };\n' + + ' \n' + + ' class Script {\n' + + ' process_incoming_request({ request }) {\n' + + ' msg = buildMessage(request.content);\n' + + ' \n' + + ' return {\n' + + ' content:{\n' + + ' text: msg.text\n' + + ' }\n' + + ' };\n' + + ' }\n' + + ' }\n' + + ' \n'; + + before(async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + + const res1 = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test with script', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: true, + overrideDestinationChannelEnabled: false, + channel: '#general', + script: ` + class Script { + process_incoming_request({ request }) { + return { + content:{ + text: request.content.text + } + }; + } + } + `, + }) + .expect(200); + withScript = res1.body.integration; + + const res2 = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test with script and default content-type', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: true, + overrideDestinationChannelEnabled: false, + channel: '#general', + script: sloppyModeScript, + }) + .expect(200); + withScriptDefaultContentType = res2.body.integration; + + // Same script but with skipTranspile: true — no Babel, class methods + // run in strict mode so `msg = buildMessage(...)` throws ReferenceError. + const res3 = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test with skipTranspile', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: true, + skipTranspile: true, + overrideDestinationChannelEnabled: false, + channel: '#general', + script: sloppyModeScript, + }) + .expect(200); + withSkipTranspile = res3.body.integration; + }); + + after(async () => { + await Promise.all([ + removeIntegration(withScript._id, 'incoming'), + removeIntegration(withScriptDefaultContentType._id, 'incoming'), + removeIntegration(withSkipTranspile._id, 'incoming'), + ]); + }); + + it('should send a message if the payload is a application/x-www-form-urlencoded JSON AND the integration has a valid script', async () => { + const payload = { text: `Message as x-www-form-urlencoded JSON sent successfully at #${Date.now()}` }; + + await request + .post(`/hooks/${withScript._id}/${withScript.token}`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .send(`payload=${JSON.stringify(payload)}`) + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === payload.text)).to.be.true; + }); + + it('should send a message if the payload is application/json and the integration has a valid script', async () => { + const payload = { test: 'test' }; + + await request + .post(`/hooks/${withScriptDefaultContentType._id}/${withScriptDefaultContentType.token}`) + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === '[#VALUE](test)')).to.be.true; + }); + + it('should create the skipTranspile integration with scriptCompiled and no scriptError', () => { + expect(withSkipTranspile).to.have.property('scriptCompiled'); + expect(withSkipTranspile).to.not.have.property('scriptError'); + expect(withSkipTranspile).to.have.property('skipTranspile', true); + }); + + it('should fail to execute the same sloppy-mode script when skipTranspile is true', async () => { + const payload = { test: 'test' }; + + await request + .post(`/hooks/${withSkipTranspile._id}/${withSkipTranspile.token}`) + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(400); + }); + }); + + describe('With manage-own-incoming-integrations permission', () => { + let integrationId: string; + + before(async () => { + await updatePermission('manage-own-incoming-integrations', ['admin']); + }); + + after(async () => { + if (integrationId) { + await removeIntegration(integrationId, 'incoming'); + } + }); + + it('should return scriptCompiled and no scriptError', async () => { + const res = await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: true, + scriptEngine: 'isolated-vm', + channel: '#general', + script: ` + class Script { + process_incoming_request({ request }) { + return { + content:{ + text: request.content.text + } + }; + } + } + `, + }) + .expect(200); + + expect(res.body.integration).to.have.property('scriptCompiled'); + expect(res.body.integration).to.not.have.property('scriptError'); + + integrationId = res.body.integration._id; + }); + }); + }); + + describe('[/integrations.history]', () => { + before(async () => { + await Promise.all([updatePermission('manage-incoming-integrations', []), updatePermission('manage-own-incoming-integrations', [])]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should return an error when trying to get history of incoming integrations if user does NOT have enough permissions', async () => { + const res = await request + .get(api('integrations.history')) + .set(credentials) + .query({ + id: integration._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('[/integrations.list]', () => { + before((done) => { + void createUser().then((createdUser) => { + user = createdUser; + void login(user.username, password).then((credentials) => { + userCredentials = credentials; + void updatePermission('manage-incoming-integrations', ['user']).then(() => { + void createIntegration( + { + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: '#general', + }, + userCredentials, + ).then((integration) => { + integrationCreatedByAnUser = integration; + done(); + }); + }); + }); + }); + }); + + it('should return the list of incoming integrations', async () => { + const res = await request.get(api('integrations.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + const integrationCreatedByAdmin = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integration._id, + ); + assert.isDefined(integrationCreatedByAdmin); + expect(integrationCreatedByAdmin).to.be.an('object'); + expect(integrationCreatedByAdmin._id).to.be.equal(integration._id); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('items'); + expect(res.body).to.have.property('total'); + }); + + describe('With manage-own-incoming-integrations permission', () => { + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', ['user']), + ]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should return the list of integrations created by the user only', async () => { + const res = await request.get(api('integrations.list')).set(userCredentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + const integrationCreatedByAdmin = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integration._id, + ); + expect(integrationCreatedByAdmin).to.be.equal(undefined); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('items'); + expect(res.body).to.have.property('total'); + }); + }); + + describe('Without any permissions', () => { + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + ]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + ]); + }); + + it('should return unauthorized error when the user does not have any integrations permissions', async () => { + await request + .get(api('integrations.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + }); + + describe('[/integrations.get]', () => { + describe('Invalid params', () => { + it('should return an error when the required "integrationId" query parameters is not sent', async () => { + const res = await request.get(api('integrations.get')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `must have required property 'integrationId'`); + }); + + it('should return an error when the user sends an invalid integration', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The integration does not exists.'); + }); + }); + + describe('Without permissions', () => { + before(async () => { + await Promise.all([updatePermission('manage-incoming-integrations', []), updatePermission('manage-own-incoming-integrations', [])]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should return an error when the user DOES NOT have the permission "manage-incoming-integrations" to get an incoming integration', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: integration._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'not-authorized'); + }); + + it('should return an error when the user DOES NOT have the permission "manage-incoming-integrations" to get an incoming integration created by another user', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: integrationCreatedByAnUser._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'not-authorized'); + }); + }); + + describe('With manage-incoming-integrations permission', () => { + before(async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + }); + + it('should return the integration successfully', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: integration._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integration._id); + }); + }); + + describe('With manage-own-incoming-integrations permission', () => { + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', ['user']), + ]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + ]); + }); + + it('should return the integration successfully when the user is able to see only your own integrations', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: integrationCreatedByAnUser._id }) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integrationCreatedByAnUser._id); + }); + }); + }); + + describe('[/integrations.update]', () => { + let senderUser: IUser; + let sendUserCredentials: Credentials; + + before(async () => { + await addPermissions({ + 'message-impersonate': ['user'], + }); + senderUser = await createUser(); + sendUserCredentials = await login(senderUser.username, password); + }); + + after(async () => { + await addPermissions({ + 'message-impersonate': ['bot', 'app'], + }); + await deleteUser(senderUser); + }); + + it('should update an integration by id and return the new data', async () => { + const res = await request + .put(api('integrations.update')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test updated', + enabled: true, + alias: 'test updated', + username: senderUser.username, + scriptEnabled: true, + overrideDestinationChannelEnabled: true, + channel: '#general', + integrationId: integration._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integration._id); + expect(res.body.integration.name).to.be.equal('Incoming test updated'); + expect(res.body.integration.alias).to.be.equal('test updated'); + integration = res.body.integration; + }); + + it('should not allow users without a role with the message-impersonate permission to be assigned to existing incoming integrations', async () => { + await request + .put(api('integrations.update')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test update attempt', + enabled: true, + alias: 'test update attempt', + username: 'rocket.cat', + scriptEnabled: true, + overrideDestinationChannelEnabled: true, + channel: '#general', + integrationId: integration._id, + }) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + "User selected for the incoming integration lacks the 'message-impersonate' permission. [error-user-lacks-message-impersonate-permission]", + ); + }); + }); + + it('should have integration updated on subsequent gets', async () => { + const res = await request + .get(api('integrations.get')) + .query({ integrationId: integration._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integration._id); + expect(res.body.integration.name).to.be.equal('Incoming test updated'); + expect(res.body.integration.alias).to.be.equal('test updated'); + }); + + it("should update an integration's username and associated userId correctly and return the new data", async () => { + await request + .put(api('integrations.update')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test updated x2', + enabled: true, + alias: 'test updated x2', + username: senderUser.username, + scriptEnabled: true, + overrideDestinationChannelEnabled: true, + channel: '#general', + integrationId: integration._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integration._id); + expect(res.body.integration.name).to.be.equal('Incoming test updated x2'); + expect(res.body.integration.alias).to.be.equal('test updated x2'); + expect(res.body.integration.username).to.be.equal(senderUser.username); + expect(res.body.integration.userId).to.be.equal(sendUserCredentials['X-User-Id']); + integration = res.body.integration; + }); + }); + + it('should send messages to the channel under the updated username', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration._id}/${integration.token}`) + .send({ + text: successfulMessage, + }) + .expect(200); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const message = (res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage); + expect(message?.u).have.property('username', senderUser.username); + }); + }); + }); + + describe('[/integrations.remove]', () => { + describe('Without permissions', () => { + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + ]); + }); + + after(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + ]); + }); + + it('should return an error when the user DOES NOT have the permission "manage-incoming-integrations" to remove an incoming integration', async () => { + const res = await request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'webhook-incoming', + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return an error when the user DOES NOT have the permission "manage-own-incoming-integrations" to remove an incoming integration', async () => { + const res = await request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'webhook-incoming', + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('Invalid params', () => { + before(async () => { + await updatePermission('manage-own-incoming-integrations', ['admin']); + }); + + it('should return an error when the user sends an invalid type of integration', async () => { + const res = await request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'invalid-type', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').include(`must match exactly one schema in oneOf`); + }); + }); + + describe('With manage-incoming-integrations permission', () => { + before(async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + }); + + it('should remove the integration successfully when the user at least one of the necessary permission to remove an incoming integration', async () => { + const res = await request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: integration.type, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('Normal user with manage-own-incoming-integrations', () => { + before(async () => { + await updatePermission('manage-own-incoming-integrations', ['user']); + }); + + after(async () => { + await updatePermission('manage-own-incoming-integrations', ['admin']); + }); + + it('the normal user should remove the integration successfully when the user have the "manage-own-incoming-integrations" to remove an incoming integration', async () => { + const res = await request + .post(api('integrations.remove')) + .set(userCredentials) + .send({ + integrationId: integrationCreatedByAnUser._id, + type: integrationCreatedByAnUser.type, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('Additional Tests for Message Delivery, Permissions, and Authentication', () => { + let nonMemberUser: IUser; + let privateTeam: ITeam; + let publicChannelInPrivateTeam: IRoom; + let privateRoom: IRoom; + let publicRoom: IRoom; + let integration2: IIntegration; + let integration3: IIntegration; + let integration4: IIntegration; + let integrationMixed1: IIntegration; + let integrationMixed2: IIntegration; + + before(async () => { + nonMemberUser = await createUser({ username: `g_${Random.id()}`, roles: ['bot'] }); + privateTeam = await createTeam(credentials, `private.team.${Random.id()}`, TeamType.PRIVATE); + await assignRoleToUser({ username: adminUsername, roleId: 'bot' }); + + const [publicInPrivateResponse, privateRoomResponse, publicRoomResponse] = await Promise.all([ + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Random.id()}`, + credentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `privateChannel.${Random.id()}`, + credentials, + }), + createRoom({ + type: 'c', + name: `publicChannel.${Random.id()}`, + credentials, + }), + ]); + + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateRoom = privateRoomResponse.body.group; + publicRoom = publicRoomResponse.body.channel; + + await updatePermission('manage-incoming-integrations', ['admin']); + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test 2 - Sending Messages', + enabled: true, + alias: 'Incoming test 2 - Sending Messages', + username: nonMemberUser.username as string, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${privateRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integration2 = res.body.integration; + }); + + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test 3 - Sending Messages', + enabled: true, + alias: 'Incoming test 3 - Sending Messages', + username: nonMemberUser.username as string, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicChannelInPrivateTeam.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integration3 = res.body.integration; + }); + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test 4 - Sending Messages', + enabled: true, + alias: 'Incoming test 4 - Sending Messages', + username: nonMemberUser.username as string, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integration4 = res.body.integration; + }); + + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test Mixed - Sending Messages', + enabled: true, + alias: 'Incoming test Mixed - Sending Messages', + username: nonMemberUser.username as string, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicRoom.fname}, #${privateRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integrationMixed1 = res.body.integration; + }); + + await request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-incoming', + name: 'Incoming test Mixed - Sending Messages', + enabled: true, + alias: 'Incoming test Mixed - Sending Messages', + username: adminUsername, + scriptEnabled: false, + overrideDestinationChannelEnabled: false, + channel: `#${publicRoom.fname}, #${privateRoom.fname}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integrationMixed2 = res.body.integration; + }); + }); + + after(async () => { + await Promise.all( + [publicRoom, privateRoom, publicChannelInPrivateTeam].map((room) => deleteRoom({ type: room.t as 'c' | 'p', roomId: room._id })), + ); + await deleteTeam(credentials, privateTeam.name); + await deleteUser(nonMemberUser); + await Promise.all([ + ...[integration2, integration3, integration4, integrationMixed1, integrationMixed2].map((integration) => + request.post(api('integrations.remove')).set(credentials).send({ + integrationId: integration._id, + type: integration.type, + }), + ), + updatePermission('manage-incoming-integrations', ['admin']), + ]); + await removeRoleFromUser(adminUsername, 'bot'); + }); + + it('should not send a message in public room if token is invalid', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration4._id}/invalid-token`) + .send({ + text: successfulMessage, + }) + .expect(500) + .expect((res) => { + expect(res.text).to.be.equal('Internal Server Error'); + }); + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.undefined; + }); + }); + + it('should not send a message in private room if token is invalid', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration2._id}/invalid-token`) + .send({ + text: successfulMessage, + }) + .expect(500) + .expect((res) => { + expect(res.text).to.be.equal('Internal Server Error'); + }); + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: privateRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.undefined; + }); + }); + + it('should not send a message to a private rooms on behalf of a non member', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration2._id}/${integration2.token}`) + .send({ + text: successfulMessage, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-not-allowed'); + }); + await request + .get(api('groups.messages')) + .set(credentials) + .query({ + roomId: privateRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.undefined; + }); + }); + + it('should not add non member to private rooms when sending message', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration2._id}/${integration2.token}`) + .send({ + text: successfulMessage, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-not-allowed'); + }); + await request + .get(api('groups.members')) + .set(credentials) + .query({ + roomId: privateRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect((res.body.members as AtLeast[]).find((m) => m._id === nonMemberUser._id)).to.be.undefined; + }); + }); + + it('should not send a message to public channel of a private team on behalf of a non team member', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration3._id}/${integration3.token}`) + .send({ + text: successfulMessage, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-not-allowed'); + }); + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicChannelInPrivateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).to.be.undefined; + }); + }); + + it('should not add non team member to the public channel in a private team when sending message', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration3._id}/${integration3.token}`) + .send({ + text: successfulMessage, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-not-allowed'); + }); + await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: publicChannelInPrivateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect((res.body.members as AtLeast[]).find((m) => m._id === nonMemberUser._id)).to.be.undefined; + }); + }); + + it('should send messages from non-members to public rooms and add them as room members', async () => { + const successfulMessage = `Message sent successfully at #${Random.id()}`; + await request + .post(`/hooks/${integration4._id}/${integration4.token}`) + .send({ + text: successfulMessage, + }) + .expect(200); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === successfulMessage)).not.to.be.undefined; + }); + + await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: publicRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect((res.body.members as AtLeast[]).find((m) => m._id === nonMemberUser._id)).not.to.be.undefined; + }); + }); + + describe('Message Settings', async () => { + const maxSize = 5000; + before(() => updateSetting('Message_MaxAllowedSize', maxSize)); + after(() => updateSetting('Message_MaxAllowedSize', maxSize)); + + it('should not send a message if message size is greater than the Message_MaxAllowedSize', async () => { + const largeMesssage = Array.from({ length: maxSize + 1 }) + .map(() => 'A') + .join(''); + await request + .post(`/hooks/${integration4._id}/${integration4.token}`) + .send({ + text: largeMesssage, + }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.be.equal('error-message-size-exceeded'); + }); + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === largeMesssage)).to.be.undefined; + }); + }); + + it('should send a message if message size is less than the Message_MaxAllowedSize', async () => { + const smallerMessage = Array.from({ length: maxSize - 1 }) + .map(() => 'A') + .join(''); + await request + .post(`/hooks/${integration4._id}/${integration4.token}`) + .send({ + text: smallerMessage, + }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.equal(true); + }); + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + expect((res.body.messages as IMessage[]).find((m) => m.msg === smallerMessage)).to.not.be.undefined; + }); + }); + }); + + describe('Multiple channels delivery', () => { + it('should not return separate responses when separateResponse is not provided', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.not.have.property('responses'); + }); + }); + + it('should not return separate responses when separateResponse = false', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: false, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.not.have.property('responses'); + }); + }); + + it('should return separate responses when separateResponse = true', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.responses).to.be.an('array').and.to.have.lengthOf(2); + const publicResponse = res.body.responses.find((r: any) => r.channel === `#${publicRoom.fname}`); + const privateResponse = res.body.responses.find((r: any) => r.channel === `#${privateRoom.fname}`); + expect(publicResponse).not.to.have.property('error'); + expect(privateResponse).to.have.property('error'); + }); + }); + + it('should not deliver to any channel if one fails when separateResponse is not provided', async () => { + const testMsg = `msg-${Random.id()}`; + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + }); + + it('should not deliver to any channel if one fails when separateResponse = false', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: false, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + }); + + it('should send message to only public channel when separateResponse = true', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed1._id}/${integrationMixed1.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + expect((res.body.messages as IMessage[]).find((m) => m.msg === testMsg)).to.be.undefined; + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + }); + + it('should send messages to all channels when message could be delivered to all channels', async () => { + const testMsg = `msg-${Random.id()}`; + + await request + .post(`/hooks/${integrationMixed2._id}/${integrationMixed2.token}`) + .send({ + text: testMsg, + separateResponse: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.responses).to.be.an('array').and.to.have.lengthOf(2); + const publicResponse = res.body.responses.find((r: any) => r.channel === `#${publicRoom.fname}`); + const privateResponse = res.body.responses.find((r: any) => r.channel === `#${privateRoom.fname}`); + expect(publicResponse).not.to.have.property('error'); + expect(privateResponse).not.to.have.property('error'); + }); + + await request + .get(api('groups.messages')) + .set(credentials) + .query({ roomId: privateRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ roomId: publicRoom._id }) + .expect(200) + .expect((res) => { + const found = (res.body.messages as IMessage[]).filter((m) => m.msg === testMsg); + expect(found).to.have.lengthOf(1); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/invites.ts b/apps/meteor/tests/end-to-end/api/invites.ts new file mode 100644 index 0000000..877486d --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/invites.ts @@ -0,0 +1,260 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IInvite, IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('Invites', () => { + let testInviteID: IInvite['_id']; + + before((done) => getCredentials(done)); + describe('POST [/findOrCreateInvite]', () => { + it('should fail if not logged in', async () => { + const res = await request + .post(api('findOrCreateInvite')) + .send({ + rid: 'GENERAL', + days: 1, + maxUses: 10, + }) + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if invalid roomid', async () => { + const res = await request + .post(api('findOrCreateInvite')) + .set(credentials) + .send({ + rid: 'invalid', + days: 1, + maxUses: 10, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + }); + + it('should create an invite for GENERAL', async () => { + const res = await request + .post(api('findOrCreateInvite')) + .set(credentials) + .send({ + rid: 'GENERAL', + days: 1, + maxUses: 10, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('days', 1); + expect(res.body).to.have.property('maxUses', 10); + expect(res.body).to.have.property('uses'); + expect(res.body).to.have.property('_id'); + testInviteID = res.body._id; + }); + + it('should return an existing invite for GENERAL', async () => { + const res = await request + .post(api('findOrCreateInvite')) + .set(credentials) + .send({ + rid: 'GENERAL', + days: 1, + maxUses: 10, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('days', 1); + expect(res.body).to.have.property('maxUses', 10); + expect(res.body).to.have.property('uses'); + expect(res.body).to.have.property('_id', testInviteID); + }); + }); + + describe('GET [/listInvites]', () => { + it('should fail if not logged in', async () => { + const res = await request.get(api('listInvites')).expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return the existing invite for GENERAL', async () => { + const res = await request.get(api('listInvites')).set(credentials).expect(200); + + expect(res.body[0]).to.have.property('_id', testInviteID); + }); + }); + + describe('POST [/useInviteToken]', () => { + it('should fail if not logged in', async () => { + const res = await request.post(api('useInviteToken')).expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if invalid token', async () => { + const res = await request + .post(api('useInviteToken')) + .set(credentials) + .send({ + token: 'invalid', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-token'); + }); + + it('should fail if missing token', async () => { + const res = await request.post(api('useInviteToken')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + + it('should use the existing invite for GENERAL', async () => { + const res = await request + .post(api('useInviteToken')) + .set(credentials) + .send({ + token: testInviteID, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('POST [/validateInviteToken]', () => { + it('should warn if invalid token', async () => { + const res = await request + .post(api('validateInviteToken')) + .set(credentials) + .send({ + token: 'invalid', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }); + + it('should succeed when valid token', async () => { + const res = await request + .post(api('validateInviteToken')) + .set(credentials) + .send({ + token: testInviteID, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', true); + }); + }); + + describe('POST [/useInviteToken] - banned user', () => { + let room: IRoom; + let bannedUser: TestUser; + let bannedUserCredentials: Credentials; + let inviteId: IInvite['_id']; + + before(async () => { + bannedUser = await createUser(); + bannedUserCredentials = await login(bannedUser.username, password); + + const result = await createRoom({ type: 'p', name: `invite-ban-test-${Date.now()}` }); + room = result.body.group; + + // Add user then ban them + await request.post(api('groups.invite')).set(credentials).send({ roomId: room._id, userId: bannedUser._id }).expect(200); + await request.post(api('rooms.banUser')).set(credentials).send({ roomId: room._id, userId: bannedUser._id }).expect(200); + + // Create invite link for the room + const invite = await request + .post(api('findOrCreateInvite')) + .set(credentials) + .send({ rid: room._id, days: 1, maxUses: 10 }) + .expect(200); + inviteId = invite.body._id; + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(bannedUser); + }); + + it('should fail if user is banned from the room', async () => { + await request + .post(api('useInviteToken')) + .set(bannedUserCredentials) + .send({ token: inviteId }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-is-banned'); + }); + }); + + it('should succeed after the user is unbanned', async () => { + await request.post(api('rooms.unbanUser')).set(credentials).send({ roomId: room._id, userId: bannedUser._id }).expect(200); + + await request + .post(api('useInviteToken')) + .set(bannedUserCredentials) + .send({ token: inviteId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.have.property('rid', room._id); + }); + }); + }); + + describe('DELETE [/removeInvite]', () => { + it('should fail if not logged in', async () => { + const res = await request.delete(api(`removeInvite/${testInviteID}`)).expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if invalid token', async () => { + const res = await request.delete(api('removeInvite/invalid')).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-invitation-id'); + }); + + it('should succeed when valid token', async () => { + const res = await request + .delete(api(`removeInvite/${testInviteID}`)) + .set(credentials) + .expect(200); + + expect(res.body).to.equal(true); + }); + + it('should fail when deleting the same invite again', async () => { + const res = await request + .delete(api(`removeInvite/${testInviteID}`)) + .set(credentials) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-invitation-id'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/licenses.ts b/apps/meteor/tests/end-to-end/api/licenses.ts new file mode 100644 index 0000000..bd5a3d4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/licenses.ts @@ -0,0 +1,99 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('licenses', () => { + let createdUser: TestUser; + + before((done) => getCredentials(done)); + let unauthorizedUserCredentials: Credentials; + + before(async () => { + createdUser = await createUser(); + unauthorizedUserCredentials = await login(createdUser.username, password); + }); + + after(() => deleteUser(createdUser)); + + describe('[/licenses.add]', () => { + it('should fail if not logged in', async () => { + const res = await request + .post(api('licenses.add')) + .send({ + license: '', + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if user is unauthorized', async () => { + const res = await request + .post(api('licenses.add')) + .set(unauthorizedUserCredentials) + .send({ + license: '', + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should fail if license is invalid', async () => { + const res = await request + .post(api('licenses.add')) + .set(credentials) + .send({ + license: '', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + describe('[/licenses.info]', () => { + it('should fail if not logged in', async () => { + const res = await request.get(api('licenses.info')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return limited information if user is unauthorized', async () => { + const res = await request + .get(api('licenses.info')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.not.have.property('license'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); + }); + + it('should return unrestricted info if user is logged in and is authorized', async () => { + const res = await request.get(api('licenses.info')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('license').and.to.be.an('object'); + if (process.env.IS_EE) { + expect(res.body.license).to.have.property('license').and.to.be.an('object'); + } + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts new file mode 100644 index 0000000..a660163 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -0,0 +1,3796 @@ +import fs from 'fs'; +import path from 'path'; + +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { + IOmnichannelRoom, + ILivechatVisitor, + IOmnichannelSystemMessage, + ILivechatPriority, + ILivechatDepartment, + ISubscription, + IOmnichannelBusinessUnit, + IUser, +} from '@rocket.chat/core-typings'; +import { LivechatPriorityWeight } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import type { SuccessResult } from '../../../../server/api/definition'; +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { apps, APP_URL } from '../../../data/apps/apps-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import type { OnlineAgent } from '../../../data/livechat/department'; +import { + createDepartmentWith2OnlineAgents, + createDepartmentWithAnOfflineAgent, + createDepartmentWithAnOnlineAgent, + deleteDepartment, +} from '../../../data/livechat/department'; +import { createSLA, getRandomPriority } from '../../../data/livechat/priorities'; +import { + createVisitor, + createLivechatRoom, + createAgent, + makeAgentAvailable, + getLivechatRoomInfo, + sendMessage, + startANewLivechatRoomAndTakeIt, + createManager, + closeOmnichannelRoom, + createDepartment, + fetchMessages, + deleteVisitor, + makeAgentUnavailable, + sendAgentMessage, + fetchInquiry, + takeInquiry, +} from '../../../data/livechat/rooms'; +import { saveTags } from '../../../data/livechat/tags'; +import { createMonitor, createUnit, deleteUnit } from '../../../data/livechat/units'; +import type { DummyResponse } from '../../../data/livechat/utils'; +import { sleep } from '../../../data/livechat/utils'; +import { + restorePermissionToRoles, + addPermissions, + removePermissionFromAllRoles, + updateEEPermission, + updatePermission, + updateSetting, + updateEESetting, +} from '../../../data/permissions.helper'; +import { adminUsername, password } from '../../../data/user'; +import type { TestUser } from '../../../data/users.helper'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +const getSubscriptionForRoom = async (roomId: string, overrideCredential?: Credentials): Promise => { + const response = await request + .get(api('subscriptions.getOne')) + .set(overrideCredential || credentials) + .query({ roomId }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { subscription } = response.body; + + return subscription; +}; + +describe('LIVECHAT - rooms', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + let appId: string; + + before((done) => getCredentials(done)); + + before(async () => { + if (IS_EE) { + // install the app + await request + .post(apps()) + .set(credentials) + .send({ url: APP_URL }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('app'); + expect(res.body.app).to.have.a.property('id'); + expect(res.body.app).to.have.a.property('version'); + expect(res.body.app).to.have.a.property('status').and.to.be.equal('manually_enabled'); + + appId = res.body.app.id; + }); + } + + await updateSetting('Livechat_enabled', true); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await updateSetting('Omnichannel_enable_department_removal', true); + await createAgent(); + await makeAgentAvailable(); + + visitor = await createVisitor(); + + room = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + + if (IS_EE) { + await request + .delete(apps(`/${appId}`)) + .set(credentials) + .expect(200); + } + }); + + describe('livechat/room', () => { + it('should fail when token is not passed as query parameter', async () => { + await request.get(api('livechat/room')).expect(400); + }); + it('should fail when token is not a valid guest token', async () => { + await request.get(api('livechat/room')).query({ token: 'invalid-token' }).expect(400); + }); + it('should fail if rid is passed but doesnt point to a valid room', async () => { + const visitor = await createVisitor(); + await request.get(api('livechat/room')).query({ token: visitor.token, rid: 'invalid-rid' }).expect(400); + await deleteVisitor(visitor.token); + }); + (IS_EE ? it : it.skip)('should prevent create a room for visitor if an app throws an error', async () => { + // this test relies on the app installed by the insertApp fixture + // TODO: this visitor should be created on before block and deleted on after block + const visitor = await createVisitor(undefined, 'visitor prevent from app'); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }); + + expect(body).to.have.property('success', false); + await deleteVisitor(visitor.token); + }); + it('should create a room for visitor', async () => { + // TODO: this visitor should be created on before block and deleted on after block + const visitor = await createVisitor(); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('room'); + expect(body.room).to.have.property('v'); + expect(body.room.v).to.have.property('token', visitor.token); + expect(body.room.source.type).to.be.equal('api'); + await closeOmnichannelRoom(body.room._id); + await deleteVisitor(visitor.token); + }); + it('should return an existing open room when visitor has one available', async () => { + // TODO: this visitor should be created on before block and deleted on after block + const visitor = await createVisitor(); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('room'); + expect(body.room).to.have.property('v'); + expect(body.room.v).to.have.property('token', visitor.token); + + const { body: body2 } = await request.get(api('livechat/room')).query({ token: visitor.token }); + + expect(body2).to.have.property('success', true); + expect(body2).to.have.property('room'); + expect(body2.room).to.have.property('_id', body.room._id); + expect(body2.newRoom).to.be.false; + await closeOmnichannelRoom(body.room._id); + await deleteVisitor(visitor.token); + }); + it('should return a room for the visitor when rid points to a valid open room', async () => { + // TODO: this visitor should be created on before block and deleted on after block + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token, rid: room._id }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('room'); + expect(body.room.v).to.have.property('token', visitor.token); + expect(body.newRoom).to.be.false; + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should properly read widget cookies', async () => { + // TODO: this visitor should be created on before block and deleted on after block + const visitor = await createVisitor(); + const { body } = await request + .get(api('livechat/room')) + .set('Cookie', [`rc_room_type=l`, `rc_is_widget=t`]) + .query({ token: visitor.token }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('room'); + expect(body.room.v).to.have.property('token', visitor.token); + expect(body.room.source.type).to.be.equal('widget'); + await closeOmnichannelRoom(body.room._id); + await deleteVisitor(visitor.token); + }); + }); + + describe('livechat/rooms', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-rooms'); + await request + .get(api('livechat/rooms')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + + await restorePermissionToRoles('view-livechat-rooms'); + }); + it('should return an error when the "agents" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ agents: { test: true } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "roomName" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ 'roomName[]': 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "departmentId" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + // it accepts an array now! + .query({ departmentId: { test: true } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "open" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ open: { test: true } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "tags" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ tags: { obj: true } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "createdAt" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ createdAt: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "closedAt" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ closedAt: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an error when the "customFields" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ customFields: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an array of rooms when has no parameters', async () => { + await request + .get(api('livechat/rooms')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.rooms).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should return an array of rooms when the query params is all valid', async () => { + await request + .get(api(`livechat/rooms`)) + .set(credentials) + .query({ + 'agents[]': 'teste', + 'departmentId': '123', + 'open': true, + 'createdAt': '{"start":"2018-01-26T00:11:22.345Z","end":"2018-01-26T00:11:22.345Z"}', + 'closedAt': '{"start":"2018-01-26T00:11:22.345Z","end":"2018-01-26T00:11:22.345Z"}', + 'tags[]': 'rocket', + 'customFields': '{ "docId": "031041" }', + 'count': 3, + 'offset': 1, + 'sort': '{ "_updatedAt": 1 }', + 'fields': '{ "msgs": 0 }', + 'roomName': 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.rooms).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should not cause issues when the customFields is empty', async () => { + await request + .get(api(`livechat/rooms`)) + .set(credentials) + .query({ customFields: {}, roomName: 'test' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.rooms).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should throw an error if customFields param is not a object', async () => { + await request + .get(api(`livechat/rooms`)) + .set(credentials) + .query({ customFields: 'string' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should only return closed rooms when "open" is set to false', async () => { + // Create and close a room + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + const { body } = await request.get(api('livechat/rooms')).query({ open: false, roomName: room.fname }).set(credentials).expect(200); + expect(body.rooms.every((room: IOmnichannelRoom) => !!room.closedAt)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room._id)).to.be.not.undefined; + await deleteVisitor(visitor.token); + }); + it('should only return open rooms when "open" is set to true', async () => { + // Create and close a room + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + const { body } = await request.get(api('livechat/rooms')).query({ open: true, roomName: room.fname }).set(credentials).expect(200); + expect(body.rooms.every((room: IOmnichannelRoom) => room.open)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room._id)).to.be.undefined; + await deleteVisitor(visitor.token); + }); + + describe('roomName filter', () => { + let room1: IOmnichannelRoom; + let room2: IOmnichannelRoom; + let visitor1: ILivechatVisitor; + let visitor2: ILivechatVisitor; + before(async () => { + visitor1 = await createVisitor(undefined, 'TEST_1'); + visitor2 = await createVisitor(undefined, 'TEST_2'); + room1 = await createLivechatRoom(visitor1.token); + room2 = await createLivechatRoom(visitor2.token); + }); + + after(async () => { + await closeOmnichannelRoom(room1._id); + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor1.token); + await deleteVisitor(visitor2.token); + }); + + it('should return only rooms matching exact term when roomName is between quotes', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ roomName: `"TEST_1"` }).set(credentials).expect(200); + expect(body.rooms[0].fname).to.equal('TEST_1'); + expect(body.rooms.find((r: IOmnichannelRoom) => r.fname === 'TEST_2')).to.be.undefined; + }); + it('should return rooms matching using regex when searching by roomName', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ roomName: `TEST_` }).set(credentials).expect(200); + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room1._id)).to.be.not.undefined; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room2._id)).to.be.not.undefined; + }); + it('should return empty if no room matches term', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ roomName: `"TEST_"` }).set(credentials).expect(200); + expect(body.rooms).to.be.empty; + }); + }); + + it('should return both closed/open when open param is not passed', async () => { + // Create and close a room + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + const { body } = await request.get(api('livechat/rooms')).set(credentials).expect(200); + expect(body.rooms.some((room: IOmnichannelRoom) => !!room.closedAt)).to.be.true; + expect(body.rooms.some((room: IOmnichannelRoom) => room.open)).to.be.true; + await deleteVisitor(visitor.token); + }); + + describe('Manual selection', () => { + let visitor: ILivechatVisitor; + let manualRoom: IOmnichannelRoom; + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + visitor = await createVisitor(); + manualRoom = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await closeOmnichannelRoom(manualRoom._id); + await deleteVisitor(visitor.token); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + }); + + it('should return queued rooms when `queued` param is passed', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ queued: true }).set(credentials).expect(200); + + expect(body.rooms.every((room: IOmnichannelRoom) => room.open)).to.be.true; + expect(body.rooms.every((room: IOmnichannelRoom) => !room.servedBy)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === manualRoom._id)).to.be.not.undefined; + }); + it('should return queued rooms when `queued` and `open` params are passed', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ queued: true, open: true }).set(credentials).expect(200); + + expect(body.rooms.every((room: IOmnichannelRoom) => room.open)).to.be.true; + expect(body.rooms.every((room: IOmnichannelRoom) => !room.servedBy)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === manualRoom._id)).to.be.not.undefined; + }); + it('should return open rooms when `open` is param is passed. Open rooms should not include queued conversations', async () => { + const { room: room2, visitor: visitor2 } = await startANewLivechatRoomAndTakeIt(); + + const { body } = await request.get(api('livechat/rooms')).query({ open: true }).set(credentials).expect(200); + + expect(body.rooms.every((room: IOmnichannelRoom) => room.open)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room2._id)).to.be.not.undefined; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === manualRoom._id)).to.be.undefined; + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor2.token); + }); + }); + + (IS_EE ? describe : describe.skip)('Queued and OnHold chats', () => { + let room: IOmnichannelRoom; + let room2: IOmnichannelRoom; + let visitor1: ILivechatVisitor; + let visitor2: ILivechatVisitor; + before(async () => { + await updateSetting('Livechat_allow_manual_on_hold', true); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + visitor2 = await createVisitor(); + const { room: room1, visitor: v1 } = await startANewLivechatRoomAndTakeIt(); + room = room1; + visitor1 = v1; + room2 = await createLivechatRoom(visitor2.token); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor1.token); + await deleteVisitor(visitor2.token); + await updateSetting('Livechat_allow_manual_on_hold', false); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + }); + + it('should not return on hold rooms along with queued rooms when `queued` is true and `onHold` is true', async () => { + await sendAgentMessage(room._id); + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(200); + + expect(response.body.success).to.be.true; + + const { body } = await request.get(api('livechat/rooms')).query({ queued: true, onhold: true }).set(credentials).expect(200); + + expect(body.rooms.every((room: IOmnichannelRoom) => room.open)).to.be.true; + expect(body.rooms.every((room: IOmnichannelRoom) => !room.servedBy)).to.be.true; + expect(body.rooms.every((room: IOmnichannelRoom) => !room.onHold)).to.be.true; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room._id)).to.be.undefined; + expect(body.rooms.find((froom: IOmnichannelRoom) => froom._id === room2._id)).to.be.not.undefined; + }); + }); + (IS_EE ? it : it.skip)('should return only rooms with the given department', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + + const { body } = await request.get(api('livechat/rooms')).query({ departmentId: department._id }).set(credentials).expect(200); + + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + await closeOmnichannelRoom(expectedRoom._id); + await deleteVisitor(expectedVisitor.token); + await deleteDepartment(department._id); + }); + (IS_EE ? it : it.skip)('should return rooms with the given departments', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + const { department: department2 } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + + const { room: expectedRoom2, visitor: expectedVisitor2 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department2._id, + }); + + const { body } = await request + .get(api('livechat/rooms')) + .query({ departmentId: [department._id, department2._id] }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(2); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom2._id)).to.be.true; + + await closeOmnichannelRoom(expectedRoom._id); + await closeOmnichannelRoom(expectedRoom2._id); + await deleteVisitor(expectedVisitor.token); + await deleteVisitor(expectedVisitor2.token); + await Promise.all([deleteDepartment(department._id), deleteDepartment(department2._id)]); + }); + (IS_EE ? it : it.skip)('should return rooms with the given department and the given status', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + + const { body } = await request + .get(api('livechat/rooms')) + .query({ departmentId: department._id, open: true }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + await closeOmnichannelRoom(expectedRoom._id); + await deleteVisitor(expectedVisitor.token); + await deleteDepartment(department._id); + }); + (IS_EE ? it : it.skip)('should return no rooms with the given department and the given status (if none match the filter)', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + + const { body } = await request + .get(api('livechat/rooms')) + .query({ departmentId: department._id, open: false }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(0); + await closeOmnichannelRoom(expectedRoom._id); + await deleteVisitor(expectedVisitor.token); + await deleteDepartment(department._id); + }); + (IS_EE ? it : it.skip)('should return only rooms served by the given agent', async () => { + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + agent: agent.credentials, + }); + + const { body } = await request.get(api('livechat/rooms')).query({ 'agents[]': agent.user._id }).set(credentials).expect(200); + + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + + await closeOmnichannelRoom(expectedRoom._id); + await deleteVisitor(expectedVisitor.token); + await Promise.all([deleteDepartment(department._id)]); + }); + (IS_EE ? it : it.skip)('should return only rooms served by the given agents', async () => { + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + agent: agent.credentials, + }); + + const { department: department2, agent: agent2 } = await createDepartmentWithAnOnlineAgent(); + + const { room: expectedRoom2, visitor: expectedVisitor2 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department2._id, + agent: agent2.credentials, + }); + + const { body } = await request + .get(api('livechat/rooms')) + .query({ agents: [agent.user._id, agent2.user._id] }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(2); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom2._id)).to.be.true; + + // close both rooms before removing visitors/departments (deleting a department with an open room fails) + await Promise.all([closeOmnichannelRoom(expectedRoom._id), closeOmnichannelRoom(expectedRoom2._id)]); + await Promise.all([ + deleteVisitor(expectedVisitor.token), + deleteVisitor(expectedVisitor2.token), + deleteDepartment(department._id), + deleteDepartment(department2._id), + ]); + }); + (IS_EE ? it : it.skip)('should return only rooms with the given tags', async () => { + const tag = await saveTags(); + + const { room: expectedRoom, visitor: expectedVisitor } = await startANewLivechatRoomAndTakeIt(); + await closeOmnichannelRoom(expectedRoom._id, [tag.name]); + + const { body } = await request.get(api('livechat/rooms')).query({ 'tags[]': tag.name }).set(credentials).expect(200); + + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === expectedRoom._id)).to.be.true; + await deleteVisitor(expectedVisitor.token); + }); + (IS_EE ? describe : describe.skip)('sort', () => { + let openRoom: IOmnichannelRoom; + let closeRoom: IOmnichannelRoom; + let department: ILivechatDepartment; + let openVisitor: ILivechatVisitor; + let closeVisitor: ILivechatVisitor; + + it('prepare data for further tests', async () => { + const { department: localDepartment } = await createDepartmentWithAnOnlineAgent(); + department = localDepartment; + + const { room: localOpenRoom, visitor: localOpenVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + openRoom = localOpenRoom; + openVisitor = localOpenVisitor; + const { room: localCloseRoom, visitor: localCloseVisitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + }); + closeRoom = localCloseRoom; + closeVisitor = localCloseVisitor; + await closeOmnichannelRoom(closeRoom._id); + }); + + after(async () => { + await closeOmnichannelRoom(openRoom._id); + await deleteVisitor(openVisitor.token); + await deleteVisitor(closeVisitor.token); + await deleteDepartment(department._id); + }); + it('should return only rooms in the asc order', async () => { + const { body } = await request + .get(api('livechat/rooms')) + .query({ sort: JSON.stringify({ open: 1 }), departmentId: department._id }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(2); + expect(body.rooms[0]._id).to.be.equal(closeRoom._id); + expect(body.rooms[1]._id).to.be.equal(openRoom._id); + }); + it('should return only rooms in the desc order', async () => { + const { body } = await request + .get(api('livechat/rooms')) + .query({ sort: JSON.stringify({ open: -1 }), departmentId: department._id }) + .set(credentials) + .expect(200); + + expect(body.rooms.length).to.be.equal(2); + expect(body.rooms[0]._id).to.be.equal(openRoom._id); + expect(body.rooms[1]._id).to.be.equal(closeRoom._id); + }); + }); + (IS_EE ? describe : describe.skip)('monitors', () => { + let user: IUser; + let userCreds: Credentials; + let user2: IUser; + let user2Creds: Credentials; + let department: ILivechatDepartment; + let department2: ILivechatDepartment; + let agent: any; + let agent2: any; + let unit: IOmnichannelBusinessUnit; + let unit2: IOmnichannelBusinessUnit; + let room1: IOmnichannelRoom; + let room2: IOmnichannelRoom; + let visitor1: ILivechatVisitor; + let visitor2: ILivechatVisitor; + + before(async () => { + user = await createUser(); + userCreds = await login(user.username!, password); + user2 = await createUser(); + user2Creds = await login(user2.username!, password); + + await createMonitor(user.username!); + await createMonitor(user2.username!); + const { department: dep1, agent: agent1 } = await createDepartmentWithAnOnlineAgent(); + const { department: dep2, agent: agen2 } = await createDepartmentWithAnOnlineAgent(); + + department = dep1; + agent = agent1; + department2 = dep2; + agent2 = agen2; + + unit = await createUnit(user._id, user.username!, [department._id], '', [{ monitorId: user2._id, username: user2.username! }]); + unit2 = await createUnit(user2._id, user2.username!, [department2._id]); + }); + before(async () => { + const { room: localOpenRoom, visitor: localVisitor1 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + agent: agent.credentials, + }); + room1 = localOpenRoom; + visitor1 = localVisitor1; + const { room: localOpenRoom2, visitor: localVisitor2 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department2._id, + agent: agent2.credentials, + }); + room2 = localOpenRoom2; + visitor2 = localVisitor2; + }); + before(async () => { + await restorePermissionToRoles('view-livechat-rooms'); + }); + after(async () => { + await closeOmnichannelRoom(room1._id); + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor1.token); + await deleteVisitor(visitor2.token); + await Promise.all([ + deleteUser(user), + deleteUser(user2), + deleteUnit(unit), + deleteUnit(unit2), + deleteDepartment(department._id), + deleteDepartment(department2._id), + ]); + }); + + it('should return valid list of rooms for monitor', () => { + return request + .get(api('livechat/rooms')) + .set(userCreds) + .expect(200) + .expect((res) => { + expect(res.body.rooms.some((r: IOmnichannelRoom) => r._id === room1._id)).to.be.true; + }); + }); + it('should return a valid list of rooms for monitor 2', () => { + return request + .get(api('livechat/rooms')) + .set(user2Creds) + .expect(200) + .expect((res) => { + expect(res.body.rooms.some((r: IOmnichannelRoom) => r._id === room2._id)).to.be.true; + expect(res.body.rooms.some((r: IOmnichannelRoom) => r._id === room1._id)).to.be.true; + }); + }); + it('should allow monitor 1 to filter by units', async () => { + const { body } = await request.get(api('livechat/rooms')).set(userCreds).query({ 'units[]': unit._id }).expect(200); + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === room1._id)).to.be.true; + expect(body.rooms.find((r: IOmnichannelRoom) => r._id === room2._id)).to.be.undefined; + expect(body.rooms.some((r: IOmnichannelRoom) => r.departmentAncestors?.includes(unit._id))).to.be.true; + expect(body.rooms.every((r: IOmnichannelRoom) => !r.departmentAncestors?.includes(unit2._id))).to.be.true; + }); + it('should not allow monitor 1 to filter by a unit hes not part of', async () => { + const { body } = await request.get(api('livechat/rooms')).set(userCreds).query({ 'units[]': unit2._id }).expect(200); + expect(body.rooms.every((r: IOmnichannelRoom) => !r.departmentAncestors?.includes(unit2._id))).to.be.true; + }); + it('should allow monitor 2 to filter by only one unit', async () => { + const { body } = await request.get(api('livechat/rooms')).set(user2Creds).query({ 'units[]': unit2._id }).expect(200); + expect(body.rooms.every((r: IOmnichannelRoom) => !r.departmentAncestors?.includes(unit._id))).to.be.true; + expect(body.rooms.some((r: IOmnichannelRoom) => r.departmentAncestors?.includes(unit2._id))).to.be.true; + expect(body.rooms.find((r: IOmnichannelRoom) => r._id === room2._id)).to.be.not.undefined; + }); + }); + (IS_EE ? describe : describe.skip)('units & room restrictions for agents & monitors', () => { + let user: IUser; + let agent: OnlineAgent; + let agent2: OnlineAgent; + let userCreds: Credentials; + let department: ILivechatDepartment; + let department2: ILivechatDepartment; + let unit: IOmnichannelBusinessUnit; + let unit2: IOmnichannelBusinessUnit; + let room1: IOmnichannelRoom; + let room2: IOmnichannelRoom; + let visitor1: ILivechatVisitor; + let visitor2: ILivechatVisitor; + + before(async () => { + user = await createUser(); + userCreds = await login(user.username!, password); + + await createMonitor(user.username!); + const { department: dep1, agent: agent1 } = await createDepartmentWithAnOnlineAgent(); + const { department: dep2, agent: agen2 } = await createDepartmentWithAnOnlineAgent(); + + department = dep1; + agent = agent1; + department2 = dep2; + agent2 = agen2; + unit = await createUnit(user._id, user.username!, [department._id]); + unit2 = await createUnit(user._id, user.username!, [department2._id]); + }); + before(async () => { + const { room: localOpenRoom, visitor: localVisitor1 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department._id, + agent: agent.credentials, + }); + const { room: localOpenRoom2, visitor: localVisitor2 } = await startANewLivechatRoomAndTakeIt({ + departmentId: department2._id, + agent: agent2.credentials, + }); + room1 = localOpenRoom; + room2 = localOpenRoom2; + visitor1 = localVisitor1; + visitor2 = localVisitor2; + }); + before(async () => { + await restorePermissionToRoles('view-livechat-rooms'); + }); + after(async () => { + await deleteVisitor(visitor1.token); + await deleteVisitor(visitor2.token); + await Promise.all([ + deleteUser(user), + deleteUser(agent.user), + deleteUnit(unit), + deleteDepartment(department._id), + deleteUnit(unit2), + deleteDepartment(department2._id), + deleteUser(agent2.user), + ]); + }); + + it('should show the room to the agent', async () => { + const { body } = await request.get(api('livechat/rooms')).query({ 'agents[]': agent.user._id }).set(agent.credentials).expect(200); + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms[0]._id).to.be.equal(room1._id); + }); + it('should show the closed room to the agent', async () => { + await closeOmnichannelRoom(room1._id); + const { body } = await request + .get(api('livechat/rooms')) + .query({ 'agents[]': agent.user._id, 'open': false }) + .set(agent.credentials) + .expect(200); + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms[0]._id).to.be.equal(room1._id); + }); + it('should not show the agent a room taken by other agent', async () => { + const { body } = await request + .get(api('livechat/rooms')) + .query({ 'agents[]': agent.user._id, 'open': true }) + .set(agent.credentials) + .expect(200); + expect(body.rooms.length).to.be.equal(0); + }); + it('should not allow the agent to filter by another agent', async () => { + await request.get(api('livechat/rooms')).query({ 'agents[]': agent.user._id, 'open': true }).set(agent2.credentials).expect(403); + }); + it('should allow the agent to filter its own closed room', async () => { + await closeOmnichannelRoom(room2._id); + const { body } = await request + .get(api('livechat/rooms')) + .query({ 'agents[]': agent2.user._id, 'open': false }) + .set(agent2.credentials) + .expect(200); + expect(body.rooms.length).to.be.equal(1); + expect(body.rooms[0]._id).to.be.equal(room2._id); + }); + it('should allow monitor to see rooms from his units', async () => { + const { body } = await request.get(api('livechat/rooms')).set(userCreds).query({ 'units[]': unit._id }).expect(200); + expect(body.rooms.find((r: IOmnichannelRoom) => r._id === room1._id)).to.be.not.undefined; + expect(body.rooms.find((r: IOmnichannelRoom) => r._id === room2._id)).to.be.undefined; + }); + it('should show the monitor both rooms when no unit filter is applied', async () => { + const { body } = await request.get(api('livechat/rooms')).set(userCreds).expect(200); + + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === room1._id)).to.be.true; + expect(body.rooms.some((room: IOmnichannelRoom) => room._id === room2._id)).to.be.true; + }); + }); + }); + + describe('livechat/room.join', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await removePermissionFromAllRoles('view-l-room'); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should fail if user doesnt have view-l-room permission', async () => { + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: '123' }).send().expect(403); + + await restorePermissionToRoles('view-l-room'); + }); + it('should fail if no roomId is present on query params', async () => { + await request.get(api('livechat/room.join')).set(credentials).expect(400); + }); + it('should fail if room is present but invalid', async () => { + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: 'invalid' }).send().expect(400); + }); + it('should allow user to join room', async () => { + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: room._id }).send().expect(200); + }); + }); + + describe('livechat/room.join', () => { + it('should fail if user doesnt have view-l-room permission', async () => { + await removePermissionFromAllRoles('view-l-room'); + + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: '123' }).send().expect(403); + + await restorePermissionToRoles('view-l-room'); + }); + it('should fail if no roomId is present on query params', async () => { + await request.get(api('livechat/room.join')).set(credentials).expect(400); + }); + it('should fail if room is present but invalid', async () => { + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: 'invalid' }).send().expect(400); + }); + describe('with room', () => { + let manager: { + user: TestUser; + credentials: Credentials; + }; + let room: IOmnichannelRoom; + let takenRoom: IOmnichannelRoom; + let roomVisitor: ILivechatVisitor; + let takenRoomVisitor: ILivechatVisitor; + + before(async () => { + const managerUser = await createUser(); + const managerCredentials = await login(managerUser.username, password); + manager = { user: managerUser, credentials: managerCredentials }; + + await createManager(manager.user.username); + roomVisitor = await createVisitor(); + room = await createLivechatRoom(roomVisitor.token); + const { room: troom, visitor: tvisitor } = await startANewLivechatRoomAndTakeIt(); + takenRoom = troom; + takenRoomVisitor = tvisitor; + }); + + after(async () => { + await Promise.all([ + closeOmnichannelRoom(takenRoom._id), + closeOmnichannelRoom(room._id), + deleteVisitor(roomVisitor.token), + deleteVisitor(takenRoomVisitor.token), + deleteUser(manager.user), + ]); + }); + + it('should allow user to join room', async () => { + await request.get(api('livechat/room.join')).set(credentials).query({ roomId: room._id }).send().expect(200); + }); + it('should allow managers to join a room which is already being served by an agent', async () => { + await request.get(api('livechat/room.join')).set(manager.credentials).query({ roomId: takenRoom._id }).send().expect(200); + }); + }); + }); + + describe('livechat/room.close', () => { + let closeRoomVisitor: ILivechatVisitor; + let closeRoom: IOmnichannelRoom; + + before(async () => { + closeRoomVisitor = await createVisitor(); + closeRoom = await createLivechatRoom(closeRoomVisitor.token); + }); + + after(async () => { + await deleteVisitor(closeRoomVisitor.token); + }); + + it('should return an "invalid-token" error when the visitor is not found due to an invalid token', async () => { + await request + .post(api('livechat/room.close')) + .send({ + token: 'invalid-token', + rid: closeRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an "invalid-room" error when the room is not found due to invalid token and/or rid', async () => { + await request + .post(api('livechat/room.close')) + .send({ + token: closeRoomVisitor.token, + rid: 'invalid-rid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return both the rid and the comment of the room when the query params is all valid', async () => { + await request + .post(api(`livechat/room.close`)) + .send({ + token: closeRoomVisitor.token, + rid: closeRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid'); + expect(res.body).to.have.property('comment'); + }); + }); + + it('should return an "room-closed" error when the room is already closed', async () => { + await request + .post(api('livechat/room.close')) + .send({ + token: closeRoomVisitor.token, + rid: closeRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + (IS_EE ? it : it.skip)( + 'should close room when chat is closed by visitor and should also generate pdf transcript if serving agent has set appropriate preference set', + async () => { + const { + room: { _id: roomId }, + visitor, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ + data: { + omnichannelTranscriptPDF: true, + }, + }) + .expect(200); + + // Give time for the setting to be on the user's preferences + await sleep(500); + + await request.post(api('livechat/room.close')).send({ rid: roomId, token: visitor.token }).expect(200); + + const latestRoom = await getLivechatRoomInfo(roomId); + expect(latestRoom).to.have.property('pdfTranscriptFileId').and.to.be.a('string'); + await deleteVisitor(visitor.token); + }, + ); + + (IS_EE ? it : it.skip)( + 'should close room when chat is closed by visitor and should not generate pdf transcript if serving agent has not set appropriate preference set', + async () => { + const { + room: { _id: roomId }, + visitor, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { omnichannelTranscriptPDF: false } }) + .expect(200); + + await request.post(api('livechat/room.close')).send({ rid: roomId, token: visitor.token }).expect(200); + + // Wait for the pdf to not be generated + await sleep(1500); + + const latestRoom = await getLivechatRoomInfo(roomId); + expect(latestRoom).to.not.have.property('pdfTranscriptFileId'); + await deleteVisitor(visitor.token); + }, + ); + + describe('Special case: visitors closing is disabled', () => { + before(async () => { + await updateSetting('Omnichannel_allow_visitors_to_close_conversation', false); + }); + after(async () => { + await updateSetting('Omnichannel_allow_visitors_to_close_conversation', true); + }); + it('should not allow visitor to close a conversation', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + await request + .post(api('livechat/room.close')) + .send({ + token: visitor.token, + rid: room._id, + }) + .expect(400); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + }); + }); + + describe('livechat/rooms.delete', () => { + const createRoomForDeletion = async () => { + const visitor = await createVisitor(undefined, `delete-room-${faker.string.uuid()}`); + const room = await createLivechatRoom(visitor.token); + + return { room, visitor }; + }; + + it('should fail if user is not logged in', async () => { + await request + .post(api('livechat/rooms.delete')) + .send({ roomId: 'invalid-room-id' }) + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'You must be logged in to do this.'); + }); + }); + + it('should fail if roomId is not provided', async () => { + await request + .post(api('livechat/rooms.delete')) + .set(credentials) + .send({}) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes("must have required property 'roomId'"); + }); + }); + + it('should fail if the livechat room is still open', async () => { + const { room, visitor } = await createRoomForDeletion(); + + await request + .post(api('livechat/rooms.delete')) + .set(credentials) + .send({ roomId: room._id }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should delete a closed livechat room', async () => { + const { room, visitor } = await createRoomForDeletion(); + + await closeOmnichannelRoom(room._id); + + await request + .post(api('livechat/rooms.delete')) + .set(credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('channels.info')) + .set(credentials) + .query({ roomId: room._id }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + + await deleteVisitor(visitor.token); + }); + + describe('with no permission', () => { + before(async () => { + await removePermissionFromAllRoles('remove-closed-livechat-room'); + }); + + after(async () => { + await restorePermissionToRoles('remove-closed-livechat-room'); + }); + + it('should fail if user does not have the remove-closed-livechat-room permission', async () => { + await request + .post(api('livechat/rooms.delete')) + .set(credentials) + .send({ roomId: 'invalid-room-id' }) + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + }); + + describe('livechat/room.forward', () => { + it('should return an "unauthorized error" when the user does not have "view-l-room" permission', async () => { + await updatePermission('transfer-livechat-guest', ['admin']); + await removePermissionFromAllRoles('view-l-room'); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + }); + + it('should return an "unauthorized error" when the user does not have "transfer-livechat-guest" permission', async () => { + await removePermissionFromAllRoles('transfer-livechat-guest'); + await updatePermission('view-l-room', ['admin']); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + + await restorePermissionToRoles('transfer-livechat-guest'); + await restorePermissionToRoles('view-l-room'); + }); + + it('should not be successful when no target (userId or departmentId) was specified', async () => { + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return a success message when transferred successfully to agent', async () => { + const initialAgentAssignedToChat = await createUser(); + const initialAgentCredentials = await login(initialAgentAssignedToChat.username, password); + await createAgent(initialAgentAssignedToChat.username); + await makeAgentAvailable(initialAgentCredentials); + + const newVisitor = await createVisitor(); + // at this point, the chat will get transferred to agent "user" + const newRoom = await createLivechatRoom(newVisitor.token); + + const forwardChatToUser = await createUser(); + const forwardChatToUserCredentials = await login(forwardChatToUser.username, password); + await createAgent(forwardChatToUser.username); + await makeAgentAvailable(forwardChatToUserCredentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + userId: forwardChatToUser._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername); + const { lastMessage } = latestRoom as { lastMessage: IOmnichannelSystemMessage }; + expect(lastMessage?.transferData?.comment).to.be.equal('test comment'); + expect(lastMessage?.transferData?.scope).to.be.equal('agent'); + expect(lastMessage?.transferData?.transferredTo?.username).to.be.equal(forwardChatToUser.username); + + // cleanup + await closeOmnichannelRoom(newRoom._id); + await deleteVisitor(newVisitor.token); + await deleteUser(initialAgentAssignedToChat); + await deleteUser(forwardChatToUser); + }); + + (IS_EE ? it : it.skip)('should return error message when transferred to a offline agent', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); + }); + + await closeOmnichannelRoom(newRoom._id); + await deleteVisitor(newVisitor.token); + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToOfflineDepartment._id); + }); + + (IS_EE ? it : it.skip)('should return a success message when transferred successfully to a department', async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent(); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername); + expect((latestRoom.lastMessage as any)?.transferData?.comment).to.be.equal('test comment'); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToDepartment._id); + + await closeOmnichannelRoom(newRoom._id); + await deleteVisitor(newVisitor.token); + await deleteDepartment(initialDepartment._id); + await deleteDepartment(forwardToDepartment._id); + }); + (IS_EE ? it : it.skip)('should return a success message when transferred successfully to an agent on a department', async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToDepartment, agent1, agent2 } = await createDepartmentWith2OnlineAgents(); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToDepartment._id, + userId: agent2.user._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToDepartment._id); + + expect(latestRoom).to.have.property('servedBy').that.is.an('object'); + expect(latestRoom.servedBy!._id).to.not.equal(agent1.user._id); + expect(latestRoom.servedBy!._id).to.equal(agent2.user._id); + + await Promise.all([ + closeOmnichannelRoom(latestRoom._id), + deleteVisitor(newVisitor.token), + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToDepartment._id), + ]); + }); + (IS_EE ? it : it.skip)( + 'should return a success message when transferred successfully to an offline department when the department accepts it', + async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await Promise.all([ + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + ]); + }, + ); + (IS_EE ? it : it.skip)('inquiry should be taken automatically when agent on department is online again', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request.post(api('livechat/room.forward')).set(credentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + await makeAgentAvailable(); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToOfflineDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername); + expect((latestRoom.lastMessage as any)?.transferData?.comment).to.be.equal('test comment'); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToOfflineDepartment._id); + + await Promise.all([ + updateSetting('Livechat_Routing_Method', 'Manual_Selection'), + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + closeOmnichannelRoom(latestRoom._id), + deleteVisitor(newVisitor.token), + ]); + }); + + (IS_EE ? it : it.skip)('when manager forward to offline department the inquiry should be set to the queue', async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, agent: offlineAgent } = await createDepartmentWithAnOfflineAgent({ + allowReceiveForwardOffline: true, + }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await makeAgentUnavailable(offlineAgent.credentials); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + await request + .get(api(`livechat/queue`)) + .set(credentials) + .query({ + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array'); + expect(res.body.queue[0].chats).not.to.undefined; + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }); + + (IS_EE ? it : it.skip)( + 'when manager forwards to department & passes an agent on the request while waiting queue is active, the inquiry should be set to the queue with default agent', + async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, agent: onlineAgent } = await createDepartmentWithAnOnlineAgent(); + await updateSetting('Livechat_waiting_queue', true); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + userId: onlineAgent.user._id, + clientAction: true, + comment: 'test comment', + }); + + const inquiry = await fetchInquiry(newRoom._id); + + expect(inquiry).to.have.property('status', 'queued'); + expect(inquiry) + .to.have.property('defaultAgent') + .to.deep.equal({ agentId: onlineAgent.user._id, username: onlineAgent.user.username }); + + await Promise.all([ + updateSetting('Livechat_waiting_queue', false), + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }, + ); + + (IS_EE ? it : it.skip)( + 'when manager forwards a chat that hasnt been assigned to a user to another department with no online agents, chat should end ready in department (not queued)', + async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOfflineAgent({}); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({}); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + const inquiry = await fetchInquiry(newRoom._id); + + // Inquiry status doesn't change, it was ready when created, it keeps ready after forwarding as no assignment was done + expect(inquiry.status).to.equal('ready'); + expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }, + ); + + (IS_EE ? it : it.skip)( + 'when manager forwards a chat that hasnt been assigned to a user to another department with no online agents when waiting queue is active, chat should end queued in department', + async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Livechat_waiting_queue', true); + const { department: initialDepartment } = await createDepartmentWithAnOfflineAgent({}); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({}); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + expect(newRoom.servedBy).to.be.undefined; + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + const inquiry = await fetchInquiry(newRoom._id); + + expect(inquiry.status).to.equal('queued'); + expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + updateSetting('Livechat_waiting_queue', false), + updateSetting('Livechat_accept_chats_with_no_agents', false), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }, + ); + + (IS_EE ? it : it.skip)( + 'when manager forward to a department while waiting_queue is disabled and allowReceiveForwardOffline is false, but department is online, transfer should succeed', + async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: targetDepartment } = await createDepartmentWithAnOnlineAgent(); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + expect(newRoom).to.have.property('servedBy'); + const res = await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: targetDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + expect(res.status).to.equal(200); + + const inquiry = await fetchInquiry(newRoom._id); + + expect(inquiry).to.have.property('department', targetDepartment._id); + expect(inquiry).to.have.property('status', 'taken'); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(targetDepartment._id), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }, + ); + + (IS_EE ? it : it.skip)( + 'when manager forward to a department while waiting_queue is enabled, but department is online, transfer should succeed but it should end queued on target', + async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: targetDepartment } = await createDepartmentWithAnOnlineAgent(); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + expect(newRoom).to.have.property('servedBy'); + + await updateSetting('Livechat_waiting_queue', true); + const res = await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: targetDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + expect(res.status).to.equal(200); + + const inquiry = await fetchInquiry(newRoom._id); + + expect(inquiry).to.have.property('department', targetDepartment._id); + expect(inquiry).to.have.property('status', 'queued'); + + // the room ends queued (never taken), so close it as the visitor — room.closeByUser rejects a room that is not being served + await request.post(api('livechat/room.close')).send({ rid: newRoom._id, token: newVisitor.token }).expect(200); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(targetDepartment._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + updateSetting('Livechat_waiting_queue', false), + ]); + }, + ); + + (IS_EE ? it : it.skip)( + 'should update inquiry last message when manager forward to offline department and the inquiry returns to queued', + async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const { department: initialDepartment, agent } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment, agent: offlineAgent } = await createDepartmentWithAnOfflineAgent({ + allowReceiveForwardOffline: true, + }); + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const inq = await fetchInquiry(newRoom._id); + await takeInquiry(inq._id, agent.credentials); + + const msgText = `return to queue ${Date.now()}`; + await request.post(api('livechat/message')).send({ token: newVisitor.token, rid: newRoom._id, msg: msgText }).expect(200); + + await makeAgentUnavailable(offlineAgent.credentials); + + const manager = await createUser(); + const managerCredentials = await login(manager.username, password); + await createManager(manager.username); + + await request.post(api('livechat/room.forward')).set(managerCredentials).send({ + roomId: newRoom._id, + departmentId: forwardToOfflineDepartment._id, + clientAction: true, + comment: 'test comment', + }); + + await request + .get(api(`livechat/queue`)) + .set(credentials) + .query({ + count: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array'); + expect(res.body.queue[0].chats).not.to.undefined; + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + + const inquiry = await fetchInquiry(newRoom._id); + + expect(inquiry).to.have.property('_id', inquiry._id); + expect(inquiry).to.have.property('rid', newRoom._id); + expect(inquiry).to.have.property('lastMessage'); + expect(inquiry.lastMessage).to.have.property('msg', ''); + expect(inquiry.lastMessage).to.have.property('t', 'livechat_transfer_history'); + + await Promise.all([ + deleteDepartment(initialDepartment._id), + deleteDepartment(forwardToOfflineDepartment._id), + closeOmnichannelRoom(newRoom._id), + deleteVisitor(newVisitor.token), + deleteUser(manager), + ]); + }, + ); + + let roomId: string; + let visitorToken: string; + let initialDepartmentToClean: string; + let forwardToDepartmentToClean: string; + let forwardToDepartment1ToClean: string; + (IS_EE ? it : it.skip)('should return a success message when transferring to a fallback department', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent(); + const forwardToDepartment1 = await createDepartment(undefined, undefined, true, { + fallbackForwardDepartment: forwardToDepartment._id, + }); + + initialDepartmentToClean = initialDepartment._id; + forwardToDepartmentToClean = forwardToDepartment._id; + forwardToDepartment1ToClean = forwardToDepartment1._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + departmentId: forwardToDepartment1._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername); + expect((latestRoom.lastMessage as any)?.transferData?.comment).to.be.equal('test comment'); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToDepartment._id); + + roomId = newRoom._id; + visitorToken = newVisitor.token; + }); + (IS_EE ? describe : describe.skip)('fallback department', () => { + let fallbackDepartment: Awaited>['department']; + let initialDepartment: Awaited>['department']; + let newVisitor: ILivechatVisitor; + let latestRoom: IOmnichannelRoom; + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + + fallbackDepartment = (await createDepartmentWithAnOnlineAgent()).department; + initialDepartment = ( + await createDepartmentWithAnOfflineAgent({ + fallbackForwardDepartment: fallbackDepartment._id, + }) + ).department; + + expect(initialDepartment.fallbackForwardDepartment).to.be.equal(fallbackDepartment._id); + }); + + after(async () => { + await Promise.all([ + deleteDepartment(fallbackDepartment._id), + deleteDepartment(initialDepartment._id), + deleteVisitor(newVisitor._id), + closeOmnichannelRoom(latestRoom._id), + ]); + }); + + it('should redirect chat to fallback department when all agents in the initial department are offline', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + + newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(fallbackDepartment._id); + }); + }); + (IS_EE ? it : it.skip)('system messages sent on transfer should be properly generated', async () => { + const messagesList = await fetchMessages(roomId, visitorToken); + + const fallbackMessages = messagesList.filter((m) => m.t === 'livechat_transfer_history_fallback'); + expect(fallbackMessages.length).to.be.equal(1); + + const userJoinedMessages = messagesList.filter((m) => m.t === 'uj'); + expect(userJoinedMessages.length).to.be.equal(2); + + const transferMessages = messagesList.filter((m) => m.t === 'livechat_transfer_history'); + expect(transferMessages.length).to.be.equal(1); + + const userLeavingMessages = messagesList.filter((m) => m.t === 'ul'); + expect(userLeavingMessages.length).to.be.equal(1); + + await closeOmnichannelRoom(roomId); + await deleteVisitor(visitorToken); + await deleteDepartment(initialDepartmentToClean); + await deleteDepartment(forwardToDepartmentToClean); + await deleteDepartment(forwardToDepartment1ToClean); + }); + }); + + describe('livechat/visitor/department.transfer', () => { + let initialDepartmentId: string; + let departmentForwardToId: string; + let omnichannelRoomId: string; + let visitorToken: string; + + afterEach(async () => { + await Promise.all([ + initialDepartmentId && deleteDepartment(initialDepartmentId), + departmentForwardToId && deleteDepartment(departmentForwardToId), + omnichannelRoomId && closeOmnichannelRoom(omnichannelRoomId), + visitorToken && deleteVisitor(visitorToken), + updateSetting('Livechat_Routing_Method', 'Manual_Selection'), + ]); + }); + + it('should not be successful when no target (userId or departmentId) was specified', async () => { + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: room._id, + token: visitor.token, + department: 'invalid-department-id', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + (IS_EE ? it : it.skip)('should return a success message when transferred successfully to a department', async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent(); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + visitorToken = newVisitor.token; + + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToDepartment._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(newVisitor.username); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToDepartment._id); + }); + + (IS_EE ? it : it.skip)( + 'should return a success message when transferred successfully to an offline department when the department accepts it', + async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToOfflineDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + visitorToken = newVisitor.token; + + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToOfflineDepartment._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }, + ); + (IS_EE ? it : it.skip)('inquiry should be taken automatically when agent on department is online again', async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true }); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToOfflineDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + visitorToken = newVisitor.token; + + await request.post(api('livechat/visitor/department.transfer')).set(credentials).send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToOfflineDepartment._id, + }); + + await makeAgentAvailable(); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToOfflineDepartment._id); + + expect(latestRoom).to.have.property('lastMessage'); + expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history'); + expect(latestRoom.lastMessage?.u?.username).to.be.equal(newVisitor.username); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToOfflineDepartment._id); + }); + }); + + describe('livechat/room.survey', () => { + it('should return an "invalid-token" error when the visitor is not found due to an invalid token', async () => { + await request + .post(api('livechat/room.survey')) + .set(credentials) + .send({ + token: 'invalid-token', + rid: room._id, + data: [{ name: 'question', value: 'answer' }], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return an "invalid-room" error when the room is not found due to invalid token and/or rid', async () => { + await request + .post(api('livechat/room.survey')) + .set(credentials) + .send({ + token: visitor.token, + rid: 'invalid-rid', + data: [{ name: 'question', value: 'answer' }], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return "invalid-data" when the items answered are not part of config.survey.items', async () => { + await request + .post(api('livechat/room.survey')) + .set(credentials) + .send({ + token: visitor.token, + rid: room._id, + data: [{ name: 'question', value: 'answer' }], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return the room id and the answers when the query params is all valid', async () => { + await request + .post(api('livechat/room.survey')) + .set(credentials) + .send({ + token: visitor.token, + rid: room._id, + data: [ + { name: 'satisfaction', value: '5' }, + { name: 'agentKnowledge', value: '3' }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid'); + expect(res.body).to.have.property('data'); + expect(res.body.data.satisfaction).to.be.equal('5'); + expect(res.body.data.agentKnowledge).to.be.equal('3'); + }); + }); + }); + + describe('livechat/upload/:rid', () => { + let visitor: ILivechatVisitor; + + afterEach(() => { + if (visitor?.token) { + return deleteVisitor(visitor.token); + } + }); + + after(async () => { + await updateSetting('FileUpload_Enabled', true); + await updateSetting('Livechat_fileupload_enabled', true); + }); + + it('should throw an error if x-visitor-token header is not present', async () => { + await request + .post(api('livechat/upload/test')) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should throw an error if x-visitor-token is present but with an invalid value', async () => { + await request + .post(api('livechat/upload/test')) + .set(credentials) + .set('x-visitor-token', 'invalid-token') + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should throw unauthorized if visitor with token exists but room is invalid', async () => { + visitor = await createVisitor(); + await request + .post(api('livechat/upload/test')) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should allow visitor to download file even after room is closed', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const { body } = await request + .post(api(`livechat/upload/${room._id}`)) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(200); + + await closeOmnichannelRoom(room._id); + const { + files: [{ _id, name }], + } = body; + const imageUrl = `/file-upload/${_id}/${name}`; + await request.get(imageUrl).query({ rc_token: visitor.token, rc_room_type: 'l', rc_rid: room._id }).expect(200); + }); + + describe('uploading', () => { + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await updateSetting('Livechat_fileupload_enabled', false); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + }); + + it('should throw an error if the file is not attached', async () => { + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw and error if file uploads are enabled but livechat file uploads are disabled', async () => { + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw and error if both file uploads are disabled', async () => { + await updateSetting('FileUpload_Enabled', false); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + await updateSetting('Livechat_fileupload_enabled', true); + }); + + it('should throw and error if livechat file uploads are enabled but file uploads are disabled', async () => { + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(400); + await updateSetting('FileUpload_Enabled', true); + }); + + it('should upload an image on the room if all params are valid', async () => { + await updateSetting('Livechat_fileupload_enabled', true); + await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should allow visitor to download file', async () => { + const { body } = await request + .post(api(`livechat/upload/${room._id}`)) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(200); + + const { + files: [{ _id, name }], + } = body; + const imageUrl = `/file-upload/${_id}/${name}`; + await request.get(imageUrl).query({ rc_token: visitor.token, rc_room_type: 'l', rc_rid: room._id }).expect(200); + }); + + it('should not allow visitor to download a file from a room he didnt create', async () => { + const visitor2 = await createVisitor(); + const { body } = await request + .post(api(`livechat/upload/${room._id}`)) + .set(credentials) + .set('x-visitor-token', visitor.token) + .attach('file', fs.createReadStream(path.join(__dirname, '../../../data/livechat/sample.png'))) + .expect('Content-Type', 'application/json') + .expect(200); + + const { + files: [{ _id, name }], + } = body; + const imageUrl = `/file-upload/${_id}/${name}`; + await request.get(imageUrl).query({ rc_token: visitor2.token, rc_room_type: 'l', rc_rid: room._id }).expect(403); + await deleteVisitor(visitor2.token); + }); + }); + }); + + describe('livechat/:rid/messages', () => { + it('should fail if room provided is invalid', async () => { + await request.get(api('livechat/test/messages')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + it('should throw an error if user doesnt have permission view-l-room', async () => { + await removePermissionFromAllRoles('view-l-room'); + + await request.get(api('livechat/test/messages')).set(credentials).expect('Content-Type', 'application/json').expect(403); + + await restorePermissionToRoles('view-l-room'); + }); + + describe('with room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should return the messages of the room', async () => { + await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .get(api(`livechat/${room._id}/messages`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages'); + expect(body.messages).to.be.an('array'); + expect(body.total).to.be.an('number').equal(1); + expect(body.messages[0]).to.have.property('msg', 'Hello'); + }); + + it('should return the messages of the room matching by searchTerm', async () => { + await sendMessage(room._id, 'Hello', visitor.token); + await sendMessage(room._id, 'Random', visitor.token); + + const { body } = await request + .get(api(`livechat/${room._id}/messages`)) + .query({ searchTerm: 'Ran' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages'); + expect(body.messages).to.be.an('array'); + expect(body.total).to.be.an('number').equal(1); + expect(body.messages[0]).to.have.property('msg', 'Random'); + }); + + it('should return the messages of the room matching by partial searchTerm', async () => { + await sendMessage(room._id, 'Hello', visitor.token); + await sendMessage(room._id, 'Random', visitor.token); + + const { body } = await request + .get(api(`livechat/${room._id}/messages`)) + .query({ searchTerm: 'ndo' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages'); + expect(body.messages).to.be.an('array'); + expect(body.total).to.be.an('number').equal(2); + expect(body.messages[0]).to.have.property('msg', 'Random'); + }); + + it('should return everything when searchTerm is ""', async () => { + const { body } = await request + .get(api(`livechat/${room._id}/messages`)) + .query({ searchTerm: '' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages'); + expect(body.messages).to.be.an('array'); + expect(body.messages).to.be.an('array').with.lengthOf.greaterThan(1); + expect(body.messages[0]).to.have.property('msg'); + }); + }); + }); + + describe('[GET] livechat/message/:_id', () => { + it('should fail if message provided is invalid', async () => { + await request.get(api('livechat/message/test')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + it('shoudl fail if token is not sent as query param', async () => { + await request.get(api('livechat/message/test')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + it('should fail if rid is not sent as query param', async () => { + await request + .get(api('livechat/message/test')) + .set(credentials) + .query({ token: 'test' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should return the message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const message = await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .get(api(`livechat/message/${message._id}`)) + .query({ + token: visitor.token, + rid: room._id, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('message'); + expect(body.message).to.have.property('msg', 'Hello'); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + }); + + describe('[PUT] livechat/message/:_id', () => { + it('should fail if room provided is invalid', async () => { + await request + .put(api('livechat/message/test')) + .set(credentials) + .send({ token: 'test', rid: 'fadsfdsafads', msg: 'fasfasdfdsf' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if token is not sent as body param', async () => { + await request + .put(api('livechat/message/test')) + .set(credentials) + .send({ msg: 'fasfadsf', rid: 'afdsfdsfads' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if rid is not sent as body param', async () => { + await request + .put(api('livechat/message/test')) + .set(credentials) + .send({ token: 'test', msg: 'fasfasdfdsf' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if msg is not sent as body param', async () => { + await request + .put(api(`livechat/message/test`)) + .set(credentials) + .send({ token: 'fasdfdsf', rid: 'fadsfdsafads' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if token is not a valid token', async () => { + await request + .put(api(`livechat/message/test`)) + .set(credentials) + .send({ token: 'test', rid: 'fadsfdsafads', msg: 'fasfasdfdsf' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if room is not a valid room', async () => { + const visitor = await createVisitor(); + await request + .put(api(`livechat/message/test`)) + .set(credentials) + .send({ token: visitor.token, rid: 'fadsfdsafads', msg: 'fasfasdfdsf' }) + .expect('Content-Type', 'application/json') + .expect(400); + await deleteVisitor(visitor.token); + }); + + describe('with room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail if _id is not a valid message id', async () => { + await request + .put(api(`livechat/message/test`)) + .set(credentials) + .send({ token: visitor.token, rid: room._id, msg: 'fasfasdfdsf' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should update a message if everything is valid', async () => { + const message = await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .put(api(`livechat/message/${message._id}`)) + .set(credentials) + .send({ token: visitor.token, rid: room._id, msg: 'Hello World' }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('message'); + expect(body.message).to.have.property('msg', 'Hello World'); + expect(body.message).to.have.property('editedAt'); + expect(body.message).to.have.property('editedBy'); + expect(body.message.editedBy).to.have.property('username', visitor.username); + }); + }); + }); + + describe('[DELETE] livechat/message/_id', () => { + it('should fail if token is not sent as body param', async () => { + await request + .delete(api('livechat/message/test')) + .set(credentials) + .send({ rid: 'afdsfdsfads' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if room provided is invalid', async () => { + await request + .delete(api('livechat/message/test')) + .set(credentials) + .send({ token: 'test', rid: 'fadsfdsafads' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if rid is not sent as body param', async () => { + await request + .delete(api('livechat/message/test')) + .set(credentials) + .send({ token: 'test' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + describe('with room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail if _id is not a valid message id', async () => { + await request + .delete(api(`livechat/message/test`)) + .set(credentials) + .send({ token: visitor.token, rid: room._id }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should delete a message if everything is valid', async () => { + const message = await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .delete(api(`livechat/message/${message._id}`)) + .set(credentials) + .send({ token: visitor.token, rid: room._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('message'); + expect(body.message).to.have.property('_id', message._id); + expect(body.message).to.have.property('ts'); + }); + }); + }); + + describe('livechat/messages', () => { + it('should fail if visitor is not sent as body param', async () => { + await request.post(api('livechat/messages')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + + it('should fail if visitor.token is not sent as body param', async () => { + await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: {} }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail if messages is not sent as body param', async () => { + await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: { token: 'test' } }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail if messages is not an array', async () => { + await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: { token: 'test' }, messages: {} }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail if messages is an empty array', async () => { + await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: { token: 'test' }, messages: [] }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should be able to create messages on a room', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .post(api('livechat/messages')) + .set(credentials) + .send({ visitor: { token: visitor.token }, messages: [{ msg: 'Hello' }, { msg: 'Hello 2' }] }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages').of.length(2); + expect(body.messages[0]).to.have.property('msg', 'Hello'); + expect(body.messages[0]).to.have.property('ts'); + expect(body.messages[0]).to.have.property('username', visitor.username); + expect(body.messages[1]).to.have.property('msg', 'Hello 2'); + expect(body.messages[1]).to.have.property('ts'); + expect(body.messages[1]).to.have.property('username', visitor.username); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + }); + + describe('livechat/transfer.history/:rid', () => { + describe('with permission', () => { + before(async () => { + await removePermissionFromAllRoles('view-livechat-rooms'); + }); + after(async () => { + await restorePermissionToRoles('view-livechat-rooms'); + }); + + it('should fail if user doesnt have "view-livechat-rooms" permission', async () => { + const { body } = await request + .get(api(`livechat/transfer.history/test`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + expect(body).to.have.property('success', false); + }); + }); + it('should fail if room is not a valid room id', async () => { + const { body } = await request + .get(api(`livechat/transfer.history/test`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + expect(body).to.have.property('success', false); + }); + describe('with room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await updatePermission('view-l-room', ['admin', 'livechat-manager', 'livechat-agent']); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should return empty for a room without transfer history', async () => { + const { body } = await request + .get(api(`livechat/transfer.history/${room._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('history').that.is.an('array'); + expect(body.history.length).to.equal(0); + }); + it('should return the transfer history for a room', async () => { + const initialAgentAssignedToChat = await createUser(); + const initialAgentCredentials = await login(initialAgentAssignedToChat.username, password); + await createAgent(initialAgentAssignedToChat.username); + await makeAgentAvailable(initialAgentCredentials); + + const forwardChatToUser = await createUser(); + const forwardChatToUserCredentials = await login(forwardChatToUser.username, password); + await createAgent(forwardChatToUser.username); + await makeAgentAvailable(forwardChatToUserCredentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: room._id, + userId: forwardChatToUser._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const { body } = await request + .get(api(`livechat/transfer.history/${room._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('history').that.is.an('array'); + expect(body.history.length).to.equal(1); + expect(body.history[0]).to.have.property('scope', 'agent'); + expect(body.history[0]).to.have.property('comment', 'test comment'); + expect(body.history[0]).to.have.property('transferredBy').that.is.an('object'); + + // cleanup + await deleteUser(initialAgentAssignedToChat); + await deleteUser(forwardChatToUser); + }); + }); + }); + + describe('livechat/room.saveInfo', () => { + it('should fail if no data is sent as body param', async () => { + await request.post(api('livechat/room.saveInfo')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + + describe('with no view-l permission', () => { + before(async () => { + await updatePermission('view-l-room', []); + }); + after(async () => { + await updatePermission('view-l-room', ['admin']); + }); + it('should return an "unauthorized error" when the user does not have "view-l-room" permission', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + }, + guestData: { + _id: 'invalid-guest-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + }); + }); + + describe('with no save-others permission', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + await updatePermission('save-others-livechat-room-info', []); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await sleep(1000); + + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + }); + it('should not allow users to update room info without serving the chat or having "save-others-livechat-room-info" permission', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + }, + guestData: { + _id: room._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + }); + + it('should allow user to update the room info', async () => { + await updatePermission('save-others-livechat-room-info', ['admin']); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(room._id); + expect(latestRoom).to.have.property('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + }); + }); + + it('should throw an error if roomData is not provided', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + guestData: { + _id: 'invalid-guest-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if guestData is not provided', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if roomData is not of valid type', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: 'invalid-room-data', + guestData: { + _id: 'invalid-guest-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if guestData is not of valid type', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + guestData: 'invalid-guest-data', + roomData: { + _id: 'invalid-room-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + (IS_EE ? describe : describe.skip)('enterprise checks', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + const cfName = faker.lorem.word(); + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + + await deleteCustomField('intfield'); + await deleteCustomField(cfName); + }); + + it('endpoint should handle empty custom fields', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + livechatData: {}, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(room._id); + expect(latestRoom).to.have.property('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + expect(latestRoom).to.not.have.property('livechatData'); + }); + + it('should allow user to update the room info - EE fields', async () => { + await createCustomField({ + searchable: true, + field: cfName, + label: cfName, + scope: 'room', + visibility: 'visible', + regexp: '', + }); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + livechatData: { + [cfName]: 'test-input-1-value', + }, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(room._id); + expect(latestRoom).to.have.property('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + expect(latestRoom).to.have.property('livechatData').to.have.property(cfName, 'test-input-1-value'); + }); + it('should throw an error if custom fields are not valid', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + livechatData: { + key: { + value: 'invalid', + }, + }, + }, + guestData: { + _id: 'invalid-visitor-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should throw an error if a valid custom field fails the check', async () => { + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: 'intfield', + label: 'intfield', + scope: 'room', + visibility: 'visible', + regexp: '\\d+', + searchable: true, + type: 'input', + required: false, + defaultValue: '0', + options: '', + public: false, + }, + }) + .expect(200); + + const response = await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: { intfield: 'asdasd' }, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'Invalid value for intfield field'); + }); + it('should throw an error if room _id is empty string', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: '', + livechatData: {}, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should throw an error if visitor _id is empty string', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: {}, + }, + guestData: { + _id: '', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if livechatData contains invalid field format', async () => { + const response = await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: { intfield: ['array', 'instead', 'of', 'string'] }, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should not throw an error if a valid custom field passes the check', async () => { + const response2 = await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: { intfield: '1' }, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response2.body).to.have.property('success', true); + }); + + (IS_EE ? it : it.skip)('should update room priority', async () => { + await addPermissions({ + 'save-others-livechat-room-info': ['admin', 'livechat-manager'], + 'view-l-room': ['livechat-agent', 'admin', 'livechat-manager'], + }); + + const priority = await getRandomPriority(); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + priorityId: priority._id, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.have.property('priorityId', priority._id); + expect(updatedRoom).to.have.property('priorityWeight', priority.sortItem); + }); + it('should update room sla', async () => { + const sla = await createSLA(); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + slaId: sla._id, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.have.property('slaId', sla._id); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('livechat/room/:rid/priority', async () => { + let priorities: ILivechatPriority[]; + let chosenPriority: ILivechatPriority; + after(async () => { + await updateEEPermission('manage-livechat-priorities', ['admin', 'livechat-manager']); + await updatePermission('view-l-room', ['admin', 'livechat-manager', 'livechat-agent']); + }); + it('should return the list of priorities', async () => { + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: DummyResponse>) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('priorities').and.to.be.an('array'); + expect(res.body.priorities).to.have.length.greaterThan(0); + }); + priorities = response.body.priorities; + const rnd = faker.number.int({ min: 0, max: priorities.length - 1 }); + chosenPriority = priorities[rnd]; + }); + it('should prioritize the room', async () => { + const response = await request + .post(api(`livechat/room/${room._id}/priority`)) + .set(credentials) + .send({ + priorityId: chosenPriority._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should return the room with the new priority', async () => { + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.have.property('priorityId', chosenPriority._id); + expect(updatedRoom).to.have.property('priorityWeight', chosenPriority.sortItem); + }); + it('should unprioritize the room', async () => { + const response = await request + .delete(api(`livechat/room/${room._id}/priority`)) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should return the room with the new priority', async () => { + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.not.have.property('priorityId'); + expect(updatedRoom).to.have.property('priorityWeight', LivechatPriorityWeight.NOT_SPECIFIED); + }); + it('should fail to return the priorities if lacking permissions', async () => { + await updatePermission('manage-livechat-priorities', []); + await updatePermission('view-l-room', []); + await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should fail to prioritize the room from a lack of permissions', async () => { + await request + .post(api(`livechat/room/${room._id}/priority`)) + .set(credentials) + .send({ + priorityId: chosenPriority._id, + }) + .expect(403); + }); + it('should fail to unprioritize the room from a lack of permissions', async () => { + await request + .delete(api(`livechat/room/${room._id}/priority`)) + .set(credentials) + .send() + .expect(403); + }); + }); + describe('livechat/rooms/filters', () => { + it('should fail if user doesnt have view-l-room permission', async () => { + await updatePermission('view-l-room', []); + await request.get(api('livechat/rooms/filters')).set(credentials).expect(403); + }); + it('should return a list of available source filters', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent', 'livechat-manager']); + const response = await request.get(api('livechat/rooms/filters')).set(credentials).expect(200); + expect(response.body).to.have.property('filters').and.to.be.an('array'); + expect(response.body.filters.find((f: IOmnichannelRoom['source']) => f.type === 'api')).to.not.be.undefined; + }); + }); + + describe('livechat/room.closeByUser', () => { + it('should fail if user is not logged in', async () => { + await request.post(api('livechat/room.closeByUser')).expect(401); + }); + it('should fail if not all required params are passed (rid)', async () => { + await request.post(api('livechat/room.closeByUser')).set(credentials).expect(400); + }); + it('should fail if room is not found', async () => { + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: 'invalid-room-id' }).expect(400); + }); + + describe('with no permission', () => { + before(async () => { + await updatePermission('close-livechat-room', []); + }); + after(async () => { + await updatePermission('close-livechat-room', ['admin']); + }); + it('should fail if user doesnt have close-livechat-room permission', async () => { + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: 'invalid-room-id' }).expect(403); + }); + }); + + describe('with no close-others permission', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await updatePermission('close-others-livechat-room', []); + }); + after(async () => { + await restorePermissionToRoles('close-others-livechat-room'); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail if user is not serving and doesnt have close-others-livechat-room permission', async () => { + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: room._id }).expect(400); + }); + }); + + describe('with a room', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should not close a room without comment', async () => { + const response = await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: room._id }).expect(400); + + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'error-comment-is-required'); + }); + it('should not close a room when comment is an empty string', async () => { + const response = await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: room._id, comment: '' }) + .expect(400); + + expect(response.body).to.have.property('success', false); + }); + }); + + it('should close room if user has permission', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(200); + await deleteVisitor(visitor.token); + }); + it('should fail if room is closed', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + // close room + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(200); + + // try to close again + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }).expect(400); + await deleteVisitor(visitor.token); + }); + it('should fail one of the requests if 3 simultaneous closes are attempted', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + expect(validResponse.length).to.equal(1); + expect(invalidResponses.length).to.equal(2); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('success', false); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('error'); + // The transaction is not consistent on the error apparently, sometimes it will reach the point of trying to close the inquiry and abort there (since another call already closed the room and finished) + // and sometimes it will abort because the transactions are still running and they're being locked. This is something i'm not liking but since tx should be retried we got this + // @ts-expect-error promise typings + expect(['error-room-cannot-be-closed-try-again', 'Error removing inquiry']).to.include(invalidResponses[0].value.body.error); + await deleteVisitor(visitor.token); + }); + + it('should allow different rooms to be closed simultaneously', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const visitor2 = await createVisitor(); + const { _id: _id2 } = await createLivechatRoom(visitor2.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id2, comment: 'test' }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + expect(validResponse.length).to.equal(2); + expect(invalidResponses.length).to.equal(0); + await deleteVisitor(visitor.token); + await deleteVisitor(visitor2.token); + }); + + it('when both user & visitor try to close room, only one will succeed (theres no guarantee who will win)', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + const results = await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.close')).set(credentials).send({ rid: _id, token: visitor.token }), + ]); + + const validResponse = results.filter((res) => (res as any).value.status === 200); + const invalidResponses = results.filter((res) => (res as any).value.status !== 200); + + // @ts-expect-error promise typings + const whoWon = validResponse[0].value.request.url.includes('closeByUser') ? 'user' : 'visitor'; + + expect(validResponse.length).to.equal(1); + expect(invalidResponses.length).to.equal(1); + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('success', false); + // This error indicates a conflict in the simultaneous close and that the request was rejected + // @ts-expect-error promise typings + expect(invalidResponses[0].value.body).to.have.property('error'); + // @ts-expect-error promise typings + expect(['error-room-cannot-be-closed-try-again', 'Error removing inquiry']).to.include(invalidResponses[0].value.body.error); + + const room = await getLivechatRoomInfo(_id); + + expect(room).to.not.have.property('open'); + expect(room).to.have.property('closer', whoWon); + await deleteVisitor(visitor.token); + }); + + it('when a close request is tried multiple times, the final state of the room should be valid', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + + await Promise.allSettled([ + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }), + ]); + + const room = await getLivechatRoomInfo(_id); + const inqForRoom = await fetchInquiry(_id); + const sub = await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: _id }) + .expect('Content-Type', 'application/json'); + + expect(room).to.not.have.property('open'); + expect(room).to.have.property('closedAt'); + expect(room).to.have.property('closer', 'user'); + expect(inqForRoom).to.be.null; + expect(sub.body.subscription).to.be.null; + await deleteVisitor(visitor.token); + }); + + describe('Force closing', () => { + after(async () => { + await updateSetting('Omnichannel_allow_force_close_conversations', false); + }); + it('should not allow force closing if setting Omnichannel_allow_force_close_conversations is off', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }); + + // Room closed, try to close again should return an error + const result = await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: _id, comment: 'test', forceClose: true }); + + expect(result.body).to.have.property('success', false); + expect(result.body).to.have.property('error', 'error-room-already-closed'); + await deleteVisitor(visitor.token); + }); + it('should allow to force close a conversation (even if the conversation is already closed)', async () => { + await updateSetting('Omnichannel_allow_force_close_conversations', true); + + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id, comment: 'test' }); + + // Room closed, try to force close again should work + const result = await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: _id, comment: 'test', forceClose: true }); + + expect(result.body).to.have.property('success', true); + await deleteVisitor(visitor.token); + }); + }); + + (IS_EE ? it : it.skip)('should close room and generate transcript pdf', async () => { + const { + room: { _id: roomId }, + visitor, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: roomId, comment: 'test', generateTranscriptPdf: true }) + .expect(200); + + // Wait for the pdf to be generated + await sleep(1500); + + const latestRoom = await getLivechatRoomInfo(roomId); + expect(latestRoom).to.have.property('pdfTranscriptFileId').and.to.be.a('string'); + await deleteVisitor(visitor.token); + }); + + (IS_EE ? it : it.skip)('should close room and not generate transcript pdf', async () => { + const { + room: { _id: roomId }, + visitor, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .post(api('livechat/room.closeByUser')) + .set(credentials) + .send({ rid: roomId, comment: 'test', generateTranscriptPdf: false }) + .expect(200); + + // Wait for the pdf to not be generated + await sleep(1500); + + const latestRoom = await getLivechatRoomInfo(roomId); + expect(latestRoom).to.not.have.property('pdfTranscriptFileId'); + await deleteVisitor(visitor.token); + }); + }); + + (IS_EE ? describe : describe.skip)('omnichannel/:rid/request-transcript', () => { + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + // Wait for one sec to be sure routing stops + await sleep(1000); + }); + + it('should fail if user is not logged in', async () => { + await request.post(api('omnichannel/rid/request-transcript')).expect(401); + }); + it('should fail if :rid doesnt exists', async () => { + await request.post(api('omnichannel/rid/request-transcript')).set(credentials).expect(400); + }); + + describe('with room', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await updatePermission('request-pdf-transcript', []); + }); + after(async () => { + await deleteVisitor(visitor.token); + await closeOmnichannelRoom(room._id); + }); + + it('should fail if user doesnt have request-pdf-transcript permission', async () => { + const { _id } = room; + await request + .post(api(`omnichannel/${_id}/request-transcript`)) + .set(credentials) + .expect(403); + }); + it('should fail if room is not closed', async () => { + await updatePermission('request-pdf-transcript', ['admin', 'livechat-agent', 'livechat-manager']); + const { _id } = await createLivechatRoom(visitor.token); + await request + .post(api(`omnichannel/${_id}/request-transcript`)) + .set(credentials) + .expect(400); + }); + }); + + it('should return OK if no one is serving the room (queued)', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(_id); + await request + .post(api(`omnichannel/${_id}/request-transcript`)) + .set(credentials) + .expect(200); + await deleteVisitor(visitor.token); + }); + let roomWithTranscriptGenerated: string; + let transcriptVisitor: ILivechatVisitor; + it('should request a pdf transcript when all conditions are met', async () => { + const { + room: { _id: roomId }, + visitor, + } = await startANewLivechatRoomAndTakeIt(); + transcriptVisitor = visitor; + + // close room since pdf transcript is only generated for closed rooms + await closeOmnichannelRoom(roomId); + + await request + .post(api(`omnichannel/${roomId}/request-transcript`)) + .set(credentials) + .expect(200); + + // wait for the pdf to be generated + await sleep(1500); + + const latestRoom = await getLivechatRoomInfo(roomId); + expect(latestRoom).to.have.property('pdfTranscriptFileId').and.to.be.a('string'); + + roomWithTranscriptGenerated = roomId; + }); + it('should return when transcript was already requested', async () => { + await request + .post(api(`omnichannel/${roomWithTranscriptGenerated}/request-transcript`)) + .set(credentials) + .expect(200); + await deleteVisitor(transcriptVisitor.token); + }); + }); + + describe('it should mark room as unread when a new message arrives and the config is activated', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + let totalMessagesSent = 0; + let departmentWithAgent: Awaited>; + + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Unread_Count_Omni', 'all_messages'); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + await deleteDepartment(departmentWithAgent.department._id); + }); + + it('it should prepare the required data for further tests', async () => { + departmentWithAgent = await createDepartmentWithAnOnlineAgent(); + visitor = await createVisitor(departmentWithAgent.department._id); + room = await createLivechatRoom(visitor.token); + + await sendMessage(room._id, 'message 1', visitor.token); + await sendMessage(room._id, 'message 2', visitor.token); + + // 1st message is for the room creation, so we need to add 1 to the total messages sent + totalMessagesSent = 3; + }); + + it("room's subscription should have correct unread count", async () => { + const { unread } = await getSubscriptionForRoom(room._id, departmentWithAgent.agent.credentials); + expect(unread).to.equal(totalMessagesSent); + await deleteVisitor(visitor.token); + }); + }); + + (IS_EE ? describe : describe.skip)('it should NOT mark room as unread when a new message arrives and the config is deactivated', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + let totalMessagesSent = 0; + let departmentWithAgent: Awaited>; + + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Unread_Count_Omni', 'mentions_only'); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + await deleteDepartment(departmentWithAgent.department._id); + }); + + it('it should prepare the required data for further tests', async () => { + departmentWithAgent = await createDepartmentWithAnOnlineAgent(); + visitor = await createVisitor(departmentWithAgent.department._id); + room = await createLivechatRoom(visitor.token); + + await sendMessage(room._id, 'message 1', visitor.token); + await sendMessage(room._id, 'message 2', visitor.token); + + // 1st message is for the room creation, so we need to add 1 to the total messages sent + totalMessagesSent = 1; + }); + + it("room's subscription should have correct unread count", async () => { + const { unread } = await getSubscriptionForRoom(room._id, departmentWithAgent.agent.credentials); + expect(unread).to.equal(totalMessagesSent); + }); + }); + + describe('livechat/transcript/:rid', () => { + it('should fail if user is not logged in', async () => { + await request.delete(api('livechat/transcript/rid')).expect(401); + }); + it('should fail if user doesnt have send-omnichannel-chat-transcript permission', async () => { + await updatePermission('send-omnichannel-chat-transcript', []); + await request.delete(api('livechat/transcript/rid')).set(credentials).expect(403); + }); + it('should fail if :rid is not a valid room id', async () => { + await updatePermission('send-omnichannel-chat-transcript', ['admin']); + await request.delete(api('livechat/transcript/rid')).set(credentials).expect(400); + }); + it('should fail if room is closed', async () => { + const visitor = await createVisitor(); + const { _id } = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(_id); + await request + .delete(api(`livechat/transcript/${_id}`)) + .set(credentials) + .expect(400); + await deleteVisitor(visitor.token); + }); + + describe('with room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail if room doesnt have a transcript request active', async () => { + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .expect(400); + }); + it('should return OK if all conditions are met', async () => { + // First, request transcript with livechat:requestTranscript method + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ + email: 'test@test.com', + subject: 'Transcript of your omnichannel conversation', + }) + .expect(200); + + // Then, delete the transcript + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .expect(200); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts new file mode 100644 index 0000000..a7694c8 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -0,0 +1,788 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ILivechatVisitor, IOmnichannelRoom, ILivechatAgent, ILivechatDepartment, IRoom, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { disableDefaultBusinessHour, makeDefaultBusinessHourActiveAndClosed } from '../../../data/livechat/businessHours'; +import { createDepartment, deleteDepartment } from '../../../data/livechat/department'; +import { + createAgent, + createManager, + createVisitor, + createLivechatRoom, + takeInquiry, + fetchInquiry, + makeAgentAvailable, + startANewLivechatRoomAndTakeIt, + moveBackToQueue, + closeOmnichannelRoom, + deleteVisitor, +} from '../../../data/livechat/rooms'; +import { + removePermissionFromAllRoles, + restorePermissionToRoles, + updateEESetting, + updatePermission, + updateSetting, +} from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, getMe, login, setUserStatus } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - Agents', () => { + let agent: ILivechatAgent; + let manager: ILivechatAgent; + + let agent2: { user: IUser; credentials: Credentials }; + + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await updateSetting('Omnichannel_enable_department_removal', true); + agent = await createAgent(); + manager = await createManager(); + }); + + before(async () => { + const user = await createUser(); + const userCredentials = await login(user.username, password); + await createAgent(user.username); + + await makeAgentAvailable(userCredentials); + + agent2 = { + user, + credentials: userCredentials, + }; + }); + + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + await deleteUser(agent2.user); + }); + + // TODO: missing test cases for POST method + describe('GET livechat/users/:type', () => { + describe('with no permissions', () => { + before(async () => { + await updatePermission('edit-omnichannel-contact', []); + await updatePermission('transfer-livechat-guest', []); + await updatePermission('manage-livechat-agents', []); + }); + after(async () => { + await updatePermission('edit-omnichannel-contact', ['admin']); + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('manage-livechat-agents', ['admin']); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await request.get(api('livechat/users/agent')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + }); + it('should throw an error when the type is invalid', async () => { + await request + .get(api('livechat/users/invalid-type')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Invalid type'); + }); + }); + it('should return an array of agents', async () => { + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + const agentRecentlyCreated = (res.body.users as ILivechatAgent[]).find((user) => agent._id === user._id); + expect(agentRecentlyCreated?._id).to.be.equal(agent._id); + }); + }); + it('should return an array of available agents', async () => { + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { statusLivechat: string }) => u.statusLivechat === 'available')).to.be.true; + }); + }); + it('should return an array of available/unavailable agents when onlyAvailable is false', async () => { + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect( + res.body.users.every( + (u: { statusLivechat: string }) => !u.statusLivechat || ['available', 'not-available'].includes(u.statusLivechat), + ), + ).to.be.true; + }); + }); + + it('should return offline agents when showIdleAgents is true', async () => { + await setUserStatus(agent2.credentials, UserStatus.OFFLINE); + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ showIdleAgents: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect( + res.body.users.every( + (u: { status: UserStatus }) => + !u.status || [UserStatus.ONLINE, UserStatus.OFFLINE, UserStatus.AWAY, UserStatus.BUSY].includes(u.status), + ), + ).to.be.true; + }); + }); + + it('should return only online agents when showIdleAgents is false', async () => { + await setUserStatus(agent2.credentials, UserStatus.ONLINE); + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ showIdleAgents: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { status: UserStatus }) => u.status !== UserStatus.OFFLINE)).to.be.true; + }); + }); + + it('should return an array of managers', async () => { + await request + .get(api('livechat/users/manager')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + const managerRecentlyCreated = (res.body.users as ILivechatAgent[]).find((user) => manager._id === user._id); + expect(managerRecentlyCreated?._id).to.be.equal(manager._id); + }); + }); + }); + + describe('POST livechat/users/:type', () => { + describe('with no permission', () => { + before(async () => { + await updatePermission('view-livechat-manager', []); + }); + after(async () => { + await updatePermission('view-livechat-manager', ['admin']); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await request + .post(api('livechat/users/agent')) + .set(credentials) + .send({ + username: 'test-agent', + }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + }); + + it('should return an error when type is invalid', async () => { + await request + .post(api('livechat/users/invalid-type')) + .set(credentials) + .send({ + username: 'test-agent', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an error when username is invalid', async () => { + await request + .post(api('livechat/users/agent')) + .set(credentials) + .send({ + username: 'mr-not-valid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return a valid user when all goes fine', async () => { + const user = await createUser(); + await request + .post(api('livechat/users/agent')) + .set(credentials) + .send({ + username: user.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('_id'); + expect(res.body.user).to.have.property('username'); + }); + + // cleanup + await deleteUser(user); + }); + + it('should properly create a manager', async () => { + const user = await createUser(); + await request + .post(api('livechat/users/manager')) + .set(credentials) + .send({ + username: user.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('_id'); + expect(res.body.user).to.have.property('username'); + }); + + // cleanup + await deleteUser(user); + }); + }); + + describe('GET livechat/users/:type/:_id', () => { + describe('with no permission', () => { + before(async () => { + await updatePermission('view-livechat-manager', []); + }); + after(async () => { + await updatePermission('view-livechat-manager', ['admin']); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await request + .get(api(`livechat/users/agent/id${agent._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + }); + + describe('with new user', () => { + let user: IUser; + before(async () => { + user = await createUser(); + }); + after(async () => { + await deleteUser(user); + }); + it('should return an error when type is invalid', async () => { + await request + .get(api(`livechat/users/invalid-type/${agent._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }).timeout(5000); + + it('should return a valid user when all goes fine', async () => { + await request + .get(api(`livechat/users/agent/${agent._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('_id'); + expect(res.body.user).to.have.property('username'); + expect(res.body.user).to.not.have.property('roles'); + }); + }); + + it('should return { user: null } when user is not an agent', async () => { + await request + .get(api(`livechat/users/agent/${user._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.be.null; + }); + }); + + it('should return { user: null } when user is not a manager', async () => { + await request + .get(api(`livechat/users/manager/${user._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.be.null; + }); + }); + }); + }); + + describe('DELETE livechat/users/:type/:_id', () => { + describe('with no permission', () => { + before(async () => { + await updatePermission('view-livechat-manager', []); + }); + after(async () => { + await updatePermission('view-livechat-manager', ['admin']); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await request.delete(api(`livechat/users/agent/id`)).set(credentials).expect('Content-Type', 'application/json').expect(403); + }).timeout(5000); + }); + + it('should return an error when type is invalid', async () => { + await request.delete(api(`livechat/users/invalid-type/id`)).set(credentials).expect('Content-Type', 'application/json').expect(400); + }).timeout(5000); + + it('should return an error when _id is invalid', async () => { + await request.delete(api('livechat/users/agent/invalid-id')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }).timeout(5000); + + it('should return a valid user when all goes fine', async () => { + const agent = await createAgent(); + await request + .delete(api(`livechat/users/agent/${agent._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + }); + }); + + (IS_EE ? describe : describe.skip)('livechat/agents/:agentId/departments', () => { + let dep1: ILivechatDepartment; + let dep2: ILivechatDepartment; + before(async () => { + dep1 = await createDepartment( + { + enabled: true, + name: Random.id(), + showOnRegistration: true, + email: `${Random.id()}@example.com`, + showOnOfflineForm: true, + }, + [{ agentId: credentials['X-User-Id'] }], + ); + dep2 = await createDepartment( + { + enabled: false, + name: Random.id(), + email: `${Random.id()}@example.com`, + showOnRegistration: true, + showOnOfflineForm: true, + }, + [{ agentId: credentials['X-User-Id'] }], + ); + }); + + after(async () => { + await deleteDepartment(dep1._id); + await deleteDepartment(dep2._id); + }); + describe('with no permission', () => { + before(async () => { + await updatePermission('view-l-room', []); + }); + after(async () => { + await updatePermission('view-l-room', ['livechat-manager', 'livechat-agent', 'admin']); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await request + .get(api(`livechat/agents/${agent._id}/departments`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + }); + it('should return an empty array of departments when the agentId is invalid', async () => { + await request + .get(api('livechat/agents/invalid-id/departments')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments').and.to.be.an('array'); + }); + }); + it('should return an array of departments when the agentId is valid', async () => { + await request + .get(api(`livechat/agents/${agent._id}/departments`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments').and.to.be.an('array'); + expect(res.body.departments.length).to.be.equal(2); + (res.body.departments as ILivechatDepartment[]).forEach((department) => { + expect(department.agentId).to.be.equal(agent._id); + expect(department).to.have.property('departmentName').that.is.a('string'); + }); + }); + }); + it('should return only enabled departments when param `enabledDepartmentsOnly` is true ', async () => { + await request + .get(api(`livechat/agents/${agent._id}/departments`)) + .set(credentials) + .query({ enabledDepartmentsOnly: true }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments').and.to.be.an('array'); + expect(res.body.departments.length).to.be.equal(1); + expect(res.body.departments[0].departmentEnabled).to.be.true; + }); + }); + }); + + describe('livechat/agent.info/:rid/:token', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail when token in url params is not valid', async () => { + await request.get(api(`livechat/agent.info/soemthing/invalid-token`)).expect(400); + }); + it('should fail when token is valid but rid isnt', async () => { + const visitor = await createVisitor(); + await request.get(api(`livechat/agent.info/invalid-rid/${visitor.token}`)).expect(400); + await deleteVisitor(visitor.token); + }); + /* it('should fail when room is not being served by any agent', async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request.get(api(`livechat/agent.info/${room._id}/${visitor.token}`)).expect(400); + }); */ + it('should return a valid agent when the room is being served and the room belongs to visitor', async () => { + const inq = await fetchInquiry(room._id); + await takeInquiry(inq._id, agent2.credentials); + + const { body } = await request.get(api(`livechat/agent.info/${room._id}/${visitor.token}`)); + expect(body).to.have.property('success', true); + expect(body).to.have.property('agent'); + expect(body.agent).to.have.property('_id', agent2.user._id); + }); + }); + + describe('livechat/agent.next/:token', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail when token in url params is not valid', async () => { + await request.get(api(`livechat/agent.next/invalid-token`)).expect(400); + }); + it('should return success when visitor with token has an open room', async () => { + await request.get(api(`livechat/agent.next/${visitor.token}`)).expect(200); + }); + + // TODO: test cases when algo is Auto_Selection + }); + + describe('livechat/agent.next/:token - with manual selection', () => { + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + }); + after(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + }); + it('should fail if theres no open room for visitor and algo is manual selection', async () => { + const visitor = await createVisitor(); + + await request.get(api(`livechat/agent.next/${visitor.token}`)).expect(400); + + await deleteVisitor(visitor.token); + }); + }); + + describe('livechat/agent.status', () => { + describe('with no view-l permission', () => { + before(async () => { + await removePermissionFromAllRoles('view-l-room'); + }); + after(async () => { + await restorePermissionToRoles('view-l-room'); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission to change its own status', async () => { + await request.post(api('livechat/agent.status')).set(credentials).send({ status: 'not-available' }).expect(403); + }); + }); + describe('with no manage-agent permission', () => { + before(async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'not-available', agentId: agent2.user._id }) + .expect(200); + await removePermissionFromAllRoles('manage-livechat-agents'); + }); + after(async () => { + await restorePermissionToRoles('manage-livechat-agents'); + }); + + it('should return an "unauthorized error" when the user does not have the necessary permission to change other status', async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'available', agentId: agent2.user._id }) + .expect(403); + }); + }); + + it('should return an error if user is not an agent', async () => { + const user = await createUser({ roles: ['livechat-manager'] }); + const userCredentials = await login(user.username, password); + await request + .post(api('livechat/agent.status')) + .set(userCredentials) + .send({ status: 'available', agentId: user._id }) + .expect(404) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Agent not found'); + }); + + // cleanup + await deleteUser(user); + }); + it('should return an error if status is not valid', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'invalid-status', agentId: agent2.user._id }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + it('should return an error if agentId param is not valid', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'available', agentId: 'invalid-agent-id' }) + .expect(404) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Agent not found'); + }); + }); + + describe('cases for valid agents', () => { + let currentUser: ILivechatAgent; + before(async () => { + currentUser = await getMe(agent2.credentials); + }); + + afterEach(async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'not-available', agentId: currentUser._id }) + .expect(200); + }); + + it('should be able to set a logged in users status to available', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'available'); + }); + }); + it('should allow managers to set other agents status to available', async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'available'); + }); + }); + describe('outside of business hours', () => { + before(async () => { + await makeDefaultBusinessHourActiveAndClosed(); + + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'not-available', agentId: currentUser._id }) + .expect(200); + }); + + after(async () => { + await disableDefaultBusinessHour(); + }); + it('should throw an error if agent tries to make themselves available outside of Business hour', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-business-hours-are-closed'); + }); + }); + it('should return success but not change the agent when admin make try other agents available outside business hour', async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'not-available'); + }); + }); + }); + }); + }); + + describe('Agent sidebar', () => { + let testUser: { user: IUser; credentials: Credentials }; + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + after(async () => { + await deleteUser(testUser.user); + }); + + it('should return an empty list of rooms for a newly created agent', async () => { + const { body } = await request.get(api('rooms.get')).set(testUser.credentials).send({}).expect(200); + + expect(body).to.have.property('success', true); + expect(body.update.filter((r: IRoom) => r.t === 'l')).to.have.lengthOf(0); + }); + + describe('with a room', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + const { room: r, visitor: v } = await startANewLivechatRoomAndTakeIt({ agent: testUser.credentials }); + room = r; + visitor = v; + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should have a new room in his sidebar after taking a conversation from the queue', async () => { + const { body } = await request.get(api('rooms.get')).set(testUser.credentials).send({}).expect(200); + + expect(body).to.have.property('success', true); + const livechatRooms = body.update.filter((r: IRoom) => r.t === 'l'); + expect(livechatRooms).to.have.lengthOf(1); + expect(body.update.find((r: { _id: string }) => r._id === room._id)).to.be.an('object'); + expect(body.update.find((r: { _id: string }) => r._id === 'GENERAL')).to.be.an('object'); + }); + + it('should not have the room if user moves room back to queue', async () => { + await moveBackToQueue(room._id, testUser.credentials); + + const { body } = await request + .get(api('rooms.get')) + .set(testUser.credentials) + .query({ updatedSince: new Date(new Date().getTime() - 2000) }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body.update.find((r: { _id: string }) => r._id === room._id)).to.be.undefined; + }); + }); + + it('should not have the room if the user closes the room', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: testUser.credentials }); + + await closeOmnichannelRoom(room._id); + + const { body } = await request.get(api('rooms.get')).set(testUser.credentials).expect(200); + + expect(body.update.find((r: { _id: string }) => r._id === room._id)).to.be.undefined; + + await deleteVisitor(visitor.token); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts b/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts new file mode 100644 index 0000000..030cdbd --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts @@ -0,0 +1,265 @@ +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { sleep } from '../../../data/livechat/utils'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - appearance', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('livechat/appearance', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request.get(api('livechat/appearance')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an array of settings', async () => { + await updatePermission('view-livechat-manager', ['admin']); + await request + .get(api('livechat/appearance')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.appearance).to.be.an('array'); + }); + }); + }); + + describe('POST livechat/appearance', () => { + it('should fail if user is not logged in', async () => { + await request.post(api('livechat/appearance')).send({}).expect(401); + }); + it('should fail if body is not an array', async () => { + await request.post(api('livechat/appearance')).set(credentials).send({}).expect(400); + }); + it('should fail if body is an empty array', async () => { + await request.post(api('livechat/appearance')).set(credentials).send([]).expect(400); + }); + it('should fail if body does not contain value', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ name: 'Livechat_title' }]) + .expect(400); + }); + it('should fail if body does not contain name', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ value: 'test' }]) + .expect(400); + }); + it('should fail if user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'invalid', value: 'test' }]) + .expect(403); + }); + it('should fail if body contains invalid _id', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'invalid', value: 'test' }]) + .expect(400); + }); + it('should update the settings', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_title', value: 'test' }]) + .expect(200); + }); + // Test for: https://github.com/ajv-validator/ajv/issues/1140 + it('should update a boolean setting and keep it as boolean', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_registration_form', value: true }]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.registrationForm).to.be.true; + }); + it('should update a boolean setting and keep it as boolean', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_registration_form', value: false }]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.registrationForm).to.be.false; + }); + it('should update a number setting and keep it as number', async () => { + await updateSetting('Livechat_enable_message_character_limit', true); + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_message_character_limit', value: 100 }]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.equal(100); + await updateSetting('Livechat_enable_message_character_limit', false); + }); + it('should coerce the value of a setting based on its stored datatype (int)', async () => { + await updateSetting('Livechat_enable_message_character_limit', true); + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_message_character_limit', value: '100' }]) + .expect(200); + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.equal(100); + await updateSetting('Livechat_enable_message_character_limit', false); + }); + it('should coerce the value of a setting based on its stored datatype (boolean)', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_registration_form', value: 'true' }]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.registrationForm).to.be.true; + }); + it('should coerce an invalid number value to zero', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([ + { _id: 'Livechat_message_character_limit', value: 'xxxx' }, + { _id: 'Livechat_enable_message_character_limit', value: true }, + ]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + // When setting is 0, we default to Message_MaxAllowedSize value + expect(body.config.settings.limitTextLength).to.be.equal(5000); + }); + it('should coerce a boolean value on an int setting to 0', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([ + { _id: 'Livechat_message_character_limit', value: true }, + { _id: 'Livechat_enable_message_character_limit', value: true }, + ]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.equal(5000); + }); + it('should coerce a non boolean value on a boolean setting to false', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_enable_message_character_limit', value: 'xxxx' }]) + .expect(200); + + // Just enough to get the stream to update cached settings + await sleep(500); + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.false; + }); + + (IS_EE ? it : it.skip)('should accept an array setting', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_hide_system_messages', value: ['livechat-started'] }]) + .expect(200); + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.hiddenSystemMessages).to.be.an('array'); + expect(body.config.settings.hiddenSystemMessages).to.include('livechat-started'); + }); + + (IS_EE ? it : it.skip)('should accept an array setting with multiple values', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_hide_system_messages', value: ['uj', 'livechat_transfer_history'] }]) + .expect(200); + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.hiddenSystemMessages).to.be.an('array'); + expect(body.config.settings.hiddenSystemMessages).to.include('uj'); + expect(body.config.settings.hiddenSystemMessages).to.include('livechat_transfer_history'); + expect(body.config.settings.hiddenSystemMessages).not.to.include('livechat-started'); + }); + + (IS_EE ? it : it.skip)('should not update an array setting with a value other than array', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_hide_system_messages', value: 'uj' }]) + .expect(200); + + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.hiddenSystemMessages).to.be.an('array'); + expect(body.config.settings.hiddenSystemMessages).to.include('uj'); + expect(body.config.settings.hiddenSystemMessages).to.include('livechat_transfer_history'); + }); + + (IS_EE ? it : it.skip)('should not update an array setting with values that are not valid setting values', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_hide_system_messages', value: ['livechat-started', 'invalid'] }]) + .expect(200); + + await sleep(500); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.hiddenSystemMessages).to.be.an('array'); + expect(body.config.settings.hiddenSystemMessages).to.include('uj'); + expect(body.config.settings.hiddenSystemMessages).to.include('livechat_transfer_history'); + expect(body.config.settings.hiddenSystemMessages).to.not.include('livechat-started'); + expect(body.config.settings.hiddenSystemMessages).to.not.include('invalid'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts b/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts new file mode 100644 index 0000000..4a175bf --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts @@ -0,0 +1,746 @@ +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { closeOmnichannelRoom, createLivechatRoom, createVisitor, deleteVisitor, getLivechatRoomInfo } from '../../../data/livechat/rooms'; +import { updatePermission, updateSetting } from '../../../data/permissions.helper'; + +describe('LIVECHAT - custom fields', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('livechat/custom-fields', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request.get(api('livechat/custom-fields')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an array of custom fields', async () => { + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/custom-fields')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.customFields).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should return an array of custom fields even requested with count and offset params', async () => { + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/custom-fields')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.customFields).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + }); + + describe('livechat/custom-fields/id', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request.get(api('livechat/custom-fields/invalid-id')).set(credentials).expect('Content-Type', 'application/json').expect(403); + await updatePermission('view-l-room', ['admin']); + }); + }); + + describe('livechat/custom.field', () => { + it('should fail when token is not on body params', async () => { + await request.post(api('livechat/custom.field')).expect(400); + }); + it('should fail when key is not on body params', async () => { + await request.post(api('livechat/custom.field')).send({ token: 'invalid-token' }).expect(400); + }); + it('should fail when value is not on body params', async () => { + await request.post(api('livechat/custom.field')).send({ token: 'invalid-token', key: 'invalid-key' }).expect(400); + }); + it('should fail when overwrite is not on body params', async () => { + await request + .post(api('livechat/custom.field')) + .send({ token: 'invalid-token', key: 'invalid-key', value: 'invalid-value' }) + .expect(400); + }); + it('should fail when token is invalid', async () => { + await request + .post(api('livechat/custom.field')) + .send({ token: 'invalid-token', key: 'invalid-key', value: 'invalid-value', overwrite: true }) + .expect(400); + }); + it('should fail when key is invalid', async () => { + const visitor = await createVisitor(); + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: 'invalid-key', value: 'invalid-value', overwrite: true }) + .expect(400); + + await deleteVisitor(visitor.token); + }); + it('should save a custom field on visitor', async () => { + const visitor = await createVisitor(); + const customFieldName = `new_custom_field_${Date.now()}`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + + const { body } = await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: customFieldName, value: 'test_address', overwrite: true }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('field'); + expect(body.field).to.have.property('value', 'test_address'); + + await deleteVisitor(visitor.token); + }); + }); + + describe('livechat/custom.fields', () => { + const customFieldName = `new_custom_field_${Date.now()}_1`; + const customFieldName2 = `new_custom_field_${Date.now()}_2`; + const customFieldName3 = `new_custom_field_${Date.now()}_3`; + const roomCustomField = `new_custom_field_${Date.now()}_4`; + let visitor: ILivechatVisitor; + let visitorRoom: IOmnichannelRoom; + + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: customFieldName2, + label: customFieldName2, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: customFieldName3, + label: customFieldName3, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: roomCustomField, + label: roomCustomField, + defaultValue: 'test_default_address', + scope: 'room', + visibility: 'public', + regexp: '', + }); + visitor = await createVisitor(); + // start a room for visitor2 + visitorRoom = await createLivechatRoom(visitor.token); + }); + after(async () => { + await Promise.all([ + deleteCustomField(customFieldName), + deleteCustomField(customFieldName2), + deleteCustomField(customFieldName3), + deleteCustomField(roomCustomField), + closeOmnichannelRoom(visitorRoom._id), + deleteVisitor(visitor.token), + updateSetting('Livechat_accept_chats_with_no_agents', false), + ]); + }); + it('should fail when token is not on body params', async () => { + await request.post(api('livechat/custom.fields')).expect(400); + }); + it('should fail if customFields is not on body params', async () => { + await request.post(api('livechat/custom.fields')).send({ token: 'invalid-token' }).expect(400); + }); + it('should fail if customFields is not an array', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: 'invalid-token', + customFields: 'invalid-custom-fields', + }) + .expect(400); + }); + it('should fail if customFields is an empty array', async () => { + await request.post(api('livechat/custom.fields')).send({ token: 'invalid-token', customFields: [] }).expect(400); + }); + it('should fail if customFields is an array with invalid objects', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ token: 'invalid-token', customFields: [{}] }) + .expect(400); + }); + it('should fail if token is not a valid token', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: 'invalid-token', + customFields: [{ key: 'invalid-key', value: 'invalid-value', overwrite: true }], + }) + .expect(400); + }); + it('should fail when customFields.key is invalid', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: 'invalid-key', value: 'invalid-value', overwrite: true }], + }) + .expect(400); + }); + it('should save a custom field on visitor', async () => { + const visitor = await createVisitor(); + + const { body } = await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName, value: 'test_address', overwrite: true }], + }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('fields'); + expect(body.fields).to.be.an('array'); + expect(body.fields).to.have.lengthOf(1); + expect(body.fields[0]).to.have.property('value', 'test_address'); + + await deleteVisitor(visitor.token); + }); + it('should save multiple custom fields on a visitor', async () => { + const visitor = await createVisitor(); + + const { body } = await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [ + { key: customFieldName, value: 'test_address', overwrite: true }, + { key: customFieldName2, value: 'test_address2', overwrite: true }, + { key: customFieldName3, value: 'test_address3', overwrite: true }, + ], + }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('fields'); + expect(body.fields).to.be.an('array'); + expect(body.fields).to.have.lengthOf(3); + expect(body.fields[0]).to.have.property('value', 'test_address'); + expect(body.fields[1]).to.have.property('value', 'test_address2'); + expect(body.fields[2]).to.have.property('value', 'test_address3'); + + await deleteVisitor(visitor.token); + }); + it('should save multiple custom fields on contact when visitor already has custom fields and an update with multiple fields is issued', async () => { + const { body } = await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName, value: 'test_address', overwrite: true }], + }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('fields'); + expect(body.fields).to.be.an('array'); + expect(body.fields).to.have.lengthOf(1); + expect(body.fields[0]).to.have.property('value', 'test_address'); + + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [ + { key: customFieldName2, value: 'test_address2', overwrite: true }, + { key: customFieldName3, value: 'test_address3', overwrite: true }, + ], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_address2'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + }); + }); + it('should mark a conflict on a contact custom fields when overwrite is true and visitor already has the custom field set', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName, value: 'test_address_conflict', overwrite: false }], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_address2'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + expect(res.body.contact).to.have.property('conflictingFields').that.is.an('array'); + expect(res.body.contact.conflictingFields[0]).to.deep.equal({ + field: `customFields.${customFieldName}`, + value: 'test_address_conflict', + }); + }); + }); + it('should overwrite the contact custom field when overwrite is true', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName2, value: 'test_new_add', overwrite: true }], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_new_add'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + expect(res.body.contact).to.have.property('conflictingFields').that.is.an('array'); + expect(res.body.contact.conflictingFields[0]).to.deep.equal({ + field: `customFields.${customFieldName}`, + value: 'test_address_conflict', + }); + }); + }); + + describe('with a room', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should save both room & vistor custom fields on one call', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [ + { key: customFieldName, value: 'test_address', overwrite: true }, + { key: roomCustomField, value: 'test_address2', overwrite: true }, + { key: customFieldName3, value: 'test_address3', overwrite: true }, + ], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + }); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo).to.have.property('livechatData').that.is.an('object'); + expect(roomInfo.livechatData).to.have.property(roomCustomField, 'test_address2'); + }); + it('should ignore a room custom field when room already has a value for it and overwrite is false', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: roomCustomField, value: 'test_value_overriden', overwrite: false }], + }) + .expect(200); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo).to.have.property('livechatData').that.is.an('object'); + expect(roomInfo.livechatData).to.have.property(roomCustomField, 'test_address2'); + }); + }); + }); + + describe('livechat/custom.field [with Contacts]', () => { + let visitor: ILivechatVisitor; + let contactId: string; + let room: IOmnichannelRoom; + + const customFieldName = `custom_field_${Date.now()}`; + const customFieldValue = 'custom-field-value'; + + before(async () => { + await updatePermission('create-livechat-contact', ['admin']); + await updatePermission('view-livechat-contact', ['admin']); + await updateSetting('Livechat_accept_chats_with_no_agents', true); + + // Create a Visitor + visitor = await createVisitor(); + + // Create a Contact and store id on var + await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitor.name, + emails: [visitor.visitorEmails?.[0].address], + phones: [visitor.phone?.[0].phoneNumber], + }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + + contactId = res.body.contactId; + }); + + room = await createLivechatRoom(visitor.token); + + // Create Custom Field + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + + await createCustomField({ + searchable: true, + field: `${customFieldName}_2`, + label: `${customFieldName}_2`, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + }); + + after(async () => { + // TODO: add clean up for contacts, visitors, etc + await Promise.all([ + deleteCustomField(customFieldName), + deleteCustomField(`${customFieldName}_2`), + deleteCustomField(`${customFieldName}_3`), + deleteVisitor(visitor.token), + closeOmnichannelRoom(room._id), + updateSetting('Livechat_accept_chats_with_no_agents', false), + ]); + }); + + it('should save the custom field on Contact when available', async () => { + // Save the custom field on Visitor/Contact + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: customFieldName, value: customFieldValue, overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + // Fetch the visitor to validate custom fields are properly set. + await request + .get(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor).to.have.property('livechatData'); + expect(res.body.visitor.livechatData).to.have.property(customFieldName, customFieldValue); + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, customFieldValue); + }); + }); + + it('Should save multiple custom fields on Contact when available', async () => { + // Save the custom field on Visitor/Contact + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: customFieldName, value: customFieldValue, overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: `${customFieldName}_2`, value: customFieldValue, overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + // Fetch the visitor to validate custom fields are properly set. + await request + .get(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor).to.have.property('livechatData'); + expect(res.body.visitor.livechatData).to.have.property(customFieldName, customFieldValue); + expect(res.body.visitor.livechatData).to.have.property(`${customFieldName}_2`, customFieldValue); + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, customFieldValue); + expect(res.body.contact.customFields).to.have.property(`${customFieldName}_2`, customFieldValue); + }); + }); + + it('should add the custom field as conflict on Contact when overwrite is false', async () => { + const conflictingFieldValue = 'conflicting-custom-field-value'; + + // Save the custom field on Contact + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: customFieldName, value: conflictingFieldValue, overwrite: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + // Fetch the visitor to validate custom fields are properly set. + await request + .get(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor).to.have.property('livechatData'); + expect(res.body.visitor.livechatData).to.have.property(customFieldName, customFieldValue); + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, customFieldValue); + + // Validate custom fields contain both entries, indicating conflict criteria + expect(res.body.contact.conflictingFields).to.have.lengthOf(1); + expect(res.body.contact.conflictingFields[0]).to.have.property('field', `customFields.${customFieldName}`); + expect(res.body.contact.conflictingFields[0]).to.have.property('value', conflictingFieldValue); + }); + }); + + it('should not save the custom field as a conflict on Contact when overwrite is false but custom field is not registered yet', async () => { + await createCustomField({ + searchable: true, + field: `${customFieldName}_3`, + label: `${customFieldName}_3`, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + + // Save the custom field on Contact + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: `${customFieldName}_3`, value: customFieldValue, overwrite: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + // Fetch the visitor to validate custom fields are properly set. + await request + .get(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor).to.have.property('livechatData'); + expect(res.body.visitor.livechatData).to.have.property(`${customFieldName}_3`, customFieldValue); + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(`${customFieldName}_3`, customFieldValue); + + // Validate custom fields contain both entries, indicating conflict criteria + expect(res.body.contact.conflictingFields).to.have.lengthOf(1); + expect(res.body.contact.conflictingFields[0]).to.not.have.property('field', `customFields.${customFieldName}_3`); + }); + }); + + describe('conflictingField not nullish', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + let contactId2: string | undefined; + + before(async () => { + visitor = await createVisitor(); + await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitor.name, + emails: [visitor.visitorEmails?.[0].address], + phones: [visitor.phone?.[0].phoneNumber], + }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + + contactId2 = res.body.contactId; + }); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should not save contact conflictingFields as nullish if not modified', async () => { + await request + .post(api('livechat/custom.field')) + .send({ token: visitor.token, key: `${customFieldName}`, value: customFieldValue, overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: contactId2 }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(`${customFieldName}`, customFieldValue); + + // Validate conflictingFields was not saved as null + expect(res.body.contact).to.not.have.property('conflictingFields'); + }); + }); + + it('should not save contact conflictingFields as nullish if not modified (through visitor update)', async () => { + await request + .post(api('livechat/visitor')) + .set(credentials) + .send({ + visitor: { + token: visitor.token, + customFields: [{ key: `${customFieldName}`, value: customFieldValue, overwrite: true }], + }, + }); + + // Fetch the visitor's contact to validate custom fields are properly set. + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: contactId2 }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(`${customFieldName}`, customFieldValue); + + // Validate conflictingFields was not saved as null + expect(res.body.contact).to.not.have.property('conflictingFields'); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts new file mode 100644 index 0000000..2e19f48 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -0,0 +1,1473 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; +import moment from 'moment'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { addOrRemoveAgentFromDepartment, createDepartmentWithAnOnlineAgent, deleteDepartment } from '../../../data/livechat/department'; +import { + closeOmnichannelRoom, + deleteVisitor, + makeAgentAvailable, + placeRoomOnHold, + sendAgentMessage, + sendMessage, + startANewLivechatRoomAndTakeIt, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent, createBotAgent } from '../../../data/livechat/users'; +import { sleep } from '../../../data/livechat/utils'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updateEESetting, updateSetting } from '../../../data/permissions.helper'; +import { deleteUser } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - dashboards', function () { + // This test is expected to take more time since we're simulating real time conversations to verify analytics + this.timeout(60000); + + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await updateSetting('Omnichannel_enable_department_removal', true); + }); + + let department: ILivechatDepartment; + let roomList: IOmnichannelRoom[]; + let visitorList: ILivechatVisitor[]; + const agents: { + credentials: Credentials; + user: IUser & { username: string }; + }[] = []; + let avgClosedRoomChatDuration = 0; + + const inactivityTimeout = 3; + + const TOTAL_MESSAGES = { + min: 5, + max: 10, + }; + const DELAY_BETWEEN_MESSAGES = { + min: 1000, + max: (inactivityTimeout - 1) * 1000, + }; + const TOTAL_ROOMS = 7; + + const simulateRealtimeConversation = async (chatInfo: Awaited>[]) => { + const promises = chatInfo.map(async (info) => { + const { room, visitor } = info; + + // send a few messages + const numberOfMessages = Random.between(TOTAL_MESSAGES.min, TOTAL_MESSAGES.max); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of Array(numberOfMessages - 1).keys()) { + // flip a coin to decide who will send the message + const willSendFromAgent = Random.between(0, 1) === 1; + + if (willSendFromAgent) { + await sendAgentMessage(room._id); + } else { + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + } + + const delay = Random.between(DELAY_BETWEEN_MESSAGES.min, DELAY_BETWEEN_MESSAGES.max); + await sleep(delay); + } + + // Last message is always from visitor so that the chat doesn't get abandoned due to + // "Livechat_visitor_inactivity_timeout" setting + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + }); + + await Promise.all(promises); + }; + + before(async () => { + if (!IS_EE) { + return; + } + + await updateSetting('Livechat_visitor_inactivity_timeout', inactivityTimeout); + await updateSetting('Livechat_enable_business_hours', false); + + // create dummy test data for further tests + const { department: createdDept, agent: agent1 } = await createDepartmentWithAnOnlineAgent(); + department = createdDept; + + const agent2 = await createAnOnlineAgent(); + await addOrRemoveAgentFromDepartment(department._id, { agentId: agent2.user._id, username: agent2.user.username }, true); + agents.push(agent1); + agents.push(agent2); + + const roomCreationStart = moment(); + // start a few chats + const promises = Array.from(Array(TOTAL_ROOMS).keys()).map((i) => { + // 2 rooms by agent 1 + if (i < 2) { + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent1.credentials }); + } + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent2.credentials }); + }); + + const chatInfo = await Promise.all(promises); + + roomList = chatInfo.map((info) => info.room); + visitorList = chatInfo.map((info) => info.visitor); + + // simulate messages being exchanged between agents and visitors + await simulateRealtimeConversation(chatInfo); + + // put a chat on hold + await sendAgentMessage(chatInfo[1].room._id); + await placeRoomOnHold(chatInfo[1].room._id); + // close a chat + await closeOmnichannelRoom(chatInfo[4].room._id); + const room5ChatDuration = moment().diff(roomCreationStart, 'seconds'); + // close an abandoned chat + await sendAgentMessage(chatInfo[5].room._id); + await sleep(inactivityTimeout * 1000); // wait for the chat to be considered abandoned + await closeOmnichannelRoom(chatInfo[5].room._id); + const room6ChatDuration = moment().diff(roomCreationStart, 'seconds'); + + avgClosedRoomChatDuration = (room5ChatDuration + room6ChatDuration) / 2; + }); + + after(async () => { + if (!IS_EE) { + return; + } + await Promise.allSettled(roomList.map((room) => closeOmnichannelRoom(room._id))); + await Promise.allSettled(visitorList.map((visitor) => deleteVisitor(visitor.token))); + await deleteDepartment(department._id); + await Promise.allSettled(agents.map((agent) => deleteUser(agent.user))); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('livechat/analytics/dashboards/conversation-totalizers', () => { + const expectedMetrics = [ + 'Total_conversations', + 'Open_conversations', + 'On_Hold_conversations', + 'Total_messages', + 'Busiest_time', + 'Total_abandoned_chats', + 'Total_visitors', + ]; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/conversation-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an array of conversation totalizers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/conversation-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.totalizers).to.be.an('array'); + (res.body.totalizers as { title: string; value: string }[]).forEach( + (prop) => expect(expectedMetrics.includes(prop.title)).to.be.true, + ); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/conversation-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + expect(result.body.totalizers).to.have.lengthOf(5); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 60 }, + { title: 'Total_visitors', value: 7 }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + + const totalMessages = result.body.totalizers.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + }); + }); + + describe('livechat/analytics/dashboards/productivity-totalizers', () => { + const expectedMetrics = ['Avg_response_time', 'Avg_first_response_time', 'Avg_reaction_time', 'Avg_of_waiting_time']; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/productivity-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an array of productivity totalizers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/productivity-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.totalizers).to.be.an('array'); + (res.body.totalizers as { title: string; value: string }[]).forEach( + (prop) => expect(expectedMetrics.includes(prop.title)).to.be.true, + ); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = [ + // // There's a bug in the code for calculation of these 3 values. + // // Due to which it always return 0 + // { title: 'Avg_response_time', value: '00:00:00' }, + // { title: 'Avg_first_response_time', value: '00:00:00' }, + // { title: 'Avg_reaction_time', value: '00:00:00' }, + + // { title: 'Avg_of_waiting_time', value: '00:00:03' }, // approx 3, 5 delta + // ]; + + const avgWaitingTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_waiting_time'); + expect(avgWaitingTime).to.not.be.undefined; + + /* const avgWaitingTimeValue = moment.duration(avgWaitingTime.value).asSeconds(); + expect(avgWaitingTimeValue).to.be.closeTo(DELAY_BETWEEN_MESSAGES.max / 1000, 5); */ + }); + }); + + describe('livechat/analytics/dashboards/chats-totalizers', () => { + const expectedMetrics = ['Total_abandoned_chats', 'Avg_of_abandoned_chats', 'Avg_of_chat_duration_time']; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/chats-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an array of chats totalizers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/chats-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.totalizers).to.be.an('array'); + (res.body.totalizers as { title: string; value: string }[]).forEach( + (prop) => expect(expectedMetrics.includes(prop.title)).to.be.true, + ); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/chats-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = [ + { title: 'Total_abandoned_chats', value: 1 }, + { title: 'Avg_of_abandoned_chats', value: '14%' }, + // { title: 'Avg_of_chat_duration_time', value: '00:00:01' }, + ]; + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + expected.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const resultAverageChatDuration = result.body.totalizers.find((item: any) => item.title === 'Avg_of_chat_duration_time'); + expect(resultAverageChatDuration).to.not.be.undefined; + + const resultAverageChatDurationValue = moment.duration(resultAverageChatDuration.value).asSeconds(); + expect(resultAverageChatDurationValue).to.be.closeTo(avgClosedRoomChatDuration, 5); // Keep a margin of 3 seconds + }); + }); + + describe('livechat/analytics/dashboards/agents-productivity-totalizers', () => { + const expectedMetrics = ['Busiest_time', 'Avg_of_available_service_time', 'Avg_of_service_time']; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/agents-productivity-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an array of agents productivity totalizers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/agents-productivity-totalizers')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.totalizers).to.be.an('array'); + (res.body.totalizers as { title: string; value: string }[]).forEach( + (prop) => expect(expectedMetrics.includes(prop.title)).to.be.true, + ); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/agents-productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // [ + // { title: 'Busiest_time', value: '- -' }, + // { title: 'Avg_of_available_service_time', value: '00:00:00' }, + // { title: 'Avg_of_service_time', value: '00:00:16' } approx 17, 6 delta + // ], + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + const avgServiceTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_service_time'); + + expect(avgServiceTime).to.not.be.undefined; + const avgServiceTimeValue = moment.duration(avgServiceTime.value).asSeconds(); + const minChatDuration = (DELAY_BETWEEN_MESSAGES.min * TOTAL_MESSAGES.min) / 1000; + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + expect(avgServiceTimeValue).to.be.closeTo((minChatDuration + maxChatDuration) / 2, 10); + }); + }); + + describe('livechat/analytics/dashboards/charts/chats', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an array of productivity totalizers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('open'); + expect(res.body).to.have.property('closed'); + expect(res.body).to.have.property('queued'); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + open: 4, + closed: 2, + queued: 0, + onhold: 1, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); + }); + + describe('livechat/analytics/dashboards/charts/chats-per-agent', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats-per-agent')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an object with open and closed chats by agent', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats-per-agent')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-agent')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + agent0: { open: 1, closed: 0, onhold: 1 }, + agent1: { open: 3, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const agent0 = result.body[agents[0].user.username as string]; + const agent1 = result.body[agents[1].user.username as string]; + + Object.entries(expected.agent0).forEach(([key, value]) => { + expect(agent0).to.have.property(key, value); + }); + Object.entries(expected.agent1).forEach(([key, value]) => { + expect(agent1).to.have.property(key, value); + }); + }); + }); + + describe('livechat/analytics/dashboards/charts/agents-status', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/agents-status')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an object with agents status metrics', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/agents-status')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offline'); + expect(res.body).to.have.property('away'); + expect(res.body).to.have.property('busy'); + expect(res.body).to.have.property('available'); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + await Promise.all(agents.map((agent) => makeAgentAvailable(agent.credentials))); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/agents-status')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // TODO: We can improve tests further by creating some agents with different status + const expected = { + offline: 0, + away: 0, + busy: 0, + available: 2, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); + }); + + describe('livechat/analytics/dashboards/charts/chats-per-department', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats-per-department')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an object with open and closed chats by department', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/chats-per-department')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-department')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + department0: { open: 5, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const department0 = result.body[department.name]; + + Object.entries(expected.department0).forEach(([key, value]) => { + expect(department0).to.have.property(key, value); + }); + }); + }); + + describe('livechat/analytics/dashboards/charts/timings', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/timings')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an object with open and closed chats by department', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/dashboards/charts/timings')) + .query({ + start: '2019-10-25T15:08:17.248Z', + end: '2019-12-08T15:08:17.248Z', + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('response'); + expect(res.body).to.have.property('reaction'); + expect(res.body).to.have.property('chatDuration'); + expect(res.body.response).to.have.property('avg'); + expect(res.body.response).to.have.property('longest'); + expect(res.body.reaction).to.have.property('avg'); + expect(res.body.reaction).to.have.property('longest'); + expect(res.body.chatDuration).to.have.property('avg'); + expect(res.body.chatDuration).to.have.property('longest'); + }); + }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/timings')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = { + // response: { avg: 0, longest: 0.207 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // reaction: { avg: 0, longest: 0.221 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // chatDuration: { avg: 0, longest: 0.18 }, // avg should be about avgClosedRoomChatDuration, and longest should be greater than avgClosedRoomChatDuration and within delta of 20 + // success: true, + // }; + + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + + const responseValues = result.body.response; + expect(responseValues).to.have.property('avg'); + expect(responseValues).to.have.property('longest'); + expect(responseValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(responseValues.longest).to.be.lessThan(maxChatDuration); + + const reactionValues = result.body.reaction; + expect(reactionValues).to.have.property('avg'); + expect(reactionValues).to.have.property('longest'); + expect(reactionValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(reactionValues.longest).to.be.lessThan(maxChatDuration); + + const chatDurationValues = result.body.chatDuration; + expect(chatDurationValues).to.have.property('avg'); + expect(chatDurationValues).to.have.property('longest'); + expect(chatDurationValues.avg).to.be.closeTo(avgClosedRoomChatDuration, 5); + expect(chatDurationValues.longest).to.be.greaterThan(avgClosedRoomChatDuration); + expect(chatDurationValues.longest).to.be.lessThan(avgClosedRoomChatDuration + 20); + }); + }); + + describe('livechat/analytics/dashboards/charts-data', () => { + it('should return the correct data structure for the charts', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/dashboards/charts-data')) + .query({ chartName: 'Total_conversations', start: yesterday, end: today }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + expect(result.body).to.have.property('chartLabel', 'Total_conversations'); + expect(result.body).to.have.property('dataLabels').to.be.an('array'); + expect(result.body).to.have.property('dataPoints').to.be.an('array'); + }); + + // it('should return the correct data structure but empty for unavailable data'); + }); + + describe('livechat/analytics/agent-overview', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'Total_conversations' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an "invalid-chart-name error" when the chart name is empty', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should return empty when chart name is invalid', async () => { + await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'invalid-chart-name' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(Object.keys(res.body)).to.have.lengthOf(1); + }); + }); + it('should return an array of agent overview data', async () => { + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'Total_conversations' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.head).to.be.an('array'); + expect(result.body.data).to.be.an('array'); + }); + (IS_EE ? it : it.skip)('should return agent overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: today, name: 'Total_conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.have.lengthOf(2); + + const user1Data = result.body.data.find((data: any) => data.name === agents[0].user.username); + const user2Data = result.body.data.find((data: any) => data.name === agents[1].user.username); + + expect(user1Data).to.not.be.undefined; + expect(user2Data).to.not.be.undefined; + + expect(user1Data).to.have.property('value', '28.57%'); + expect(user2Data).to.have.property('value', '71.43%'); + }); + (IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total conversations', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Total_conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', '%_of_conversations'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average chat durations', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_chat_duration', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_chat_duration'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total messages', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Total_messages', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Total_messages'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average first response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_first_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_first_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for best first response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Best_first_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Best_first_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average response times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_response_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_response_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + (IS_EE ? it : it.skip)( + 'should only return results in the provided date interval when searching for average reaction times', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: yesterday, name: 'Avg_reaction_time', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body.head).to.be.an('array').with.lengthOf(2); + expect(result.body.head[0]).to.have.property('name', 'Agent'); + expect(result.body.head[1]).to.have.property('name', 'Avg_reaction_time'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.empty; + }, + ); + }); + + describe('[livechat/analytics/agent-overview] - Average first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let forwardAgent: { credentials: Credentials; user: IUser & { username: string } }; + let botAgent: { credentials: Credentials; user: IUser }; + let originalFirstResponseTimeInSeconds: number; + let roomId: string; + const firstDelayInSeconds = 4; + const secondDelayInSeconds = 8; + const roomsToClose: IOmnichannelRoom[] = []; + const visitorsToDelete: ILivechatVisitor[] = []; + + before(async () => { + agent = await createAnOnlineAgent(); + forwardAgent = await createAnOnlineAgent(); + botAgent = await createBotAgent(); + + await updateSetting('Omnichannel_Metrics_Ignore_Automatic_Messages', true); + }); + + after(async () => { + await Promise.allSettled(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.allSettled(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([ + deleteUser(agent.user), + deleteUser(forwardAgent.user), + deleteUser(botAgent.user), + updateSetting('Omnichannel_Metrics_Ignore_Automatic_Messages', false), + ]); + }); + + it('should return no average response time for an agent if no response has been sent in the period', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' first response time metric", async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + await sleep(firstDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds); + }); + + it("should not consider bot messages in agent's first response time metric if setting is enabled", async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: botAgent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + + roomId = room._id; + + await sleep(firstDelayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', botAgent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === botAgent.user.username, + ); + expect(agentData).to.be.undefined; + }); + + it('should correctly associate the first response time to the first agent who responded the room', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: forwardAgent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + await sendAgentMessage(roomId, 'first response from agent', forwardAgent.credentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId, + userId: agent.user._id, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await sendAgentMessage(roomId, 'first response from forwarded agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + // The agent to whom the room has been forwarded shouldn't have their average first response time changed + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const averageFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.equal(averageFirstResponseTimeInSeconds); + + // A room's first response time should be attached to the agent who first responded to it even if it has been forwarded + const forwardAgentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === forwardAgent.user.username, + ); + expect(forwardAgentData).to.not.be.undefined; + expect(forwardAgentData).to.have.property('name', forwardAgent.user.username); + expect(forwardAgentData).to.have.property('value'); + const forwardAgentAverageFirstResponseTimeInSeconds = moment.duration(forwardAgentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.greaterThan(forwardAgentAverageFirstResponseTimeInSeconds); + }); + + it('should correctly calculate the average time of first responses for an agent', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + await sleep(secondDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const averageFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThan(originalFirstResponseTimeInSeconds); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThanOrEqual((firstDelayInSeconds + secondDelayInSeconds) / 2); + expect(averageFirstResponseTimeInSeconds).to.be.lessThan(secondDelayInSeconds); + }); + }); + + describe('[livechat/analytics/agent-overview] - Best first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let forwardAgent: { credentials: Credentials; user: IUser & { username: string } }; + let originalBestFirstResponseTimeInSeconds: number; + let roomId: string; + const roomsToClose: IOmnichannelRoom[] = []; + const visitorsToDelete: ILivechatVisitor[] = []; + + before(async () => { + agent = await createAnOnlineAgent(); + forwardAgent = await createAnOnlineAgent(); + }); + + after(async () => { + await Promise.allSettled(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.allSettled(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([deleteUser(agent.user), deleteUser(forwardAgent.user)]); + }); + + it('should return no best response time for an agent if no response has been sent in the period', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' best response time metric", async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + const delayInSeconds = 4; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalBestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalBestFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(delayInSeconds); + }); + + it('should correctly calculate the best first response time for an agent and there are multiple first responses in the period', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + const delayInSeconds = 6; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds); + }); + + it('should correctly associate best first response time to the first agent who responded the room', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ agent: forwardAgent.credentials }); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + roomId = room._id; + + await sendAgentMessage(roomId, 'first response from agent', forwardAgent.credentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId, + userId: agent.user._id, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await sendAgentMessage(roomId, 'first response from forwarded agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + // The agent to whom the room has been forwarded shouldn't have their best first response time changed + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds); + + // A room's first response time should be attached to the agent who first responded to it even if it has been forwarded + const forwardAgentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === forwardAgent.user.username, + ); + expect(forwardAgentData).to.not.be.undefined; + expect(forwardAgentData).to.have.property('name', forwardAgent.user.username); + expect(forwardAgentData).to.have.property('value'); + const forwardAgentBestFirstResponseTimeInSeconds = moment.duration(forwardAgentData.value).asSeconds(); + expect(forwardAgentBestFirstResponseTimeInSeconds).to.be.lessThan(originalBestFirstResponseTimeInSeconds); + }); + }); + + describe('livechat/analytics/overview', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'Conversations' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return an "invalid-chart-name error" when the chart name is empty', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .get(api('livechat/analytics/overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should return empty when chart name is invalid', async () => { + await request + .get(api('livechat/analytics/overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'invalid-chart-name' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(Object.keys(res.body)).to.have.lengthOf(1); + }); + }); + it('should return an array of analytics overview data', async () => { + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: '2020-01-01', to: '2020-01-02', name: 'Conversations' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + expect(result.body).to.have.lengthOf(7); + expect(result.body[0]).to.have.property('title', 'Total_conversations'); + expect(result.body[0]).to.have.property('value', 0); + }); + (IS_EE ? it : it.skip)('should return analytics overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: today, name: 'Conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 6 }, + // { title: 'Busiest_day', value: moment().format('dddd') }, + { title: 'Conversations_per_day', value: '3.50' }, + // { title: 'Busiest_time', value: '' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + + const totalMessages = result.body.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + }); + (IS_EE ? it : it.skip)( + 'should only consider conversations in the provided time range when returning analytics conversations overview data', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: yesterday, name: 'Conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Conversations_per_day', value: '0.00' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + }, + ); + (IS_EE ? it : it.skip)( + 'should only consider conversations in the provided time range when returning analytics productivity overview data', + async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: yesterday, name: 'Productivity', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + }, + ); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/05-inquiries.ts b/apps/meteor/tests/end-to-end/api/livechat/05-inquiries.ts new file mode 100644 index 0000000..fc74611 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/05-inquiries.ts @@ -0,0 +1,611 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, ILivechatInquiryRecord, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { deleteDepartment } from '../../../data/livechat/department'; +import { + closeOmnichannelRoom, + createAgent, + createDepartment, + createLivechatRoom, + createVisitor, + deleteVisitor, + fetchInquiry, + getLivechatRoomInfo, + makeAgentAvailable, + startANewLivechatRoomAndTakeIt, + takeInquiry, +} from '../../../data/livechat/rooms'; +import { + removePermissionFromAllRoles, + restorePermissionToRoles, + updateEESetting, + updatePermission, + updateSetting, +} from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, login, deleteUser } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; +import { retry } from '../helpers/retry'; + +describe('LIVECHAT - inquiries', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + }); + + describe('livechat/inquiries.list', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request.get(api('livechat/inquiries.list')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an array of inquiries', async () => { + await updatePermission('view-livechat-manager', ['admin']); + await request + .get(api('livechat/inquiries.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.inquiries).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + }); + + describe('livechat/inquiries.getOne', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + await createAgent(); + await makeAgentAvailable(); + + visitor = await createVisitor(); + + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request + .get(api('livechat/inquiries.getOne')) + .query({ roomId: 'room-id' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should return a inquiry', async () => { + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/inquiries.getOne')) + .query({ roomId: 'room-id' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inquiry'); + }); + }); + + it('should get an inquiry by room id', async () => { + const inquiry = await fetchInquiry(room._id); + await request + .get(api(`livechat/inquiries.getOne`)) + .query({ roomId: room._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inquiry'); + expect(res.body.inquiry).to.have.property('_id', inquiry._id); + expect(res.body.inquiry).to.have.property('rid', room._id); + expect(res.body.inquiry).to.have.property('ts'); + expect(res.body.inquiry.ts).to.be.a('string'); + expect(res.body.inquiry).to.have.property('status', 'queued'); + expect(res.body.inquiry).to.have.property('name', visitor.name); + expect(res.body.inquiry).to.have.property('t', 'l'); + expect(res.body.inquiry).to.have.property('priorityWeight'); + expect(res.body.inquiry).to.have.property('estimatedWaitingTimeQueue'); + expect(res.body.inquiry.source).to.have.property('type', 'api'); + expect(res.body.inquiry).to.have.property('_updatedAt'); + expect(res.body.inquiry).to.have.property('v').and.be.an('object'); + expect(res.body.inquiry.v).to.have.property('_id', visitor._id); + }); + }); + }); + + describe('POST livechat/inquiries.take', () => { + let takenRoom: IOmnichannelRoom; + let servedByRoom: IOmnichannelRoom; + let visitor: ILivechatVisitor; + let visitor2: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + visitor2 = await createVisitor(); + + takenRoom = await createLivechatRoom(visitor.token); + servedByRoom = await createLivechatRoom(visitor2.token); + }); + + after(async () => { + await closeOmnichannelRoom(takenRoom._id); + await closeOmnichannelRoom(servedByRoom._id); + await deleteVisitor(visitor.token); + await deleteVisitor(visitor2.token); + }); + + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ inquiryId: 'room-id' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should throw an error when userId is provided but is invalid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ inquiryId: 'room-id', userId: 'invalid-user-id' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should throw an error if inquiryId is not an string', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ inquiryId: { regexxxx: 'bla' }, userId: 'user-id' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should take an inquiry if all params are good', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + const agent = await createAgent(); + await makeAgentAvailable(); + const inquiry = await fetchInquiry(takenRoom._id); + + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ + inquiryId: inquiry._id, + userId: agent._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + const inquiry2 = (await fetchInquiry(takenRoom._id)) as ILivechatInquiryRecord; + expect(inquiry2.source?.type).to.equal('api'); + expect(inquiry2.status).to.equal('taken'); + }); + it('should mark a taken room as servedBy me', async () => { + const agent = await createAgent(); + await makeAgentAvailable(); + const inquiry = await fetchInquiry(servedByRoom._id); + + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ + inquiryId: inquiry._id, + userId: agent._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const roomInfo = await getLivechatRoomInfo(servedByRoom._id); + + expect(roomInfo).to.have.property('servedBy').that.is.an('object'); + expect(roomInfo.servedBy).to.have.property('_id', 'rocketchat.internal.admin.test'); + }); + }); + + describe('livechat/inquiries.queuedForUser', () => { + let testUser: { user: IUser; credentials: { [key: string]: string } }; + const departmentsToDelete: ILivechatDepartment[] = []; + const visitorsToDelete: ILivechatVisitor[] = []; + const roomsToClose: IOmnichannelRoom[] = []; + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await updateSetting('Omnichannel_enable_department_removal', true); + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + after(async () => { + await Promise.allSettled(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.allSettled(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.allSettled(departmentsToDelete.map((dep) => deleteDepartment(dep._id))); + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await updateSetting('Omnichannel_enable_department_removal', false); + await deleteUser(testUser.user); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request.get(api('livechat/inquiries.queuedForUser')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an array of inquiries', async () => { + await restorePermissionToRoles('view-l-room'); + await request + .get(api('livechat/inquiries.queuedForUser')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.inquiries).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should validate all returned inquiries are queued', async () => { + await request + .get(api('livechat/inquiries.queuedForUser')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.inquiries).to.be.an('array'); + for (const inquiry of res.body.inquiries) { + expect(inquiry).to.have.property('status', 'queued'); + } + }); + }); + it('should return only public inquiries for a user with no departments', async () => { + const { body } = await request + .get(api('livechat/inquiries.queuedForUser')) + .set(testUser.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.inquiries).to.be.an('array'); + for (const inq of body.inquiries) { + expect(inq).to.not.have.property('department'); + expect(inq).to.have.property('status', 'queued'); + } + }); + (IS_EE ? it : it.skip)('should return inquiries of the same department as the user', async () => { + const dep = await createDepartment([{ agentId: testUser.user._id }]); + departmentsToDelete.push(dep); + const visitor = await createVisitor(dep._id); + visitorsToDelete.push(visitor); + const room = await createLivechatRoom(visitor.token); + roomsToClose.push(room); + + const { body } = await request + .get(api('livechat/inquiries.queuedForUser')) + .set(testUser.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body.inquiries).to.be.an('array'); + const depInq = body.inquiries.filter((inq: { department: string }) => inq.department === dep._id); + + expect(depInq.length).to.be.equal(1); + }); + (IS_EE ? it : it.skip)('should not return an inquiry of a department the user is not part of', async () => { + const dep = await createDepartment(); + departmentsToDelete.push(dep); + const visitor = await createVisitor(dep._id); + visitorsToDelete.push(visitor); + const room = await createLivechatRoom(visitor.token); + roomsToClose.push(room); + + const { body } = await request + .get(api('livechat/inquiries.queuedForUser')) + .set(testUser.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body.inquiries).to.be.an('array'); + const depInq = body.inquiries.filter((inq: { department: string }) => inq.department === dep._id); + + expect(depInq.length).to.be.equal(0); + }); + }); + + describe('livechat/inquiries.returnAsInquiry', () => { + let testUser: { user: IUser; credentials: Credentials }; + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + after(async () => { + await deleteUser(testUser.user); + }); + + it('should throw an error if user doesnt have view-l-room permission', async () => { + await removePermissionFromAllRoles('view-l-room'); + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ roomId: 'test' }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(body).to.have.property('success', false); + expect(body.error).to.have.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + it('should fail if provided room doesnt exists', async () => { + await restorePermissionToRoles('view-l-room'); + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ + roomId: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(body).to.have.property('success', false); + expect(body).to.have.property('error', 'error-room-not-found'); + }); + it('should fail if room is not a livechat room', async () => { + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ roomId: 'GENERAL' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(body).to.have.property('success', false); + expect(body).to.have.property('error', 'error-room-not-found'); + }); + it('should fail if room is closed', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(body).to.have.property('success', false); + expect(body).to.have.property('error', 'room-closed'); + + await deleteVisitor(visitor.token); + }); + describe('no serving', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + it('should fail if no one is serving the room', async () => { + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('result', false); + }); + }); + + let inquiry: ILivechatInquiryRecord; + let room: IOmnichannelRoom; + let returnAsInquiryDep: ILivechatDepartment; + let returnAsInquiryVisitor: ILivechatVisitor; + (IS_EE ? it : it.skip)('should move a room back to queue', async () => { + await updateSetting('Omnichannel_enable_department_removal', true); + returnAsInquiryDep = await createDepartment([{ agentId: testUser.user._id }]); + returnAsInquiryVisitor = await createVisitor(returnAsInquiryDep._id); + room = await createLivechatRoom(returnAsInquiryVisitor.token); + const inq = await fetchInquiry(room._id); + inquiry = inq; + await takeInquiry(inq._id, testUser.credentials); + + const { body } = await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(testUser.credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('result', true); + }); + (IS_EE ? it : it.skip)('should appear on users queued elements', async () => { + const { body } = await request + .get(api('livechat/inquiries.queuedForUser')) + .set(testUser.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body.inquiries).to.be.an('array'); + const depInq = body.inquiries.filter((inq: { _id: string }) => inq._id === inquiry._id); + + expect(depInq.length).to.be.equal(1); + + await closeOmnichannelRoom(room._id); + await deleteVisitor(returnAsInquiryVisitor.token); + await deleteDepartment(returnAsInquiryDep._id); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + }); + + describe('keep inquiry last message updated', () => { + let room: any; + let visitor: any; + let agent: any; + + before(async () => { + agent = await createAgent(); + visitor = await createVisitor(); + + await makeAgentAvailable(); + room = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor.token); + }); + + it('should update inquiry last message', async () => { + const msgText = `update inquiry ${Date.now()}`; + + await request.post(api('livechat/message')).send({ token: visitor.token, rid: room._id, msg: msgText }).expect(200); + + const inquiry = await fetchInquiry(room._id); + + expect(inquiry).to.have.property('_id', inquiry._id); + expect(inquiry).to.have.property('rid', room._id); + expect(inquiry).to.have.property('lastMessage'); + expect(inquiry.lastMessage).to.have.property('msg', msgText); + }); + + it('should update room last message after inquiry is taken', async () => { + const msgText = `update room ${Date.now()}`; + + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(credentials) + .send({ + inquiryId: inquiry._id, + userId: agent._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request.post(api('livechat/message')).send({ token: visitor.token, rid: room._id, msg: msgText }).expect(200); + + // check room + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo).to.have.property('lastMessage'); + expect(roomInfo.lastMessage).to.have.property('msg', msgText); + }); + + it('should have the correct last message when room is returned to queue', async () => { + const msgText = `return to queue ${Date.now()}`; + + await request.post(api('livechat/message')).send({ token: visitor.token, rid: room._id, msg: msgText }).expect(200); + + await request + .post(api('livechat/inquiries.returnAsInquiry')) + .set(credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + const inquiry = await fetchInquiry(room._id); + + expect(inquiry).to.have.property('_id', inquiry._id); + expect(inquiry).to.have.property('rid', room._id); + expect(inquiry).to.have.property('lastMessage'); + expect(inquiry.lastMessage).to.have.property('msg', msgText); + }); + }); + + (IS_EE ? describe : describe.skip)('Auto Transfer Scheduler - Manual_Selection', () => { + let testRoom: IOmnichannelRoom; + let testDepartment: ILivechatDepartment; + let testVisitor: ILivechatVisitor; + before(async () => { + // seconds + await Promise.all([ + updateSetting('Livechat_auto_transfer_chat_timeout', 3), + createAgent(), + updateSetting('Omnichannel_enable_department_removal', true), + ]); + testDepartment = await createDepartment([{ agentId: 'rocketchat.internal.admin.test' }]); + }); + + after(async () => { + await deleteVisitor(testVisitor.token); + await deleteDepartment(testDepartment._id); + await Promise.all([ + updateSetting('Livechat_auto_transfer_chat_timeout', 0), + updateSetting('Omnichannel_enable_department_removal', false), + ]); + }); + + it('should create a room and schedule it for transfer', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ departmentId: testDepartment._id }); + testVisitor = visitor; + // The room returned is not updated :( + const updatedRoom = await getLivechatRoomInfo(room._id); + + expect(updatedRoom).to.have.property('servedBy').that.is.an('object'); + testRoom = updatedRoom; + }); + it('should return a chat to the queue when not answered after 3 seconds', async () => { + await retry( + `The agenda has a 'heartbeat' of 3 seconds (for testing environment), so its likely that the first fetch will not have the room updated`, + async () => { + const inquiry = await fetchInquiry(testRoom._id); + const room = await getLivechatRoomInfo(testRoom._id); + + expect(room).to.not.have.property('servedBy'); + expect(inquiry).to.have.property('status', 'queued'); + + await closeOmnichannelRoom(testRoom._id); + }, + { + delayMs: 3000, + }, + ); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts b/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts new file mode 100644 index 0000000..0ccc395 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/06-integrations.ts @@ -0,0 +1,300 @@ +import type { ILivechatVisitor, IOmnichannelRoom, ISetting } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { + closeOmnichannelRoom, + createAgent, + createLivechatRoom, + createVisitor, + deleteVisitor, + getLivechatRoomInfo, + startANewLivechatRoomAndTakeIt, +} from '../../../data/livechat/rooms'; +import { sleep } from '../../../data/livechat/utils'; +import { getLivechatVisitorByToken } from '../../../data/livechat/visitor'; +import { updatePermission, updateSetting } from '../../../data/permissions.helper'; + +describe('LIVECHAT - Integrations', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('livechat/integrations.settings', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request.get(api('livechat/integrations.settings')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an array of settings', (done) => { + updatePermission('view-livechat-manager', ['admin']) + .then(async () => { + const res = await request + .get(api('livechat/integrations.settings')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body.settings).to.be.an('array'); + const settingIds = res.body.settings.map((setting: ISetting) => setting._id); + expect(settingIds).to.include.members([ + 'Livechat_webhookUrl', + 'Livechat_secret_token', + 'Livechat_http_timeout', + 'Livechat_webhook_on_start', + 'Livechat_webhook_on_close', + 'Livechat_webhook_on_chat_taken', + 'Livechat_webhook_on_chat_queued', + 'Livechat_webhook_on_forward', + 'Livechat_webhook_on_offline_msg', + 'Livechat_webhook_on_visitor_message', + 'Livechat_webhook_on_agent_message', + ]); + }) + .then(done) + .catch(done); + }); + }); + + describe('Incoming SMS', () => { + let smsVisitor: ILivechatVisitor; + let smsRoom: string; + + before(async () => { + await updateSetting('SMS_Enabled', true); + await updateSetting('SMS_Service', ''); + }); + + after(async () => { + await closeOmnichannelRoom(smsRoom); + + await deleteVisitor(smsVisitor.token); + + await updateSetting('SMS_Default_Omnichannel_Department', ''); + await updateSetting('SMS_Service', 'twilio'); + }); + + // Twilio sends the body as a x-www-form-urlencoded, the tests should do the same + describe('POST livechat/sms-incoming/:service', () => { + it('should throw an error if SMS is disabled', async () => { + await updateSetting('SMS_Enabled', false); + await request + .post(api('livechat/sms-incoming/twilio')) + .set(credentials) + .send('from=%2B123456789&body=Hello') + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an error when SMS service is not configured', async () => { + await updateSetting('SMS_Enabled', true); + await request + .post(api('livechat/sms-incoming/twilio')) + .set(credentials) + .send('From=%2B123456789&To=%2B123456789&Body=Hello') + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if SMS_Default_Omnichannel_Department does not exists', async () => { + await updateSetting('SMS_Default_Omnichannel_Department', '123'); + + await request + .post(api('livechat/sms-incoming/twilio')) + .set(credentials) + .send('From=%2B123456789&To=%2B123456789&Body=Hello') + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return headers and as body on success', async () => { + await updateSetting('SMS_Default_Omnichannel_Department', ''); + await updateSetting('SMS_Service', 'twilio'); + + // Create visitor with the phone number that will be used in SMS + smsVisitor = await createVisitor(undefined, 'sms-visitor', undefined, '+12345678910'); + + const response = await request + .post(api('livechat/sms-incoming/twilio')) + .set(credentials) + .send('From=%2B12345678910&To=%2B123456789&Body=Hello') + .expect('Content-Type', 'text/xml') + .expect(200); + + expect(response).to.have.property('text', ''); + + const lastChat = (await getLivechatVisitorByToken(smsVisitor.token)).lastChat?._id; + expect(lastChat).to.exist; + + smsRoom = lastChat as string; + }); + }); + }); + + describe('Livechat - Webhooks', () => { + const webhookUrl = process.env.WEBHOOK_TEST_URL || 'https://httpbin.org'; + + describe('livechat/webhook.test', () => { + it('should fail when user doesnt have view-livechat-webhooks permission', async () => { + await updatePermission('view-livechat-webhooks', []); + const response = await request.post(api('livechat/webhook.test')).set(credentials).expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should fail if setting Livechat_webhookUrl is not set', async () => { + await updateSetting('Livechat_webhookUrl', ''); + await updatePermission('view-livechat-webhooks', ['admin', 'livechat-manager']); + const response = await request.post(api('livechat/webhook.test')).set(credentials).expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return true if webhook test went good', async () => { + await updateSetting('Livechat_webhookUrl', `${webhookUrl}/status/200`); + const response = await request.post(api('livechat/webhook.test')).set(credentials).expect(200); + expect(response.body.success).to.be.true; + }); + it('should fail if webhook test went bad', async () => { + await updateSetting('Livechat_webhookUrl', `${webhookUrl}/status/400`); + await request.post(api('livechat/webhook.test')).set(credentials).expect(400); + }); + }); + + describe('Webhook notifications', () => { + const visitorsToDelete: ILivechatVisitor[] = []; + const roomsToClose: IOmnichannelRoom[] = []; + + before(async () => { + await updateSetting('Livechat_webhookUrl', `${webhookUrl}/anything`); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await createAgent(); + }); + after(async () => { + await Promise.allSettled(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.allSettled(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await updateSetting('Livechat_webhookUrl', ''); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Livechat_webhook_on_start', false); + await updateSetting('Livechat_webhook_on_close', false); + await updateSetting('Livechat_webhook_on_chat_taken', false); + await updateSetting('Livechat_webhook_on_chat_queued', false); + }); + + it('should send a notification on chat start', async () => { + await updateSetting('Livechat_webhook_on_start', true); + + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + await sleep(1000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.crmData).to.be.an('string'); + expect(JSON.parse(roomInfo.crmData as string)) + .to.have.property('json') + .that.has.property('type', 'LivechatSessionStart'); + await updateSetting('Livechat_webhook_on_start', false); + }); + it('should send a notification on chat taken', async () => { + await updateSetting('Livechat_webhook_on_chat_taken', true); + + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + roomsToClose.push(room); + visitorsToDelete.push(visitor); + await sleep(1000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.crmData).to.be.an('string'); + expect(JSON.parse(roomInfo.crmData as string)) + .to.have.property('json') + .that.has.property('type', 'LivechatSessionTaken'); + await updateSetting('Livechat_webhook_on_chat_taken', false); + }); + let queuedRoom: IOmnichannelRoom; + let queuedVisitor: ILivechatVisitor; + it('should send a notification on chat queued', async () => { + await updateSetting('Livechat_webhook_on_chat_queued', true); + + queuedVisitor = await createVisitor(); + queuedRoom = await createLivechatRoom(queuedVisitor.token); + roomsToClose.push(queuedRoom); + visitorsToDelete.push(queuedVisitor); + await sleep(1000); + + const roomInfo = await getLivechatRoomInfo(queuedRoom._id); + + expect(roomInfo.crmData).to.be.an('string'); + expect(JSON.parse(roomInfo.crmData as string)) + .to.have.property('json') + .that.has.property('type', 'LivechatSessionQueued'); + await updateSetting('Livechat_webhook_on_chat_queued', false); + }); + it('should send a notification on chat close', async () => { + await updateSetting('Livechat_webhook_on_close', true); + + await closeOmnichannelRoom(queuedRoom._id); + const idx = roomsToClose.indexOf(queuedRoom); + if (idx > -1) { + roomsToClose.splice(idx, 1); + } + + await sleep(1000); + + const roomInfo = await getLivechatRoomInfo(queuedRoom._id); + + expect(roomInfo.crmData).to.be.an('string'); + expect(JSON.parse(roomInfo.crmData as string)) + .to.have.property('json') + .that.has.property('type', 'LivechatSession'); + await updateSetting('Livechat_webhook_on_close', false); + }); + }); + }); + + describe('omnichannel/integrations', () => { + describe('POST', () => { + it('should update the integration settings if the required parameters are provided', async () => { + const response = await request + .post(api('omnichannel/integrations')) + .set(credentials) + .send({ + LivechatWebhookUrl: 'http://localhost:8080', + LivechatSecretToken: 'asdfasdf', + LivechatHttpTimeout: 3000, + LivechatWebhookOnStart: false, + LivechatWebhookOnClose: false, + LivechatWebhookOnChatTaken: false, + LivechatWebhookOnChatQueued: false, + LivechatWebhookOnForward: false, + LivechatWebhookOnOfflineMsg: false, + LivechatWebhookOnVisitorMessage: false, + LivechatWebhookOnAgentMessage: false, + }) + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should fail if a wrong type is provided', async () => { + const response = await request + .post(api('omnichannel/integrations')) + .set(credentials) + .send({ + LivechatWebhookUrl: 8000, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should fail if a wrong setting is provided', async () => { + const response = await request + .post(api('omnichannel/integrations')) + .set(credentials) + .send({ + LivechatWebhook_url: 'http://localhost:8000', + }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts new file mode 100644 index 0000000..18cc4e5 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts @@ -0,0 +1,1479 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createDepartmentWithAnOnlineAgent, deleteDepartment, addOrRemoveAgentFromDepartment } from '../../../data/livechat/department'; +import { + createVisitor, + createLivechatRoom, + closeOmnichannelRoom, + deleteVisitor, + createAgent, + createDepartment, + getLivechatRoomInfo, + makeAgentAvailable, + updateDepartment, + startANewLivechatRoomAndTakeIt, + fetchInquiry, + takeInquiry, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent, updateLivechatSettingsForUser } from '../../../data/livechat/users'; +import { sleep } from '../../../data/livechat/utils'; +import { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +const cleanupRooms = async () => { + const response = await request + .get(api('livechat/queue')) + .set(credentials) + .query({ count: 100 }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { queue } = response.body; + + for await (const item of queue) { + const { + body: { rooms }, + } = await request.get(api('livechat/rooms')).query({ 'agents[]': item.user._id }).set(credentials); + + await Promise.all( + rooms.map((room: IOmnichannelRoom) => + request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: room._id, comment: faker.lorem.sentence() }), + ), + ); + } +}; + +describe('LIVECHAT - Queue', () => { + before((done) => getCredentials(done)); + + before(async () => + Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + updateEESetting('Livechat_Require_Contact_Verification', 'never'), + updateSetting('Omnichannel_enable_department_removal', true), + + // this cleanup is required since previous tests left the DB dirty + cleanupRooms(), + ]), + ); + + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('livechat/queue', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + await request.get(api('livechat/queue')).set(credentials).expect('Content-Type', 'application/json').expect(403); + await updatePermission('view-l-room', ['admin']); + }); + + it('should return an array of queued metrics', async () => { + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should return an array of queued metrics even requested with count and offset params', async () => { + await request + .get(api('livechat/queue')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + it('should have an empty queue (cause we removed everything above)', async () => { + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.queue).to.be.an('array').that.is.empty; + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('count', 0); + }); + }); + }); + + describe('queue position', () => { + let agent1: { user: IUser }; + let agent2: { user: IUser }; + let agent3: { user: IUser }; + let deptd1: ILivechatDepartment; + let deptd2: ILivechatDepartment; + + const roomsToClose: IOmnichannelRoom[] = []; + const visitors: ILivechatVisitor[] = []; + const usersToDelete: IUser[] = []; + + before(async () => { + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + + deptd1 = department; + agent1 = agent; + + usersToDelete.push(agent.user); + + const newVisitor = await createVisitor(deptd1._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + roomsToClose.push(newRoom); + visitors.push(newVisitor); + }); + + after(async () => { + await deleteDepartment(deptd1._id); + + if (deptd2) { + await deleteDepartment(deptd2._id); + } + + await Promise.all(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.all(visitors.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all(usersToDelete.map((user) => deleteUser(user))); + }); + + it('should have one item in the queue', async () => { + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(1); + + const [queue] = res.body.queue; + + expect(queue).to.have.property('chats', 1); + expect(queue).to.have.nested.property('user._id', agent1.user._id); + expect(queue).to.have.nested.property('department._id', deptd1._id); + }); + }); + + it('should return empty results when filtering by another agent', async () => { + await request + .get(api('livechat/queue')) + .query({ agentId: 'another-agent' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('count', 0); + expect(res.body.queue).to.be.an('array').of.length(0); + }); + }); + + it('should increase chats when a new room for same department is created', async () => { + const newVisitor = await createVisitor(deptd1._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + roomsToClose.push(newRoom); + visitors.push(newVisitor); + + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(1); + + const [queue] = res.body.queue; + + expect(queue).to.have.property('chats', 2); + expect(queue).to.have.nested.property('user._id', agent1.user._id); + expect(queue).to.have.nested.property('department._id', deptd1._id); + }); + }); + + it('should have two items when create room for another agent', async () => { + const { user } = await createAnOnlineAgent(); + await addOrRemoveAgentFromDepartment(deptd1._id, { agentId: user._id, username: user.username }, true); + + agent2 = { user }; + + usersToDelete.push(user); + + const newVisitor = await createVisitor(deptd1._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + roomsToClose.push(newRoom); + visitors.push(newVisitor); + + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(2); + + const [queue1, queue2] = res.body.queue; + + expect(queue1).to.have.property('chats', 2); + expect(queue1).to.have.nested.property('user._id', agent1.user._id); + expect(queue1).to.have.nested.property('department._id', deptd1._id); + + expect(queue2).to.have.property('chats', 1); + expect(queue2).to.have.nested.property('user._id', agent2.user._id); + expect(queue2).to.have.nested.property('department._id', deptd1._id); + }); + }); + + it('should be able to filter for second agent only', async () => { + await request + .get(api('livechat/queue')) + .query({ agentId: agent2.user._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(1); + + const [queue1] = res.body.queue; + + expect(queue1).to.have.property('chats', 1); + expect(queue1).to.have.nested.property('user._id', agent2.user._id); + expect(queue1).to.have.nested.property('department._id', deptd1._id); + }); + }); + + it('should return empty if filter for a department without chats', async () => { + await request + .get(api('livechat/queue')) + .query({ departmentId: 'no-chats' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('count', 0); + expect(res.body.queue).to.be.an('array').of.length(0); + }); + }); + + it('should be able to filter for first department only', async () => { + await request + .get(api('livechat/queue')) + .query({ departmentId: deptd1._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(2); + + const [queue1] = res.body.queue; + + expect(queue1).to.have.property('chats', 2); + expect(queue1).to.have.nested.property('user._id', agent1.user._id); + expect(queue1).to.have.nested.property('department._id', deptd1._id); + }); + }); + + (IS_EE ? it : it.skip)('should have three items when create room for another department', async () => { + const { department: dep2, agent: ag3 } = await createDepartmentWithAnOnlineAgent(); + + agent3 = ag3; + + usersToDelete.push(ag3.user); + + deptd2 = dep2; + + const newVisitor = await createVisitor(deptd2._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + roomsToClose.push(newRoom); + visitors.push(newVisitor); + + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(3); + + const [queue1, queue2, queue3] = res.body.queue; + + expect(queue1).to.have.property('chats', 2); + expect(queue1).to.have.nested.property('user._id', agent1.user._id); + expect(queue1).to.have.nested.property('department._id', deptd1._id); + + expect(queue2).to.have.property('chats', 1); + expect(queue3).to.have.property('chats', 1); + }); + }); + + (IS_EE ? it : it.skip)('should change the order when second department gets more rooms', async () => { + const newVisitor1 = await createVisitor(deptd2._id); + const newRoom1 = await createLivechatRoom(newVisitor1.token); + + roomsToClose.push(newRoom1); + visitors.push(newVisitor1); + + const newVisitor2 = await createVisitor(deptd2._id); + const newRoom2 = await createLivechatRoom(newVisitor2.token); + + roomsToClose.push(newRoom2); + visitors.push(newVisitor2); + + await request + .get(api('livechat/queue')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.queue).to.be.an('array').of.length(3); + + const [queue1, queue2, queue3] = res.body.queue; + + expect(queue1).to.have.property('chats', 3); + expect(queue1).to.have.nested.property('user._id', agent3.user._id); + expect(queue1).to.have.nested.property('department._id', deptd2._id); + + expect(queue2).to.have.property('chats', 2); + expect(queue2).to.have.nested.property('user._id', agent1.user._id); + expect(queue2).to.have.nested.property('department._id', deptd1._id); + + expect(queue3).to.have.property('chats', 1); + expect(queue3).to.have.nested.property('user._id', agent2.user._id); + expect(queue3).to.have.nested.property('department._id', deptd1._id); + }); + }); + }); +}); + +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - AutoSelection', () => { + let testUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + const roomsToClose: IOmnichannelRoom[] = []; + const visitorsToDelete: ILivechatVisitor[] = []; + + before((done) => getCredentials(done)); + + before(async () => + Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', true), + updateSetting('Omnichannel_queue_delay_timeout', 3), + ]), + ); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials3 = await login(user.username, password); + await makeAgentAvailable(credentials3); + + testUser = { + user, + credentials: credentials3, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department`, true, { + maxNumberSimultaneousChat: 2, + }); + testDepartment2 = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department2`, true, { + maxNumberSimultaneousChat: 2, + }); + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [testDepartment._id, testDepartment2._id]); + }); + + after(async () => { + await Promise.all(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.all(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([ + deleteUser(testUser.user), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + updateSetting('Omnichannel_queue_delay_timeout', 5), + deleteDepartment(testDepartment._id), + deleteDepartment(testDepartment2._id), + ]); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + it('should allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 0 chats)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + let previousChat: string; + it('should not allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 1 chat)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow a user to take a chat on a department when agent limit is increased to 2 and department limit is set to 2 (agent has 1 chat)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 2 }, [testDepartment._id, testDepartment2._id]); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department B when agent limit is 2 and already has 2 chats', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take a chat on department B when agent limit is increased to 3 and user has 2 chats on department A', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 3 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow a user to take a chat on department B when agent limit is 0 and department B limit is 2 (user has 3 chats)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B when is on the limit (user has 4 chats, 2 chats on department B)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should not allow user to take a chat on department B even if global limit allows it (user has 4 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 6); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should allow user to take a chat when department B limit is removed and its below global limit (user has 4 chats)', async () => { + await updateDepartment({ departmentId: testDepartment2._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow user to take a chat on department B (user has 5 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B (user has 6 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take chat once the global limit is removed (user has 7 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 0); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department A (as limit for it hasnt changed)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + }); + + it('should allow user to take a chat on department A when its limit gets removed (no agent, global or department filter are applied)', async () => { + await updateDepartment({ departmentId: testDepartment._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should honor agent limit over global limit when both are set (user has 8 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 100000); + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 4 }, [testDepartment._id, testDepartment2._id]); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + // We already tested this case but this way the queue ends up empty :) + it('should receive the chat after agent limit is removed', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); +}); + +// Note: didn't add for LoadRotation as everything that changes is how the agent is selected +// but the limits applicable are the same as load balance and autoselection +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - LoadBalance', () => { + let testUser: { user: IUser; credentials: Credentials }; + let testUser2: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + let testDepartment3: ILivechatDepartment; + const roomsToClose: IOmnichannelRoom[] = []; + const visitorsToDelete: ILivechatVisitor[] = []; + + before((done) => getCredentials(done)); + + before(async () => + Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Load_Balancing'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateSetting('Omnichannel_queue_delay_timeout', 3), + updateEESetting('Livechat_waiting_queue', true), + ]), + ); + + before(async () => { + const user = await createUser(); + const user2 = await createUser(); + await createAgent(user.username); + await createAgent(user2.username); + const credentials3 = await login(user.username, password); + const credentials4 = await login(user2.username, password); + await makeAgentAvailable(credentials3); + await makeAgentAvailable(credentials4); + + await updatePermission('transfer-livechat-guest', ['livechat-agent']); + + testUser = { + user, + credentials: credentials3, + }; + testUser2 = { + user: user2, + credentials: credentials4, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department`, true, { + maxNumberSimultaneousChat: 2, + }); + testDepartment2 = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department2`, true, { + maxNumberSimultaneousChat: 2, + }); + testDepartment3 = await createDepartment( + [{ agentId: testUser.user._id }, { agentId: testUser2.user._id }], + `${new Date().toISOString()}-department3`, + true, + { + maxNumberSimultaneousChat: 2, + }, + ); + + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + testDepartment._id, + testDepartment2._id, + testDepartment3._id, + ]); + await updateLivechatSettingsForUser(testUser2.user._id, { maxNumberSimultaneousChat: 1 }, [testDepartment3._id]); + }); + + after(async () => { + await Promise.all(roomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.all(visitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([ + deleteUser(testUser.user), + deleteUser(testUser2.user), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + updateSetting('Omnichannel_queue_delay_timeout', 5), + deleteDepartment(testDepartment._id), + deleteDepartment(testDepartment2._id), + deleteDepartment(testDepartment3._id), + ]); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + it('should allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 0 chats)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + let previousChat: string; + it('should not allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 1 chat)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow a user to take a chat on a department when agent limit is increased to 2 and department limit is set to 2 (agent has 1 chat)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 2 }, [testDepartment._id, testDepartment2._id]); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department B when agent limit is 2 and already has 2 chats', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take a chat on department B when agent limit is increased to 3 and user has 2 chats on department A', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 3 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow a user to take a chat on department B when agent limit is 0 and department B limit is 2 (user has 3 chats)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B when is on the limit (user has 4 chats, 2 chats on department B)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should not allow user to take a chat on department B even if global limit allows it (user has 4 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 6); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should allow user to take a chat when department B limit is removed and its below global limit (user has 4 chats)', async () => { + await updateDepartment({ departmentId: testDepartment2._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow user to take a chat on department B (user has 5 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B (user has 6 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take chat once the global limit is removed (user has 7 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 0); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department A (as limit for it hasnt changed)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + }); + + it('should allow user to take a chat on department A when its limit gets removed (no agent, global or department filter are applied)', async () => { + await updateDepartment({ departmentId: testDepartment._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should honor agent limit over global limit when both are set (user has 8 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 100000); + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 4 }, [testDepartment._id, testDepartment2._id]); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + // We already tested this case but this way the queue ends up empty :) + it('should receive the chat after agent limit is removed', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should route the chat to another agent if limit for agent A is reached and agent B is available', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + testDepartment._id, + testDepartment2._id, + testDepartment3._id, + ]); + + const visitor = await createVisitor(testDepartment3._id); + const room = await createLivechatRoom(visitor.token); + visitorsToDelete.push(visitor); + roomsToClose.push(room); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + + describe('when forwarding a chat to an agent already at their limit', () => { + let forwardingRoom: { _id: string }; + let otherRoom: { _id: string }; + let visitor1: ILivechatVisitor; + let visitor2: ILivechatVisitor; + let forwardingDept: ILivechatDepartment; + let forwardUserA: { user: IUser; credentials: Credentials }; + let forwardUserB: { user: IUser; credentials: Credentials }; + + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + + const userA = await createUser(); + const userB = await createUser(); + + await createAgent(userA.username); + await createAgent(userB.username); + + const credA = await login(userA.username, password); + const credB = await login(userB.username, password); + + await makeAgentAvailable(credA); + await makeAgentAvailable(credB); + + forwardUserA = { user: userA, credentials: credA }; + forwardUserB = { user: userB, credentials: credB }; + + forwardingDept = await createDepartment( + [{ agentId: forwardUserA.user._id }, { agentId: forwardUserB.user._id }], + `${new Date().toISOString()}-forward-limit`, + true, + { + maxNumberSimultaneousChat: 1, + }, + ); + + await updateLivechatSettingsForUser(forwardUserA.user._id, { maxNumberSimultaneousChat: 1 }, [forwardingDept._id]); + await updateLivechatSettingsForUser(forwardUserB.user._id, { maxNumberSimultaneousChat: 1 }, [forwardingDept._id]); + + // Force agent A to take a room on the dedicated department + const { room: room1, visitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: forwardingDept._id, + agent: forwardUserA.credentials, + }); + visitor1 = visitor; + + const roomInfo1 = await getLivechatRoomInfo(room1._id); + expect(roomInfo1.servedBy).to.be.an('object'); + expect(roomInfo1.servedBy?._id).to.be.equal(forwardUserA.user._id); + otherRoom = room1; + }); + + before(async () => { + // Force agent B to take another room on the same department + const { room: room2, visitor } = await startANewLivechatRoomAndTakeIt({ + departmentId: forwardingDept!._id, + agent: forwardUserB.credentials, + }); + visitor2 = visitor; + + const roomInfo2 = await getLivechatRoomInfo(room2._id); + expect(roomInfo2.servedBy).to.be.an('object'); + expect(roomInfo2.servedBy?._id).to.be.equal(forwardUserB.user._id); + + forwardingRoom = room2; + }); + + after(async () => { + await closeOmnichannelRoom(forwardingRoom._id); + await closeOmnichannelRoom(otherRoom._id); + await deleteDepartment(forwardingDept._id); + await deleteVisitor(visitor1._id); + await deleteVisitor(visitor2._id); + await deleteUser(forwardUserA.user); + await deleteUser(forwardUserB.user); + }); + + it('should not allow forwarding and should not add system messages to the room', async () => { + const res = await request.post(api('livechat/room.forward')).set(forwardUserB.credentials).send({ + roomId: forwardingRoom._id, + userId: forwardUserA.user._id, + clientAction: true, + comment: 'forward to agent at limit', + }); + + expect(res.status).to.equal(400); + expect(res.body).to.have.property('success', false); + + const messagesResponse = await request.get(api('channels.messages')).set(credentials).query({ roomId: forwardingRoom._id }); + + expect(messagesResponse.status).to.equal(200); + expect(messagesResponse.body).to.have.property('messages'); + const systemMessages = messagesResponse.body.messages.filter((msg: any) => msg.t === 'livechat_transfer_history'); + expect(systemMessages).to.have.length(0); + }); + + it('should allow forwarding with the waiting queue disabled', async () => { + await updateEESetting('Livechat_waiting_queue', false); + + const res = await request.post(api('livechat/room.forward')).set(forwardUserB.credentials).send({ + roomId: forwardingRoom._id, + userId: forwardUserA.user._id, + clientAction: true, + comment: 'forward to agent at limit with waiting queue disabled', + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('success', true); + + const messagesResponse = await request.get(api('channels.messages')).set(credentials).query({ roomId: forwardingRoom._id }); + + expect(messagesResponse.status).to.equal(200); + expect(messagesResponse.body).to.have.property('messages'); + const systemMessages = messagesResponse.body.messages.filter((msg: any) => msg.t === 'livechat_transfer_history'); + expect(systemMessages).to.have.length.greaterThan(0); + }); + }); +}); + +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - Manual Selection', () => { + let manualUser: { user: IUser; credentials: Credentials }; + let manualDepartment: ILivechatDepartment; + let manualDepartment2: ILivechatDepartment; + const manualRoomsToClose: IOmnichannelRoom[] = []; + const manualVisitorsToDelete: ILivechatVisitor[] = []; + + before((done) => getCredentials(done)); + + before(async () => { + await Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Manual_Selection'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', true), + ]); + + await sleep(1000); + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const creds = await login(user.username, password); + await makeAgentAvailable(creds); + + manualUser = { user, credentials: creds }; + }); + + before(async () => { + manualDepartment = await createDepartment([{ agentId: manualUser.user._id }], `${new Date().toISOString()}-manual-dept`, true, { + maxNumberSimultaneousChat: 2, + }); + manualDepartment2 = await createDepartment([{ agentId: manualUser.user._id }], `${new Date().toISOString()}-manual-dept2`, true, { + maxNumberSimultaneousChat: 2, + }); + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + after(async () => { + await Promise.all(manualRoomsToClose.map((room) => closeOmnichannelRoom(room._id))); + await Promise.all(manualVisitorsToDelete.map((visitor) => deleteVisitor(visitor.token))); + await Promise.all([ + deleteUser(manualUser.user), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + deleteDepartment(manualDepartment._id), + deleteDepartment(manualDepartment2._id), + ]); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('when agent limit is 1 and has 0 chats', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to manually take the inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent limit is 1 and has 1 chat', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to manually take the inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is increased to 2', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 2 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is 2 and already has 2 chats on department A', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is increased to 3', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 3 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is 0 and department B limit is 2 (agent has 3 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 0 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent has 4 chats and 2 on department B (at department limit)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when global limit is set to 6', () => { + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 6); + }); + + it('should not allow agent to take inquiry on department B even if global limit allows it', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when department B limit is removed', () => { + before(async () => { + await updateDepartment({ + departmentId: manualDepartment2._id, + opts: { maxNumberSimultaneousChat: 0 }, + userCredentials: credentials, + }); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + }); + + describe('when agent has 5 chats, global limit is 6, department limit is 0', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + + describe('when agent has 6 chats, global limit is 6, department limit is 0', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment2._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department B', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when global limit is removed', () => { + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 0); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when department A limit is still 2 (agent has 7 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should not allow agent to take inquiry on department A', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when department A limit is removed', () => { + before(async () => { + await updateDepartment({ + departmentId: manualDepartment._id, + opts: { maxNumberSimultaneousChat: 0 }, + userCredentials: credentials, + }); + }); + + it('should allow agent to take the pending inquiry on department A', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); + + describe('when agent limit is set to 4 and global limit is high (agent has 8 chats)', () => { + let room: IOmnichannelRoom; + let visitor: ILivechatVisitor; + + before(async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 100000); + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 4 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + + visitor = await createVisitor(manualDepartment._id); + room = await createLivechatRoom(visitor.token); + manualVisitorsToDelete.push(visitor); + manualRoomsToClose.push(room); + }); + + it('should honor agent limit over global limit and not allow taking inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + + await request + .post(api('livechat/inquiries.take')) + .set(manualUser.credentials) + .send({ userId: manualUser.user._id, inquiryId: inquiry._id, options: { clientAction: true } }) + .expect(400); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + + describe('when agent limit is removed', () => { + before(async () => { + await updateLivechatSettingsForUser(manualUser.user._id, { maxNumberSimultaneousChat: 0 }, [ + manualDepartment._id, + manualDepartment2._id, + ]); + }); + + it('should allow agent to take the pending inquiry', async () => { + const inquiry = await fetchInquiry(room._id); + await takeInquiry(inquiry._id, manualUser.credentials); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(manualUser.user._id); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts b/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts new file mode 100644 index 0000000..6168ed4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts @@ -0,0 +1,426 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createTrigger, fetchTriggers } from '../../../data/livechat/triggers'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - triggers', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('livechat/triggers', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request.get(api('livechat/triggers')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + + it('should return an array of triggers', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await createTrigger(`test${Date.now()}`); + await request + .get(api('livechat/triggers')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.triggers).to.be.an('array'); + expect(res.body).to.have.property('count').to.be.greaterThan(0); + expect(res.body.triggers[0]).to.have.property('_id'); + expect(res.body.triggers[0]).to.have.property('name'); + expect(res.body.triggers[0]).to.have.property('description'); + expect(res.body.triggers[0]).to.have.property('enabled', true); + expect(res.body.triggers[0]).to.have.property('runOnce').that.is.a('boolean'); + expect(res.body.triggers[0]).to.have.property('conditions').that.is.an('array').with.lengthOf.greaterThan(0); + expect(res.body.triggers[0]).to.have.property('actions').that.is.an('array').with.lengthOf.greaterThan(0); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); + }); + }); + + describe('POST livechat/triggers', () => { + it('should fail if user is not logged in', async () => { + await request.post(api('livechat/triggers')).send({}).expect(401); + }); + it('should fail if no data is sent', async () => { + await request.post(api('livechat/triggers')).set(credentials).send({}).expect(400); + }); + it('should fail if invalid data is sent', async () => { + await request.post(api('livechat/triggers')).set(credentials).send({ name: 'test' }).expect(400); + }); + it('should fail if name is not an string', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 1, description: 'test', enabled: true, runOnce: true, conditions: [], actions: [] }) + .expect(400); + }); + it('should fail if description is not an string', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 1, enabled: true, runOnce: true, conditions: [], actions: [] }) + .expect(400); + }); + it('should fail if enabled is not an boolean', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: 1, runOnce: true, conditions: [], actions: [] }) + .expect(400); + }); + it('should fail if runOnce is not an boolean', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: 1, conditions: [], actions: [] }) + .expect(400); + }); + it('should fail if conditions is not an array', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: true, conditions: 1, actions: [] }) + .expect(400); + }); + it('should fail if actions is not an array', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: true, conditions: [], actions: 1 }) + .expect(400); + }); + it('should fail if conditions is an array with invalid data', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: true, conditions: [1], actions: [] }) + .expect(400); + }); + it('should fail if conditions is an array of objects, but name is not a valid value', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: true, conditions: [{ name: 'invalid' }], actions: [] }) + .expect(400); + }); + it('should fail if actions is an array of invalid values', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ name: 'test', description: 'test', enabled: true, runOnce: true, conditions: [{ name: 'page-url' }], actions: [1] }) + .expect(400); + }); + it('should fail if actions is an array of objects, but name is not a valid value', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [{ name: 'invalid' }], + }) + .expect(400); + }); + it('should fail if actions is an array of objects, but sender is not a valid value', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url' }], + actions: [{ name: 'send-message', params: { sender: 'invalid' } }], + }) + .expect(400); + }); + it('should fail if actions is an array of objects, but msg is not a valid value', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [{ name: 'send-message', params: { sender: 'custom' } }], + }) + .expect(400); + }); + it('should fail if actions is an array of objects, but name is not a valid value', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [{ name: 'send-message', params: { sender: 'custom', msg: 'test', name: {} } }], + }) + .expect(400); + }); + it('should fail if user doesnt have view-livechat-manager permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [{ name: 'send-message', params: { sender: 'custom', msg: 'test', name: 'test' } }], + }) + .expect(403); + }); + it('should save a new trigger of type send-message', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test', + description: 'test', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [{ name: 'send-message', params: { sender: 'custom', msg: 'test', name: 'test' } }], + }) + .expect(200); + }); + it('should fail if type is use-external-service but serviceUrl is not a present', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test2', + description: 'test2', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [ + { + name: 'use-external-service', + params: { + serviceTimeout: 5000, + serviceFallbackMessage: 'Were sorry, we cannot complete your request', + }, + }, + ], + }) + .expect(400); + }); + it('should fail if type is use-external-service but serviceTimeout is not a present', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test2', + description: 'test2', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [ + { + name: 'use-external-service', + params: { + serviceUrl: 'http://localhost:3000/api/vX', + serviceFallbackMessage: 'Were sorry, we cannot complete your request', + }, + }, + ], + }) + .expect(400); + }); + it('should fail if type is use-external-service but serviceFallbackMessage is not a present', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test2', + description: 'test2', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [ + { + name: 'use-external-service', + params: { + serviceUrl: 'http://localhost:3000/api/vX', + serviceTimeout: 5000, + }, + }, + ], + }) + .expect(400); + }); + it('should save a new trigger of type use-external-service', async () => { + await request + .post(api('livechat/triggers')) + .set(credentials) + .send({ + name: 'test3', + description: 'test3', + enabled: true, + runOnce: true, + conditions: [{ name: 'page-url', value: 'http://localhost:3000' }], + actions: [ + { + name: 'use-external-service', + params: { + serviceUrl: 'http://localhost:3000/api/vX', + serviceTimeout: 5000, + serviceFallbackMessage: 'Were sorry, we cannot complete your request', + }, + }, + ], + }) + .expect(200); + }); + }); + + (IS_EE ? describe : describe.skip)('POST livechat/triggers/external-service/test', () => { + const webhookUrl = process.env.WEBHOOK_TEST_URL || 'https://httpbin.org'; + + after(() => Promise.all([updateSetting('Livechat_secret_token', ''), restorePermissionToRoles('view-livechat-manager')])); + + it('should fail if user is not logged in', async () => { + await request.post(api('livechat/triggers/external-service/test')).send({}).expect(401); + }); + it('should fail if no data is sent', async () => { + await request.post(api('livechat/triggers/external-service/test')).set(credentials).send({}).expect(400); + }); + it('should fail if invalid data is sent', async () => { + await request.post(api('livechat/triggers/external-service/test')).set(credentials).send({ webhookUrl: 'test' }).expect(400); + }); + it('should fail if webhookUrl is not an string', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 1, timeout: 1000, fallbackMessage: 'test', extraData: [] }) + .expect(400); + }); + it('should fail if timeout is not an number', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 'test', timeout: '1000', fallbackMessage: 'test', extraData: [] }) + .expect(400); + }); + it('should fail if fallbackMessage is not an string', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 1, extraData: [] }) + .expect(400); + }); + it('should fail if params is not an array', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: 1 }) + .expect(400); + }); + it('should fail if user doesnt have view-livechat-webhooks permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: [] }) + .expect(403); + }); + it('should fail if Livechat_secret_token setting is empty', async () => { + await restorePermissionToRoles('view-livechat-manager'); + await updateSetting('Livechat_secret_token', ''); + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: [] }) + .expect(400); + }); + it('should return error when webhook returns error', async () => { + await updateSetting('Livechat_secret_token', 'test'); + + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: `${webhookUrl}/status/500`, timeout: 5000, fallbackMessage: 'test', extraData: [] }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-external-service-response'); + expect(res.body).to.have.property('response').to.be.a('string'); + }); + }); + it('should return error when webhook times out', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: `${webhookUrl}/delay/2`, timeout: 1000, fallbackMessage: 'test', extraData: [] }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-timeout'); + expect(res.body).to.have.property('response').to.be.a('string'); + }); + }); + it('should fail when webhook returns an answer that doesnt match the format', async () => { + await request + .post(api('livechat/triggers/external-service/test')) + .set(credentials) + .send({ webhookUrl: `${webhookUrl}/anything`, timeout: 5000, fallbackMessage: 'test', extraData: [] }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-external-service-response'); + }); + }); + }); + + describe('livechat/triggers/:id', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request.get(api('livechat/triggers/invalid-id')).set(credentials).expect('Content-Type', 'application/json').expect(403); + await updatePermission('view-livechat-manager', ['admin']); + }); + it('should return null when trigger does not exist', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/triggers/invalid-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.trigger).to.be.null; + }); + it('should return the trigger', async () => { + const triggerName = `test${Date.now()}`; + await createTrigger(triggerName); + const trigger = (await fetchTriggers()).find((t) => t.name === triggerName); + const response = await request + .get(api(`livechat/triggers/${trigger?._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.trigger).to.be.an('object'); + expect(response.body.trigger).to.have.property('_id', trigger?._id); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts new file mode 100644 index 0000000..6a15e5e --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -0,0 +1,1680 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { + makeAgentAvailable, + createAgent, + createLivechatRoom, + createVisitor, + startANewLivechatRoomAndTakeIt, + closeOmnichannelRoom, + createVisitorWithCustomData, + sendAgentMessage, + sendMessage, +} from '../../../data/livechat/rooms'; +import { getRandomVisitorToken } from '../../../data/livechat/users'; +import { getLivechatVisitorByToken } from '../../../data/livechat/visitor'; +import { + updatePermission, + updateSetting, + removePermissionFromAllRoles, + restorePermissionToRoles, + updateEESetting, +} from '../../../data/permissions.helper'; +import { adminUsername } from '../../../data/user'; +import { IS_EE } from '../../../e2e/config/constants'; + +const getLicenseInfo = (loadValues = false) => { + return request.get(api('licenses.info')).set(credentials).query({ loadValues }).expect(200); +}; + +describe('LIVECHAT - visitors', () => { + let visitor: ILivechatVisitor; + + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updatePermission('view-livechat-manager', ['admin']); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await createAgent(); + await makeAgentAvailable(); + visitor = await createVisitor(); + }); + + describe('livechat/visitor', () => { + it('should fail if no "visitor" key is passed as body parameter', async () => { + const { body } = await request.post(api('livechat/visitor')).send({}); + expect(body).to.have.property('success', false); + }); + it('should fail if visitor.token is not present', async () => { + const { body } = await request.post(api('livechat/visitor')).send({ visitor: {} }); + expect(body).to.have.property('success', false); + }); + it('should fail when token is an empty string', async () => { + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token: '' } }); + expect(body).to.have.property('success', false); + }); + it('should create a visitor', async () => { + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token: 'test' } }); + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', 'test'); + + // Ensure all new visitors are created as online :) + expect(body.visitor).to.have.property('status', 'online'); + }); + it('should create a visitor with provided extra information', async () => { + const token = `${new Date().getTime()}-test`; + const phone = new Date().getTime().toString(); + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token, phone } }); + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.have.property('phone'); + expect(body.visitor.phone[0].phoneNumber).to.equal(phone); + }); + it('should save customFields when passed', async () => { + const customFieldName = `new_custom_field_${Date.now()}`; + const token = `${new Date().getTime()}-test`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'Not a real address :)', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.have.property('livechatData'); + expect(body.visitor.livechatData).to.have.property(customFieldName, 'Not a real address :)'); + }); + it('should update a current visitor when phone is same', async () => { + const token = `${new Date().getTime()}-test`; + const token2 = `${new Date().getTime()}-test2`; + const phone = new Date().getTime().toString(); + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token, phone } }); + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: token2, + phone, + }, + }); + + expect(body2).to.have.property('success', true); + expect(body2).to.have.property('visitor'); + + // Same visitor won't update the token + expect(body2.visitor).to.have.property('token', token); + expect(body2.visitor).to.have.property('phone'); + expect(body2.visitor.phone[0].phoneNumber).to.equal(phone); + }); + + it('should update a visitor custom fields when customFields key is provided', async () => { + const token = `${new Date().getTime()}-test`; + const customFieldName = `new_custom_field_${Date.now()}`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'Not a real address :)', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.have.property('livechatData'); + expect(body.visitor.livechatData).to.have.property(customFieldName, 'Not a real address :)'); + }); + + it('should not update a custom field when it does not exists', async () => { + const token = `${new Date().getTime()}-test`; + const customFieldName = `new_custom_field_${Date.now()}`; + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'Not a real address :)', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.not.have.property('livechatData'); + }); + + it('should not update a custom field when the scope of it is not visitor', async () => { + const token = `${new Date().getTime()}-test`; + const customFieldName = `new_custom_field_${Date.now()}`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'room', + visibility: 'public', + regexp: '', + }); + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'Not a real address :)', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.not.have.property('livechatData'); + }); + + it('should not update a custom field when the overwrite flag is false', async () => { + const token = `${new Date().getTime()}-test`; + const customFieldName = `new_custom_field_${Date.now()}`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'Not a real address :)', overwrite: true }], + }, + }); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token, + customFields: [{ key: customFieldName, value: 'This should not change!', overwrite: false }], + }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + expect(body.visitor).to.have.property('livechatData'); + expect(body.visitor.livechatData).to.have.property(customFieldName, 'Not a real address :)'); + }); + + it('should not validate required custom fields if no custom fields are provided', async () => { + const token = `${new Date().getTime()}-test`; + const customFieldName = `required_custom_field`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'default_value', + scope: 'visitor', + visibility: 'public', + regexp: '', + required: true, + }); + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token } }); + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + await deleteCustomField(customFieldName); + }); + + it('should fail if provided custom fields but are missing required ones', async () => { + const token = `${new Date().getTime()}-test`; + const optionalCustomFieldName = `optional_custom_field`; + await createCustomField({ + searchable: true, + field: optionalCustomFieldName, + label: optionalCustomFieldName, + defaultValue: 'default_value', + scope: 'visitor', + visibility: 'public', + regexp: '', + required: false, + }); + const requiredCustomFieldName = `required_custom_field`; + await createCustomField({ + searchable: true, + field: requiredCustomFieldName, + label: requiredCustomFieldName, + defaultValue: 'default_value', + scope: 'visitor', + visibility: 'public', + regexp: '', + required: true, + }); + const { body } = await request + .post(api('livechat/visitor')) + .send({ visitor: { token, customFields: [{ key: optionalCustomFieldName, value: 'test', overwrite: true }] } }); + expect(body).to.have.property('success', false); + expect(body).to.have.property('error'); + expect(body.error).to.be.equal(`Missing required custom fields: required_custom_field`); + await Promise.all([deleteCustomField(optionalCustomFieldName), deleteCustomField(requiredCustomFieldName)]); + }); + + describe('special cases', () => { + before(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true); + }); + after(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', false); + }); + + it('should allow to create a visitor without passing connectionData when GDPR setting is enabled', async () => { + const token = `${new Date().getTime()}-test`; + + const { body } = await request.post(api('livechat/visitor')).send({ visitor: { token } }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('visitor'); + expect(body.visitor).to.have.property('token', token); + }); + }); + + describe('visitor & contact custom fields', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + const cf1 = `cf1-${Date.now()}_1`; + const cf2 = `cf2-${Date.now()}_2`; + const cf3 = `cf3-${Date.now()}_3`; + before(async () => { + await createCustomField({ + searchable: true, + field: cf1, + label: cf1, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: cf2, + label: cf2, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: cf3, + label: cf3, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + }); + after(async () => { + await Promise.all([deleteCustomField(cf1), deleteCustomField(cf2), deleteCustomField(cf3)]); + }); + + it('should update custom fields on the contact', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should update multiple custom fields on a contact after it already has custom fields added', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf2, value: 'test', overwrite: true }, + { key: cf3, value: 'test', overwrite: false }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.customFields).to.have.property(cf3, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should overwrite a custom field value when the flag is true', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test', overwrite: true }, + { key: cf3, value: 'test', overwrite: false }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'new test'); + expect(res.body.contact.customFields).to.have.property(cf3, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should properly conflict a custom field when existing and overwrite is false', async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test', overwrite: false }, + { key: cf2, value: 'test', overwrite: true }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.conflictingFields).to.be.an('array'); + expect(res.body.contact.conflictingFields[0]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test', + }); + }); + }); + it('should add more conflicts to a contact custom fields', async () => { + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test 2', overwrite: false }, + { key: cf2, value: 'test', overwrite: true }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.conflictingFields).to.be.an('array').with.lengthOf(2); + expect(res.body.contact.conflictingFields[0]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test', + }); + expect(res.body.contact.conflictingFields[1]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test 2', + }); + }); + await closeOmnichannelRoom(room._id); + }); + }); + }); + + describe('livechat/visitors.info', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + + await request + .get(api('livechat/visitors.info')) + .query({ visitorId: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + it('should return an "visitor not found error" when the visitor doe snot exists', async () => { + await updatePermission('view-l-room', ['admin']); + + await request + .get(api('livechat/visitors.info')) + .query({ visitorId: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('visitor-not-found'); + }); + }); + it('should return the visitor info', async () => { + await request + .get(api('livechat/visitors.info')) + .query({ visitorId: visitor._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor._id).to.be.equal(visitor._id); + }); + }); + it('should not return the visitor token', async () => { + await request + .get(api('livechat/visitors.info')) + .query({ visitorId: visitor._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.visitor).to.not.have.property('token'); + }); + }); + }); + + describe('livechat/visitors.pagesVisited', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + + await request + .get(api('livechat/visitors.pagesVisited/room-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + it('should return an "error" when the roomId param is not provided', async () => { + await updatePermission('view-l-room', ['admin']); + + await request + .get(api('livechat/visitors.pagesVisited/room-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an array of pages', async () => { + await updatePermission('view-l-room', ['admin']); + const createdVisitor = await createVisitor(); + const createdRoom = await createLivechatRoom(createdVisitor.token); + + await request + .get(api(`livechat/visitors.pagesVisited/${createdRoom._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.pages).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + }); + + describe('livechat/visitors.chatHistory/room/room-id/visitor/visitor-id', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-l-room', []); + + await request + .get(api('livechat/visitors.chatHistory/room/room-id/visitor/visitor-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + it('should return an "error" when the roomId param is invalid', async () => { + await updatePermission('view-l-room', ['admin']); + + await request + .get(api('livechat/visitors.chatHistory/room/room-id/visitor/visitor-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should return an array of chat history', async () => { + await updatePermission('view-l-room', ['admin']); + const createdVisitor = await createVisitor(); + + const createdRoom = await createLivechatRoom(createdVisitor.token); + await request + .get(api(`livechat/visitors.chatHistory/room/${createdRoom._id}/visitor/${createdVisitor._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.history).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); + }); + + describe('livechat/visitor/:token', () => { + // get + it("should return a 'invalid token' error when visitor with given token does not exist ", async () => { + await request + .get(api('livechat/visitor/invalid')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('[invalid-token]'); + }); + }); + + it('should return an error when the "token" query parameter is not valid', async () => { + await request + .get(api('livechat/visitor/invalid')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return a visitor when the query params is all valid', async () => { + await request + .get(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('visitor'); + }); + }); + + // delete + it("should return a 'invalid token' error when visitor with given token does not exist ", async () => { + await request + .delete(api('livechat/visitor/invalid')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('[invalid-token]'); + }); + }); + + it('should return an error when the "token" query parameter is not valid', async () => { + await request + .delete(api('livechat/visitor/invalid')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it("should return a 'visitor-has-open-rooms' error when there are open rooms", async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', false); + const createdVisitor = await createVisitor(); + await createLivechatRoom(createdVisitor.token); + + await request + .delete(api(`livechat/visitor/${createdVisitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Cannot remove visitors with opened rooms [visitor-has-open-rooms]'); + }); + }); + + it("should not return a 'visitor-has-open-rooms' when visitor has open rooms but GDPR is enabled", async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true); + const createdVisitor = await createVisitor(); + await createLivechatRoom(createdVisitor.token); + + await request + .delete(api(`livechat/visitor/${createdVisitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should remove the rooms associated with the visitor if any', async () => { + const createdVisitor = await createVisitor(); + const room = await createLivechatRoom(createdVisitor.token); + + await request + .delete(api(`livechat/visitor/${createdVisitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + await request.get(api('livechat/room')).query({ rid: room._id }).set(credentials).expect(400); + }); + + it('should remove the contact associated with the visitor if any', async () => { + const createdVisitor = await createVisitor(); + const room = await createLivechatRoom(createdVisitor.token); + + await request + .delete(api(`livechat/visitor/${createdVisitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }).expect(404); + }); + + it('should return a visitor when the query params is all valid', async () => { + const createdVisitor = await createVisitor(); + await request + .delete(api(`livechat/visitor/${createdVisitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('visitor'); + expect(res.body.visitor).to.have.property('_id'); + expect(res.body.visitor).to.have.property('ts'); + expect(res.body.visitor._id).to.be.equal(createdVisitor._id); + }); + }); + + it('should not affect MAC count when a visitor is removed via GDPR', async () => { + const { visitor, room } = await startANewLivechatRoomAndTakeIt(); + // agent should send a message on the room + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: room._id, + msg: 'test', + }, + }); + const { body: currentLicense } = await getLicenseInfo(true); + + await request + .delete(api(`livechat/visitor/${visitor.token}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const { body: licenseAfterGdpr } = await getLicenseInfo(true); + + expect(currentLicense.license).to.have.property('limits'); + expect(currentLicense.license.limits).to.have.property('monthlyActiveContacts'); + expect(currentLicense.license.limits.monthlyActiveContacts).to.have.property('value'); + const currentLimit = currentLicense.license.limits.monthlyActiveContacts.value; + + expect(licenseAfterGdpr.license).to.have.property('limits'); + expect(licenseAfterGdpr.license.limits).to.have.property('monthlyActiveContacts'); + expect(licenseAfterGdpr.license.limits.monthlyActiveContacts).to.have.property('value'); + const limitAfterGdpr = licenseAfterGdpr.license.limits.monthlyActiveContacts.value; + + expect(limitAfterGdpr).to.be.equal(currentLimit); + }); + + it("should return a 'error-removing-visitor' error when removeGuest's result is false", async () => { + await request + .delete(api('livechat/visitor/123')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('livechat/visitors.autocomplete', () => { + it('should return an error when the user doesnt have the right permissions', async () => { + await updatePermission('view-l-room', []); + await request + .get(api('livechat/visitors.autocomplete')) + .set(credentials) + .query({ selector: 'invalid' }) + .query({ selector: 'xxx' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should return an error when the "selector" query parameter is not valid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await request + .get(api('livechat/visitors.autocomplete')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'selector' [invalid-params]"); + }); + }); + + it('should return an error if "selector" param is not JSON serializable', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await request + .get(api('livechat/visitors.autocomplete')) + .query({ selector: '{invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return a list of visitors when the query params is all valid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + const createdVisitor = await createVisitor(); + + await request + .get(api('livechat/visitors.autocomplete')) + .query({ selector: JSON.stringify({ term: createdVisitor.name }) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items'); + expect(res.body.items).to.be.an('array'); + expect(res.body.items).to.have.length.of.at.least(1); + expect(res.body.items[0]).to.have.property('_id'); + expect(res.body.items[0]).to.have.property('name'); + + const visitor = res.body.items.find((item: any) => item._id === createdVisitor._id); + expect(visitor).to.have.property('_id'); + expect(visitor).to.have.property('name'); + expect(visitor._id).to.be.equal(createdVisitor._id); + }); + }); + }); + + describe('livechat/visitors.searchChats/room/:roomId/visitor/:visitorId', () => { + it('should return an error when the user doesnt have the right permissions', async () => { + await updatePermission('view-l-room', []); + await request + .get(api('livechat/visitors.searchChats/room/123/visitor/123')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should throw an error when the roomId is not valid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + + await request + .get(api('livechat/visitors.searchChats/room/invalid/visitor/123')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return an empty array if the user is not the one serving the chat', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const createdVisitor = await createVisitor(); + const room = await createLivechatRoom(createdVisitor.token); + await request + .get(api(`livechat/visitors.searchChats/room/${room._id}/visitor/123`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history).to.have.lengthOf(0); + }); + }); + + it('should return an empty array if the visitorId doesnt correlate to room', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + const createdVisitor = await createVisitor(); + const room = await createLivechatRoom(createdVisitor.token); + await request + .get(api(`livechat/visitors.searchChats/room/${room._id}/visitor/123`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history).to.have.lengthOf(0); + }); + }); + + it('should return a list of chats when the query params is all valid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent', 'livechat-manager']); + await createAgent(); + + const { + room: { _id: roomId }, + visitor: { _id: visitorId }, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .get(api(`livechat/visitors.searchChats/room/${roomId}/visitor/${visitorId}?closedChatsOnly=false&servedChatsOnly=true`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history).to.have.length.of.at.least(1); + expect(res.body.history[0]).to.have.property('_id'); + expect(res.body.history[0]).to.have.property('fname'); + expect(res.body.history[0]).to.have.property('v'); + }); + }); + + it('should return a list of chats when filtered by ', async () => { + const { + room: { _id: roomId }, + visitor: { _id: visitorId }, + } = await startANewLivechatRoomAndTakeIt(); + + await request + .get(api(`livechat/visitors.searchChats/room/${roomId}/visitor/${visitorId}?source=api&servedChatsOnly=true`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history).to.have.length.of.at.least(1); + expect(res.body.history[0]).to.have.property('_id'); + expect(res.body.history[0]).to.have.property('fname'); + expect(res.body.history[0]).to.have.property('v'); + }); + }); + + it('should return only closed chats when closedChatsOnly is true', async () => { + const { + room: { _id: roomId }, + visitor: { _id: visitorId }, + } = await startANewLivechatRoomAndTakeIt(); + + await closeOmnichannelRoom(roomId); + + await request + .get(api(`livechat/visitors.searchChats/room/${roomId}/visitor/${visitorId}?closedChatsOnly=true&servedChatsOnly=false`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.find((chat: any) => chat._id === roomId)).to.be.an('object'); + }); + }); + + it('should return only served chats when servedChatsOnly is true', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + await request + .get(api(`livechat/visitors.searchChats/room/${room._id}/visitor/${visitor._id}?closedChatsOnly=false&servedChatsOnly=true`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.find((chat: any) => chat._id === room._id)).to.be.undefined; + }); + }); + + it('should return closed rooms (served & unserved) when `closedChatsOnly` is true & `servedChatsOnly` is false', async () => { + const { + room: { _id: roomId }, + visitor: { _id: visitorId, token }, + } = await startANewLivechatRoomAndTakeIt(); + await closeOmnichannelRoom(roomId); + const room2 = await createLivechatRoom(token); + await closeOmnichannelRoom(room2._id); + + await request + .get(api(`livechat/visitors.searchChats/room/${roomId}/visitor/${visitorId}?closedChatsOnly=true&servedChatsOnly=false`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history'); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.find((chat: any) => chat._id === roomId)).to.be.an('object'); + expect(res.body.history.find((chat: any) => chat._id === room2._id)).to.be.an('object'); + }); + }); + + it('should return all chats when both closed & served flags are false', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + const room2 = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room2._id); + await createLivechatRoom(visitor.token); + + const { body } = await request + .get(api(`livechat/visitors.searchChats/room/${room._id}/visitor/${visitor._id}?closedChatsOnly=false&servedChatsOnly=false`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body.count).to.be.equal(3); + expect(body.history.filter((chat: any) => !!chat.closedAt).length === 2).to.be.true; + expect(body.history.filter((chat: any) => !chat.closedAt).length === 1).to.be.true; + expect(body.total).to.be.equal(3); + }); + }); + + describe('livechat/visitor.status', () => { + it('should return an error if token is not present as body param', async () => { + const res = await request.post(api('livechat/visitor.status')).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + }); + it('should return an error if status is not present as body param', async () => { + const res = await request.post(api('livechat/visitor.status')).set(credentials).send({ token: '123' }).expect(400); + expect(res.body).to.have.property('success', false); + }); + it('should return an error if token is not a valid guest token', async () => { + const res = await request.post(api('livechat/visitor.status')).set(credentials).send({ token: '123', status: 'online' }).expect(400); + expect(res.body).to.have.property('success', false); + }); + it('should update visitor status if all things are valid', async () => { + const visitor = await createVisitor(); + const res = await request + .post(api('livechat/visitor.status')) + .set(credentials) + .send({ token: visitor.token, status: 'online' }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); + }); + + describe('GET [omnichannel/contact.search]', () => { + it('should fail if no email|phone|custom params are passed as query', async () => { + await request.get(api('omnichannel/contact.search')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + it('should fail if its trying to find by an empty string', async () => { + await request + .get(api('omnichannel/contact.search')) + .query({ email: '' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if custom is passed but is not JSON serializable', async () => { + await request + .get(api('omnichannel/contact.search')) + .query({ custom: '{a":1}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if custom is an empty object and no email|phone are provided', async () => { + await request + .get(api('omnichannel/contact.search')) + .query({ custom: '{}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should find a contact by email', async () => { + const visitor = await createVisitor(); + await request + .get(api('omnichannel/contact.search')) + .query({ email: visitor.visitorEmails?.[0].address }) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('_id'); + expect(res.body.contact).to.have.property('name'); + expect(res.body.contact).to.have.property('username'); + expect(res.body.contact).to.have.property('phone'); + expect(res.body.contact).to.have.property('visitorEmails'); + expect(res.body.contact._id).to.be.equal(visitor._id); + expect(res.body.contact.phone[0].phoneNumber).to.be.equal(visitor.phone?.[0].phoneNumber); + // done(); + }); + }); + it('should find a contact by phone', async () => { + const visitor = await createVisitor(); + await request + .get(api('omnichannel/contact.search')) + .query({ phone: visitor.phone?.[0].phoneNumber }) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('_id'); + expect(res.body.contact).to.have.property('name'); + expect(res.body.contact).to.have.property('username'); + expect(res.body.contact).to.have.property('phone'); + expect(res.body.contact).to.have.property('visitorEmails'); + expect(res.body.contact._id).to.be.equal(visitor._id); + expect(res.body.contact.phone[0].phoneNumber).to.be.equal(visitor.phone?.[0].phoneNumber); + }); + }); + it('should find a contact by custom field', async () => { + const cfID = 'address'; + const cf = await createCustomField({ + searchable: true, + field: 'address', + label: 'address', + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + + if (!cf) { + throw new Error('Custom field not created'); + } + + await createVisitor(); + + await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ address: 'Rocket.Chat' }) }) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('name'); + expect(res.body.contact).to.have.property('username'); + expect(res.body.contact).to.have.property('phone'); + expect(res.body.contact).to.have.property('visitorEmails'); + expect(res.body.contact.livechatData).to.have.property('address', 'Rocket.Chat street'); + }); + + await deleteCustomField(cfID); + }); + + it('should return null if an invalid set of custom fields is passed and no other params are sent', async () => { + const res = await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ nope: 'nel' }) }) + .set(credentials) + .send(); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it('should not break if more than 1 custom field are passed', async () => { + const res = await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ nope: 'nel', another: 'field' }) }) + .set(credentials) + .send(); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it('should not break if bad things are passed as custom field keys', async () => { + const res = await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ $regex: 'nel' }) }) + .set(credentials) + .send(); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it('should not break if bad things are passed as custom field keys 2', async () => { + const res = await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ '$regex: { very-bad }': 'nel' }) }) + .set(credentials) + .send(); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + + it('should not break if bad things are passed as custom field values', async () => { + const res = await request + .get(api('omnichannel/contact.search')) + .query({ custom: JSON.stringify({ nope: '^((ab)*)+$' }) }) + .set(credentials) + .send(); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.be.null; + }); + }); + + describe('livechat/visitors.search', () => { + it('should fail if user doesnt have view-l-room permission', async () => { + await updatePermission('view-l-room', []); + const res = await request.get(api('livechat/visitors.search')).query({ text: 'nel' }).set(credentials).send(); + expect(res.body).to.have.property('success', false); + }); + it('should fail if term is not on query params', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + const res = await request.get(api(`livechat/visitors.search`)).set(credentials).send(); + expect(res.body).to.have.property('success', false); + }); + it('should not fail when term is an evil regex string', async () => { + const res = await request.get(api('livechat/visitors.search')).query({ term: '^((ab)*)+$' }).set(credentials).send(); + expect(res.body).to.have.property('success', true); + }); + it('should return a list of visitors when term is a valid string', async () => { + const visitor = await createVisitor(); + + const res = await request.get(api('livechat/visitors.search')).query({ term: visitor.name }).set(credentials).send(); + expect(res.body).to.have.property('success', true); + expect(res.body.visitors).to.be.an('array'); + expect(res.body.visitors).to.have.lengthOf.greaterThan(0); + expect(res.body.visitors[0]).to.have.property('_id', visitor._id); + expect(res.body.visitors[0]).to.have.property('name', visitor.name); + expect(res.body.visitors[0]).to.have.property('username', visitor.username); + expect(res.body.visitors[0]).to.have.property('visitorEmails'); + expect(res.body.visitors[0]).to.have.property('phone'); + }); + it('should return a list of visitors when term is an empty string', async () => { + const res = await request.get(api('livechat/visitors.search')).query({ term: '' }).set(credentials).send(); + expect(res.body).to.have.property('success', true); + expect(res.body.visitors).to.be.an('array'); + expect(res.body.visitors).to.have.lengthOf.greaterThan(0); + expect(res.body.visitors[0]).to.have.property('_id'); + expect(res.body.visitors[0]).to.have.property('username'); + expect(res.body.visitors[0]).to.have.property('name'); + expect(res.body.visitors[0]).to.have.property('phone'); + expect(res.body.visitors[0]).to.have.property('visitorEmails'); + }); + }); + + describe('omnichannel/contact', () => { + let contact: ILivechatVisitor; + it('should fail if user doesnt have view-l-room permission', async () => { + await removePermissionFromAllRoles('view-l-room'); + const res = await request.get(api('omnichannel/contact')).query({ text: 'nel' }).set(credentials).send(); + expect(res.body).to.have.property('success', false); + + await restorePermissionToRoles('view-l-room'); + }); + it('should create a new contact', async () => { + const token = getRandomVisitorToken(); + const res = await request.post(api('omnichannel/contact')).set(credentials).send({ + name: faker.person.fullName(), + token, + }); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.be.an('string'); + const contactId: string = res.body.contact; + + contact = await getLivechatVisitorByToken(token); + expect(contact._id).to.equal(contactId); + }); + it('should update an existing contact', async () => { + const name = faker.person.fullName(); + const res = await request.post(api('omnichannel/contact')).set(credentials).send({ + name, + token: contact.token, + }); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.be.an('string'); + expect(res.body.contact).to.equal(contact._id); + + contact = await getLivechatVisitorByToken(contact.token); + expect(contact.name).to.equal(name); + }); + it('should change the contact name, email and phone', async () => { + const name = faker.person.fullName(); + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + const res = await request.post(api('omnichannel/contact')).set(credentials).send({ + name, + email, + phone, + token: contact.token, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.be.an('string'); + expect(res.body.contact).to.equal(contact._id); + + contact = await getLivechatVisitorByToken(contact.token); + expect(contact.name).to.equal(name); + expect(contact.visitorEmails).to.be.an('array'); + expect(contact.visitorEmails).to.have.lengthOf(1); + if (contact.visitorEmails?.[0]) { + expect(contact.visitorEmails[0].address).to.equal(email); + } + expect(contact.phone).to.be.an('array'); + expect(contact.phone).to.have.lengthOf(1); + if (contact.phone?.[0]) { + expect(contact.phone[0].phoneNumber).to.equal(phone); + } + }); + (IS_EE ? it : it.skip)('should change the contact manager', async () => { + const managerUsername = adminUsername; + + const res = await request + .post(api('omnichannel/contact')) + .set(credentials) + .send({ + contactManager: { + username: managerUsername, + }, + token: contact.token, + name: contact.name, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.be.an('string'); + expect(res.body.contact).to.equal(contact._id); + + contact = await getLivechatVisitorByToken(contact.token); + expect(contact.contactManager).to.be.an('object'); + expect(contact.contactManager).to.have.property('username', managerUsername); + }); + it('should change custom fields', async () => { + const cfName = faker.lorem.word(); + await createCustomField({ + searchable: true, + field: cfName, + label: cfName, + scope: 'visitor', + visibility: 'visible', + regexp: '', + }); + + const res = await request + .post(api('omnichannel/contact')) + .set(credentials) + .send({ + token: contact.token, + name: contact.name, + customFields: { + [cfName]: 'test', + }, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.be.an('string'); + expect(res.body.contact).to.equal(contact._id); + + contact = await getLivechatVisitorByToken(contact.token); + expect(contact).to.have.property('livechatData'); + expect(contact.livechatData).to.have.property(cfName, 'test'); + }); + }); + + describe('lead capture', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitorWithCustomData({ + ignoreEmail: true, + ignorePhone: true, + }); + room = await createLivechatRoom(visitor.token); + await createAgent(); + }); + after(async () => { + await closeOmnichannelRoom(room._id); + }); + + it('should capture the data matching the email regex and add it to the visitor', async () => { + await sendMessage(room._id, 'My email is random@email.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(1); + expect(visitor.visitorEmails?.[0].address).to.be.equal('random@email.com'); + }); + + it('should capture more emails when the visitor sends the message', async () => { + await sendMessage(room._id, 'Another email is test@teste.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(2); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('test@teste.com'); + }); + + it('should capture multiple emails', async () => { + await sendMessage(room._id, 'My emails are test@123.com notest@1234.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('test@123.com'); + expect(emails).to.include('notest@1234.com'); + }); + + it('should not add an email thats already registered', async () => { + await sendMessage(room._id, 'My email is test@123.com', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + }); + + it('should not save emails the agent sends', async () => { + await sendAgentMessage(room._id, 'Confirming your email is test@12345.com?', credentials); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(4); + }); + + it('should save phone numbers matching the regex', async () => { + await sendMessage(room._id, 'My phone number is 12345678', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(1); + expect(visitor.phone?.[0].phoneNumber).to.be.equal('12345678'); + }); + + it('should capture more phone numbers when the visitor sends the message', async () => { + await sendMessage(room._id, 'Another phone number is 87654321 87654323', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('87654321'); + expect(phones).to.include('87654323'); + }); + + it('should not add a phone number thats already registered', async () => { + await sendMessage(room._id, 'My phone number is 12345678', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + }); + + it('should not save phone numbers the agent sends', async () => { + await sendAgentMessage(room._id, 'Confirming your phone number is 99999999?', credentials); + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(3); + }); + + it('should capture both phones & emails when sent on the same message', async () => { + await sendMessage(room._id, 'My email is zardw@asdf.com and my phone is 11223344', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(5); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('zardw@asdf.com'); + + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(4); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('11223344'); + }); + + describe('when settings are empty', () => { + before(async () => { + await updateSetting('Livechat_lead_phone_regex', ''); + await updateSetting('Livechat_lead_email_regex', ''); + }); + + after(async () => { + // reset settings + await updateSetting('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b'); + await updateSetting( + 'Livechat_lead_phone_regex', + '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', + ); + }); + + it('should not capture any email or phone no matter what the visitor sends', async () => { + await sendMessage(room._id, 'Now my email is this@shalnotpass.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 55667788', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails || []).to.not.include('this@shalnotpass.com'); + + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones || []).to.not.include('55667788'); + }); + }); + + describe('when phone regex is broken', () => { + before(async () => { + await updateSetting('Livechat_lead_phone_regex', '('); + }); + + after(async () => { + // reset settings + await updateSetting( + 'Livechat_lead_phone_regex', + '((?:\\([0-9]{1,3}\\)|[0-9]{2})[ \\-]*?[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$)|[0-9]{4,5}(?:[\\-\\s\\_]{1,2})?[0-9]{4}(?:(?=[^0-9])|$))', + ); + }); + + it('should capture email', async () => { + await sendMessage(room._id, 'Now my email is this@isok.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 55667799', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + + expect(visitor.visitorEmails).to.be.an('array'); + expect(visitor.visitorEmails).to.have.lengthOf(6); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('this@isok.com'); + + expect(visitor.phone).to.have.lengthOf(4); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones || []).to.not.include('55667788'); + }); + }); + + describe('when email regex is broken', () => { + before(async () => { + await updateSetting('Livechat_lead_email_regex', '('); + }); + + after(async () => { + // reset settings + await updateSetting('Livechat_lead_email_regex', '\\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\\.)+[A-Z]{2,4}\\b'); + }); + + it('should capture email', async () => { + await sendMessage(room._id, 'Now my email is this@shalnotpass.com', visitor.token); + + await sendMessage(room._id, 'And my phone number is 98765432', visitor.token); + + visitor = await getLivechatVisitorByToken(visitor.token); + + expect(visitor.visitorEmails).to.have.lengthOf(6); + const emails = visitor.visitorEmails?.map((e) => e.address); + expect(emails || []).to.not.include('this@shalnotpass.com'); + + expect(visitor.phone).to.be.an('array'); + expect(visitor.phone).to.have.lengthOf(5); + const phones = visitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('98765432'); + }); + }); + + describe('when the visitor has emails & phones already', () => { + let newVisitor: ILivechatVisitor; + let newRoom: IOmnichannelRoom; + before(async () => { + newVisitor = await createVisitor(); + newRoom = await createLivechatRoom(newVisitor.token); + }); + after(async () => { + await closeOmnichannelRoom(newRoom._id); + }); + + it('should capture new emails & phones and add them to the existing ones', async () => { + await sendMessage(newRoom._id, 'My email is 1234@12344.com and my phone is 11223344', newVisitor.token); + + newVisitor = await getLivechatVisitorByToken(newVisitor.token); + expect(newVisitor.visitorEmails).to.be.an('array'); + expect(newVisitor.visitorEmails).to.have.lengthOf(2); + const emails = newVisitor.visitorEmails?.map((e) => e.address); + expect(emails).to.include('1234@12344.com'); + + expect(newVisitor.phone).to.be.an('array'); + expect(newVisitor.phone).to.have.lengthOf(2); + const phones = newVisitor.phone?.map((p) => p.phoneNumber); + expect(phones).to.include('11223344'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts new file mode 100644 index 0000000..4e65439 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts @@ -0,0 +1,1022 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createDepartmentWithAnOnlineAgent, deleteDepartment } from '../../../data/livechat/department'; +import { + makeAgentAvailable, + createAgent, + createDepartment, + createVisitor, + createLivechatRoom, + getLivechatRoomInfo, +} from '../../../data/livechat/rooms'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { restorePermissionToRoles, updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe.skip : describe)('LIVECHAT - Departments[CE]', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await restorePermissionToRoles('view-livechat-manager'); + await createAgent(); + await makeAgentAvailable(); + await updateSetting('Omnichannel_enable_department_removal', true); + }); + + // Remove departments that may have been created before + before(async () => { + const { body } = await request.get(api('livechat/department')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + for await (const department of body.departments) { + await deleteDepartment(department._id); + } + }); + + let departmentId: string; + + after(async () => { + await deleteDepartment(departmentId); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + it('should create a new department', async () => { + const { body } = await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + allowReceiveForwardOffline: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('department'); + expect(body.department).to.have.property('_id'); + expect(body.department).to.have.property('name', 'Test'); + expect(body.department).to.have.property('enabled', true); + expect(body.department).to.have.property('showOnOfflineForm', true); + expect(body.department).to.have.property('showOnRegistration', true); + expect(body.department).to.have.property('allowReceiveForwardOffline', true); + + departmentId = body.department._id; + }); + + it('should not create a 2nd department', () => { + return request + .post(api('livechat/department')) + .set(credentials) + .send({ department: { name: 'Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' } }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return a list of 1 department', async () => { + const { body } = await request.get(api('livechat/department')).set(credentials).expect('Content-Type', 'application/json').expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('departments'); + expect(body.departments).to.be.an('array'); + expect(body.departments).to.have.lengthOf(1); + }); +}); + +(IS_EE ? describe : describe.skip)('LIVECHAT - Departments', () => { + let initialDep: ILivechatDepartment; + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updatePermission('view-livechat-manager', ['admin']); + await createAgent(); + await makeAgentAvailable(); + await updateSetting('Omnichannel_enable_department_removal', true); + initialDep = await createDepartment(undefined, 'Random Pagination Department'); + }); + + after(async () => { + await deleteDepartment(initialDep._id); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('GET livechat/department', () => { + it('should return unauthorized error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-departments', []); + await updatePermission('view-l-room', []); + + await request.get(api('livechat/department')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + + it('should return a list of departments', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await request + .get(api('livechat/department')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments'); + expect(res.body.departments).to.be.an('array'); + expect(res.body.departments).to.have.length.of.at.least(0); + }); + }); + + it('should reject invalid pagination params', async () => { + await request + .get(api('livechat/department')) + .set(credentials) + .query({ count: 'invalid' }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return a list of paginated departments', async () => { + await request + .get(api('livechat/department')) + .set(credentials) + .query({ count: 1, offset: 0 }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments'); + expect(res.body.departments).to.be.an('array'); + expect(res.body.departments).to.have.lengthOf(1); + }); + }); + + it('should sort list alphabetically following mongodb default sort (no collation)', async () => { + const department1 = await createDepartment(undefined, 'A test'); + const department2 = await createDepartment(undefined, 'a test'); + await request + .get(api('livechat/department')) + .set(credentials) + .query({ count: 2, offset: 0, text: 'test' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments'); + expect(res.body.departments).to.be.an('array'); + expect(res.body.departments).to.have.lengthOf(2); + expect(res.body.departments[0]).to.have.property('_id', department1._id); + expect(res.body.departments[1]).to.have.property('_id', department2._id); + }); + await deleteDepartment(department1._id); + await deleteDepartment(department2._id); + }); + + it('should return a list of departments matching name', async () => { + const department1 = await createDepartment(undefined, 'A test 123'); + const department2 = await createDepartment(undefined, 'a test 456'); + await request + .get(api('livechat/department')) + .set(credentials) + .query({ count: 2, offset: 0, text: 'A test 123' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('departments'); + expect(res.body.departments).to.be.an('array'); + expect(res.body.departments).to.have.lengthOf(1); + expect(res.body.departments[0]).to.have.property('_id', department1._id); + expect(res.body.departments.find((dept: ILivechatDepartment) => dept._id === department2._id)).to.be.undefined; + }); + await deleteDepartment(department1._id); + await deleteDepartment(department2._id); + }); + }); + + describe('POST livechat/departments', () => { + it('should return unauthorized error when the user does not have the necessary permission', async () => { + await updatePermission('manage-livechat-departments', []); + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { name: 'TestUnauthorized', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should return an error when no keys are provided', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await request + .post(api('livechat/department')) + .set(credentials) + .send({ department: {} }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an error if requestTagBeforeClosing is true but no tags are provided', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + requestTagBeforeClosingChat: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an error if requestTagBeforeClosing is true but tags are not an array', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + requestTagBeforeClosingChat: true, + chatClosingTags: 'not an array', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should create department if requestTagBeforeClosing is true and tags are an array', async () => { + const chatClosingTags = ['tagA', 'tagB']; + const { body } = await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + requestTagBeforeClosingChat: true, + chatClosingTags, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body.department).to.have.property('requestTagBeforeClosingChat', true); + expect(body.department.chatClosingTags).to.deep.equal(chatClosingTags); + + await deleteDepartment(body.department._id); + }); + + it('should return an error if fallbackForwardDepartment is present but is not a department id', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + fallbackForwardDepartment: 'not a department id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should return an error if fallbackForwardDepartment is referencing a department that does not exist', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + fallbackForwardDepartment: 'not a department id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should create a new department', async () => { + const { body } = await request + .post(api('livechat/department')) + .set(credentials) + .send({ department: { name: 'Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' } }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('department'); + expect(body.department).to.have.property('_id'); + expect(body.department).to.have.property('name', 'Test'); + expect(body.department).to.have.property('enabled', true); + expect(body.department).to.have.property('showOnOfflineForm', true); + expect(body.department).to.have.property('showOnRegistration', true); + await deleteDepartment(body.department._id); + }); + + it('should create a new disabled department', async () => { + const { body } = await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: faker.hacker.adjective(), + enabled: false, + showOnOfflineForm: true, + showOnRegistration: true, + email: faker.internet.email(), + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('department'); + expect(body.department).to.have.property('_id'); + expect(body.department).to.have.property('enabled', false); + await deleteDepartment(body.department._id); + }); + }); + + describe('GET livechat/department/:_id', () => { + it('should return unauthorized error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-departments', []); + await request + .get(api('livechat/department/testetetetstetete')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }).timeout(5000); + + it('should return an error when the department does not exist', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await request + .get(api('livechat/department/testesteteste')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('department'); + expect(res.body.department).to.be.null; + }); + }).timeout(5000); + + it('should return the department', async () => { + await updatePermission('view-livechat-departments', ['admin']); + const department = await createDepartment(); + const { body } = await request + .get(api(`livechat/department/${department._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('department'); + expect(body.department).to.have.property('_id'); + expect(body.department).to.have.property('name', department.name); + expect(body.department).to.have.property('enabled', department.enabled); + expect(body.department).to.have.property('showOnOfflineForm', department.showOnOfflineForm); + expect(body.department).to.have.property('showOnRegistration', department.showOnRegistration); + expect(body.department).to.have.property('email', department.email); + await deleteDepartment(body.department._id); + }); + }); + + describe('PUT livechat/departments/:_id', () => { + let department: ILivechatDepartment; + before(async () => { + department = await createDepartment(); + }); + after(async () => { + await deleteDepartment(department._id); + }); + + it('should return an error if fallbackForwardDepartment points to same department', async () => { + await request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { + name: faker.hacker.adjective(), + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: faker.internet.email(), + fallbackForwardDepartment: department._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should fail if `agents` param is not an array', async () => { + await request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { + name: faker.hacker.adjective(), + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: faker.internet.email(), + }, + agents: 'not an array', + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should throw an error if user has permission to add agents and agents array has invalid format', async () => { + await updatePermission('add-livechat-department-agents', ['admin']); + await request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { + name: faker.hacker.adjective(), + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: faker.internet.email(), + }, + agents: [{ notAValidKey: 'string' }], + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should throw an error if user has permission to add agents and agents array has invalid internal format', async () => { + await request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { + name: faker.hacker.adjective(), + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: faker.internet.email(), + }, + agents: [{ upsert: [{ notAValidKey: 'string' }] }], + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); + + describe('DELETE livechat/department/:_id', () => { + describe('With setting disabled', () => { + before(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + }); + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', true); + }); + + it('should not allow to remove a department if setting is disabled', async () => { + const department = await createDepartment(); + await request + .delete(api(`livechat/department/${department._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-department-removal-disabled'); + }); + }); + }); + + it('should return unauthorized error when the user does not have the necessary permission', async () => { + await updatePermission('manage-livechat-departments', []); + await updatePermission('remove-livechat-department', []); + + await request + .delete(api('livechat/department/testetetetstetete')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should return an error when the department does not exist', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + + const resp: Response = await request + .delete(api('livechat/department/testesteteste')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(resp.body).to.have.property('success', false); + expect(resp.body).to.have.property('error', 'error-department-not-found'); + }); + + it('it should remove the department', async () => { + const department = await createDepartment(); + + const resp: Response = await request + .delete(api(`livechat/department/${department._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(resp.body).to.have.property('success', true); + }); + + it('it should remove the department and disassociate the rooms from it', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + const newVisitor = await createVisitor(department._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const resp: Response = await request + .delete(api(`livechat/department/${department._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(resp.body).to.have.property('success', true); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + expect(latestRoom.departmentId).to.be.undefined; + }); + + (IS_EE ? it : it.skip)('it should remove the department and disassociate the rooms from it which have its units', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + const newVisitor = await createVisitor(department._id); + const newRoom = await createLivechatRoom(newVisitor.token); + + const monitor = await createUser(); + await createMonitor(monitor.username); + const unit = await createUnit(monitor._id, monitor.username, [department._id]); + + // except the room to have the unit + let latestRoom = await getLivechatRoomInfo(newRoom._id); + expect(latestRoom.departmentId).to.be.equal(department._id); + expect(latestRoom.departmentAncestors).to.be.an('array').that.includes(unit._id); + + const resp: Response = await request + .delete(api(`livechat/department/${department._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(resp.body).to.have.property('success', true); + + latestRoom = await getLivechatRoomInfo(newRoom._id); + expect(latestRoom.departmentId).to.be.undefined; + expect(latestRoom.departmentAncestors).to.be.undefined; + + // cleanup + await deleteUser(monitor); + }); + + (IS_EE ? it : it.skip)( + 'contd from above test case: if a unit has more than 1 dept, then it should not disassociate rooms from other dept when any one dept is removed', + async () => { + const { department: department1 } = await createDepartmentWithAnOnlineAgent(); + const newVisitor1 = await createVisitor(department1._id); + const newRoom1 = await createLivechatRoom(newVisitor1.token); + + const { department: department2 } = await createDepartmentWithAnOnlineAgent(); + const newVisitor2 = await createVisitor(department2._id); + const newRoom2 = await createLivechatRoom(newVisitor2.token); + + const monitor = await createUser(); + await createMonitor(monitor.username); + const unit = await createUnit(monitor._id, monitor.username, [department1._id, department2._id]); + + // except the room to have the unit + let latestRoom1 = await getLivechatRoomInfo(newRoom1._id); + let latestRoom2 = await getLivechatRoomInfo(newRoom2._id); + expect(latestRoom1.departmentId).to.be.equal(department1._id); + expect(latestRoom1.departmentAncestors).to.be.an('array').that.includes(unit._id); + expect(latestRoom2.departmentId).to.be.equal(department2._id); + expect(latestRoom2.departmentAncestors).to.be.an('array').that.includes(unit._id); + + const resp: Response = await request + .delete(api(`livechat/department/${department1._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(resp.body).to.have.property('success', true); + + latestRoom1 = await getLivechatRoomInfo(newRoom1._id); + expect(latestRoom1.departmentId).to.be.undefined; + expect(latestRoom1.departmentAncestors).to.be.undefined; + + latestRoom2 = await getLivechatRoomInfo(newRoom2._id); + expect(latestRoom2.departmentId).to.be.equal(department2._id); + expect(latestRoom2.departmentAncestors).to.be.an('array').that.includes(unit._id); + + // cleanup + await deleteUser(monitor); + }, + ); + }); + + describe('GET livechat/department.autocomplete', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-departments', []); + await updatePermission('view-l-room', []); + await request.get(api('livechat/department.autocomplete')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + it('should return an error when the query is not provided', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should return an error when the query is empty', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .query({ selector: '' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should return an error when the query is not a string', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .query({ selector: { name: 'test' } }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should return an error when selector is not valid JSON', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .query({ selector: '{name: "test"' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should return a list of departments that match selector.term', async () => { + // Convert to async/await + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + const department = await createDepartment(undefined, 'test'); + const response = await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .query({ selector: '{"term":"test"}' }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('items'); + expect(response.body.items).to.be.an('array'); + expect(response.body.items).to.have.length.of.at.least(1); + expect(response.body.items[0]).to.have.property('_id'); + expect(response.body.items[0]).to.have.property('name'); + await deleteDepartment(department._id); + }); + + it('should return a list of departments excluding the ids on selector.exceptions', async function () { + if (!IS_EE) { + this.skip(); + } + + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + const dep1 = await createDepartment(); + await createDepartment(); + await request + .get(api('livechat/department.autocomplete')) + .set(credentials) + .query({ selector: `{"exceptions":["${dep1._id}"]}` }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items'); + expect(res.body.items).to.be.an('array'); + expect(res.body.items).to.have.length.of.at.least(1); + expect(res.body.items[0]).to.have.property('_id'); + expect(res.body.items[0]).to.have.property('name'); + expect(res.body.items.every((department: ILivechatDepartment) => department._id !== dep1._id)).to.be.true; + }); + }); + }); + + describe('GET livechat/departments.listByIds', () => { + it('should throw an error if the user doesnt have the permission to view the departments', async () => { + await updatePermission('view-livechat-departments', []); + await updatePermission('view-l-room', []); + await request.get(api('livechat/department.listByIds')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + + it('should return an error when the query is not present', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.listByIds')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should return an error when the query is not an array', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin']); + await request + .get(api('livechat/department.listByIds')) + .set(credentials) + .query({ ids: 'test' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + + describe('GET livechat/department/:departmentId/agents', () => { + it('should throw an error if the user doesnt have the permission to view the departments', async () => { + await updatePermission('view-livechat-departments', []); + await updatePermission('view-l-room', []); + await request.get(api('livechat/department/test/agents')).set(credentials).expect('Content-Type', 'application/json').expect(403); + }); + + it('should return an empty array when the departmentId is not valid', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + await request + .get(api('livechat/department/test/agents')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('agents'); + expect(res.body.agents).to.be.an('array'); + expect(res.body.agents).to.have.lengthOf(0); + expect(res.body.total).to.be.equal(0); + }); + }); + + it('should return an emtpy array for a department without agents', async () => { + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + const dep = await createDepartment(); + const res = await request + .get(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + .expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('agents'); + expect(res.body.agents).to.be.an('array'); + expect(res.body.agents).to.have.lengthOf(0); + expect(res.body.total).to.be.equal(0); + await deleteDepartment(dep._id); + }); + + it('should return the agents of the department', async () => { + // convert to async await + await updatePermission('view-livechat-departments', ['admin']); + await updatePermission('view-l-room', ['admin', 'livechat-agent']); + const agent = await createAgent(); + const dep = await createDepartment([{ agentId: agent._id }]); + const res = await request + .get(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + .expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('agents'); + expect(res.body.agents).to.be.an('array'); + expect(res.body.agents).to.have.lengthOf(1); + expect(res.body.agents[0]).to.have.property('_id'); + expect(res.body.agents[0]).to.have.property('departmentId', dep._id); + expect(res.body.agents[0]).to.have.property('departmentEnabled', true); + expect(res.body.count).to.be.equal(1); + await deleteDepartment(dep._id); + }); + }); + + describe('POST livechat/department/:departmentId/agents', () => { + it('should throw an error if the user doesnt have the permission to manage the departments', async () => { + await updatePermission('manage-livechat-departments', []); + await updatePermission('add-livechat-department-agents', []); + await request + .post(api('livechat/department/test/agents')) + .set(credentials) + .send({ upsert: [], remove: [] }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should throw an error if the departmentId is not valid', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await updatePermission('add-livechat-department-agents', ['admin', 'livechat-manager']); + await request + .post(api('livechat/department/test/agents')) + .set(credentials) + .send({ upsert: [], remove: [] }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Department not found [error-department-not-found]'); + }); + }); + + it('should throw an error if body doesnt contain { upsert: [], remove: [] }', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await updatePermission('add-livechat-department-agents', ['admin', 'livechat-manager']); + const dep = await createDepartment(); + const res = await request + .post(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "must have required property 'upsert' [invalid-params]"); + await deleteDepartment(dep._id); + }); + + it('should throw an error if upsert or remove in body doesnt contain agentId and username', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await updatePermission('add-livechat-department-agents', ['admin', 'livechat-manager']); + const dep = await createDepartment(); + const res = await request + .post(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + .send({ upsert: [{}], remove: [] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "must have required property 'agentId' [invalid-params]"); + await deleteDepartment(dep._id); + }); + + it('should sucessfully add an agent to a department', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await updatePermission('add-livechat-department-agents', ['admin', 'livechat-manager']); + const [dep, agent] = await Promise.all([createDepartment(), createAgent()]); + const res = await request + .post(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + // UI sends agent name as well. API doens't use it, but keeping here for avoid Breaking Changes + .send({ upsert: [{ agentId: agent._id, username: agent.username, name: agent.name }], remove: [] }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); + it('should successfully remove an agent from a department', async () => { + const [dep, agent] = await Promise.all([createDepartment(), createAgent()]); + const res = await request + .post(api(`livechat/department/${dep._id}/agents`)) + .set(credentials) + // UI sends the whole agent object, but API only needs agentId and username + .send({ + remove: [ + { + agentId: agent._id, + username: agent.username, + name: agent.name, + count: 0, + order: 0, + departmentId: 'afdsfads', + _id: 'afsdfadsfaf', + _updatedAt: new Date(), + }, + ], + upsert: [], + }) + .expect(200); + expect(res.body).to.have.property('success', true); + await deleteDepartment(dep._id); + }); + }); + + describe('Department archivation', () => { + let departmentForTest: ILivechatDepartment; + it('should fail if user is not logged in', async () => { + await request.post(api('livechat/department/123/archive')).expect(401); + }); + it('should fail if user doesnt have manage-livechat-departments permission', async () => { + await updatePermission('manage-livechat-departments', []); + await request.post(api('livechat/department/123/archive')).set(credentials).expect(403); + }); + it('should fail if departmentId is not valid', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + await request.post(api('livechat/department/123/archive')).set(credentials).expect(400); + }); + it('should archive a department', async () => { + await updatePermission('manage-livechat-departments', ['admin']); + const department = await createDepartment(); + await request + .post(api(`livechat/department/${department._id}/archive`)) + .set(credentials) + .expect(200); + departmentForTest = department; + }); + it('should return a list of archived departments', async () => { + const { body } = await request.get(api('livechat/departments/archived')).set(credentials).expect(200); + expect(body).to.have.property('success', true); + expect(body).to.have.property('departments'); + expect(body.departments).to.be.an('array'); + expect(body.departments[0]).to.have.property('_id', departmentForTest._id); + expect(body.departments.length).to.be.equal(1); + }); + it('should unarchive a department', async () => { + await request + .post(api(`livechat/department/${departmentForTest._id}/unarchive`)) + .set(credentials) + .expect(200); + }); + }); + + describe('With multiple bussines hours', () => { + before(async () => + Promise.all([updateEESetting('Livechat_enable_business_hours', true), updateEESetting('Livechat_business_hour_type', 'Multiple')]), + ); + after(async () => + Promise.all([updateEESetting('Livechat_enable_business_hours', false), updateEESetting('Livechat_business_hour_type', 'Single')]), + ); + + let testUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials3 = await login(user.username, password); + await makeAgentAvailable(credentials3); + + testUser = { + user, + credentials: credentials3, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department`, true); + }); + + after(async () => { + await Promise.all([deleteUser(testUser.user), deleteDepartment(testDepartment._id)]); + }); + + it('should allow to remove an agent from a department when multiple business hours are enabled', async () => { + const res = await request + .post(api(`livechat/department/${testDepartment._id}/agents`)) + .set(credentials) + .send({ upsert: [], remove: [{ agentId: testUser.user._id, username: testUser.user.username }] }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts b/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts new file mode 100644 index 0000000..8b0f3d8 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts @@ -0,0 +1,215 @@ +import type { IEmailInbox } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createEmailInbox } from '../../../data/livechat/inboxes'; +import { updatePermission } from '../../../data/permissions.helper'; + +describe('Email inbox', () => { + before((done) => getCredentials(done)); + let testInbox = ''; + before(async () => { + await request + .post(api('email-inbox')) + .set(credentials) + .send({ + active: true, + name: 'test-email-inbox##', + email: 'test-email@example.com', + description: 'test email inbox', + senderInfo: 'test email inbox', + smtp: { + server: 'smtp.example.com', + port: 587, + username: 'example@example.com', + password: 'not-a-real-password', + secure: true, + }, + imap: { + server: 'imap.example.com', + port: 993, + username: 'example@example.com', + password: 'not-a-real-password', + secure: true, + maxRetries: 10, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success'); + if (res.body.success === true) { + testInbox = res.body._id; + } else { + expect(res.body).to.have.property('error'); + expect(res.body.error.includes('E11000')).to.be.eq(true); + } + }); + }); + after(async () => { + if (testInbox) { + await request + .delete(api(`email-inbox/${testInbox}`)) + .set(credentials) + .send() + .expect(200); + } + }); + describe('GET email-inbox.list', () => { + it('should fail if user doesnt have manage-email-inbox permission', async () => { + await updatePermission('manage-email-inbox', []); + await request.get(api('email-inbox.list')).set(credentials).expect(403); + }); + it('should return a list of email inboxes', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const res = await request.get(api('email-inbox.list')).set(credentials).send().expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('emailInboxes'); + expect(res.body.emailInboxes).to.be.an('array'); + expect(res.body.emailInboxes).to.have.length.of.at.least(1); + expect(res.body.emailInboxes.filter((ibx: IEmailInbox) => ibx.email === 'test-email@example.com')).to.have.length.gte(1); + // make sure we delete the test inbox, even if creation failed on this test run + testInbox = res.body.emailInboxes.filter((ibx: IEmailInbox) => ibx.email === 'test-email@example.com')[0]._id; + expect(res.body).to.have.property('total'); + expect(res.body.total).to.be.a('number'); + expect(res.body).to.have.property('count'); + expect(res.body.count).to.be.a('number'); + expect(res.body).to.have.property('offset'); + expect(res.body.offset).to.be.a('number'); + }); + }); + + describe('POST email-inbox', () => { + let inboxId: string; + const mockedPayload = { + name: 'test', + active: false, + email: `test${new Date().getTime()}@test.com`, + description: 'Updated test description', + senderInfo: 'test', + smtp: { + server: 'smtp.example.com', + port: 587, + username: 'xxxx', + password: 'xxxx', + secure: true, + }, + imap: { + server: 'imap.example.com', + port: 993, + username: 'xxxx', + password: 'xxxx', + secure: true, + maxRetries: 10, + }, + }; + it('should fail if user doesnt have manage-email-inbox permission', async () => { + await updatePermission('manage-email-inbox', []); + await request.post(api('email-inbox')).set(credentials).send(mockedPayload).expect(403); + }); + it('should fail if smtp config is not on body params', async () => { + await updatePermission('manage-email-inbox', ['admin']); + await request.post(api('email-inbox')).set(credentials).send({}).expect(400); + }); + it('should fail if imap config is not on body params', async () => { + const { imap, ...payload } = mockedPayload; + await request.post(api('email-inbox')).set(credentials).send(payload).expect(400); + }); + it('should fail if name is not on body params', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const { name, ...payload } = mockedPayload; + await request.post(api('email-inbox')).set(credentials).send(payload).expect(400); + }); + it('should fail if active is not on body params', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const { active, ...payload } = mockedPayload; + await request.post(api('email-inbox')).set(credentials).send(payload).expect(400); + }); + it('should fail if email is not on body params', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const { email, ...payload } = mockedPayload; + await request.post(api('email-inbox')).set(credentials).send(payload).expect(400); + }); + it('should save an email inbox', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const { email, ...payload } = mockedPayload; + const { body } = await request + .post(api('email-inbox')) + .set(credentials) + .send({ + ...payload, + email: `test${new Date().getTime()}@test.com`, + }) + .expect(200); + + expect(body).to.have.property('_id'); + inboxId = body._id; + }); + it('should update an email inbox when _id is passed in the object', async () => { + await updatePermission('manage-email-inbox', ['admin']); + const { body } = await request + .post(api('email-inbox')) + .set(credentials) + .send({ + ...mockedPayload, + _id: inboxId, + description: 'Updated test description', + }) + .expect(200); + + expect(body).to.have.property('_id'); + }); + }); + describe('GET email-inbox/:_id', () => { + it('should fail if user doesnt have manage-email-inbox permission', async () => { + await updatePermission('manage-email-inbox', []); + await request.get(api('email-inbox/123')).set(credentials).expect(403); + }); + it('should fail when email inbox does not exist', async () => { + await updatePermission('manage-email-inbox', ['admin']); + await request.get(api('email-inbox/123')).set(credentials).expect(404); + }); + it('should return an email inbox', async () => { + const inbox = await createEmailInbox(); + const { body } = await request + .get(api(`email-inbox/${inbox._id}`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('_id'); + expect(body).to.have.property('name', 'test'); + }); + }); + describe('DELETE email-inbox/:_id', () => { + it('should fail if user doesnt have manage-email-inbox permission', async () => { + await updatePermission('manage-email-inbox', []); + await request.delete(api('email-inbox/123')).set(credentials).expect(403); + }); + it('should return nothing when email inbox does not exist', async () => { + await updatePermission('manage-email-inbox', ['admin']); + await request.delete(api('email-inbox/123')).set(credentials).expect(404); + }); + it('should delete an email inbox', async () => { + const inbox = await createEmailInbox(); + const { body } = await request + .delete(api(`email-inbox/${inbox._id}`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('success', true); + }); + }); + describe('GET email-inbox.search', () => { + it('should fail if user doesnt have manage-email-inbox permission', async () => { + await updatePermission('manage-email-inbox', []); + await request.get(api('email-inbox.search')).set(credentials).expect(403); + }); + it('should return an email inbox matching email', async () => { + await createEmailInbox(); + await updatePermission('manage-email-inbox', ['admin']); + await request.get(api('email-inbox.search')).query({ email: 'test' }).set(credentials).expect(200); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts new file mode 100644 index 0000000..64a13fd --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts @@ -0,0 +1,844 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { sleep } from '../../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { + addOrRemoveAgentFromDepartment, + createDepartmentWithAnOfflineAgent, + createDepartmentWithAnOnlineAgent, + deleteDepartment, +} from '../../../data/livechat/department'; +import { + createVisitor, + createLivechatRoom, + makeAgentUnavailable, + closeOmnichannelRoom, + sendMessage, + deleteVisitor, + createDepartment, +} from '../../../data/livechat/rooms'; +import { createBotAgent, getRandomVisitorToken } from '../../../data/livechat/users'; +import type { WithRequiredProperty } from '../../../data/livechat/utils'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { deleteUser } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - Utils', () => { + before((done) => getCredentials(done)); + before(async () => { + await updateSetting('Omnichannel_enable_department_removal', true); + }); + + after(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_offline_email', ''); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('livechat/offline.message', () => { + it('should fail if name is not sent as body parameter', async () => { + const { body } = await request.post(api('livechat/offline.message')).set(credentials).send({}); + expect(body).to.have.property('success', false); + }); + it('should fail if email is not sent as body parameter', async () => { + const { body } = await request.post(api('livechat/offline.message')).set(credentials).send({ name: 'test' }); + expect(body).to.have.property('success', false); + }); + it('should fail if message is not sent as body parameter', async () => { + await request.post(api('livechat/offline.message')).set(credentials).send({ name: 'test', email: '' }).expect(400); + }); + it('should fail if setting Livechat_display_offline_form is disabled', async () => { + await updateSetting('Livechat_display_offline_form', false); + await request.post(api('livechat/offline.message')).set(credentials).send({ name: 'test', email: '', message: 'test' }).expect(400); + }); + it('should fail if Livechat_validate_offline_email is enabled and email passed is not resolvable', async () => { + await updateSetting('Livechat_display_offline_form', true); + await updateSetting('Livechat_validate_offline_email', true); + await request + .post(api('livechat/offline.message')) + .set(credentials) + .send({ name: 'test', email: 'test@fadsjfldasjfd.com', message: 'test' }) + .expect(400); + }); + it('should fail if setting Livechat_offline_email is not setup or is invalid', async () => { + await updateSetting('Livechat_validate_offline_email', false); + await updateSetting('Livechat_validate_offline_email', 'afsdxcvxc'); + await request + .post(api('livechat/offline.message')) + .set(credentials) + .send({ name: 'test', email: 'test@email.com', message: 'this is a test :)' }) + .expect(400); + }); + it('should send an offline email', async () => { + await updateSetting('Livechat_validate_offline_email', false); + await updateSetting('Livechat_offline_email', 'test-email@rocket.chat'); + await request + .post(api('livechat/offline.message')) + .set(credentials) + .send({ name: 'test', email: 'test@email.com', message: 'this is a test :)' }) + .expect(200); + }); + }); + + describe('livechat/config', () => { + let emptyDepartment: ILivechatDepartment; + let forwardDepartment: ILivechatDepartment; + let testDepartment: ILivechatDepartment; + let agent: { user: WithRequiredProperty; credentials: Credentials }; + let agent2: { user: WithRequiredProperty; credentials: Credentials }; + + before(async () => { + if (!IS_EE) { + return; + } + + emptyDepartment = await createDepartment(); + ({ department: forwardDepartment, agent } = await createDepartmentWithAnOnlineAgent()); + ({ department: testDepartment, agent: agent2 } = await createDepartmentWithAnOfflineAgent({ + departmentsAllowedToForward: [forwardDepartment._id], + })); + }); + + after(() => { + if (!IS_EE) { + return; + } + + return Promise.all([ + deleteDepartment(emptyDepartment._id), + deleteDepartment(forwardDepartment._id), + deleteDepartment(testDepartment._id), + deleteUser(agent.user), + deleteUser(agent2.user), + ]); + }); + + it('should return enabled: false if livechat is disabled', async () => { + await updateSetting('Livechat_enabled', false); + const { body } = await request.get(api('livechat/config')).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('enabled', false); + }); + it('should return basic livechat config when enabled', async () => { + await updateSetting('Livechat_enabled', true); + const { body } = await request.get(api('livechat/config')).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('enabled', true); + expect(body.config).to.have.property('settings'); + expect(body.config).to.have.property('departments').that.is.an('array'); + }); + it('should have custom fields data', async () => { + const customFieldName = `new_custom_field_${Date.now()}`; + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'visible', + regexp: '', + public: true, + required: false, + options: '', + }); + + const { body } = await request.get(api('livechat/config')).set(credentials); + + expect(body).to.have.property('config'); + expect(body.config).to.have.property('customFields').that.is.an('array'); + expect(body.config.customFields).to.have.length.greaterThan(0); + const customField = body.config.customFields.find((field: any) => field._id === customFieldName); + expect(customField).to.be.an('object'); + expect(customField).to.have.property('label', customFieldName); + + await deleteCustomField(customFieldName); + }); + (IS_EE ? it : it.skip)('should return online as true if there is at least one agent online', async () => { + const { department } = await createDepartmentWithAnOnlineAgent(); + + const { body } = await request.get(api('livechat/config')).query({ department: department._id }).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('online', true); + }); + (IS_EE ? it : it.skip)('should return online as false if there is no agent online', async () => { + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await makeAgentUnavailable(agent.credentials); + + const { body } = await request.get(api('livechat/config')).query({ department: department._id }).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('online', false); + }); + (IS_EE ? it : it.skip)('should return online as true if bot is online and there is no agent online', async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await makeAgentUnavailable(agent.credentials); + + const botUser = await createBotAgent(); + await addOrRemoveAgentFromDepartment(department._id, { agentId: botUser.user._id, username: botUser.user.username as string }, true); + + const { body } = await request.get(api('livechat/config')).query({ department: department._id }).set(credentials); + expect(body).to.have.property('config'); + + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + await makeAgentUnavailable(botUser.credentials); + }); + it('should return a guest if there exists a guest with the same token', async () => { + const guest = await createVisitor(); + const { body } = await request.get(api('livechat/config')).query({ token: guest.token }).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('guest'); + expect(body.config.guest).to.have.property('name', guest.name); + }); + it('should not return a guest if there exists a guest with the same token but the guest is not online', async () => { + const token = getRandomVisitorToken(); + + const { body } = await request.get(api('livechat/config')).query({ token }).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.not.have.property('guest'); + }); + it('should return no online room if visitor is not chatting with an agent', async () => { + const visitor = await createVisitor(); + const { body } = await request.get(api('livechat/config')).query({ token: visitor.token }).set(credentials); + expect(body).to.have.property('config'); + expect(body.config).to.not.have.property('room'); + }); + it('should return online room if visitor is already chatting with an agent', async () => { + const newVisitor = await createVisitor(); + const newRoom = await createLivechatRoom(newVisitor.token); + + const { body } = await request.get(api('livechat/config')).query({ token: newVisitor.token }).set(credentials); + + expect(body).to.have.property('config'); + expect(body.config).to.have.property('room'); + expect(body.config.room).to.have.property('_id', newRoom._id); + }); + (IS_EE ? it : it.skip)('should return list of departments with at least one agent', async () => { + const { body } = await request.get(api('livechat/config')).set(credentials); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('config'); + expect(body.config).to.have.property('departments'); + expect(body.config.departments).to.be.an('array').with.lengthOf.at.least(2); + + expect(body.config.departments).to.not.deep.include({ + _id: emptyDepartment._id, + name: emptyDepartment.name, + showOnRegistration: emptyDepartment.showOnRegistration, + showOnOfflineForm: emptyDepartment.showOnOfflineForm, + }); + expect(body.config.departments).to.deep.include({ + _id: forwardDepartment._id, + name: forwardDepartment.name, + showOnRegistration: forwardDepartment.showOnRegistration, + showOnOfflineForm: forwardDepartment.showOnOfflineForm, + }); + expect(body.config.departments).to.deep.include({ + _id: testDepartment._id, + name: testDepartment.name, + showOnRegistration: testDepartment.showOnRegistration, + showOnOfflineForm: testDepartment.showOnOfflineForm, + departmentsAllowedToForward: [forwardDepartment._id], + }); + }); + }); + + describe('livechat/page.visited', () => { + it('should fail if token is not sent as body param', async () => { + const { body } = await request.post(api('livechat/page.visited')).set(credentials).send({}); + expect(body).to.have.property('success', false); + }); + it('should fail if rid is not an string', async () => { + const { body } = await request.post(api('livechat/page.visited')).set(credentials).send({ token: 'test', rid: {} }); + expect(body).to.have.property('success', false); + }); + it('should fail if pageInfo is not sent as body param', async () => { + const { body } = await request.post(api('livechat/page.visited')).set(credentials).send({ token: 'test', rid: 'test' }); + expect(body).to.have.property('success', false); + }); + it('should fail if pageInfo is not of the right format', async () => { + const { body } = await request.post(api('livechat/page.visited')).set(credentials).send({ token: 'test', rid: 'test', pageInfo: {} }); + expect(body).to.have.property('success', false); + }); + it('should return empty if pageInfo.change is not equal to Livechat_history_monitor_type', async () => { + const { body } = await request + .post(api('livechat/page.visited')) + .set(credentials) + .send({ token: 'test', rid: 'test', pageInfo: { change: 'test', title: 'test', location: { href: 'test' } } }); + expect(body).to.have.property('success', true); + }); + it('should store values for 1 month if visitor has no room yet', async () => { + const { body } = await request + .post(api('livechat/page.visited')) + .set(credentials) + .send({ token: 'test', rid: 'test', pageInfo: { change: 'url', title: 'Rocket.Chat', location: { href: 'https://rocket.chat' } } }); + expect(body).to.have.property('success', true); + expect(body).to.have.property('page'); + expect(body.page).to.have.property('navigation'); + expect(body.page).to.have.property('msg'); + }); + it('should store values correctly on visitor room when room is valid', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/page.visited')) + .set(credentials) + .send({ + token: visitor.token, + rid: room._id, + pageInfo: { change: 'url', title: 'Rocket.Chat', location: { href: 'https://rocket.chat' } }, + }); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('page'); + expect(body.page).to.have.property('navigation'); + expect(body.page).to.have.property('msg'); + }); + }); + + describe('livechat/transcript', () => { + it('should fail if token is not in body params', async () => { + const { body } = await request.post(api('livechat/transcript')).set(credentials).send({}); + expect(body).to.have.property('success', false); + }); + it('should fail if rid is not in body params', async () => { + const { body } = await request.post(api('livechat/transcript')).set(credentials).send({ token: 'test' }); + expect(body).to.have.property('success', false); + }); + it('should fail if email is not in body params', async () => { + const { body } = await request.post(api('livechat/transcript')).set(credentials).send({ token: 'test', rid: 'test' }); + expect(body).to.have.property('success', false); + }); + it('should fail if token is not a valid guest token', async () => { + const { body } = await request.post(api('livechat/transcript')).set(credentials).send({ token: 'test', rid: 'test', email: '' }); + expect(body).to.have.property('success', false); + }); + it('should fail if rid is not a valid room id', async () => { + const visitor = await createVisitor(); + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor.token, rid: 'test', email: '' }); + expect(body).to.have.property('success', false); + }); + it('should fail if requesting a transcript of another visitors room', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const visitor2 = await createVisitor(); + + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor2.token, rid: room._id, email: '' }); + expect(body).to.have.property('success', false); + }); + it('should fail if email is not a valid email', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor.token, rid: room._id, email: 'test' }); + expect(body).to.have.property('success', false); + }); + it('should send a transcript if all is good', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor.token, rid: room._id, email: 'visitor@notadomain.com' }); + expect(body).to.have.property('success', true); + }); + it('should allow a visitor to get a transcript even if token changed by using an old token that matches room.v', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + const visitor2 = await createVisitor(undefined, undefined, visitor.visitorEmails?.[0].address); + const room2 = await createLivechatRoom(visitor2.token); + await closeOmnichannelRoom(room2._id); + + expect(visitor.token !== visitor2.token).to.be.true; + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor.token, rid: room._id, email: 'visitor@notadomain.com' }); + expect(body).to.have.property('success', true); + + const { body: body2 } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor2.token, rid: room2._id, email: 'visitor@notadomain.com' }); + expect(body2).to.have.property('success', true); + }); + }); + + describe('livechat/transcript/:rid', () => { + it('should fail if user is not authenticated', async () => { + await request.delete(api('livechat/transcript/rid')).send({}).expect(401); + }); + it('should fail if user doesnt have "send-omnichannel-chat-transcript" permission', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + await removePermissionFromAllRoles('send-omnichannel-chat-transcript'); + + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({}) + .expect(403); + }); + it('should fail if rid is not a valid room id', async () => { + await restorePermissionToRoles('send-omnichannel-chat-transcript'); + await request.delete(api('livechat/transcript/rid')).set(credentials).send({}).expect(400); + }); + it('should fail if room is not open', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + await closeOmnichannelRoom(room._id); + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({}) + .expect(400); + }); + it('should fail if room doesnt have transcript requested', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({}) + .expect(400); + }); + it('should remove transcript if all good', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(200); + + await request + .delete(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({}) + .expect(200); + }); + }); + + describe('POST livechat/transcript/:rid', () => { + it('should fail if user is not authenticated', async () => { + await request.post(api('livechat/transcript/rid')).send({}).expect(401); + }); + it('should fail if "email" param is not sent', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({}) + .expect(400); + }); + it('should fail if "subject" param is not sent', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.xmz' }) + .expect(400); + }); + it('should fail if user doesnt have "send-omnichannel-chat-transcript" permission', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + await updatePermission('send-omnichannel-chat-transcript', []); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(403); + }); + it('should fail if rid is not a valid room id', async () => { + await updatePermission('send-omnichannel-chat-transcript', ['livechat-manager', 'admin']); + await request.post(api('livechat/transcript/rid')).set(credentials).send({ email: 'abc@abc.com', subject: 'test' }).expect(400); + }); + it('should fail if room is not open', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + await closeOmnichannelRoom(room._id); + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(400); + }); + it('should fail if room already has transcript requested', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(200); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(400); + }); + it('should request transcript if all good', async () => { + const user = await createVisitor(); + const room = await createLivechatRoom(user.token); + + await request + .post(api(`livechat/transcript/${room._id}`)) + .set(credentials) + .send({ email: 'abc@abc.com', subject: 'test' }) + .expect(200); + }); + }); + + describe('livechat/visitors.search', () => { + it('should bring sorted data by last chat time', async () => { + const visitor1 = await createVisitor(undefined, 'VisitorInPast'); + const room1 = await createLivechatRoom(visitor1.token); + + const visitor2 = await createVisitor(undefined, 'VisitorInPresent'); + const room2 = await createLivechatRoom(visitor2.token); + + const { body: result1 } = await request + .get(api('livechat/visitors.search')) + .query({ term: 'VisitorIn', sort: '{"lastChat.ts":1}' }) + .set(credentials) + .send(); + + expect(result1).to.have.property('visitors').that.is.an('array'); + expect(result1.visitors[0]).to.have.property('name'); + expect(result1.visitors[0].name).to.be.eq('VisitorInPast'); + + const { body: result2 } = await request + .get(api('livechat/visitors.search')) + .query({ term: 'VisitorIn', sort: '{"lastChat.ts":-1}' }) + .set(credentials) + .send(); + + expect(result2).to.have.property('visitors').that.is.an('array'); + expect(result2.visitors[0]).to.have.property('name'); + expect(result2.visitors[0].name).to.be.eq('VisitorInPresent'); + + await closeOmnichannelRoom(room1._id); + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor1.token); + await deleteVisitor(visitor2.token); + }); + }); + + describe('livechat/message', () => { + const visitorTokens: string[] = []; + + after(() => Promise.all(visitorTokens.map((token) => deleteVisitor(token)))); + + it('should fail if no token', async () => { + await request.post(api('livechat/message')).set(credentials).send({}).expect(400); + }); + it('should fail if no rid', async () => { + await request.post(api('livechat/message')).set(credentials).send({ token: 'test' }).expect(400); + }); + it('should fail if no msg', async () => { + await request.post(api('livechat/message')).set(credentials).send({ token: 'test', rid: 'test' }).expect(400); + }); + it('should fail if token is invalid', async () => { + await request.post(api('livechat/message')).set(credentials).send({ token: 'test', rid: 'test', msg: 'test' }).expect(400); + }); + it('should fail if rid is invalid', async () => { + const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: 'test', msg: 'test' }).expect(400); + }); + it('should fail if rid belongs to another visitor', async () => { + const visitor = await createVisitor(); + const visitor2 = await createVisitor(); + visitorTokens.push(visitor.token, visitor2.token); + + const room = await createLivechatRoom(visitor2.token); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(400); + }); + it('should fail if room is closed', async () => { + const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(400); + }); + it('should fail if message is greater than Livechat_enable_message_character_limit setting', async () => { + const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + + const room = await createLivechatRoom(visitor.token); + await updateSetting('Livechat_enable_message_character_limit', true); + await updateSetting('Livechat_message_character_limit', 1); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(400); + + await updateSetting('Livechat_enable_message_character_limit', false); + }); + it('should send a message', async () => { + const visitor = await createVisitor(); + visitorTokens.push(visitor.token); + + const room = await createLivechatRoom(visitor.token); + await request.post(api('livechat/message')).set(credentials).send({ token: visitor.token, rid: room._id, msg: 'test' }).expect(200); + }); + }); + + describe('[GET] livechat/message/:_id', () => { + it('should fail if no token is provided', async () => { + await request.get(api('livechat/message/rid')).set(credentials).expect(400); + }); + it('should fail if no rid is provided', async () => { + await request.get(api('livechat/message/mid')).query({ token: 'test' }).expect(400); + }); + it('should fail if token points to an invalid visitor', async () => { + await request.get(api('livechat/message/mid')).query({ token: 'test', rid: 'test' }).expect(400); + }); + it('should fail if room points to an invalid room', async () => { + const visitor = await createVisitor(); + await request.get(api('livechat/message/mid')).query({ token: visitor.token, rid: 'test' }).expect(400); + }); + it('should fail if _id points to an invalid message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request.get(api('livechat/message/mid')).query({ token: visitor.token, rid: room._id }).expect(400); + }); + it('should return a message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const message = await sendMessage(room._id, 'test', visitor.token); + await request + .get(api(`livechat/message/${message._id}`)) + .query({ token: visitor.token, rid: room._id }) + .expect(200); + }); + }); + + describe('[PUT] livechat/message/:_id', () => { + it('should fail if no token is provided', async () => { + await request.put(api('livechat/message/rid')).set(credentials).expect(400); + }); + it('should fail if no rid is provided', async () => { + await request.put(api('livechat/message/mid')).query({ token: 'test' }).expect(400); + }); + it('should fail if token points to an invalid visitor', async () => { + await request.put(api('livechat/message/mid')).query({ token: 'test', rid: 'test' }).expect(400); + }); + it('should fail if room points to an invalid room', async () => { + const visitor = await createVisitor(); + await request.put(api('livechat/message/mid')).query({ token: visitor.token, rid: 'test' }).expect(400); + }); + it('should fail if _id points to an invalid message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request.put(api('livechat/message/mid')).query({ token: visitor.token, rid: room._id }).expect(400); + }); + it('should update a message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const message = await sendMessage(room._id, 'test', visitor.token); + const { body } = await request + .put(api(`livechat/message/${message._id}`)) + .set(credentials) + .send({ msg: 'test2', token: visitor.token, rid: room._id }) + .expect(200); + expect(body).to.have.property('message'); + expect(body.message).to.have.property('_id', message._id); + expect(body.message).to.have.property('msg', 'test2'); + }); + }); + + describe('[DELETE] livechat/message/:_id', () => { + it('should fail if no token is provided', async () => { + await request.delete(api('livechat/message/rid')).set(credentials).expect(400); + }); + it('should fail if no rid is provided', async () => { + await request.delete(api('livechat/message/mid')).query({ token: 'test' }).expect(400); + }); + it('should fail if token points to an invalid visitor', async () => { + await request.delete(api('livechat/message/mid')).query({ token: 'test', rid: 'test' }).expect(400); + }); + it('should fail if room points to an invalid room', async () => { + const visitor = await createVisitor(); + await request.delete(api('livechat/message/mid')).query({ token: visitor.token, rid: 'test' }).expect(400); + }); + it('should fail if _id points to an invalid message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await request.delete(api('livechat/message/mid')).query({ token: visitor.token, rid: room._id }).expect(400); + }); + it('should delete a message', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const message = await sendMessage(room._id, 'test', visitor.token); + + const { body } = await request + .delete(api(`livechat/message/${message._id}`)) + .send({ token: visitor.token, rid: room._id }) + .expect(200); + + expect(body).to.have.property('message'); + expect(body.message).to.have.property('_id', message._id); + }); + }); + + describe('livechat/messages.history/:rid', () => { + it('should fail if no token is provided', async () => { + await request.get(api('livechat/messages.history/rid')).set(credentials).expect(400); + }); + it('should fail if token points to an invalid visitor', async () => { + await request.get(api('livechat/messages.history/rid')).query({ token: 'test' }).expect(400); + }); + it('should fail if room points to an invalid room', async () => { + const visitor = await createVisitor(); + await request.get(api('livechat/messages.history/rid')).query({ token: visitor.token }).expect(400); + }); + it('should fail if room points to a room of another visitor', async () => { + const visitor = await createVisitor(); + const visitor2 = await createVisitor(); + const room = await createLivechatRoom(visitor2.token); + await request + .get(api(`livechat/messages.history/${room._id}`)) + .query({ token: visitor.token }) + .expect(400); + }); + it('should return a list of messages', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'test', visitor.token); + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .query({ token: visitor.token }) + .expect(200); + expect(body).to.have.property('messages').that.is.an('array'); + expect(body.messages).to.have.lengthOf(2); + expect(body.messages[0]).to.have.property('msg', 'test'); + }); + it('should return a list of messages with offset and count', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'test', visitor.token); + await sendMessage(room._id, 'test2', visitor.token); + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .query({ token: visitor.token, offset: 1, limit: 1 }) + .expect(200); + expect(body).to.have.property('messages').that.is.an('array'); + expect(body.messages).to.have.lengthOf(1); + expect(body.messages[0]).to.have.property('msg', 'test'); + }); + it('should return a list of unseen messages', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'test', visitor.token); + await sendMessage(room._id, 'test2', visitor.token); + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .query({ token: visitor.token, ls: new Date() }) + .expect(200); + expect(body).to.have.property('messages').that.is.an('array'); + expect(body.messages).to.have.lengthOf(3); + expect(body.messages[0]).to.have.property('msg', 'test2'); + }); + it('should return a list of messages up to a specific date', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const msg = await sendMessage(room._id, 'test', visitor.token); + const tsPlusSomeMillis = new Date(new Date(msg.ts).getTime() + 500); + await sleep(1000); + await sendMessage(room._id, 'test2', visitor.token); + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .query({ token: visitor.token, end: tsPlusSomeMillis }) + .expect(200); + expect(body).to.have.property('messages').that.is.an('array'); + expect(body.messages).to.have.lengthOf(2); + expect(body.messages[0]).to.have.property('msg', 'test'); + }); + it('should return message history for a valid room with pagination', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .set(credentials) + .query({ token: visitor.token, limit: 1 }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages').of.length(1); + expect(body.messages[0]).to.have.property('msg', 'Hello'); + }); + it('should return message history for a valid room with pagination and offset', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'Hello', visitor.token); + + const { body } = await request + .get(api(`livechat/messages.history/${room._id}`)) + .set(credentials) + .query({ token: visitor.token, limit: 1, offset: 1 }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('messages').of.length(1); + expect(body.messages[0]).to.have.property('t'); + }); + }); + + (IS_EE ? describe : describe.skip)('[EE] livechat widget', () => { + it('should include additional css when provided via Livechat_WidgetLayoutClasses setting', async () => { + await updateSetting('Livechat_WidgetLayoutClasses', 'http://my.css.com/my.css'); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.css.com/my.css')).to.be.true; + }); + + it('should remove additional css when setting Livechat_WidgetLayoutClasses is empty', async () => { + await updateSetting('Livechat_WidgetLayoutClasses', ''); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.css.com/my.css')).to.be.false; + }); + + it('should include additional js when provided via Livechat_AdditionalWidgetScripts setting', async () => { + await updateSetting('Livechat_AdditionalWidgetScripts', 'http://my.js.com/my.js'); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.js.com/my.js')).to.be.true; + }); + + it('should remove additional js when setting Livechat_AdditionalWidgetScripts is empty', async () => { + await updateSetting('Livechat_AdditionalWidgetScripts', ''); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.js.com/my.js')).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts b/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts new file mode 100644 index 0000000..c9bdaa6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { api, request, credentials, getCredentials } from '../../../data/api-data'; +import { updatePermission } from '../../../data/permissions.helper'; + +describe('Mailer', () => { + before((done) => getCredentials(done)); + + describe('POST mailer', async () => { + before(async () => { + return updatePermission('send-mail', ['admin']); + }); + + after(async () => { + return updatePermission('send-mail', ['admin']); + }); + + it('should send an email if the payload is correct', async () => { + await request + .post(api('mailer')) + .set(credentials) + .send({ + from: 'rocketchat.internal.admin.test@rocket.chat', + subject: 'Test email subject', + body: 'Test email body [unsubscribe]', + dryrun: true, + query: '', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + it('should throw an error if the request is incorrect', async () => { + await request + .post(api('mailer')) + .set(credentials) + .send({ + from: 12345, + subject: {}, + body: 'Test email body', + dryrun: true, + query: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should throw an error if the "from" param is missing', async () => { + await request + .post(api('mailer')) + .set(credentials) + .send({ + subject: 'Test email subject', + body: 'Test email body', + dryrun: true, + query: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should throw an error if user does NOT have the send-mail permission', async () => { + await updatePermission('send-mail', []); + await request + .post(api('mailer')) + .set(credentials) + .send({ + from: 'test-mail@test.com', + subject: 'Test email subject', + body: 'Test email body', + dryrun: true, + query: '', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); +}); + +describe('Mailer Unsubscribe', () => { + describe('POST mailer unsubscribe', () => { + it('should unsubscribe to mailer if the request is correct', async () => { + await request + .post(api('mailer.unsubscribe')) + .set(credentials) + .send({ + _id: credentials['X-User-Id'], + createdAt: new Date().getTime().toString(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + it('should throw an error if the "_id" param is missing', async () => { + await request + .post(api('mailer.unsubscribe')) + .set(credentials) + .send({ + createdAt: new Date().getTime().toString(), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + it('should throw an error if the "createdAt" param is missing', async () => { + await request + .post(api('mailer.unsubscribe')) + .set(credentials) + .send({ + _id: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/12-priorites.ts b/apps/meteor/tests/end-to-end/api/livechat/12-priorites.ts new file mode 100644 index 0000000..7d5a57d --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/12-priorites.ts @@ -0,0 +1,936 @@ +import { faker } from '@faker-js/faker'; +import type { + ILivechatInquiryRecord, + ILivechatPriority, + IOmnichannelRoom, + IOmnichannelServiceLevelAgreements, +} from '@rocket.chat/core-typings'; +import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { fetchAllInquiries } from '../../../data/livechat/inquiries'; +import { createSLA, deleteSLA, bulkCreateSLA, deleteAllSLA } from '../../../data/livechat/priorities'; +import { + createAgent, + createVisitor, + createLivechatRoom, + bulkCreateLivechatRooms, + startANewLivechatRoomAndTakeIt, + makeAgentAvailable, + deleteAgent, +} from '../../../data/livechat/rooms'; +import { + addPermissions, + removePermissionFromAllRoles, + removePermissions, + restorePermissionToRoles, + updateEESetting, + updatePermission, + updateSetting, +} from '../../../data/permissions.helper'; +import { IS_EE } from '../../../e2e/config/constants'; +import { generateRandomSLAData } from '../../../e2e/utils/omnichannel/sla'; + +(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - Priorities & SLAs', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + }); + + after(async () => { + await addPermissions({ + 'manage-livechat-priorities': ['admin', 'livechat-manager'], + 'manage-livechat-sla': ['admin', 'livechat-manager'], + 'view-l-room': ['admin', 'livechat-manager', 'livechat-agent'], + }); + }); + + describe('livechat/sla', () => { + // GET + it('should return an "unauthorized error" when the user does not have the necessary permission for [GET] livechat/sla endpoint', async () => { + await removePermissions(['manage-livechat-sla', 'view-l-room']); + const response = await request.get(api('livechat/sla')).set(credentials).expect('Content-Type', 'application/json').expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of slas', async () => { + await addPermissions({ + 'manage-livechat-sla': ['admin', 'livechat-manager'], + 'view-l-room': ['livechat-agent', 'admin', 'livechat-manager'], + }); + const sla = await createSLA(); + expect(sla).to.not.be.undefined; + expect(sla).to.have.property('_id'); + const response = await request.get(api('livechat/sla')).set(credentials).expect('Content-Type', 'application/json').expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.sla).to.be.an('array').with.lengthOf.greaterThan(0); + const current = response.body.sla.find((p: IOmnichannelServiceLevelAgreements) => p && p?._id === sla._id); + expect(current).to.be.an('object'); + expect(current).to.have.property('name', sla.name); + expect(current).to.have.property('description', sla.description); + expect(current).to.have.property('dueTimeInMinutes', sla.dueTimeInMinutes); + await deleteSLA(sla._id); + }); + // POST + it('should return an "unauthorized error" when the user does not have the necessary permission for [POST] livechat/sla endpoint', async () => { + await updatePermission('manage-livechat-sla', []); + const response = await request + .post(api('livechat/sla')) + .set(credentials) + .send(generateRandomSLAData()) + .expect('Content-Type', 'application/json') + .expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should create a new sla', async () => { + await restorePermissionToRoles('manage-livechat-sla'); + + const sla = generateRandomSLAData(); + + const response = await request + .post(api('livechat/sla')) + .set(credentials) + .send(sla) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.sla).to.be.an('object'); + expect(response.body.sla).to.have.property('_id'); + expect(response.body.sla).to.have.property('name', sla.name); + expect(response.body.sla).to.have.property('description', sla.description); + expect(response.body.sla).to.have.property('dueTimeInMinutes', sla.dueTimeInMinutes); + + await deleteSLA(response.body.sla._id); + }); + it('should throw an error when trying to create a duplicate sla with same dueTimeInMinutes', async () => { + const firstSla = generateRandomSLAData(); + + const response = await request + .post(api('livechat/sla')) + .set(credentials) + .send(firstSla) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + + const secondSla = generateRandomSLAData(); + + secondSla.dueTimeInMinutes = firstSla.dueTimeInMinutes; + + const secondResponse = await request + .post(api('livechat/sla')) + .set(credentials) + .send(secondSla) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(secondResponse.body).to.have.property('success', false); + expect(secondResponse.body).to.have.property('error'); + expect(secondResponse.body?.error).to.contain('error-duplicated-sla'); + + await deleteSLA(response.body.sla._id); + }); + it('should throw an error when trying to create a duplicate sla with same name', async () => { + const firstSla = generateRandomSLAData(); + + const response = await request + .post(api('livechat/sla')) + .set(credentials) + .send(firstSla) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + + const secondSla = generateRandomSLAData(); + + secondSla.name = firstSla.name; + + const secondResponse = await request + .post(api('livechat/sla')) + .set(credentials) + .send(secondSla) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(secondResponse.body).to.have.property('success', false); + expect(secondResponse.body).to.have.property('error'); + expect(secondResponse.body?.error).to.contain('error-duplicated-sla'); + + await deleteSLA(response.body.sla._id); + }); + }); + + describe('livechat/sla/:slaId', () => { + // GET + it('should return an "unauthorized error" when the user does not have the necessary permission for [GET] livechat/sla/:slaId endpoint', async () => { + await Promise.all([removePermissionFromAllRoles('manage-livechat-sla'), removePermissionFromAllRoles('view-l-room')]); + + const response = await request.get(api('livechat/sla/123')).set(credentials).expect('Content-Type', 'application/json').expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should create, find and delete an sla', async () => { + await Promise.all([restorePermissionToRoles('manage-livechat-sla'), restorePermissionToRoles('view-l-room')]); + + const sla = await createSLA(); + const response = await request + .get(api(`livechat/sla/${sla._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.be.an('object'); + expect(response.body._id).to.be.equal(sla._id); + await deleteSLA(sla._id); + }); + // PUT + it('should return an "unauthorized error" when the user does not have the necessary permission for [PUT] livechat/sla/:slaId endpoint', async () => { + await removePermissionFromAllRoles('manage-livechat-sla'); + + const response = await request + .put(api('livechat/sla/123')) + .set(credentials) + .send(generateRandomSLAData()) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(response.body).to.have.property('success', false); + }); + it('should update an sla', async () => { + await restorePermissionToRoles('manage-livechat-sla'); + + const sla = await createSLA(); + const newSlaData = generateRandomSLAData(); + + const response = await request + .put(api(`livechat/sla/${sla._id}`)) + .set(credentials) + .send(newSlaData) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.sla).to.be.an('object'); + expect(response.body.sla).to.have.property('_id'); + expect(response.body.sla).to.have.property('name', newSlaData.name); + expect(response.body.sla).to.have.property('description', newSlaData.description); + expect(response.body.sla).to.have.property('dueTimeInMinutes', newSlaData.dueTimeInMinutes); + + await deleteSLA(response.body.sla._id); + }); + it('should throw an error when trying to update a sla with a duplicate name', async () => { + const firstSla = await createSLA(); + const secondSla = await createSLA(); + + secondSla.name = firstSla.name; + + const response = await request + .put(api(`livechat/sla/${secondSla._id}`)) + .set(credentials) + .send({ + name: secondSla.name, + description: secondSla.description, + dueTimeInMinutes: secondSla.dueTimeInMinutes, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('error-duplicated-sla'); + + await deleteSLA(firstSla._id); + await deleteSLA(secondSla._id); + }); + it('should throw an error when trying to update a sla with a duplicate dueTimeInMinutes', async () => { + const firstSla = await createSLA(); + const secondSla = await createSLA(); + + secondSla.dueTimeInMinutes = firstSla.dueTimeInMinutes; + + const response = await request + .put(api(`livechat/sla/${secondSla._id}`)) + .set(credentials) + .send({ + name: secondSla.name, + description: secondSla.description, + dueTimeInMinutes: secondSla.dueTimeInMinutes, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('error-duplicated-sla'); + + await deleteSLA(firstSla._id); + await deleteSLA(secondSla._id); + }); + + // DELETE + it('should return an "unauthorized error" when the user does not have the necessary permission for [DELETE] livechat/sla/:slaId endpoint', async () => { + await updatePermission('manage-livechat-sla', []); + const response = await request + .delete(api('livechat/sla/123')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should delete an sla', async () => { + await updatePermission('manage-livechat-sla', ['admin', 'livechat-manager']); + const sla = await createSLA(); + const response = await request + .delete(api(`livechat/sla/${sla._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + }); + + describe('livechat/inquiry.setSLA', () => { + before(async () => { + await createAgent(); + await makeAgentAvailable(); + }); + after(async () => { + await deleteAgent(); + }); + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissions(['manage-livechat-sla', 'view-l-room', 'manage-livechat-priorities']); + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId: '123', + sla: '123', + }) + .expect('Content-Type', 'application/json') + .expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should fail if roomId is not in request body', async () => { + await addPermissions({ + 'manage-livechat-sla': ['admin', 'livechat-manager'], + 'manage-livechat-priorities': ['admin', 'livechat-manager'], + 'view-l-room': ['livechat-agent', 'admin', 'livechat-manager'], + }); + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + sla: '123', + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if roomId is invalid', async () => { + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId: '123', + sla: '123', + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if sla is not in request body', async () => { + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId: '123', + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if sla is not valid', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId: room._id, + sla: '123', + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if inquiry is not queued', async () => { + const { + room: { _id: roomId }, + } = await startANewLivechatRoomAndTakeIt(); + + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId, + sla: '123', + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should prioritize an inquiry', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const sla = await createSLA(); + const response = await request + .put(api('livechat/inquiry.setSLA')) + .set(credentials) + .send({ + roomId: room._id, + sla: sla._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + }); + + describe('livechat/priorities', () => { + let priority: ILivechatPriority; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissions(['manage-livechat-priorities', 'view-l-room']); + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of priorities', async () => { + await addPermissions({ + 'manage-livechat-priorities': ['admin', 'livechat-manager'], + 'view-l-room': ['livechat-agent', 'admin', 'livechat-manager'], + }); + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.priorities).to.be.an('array'); + expect(response.body.priorities).to.have.lengthOf(5); + expect(response.body.priorities[0]).to.have.property('_id'); + expect(response.body.priorities[0]).to.have.property('i18n'); + expect(response.body.priorities[0]).to.have.property('dirty'); + priority = response.body.priorities[0]; + }); + it('should return an array of priorities matching text param', async () => { + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .query({ + text: priority.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.priorities).to.be.an('array'); + expect(response.body.priorities).to.have.length.greaterThan(0); + expect(response.body.priorities[0]).to.have.property('_id'); + }); + }); + + describe('livechat/priorities/:priorityId', () => { + let priority: ILivechatPriority; + const name = `${faker.lorem.word()}${faker.git.commitSha({ length: 6 })}`; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissions(['manage-livechat-priorities', 'view-l-room']); + const response = await request.get(api('livechat/priorities/123')).set(credentials).expect(403); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('error-unauthorized'); + }); + it('should return a priority by id', async () => { + await addPermissions({ + 'manage-livechat-priorities': ['admin', 'livechat-manager'], + 'view-l-room': ['livechat-agent', 'admin', 'livechat-manager'], + }); + const { + body: { priorities }, + } = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + priority = priorities[0]; + + const response = await request + .get(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.be.an('object'); + expect(response.body._id).to.be.equal(priority._id); + }); + it('should throw 404 when priority does not exist', async () => { + const response = await request.get(api('livechat/priorities/123')).set(credentials).expect(404); + expect(response.body).to.have.property('success', false); + }); + it('should update a priority when using PUT', async () => { + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should return dirty: true after a priority has been updated', async () => { + const response = await request + .get(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('dirty', true); + }); + it('should return an array of priorities matching text param', async () => { + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .query({ + text: name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.priorities).to.be.an('array'); + expect(response.body.priorities).to.have.length.greaterThan(0); + const pos = response.body.priorities.findIndex((p: ILivechatPriority) => p._id === priority._id); + expect(pos).to.be.greaterThan(-1); + expect(response.body.priorities[pos]).to.have.property('_id'); + expect(response.body.priorities[pos]).to.have.property('i18n', priority.i18n); + }); + it('should edit a priority with a PUT', async () => { + const newName = `${faker.lorem.word()}${faker.git.commitSha({ length: 6 })}`; + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name: newName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + const newPriorityResponse = await request + .get(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(newPriorityResponse.body).to.have.property('success', true); + expect(newPriorityResponse.body).to.have.property('dirty', true); + expect(newPriorityResponse.body).to.have.property('name', newName); + }); + it('should fail to edit a priority with a PUT if using too many parameters', async () => { + const newName = faker.lorem.word(); + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name: newName, + reset: true, + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('invalid-params'); + }); + it('should fail to edit a priority with a PUT if using an object as name', async () => { + const newName = faker.lorem.word(); + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name: { name: newName }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('invalid-params'); + }); + it('should not fail to edit a priority with a PUT if using a boolean as name (it becomes a string)', async () => { + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name: false, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + }); + it('should fail to update a non-existing priority', async () => { + const response = await request + .put(api('livechat/priorities/123')) + .set(credentials) + .send({ + name: faker.lorem.word(), + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should reset a single priority with a reset:true PUT parameter', async () => { + const response = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + reset: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + const newPriorityResponse = await request + .get(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(newPriorityResponse.body).to.have.property('success', true); + expect(newPriorityResponse.body).to.have.property('dirty', false); + expect(newPriorityResponse.body).to.not.have.property('name'); + }); + it('should throw a duplicate error incase there is already a priority with same name', async () => { + const { + body: { priorities }, + } = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + // change name of first priority to a random name + const newName = `${faker.lorem.word()}${faker.git.commitSha({ length: 6 })}`; + await request + .put(api(`livechat/priorities/${priorities[0]._id}`)) + .set(credentials) + .send({ name: newName }) + .expect('Content-Type', 'application/json') + .expect(200); + + // change name of second priority to the name of first priority and expect error + const response = await request + .put(api(`livechat/priorities/${priorities[1]._id}`)) + .set(credentials) + .send({ name: newName }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'error-duplicate-priority-name'); + }); + it('should throw a duplicate error incase there is already a priority with same name (case insensitive)', async () => { + const { + body: { priorities }, + } = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + // change name of first priority to a random name + const newNameLowercase = `${faker.lorem.word()}${faker.git.commitSha({ length: 6 })}`.toLowerCase(); + await request + .put(api(`livechat/priorities/${priorities[0]._id}`)) + .set(credentials) + .send({ name: newNameLowercase }) + .expect('Content-Type', 'application/json') + .expect(200); + + // change name of second priority to the name of first priority in different case and expect error + const response = await request + .put(api(`livechat/priorities/${priorities[1]._id}`)) + .set(credentials) + .send({ name: newNameLowercase.toUpperCase() }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'error-duplicate-priority-name'); + }); + }); + + describe('livechat/priorities.reset', () => { + let priority: ILivechatPriority; + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('manage-livechat-priorities', []); + const response = await request.post(api('livechat/priorities.reset')).set(credentials).expect(403); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('error-unauthorized'); + }); + it('should respond reset:true when a priority has been changed', async () => { + await updatePermission('manage-livechat-priorities', ['admin', 'livechat-manager']); + const { + body: { priorities }, + } = await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + priority = priorities[0]; + + priority.name = `${faker.lorem.word()}${faker.git.commitSha({ length: 6 })}`; + const responseChange = await request + .put(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .send({ + name: priority.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(responseChange.body).to.have.property('success', true); + + const response = await request + .get(api('livechat/priorities.reset')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('reset', true); + }); + it('should reset all priorities', async () => { + const resetRespose = await request + .post(api('livechat/priorities.reset')) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(200); + expect(resetRespose.body).to.have.property('success', true); + }); + it('should return reset: false after all priorities have been reset', async () => { + const response = await request + .get(api('livechat/priorities.reset')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('reset', false); + }); + it('should change the priority name and dirty status when reset', async () => { + const response = await request + .get(api(`livechat/priorities/${priority._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('dirty', false); + expect(response.body).to.not.have.property('name'); + }); + it('should change all priorities to their default', async () => { + const response = await request + .get(api('livechat/priorities')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.priorities).to.be.an('array'); + response.body.priorities.forEach((priority: ILivechatPriority) => { + expect(priority).to.not.have.property('name'); + expect(priority).to.have.property('dirty', false); + }); + }); + it('should fail to reset when lacking permissions', async () => { + await updatePermission('manage-livechat-priorities', []); + const response = await request.post(api('livechat/priorities.reset')).set(credentials).expect(403); + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body?.error).to.contain('error-unauthorized'); + }); + }); + + describe('Inquiry queue sorting mechanism', () => { + let omniRooms: IOmnichannelRoom[]; + let departmentWithAgent: Awaited>; + let priorities: ILivechatPriority[]; + let slas: Awaited>; + + const hasSlaProps = (inquiry: ILivechatInquiryRecord): inquiry is ILivechatInquiryRecord & { slaId: string } => + !!inquiry.estimatedWaitingTimeQueue && !!inquiry.slaId; + const hasPriorityProps = (inquiry: ILivechatInquiryRecord): inquiry is ILivechatInquiryRecord & { priorityId: string } => + inquiry.priorityId !== undefined; + + const getPriorityOrderById = (id: string) => { + const priority = priorities.find((priority) => priority._id === id); + return priority?.sortItem || 0; + }; + + const sortBySLAProps = (inquiry1: ILivechatInquiryRecord, inquiry2: ILivechatInquiryRecord): number => { + if (hasSlaProps(inquiry1) || hasSlaProps(inquiry2)) { + if (hasSlaProps(inquiry1) && hasSlaProps(inquiry2)) { + // if both inquiries have sla props, then sort by estimatedWaitingTimeQueue: 1 + const estimatedWaitingTimeQueue1 = inquiry1.estimatedWaitingTimeQueue; + const estimatedWaitingTimeQueue2 = inquiry2.estimatedWaitingTimeQueue; + + if (estimatedWaitingTimeQueue1 !== estimatedWaitingTimeQueue2) { + return estimatedWaitingTimeQueue1 - estimatedWaitingTimeQueue2; + } + } + + if (hasSlaProps(inquiry1)) { + return -1; + } + + if (hasSlaProps(inquiry2)) { + return 1; + } + } + + return 0; + }; + + const sortByPriorityProps = (inquiry1: ILivechatInquiryRecord, inquiry2: ILivechatInquiryRecord): number => { + if (hasPriorityProps(inquiry1) || hasPriorityProps(inquiry2)) { + if (hasPriorityProps(inquiry1) && hasPriorityProps(inquiry2)) { + // if both inquiries have priority props, then sort by priorityId: 1 + return getPriorityOrderById(inquiry1.priorityId) - getPriorityOrderById(inquiry2.priorityId); + } + + if (hasPriorityProps(inquiry1)) { + return -1; + } + + if (hasPriorityProps(inquiry2)) { + return 1; + } + } + + return 0; + }; + + // this should sort using logic - { ts: 1 } + const sortByTimestamp = (inquiry1: ILivechatInquiryRecord, inquiry2: ILivechatInquiryRecord) => { + return new Date(inquiry1.ts).getTime() - new Date(inquiry2.ts).getTime(); + }; + + // this should sort using logic - { priorityWeight: 1, ts: 1 } + const sortByPriority = (inquiry1: ILivechatInquiryRecord, inquiry2: ILivechatInquiryRecord) => { + const priorityPropsSort = sortByPriorityProps(inquiry1, inquiry2); + if (priorityPropsSort !== 0) { + return priorityPropsSort; + } + + return sortByTimestamp(inquiry1, inquiry2); + }; + + // this should sort using logic - { estimatedWaitingTimeQueue: 1, ts: 1 } + const sortBySLA = (inquiry1: ILivechatInquiryRecord, inquiry2: ILivechatInquiryRecord) => { + const slaPropsSort = sortBySLAProps(inquiry1, inquiry2); + if (slaPropsSort !== 0) { + return slaPropsSort; + } + + return sortByTimestamp(inquiry1, inquiry2); + }; + + const filterUnnecessaryProps = (inquiries: ILivechatInquiryRecord[]) => + inquiries.map((inquiry) => { + return { + _id: inquiry._id, + rid: inquiry.rid, + ...(hasPriorityProps(inquiry) && { + priority: { + priorityWeight: getPriorityOrderById(inquiry.priorityId), + id: inquiry.priorityWeight, + }, + }), + ...(hasSlaProps(inquiry) && { + sla: { + estimatedWaitingTimeQueue: inquiry.estimatedWaitingTimeQueue, + }, + }), + ts: inquiry.ts, + }; + }); + + const compareInquiries = (inquiries1: ILivechatInquiryRecord[], inquiries2: ILivechatInquiryRecord[]) => { + const filteredInquiries1 = filterUnnecessaryProps(inquiries1); + const filteredInquiries2 = filterUnnecessaryProps(inquiries2); + + expect(filteredInquiries1).to.deep.equal(filteredInquiries2); + }; + + it('it should create all the data required for further testing', async () => { + departmentWithAgent = await createDepartmentWithAnOnlineAgent(); + + const { + body: { priorities: prioritiesResponse }, + } = (await request.get(api('livechat/priorities')).set(credentials).expect('Content-Type', 'application/json').expect(200)) as { + body: { priorities: ILivechatPriority[] }; + }; + priorities = prioritiesResponse; + + await deleteAllSLA(); + slas = await bulkCreateSLA(5); + + type RoomParamsReturnType = { priority: string } | { sla: string } | { priority: string; sla: string } | undefined; + + // create 20 rooms, 5 with only priority, 5 with only SLA, 5 with both, 5 without priority or SLA + omniRooms = await bulkCreateLivechatRooms(20, departmentWithAgent.department._id, (index): RoomParamsReturnType => { + if (index < 5) { + return { + priority: priorities[index]._id, + }; + } + + if (index < 10) { + return { + sla: slas[index - 5]._id, + }; + } + + if (index < 15) { + // random number between 0 and 4 + const randomPriorityIndex = Math.floor(Math.random() * 5); + const randomSlaIndex = Math.floor(Math.random() * 5); + + return { + priority: priorities[randomPriorityIndex]._id, + sla: slas[randomSlaIndex]._id, + }; + } + }); + }); + + it('it should sort the queue based on priority', async () => { + await updateEESetting('Omnichannel_sorting_mechanism', OmnichannelSortingMechanismSettingType.Priority); + + const origInquiries = await fetchAllInquiries(departmentWithAgent.agent.credentials, departmentWithAgent.department._id); + expect(origInquiries.length).to.be.greaterThanOrEqual(omniRooms.length); + + const expectedSortedInquiries: ILivechatInquiryRecord[] = JSON.parse(JSON.stringify(origInquiries)); + + expectedSortedInquiries.sort(sortByPriority); + + compareInquiries(origInquiries, expectedSortedInquiries); + }); + + it('it should sort the queue based on slas', async () => { + await updateEESetting('Omnichannel_sorting_mechanism', OmnichannelSortingMechanismSettingType.SLAs); + + const origInquiries = await fetchAllInquiries(departmentWithAgent.agent.credentials, departmentWithAgent.department._id); + expect(origInquiries.length).to.be.greaterThanOrEqual(omniRooms.length); + + const expectedSortedInquiries = origInquiries.sort(sortBySLA); + + compareInquiries(origInquiries, expectedSortedInquiries); + }); + + it('it should sort the queue based on timestamp', async () => { + await updateEESetting('Omnichannel_sorting_mechanism', OmnichannelSortingMechanismSettingType.Timestamp); + + const origInquiries = await fetchAllInquiries(departmentWithAgent.agent.credentials, departmentWithAgent.department._id); + expect(origInquiries.length).to.be.greaterThanOrEqual(omniRooms.length); + + const expectedSortedInquiries = origInquiries.sort(sortByTimestamp); + + compareInquiries(origInquiries, expectedSortedInquiries); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/13-tags.ts b/apps/meteor/tests/end-to-end/api/livechat/13-tags.ts new file mode 100644 index 0000000..9f20cc2 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/13-tags.ts @@ -0,0 +1,242 @@ +import type { ILivechatDepartment, ILivechatTag } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { saveTags, removeAllTags } from '../../../data/livechat/tags'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import type { IUserWithCredentials } from '../../../data/user'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[EE] Livechat - Tags', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('livechat/tags', () => { + let tagsData: { + caseA: { department: ILivechatDepartment; tag: ILivechatTag; agent: IUserWithCredentials }; + caseB: { department: ILivechatDepartment; tag: ILivechatTag; agent: IUserWithCredentials }; + casePublic: { tag: ILivechatTag }; + }; + let monitor: IUserWithCredentials; + + before(async () => { + // create monitor + const monitorUser = await createUser(); + const monitorCredentials = await login(monitorUser.username, password); + await createMonitor(monitorUser.username); + monitor = { + user: monitorUser, + credentials: monitorCredentials, + }; + + // remove all existing tags + await removeAllTags(); + + // should add 3 tags + const { department: dA, agent: agentA } = await createDepartmentWithAnOnlineAgent(); + const tagA = await saveTags([dA._id]); + const { department: dB, agent: agentB } = await createDepartmentWithAnOnlineAgent(); + const tagB = await saveTags([dB._id]); + const publicTag = await saveTags(); + tagsData = { + caseA: { department: dA, tag: tagA, agent: agentA }, + caseB: { department: dB, tag: tagB, agent: agentB }, + casePublic: { tag: publicTag }, + }; + }); + + after(async () => { + await deleteUser(monitor.user); + await removeAllTags(); + }); + + it('should throw unauthorized error when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('manage-livechat-tags'); + await removePermissionFromAllRoles('view-l-room'); + const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(403); + expect(response.body).to.have.property('success', false); + + await restorePermissionToRoles('manage-livechat-tags'); + await restorePermissionToRoles('view-l-room'); + }); + it('should return an array of tags', async () => { + const { tag } = tagsData.caseA; + const response = await request + .get(api('livechat/tags')) + .set(credentials) + .query({ text: tag.name, viewAll: 'true' }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(1); + expect(response.body.tags[0]).to.have.property('_id', tag._id); + }); + it('[Manager role] show return all tags when "viewAll" param is true and user has access to all tags', async () => { + const response = await request + .get(api('livechat/tags')) + .set(credentials) + .query({ viewAll: 'true' }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(3); + + const expectedTags = [tagsData.caseA.tag, tagsData.caseB.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Monitor role] show return only public tags when "viewAll" param is true and user does not have access to all tags', async () => { + const response = await request + .get(api('livechat/tags')) + .set(monitor.credentials) + .query({ viewAll: 'true' }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(1); + + const expectedTags = [tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Manager Role] should return department tags and public tags when "departmentId" param is provided', async () => { + const { department } = tagsData.caseA; + + const response = await request + .get(api('livechat/tags')) + .set(credentials) + .query({ department: department._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(2); + + const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Manager role] should return public tags when "departmentId" param is not provided', async () => { + const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(1); + expect(response.body.tags[0]).to.have.property('_id', tagsData.casePublic.tag._id); + }); + it('[Monitor role] should return only public tags when user does not have access to department', async () => { + const { department } = tagsData.caseA; + + const response = await request + .get(api('livechat/tags')) + .set(monitor.credentials) + .query({ department: department._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(1); + + const expectedTags = [tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Monitor Role] should return department tags and public tags when "departmentId" param is provided', async () => { + const { department } = tagsData.caseA; + + await createUnit(monitor.user._id, monitor.user.username || '', [department._id]); + + const response = await request + .get(api('livechat/tags')) + .set(monitor.credentials) + .query({ department: department._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(2); + + const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Agent Role] should return department tags and public tags when "departmentId" param is provided', async () => { + const { department, agent } = tagsData.caseA; + + const response = await request + .get(api('livechat/tags')) + .set(agent.credentials) + .query({ department: department._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(2); + + const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Monitor role] should return public tags and department tags which they are part of, when "departmentId" param is not provided', async () => { + const response = await request + .get(api('livechat/tags')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(2); + const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + it('[Agent role] should return public tags when "departmentId" param is not provided', async () => { + const response = await request + .get(api('livechat/tags')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.tags).to.be.an('array').with.lengthOf(2); + const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort(); + const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort(); + expect(actualTags).to.deep.equal(expectedTags); + }); + }); + + describe('livechat/tags/:tagId', () => { + it('should throw unauthorized error when the user does not have the necessary permission', async () => { + await updatePermission('manage-livechat-tags', []); + await updatePermission('view-l-room', []); + const response = await request.get(api('livechat/tags/123')).set(credentials).expect('Content-Type', 'application/json').expect(403); + expect(response.body).to.have.property('success', false); + }); + it('should return null when the tag does not exist', async () => { + await updatePermission('manage-livechat-tags', ['admin']); + await updatePermission('view-l-room', ['livechat-agent']); + await request.get(api('livechat/tags/123')).set(credentials).expect('Content-Type', 'application/json').expect(404); + }); + it('should return a tag', async () => { + const tag = await saveTags(); + const response = await request + .get(api(`livechat/tags/${tag._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('_id', tag._id); + expect(response.body).to.have.property('name', tag.name); + expect(response.body).to.have.property('numDepartments', 0); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/14-units.ts b/apps/meteor/tests/end-to-end/api/livechat/14-units.ts new file mode 100644 index 0000000..1646efd --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/14-units.ts @@ -0,0 +1,739 @@ +import type { ILivechatDepartment, IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { deleteDepartment, getDepartmentById } from '../../../data/livechat/department'; +import { createDepartment, updateDepartment } from '../../../data/livechat/rooms'; +import { createMonitor, createUnit, deleteUnit, getUnit } from '../../../data/livechat/units'; +import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - Units', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updatePermission('manage-livechat-departments', ['livechat-manager', 'livechat-monitor', 'admin']); + await updateSetting('Omnichannel_enable_department_removal', true); + }); + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('[GET] livechat/units', () => { + it('should return empty if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request + .get(api('livechat/units')) + .set(credentials) + .send({ + unitData: { + name: 'test', + enabled: true, + }, + unitMonitors: [], + unitDepartments: [], + }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('units').that.is.an('array').with.lengthOf(0); + }); + }); + it('should return a list of units', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request.get(api('livechat/units')).set(credentials).expect(200); + expect(body.units).to.be.an('array').with.lengthOf.greaterThan(0); + const unitFound = body.units.find((u: IOmnichannelBusinessUnit) => u._id === unit._id); + expect(unitFound).to.have.property('_id', unit._id); + expect(unitFound).to.have.property('name', unit.name); + expect(unitFound).to.have.property('numMonitors', 1); + expect(unitFound).to.have.property('numDepartments', 1); + expect(unitFound).to.have.property('type', 'u'); + + // cleanup + await deleteUser(user); + }); + + it('should return a list of units matching the provided filter', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request.get(api('livechat/units')).query({ text: unit.name }).set(credentials).expect(200); + expect(body.units).to.be.an('array').with.lengthOf(1); + const unitFound = body.units.find((u: IOmnichannelBusinessUnit) => u._id === unit._id); + expect(unitFound).to.have.property('_id', unit._id); + expect(unitFound).to.have.property('name', unit.name); + expect(unitFound).to.have.property('numMonitors', 1); + expect(unitFound).to.have.property('numDepartments', 1); + expect(unitFound).to.have.property('type', 'u'); + + // cleanup + await deleteUser(user); + }); + + it('should properly paginate the result set', async () => { + const { body } = await request.get(api('livechat/units')).query({ count: 1 }).set(credentials).expect(200); + expect(body).to.have.property('units').and.to.be.an('array').with.lengthOf(1); + expect(body).to.have.property('count').and.to.be.equal(1); + const unit = body.units[0]; + + const { body: body2 } = await request.get(api('livechat/units')).query({ count: 1, offset: 1 }).set(credentials).expect(200); + expect(body2).to.have.property('units').and.to.be.an('array').with.lengthOf(1); + const unit2 = body2.units[0]; + + expect(unit._id).to.not.be.equal(unit2._id); + }); + + it('should sort the result set based on provided fields', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id], 'A test 1234'); + const unit2 = await createUnit(user._id, user.username, [department._id], 'a test 1234'); + + const { body } = await request + .get(api('livechat/units')) + .query({ sort: JSON.stringify({ name: 1 }), text: 'test', count: 2 }) + .set(credentials) + .expect(200); + expect(body).to.have.property('units').and.to.be.an('array').with.lengthOf(2); + expect(body.units[0]._id).to.be.equal(unit._id); + expect(body.units[1]._id).to.be.equal(unit2._id); + + await deleteUser(user); + }); + }); + + describe('[POST] livechat/units', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request + .post(api('livechat/units')) + .set(credentials) + .send({ + unitData: { + name: 'test', + enabled: true, + }, + unitMonitors: [], + unitDepartments: [], + }) + .expect(403); + }); + it('should return a created unit', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + + const { body } = await request + .post(api('livechat/units')) + .set(credentials) + .send({ + unitData: { name: 'test', visibility: 'public', enabled: true, description: 'test' }, + unitMonitors: [{ monitorId: user._id, username: user.username }], + unitDepartments: [{ departmentId: department._id }], + }) + .expect(200); + + expect(body).to.have.property('_id'); + expect(body).to.have.property('name', 'test'); + expect(body).to.have.property('visibility', 'public'); + expect(body).to.have.property('type', 'u'); + expect(body).to.have.property('numMonitors', 1); + expect(body).to.have.property('numDepartments', 1); + + // cleanup + await deleteUser(user); + }); + + it('should return a unit with no monitors if a user who is not a monitor is passed', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + const department = await createDepartment(); + + const { body } = await request + .post(api('livechat/units')) + .set(credentials) + .send({ + unitData: { name: 'test', visibility: 'public', enabled: true, description: 'test' }, + unitMonitors: [{ monitorId: user._id, username: user.username }], + unitDepartments: [{ departmentId: department._id }], + }) + .expect(200); + + expect(body).to.have.property('numMonitors', 0); + expect(body).to.have.property('name', 'test'); + + // cleanup + await deleteUser(user); + }); + }); + + describe('[GET] livechat/units/:id', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request.get(api('livechat/units/123')).set(credentials).send().expect(403); + }); + it('should return a unit', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .get(api(`livechat/units/${unit._id}`)) + .set(credentials) + .expect(200); + expect(body).to.have.property('_id', unit._id); + expect(body).to.have.property('name', unit.name); + expect(body).to.have.property('numMonitors', 1); + expect(body).to.have.property('numDepartments', 1); + expect(body).to.have.property('type', 'u'); + + // cleanup + await deleteUser(user); + }); + }); + + describe('[POST] livechat/units/:id', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request.post(api('livechat/units/123')).set(credentials).expect(403); + }); + it('should fail if unit does not exist', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + + const { body } = await request + .post(api('livechat/units/123')) + .set(credentials) + .send({ + unitData: { name: 'test', visibility: 'public', enabled: true, description: 'test' }, + unitMonitors: [{ monitorId: user._id, username: user.username }], + unitDepartments: [{ departmentId: department._id }], + }) + .expect(400); + + expect(body).to.have.property('success', false); + // cleanup + await deleteUser(user); + }); + it('should return a updated unit', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .post(api(`livechat/units/${unit._id}`)) + .set(credentials) + .send({ + unitData: { name: 'test', visibility: 'private', enabled: true, description: 'test' }, + unitMonitors: [{ monitorId: user._id, username: user.username }], + unitDepartments: [{ departmentId: department._id }], + }) + .expect(200); + + expect(body).to.have.property('_id'); + expect(body).to.have.property('name', 'test'); + expect(body).to.have.property('visibility', 'private'); + expect(body).to.have.property('type', 'u'); + expect(body).to.have.property('numMonitors', 1); + expect(body).to.have.property('numDepartments', 1); + + // cleanup + await deleteUser(user); + }); + it('should move the department to the latest unit that attempted to assign it', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit1 = await createUnit(user._id, user.username, [department._id]); + const unit2 = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .get(api(`livechat/units/${unit1._id}/departments`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('departments'); + expect(body.departments).to.have.lengthOf(0); + expect(unit2.numDepartments).to.be.equal(1); + }); + it('should remove the department from the unit if it is not passed in the request', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit1 = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .post(api(`livechat/units/${unit1._id}`)) + .set(credentials) + .send({ + unitData: { name: unit1.name, visibility: unit1.visibility }, + unitMonitors: [{ monitorId: user._id, username: user.username }], + unitDepartments: [], + }) + .expect(200); + + expect(body).to.have.property('_id', unit1._id); + expect(body).to.have.property('numDepartments', 0); + }); + it('should remove the monitor from the unit if it is not passed in the request', async () => { + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit1 = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .post(api(`livechat/units/${unit1._id}`)) + .set(credentials) + .send({ + unitData: { name: unit1.name, visibility: unit1.visibility }, + unitMonitors: [], + unitDepartments: [{ departmentId: department._id }], + }) + .expect(200); + + expect(body).to.have.property('_id', unit1._id); + expect(body).to.have.property('numMonitors', 0); + }); + }); + + describe('[DELETE] livechat/units/:id', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request.delete(api('livechat/units/123')).set(credentials).expect(403); + }); + it('should return a deleted unit', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .delete(api(`livechat/units/${unit._id}`)) + .set(credentials) + .expect(200); + + expect(body).to.have.be.a('number').equal(1); + + // cleanup + await deleteUser(user); + }); + }); + + describe('livechat/units/:unitId/departments', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request.get(api('livechat/units/123/departments')).set(credentials).expect(403); + }); + it('should return departments associated with a unit', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .get(api(`livechat/units/${unit._id}/departments`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('departments'); + expect(body.departments).to.have.lengthOf(1); + expect(body.departments[0]).to.have.property('_id', department._id); + expect(body.departments[0]).to.have.property('name', department.name); + + // cleanup + await deleteUser(user); + }); + }); + + describe('livechat/units/:unitId/departments/available', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-units', []); + return request.get(api('livechat/units/123/departments/available')).set(credentials).expect(403); + }); + it('should return departments not associated with a unit', async () => { + await updatePermission('manage-livechat-units', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .get(api(`livechat/units/${unit._id}/departments/available`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('departments'); + expect(body.departments).to.have.lengthOf.greaterThan(0); + + const myUnit = body.departments.find((d: ILivechatDepartment) => d.parentId === unit._id); + expect(myUnit).to.not.be.undefined.and.not.be.null; + + // cleanup + await deleteUser(user); + }); + }); + + describe('livechat/units/:unitId/monitors', () => { + it('should fail if manage-livechat-units permission is missing', async () => { + await updatePermission('manage-livechat-monitors', []); + return request.get(api('livechat/units/123/monitors')).set(credentials).expect(403); + }); + it('should return monitors associated with a unit', async () => { + await updatePermission('manage-livechat-monitors', ['admin']); + const user = await createUser(); + await createMonitor(user.username); + const department = await createDepartment(); + const unit = await createUnit(user._id, user.username, [department._id]); + + const { body } = await request + .get(api(`livechat/units/${unit._id}/monitors`)) + .set(credentials) + .expect(200); + + expect(body).to.have.property('monitors'); + expect(body.monitors).to.have.lengthOf(1); + expect(body.monitors[0]).to.have.property('monitorId', user._id); + expect(body.monitors[0]).to.have.property('username', user.username); + + // cleanup + await deleteUser(user); + }); + }); + + describe('[POST] livechat/department', () => { + let monitor1: Awaited>; + let monitor1Credentials: Awaited>; + let monitor2: Awaited>; + let monitor2Credentials: Awaited>; + let unit: IOmnichannelBusinessUnit; + + before(async () => { + monitor1 = await createUser(); + monitor2 = await createUser(); + await createMonitor(monitor1.username); + monitor1Credentials = await login(monitor1.username, password); + await createMonitor(monitor2.username); + monitor2Credentials = await login(monitor2.username, password); + unit = await createUnit(monitor1._id, monitor1.username, []); + }); + + after(async () => Promise.all([deleteUser(monitor1), deleteUser(monitor2), deleteUnit(unit)])); + + it('should fail creating department when providing an invalid property in the department unit object', () => { + return request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { name: 'Fail-Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { invalidProperty: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail creating a department into an existing unit that a monitor does not supervise', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }, monitor2Credentials); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 0); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + + await deleteDepartment(department._id); + }); + + it('should succesfully create a department into an existing unit as an admin', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + + await deleteDepartment(department._id); + }); + + it('should succesfully create a department into an existing unit that a monitor supervises', async () => { + const department = await createDepartment(undefined, undefined, undefined, undefined, { _id: unit._id }, monitor1Credentials); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + + await deleteDepartment(department._id); + }); + + it('unit should end up with 0 departments after removing all of them', async () => { + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 0); + }); + }); + + describe('[PUT] livechat/department', () => { + let monitor1: Awaited>; + let monitor1Credentials: Awaited>; + let monitor2: Awaited>; + let monitor2Credentials: Awaited>; + let unit: IOmnichannelBusinessUnit; + let department: ILivechatDepartment; + let baseDepartment: ILivechatDepartment; + + before(async () => { + monitor1 = await createUser(); + monitor2 = await createUser(); + await createMonitor(monitor1.username); + monitor1Credentials = await login(monitor1.username, password); + await createMonitor(monitor2.username); + monitor2Credentials = await login(monitor2.username, password); + department = await createDepartment(); + baseDepartment = await createDepartment(); + unit = await createUnit(monitor1._id, monitor1.username, [baseDepartment._id]); + }); + + after(async () => + Promise.all([ + deleteUser(monitor1), + deleteUser(monitor2), + deleteUnit(unit), + deleteDepartment(department._id), + deleteDepartment(baseDepartment._id), + ]), + ); + + it("should fail updating a department's unit when providing an invalid property in the department unit object", () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { invalidProperty: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Match error: Unknown key in field departmentUnit.invalidProperty'); + }); + }); + + it("should fail updating a department's unit when providing an invalid _id type in the department unit object", () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${department._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: { _id: true }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Match error: Expected string, got boolean in field departmentUnit._id'); + }); + }); + + it('should fail removing the last department from a unit', () => { + const updatedName = 'updated-department-name'; + return request + .put(api(`livechat/department/${baseDepartment._id}`)) + .set(credentials) + .send({ + department: { name: updatedName, enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' }, + departmentUnit: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-unit-cant-be-empty'); + }); + }); + + it('should succesfully add an existing department to a unit as an admin', async () => { + const updatedName = 'updated-department-name'; + + const updatedDepartment = await updateDepartment({ + userCredentials: credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove an existing department from a unit as an admin', async () => { + const updatedName = 'updated-department-name'; + + const updatedDepartment = await updateDepartment({ + userCredentials: credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + }); + + it('should fail adding a department into an existing unit that a monitor does not supervise', async () => { + const updatedName = 'updated-department-name2'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor2Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + }); + + it('should succesfully add a department into an existing unit that a monitor supervises', async () => { + const updatedName = 'updated-department-name3'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor1Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: { _id: unit._id }, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should fail removing a department from a unit that a monitor does not supervise', async () => { + const updatedName = 'updated-department-name4'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor2Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 2); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.have.property('parentId', unit._id); + expect(fullDepartment).to.have.property('ancestors').that.is.an('array').with.lengthOf(1); + expect(fullDepartment.ancestors?.[0]).to.equal(unit._id); + }); + + it('should succesfully remove a department from a unit that a monitor supervises', async () => { + const updatedName = 'updated-department-name5'; + + const updatedDepartment = await updateDepartment({ + userCredentials: monitor1Credentials, + departmentId: department._id, + name: updatedName, + departmentUnit: {}, + }); + expect(updatedDepartment).to.have.property('name', updatedName); + expect(updatedDepartment).to.have.property('type', 'd'); + expect(updatedDepartment).to.have.property('_id', department._id); + + const updatedUnit = await getUnit(unit._id); + expect(updatedUnit).to.have.property('name', unit.name); + expect(updatedUnit).to.have.property('numMonitors', 1); + expect(updatedUnit).to.have.property('numDepartments', 1); + + const fullDepartment = await getDepartmentById(department._id); + expect(fullDepartment).to.have.property('name', updatedName); + expect(fullDepartment).to.not.have.property('parentId'); + expect(fullDepartment).to.not.have.property('ancestors'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/15-canned-responses.ts b/apps/meteor/tests/end-to-end/api/livechat/15-canned-responses.ts new file mode 100644 index 0000000..fc27b64 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/15-canned-responses.ts @@ -0,0 +1,296 @@ +import { faker } from '@faker-js/faker'; +import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCannedResponse } from '../../../data/livechat/canned-responses'; +import { createAgent, createDepartment } from '../../../data/livechat/rooms'; +import { removeTag, saveTags } from '../../../data/livechat/tags'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { password, adminUsername } from '../../../data/user'; +import { createUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - Canned responses', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + }); + + describe('canned-responses.get', () => { + it('should throw unauthorized when view-canned-responses permission is not set', async () => { + await updatePermission('view-canned-responses', []); + return request.get(api('canned-responses.get')).set(credentials).expect(403); + }); + it('should return an array of canned responses when available', async () => { + await updatePermission('view-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']); + await createCannedResponse(); + const { body } = await request.get(api('canned-responses.get')).set(credentials).expect(200); + expect(body).to.have.property('success', true); + expect(body.responses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.responses[0]).to.have.property('_id'); + expect(body.responses[0]).to.have.property('shortcut'); + expect(body.responses[0]).to.have.property('scope'); + expect(body.responses[0]).to.have.property('tags'); + expect(body.responses[0]).to.have.property('text'); + expect(body.responses[0]).to.have.property('userId'); + }); + it('should return canned responses for monitors even if department is disabled', async () => { + const user = await createUser(); + const monitor = await createMonitor(user.username); + const creds = await login(user.username, password); + const department = await createDepartment(undefined, undefined, false); + await createUnit(monitor._id, user.username, [department._id]); + + // create canned response + const shortcut = faker.string.uuid(); + await request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut, scope: 'department', tags: ['tag'], text: 'text', departmentId: department._id }) + .expect(200); + + const { body } = await request.get(api('canned-responses.get')).set(creds).expect(200); + + expect(body).to.have.property('success', true); + expect(body.responses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.responses.find((response: IOmnichannelCannedResponse) => response.shortcut === shortcut)).to.be.an('object'); + }); + it('should return canned respones for scope user when agent is not on any department', async () => { + const user = await createUser(); + await createAgent(user.username); + const creds = await login(user.username, password); + + // create canned response + const shortcut = faker.string.uuid(); + await request + .post(api('canned-responses')) + .set(creds) + .send({ shortcut, scope: 'user', tags: ['tag'], text: 'text' }) + .expect(200); + + const { body } = await request.get(api('canned-responses.get')).set(creds).expect(200); + + expect(body).to.have.property('success', true); + expect(body.responses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.responses.find((response: IOmnichannelCannedResponse) => response.shortcut === shortcut)).to.be.an('object'); + }); + it('should return canned responses on the global scope', async () => { + const user = await createUser(); + const creds = await login(user.username, password); + await createAgent(user.username); + + // create canned response + const shortcut = faker.string.uuid(); + await request.post(api('canned-responses')).set(credentials).send({ shortcut, scope: 'global', text: 'text' }).expect(200); + + const { body } = await request.get(api('canned-responses.get')).set(creds).expect(200); + + expect(body).to.have.property('success', true); + expect(body.responses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.responses.find((response: IOmnichannelCannedResponse) => response.shortcut === shortcut)).to.be.an('object'); + }); + it('should return canned responses from the departments user is in', async () => { + const user = await createUser(); + const creds = await login(user.username, password); + await createAgent(user.username); + const department = await createDepartment([{ agentId: user._id }], undefined, true); + + // create canned response + const shortcut = faker.string.uuid(); + await request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut, scope: 'department', text: 'text', departmentId: department._id }) + .expect(200); + + const { body } = await request.get(api('canned-responses.get')).set(creds).expect(200); + + expect(body).to.have.property('success', true); + expect(body.responses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.responses.find((response: IOmnichannelCannedResponse) => response.shortcut === shortcut)).to.be.an('object'); + }); + }); + + describe('[GET] canned-responses', () => { + it('should fail if user dont have view-canned-responses permission', async () => { + await updatePermission('view-canned-responses', []); + return request.get(api('canned-responses')).set(credentials).expect(403); + }); + it('should return an array of canned responses when available', async () => { + await updatePermission('view-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']); + await createCannedResponse(); + const { body } = await request.get(api('canned-responses')).query({ sort: '{ "_createdAt": -1 }' }).set(credentials).expect(200); + expect(body).to.have.property('success', true); + expect(body.cannedResponses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.cannedResponses[0]).to.have.property('_id'); + expect(body.cannedResponses[0]).to.have.property('shortcut'); + expect(body.cannedResponses[0]).to.have.property('scope'); + expect(body.cannedResponses[0]).to.have.property('tags'); + expect(body.cannedResponses[0]).to.have.property('text'); + expect(body.cannedResponses[0]).to.have.property('userId'); + }); + it('should return a canned response matching the params provided (shortcut)', async () => { + const response = await createCannedResponse(); + const { body } = await request.get(api('canned-responses')).set(credentials).query({ shortcut: response.shortcut }).expect(200); + expect(body).to.have.property('success', true); + expect(body.cannedResponses).to.be.an('array').with.lengthOf(1); + expect(body.cannedResponses[0]).to.have.property('_id'); + expect(body.cannedResponses[0]).to.have.property('shortcut', response.shortcut); + }); + it('should return a canned response matching the params provided (scope)', async () => { + const response = await createCannedResponse(); + const { body } = await request.get(api('canned-responses')).set(credentials).query({ scope: response.scope }).expect(200); + expect(body).to.have.property('success', true); + expect(body.cannedResponses).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.cannedResponses[0]).to.have.property('_id'); + expect(body.cannedResponses[0]).to.have.property('scope', response.scope); + }); + it('should return a canned response matching the params provided (tags)', async () => { + const response = await createCannedResponse(); + const { body } = await request.get(api('canned-responses')).set(credentials).query({ 'tags[]': response.tags?.[0] }).expect(200); + expect(body).to.have.property('success', true); + expect(body.cannedResponses).to.be.an('array').with.lengthOf(1); + expect(body.cannedResponses[0]).to.have.property('_id'); + expect(body.cannedResponses[0]).to.have.property('tags').that.is.an('array').which.includes(response.tags?.[0]); + }); + it('should return a canned response matching the params provided (text)', async () => { + const response = await createCannedResponse(); + const { body } = await request.get(api('canned-responses')).set(credentials).query({ text: response.text }).expect(200); + expect(body).to.have.property('success', true); + expect(body.cannedResponses).to.be.an('array').with.lengthOf(1); + expect(body.cannedResponses[0]).to.have.property('_id'); + expect(body.cannedResponses[0]).to.have.property('text', response.text); + }); + }); + + describe('[POST] canned-responses', () => { + const dupshortcut = `shortcut-${faker.string.nanoid(6)}`; + it('should fail if user dont have save-canned-responses permission', async () => { + await updatePermission('save-canned-responses', []); + return request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: 'shortcut', scope: 'user', tags: ['tag'], text: 'text' }) + .expect(403); + }); + it('should fail if shortcut is not on the request', async () => { + await updatePermission('save-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']); + return request.post(api('canned-responses')).set(credentials).expect(400); + }); + it('should fail if text is not on the request', async () => { + return request.post(api('canned-responses')).set(credentials).send({ shortcut: 'shortcut' }).expect(400); + }); + it('should fail if scope is not on the request', async () => { + return request.post(api('canned-responses')).set(credentials).send({ shortcut: 'shortcut', text: 'text' }).expect(400); + }); + it('should fail if tags is not an array of strings', async () => { + return request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: 'shortcut', text: 'text', scope: 'department', tags: 'tag' }) + .expect(400); + }); + it('should create a new canned response', async () => { + const { body } = await request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: dupshortcut, scope: 'user', tags: ['tag'], text: 'text' }) + .expect(200); + expect(body).to.have.property('success', true); + }); + it('should fail if shortcut is already in use', async () => { + return request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: dupshortcut, scope: 'user', tags: ['tag'], text: 'text' }) + .expect(400); + }); + it('should save a canned response related to an EE tag', async () => { + const tag = await saveTags(); + + const { body } = await request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: `eetag-${faker.string.nanoid(6)}`, scope: 'user', tags: [tag.name], text: 'text' }) + .expect(200); + + expect(body).to.have.property('success', true); + + const { body: getResult } = await request.get(api('canned-responses')).set(credentials).query({ 'tags[]': tag.name }).expect(200); + + expect(getResult).to.have.property('success', true); + expect(getResult.cannedResponses).to.be.an('array').with.lengthOf(1); + expect(getResult.cannedResponses[0]).to.have.property('_id'); + expect(getResult.cannedResponses[0]).to.have.property('tags').that.is.an('array').which.includes(tag.name); + }); + it('should not include removed tags in the response', async () => { + const tag = await saveTags(); + + const { body } = await request + .post(api('canned-responses')) + .set(credentials) + .send({ shortcut: `remove-${faker.string.nanoid(6)}`, scope: 'user', tags: [tag.name], text: 'text' }) + .expect(200); + + expect(body).to.have.property('success', true); + + await removeTag(tag._id); + + const { body: getResult } = await request.get(api('canned-responses')).set(credentials).query({ 'tags[]': tag.name }).expect(200); + + expect(getResult).to.have.property('success', true); + expect(getResult.cannedResponses).to.be.an('array').with.lengthOf(0); + }); + + it('should persist userId when updating a canned response', async () => { + const response = await createCannedResponse(); + + const { + body: { cannedResponses }, + } = await request.get(api('canned-responses')).set(credentials).query({}).expect(200); + + const targetCannedResponse = cannedResponses.find( + (cannedResponse: IOmnichannelCannedResponse) => cannedResponse.shortcut === response.shortcut, + ); + + const { body } = await request + .post(api('canned-responses')) + .set(credentials) + .send({ _id: targetCannedResponse._id, shortcut: `${targetCannedResponse.shortcut}-edited`, text: 'edited text', scope: 'user' }) + .expect(200); + expect(body).to.have.property('success', true); + + const { body: getResult } = await request + .get(api('canned-responses')) + .set(credentials) + .query({ shortcut: `${targetCannedResponse.shortcut}-edited` }) + .expect(200); + expect(getResult).to.have.property('success', true); + expect(getResult.cannedResponses[0]).to.have.property('userId', adminUsername); + }); + }); + + describe('[DELETE] canned-responses', () => { + it('should fail if user dont have remove-canned-responses permission', async () => { + await updatePermission('remove-canned-responses', []); + return request.delete(api('canned-responses/sfdads')).set(credentials).expect(403); + }); + it('should fail if _id is not on the request', async () => { + await updatePermission('remove-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']); + return request.delete(api('canned-responses')).set(credentials).expect(400); + }); + it('should delete a canned response', async () => { + const response = await createCannedResponse(); + const { body: cr } = await request.get(api('canned-responses')).set(credentials).query({ shortcut: response.shortcut }).expect(200); + const { body } = await request + .delete(api(`canned-responses/${cr.cannedResponses[0]._id}`)) + .set(credentials) + .expect(200); + expect(body).to.have.property('success', true); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/17-dashboards-ee.ts b/apps/meteor/tests/end-to-end/api/livechat/17-dashboards-ee.ts new file mode 100644 index 0000000..ac19674 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/17-dashboards-ee.ts @@ -0,0 +1,821 @@ +import type { ILivechatDepartment, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createDepartment, deleteDepartment } from '../../../data/livechat/department'; +import { + createVisitor, + createLivechatRoom, + takeInquiry, + closeOmnichannelRoom, + makeAgentUnavailable, + makeAgentAvailable, + createAgent, + sendAgentMessage, + sendMessage, + fetchInquiry, + deleteVisitor, +} from '../../../data/livechat/rooms'; +import { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { pagination } from '../../../data/utils'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - dashboards', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await updateSetting('Omnichannel_enable_department_removal', true); + await createAgent(); + await makeAgentAvailable(); + }); + + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + describe('livechat/analytics/agents/average-service-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of agents', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const inq = await fetchInquiry(room._id); + await takeInquiry(inq._id); + await closeOmnichannelRoom(room._id); + + const { body } = await request + .get(api('livechat/analytics/agents/average-service-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.agents).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.agents[0]).to.have.a.property('_id'); + expect(body.agents[0]).to.have.a.property('username'); + expect(body.agents[0]).to.have.a.property('averageServiceTimeInSeconds').that.is.a('number'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/agents/average-service-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/agents/total-service-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of agents', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/agents/total-service-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.agents).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.agents[0]).to.have.a.property('_id'); + expect(body.agents[0]).to.have.a.property('username'); + expect(body.agents[0]).to.have.a.property('serviceTimeDuration').that.is.a('number'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/agents/total-service-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/agents/available-for-service-history', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of agents', async () => { + // Toggling to populate agent activity collection + await createAgent(); + await makeAgentUnavailable(); + await makeAgentAvailable(); + + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/agents/available-for-service-history')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.agents).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.agents[0]).to.have.a.property('username'); + expect(body.agents[0]).to.have.a.property('availableTimeInSeconds').that.is.a('number'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/agents/available-for-service-history', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/amount-of-chats', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/departments/amount-of-chats')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('rooms').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/amount-of-chats', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/average-service-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + describe('with data', () => { + let department: ILivechatDepartment; + let visitor: ILivechatVisitor; + + before(async () => { + department = await createDepartment({}, [{ agentId: credentials['X-User-Id'] }]); + + visitor = await createVisitor(department._id); + const room = await createLivechatRoom(visitor.token); + const inq = await fetchInquiry(room._id); + await takeInquiry(inq._id); + await sendAgentMessage(room._id); + await closeOmnichannelRoom(room._id); + }); + + after(async () => { + await deleteVisitor(visitor.token); + await deleteDepartment(department._id); + }); + + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + + const { body } = await request + .get(api('livechat/analytics/departments/average-service-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('averageServiceTimeInSeconds').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + }); + + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/average-service-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/average-chat-duration-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/departments/average-chat-duration-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('averageChatDurationTimeInSeconds').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/average-chat-duration-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/total-service-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/departments/total-service-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('chats').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('serviceTimeDuration').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/total-service-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/average-waiting-time', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + describe('with data', () => { + let department: ILivechatDepartment; + let visitor: ILivechatVisitor; + + before(async () => { + department = await createDepartment({}, [{ agentId: credentials['X-User-Id'] }]); + visitor = await createVisitor(department._id); + const room = await createLivechatRoom(visitor.token); + const inq = await fetchInquiry(room._id); + await takeInquiry(inq._id); + await sendAgentMessage(room._id); + await closeOmnichannelRoom(room._id); + }); + after(async () => { + await deleteVisitor(visitor.token); + await deleteDepartment(department._id); + }); + + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/departments/average-waiting-time')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('averageWaitingTimeInSeconds').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/average-waiting-time', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/total-transferred-chats', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const { body } = await request + .get(api('livechat/analytics/departments/total-transferred-chats')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('numberOfTransferredRooms').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/total-transferred-chats', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/total-abandoned-chats', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + await updateSetting('Livechat_visitor_inactivity_timeout', 0); + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + const inq = await fetchInquiry(room._id); + await takeInquiry(inq._id); + await sendMessage(room._id, 'first message', visitor.token); + await sendAgentMessage(room._id); + await closeOmnichannelRoom(room._id); + + const { body } = await request + .get(api('livechat/analytics/departments/total-abandoned-chats')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('abandonedRooms').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/total-abandoned-chats', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); + describe('livechat/analytics/departments/percentage-abandoned-chats', () => { + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-manager', []); + await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01', end: '2020-01-02' }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + it('should fail if start is not present as query param', async () => { + await updatePermission('view-livechat-manager', ['admin']); + const response = await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ end: '2020-01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not present as query param', async () => { + const response = await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if start is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ start: '2020-01-01x', end: date }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should fail if end is not a valid date', async () => { + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + const response = await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ start: date, end: '2020-x01-02' }) + .expect(400); + expect(response.body).to.have.property('success', false); + }); + it('should return an array of departments', async () => { + await updateSetting('Livechat_visitor_inactivity_timeout', 0); + const date = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(); + + const { body } = await request + .get(api('livechat/analytics/departments/percentage-abandoned-chats')) + .set(credentials) + .query({ start: date, end: new Date().toISOString() }) + .expect('Content-Type', 'application/json') + .expect(200); + expect(body).to.have.property('success', true); + expect(body.departments).to.be.an('array').with.lengthOf.greaterThan(0); + expect(body.departments[0]).to.have.a.property('percentageOfAbandonedChats').that.is.a('number'); + expect(body.departments[0]).to.have.a.property('_id'); + }); + it('should accept pagination', async () => { + await pagination('livechat/analytics/departments/percentage-abandoned-chats', credentials, { + start: new Date().toISOString(), + end: new Date().toISOString(), + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/18-rooms-ee.ts b/apps/meteor/tests/end-to-end/api/livechat/18-rooms-ee.ts new file mode 100644 index 0000000..9f9a854 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/18-rooms-ee.ts @@ -0,0 +1,374 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { + createVisitor, + createLivechatRoom, + sendMessage, + sendAgentMessage, + placeRoomOnHold, + getLivechatRoomInfo, + startANewLivechatRoomAndTakeIt, + makeAgentAvailable, + createAgent, + closeOmnichannelRoom, + fetchMessages, +} from '../../../data/livechat/rooms'; +import { sleep } from '../../../data/livechat/utils'; +import { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[EE] LIVECHAT - rooms', () => { + before((done) => getCredentials(done)); + + let agent2: { user: IUser; credentials: Credentials }; + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + await createAgent(); + await makeAgentAvailable(); + }); + + before(async () => { + const user = await createUser(); + const userCredentials = await login(user.username, password); + await createAgent(user.username); + await updateSetting('Livechat_allow_manual_on_hold', true); + + agent2 = { + user, + credentials: userCredentials, + }; + }); + + after(async () => { + await deleteUser(agent2.user); + await updateSetting('Livechat_allow_manual_on_hold', false); + await updateSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only', true); + }); + describe('livechat/room.onHold', () => { + it('should fail if user doesnt have on-hold-livechat-room permission', async () => { + await updatePermission('on-hold-livechat-room', []); + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect(403); + + expect(response.body.success).to.be.false; + + await updatePermission('on-hold-livechat-room', ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin']); + }); + it('should fail if roomId is invalid', async () => { + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-invalid-room'); + }); + it('should fail if room is an empty string', async () => { + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: ' ', + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-invalid-room'); + }); + it('should fail if room is not a livechat room', async () => { + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: 'GENERAL', + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-invalid-room'); + }); + it('should fail if visitor is awaiting response (visitor sent last message)', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendMessage(room._id, 'test', visitor.token); + + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-cannot-place-chat-on-hold'); + }); + it('should fail if room is closed', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-room-already-closed'); + }); + it('should fail if user is not serving the chat and doesnt have on-hold-others-livechat-room permission', async () => { + const { room } = await startANewLivechatRoomAndTakeIt(); + await sendAgentMessage(room._id); + + const response = await request + .post(api('livechat/room.onHold')) + .set(agent2.credentials) + .send({ + roomId: room._id, + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('Not_authorized'); + }); + it('should put room on hold', async () => { + const { room } = await startANewLivechatRoomAndTakeIt(); + await sendAgentMessage(room._id); + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(200); + + expect(response.body.success).to.be.true; + + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom.onHold).to.be.true; + }); + it('Should put room on hold, even in the visitor sent the last message', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + await updateSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only', false); + await sendMessage(room._id, '-', visitor.token); + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(200); + expect(response.body.success).to.be.true; + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom.onHold).to.be.true; + }); + it('should not put room on hold when visitor sent the last message', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + await updateSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only', true); + await sendMessage(room._id, '-', visitor.token); + const response = await request + .post(api('livechat/room.onHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(400); + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-cannot-place-chat-on-hold'); + }); + }); + + describe('livechat/room.resumeOnHold', () => { + it('should fail if user doesnt have view-l-room permission', async () => { + await updatePermission('view-l-room', []); + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect(403); + + expect(response.body.success).to.be.false; + }); + it('should fail if roomId is invalid', async () => { + await updatePermission('view-l-room', ['admin', 'livechat-manager', 'livechat-agent']); + + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-invalid-room'); + }); + it('should fail if room is not a livechat room', async () => { + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: 'GENERAL', + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-invalid-room'); + }); + + it('should fail if room is not on hold', async () => { + const { room } = await startANewLivechatRoomAndTakeIt(); + + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(400); + + expect(response.body.success).to.be.false; + expect(response.body.error).to.be.equal('error-room-not-on-hold'); + }); + it('should resume room on hold', async () => { + const { room } = await startANewLivechatRoomAndTakeIt(); + + await sendAgentMessage(room._id); + await placeRoomOnHold(room._id); + + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(200); + + expect(response.body.success).to.be.true; + + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.not.have.property('onHold'); + }); + it('should resume room on hold and send proper system message', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + + await sendAgentMessage(room._id); + await placeRoomOnHold(room._id); + + const response = await request + .post(api('livechat/room.resumeOnHold')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect(200); + + expect(response.body.success).to.be.true; + + const messages = await fetchMessages(room._id, visitor.token); + expect(messages).to.be.an('array'); + expect(messages[0]).to.not.be.undefined; + expect(messages[0]).to.have.property('t', 'omnichannel_on_hold_chat_resumed'); + expect(messages[0]).to.have.property('comment', 'The chat was manually resumed from On Hold by RocketChat Internal Admin Test'); + }); + it('should resume chat automatically if visitor sent a message', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + + await sendAgentMessage(room._id); + await placeRoomOnHold(room._id); + await sendMessage(room._id, 'test', visitor.token); + + // wait for the room to be resumed since that logic is within callbacks + await sleep(500); + + const updatedRoom = await getLivechatRoomInfo(room._id); + expect(updatedRoom).to.not.have.property('onHold'); + }); + }); + + describe('visitor abandonment feature', () => { + let room: IOmnichannelRoom; + + before(async () => { + await updateSetting('Livechat_abandoned_rooms_action', 'Livechat_close_chat'); + await updateSetting('Livechat_visitor_inactivity_timeout', 60); + }); + + it('should set predictedVisitorAbandonmentAt when agent sends a message', async () => { + const { room: newRoom } = await startANewLivechatRoomAndTakeIt(); + + room = newRoom; + + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + const lastMessageTs = updatedRoom.responseBy?.lastMessageTs; + const firstResponseTs = updatedRoom.responseBy?.firstResponseTs; + const predictedVisitorAbandonmentAt = updatedRoom.omnichannel?.predictedVisitorAbandonmentAt; + + expect(predictedVisitorAbandonmentAt).to.not.be.undefined; + expect(lastMessageTs).to.not.be.undefined; + expect(firstResponseTs).to.not.be.undefined; + + // expect predictedVisitorAbandonmentAt to be 60 seconds after lastMessageTs + const lastMessageTsDate = new Date(lastMessageTs as Date); + const predictedVisitorAbandonmentAtDate = new Date(predictedVisitorAbandonmentAt as Date); + const firstResponseTsDate = new Date(firstResponseTs as Date); + + expect(predictedVisitorAbandonmentAtDate.getTime()).to.be.equal(lastMessageTsDate.getTime() + 60000); + expect(firstResponseTsDate.getTime()).to.be.equal(lastMessageTsDate.getTime()); + }); + + it('should not update predictedVisitorAbandonmentAt when agent sends yet another message', async () => { + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + const lastMessageTs = updatedRoom.responseBy?.lastMessageTs; + const firstResponseTs = updatedRoom.responseBy?.firstResponseTs; + const predictedVisitorAbandonmentAt = updatedRoom.omnichannel?.predictedVisitorAbandonmentAt; + + expect(predictedVisitorAbandonmentAt).to.not.be.undefined; + expect(lastMessageTs).to.not.be.undefined; + + // expect predictedVisitorAbandonmentAt to be 60 seconds after first message + const lastMessageTsDate = new Date(lastMessageTs as Date); + const predictedVisitorAbandonmentAtDate = new Date(predictedVisitorAbandonmentAt as Date); + const firstResponseTsDate = new Date(firstResponseTs as Date); + + expect(predictedVisitorAbandonmentAtDate.getTime()).to.be.equal(firstResponseTsDate.getTime() + 60000); + + // lastMessageTs should be updated + expect(lastMessageTsDate.getTime()).to.not.be.equal(firstResponseTsDate.getTime()); + expect(lastMessageTsDate.getTime()).to.be.greaterThan(firstResponseTsDate.getTime()); + }); + + after(async () => { + await updateSetting('Livechat_abandoned_rooms_action', 'none'); + await updateSetting('Livechat_visitor_inactivity_timeout', 3600); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts new file mode 100644 index 0000000..eebd4c3 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts @@ -0,0 +1,1128 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatAgent, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourBehaviors, LivechatBusinessHourTypes, ILivechatAgentStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { sleep } from '../../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { + getDefaultBusinessHour, + removeAllCustomBusinessHours, + saveBusinessHour, + openOrCloseBusinessHour, + createCustomBusinessHour, + getCustomBusinessHourById, + getWorkHours, +} from '../../../data/livechat/businessHours'; +import { + addOrRemoveAgentFromDepartment, + archiveDepartment, + createDepartmentWithAnOnlineAgent, + createDepartmentWithAgent, + disableDepartment, + getDepartmentById, + deleteDepartment, +} from '../../../data/livechat/department'; +import { createAgent, createManager, makeAgentAvailable } from '../../../data/livechat/rooms'; +import { createAnOnlineAgent, removeAgent } from '../../../data/livechat/users'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting, updateEESetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import type { TestUser } from '../../../data/users.helper'; +import { setUserActiveStatus, createUser, deleteUser, getMe, getUserByUsername, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; +import { retry } from '../helpers/retry'; + +describe('LIVECHAT - business hours', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updateSetting('Livechat_enable_business_hours', true); + await updateSetting('Omnichannel_enable_department_removal', true); + await createAgent(); + }); + + after(async () => { + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + let defaultBhId: any; + describe('[CE] livechat/business-hour', () => { + after(async () => { + const { _updatedAt, ts, ...cleanedBusinessHour } = defaultBhId; + await saveBusinessHour({ + ...cleanedBusinessHour, + timezone: 'America/Sao_Paulo', + timezoneName: 'America/Sao_Paulo', + workHours: getWorkHours(true), + }); + }); + + it('should fail when user doesnt have view-livechat-business-hours permission', async () => { + await removePermissionFromAllRoles('view-livechat-business-hours'); + const response = await request.get(api('livechat/business-hour')).set(credentials).expect(403); + expect(response.body.success).to.be.false; + + await restorePermissionToRoles('view-livechat-business-hours'); + }); + it('should fail when business hour type is not a valid BH type', async () => { + const response = await request.get(api('livechat/business-hour')).set(credentials).query({ type: 'invalid' }).expect(200); + expect(response.body.success).to.be.true; + expect(response.body.businessHour).to.be.null; + }); + it('should return a business hour of type default', async () => { + const response = await request.get(api('livechat/business-hour')).set(credentials).query({ type: 'default' }).expect(200); + expect(response.body.success).to.be.true; + expect(response.body.businessHour).to.be.an('object'); + expect(response.body.businessHour._id).to.be.a('string'); + expect(response.body.businessHour.workHours).to.be.an('array'); + expect(response.body.businessHour.workHours[0]).to.be.an('object'); + expect(response.body.businessHour.workHours[0].day) + .to.be.an('string') + .that.is.oneOf(['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']); + expect(response.body.businessHour.workHours[0].start).to.be.an('object'); + expect(response.body.businessHour.workHours[0].finish).to.be.an('object'); + expect(response.body.businessHour.workHours[0].open).to.be.a('boolean'); + expect(response.body.businessHour.timezone).to.be.an('object').that.has.property('name').that.is.an('string'); + + defaultBhId = response.body.businessHour; + }); + it('should not allow a user to be available if BH are closed', async () => { + const { _updatedAt, ts, ...cleanedBusinessHour } = defaultBhId; + await saveBusinessHour({ + ...cleanedBusinessHour, + timezone: 'America/Sao_Paulo', + timezoneName: 'America/Sao_Paulo', + workHours: [ + { + day: 'Monday', + open: true, + start: '00:00', + finish: '00:01', + }, + ], + }); + + const { body } = await makeAgentAvailable(credentials); + + expect(body).to.have.property('success', false); + expect(body.error).to.be.equal('error-business-hours-are-closed'); + }); + it('should allow a user to be available if BH are open', async () => { + const { _updatedAt, ts, ...cleanedBusinessHour } = defaultBhId; + await saveBusinessHour({ + ...cleanedBusinessHour, + timezone: 'America/Sao_Paulo', + timezoneName: 'America/Sao_Paulo', + workHours: getWorkHours(true), + }); + + const { body } = await makeAgentAvailable(credentials); + + expect(body).to.have.property('success', true); + }); + it('should save a default business hour with proper timezone settings', async () => { + const { _updatedAt, ts, ...cleanedBusinessHour } = defaultBhId; + await saveBusinessHour({ + ...cleanedBusinessHour, + timezone: 'Asia/Kolkata', + workHours: getWorkHours(true), + timezoneName: 'Asia/Kolkata', + }); + + const { body } = await request + .get(api('livechat/business-hour')) + .set(credentials) + .query({ type: LivechatBusinessHourTypes.DEFAULT }) + .expect(200); + + expect(body.success).to.be.true; + expect(body.businessHour).to.be.an('object'); + expect(body.businessHour.timezone).to.be.an('object').that.has.property('name').that.is.equal('Asia/Kolkata'); + expect(body.businessHour.workHours).to.be.an('array').with.lengthOf(7); + + const { workHours } = body.businessHour; + + expect(workHours[0].day).to.be.equal('Sunday'); + expect(workHours[0].start.utc.dayOfWeek).to.be.equal('Saturday'); + expect(workHours[0].finish.utc.dayOfWeek).to.be.equal('Sunday'); + }); + + it('should allow agents to be available', async () => { + const { body } = await makeAgentAvailable(credentials); + expect(body).to.have.property('success', true); + }); + }); + + (IS_EE ? describe : describe.skip)('[EE] livechat/business-hour', () => { + it('should fail if user doesnt have view-livechat-business-hours permission', async () => { + await removePermissionFromAllRoles('view-livechat-business-hours'); + const response = await request.get(api('livechat/business-hours')).set(credentials).expect(403); + expect(response.body.success).to.be.false; + + await restorePermissionToRoles('view-livechat-business-hours'); + }); + it('should return a list of business hours', async () => { + const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200); + expect(response.body.success).to.be.true; + expect(response.body.businessHours).to.be.an('array').with.lengthOf.greaterThan(0); + expect(response.body.businessHours[0]).to.be.an('object'); + expect(response.body.businessHours[0]._id).to.be.a('string'); + expect(response.body.businessHours[0].workHours).to.be.an('array'); + expect(response.body.businessHours[0].workHours[0]).to.be.an('object'); + expect(response.body.businessHours[0].workHours[0].day) + .to.be.an('string') + .that.is.oneOf(['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']); + expect(response.body.businessHours[0].workHours[0].start).to.be.an('object'); + expect(response.body.businessHours[0].workHours[0].finish).to.be.an('object'); + expect(response.body.businessHours[0].workHours[0].open).to.be.a('boolean'); + expect(response.body.businessHours[0].timezone).to.be.an('object').that.has.property('name').that.is.an('string'); + expect(response.body.businessHours[0].active).to.be.a('boolean'); + }); + it('should return a just created custom business hour', async () => { + const name = `business-hour-${Date.now()}`; + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + await saveBusinessHour({ + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: [{ day: 'Monday', start: '08:00', finish: '18:00', open: true }], + daysOpen: ['Monday'], + daysTime: [{ day: 'Monday', start: { time: '08:00' }, finish: { time: '18:00' }, open: true }], + departmentsToApplyBusinessHour: '', + timezone: 'America/Sao_Paulo', + timezoneName: 'America/Sao_Paulo', + }); + + const { body } = await request.get(api('livechat/business-hours')).set(credentials).query({ name }).expect(200); + expect(body.success).to.be.true; + expect(body.businessHours).to.be.an('array').with.lengthOf(1); + expect(body.businessHours[0]).to.be.an('object'); + expect(body.businessHours[0]._id).to.be.a('string'); + expect(body.businessHours[0]).to.have.property('name', name); + expect(body.businessHours[0]).to.have.property('active', true); + expect(body.businessHours[0]).to.have.property('type', LivechatBusinessHourTypes.CUSTOM); + expect(body.businessHours[0]).to.have.property('workHours').that.is.an('array').with.lengthOf(1); + expect(body.businessHours[0].workHours[0]).to.be.an('object').with.property('day', 'Monday'); + expect(body.businessHours[0].workHours[0]).to.have.property('start').that.is.an('object'); + expect(body.businessHours[0].workHours[0]).to.have.property('finish').that.is.an('object'); + expect(body.businessHours[0]).to.have.property('timezone').that.is.an('object').with.property('name', 'America/Sao_Paulo'); + }); + it('should fail if start and finish time are the same', async () => { + const name = `business-hour-${Date.now()}`; + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + const result = await saveBusinessHour({ + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: [{ day: 'Monday', open: true, start: '08:00', finish: '08:00' }], + daysOpen: ['Monday'], + daysTime: [{ day: 'Monday', start: { time: '08:00' }, finish: { time: '08:00' }, open: true }], + timezone: 'America/Sao_Paulo', + departmentsToApplyBusinessHour: '', + timezoneName: 'America/Sao_Paulo', + }); + + expect(result).to.have.property('error'); + }); + it('should fail if finish is before start time', async () => { + const name = `business-hour-${Date.now()}`; + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + const result = await saveBusinessHour({ + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: [{ day: 'Monday', open: true, start: '10:00', finish: '08:00' }], + daysOpen: ['Monday'], + daysTime: [{ day: 'Monday', start: { time: '10:00' }, finish: { time: '08:00' }, open: true }], + timezone: 'America/Sao_Paulo', + departmentsToApplyBusinessHour: '', + timezoneName: 'America/Sao_Paulo', + }); + + expect(result).to.have.property('error'); + }); + it('should fail if data is invalid', async () => { + const name = `business-hour-${Date.now()}`; + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + const result = await saveBusinessHour({ + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: [{ day: 'Monday', open: true, start: '20000', finish: 'xxxxx' }], + daysOpen: ['Monday'], + daysTime: [{ day: 'Monday', start: { time: '20000' }, finish: { time: 'xxxxx' }, open: true }], + timezone: 'America/Sao_Paulo', + departmentsToApplyBusinessHour: '', + timezoneName: 'America/Sao_Paulo', + }); + + expect(result).to.have.property('error'); + }); + }); + + (IS_EE ? describe : describe.skip)('[EE][BH] On Business Hour created', () => { + let defaultBusinessHour: ILivechatBusinessHour; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, true); + }); + + it('should create a custom business hour which is closed by default', async () => { + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await createCustomBusinessHour([department._id], false); + + const latestAgent = await getMe(agent.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(0); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + + it('should create a custom business hour which is closed by default, but a bot agent shouldnt be affected', async () => { + const bot = await createUser({ roles: ['bot', 'livechat-agent'] }); + const creds = await login(bot.username, password); + await makeAgentAvailable(creds); + + const { department } = await createDepartmentWithAgent({ user: bot, credentials: creds }); + + await createCustomBusinessHour([department._id], false); + + const latestAgent: ILivechatAgent = await getMe(creds); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(0); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.AVAILABLE); + }); + }); + + // Scenario: A BH is linked to departments and gets re-saved with a smaller (or empty) department list + // Expected result: the removed departments lose the link (and their agents lose the BH), + // while the remaining departments stay linked + (IS_EE ? describe : describe.skip)('[EE][BH] On Business Hour departments removed via save', () => { + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + const createdDepartments: Awaited>[] = []; + let keptDepartmentWithAgent: Awaited>; + let removedDepartmentWithAgent: Awaited>; + + beforeEach(async () => { + await removeAllCustomBusinessHours(); + await openOrCloseBusinessHour(await getDefaultBusinessHour(), false); + keptDepartmentWithAgent = await createDepartmentWithAnOnlineAgent(); + removedDepartmentWithAgent = await createDepartmentWithAnOnlineAgent(); + createdDepartments.push(keptDepartmentWithAgent, removedDepartmentWithAgent); + }); + + after(async () => { + await Promise.all(createdDepartments.map(({ department }) => deleteDepartment(department._id))); + await Promise.all(createdDepartments.map(({ agent }) => deleteUser(agent.user))); + }); + + const resaveWithDepartments = async (businessHour: ILivechatBusinessHour, departmentsToApplyBusinessHour: string) => { + const { _updatedAt, ts, departments, ...cleanedBusinessHour } = businessHour; + await saveBusinessHour({ + ...cleanedBusinessHour, + timezoneName: businessHour.timezone.name, + timezone: businessHour.timezone.name, + workHours: getWorkHours(true), + daysOpen: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + departmentsToApplyBusinessHour, + } as any); + }; + + it('should unlink only the removed departments when re-saving with a smaller department list', async () => { + const { department: keptDepartment, agent: keptAgent } = keptDepartmentWithAgent; + const { department: removedDepartment, agent: removedAgent } = removedDepartmentWithAgent; + const customBusinessHour = await createCustomBusinessHour([keptDepartment._id, removedDepartment._id]); + await openOrCloseBusinessHour(customBusinessHour, true); + + await resaveWithDepartments(customBusinessHour, keptDepartment._id); + + const latestKeptDepartment = await getDepartmentById(keptDepartment._id); + expect(latestKeptDepartment.businessHourId).to.be.equal(customBusinessHour._id); + const latestRemovedDepartment = await getDepartmentById(removedDepartment._id); + expect(latestRemovedDepartment.businessHourId).to.be.undefined; + + const latestKeptAgent = await getMe(keptAgent.credentials); + expect(latestKeptAgent.openBusinessHours).to.be.an('array').that.includes(customBusinessHour._id); + + const latestRemovedAgent = await getMe(removedAgent.credentials); + expect(latestRemovedAgent.openBusinessHours).to.be.an('array').that.does.not.include(customBusinessHour._id); + }); + + it('should unlink all departments when re-saving with an empty department list', async () => { + const { department } = keptDepartmentWithAgent; + const customBusinessHour = await createCustomBusinessHour([department._id]); + await openOrCloseBusinessHour(customBusinessHour, true); + + await resaveWithDepartments(customBusinessHour, ''); + + const latestDepartment = await getDepartmentById(department._id); + expect(latestDepartment.businessHourId).to.be.undefined; + }); + }); + + // Scenario: Assume we have a BH linked to a department, and we archive the department + // Expected result: + // 1) If BH is open and only linked to that department, it should be closed + // 2) If BH is open and linked to other departments, it should remain open + // 3) Agents within the archived department should be assigned to default BH + // 3.1) We'll also need to handle the case where if an agent is assigned to "dep1" + // and "dep2" and both these depts are connected to same BH, then in this case after + // archiving "dep1", we'd still need to BH within this user's cache since he's part of + // "dep2" which is linked to BH + (IS_EE ? describe : describe.skip)('[EE][BH] On Department archived', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon archiving a department, if BH is open and only linked to that department, it should be closed', async () => { + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const department = await getDepartmentById(deptLinkedToCustomBH._id); + expect(department).to.be.an('object'); + expect(department).to.have.property('archived', true); + expect(department.businessHourId).to.be.undefined; + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', false); + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon archiving a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const archivedDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(archivedDepartment).to.be.an('object'); + expect(archivedDepartment).to.have.property('archived', true); + expect(archivedDepartment.businessHourId).to.be.undefined; + // verify if other department is not archived and BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon archiving a department, agents within the archived department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBusinessHour._id); + }); + + it('upon archiving a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const archivedDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(archivedDepartment).to.be.an('object'); + expect(archivedDepartment).to.have.property('archived', true); + expect(archivedDepartment.businessHourId).to.be.undefined; + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + await deleteUser(agentLinkedToDept.user); + }); + }); + (IS_EE ? describe : describe.skip)('[EE][BH] On Department disabled', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon disabling a department, if BH is open and only linked to that department, it should be closed', async () => { + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const department = await getDepartmentById(deptLinkedToCustomBH._id); + expect(department).to.be.an('object'); + expect(department.businessHourId).to.be.undefined; + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH.active).to.be.false; + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon disabling a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const disabledDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(disabledDepartment).to.be.an('object'); + expect(disabledDepartment.businessHourId).to.be.undefined; + + // verify if other department BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon disabling a department, agents within the disabled department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBusinessHour._id); + }); + + it('upon disabling a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const disabledDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(disabledDepartment).to.be.an('object'); + expect(disabledDepartment.businessHourId).to.be.undefined; + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + await deleteUser(agentLinkedToDept.user); + }); + }); + (IS_EE ? describe : describe.skip)('[EE][BH] On Department removed', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon deleting a department, if BH is open and only linked to that department, it should be closed', async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH.active).to.be.false; + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon deleting a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if other department BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon deleting a department, agents within the disabled department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + await deleteDepartment(deptLinkedToCustomBH._id); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBusinessHour._id); + }); + + it('upon deleting a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteUser(agentLinkedToDept.user); + }); + }); + (IS_EE ? describe : describe.skip)('[EE][BH] On agent removed from department', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + // the business hour recomputation runs on a fire-and-forget callback, so poll until it lands + const waitForAgentBusinessHours = async (agentCredentials: Credentials, expected: string[]): Promise => { + const matches = (agent: ILivechatAgent) => + JSON.stringify([...(agent.openBusinessHours || [])].sort()) === JSON.stringify([...expected].sort()); + + let latestAgent: ILivechatAgent = await getMe(agentCredentials); + for (let attempts = 0; attempts < 10 && !matches(latestAgent); attempts++) { + await sleep(500); + latestAgent = await getMe(agentCredentials); + } + return latestAgent.openBusinessHours; + }; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + await removeAllCustomBusinessHours(); + + defaultBusinessHour = await getDefaultBusinessHour(); + await openOrCloseBusinessHour(defaultBusinessHour, true); + + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + deptLinkedToCustomBH = department; + agentLinkedToDept = agent; + + customBusinessHour = await createCustomBusinessHour([department._id]); + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon removing an agent from its only department, the agent should fall back to the default business hour', async () => { + const openBusinessHoursBefore = await waitForAgentBusinessHours(agentLinkedToDept.credentials, [customBusinessHour._id]); + expect(openBusinessHoursBefore).to.be.an('array').of.length(1); + expect(openBusinessHoursBefore?.[0]).to.be.equal(customBusinessHour._id); + + await addOrRemoveAgentFromDepartment( + deptLinkedToCustomBH._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + false, + ); + + const openBusinessHours = await waitForAgentBusinessHours(agentLinkedToDept.credentials, [defaultBusinessHour._id]); + expect(openBusinessHours).to.be.an('array').of.length(1); + expect(openBusinessHours?.[0]).to.be.equal(defaultBusinessHour._id); + }); + + it('upon removing an agent from one department, the agent should keep the business hour shared with its other department', async () => { + // link a second department (with its own agent) to the same business hour and make the first agent overlap + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + await openOrCloseBusinessHour(customBusinessHour, true); + + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + // control agent: belongs only to the first department and is removed in the same request as the + // overlapping agent, so both are processed by the same recomputation callback. Observing the control + // agent fall back to the default business hour proves that callback ran — only then is asserting the + // overlapping agent's unchanged state meaningful (it equals the pre-removal state, so it can't + // distinguish "correctly kept" from "callback never ran" on its own) + const controlAgent = await createAnOnlineAgent(); + await addOrRemoveAgentFromDepartment( + deptLinkedToCustomBH._id, + { + agentId: controlAgent.user._id, + username: controlAgent.user.username || '', + }, + true, + ); + + const overlappingBefore = await waitForAgentBusinessHours(agentLinkedToDept.credentials, [customBusinessHour._id]); + expect(overlappingBefore).to.be.an('array').of.length(1); + const controlBefore = await waitForAgentBusinessHours(controlAgent.credentials, [customBusinessHour._id]); + expect(controlBefore).to.be.an('array').of.length(1); + + await request + .post(api(`livechat/department/${deptLinkedToCustomBH._id}/agents`)) + .set(credentials) + .send({ + upsert: [], + remove: [ + { agentId: agentLinkedToDept.user._id, username: agentLinkedToDept.user.username || '' }, + { agentId: controlAgent.user._id, username: controlAgent.user.username || '' }, + ], + }) + .expect(200); + + const controlOpenBusinessHours = await waitForAgentBusinessHours(controlAgent.credentials, [defaultBusinessHour._id]); + expect(controlOpenBusinessHours).to.be.an('array').of.length(1); + expect(controlOpenBusinessHours?.[0]).to.be.equal(defaultBusinessHour._id); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + await deleteDepartment(department._id); + await deleteUser(agent.user); + await deleteUser(controlAgent.user); + }); + + afterEach(async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + await deleteUser(agentLinkedToDept.user); + }); + + after(async () => { + await removeAllCustomBusinessHours(); + }); + }); + describe('[CE][BH] On Agent created/removed', () => { + let defaultBH: ILivechatBusinessHour; + let agent: ILivechatAgent; + let agentCredentials: Credentials; + + before(async () => { + await updateSetting('Livechat_enable_business_hours', true); + await updateEESetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.SINGLE); + // wait for callbacks to run + await sleep(2000); + + defaultBH = await getDefaultBusinessHour(); + await openOrCloseBusinessHour(defaultBH, true); + + agent = await createUser(); + agentCredentials = await login(agent.username, password); + }); + + after(async () => { + await deleteUser(agent); + }); + + it('should create a new agent and verify if it is assigned to the default business hour which is open', async () => { + agent = await createAgent(agent.username); + + // the BH assignment runs on a fire-and-forget callback, so the create-agent request returns before it lands + await retry( + 'agent BH assignment is async, so openBusinessHours may still be unset on the first fetch', + async () => { + const latestAgent: ILivechatAgent = await getMe(agentCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBH._id); + }, + { retries: 20, delayMs: 250 }, + ); + }); + + it('should create a new agent and verify if it is assigned to the default business hour which is closed', async () => { + await openOrCloseBusinessHour(defaultBH, false); + + // the BH close recalculates agent statuses asynchronously; wait for it to propagate (existing agent loses the + // BH assignment) before creating the new agent, otherwise it is created while the BH still counts as open + await retry( + 'BH close propagation is async, so the existing agent may still be assigned on the first fetch', + async () => { + const current: ILivechatAgent = await getMe(agentCredentials); + expect(current.openBusinessHours ?? []).to.have.lengthOf(0); + }, + { retries: 20, delayMs: 250 }, + ); + + const newUser: ILivechatAgent = await createUser(); + const newUserCredentials = await login(newUser.username, password); + await createAgent(newUser.username); + + // createAgent sets the agent as available first and the async callback moves it to not-available, so the + // first fetch can still show the intermediate available state + await retry( + 'agent BH assignment is async, so statusLivechat may still be available on the first fetch', + async () => { + const latestAgent: ILivechatAgent = await getMe(newUserCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.undefined; + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }, + { retries: 20, delayMs: 250 }, + ); + + // cleanup + await deleteUser(newUser); + }); + + it('should verify if agent is assigned to BH when it is opened', async () => { + // first verify if agent is not assigned to any BH + let latestAgent: ILivechatAgent = await getMe(agentCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(0); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + + // now open BH + await openOrCloseBusinessHour(defaultBH, true); + + // verify if agent is assigned to BH + latestAgent = await getMe(agentCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBH._id); + + // verify if agent is able to make themselves available + await makeAgentAvailable(agentCredentials as any); + }); + + it('should verify if BH related props are cleared when an agent is deleted', async () => { + await removeAgent(agent._id); + + const latestAgent: ILivechatAgent = await getMe(agentCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.undefined; + expect(latestAgent.statusLivechat).to.be.undefined; + }); + + describe('Special Case - Agent created, BH already enabled', () => { + let newAgent: ILivechatAgent; + let newAgentCredentials: Credentials; + before(async () => { + newAgent = await createUser({ roles: ['user', 'livechat-agent'] }); + newAgentCredentials = await login(newAgent.username, password); + }); + after(async () => { + await deleteUser(newAgent); + }); + it('should verify a newly created agent to be assigned to the default business hour', async () => { + const latestAgent: ILivechatAgent = await getMe(newAgentCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBH._id); + }); + }); + + after(async () => { + await deleteUser(agent); + }); + }); + + describe('[CE][BH] On Agent deactivated/activated', () => { + let defaultBH: ILivechatBusinessHour; + let agent: TestUser; + + before(async () => { + await updateSetting('Livechat_enable_business_hours', true); + await updateEESetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.SINGLE); + // wait for callbacks to run + await sleep(2000); + + defaultBH = await getDefaultBusinessHour(); + await openOrCloseBusinessHour(defaultBH, true); + + agent = await createUser(); + await createAgent(agent.username); + }); + + after(async () => { + await deleteUser(agent); + await updateSetting('Livechat_enable_business_hours', false); + }); + + it('should verify if agent becomes unavailable to take chats when user is deactivated', async () => { + await makeAgentAvailable(await login(agent.username, password)); + await setUserActiveStatus(agent._id, false); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + + it('should verify if agent becomes available to take chats when user is activated, if business hour is active', async () => { + await openOrCloseBusinessHour(defaultBH, true); + + await setUserActiveStatus(agent._id, true); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.AVAILABLE); + }); + it('should verify if agent becomes unavailable to take chats when user is activated, if business hour is inactive', async () => { + await openOrCloseBusinessHour(defaultBH, false); + + await setUserActiveStatus(agent._id, false); + await setUserActiveStatus(agent._id, true); + + const latestAgent = await getUserByUsername(agent.username); + + expect(latestAgent).to.be.an('object'); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + it('should verify if managers are not able to make deactivated agents available', async () => { + await createManager(); + + await setUserActiveStatus(agent._id, false); + + const response = await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ + status: 'available', + agentId: agent._id, + }) + .expect(400); + + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'error-user-deactivated'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts b/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts new file mode 100644 index 0000000..6c16f62 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts @@ -0,0 +1,124 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatAgent, ILivechatVisitor, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { api, getCredentials, request } from '../../../data/api-data'; +import { sendSimpleMessage } from '../../../data/chat.helper'; +import { + sendMessage, + startANewLivechatRoomAndTakeIt, + sendAgentMessage, + createAgent, + makeAgentAvailable, + uploadFile, + closeOmnichannelRoom, +} from '../../../data/livechat/rooms'; +import { removeAgent } from '../../../data/livechat/users'; +import { updateEESetting, updateSetting } from '../../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../../data/rooms.helper'; + +describe('LIVECHAT - messages', () => { + let agent: ILivechatAgent; + before((done) => getCredentials(done)); + + before(async () => { + agent = await createAgent(); + await makeAgentAvailable(); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + }); + + after(() => Promise.all([updateSetting('Livechat_Routing_Method', 'Auto_Selection'), removeAgent(agent._id)])); + + describe('Quote message feature for visitors', () => { + it('it should verify if visitor can quote message', async () => { + const { + room: { _id: roomId }, + visitor: { token }, + } = await startANewLivechatRoomAndTakeIt(); + + await sendMessage(roomId, 'Hello from visitor', token); + const agentMsgSentence = faker.lorem.sentence(); + const agentMsg = await sendAgentMessage(roomId, agentMsgSentence); + + const siteUrl = process.env.SITE_URL || process.env.TEST_API_URL || 'http://localhost:3000'; + + const msgLink = `${siteUrl}/live/${roomId}?msg=${agentMsg._id}`; + const quotedMsgSentence = faker.lorem.sentence(); + const wholeQuotedMsg = `[${quotedMsgSentence}](${msgLink})`; + + const quotedMessage = await sendMessage(roomId, wholeQuotedMsg, token); + + expect(quotedMessage).to.have.property('_id'); + expect(quotedMessage).to.have.property('msg').that.is.equal(wholeQuotedMsg); + + expect(quotedMessage).to.have.property('attachments'); + expect(quotedMessage.attachments).to.be.an('array').that.has.lengthOf(1); + const quotedMessageAttachments = quotedMessage.attachments?.[0]; + if (quotedMessageAttachments) { + expect(quotedMessageAttachments).to.have.property('message_link').that.is.equal(msgLink); + expect(quotedMessageAttachments).to.have.property('text').that.is.equal(agentMsgSentence); + } + }); + + it('should verify if visitor is receiving a message with a image attachment', async () => { + const { + room: { _id: roomId }, + visitor: { token }, + } = await startANewLivechatRoomAndTakeIt(); + + const imgMessage = await uploadFile(roomId, token); + + expect(imgMessage).to.have.property('files').that.is.an('array'); + expect(imgMessage.files?.[0]).to.have.keys('_id', 'name', 'type'); + expect(imgMessage).to.have.property('file').that.deep.equal(imgMessage?.files?.[0]); + }); + }); + + describe('Livechat Messages', async () => { + let room: IOmnichannelRoom; + let privateRoom: IRoom; + let visitor: ILivechatVisitor; + + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + + const data = await startANewLivechatRoomAndTakeIt(); + visitor = data.visitor; + room = data.room; + + const response = await createRoom({ type: 'p', name: `private-room-${Math.random()}` } as any); + privateRoom = response.body.group; + }); + + after(() => Promise.all([closeOmnichannelRoom(room._id), deleteRoom({ roomId: privateRoom._id, type: 'p' })])); + + it('should not allow fetching arbitrary messages from another channel', async () => { + const response = await sendSimpleMessage({ roomId: privateRoom._id } as any); + const { message } = response.body; + + await request + .get(api(`livechat/message/${message._id}`)) + .query({ token: visitor.token, rid: room._id }) + .send() + .expect(400) + .expect((res) => { + expect(res.body.error).to.be.equal('invalid-message'); + }); + }); + + it('should allow fetching messages using their _id and roomId', async () => { + const message = await sendMessage(room._id, 'Hello from visitor', visitor.token); + + await request + .get(api(`livechat/message/${message._id}`)) + .query({ token: visitor.token, rid: room._id }) + .send() + .expect(200) + .expect((res) => { + expect(res.body.message._id).to.be.equal(message._id); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/21-reports.ts b/apps/meteor/tests/end-to-end/api/livechat/21-reports.ts new file mode 100644 index 0000000..7f006ba --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/21-reports.ts @@ -0,0 +1,573 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { api, request, credentials, getCredentials } from '../../../data/api-data'; +import { createDepartment, addOrRemoveAgentFromDepartment } from '../../../data/livechat/department'; +import { startANewLivechatRoomAndTakeIt, createAgent } from '../../../data/livechat/rooms'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { restorePermissionToRoles, updateEESetting, updatePermission } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('LIVECHAT - reports', () => { + before((done) => getCredentials(done)); + + let agent2: { user: IUser; credentials: Credentials }; + let agent3: { user: IUser; credentials: Credentials }; + + before(async () => { + await updateEESetting('Livechat_Require_Contact_Verification', 'never'); + const user = await createUser(); + const userCredentials = await login(user.username, password); + if (!user.username) { + throw new Error('user not created'); + } + await createMonitor(user.username); + + agent2 = { + user, + credentials: userCredentials, + }; + }); + + before(async () => { + const user = await createUser(); + const userCredentials = await login(user.username, password); + await createAgent(); + if (!user.username) { + throw new Error('user not created'); + } + await createMonitor(user.username); + const dep1 = await createDepartment(); + await addOrRemoveAgentFromDepartment( + dep1._id, + { agentId: 'rocketchat.internal.admin.test', username: 'rocketchat.internal.admin.test', count: 0, order: 0 }, + true, + ); + const { room, visitor } = await startANewLivechatRoomAndTakeIt({ departmentId: dep1._id }); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await createUnit(user._id, user.username, [dep1._id]); + + agent3 = { + user, + credentials: userCredentials, + }; + }); + + after(async () => { + await deleteUser(agent2.user); + await deleteUser(agent3.user); + }); + + describe('livechat/analytics/dashboards/conversations-by-source', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-reports', []); + await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(403); + }); + it('should return an error when the start and end parameters are not provided', async () => { + await restorePermissionToRoles('view-livechat-reports'); + await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).expect(400); + }); + it('should return an error when the start parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).query({ end: 'test' }).expect(400); + }); + it('should return an error when the end parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).query({ start: 'test' }).expect(400); + }); + it('should return an error when the start parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(400); + }); + it('should fail if dates are more than 1 year apart', async () => { + const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: oneYearAgo, end: now }) + .expect(400); + }); + it('should return an error when start is after end', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: now, end: oneHourAgo }) + .expect(400); + }); + it('should return an error when the end parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: '2020-01-01', end: 'test' }) + .expect(400); + }); + it('should return the proper data when the parameters are valid', async () => { + // Note: this way all data will come as 0 + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: now, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf(0); + expect(body.total).to.be.equal(0); + expect(body.success).to.be.true; + }); + it('should return only public rooms for a monitor with no units', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(agent2.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data.every((room: IOmnichannelRoom) => !room.departmentAncestors && !room.departmentId)).to.be.true; + }); + it('should return only the data from the unit the monitor belongs to', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(agent3.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + expect(body.total).to.be.greaterThan(0); + expect(body.success).to.be.true; + }); + it('should return valid data when login as a manager', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-source')) + .set(credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + expect(body.total).to.be.greaterThan(0); + }); + }); + describe('livechat/analytics/dashboards/conversations-by-status', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-reports', []); + await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(403); + }); + it('should return an error when the start and end parameters are not provided', async () => { + await restorePermissionToRoles('view-livechat-reports'); + await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).expect(400); + }); + it('should return an error when the start parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).query({ end: 'test' }).expect(400); + }); + it('should return an error when the end parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).query({ start: 'test' }).expect(400); + }); + it('should return an error when the start parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(400); + }); + it('should return an error when the end parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: '2020-01-01', end: 'test' }) + .expect(400); + }); + it('should return an error if dates are more than 1 year apart', async () => { + const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: oneYearAgo, end: now }) + .expect(400); + }); + it('should return an error when start is after end', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: now, end: oneHourAgo }) + .expect(400); + }); + it('should return the proper data when the parameters are valid', async () => { + // Note: this way all data will come as 0 + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: now, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf(0); + expect(body.success).to.be.true; + }); + it('should return public rooms for a monitor with no units', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(agent2.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data.every((room: IOmnichannelRoom) => !room.departmentAncestors && !room.departmentId)).to.be.true; + }); + it('should return the proper data when login as a manager', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-status')) + .set(credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + }); + }); + describe('livechat/analytics/dashboards/conversations-by-department', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-reports', []); + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(403); + }); + it('should return an error when the start and end parameters are not provided', async () => { + await restorePermissionToRoles('view-livechat-reports'); + await request.get(api('livechat/analytics/dashboards/conversations-by-department')).set(credentials).expect(400); + }); + it('should return an error when the start parameter is not provided', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ end: 'test' }) + .expect(400); + }); + it('should return an error when the end parameter is not provided', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: 'test' }) + .expect(400); + }); + it('should return an error when the start parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(400); + }); + it('should return an error when the end parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: '2020-01-01', end: 'test' }) + .expect(400); + }); + it('should return an error if dates are more than 1 year apart', async () => { + const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: oneYearAgo, end: now }) + .expect(400); + }); + it('should return an error when start is after end', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: now, end: oneHourAgo }) + .expect(400); + }); + it('should return the proper data when the parameters are valid', async () => { + // Note: this way all data will come as 0 + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: now, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf(0); + expect(body.success).to.be.true; + }); + it('should return public rooms for a monitor with no units', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(agent2.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data.every((room: IOmnichannelRoom) => !room.departmentAncestors && !room.departmentId)).to.be.true; + }); + it('should return the proper data when login as a manager', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-department')) + .set(credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + }); + }); + describe('livechat/analytics/dashboards/conversations-by-tags', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-reports', []); + await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(403); + }); + it('should return an error when the start and end parameters are not provided', async () => { + await restorePermissionToRoles('view-livechat-reports'); + await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).expect(400); + }); + it('should return an error when the start parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).query({ end: 'test' }).expect(400); + }); + it('should return an error when the end parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).query({ start: 'test' }).expect(400); + }); + it('should return an error when the start parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(400); + }); + it('should return an error when the end parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: '2020-01-01', end: 'test' }) + .expect(400); + }); + it('should return an error if dates are more than 1 year apart', async () => { + const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: oneYearAgo, end: now }) + .expect(400); + }); + it('should return an error when start is after end', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: now, end: oneHourAgo }) + .expect(400); + }); + it('should return the proper data when the parameters are valid', async () => { + // Note: this way all data will come as 0 + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: now, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf(0); + expect(body.success).to.be.true; + }); + it('should return public rooms for a monitor with no units', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(agent2.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data.every((room: IOmnichannelRoom) => !room.departmentAncestors && !room.departmentId)).to.be.true; + }); + it('should return the proper data when login as a manager', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-tags')) + .set(credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + }); + }); + describe('livechat/analytics/dashboards/conversations-by-agent', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-livechat-reports', []); + await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(403); + }); + it('should return an error when the start and end parameters are not provided', async () => { + await restorePermissionToRoles('view-livechat-reports'); + await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).expect(400); + }); + it('should return an error when the start parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).query({ end: 'test' }).expect(400); + }); + it('should return an error when the end parameter is not provided', async () => { + await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).query({ start: 'test' }).expect(400); + }); + it('should return an error when the start parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: 'test', end: 'test' }) + .expect(400); + }); + it('should return an error when the end parameter is not a valid date', async () => { + await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: '2020-01-01', end: 'test' }) + .expect(400); + }); + it('should return an error if dates are more than 1 year apart', async () => { + const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: oneYearAgo, end: now }) + .expect(400); + }); + it('should return an error when start is after end', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: now, end: oneHourAgo }) + .expect(400); + }); + it('should return the proper data when the parameters are valid', async () => { + // Note: this way all data will come as 0 + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: now, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf(0); + }); + it('should return public rooms for a monitor with no units', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(agent2.credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data.every((room: IOmnichannelRoom) => !room.departmentAncestors && !room.departmentId)).to.be.true; + }); + it('should return the proper data when login as a manager', async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + + const { body } = await request + .get(api('livechat/analytics/dashboards/conversations-by-agent')) + .set(credentials) + .query({ start: oneHourAgo, end: now }) + .expect(200); + + expect(body).to.have.property('data').and.to.be.an('array'); + expect(body.data).to.have.lengthOf.greaterThan(0); + expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts b/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts new file mode 100644 index 0000000..6c5066e --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts @@ -0,0 +1,301 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, it, describe, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { addOrRemoveAgentFromDepartment, createDepartment } from '../../../data/livechat/department'; +import { + createAgent, + createLivechatRoom, + createManager, + createVisitor, + getLivechatRoomInfo, + makeAgentAvailable, +} from '../../../data/livechat/rooms'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { updateSetting, updatePermission, restorePermissionToRoles, removePermissionFromAllRoles } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login, setUserActiveStatus } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +type TestUser = { user: IUser; credentials: Credentials }; + +(IS_EE ? describe : describe.skip)('Omnichannel - Monitors', () => { + let manager: TestUser; + let monitor: TestUser; + let noUnitDepartment: ILivechatDepartment; + let unitDepartment: ILivechatDepartment; + + before((done) => getCredentials(done)); + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await setUserActiveStatus('rocketchat.internal.admin.test', true); + await createAgent(); + await makeAgentAvailable(); + }); + before(async () => { + const user = await createUser(); + const userCredentials = await login(user.username, password); + if (!user.username) { + throw new Error('user not created'); + } + await createManager(user.username); + + manager = { + user, + credentials: userCredentials, + }; + }); + before(async () => { + const user = await createUser(); + const userCredentials = await login(user.username, password); + if (!user.username) { + throw new Error('user not created'); + } + await createMonitor(user.username); + + monitor = { + user, + credentials: userCredentials, + }; + }); + before(async () => { + noUnitDepartment = await createDepartment(); + unitDepartment = await createDepartment(); + + await createUnit(monitor.user._id, monitor.user.username!, [unitDepartment._id]); + }); + before(async () => { + await updatePermission('transfer-livechat-guest', ['admin', 'livechat-manager', 'livechat-agent', 'livechat-monitor']); + }); + + describe('Monitors', () => { + let user: IUser; + before(async () => { + user = await createUser(); + }); + after(async () => { + await deleteUser(user); + }); + + it('should properly create a new monitor', async () => { + const { body } = await request.post(api('livechat/monitors.create')).send({ username: user.username }).set(credentials).expect(200); + + expect(body.success).to.be.true; + }); + + it('should not fail when trying to create a monitor that already exists', async () => { + const { body } = await request.post(api('livechat/monitors.create')).send({ username: user.username }).set(credentials).expect(200); + + expect(body.success).to.be.true; + }); + + it('should fail when trying to create a monitor with an invalid username', async () => { + const { body } = await request + .post(api('livechat/monitors.create')) + .set(credentials) + .send({ username: 'invalid-username' }) + .expect(400); + + expect(body.success).to.be.false; + expect(body).to.have.property('error').to.be.equal('Invalid user [error-invalid-user]'); + }); + + it('should fail when trying to create a monitor with an empty username', async () => { + const { body } = await request.post(api('livechat/monitors.create')).set(credentials).send({ username: '' }).expect(400); + + expect(body.success).to.be.false; + expect(body).to.have.property('error').to.be.equal('Invalid user [error-invalid-user]'); + }); + + it('should remove a monitor', async () => { + const { body } = await request.post(api('livechat/monitors.delete')).set(credentials).send({ username: user.username }).expect(200); + + expect(body.success).to.be.true; + }); + + it('should fail when trying to remove a monitor that does not exist', async () => { + const { body } = await request + .post(api('livechat/monitors.delete')) + .set(credentials) + .send({ username: 'some-random-username' }) + .expect(400); + + expect(body.success).to.be.false; + expect(body).to.have.property('error').to.be.equal('Invalid user'); + }); + }); + + describe('[GET] livechat/monitors', () => { + it('should fail if manage-livechat-monitors permission is missing', async () => { + await removePermissionFromAllRoles('manage-livechat-monitors'); + return request.get(api('livechat/monitors')).set(credentials).expect(403); + }); + it('should return all monitors', async () => { + await restorePermissionToRoles('manage-livechat-monitors'); + const user = await createUser(); + await createMonitor(user.username); + + const { body } = await request.get(api('livechat/monitors')).set(credentials).query({ text: user.username }).expect(200); + expect(body).to.have.property('monitors'); + expect(body.monitors).to.have.lengthOf(1); + expect(body.monitors[0]).to.have.property('username', user.username); + + // cleanup + await deleteUser(user); + }); + }); + + describe('livechat/monitors/:username', () => { + it('should fail if manage-livechat-monitors permission is missing', async () => { + await removePermissionFromAllRoles('manage-livechat-monitors'); + return request.get(api('livechat/monitors/123')).set(credentials).expect(403); + }); + it('should return a monitor', async () => { + await restorePermissionToRoles('manage-livechat-monitors'); + const user = await createUser(); + await createMonitor(user.username); + + const { body } = await request + .get(api(`livechat/monitors/${user.username}`)) + .set(credentials) + .expect(200); + expect(body).to.have.property('username', user.username); + + // cleanup + await deleteUser(user); + }); + }); + + describe('Monitors & Rooms', () => { + it('should not return a room of a department that the monitor is not assigned to', async () => { + const visitor = await createVisitor(noUnitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .get(api('livechat/rooms')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('rooms').that.is.an('array'); + expect(body.rooms.find((r: any) => r._id === room._id)).to.not.exist; + }); + it('should return a room of a department the monitor is assigned to', async () => { + const visitor = await createVisitor(unitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .get(api('livechat/rooms')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('rooms').that.is.an('array'); + expect(body.rooms.find((r: any) => r._id === room._id)).to.exist; + }); + }); + + describe('Monitors & Departments', () => { + it('should not return a department that the monitor is not assigned to', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.not.exist; + }); + it('should return a department that the monitor is assigned to', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.length).to.be.equal(1); + expect(body.departments.find((d: any) => d._id === unitDepartment._id)).to.exist; + }); + it('should return both created departments to a manager', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true, sort: '{ "_updatedAt": 1 }' }) + .set(manager.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.exist; + expect(body.departments.find((d: any) => d._id === unitDepartment._id)).to.exist; + }); + // OnlyMyDepartments to return data from units + the ones you serve in + it.skip('should not return a department when monitor is only assigned as agent there', async () => { + await createAgent(monitor.user.username!); + await addOrRemoveAgentFromDepartment( + noUnitDepartment._id, + { agentId: monitor.user._id, username: monitor.user.username!, count: 0, order: 0 }, + true, + ); + + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.length).to.be.equal(1); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.not.exist; + }); + }); + + describe('Monitors & Forward', () => { + it('should successfully forward a room to another agent', async () => { + const visitor = await createVisitor(unitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/room.forward')) + .set(monitor.credentials) + .send({ + roomId: room._id, + userId: 'rocketchat.internal.admin.test', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + + const room2 = await getLivechatRoomInfo(room._id); + + expect(room2).to.have.property('servedBy').that.is.an('object'); + expect(room2.servedBy).to.have.property('_id', 'rocketchat.internal.admin.test'); + }); + it('should successfully forward a room to a department', async () => { + const visitor = await createVisitor(noUnitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/room.forward')) + .set(monitor.credentials) + .send({ + roomId: room._id, + departmentId: unitDepartment._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + + const room2 = await getLivechatRoomInfo(room._id); + expect(room2.departmentId).to.be.equal(unitDepartment._id); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts b/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts new file mode 100644 index 0000000..3b64f20 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts @@ -0,0 +1,134 @@ +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, afterEach, after, describe, it } from 'mocha'; +import moment from 'moment'; + +import { api, getCredentials, request, credentials } from '../../../data/api-data'; +import { + createVisitor, + createLivechatRoom, + createAgent, + makeAgentAvailable, + sendAgentMessage, + getLivechatRoomInfo, + fetchInquiry, + closeOmnichannelRoom, + deleteVisitor, +} from '../../../data/livechat/rooms'; + +describe('MAC', () => { + before((done) => getCredentials(done)); + + before(async () => { + await createAgent(); + await makeAgentAvailable(); + }); + + describe('MAC rooms', () => { + let visitor: ILivechatVisitor; + let multipleContactsVisitor: ILivechatVisitor; + + afterEach(() => deleteVisitor(visitor.token)); + + after(() => deleteVisitor(multipleContactsVisitor.token)); + + it('Should create an innactive room and contact by default', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + expect(room).to.be.an('object'); + expect(room.v.activity).to.be.undefined; + }); + + it('Should create an innactive contact by default', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(visitor._id); + expect(res.body.contact).not.to.have.property('activity'); + }); + + it('should mark room as active when agent sends a message', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array'); + }); + + it('should mark contact as active when agent sends a message', async () => { + multipleContactsVisitor = await createVisitor(); + const room = await createLivechatRoom(multipleContactsVisitor.token); + + await sendAgentMessage(room._id); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(multipleContactsVisitor._id); + expect(res.body.contact).to.have.property('activity').that.is.an('array').with.lengthOf(1); + expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM')); + }); + + it('should mark multiple rooms as active when they come from same contact after an agent sends a message', async () => { + const room = await createLivechatRoom(multipleContactsVisitor.token); + + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array'); + await closeOmnichannelRoom(room._id); + }); + + it('should keep contact active when reusing it and an agent response is received', async () => { + const room = await createLivechatRoom(multipleContactsVisitor.token); + + await sendAgentMessage(room._id); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(multipleContactsVisitor._id); + expect(res.body.contact).to.have.property('activity').that.is.an('array').with.lengthOf(1); + expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM')); + await closeOmnichannelRoom(room._id); + }); + + it('should mark room as active when it comes from same contact on same period, even without agent interaction', async () => { + const room = await createLivechatRoom(multipleContactsVisitor.token); + + expect(room).to.have.nested.property('v.activity').and.to.be.an('array'); + expect(room.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true; + await closeOmnichannelRoom(room._id); + }); + + it('should mark an inquiry as active when it comes from same contact on same period, even without agent interaction', async () => { + const room = await createLivechatRoom(multipleContactsVisitor.token); + const inquiry = await fetchInquiry(room._id); + + expect(inquiry).to.have.nested.property('v.activity').and.to.be.an('array'); + expect(inquiry.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true; + expect(room.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true; + await closeOmnichannelRoom(room._id); + }); + + it('contact should be marked as active for period', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await sendAgentMessage(room._id); + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('contact').and.to.be.an('object'); + expect(res.body.contact.channels).to.be.an('array').with.lengthOf(1); + expect(res.body.contact.channels[0].name).to.be.equal('api'); + expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(visitor._id); + + expect(res.body.contact).to.have.nested.property('activity').and.to.be.an('array').with.lengthOf(1); + expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM')); + await closeOmnichannelRoom(room._id); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts new file mode 100644 index 0000000..bb2e386 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -0,0 +1,702 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IUser, ILivechatAgent } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, api, credentials } from '../../../data/api-data'; +import { + createAgent, + makeAgentAvailable, + createDepartment, + createVisitor, + createLivechatRoom, + getLivechatRoomInfo, + makeAgentUnavailable, + switchLivechatStatus, +} from '../../../data/livechat/rooms'; +import { getAgent } from '../../../data/livechat/users'; +import { getSettingValueById, updateSetting } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { + before((done) => getCredentials(done)); + + after(async () => { + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + }); + + // Basically: if there's a bot in the department, it should be assigned to the conversation + // No matter what settings workspace has in, as long as the setting to assign new conversations to bots is enabled + describe('Bots - Manual selection', () => { + let botUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + before(async () => { + const bot = await createUser({ roles: ['bot', 'livechat-agent'] }); + const credentials = await login(bot.username, password); + + botUser = { user: bot, credentials }; + }); + before(async () => { + testDepartment = await createDepartment([{ agentId: botUser.user._id }]); + testDepartment2 = await createDepartment(); + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + await updateSetting('Livechat_accept_chats_with_no_agents', true); + }); + + after(async () => { + await deleteUser(botUser.user); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + await updateSetting('Livechat_accept_chats_with_no_agents', false); + }); + + it('should assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy?._id).to.be.equal(botUser.user._id); + }); + it('should not assign conversation to bot if department has no bots', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy).to.be.undefined; + }); + + describe('with setting disabled', () => { + before(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + }); + after(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + }); + + it('should not assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy).to.be.undefined; + }); + }); + }); + + describe('Bots - Auto selection', () => { + let botUser: { user: IUser; credentials: Credentials }; + let otherUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + before(async () => { + const bot = await createUser({ roles: ['bot', 'livechat-agent'] }); + const credentials = await login(bot.username, password); + + const other = await createUser({ roles: ['livechat-agent'] }); + const otherCredentials = await login(other.username, password); + + await makeAgentAvailable(otherCredentials); + + botUser = { user: bot, credentials }; + otherUser = { user: other, credentials: otherCredentials }; + }); + before(async () => { + testDepartment = await createDepartment([{ agentId: botUser.user._id }, { agentId: otherUser.user._id }]); + testDepartment2 = await createDepartment(); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + await updateSetting('Livechat_accept_chats_with_no_agents', true); + }); + + after(async () => { + await deleteUser(botUser.user); + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + await updateSetting('Livechat_accept_chats_with_no_agents', false); + }); + + it('should assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy?._id).to.be.equal(botUser.user._id); + }); + it('should not assign conversation to bot if department has no bots', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy).to.be.undefined; + }); + + describe('with setting disabled', () => { + before(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + }); + after(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + }); + + it('should not assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy?._id).to.be.equal(otherUser.user._id); + }); + }); + }); + + describe('Bots - Auto selection & Waiting queue', () => { + let botUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + before(async () => { + const bot = await createUser({ roles: ['bot', 'livechat-agent'] }); + const credentials = await login(bot.username, password); + + botUser = { user: bot, credentials }; + }); + before(async () => { + testDepartment = await createDepartment([{ agentId: botUser.user._id }]); + testDepartment2 = await createDepartment(); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + await updateSetting('Livechat_waiting_queue', true); + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + await updateSetting('Livechat_accept_chats_with_no_agents', true); + }); + + after(async () => { + await deleteUser(botUser.user); + await updateSetting('Livechat_waiting_queue', false); + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + await updateSetting('Livechat_accept_chats_with_no_agents', false); + }); + + it('should assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy?._id).to.be.equal(botUser.user._id); + }); + it('should not assign conversation to bot if department has no bots', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy).to.be.undefined; + }); + + describe('with setting disabled', () => { + before(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', false); + }); + after(async () => { + await updateSetting('Livechat_assign_new_conversation_to_bot', true); + }); + + it('should not assign conversation to bot', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + expect(room.servedBy).to.be.undefined; + }); + }); + }); + + describe('Auto-Selection', () => { + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + }); + + let testUser: { user: IUser; credentials: Credentials }; + let testUser2: { user: IUser; credentials: Credentials }; + let testUser3: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let visitorEmail: string; + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentUnavailable(credentials2); + + testUser2 = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials3 = await login(user.username, password); + await makeAgentAvailable(credentials3); + + testUser3 = { + user, + credentials: credentials3, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }, { agentId: testUser3.user._id }]); + + const visitorName = faker.person.fullName(); + visitorEmail = faker.internet.email().toLowerCase(); + await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitorName, + emails: [visitorEmail], + phones: [], + contactManager: testUser3.user._id, + }); + }); + + after(async () => + Promise.all([ + deleteUser(testUser.user), + deleteUser(testUser2.user), + deleteUser(testUser3.user), + updateSetting('Livechat_enabled_when_agent_idle', true), + ]), + ); + + it('should route a room to an available agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should not route to a not-available agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should route to contact manager if it is online and Omnichannel_contact_manager_routing is enabled', async () => { + const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); + }); + it('should fail to start a conversation if there is noone available and Livechat_accept_chats_with_no_agents is false', async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await makeAgentUnavailable(testUser.credentials); + await makeAgentUnavailable(testUser3.credentials); + + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + it('should accept a conversation but not route to anyone when Livechat_accept_chats_with_no_agents is true', async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should not allow users to take more than Livechat_maximum_chats_per_agent chats', async () => { + await updateSetting('Livechat_maximum_chats_per_agent', 2); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should ignore disabled users', async () => { + await updateSetting('Livechat_maximum_chats_per_agent', 0); + await setUserActiveStatus(testUser2.user._id, false); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { + await makeAgentAvailable(testUser.credentials); + const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + describe('with setting Omnichannel_contact_manager_routing disabled', () => { + let testDepartment2: ILivechatDepartment; + before(async () => { + await updateSetting('Omnichannel_contact_manager_routing', false); + // We should update the count of the user + }); + after(async () => { + await updateSetting('Omnichannel_contact_manager_routing', true); + }); + + before(async () => { + // This will deprioritize the CM from normal routing. So the test is predictable + // IF the setting is off, the CM will be treated as a normal agent + // IF the setting is on, the CM will be treated as a CM and no matter the count it will get assigned + testDepartment2 = await createDepartment([{ agentId: testUser.user._id }, { agentId: testUser3.user._id, count: 10000 }]); + + const visitorName = faker.person.fullName(); + visitorEmail = faker.internet.email().toLowerCase(); + await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitorName, + emails: [visitorEmail], + phones: [], + contactManager: testUser3.user._id, + }); + }); + + it('should not route to contact manager if it is online but setting is off', async () => { + const visitor = await createVisitor(testDepartment2._id, faker.person.fullName(), visitorEmail); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object').that.has.property('_id').that.is.not.equal(testUser3.user._id); + }); + }); + describe('Auto_Selection - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser3InitialState: ILivechatAgent; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser3InitialState = await getAgent(testUser3.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser3InitialState.statusLivechat, testUser3.credentials); + await setUserStatus(testUser3.credentials, testUser3InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); + }); + }); + }); + }); + describe('Load Balancing', () => { + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Load_Balancing'); + }); + + let testUser: { user: IUser; credentials: Credentials }; + let testUser2: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentUnavailable(credentials2); + + testUser2 = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }, { agentId: testUser2.user._id }]); + }); + + after(async () => { + await deleteUser(testUser.user); + await deleteUser(testUser2.user); + }); + + it('should route a room to an available agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should not route to a not-available agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should route the chat to the less busy agent when both are available', async () => { + await makeAgentAvailable(testUser2.credentials); + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + it('should route again to the less busy agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + describe('Load_Balancing - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); + }); + }); + describe('Load Rotation', () => { + before(async () => { + await updateSetting('Livechat_Routing_Method', 'Load_Rotation'); + }); + + let testUser: { user: IUser; credentials: Credentials }; + let testUser2: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentAvailable(credentials2); + + testUser = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials2 = await login(user.username, password); + await makeAgentUnavailable(credentials2); + + testUser2 = { + user, + credentials: credentials2, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }, { agentId: testUser2.user._id }]); + }); + + after(async () => { + await deleteUser(testUser.user); + await deleteUser(testUser2.user); + }); + + it('should route a room to an available agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should not route chat to an unavailable agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.not.be.equal(testUser2.user._id); + }); + it('should route the chat to the agent with the oldest routing time when both are available', async () => { + await makeAgentAvailable(testUser2.credentials); + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + it('should route again to the agent with the oldest routing time', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + describe('Load_Rotation - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts new file mode 100644 index 0000000..d1d2478 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -0,0 +1,1881 @@ +import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { + ILivechatAgent, + ILivechatVisitor, + IOmnichannelRoom, + IUser, + ILivechatContactVisitorAssociation, +} from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { + closeOmnichannelRoom, + createAgent, + createLivechatRoom, + createLivechatRoomWidget, + createVisitor, + deleteVisitor, + fetchInquiry, + getLivechatRoomInfo, + startANewLivechatRoomAndTakeIt, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent, removeAgent } from '../../../data/livechat/users'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { createUser, deleteUser } from '../../../data/users.helper'; +import { expectInvalidParams } from '../../../data/validation.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +describe('LIVECHAT - contacts', () => { + let agentUser: IUser; + let livechatAgent: ILivechatAgent; + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + agentUser = await createUser(); + livechatAgent = await createAgent(agentUser.username); + }); + + after(async () => { + await removeAgent(livechatAgent._id); + await deleteUser(agentUser); + await restorePermissionToRoles('create-livechat-contact'); + await updateSetting('Livechat_enabled', true); + }); + + describe('[POST] omnichannel/contacts', () => { + before(async () => { + await updatePermission('create-livechat-contact', ['admin']); + }); + + after(async () => { + await restorePermissionToRoles('create-livechat-contact'); + }); + + it('should be able to create a new contact', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it("should return an error if user doesn't have 'create-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('create-livechat-contact'); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('create-livechat-contact'); + }); + + it('should return an error if contact manager not exists', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + }); + + it('should return an error if contact manager is not a livechat-agent', async () => { + const normalUser = await createUser(); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: normalUser._id, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + + await deleteUser(normalUser); + }); + + it('should be able to create a new contact with a contact manager', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: livechatAgent._id, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + describe('Custom Fields', () => { + let contactId: string; + before(async () => { + const defaultProps = { + scope: 'visitor' as const, + visibility: 'public', + type: 'input', + regexp: '^[0-9]+$', + searchable: true, + public: true, + }; + + await Promise.all([ + createCustomField({ + ...defaultProps, + field: 'cf1', + label: 'Custom Field 1', + required: true, + }), + createCustomField({ + ...defaultProps, + field: 'cf2', + label: 'Custom Field 2', + required: false, + }), + createCustomField({ + ...defaultProps, + field: 'cfOptional', + label: 'Optional Custom Field', + required: false, + }), + ]); + }); + + after(async () => { + await deleteCustomField('cf1'); + }); + + it('should validate custom fields correctly', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: '123', + }, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it('should return an error for missing required custom field', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: {}, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + + it('should return an error for invalid custom field value', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: 'invalid', + }, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + + it('should keep a legacy custom field, but not update it, nor throw an error if it is specified on update', async () => { + const createRes = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: '123', + cf2: '456', + }, + }); + expect(createRes.body).to.have.property('success', true); + expect(createRes.body).to.have.property('contactId').that.is.a('string'); + contactId = createRes.body.contactId; + + await deleteCustomField('cf2'); + + const updateRes = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '456', + cf2: '789', + }, + }); + expect(updateRes.body).to.have.property('success', true); + expect(updateRes.body).to.have.property('contact').that.is.an('object'); + expect(updateRes.body.contact).to.have.property('_id', contactId); + expect(updateRes.body.contact).to.have.property('customFields').that.is.an('object'); + expect(updateRes.body.contact.customFields).to.have.property('cf1', '456'); + expect(updateRes.body.contact.customFields).to.have.property('cf2', '456'); + }); + + it('should keep a legacy custom field and not throw an error if it is not specified on update', async () => { + const updateRes = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '789', + cfOptional: '567', + }, + }); + expect(updateRes.body).to.have.property('success', true); + expect(updateRes.body).to.have.property('contact').that.is.an('object'); + expect(updateRes.body.contact).to.have.property('_id', contactId); + expect(updateRes.body.contact).to.have.property('customFields').that.is.an('object'); + expect(updateRes.body.contact.customFields).to.have.property('cf1', '789'); + expect(updateRes.body.contact.customFields).to.have.property('cfOptional', '567'); + expect(updateRes.body.contact.customFields).to.have.property('cf2', '456'); + }); + + it('should keep a legacy custom field, but remove an optional registered custom field if it is not specified on update', async () => { + const updateRes = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '789', + }, + }); + expect(updateRes.body).to.have.property('success', true); + expect(updateRes.body).to.have.property('contact').that.is.an('object'); + expect(updateRes.body.contact).to.have.property('_id', contactId); + expect(updateRes.body.contact).to.have.property('customFields').that.is.an('object'); + expect(updateRes.body.contact.customFields).to.have.property('cf1', '789'); + expect(updateRes.body.contact.customFields).to.have.property('cf2', '456'); + expect(updateRes.body.contact.customFields).to.not.have.property('cfOptional'); + }); + + it('should throw an error if trying to update a custom field that is not registered in the workspace and does not exist in the contact', async () => { + const updateRes = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '123', + cf3: 'invalid', + }, + }); + expect(updateRes.body).to.have.property('success', false); + expect(updateRes.body).to.have.property('error'); + expect(updateRes.body.error).to.be.equal('Custom field cf3 is not allowed'); + }); + }); + + describe('Fields Validation', () => { + it('should return an error if name is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'name' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'emails' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'phones' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: 'invalid', + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be array [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [{ invalid: true }], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [{ invalid: true }], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if additional fields are provided', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + additional: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must NOT have additional properties [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + }); + + describe('[POST] omnichannel/contacts.update', () => { + let contactId: string; + + before(async () => { + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + contactId = body.contactId; + }); + + after(async () => { + await restorePermissionToRoles('update-livechat-contact'); + }); + + it('should be able to update a contact', async () => { + const name = faker.person.fullName(); + const emails = [faker.internet.email().toLowerCase()]; + const phones = [faker.phone.number()]; + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + name, + emails, + phones, + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.name).to.be.equal(name); + expect(res.body.contact.emails).to.be.deep.equal([ + { + address: emails[0], + }, + ]); + expect(res.body.contact.phones).to.be.deep.equal([ + { + phoneNumber: phones[0], + }, + ]); + }); + + it('should set the unknown field to false when updating a contact', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + name: faker.person.fullName(), + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.unknown).to.be.equal(false); + }); + + it('should be able to update the contact manager', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + contactManager: livechatAgent._id, + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.contactManager).to.be.equal(livechatAgent._id); + }); + + it('should be able to clear the contact manager', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + contactManager: '', + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact).to.not.have.property('contactManager'); + }); + + it('should return an error if contact does not exist', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId: 'invalid', + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + + it('should return an error if contact manager not exists', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + contactManager: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + }); + + describe('Permissions', () => { + before(async () => { + await removePermissionFromAllRoles('update-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('update-livechat-contact'); + }); + + it("should return an error if user doesn't have 'update-livechat-contact' permission", async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('Custom Fields', () => { + before(async () => { + await createCustomField({ + field: 'cf1', + label: 'Custom Field 1', + scope: 'visitor', + visibility: 'public', + type: 'input', + required: true, + regexp: '^[0-9]+$', + searchable: true, + public: true, + }); + }); + + after(async () => { + await deleteCustomField('cf1'); + }); + + it('should validate custom fields correctly', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '123', + }, + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + }); + + it('should return an error for invalid custom field value', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: 'invalid', + }, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + + it('should return an error if additional custom fields are provided', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '123', + cf2: 'invalid', + }, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Custom field cf2 is not allowed'); + }); + }); + + describe('Fields Validation', () => { + it('should return an error if contactId is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'contactId' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + emails: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be array [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + emails: [{ invalid: true }], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is not an array', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + phones: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be array [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + phones: [{ invalid: true }], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if additional fields are provided', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + unknown: true, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must NOT have additional properties [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + }); + + describe('[POST] omnichannel/contacts.conflicts', () => { + let token: string; + let contactId: string; + + before(async () => { + const visitor = await createVisitor(); + token = visitor.token; + + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitor.name, + emails: [visitor.visitorEmails?.[0].address], + phones: [visitor.phone?.[0].phoneNumber], + }); + contactId = body.contactId; + + await request.get(api('livechat/room')).query({ token: visitor.token }); + + await createCustomField({ + field: 'cf1', + label: 'Custom Field 1', + scope: 'visitor', + visibility: 'public', + type: 'input', + required: true, + regexp: '^[0-9]+$', + searchable: true, + public: true, + }); + }); + + after(async () => { + await restorePermissionToRoles('update-livechat-contact'); + await deleteCustomField('cf1'); + }); + + it('should resolve the contact custom field conflict', async () => { + await request + .post(api('livechat/custom.field')) + .send({ token, key: 'cf1', value: '123', overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('livechat/custom.field')) + .send({ token, key: 'cf1', value: '456', overwrite: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const res = await request + .post(api('omnichannel/contacts.conflicts')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '123', + }, + }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result'); + + expect(res.body.result).to.have.property('customFields'); + expect(res.body.result.customFields).to.have.property('cf1', '123'); + }); + + it('should be able to clear the contact manager when resolving conflicts', async () => { + await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + contactManager: livechatAgent._id, + }) + .expect(200); + + // Create a conflict + await request.post(api('livechat/custom.field')).send({ token, key: 'cf1', value: '111', overwrite: true }).expect(200); + + await request.post(api('livechat/custom.field')).send({ token, key: 'cf1', value: '222', overwrite: false }).expect(200); + + // Verify the contact has a contact manager and conflicts + const contactBefore = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId }).expect(200); + expect(contactBefore.body.contact).to.have.property('contactManager', livechatAgent._id); + expect(contactBefore.body.contact).to.have.property('conflictingFields'); + expect(contactBefore.body.contact.conflictingFields).to.have.lengthOf.greaterThan(0); + + const res = await request + .post(api('omnichannel/contacts.conflicts')) + .set(credentials) + .send({ + contactId, + contactManager: '', + customFields: { + cf1: '111', + }, + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result'); + expect(res.body.result).to.not.have.property('contactManager'); + expect(res.body.result).to.have.property('customFields'); + expect(res.body.result.customFields).to.have.property('cf1', '111'); + + const contactAfter = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId }).expect(200); + expect(contactAfter.body.contact).to.not.have.property('contactManager'); + }); + }); + + describe('Contact Rooms', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + agent = await createAnOnlineAgent(); + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + await deleteUser(agent.user); + }); + + it('should create a contact and assign it to the room', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + expect(room).to.have.property('contactId').that.is.a('string'); + }); + + it('should create a room using the pre-created contact', async () => { + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + + const contact = { + name: 'Contact Name', + emails: [email], + phones: [phone], + contactManager: agentUser?._id, + }; + + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + const { contactId } = body; + + const visitor = await createVisitor(undefined, 'Visitor Name', email, phone); + + const room = await createLivechatRoom(visitor.token); + + expect(room).to.have.property('contactId', contactId); + expect(room).to.have.property('fname', 'Contact Name'); + }); + + it('should update room names when a contact name changes', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + expect(room).to.have.property('contactId').that.is.a('string'); + expect(room.fname).to.not.be.equal('New Contact Name'); + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId: room.contactId, + name: 'New Contact Name', + }); + + expect(res.status).to.be.equal(200); + + const sameRoom = await createLivechatRoom(visitor.token, { rid: room._id }); + expect(sameRoom._id).to.be.equal(room._id); + expect(sameRoom.fname).to.be.equal('New Contact Name'); + }); + + it('should update room subscriptions when a contact name changes', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + const { room, visitor } = response; + const newName = faker.person.fullName(); + + expect(room).to.have.property('contactId').that.is.a('string'); + expect(room.fname).to.be.equal(visitor.name); + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId: room.contactId, + name: newName, + }); + + expect(res.status).to.be.equal(200); + + const sameRoom = await createLivechatRoom(visitor.token, { rid: room._id }); + expect(sameRoom._id).to.be.equal(room._id); + expect(sameRoom.fname).to.be.equal(newName); + + const subscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(agent.credentials) + .query({ roomId: room._id }) + .expect('Content-Type', 'application/json'); + const { subscription } = subscriptionResponse.body; + expect(subscription).to.have.property('v').that.is.an('object'); + expect(subscription.v).to.have.property('_id', visitor._id); + expect(subscription).to.have.property('name', newName); + expect(subscription).to.have.property('fname', newName); + }); + + it('should update inquiry when a contact name changes', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + expect(room).to.have.property('contactId').that.is.a('string'); + expect(room.fname).to.not.be.equal('New Contact Name'); + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId: room.contactId, + name: 'Edited Contact Name Inquiry', + }); + expect(res.status).to.be.equal(200); + + const roomInquiry = await fetchInquiry(room._id); + expect(roomInquiry).to.have.property('name', 'Edited Contact Name Inquiry'); + }); + }); + + describe('[GET] omnichannel/contacts.get', () => { + let contactId: string; + let contactId2: string; + + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + + const contact = { + name: faker.person.fullName(), + emails: [email], + phones: [phone], + contactManager: agentUser?._id, + }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + contactId = body.contactId; + + const { body: contact2Body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: agentUser?._id, + }); + contactId2 = contact2Body.contactId; + + const visitor = await createVisitor(undefined, contact.name, email, phone); + + await createLivechatRoom(visitor.token); + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should be able get a contact by id', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('createdAt'); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact.name).to.be.equal(contact.name); + expect(res.body.contact.emails).to.be.deep.equal([ + { + address: contact.emails[0], + }, + ]); + expect(res.body.contact.phones).to.be.deep.equal([{ phoneNumber: contact.phones[0] }]); + expect(res.body.contact.contactManager).to.be.equal(contact.contactManager); + }); + + it('should return 404 if contact does not exist using contactId', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: 'invalid' }); + + expect(res.status).to.be.equal(404); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Resource not found'); + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should return an error if contactId and visitor association is missing', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials); + + expectInvalidParams(res, ["must have required property 'contactId' [invalid-params]"]); + }); + + describe('Contact Channels', () => { + let visitor: ILivechatVisitor; + + beforeEach(async () => { + visitor = await createVisitor(); + }); + + afterEach(async () => { + await deleteVisitor(visitor.token); + }); + + it('should add a channel to a contact when creating a new room', async () => { + const room = await createLivechatRoom(visitor.token); + + expect(room.contactId).to.be.a('string'); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact.channels).to.be.an('array'); + expect(res.body.contact.channels.length).to.be.equal(1); + expect(res.body.contact.channels[0].name).to.be.equal('api'); + expect(res.body.contact.channels[0].verified).to.be.false; + expect(res.body.contact.channels[0].blocked).to.be.false; + expect(res.body.contact.channels[0].visitor) + .to.be.an('object') + .that.is.deep.equal({ + visitorId: visitor._id, + source: { + type: 'api', + }, + }); + }); + + it('should not add a channel if visitor already has one with same type', async () => { + const room = await createLivechatRoom(visitor.token); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact.channels).to.be.an('array'); + expect(res.body.contact.channels.length).to.be.equal(1); + + await closeOmnichannelRoom(room._id); + await request.get(api('livechat/room')).query({ token: visitor.token }); + + const secondResponse = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + + expect(secondResponse.status).to.be.equal(200); + expect(secondResponse.body).to.have.property('success', true); + expect(secondResponse.body.contact.channels).to.be.an('array'); + expect(secondResponse.body.contact.channels.length).to.be.equal(1); + }); + }); + + describe('Last Chat', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor._id); + }); + + it('should have assigned a contactId to the new room', async () => { + expect(room.contactId).to.be.a('string'); + }); + + it('should return the last chat', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('lastChat'); + expect(res.body.contact.lastChat).to.have.property('ts'); + expect(res.body.contact.lastChat._id).to.be.equal(room._id); + expect(res.body.contact.channels[0].lastChat).to.have.property('ts'); + expect(res.body.contact.channels[0].lastChat._id).to.be.equal(room._id); + }); + + it('should not return the last chat if contact never chatted', async () => { + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: contactId2 }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact).to.have.property('_id', contactId2); + expect(res.body.contact).to.not.have.property('lastChat'); + }); + }); + }); + + describe('[POST] omnichannel/contacts.delete', () => { + let contactId: string; + let roomId: string; + + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + + const contact = { + name: faker.person.fullName(), + emails: [email], + phones: [phone], + contactManager: agentUser?._id, + }; + + before(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true); + + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + contactId = body.contactId; + + const visitor = await createVisitor(undefined, contact.name, email, phone); + + const room = await createLivechatRoom(visitor.token); + roomId = room._id; + }); + + after(async () => { + await closeOmnichannelRoom(roomId); + }); + + it('should be able to disable a contact by its id', async () => { + const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId }); + + expect(response.status).to.be.equal(200); + expect(response.body).to.have.property('success', true); + }); + + it('should return an error if the contact is not found', async () => { + const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId }); + + expect(response.status).to.be.equal(400); + expect(response.body).to.have.property('success', false); + expect(response.body.error).to.be.equal('error-contact-not-found'); + }); + + describe('[PERMISSIONS] omnichannel/contacts.delete', () => { + before(async () => { + await removePermissionFromAllRoles('delete-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('delete-livechat-contact'); + }); + + it("should return an error if user doesn't have 'delete-livechat-contact' permission", async () => { + const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId }); + + expect(response.status).to.be.equal(403); + expect(response.body).to.have.property('success', false); + expect(response.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + describe('[GDPR Setting] omnichannel/contacts.delete', () => { + before(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', false); + + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + contactId = body.contactId; + + const visitor = await createVisitor(undefined, contact.name, email, phone); + + const room = await createLivechatRoom(visitor.token); + roomId = room._id; + }); + + after(async () => { + await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true); + }); + + it("should not delete the contact if the GDPR setting isn't enabled and contact has open rooms", async () => { + const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId }); + + expect(response.status).to.be.equal(400); + expect(response.body).to.have.property('success', false); + expect(response.body.error).to.be.equal('error-contact-has-open-rooms'); + + const contactCheck = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId }); + expect(contactCheck.status).to.be.equal(200); + }); + }); + }); + + describe('[GET] omnichannel/contacts.checkExistence', () => { + let contactId: string; + let roomId: string; + + const email = faker.internet.email().toLowerCase(); + const phone = faker.phone.number(); + + const contact = { + name: faker.person.fullName(), + emails: [email], + phones: [phone], + contactManager: agentUser?._id, + }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ ...contact }); + contactId = body.contactId; + + const visitor = await createVisitor(undefined, contact.name, email, phone); + + const room = await createLivechatRoom(visitor.token); + roomId = room._id; + }); + + after(async () => Promise.all([restorePermissionToRoles('view-livechat-contact'), closeOmnichannelRoom(roomId)])); + + it('should confirm a contact exists when checking by contact id', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', true); + }); + + it('should confirm a contact does not exist when checking by contact id', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId: 'invalid-contact-id' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', false); + }); + + it('should confirm a contact exists when checking by email', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ email }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', true); + }); + + it('should confirm a contact does not exist when checking by email', async () => { + const res = await request + .get(api(`omnichannel/contacts.checkExistence`)) + .set(credentials) + .query({ email: 'invalid-email@example.com' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', false); + }); + + it('should confirm a contact exists when checking by phone', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ phone }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', true); + }); + + it('should confirm a contact does not exist when checking by phone', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ phone: 'invalid-phone' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', false); + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should return an error if all query params are missing', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials); + + expectInvalidParams(res, [ + "must have required property 'contactId'", + "must have required property 'email'", + "must have required property 'phone'", + 'must match exactly one schema in oneOf [invalid-params]', + ]); + }); + + it('should return an error if more than one field is provided', async () => { + const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId, email, phone }); + + expectInvalidParams(res, [ + 'must NOT have additional properties', + 'must NOT have additional properties', + 'must NOT have additional properties', + 'must match exactly one schema in oneOf [invalid-params]', + ]); + }); + }); + + describe('[GET] omnichannel/contacts.search', () => { + let contactId: string; + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + const contact = { + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: agentUser?._id, + }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + const { body } = await request.post(api('omnichannel/contacts')).set(credentials).send(contact); + contactId = body.contactId; + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + await closeOmnichannelRoom(room._id); + await deleteVisitor(visitor._id); + }); + + it('should be able to list all contacts', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.greaterThan(0); + expect(res.body.count).to.be.an('number'); + expect(res.body.total).to.be.an('number'); + expect(res.body.offset).to.be.an('number'); + }); + + it('should return only known contacts by default', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts[0].unknown).to.be.false; + }); + + it('should be able to filter contacts by unknown field', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ unknown: true }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts[0].unknown).to.be.true; + }); + + it('should return only contacts that match the searchText using email', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.emails[0] }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].emails[0].address).to.be.equal(contact.emails[0]); + }); + + it('should return only contacts that match the searchText using phone number', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.phones[0] }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].phones[0].phoneNumber).to.be.equal(contact.phones[0]); + }); + + it('should return only contacts that match the searchText using name', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.name }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].emails[0].address).to.be.equal(contact.emails[0]); + }); + + it('should return an empty list if no contacts exist', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: 'invalid' }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(0); + expect(res.body.total).to.be.equal(0); + }); + + describe('Permissions', () => { + before(async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[GET] omnichannel/contacts.history', () => { + let visitor: ILivechatVisitor; + let room1: IOmnichannelRoom; + let room2: IOmnichannelRoom; + + before(async () => { + visitor = await createVisitor(); + room1 = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room1._id); + room2 = await createLivechatRoomWidget(visitor.token); + }); + + after(async () => { + await closeOmnichannelRoom(room2._id); + await deleteVisitor(visitor._id); + }); + + it('should be able to list a contact history', async () => { + const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: room1.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.count).to.be.an('number'); + expect(res.body.offset).to.be.an('number'); + + expect(res.body.history[0]).to.have.property('_id', room1._id); + expect(res.body.history[0]).to.have.property('closedAt'); + expect(res.body.history[0]).to.have.property('closedBy'); + expect(res.body.history[0]).to.have.property('closer', 'user'); + expect(res.body.history[0].source).to.have.property('type', 'api'); + }); + + it('should be able to filter a room by the source', async () => { + const res = await request + .get(api(`omnichannel/contacts.history`)) + .set(credentials) + .query({ contactId: room1.contactId, source: 'api' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.count).to.be.an('number'); + expect(res.body.offset).to.be.an('number'); + expect(res.body.history[0].source).to.have.property('type', 'api'); + }); + + it('should return an empty list if contact does not have history', async () => { + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: body.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.history).to.be.an('array'); + expect(res.body.history.length).to.be.equal(0); + expect(res.body.total).to.be.equal(0); + }); + + it('should return an error if contacts not exists', async () => { + const res = await request.get(api(`omnichannel/contacts.history`)).set(credentials).query({ contactId: 'invalid' }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + }); + + describe('[GET] omnichannel/contacts.channels', () => { + let contactId: string; + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + contactId = room.contactId as string; + }); + + after(async () => { + await deleteVisitor(visitor._id); + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should be able get the channels of a contact by his id', async () => { + const res = await request.get(api(`omnichannel/contacts.channels`)).set(credentials).query({ contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.channels).to.be.an('array'); + expect(res.body.channels.length).to.be.equal(1); + expect(res.body.channels[0]).to.have.property('name', 'api'); + expect(res.body.channels[0]) + .to.have.property('visitor') + .that.is.an('object') + .and.deep.equal({ + visitorId: visitor._id, + source: { + type: 'api', + }, + }); + expect(res.body.channels[0]).to.have.property('verified', false); + expect(res.body.channels[0]).to.have.property('blocked', false); + expect(res.body.channels[0]).to.have.property('lastChat'); + }); + + it('should return an empty array if contact does not have channels', async () => { + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ name: faker.person.fullName(), emails: [faker.internet.email().toLowerCase()], phones: [faker.phone.number()] }); + const res = await request.get(api(`omnichannel/contacts.channels`)).set(credentials).query({ contactId: body.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.channels).to.be.an('array'); + expect(res.body.channels.length).to.be.equal(0); + }); + + it('should return an empty array if contact does not exist', async () => { + const res = await request.get(api(`omnichannel/contacts.channels`)).set(credentials).query({ contactId: 'invalid' }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.channels).to.be.an('array'); + expect(res.body.channels.length).to.be.equal(0); + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + + const res = await request.get(api(`omnichannel/contacts.channels`)).set(credentials).query({ contactId }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should return an error if contactId is missing', async () => { + const res = await request.get(api(`omnichannel/contacts.channels`)).set(credentials); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'contactId' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + + (IS_EE ? describe : describe.skip)('[POST] omnichannel/contacts.block', async () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + let association: ILivechatContactVisitorAssociation; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + + association = { + visitorId: visitor._id, + source: { + type: room.source.type, + id: room.source.id, + }, + }; + }); + + after(async () => { + await deleteVisitor(visitor.token); + }); + + it('should be able to block a contact channel', async () => { + const res = await request.post(api('omnichannel/contacts.block')).set(credentials).send({ visitor: association }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + + const { body } = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId: room.contactId }); + + expect(body.contact.channels).to.be.an('array'); + expect(body.contact.channels.length).to.be.equal(1); + expect(body.contact.channels[0].blocked).to.be.true; + }); + + it('should return an error if contact does not exist', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { ...association, visitorId: 'invalid' } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + + it('should return an error if contact does not exist 2', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { ...association, source: { type: 'sms' } } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + + it('should close room when contact is blocked', async () => { + const res = await request.post(api('omnichannel/contacts.block')).set(credentials).send({ visitor: association }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + + const closedRoom = await getLivechatRoomInfo(room._id); + + expect(closedRoom).to.have.property('closedAt'); + expect(closedRoom).to.have.property('closedBy'); + expect(closedRoom.lastMessage?.msg).to.be.equal('This channel has been blocked'); + }); + + it('should not be able to open a room when contact is blocked', async () => { + await request.post(api('omnichannel/contacts.block')).set(credentials).send({ visitor: association }); + + const createRoomResponse = await request.get(api('livechat/room')).query({ token: visitor.token }).set(credentials); + + expect(createRoomResponse.status).to.be.equal(400); + expect(createRoomResponse.body).to.have.property('success', false); + expect(createRoomResponse.body).to.have.property('error', 'error-contact-channel-blocked'); + }); + + it('should return an error if visitor is missing', async () => { + const res = await request.post(api('omnichannel/contacts.block')).set(credentials).send({}); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'visitor' [invalid-params]"); + }); + + it('should return an error if visitorId is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { source: association.source } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'visitorId' [invalid-params]"); + }); + + it('should return an error if source is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { visitorId: association.visitorId } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'source' [invalid-params]"); + }); + + it('should return an error if source type is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { visitorId: association.visitorId, source: { id: association.source.id } } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'type' [invalid-params]"); + }); + + describe('Permissions', () => { + before(async () => { + await removePermissionFromAllRoles('block-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('block-livechat-contact'); + }); + + it("should return an error if user doesn't have 'block-livechat-contact' permission", async () => { + const res = await request.post(api('omnichannel/contacts.block')).set(credentials).send({ visitor: association }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('[POST] omnichannel/contacts.unblock', async () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + let association: ILivechatContactVisitorAssociation; + + before(async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + association = { visitorId: visitor._id, source: { type: room.source.type, id: room.source.id } }; + }); + + after(async () => { + await deleteVisitor(visitor.token); + }); + + it('should be able to unblock a contact channel', async () => { + await request.post(api('omnichannel/contacts.block')).set(credentials).send({ visitor: association }); + + const { body } = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId: room.contactId }); + + expect(body.contact.channels).to.be.an('array'); + expect(body.contact.channels.length).to.be.equal(1); + expect(body.contact.channels[0].blocked).to.be.true; + + const res = await request.post(api('omnichannel/contacts.unblock')).set(credentials).send({ visitor: association }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + + const { body: body2 } = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId: room.contactId }); + + expect(body2.contact.channels).to.be.an('array'); + expect(body2.contact.channels.length).to.be.equal(1); + expect(body2.contact.channels[0].blocked).to.be.false; + }); + + it('should return an error if contact does not exist', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { ...association, visitorId: 'invalid' } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + + it('should return an error if contact does not exist 2', async () => { + const res = await request + .post(api('omnichannel/contacts.block')) + .set(credentials) + .send({ visitor: { ...association, source: { type: 'sms', id: room.source.id } } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-contact-not-found'); + }); + + it('should return an error if visitor is missing', async () => { + const res = await request.post(api('omnichannel/contacts.unblock')).set(credentials).send({}); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'visitor' [invalid-params]"); + }); + + it('should return an error if visitorId is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.unblock')) + .set(credentials) + .send({ visitor: { source: association.source } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'visitorId' [invalid-params]"); + }); + + it('should return an error if source is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.unblock')) + .set(credentials) + .send({ visitor: { visitorId: association.visitorId } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'source' [invalid-params]"); + }); + + it('should return an error if source type is missing', async () => { + const res = await request + .post(api('omnichannel/contacts.unblock')) + .set(credentials) + .send({ visitor: { visitorId: association.visitorId, source: { id: association.source.id } } }); + + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal("must have required property 'type' [invalid-params]"); + }); + + describe('Permissions', () => { + before(async () => { + await removePermissionFromAllRoles('unblock-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('unblock-livechat-contact'); + }); + + it("should return an error if user doesn't have 'unblock-livechat-contact' permission", async () => { + const res = await request.post(api('omnichannel/contacts.unblock')).set(credentials).send({ visitor: association }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts b/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts new file mode 100644 index 0000000..2ba82c4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts @@ -0,0 +1,297 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { + getSettingValueById, + removePermissionFromAllRoles, + restorePermissionToRoles, + updateSetting, +} from '../../../data/permissions.helper'; + +describe('LIVECHAT - custom fields', () => { + let settingLivechatEnabled: boolean; + + before((done) => getCredentials(done)); + + before(async () => { + settingLivechatEnabled = (await getSettingValueById('Livechat_enabled')) as boolean; + await updateSetting('Livechat_enabled', true); + }); + + after(async () => { + await updateSetting('Livechat_enabled', settingLivechatEnabled); + }); + + describe('livechat/custom-fields.save', () => { + let customFieldId: string; + + after(async () => { + if (customFieldId) { + await deleteCustomField(customFieldId); + } + }); + + describe('Authentication/Authorization', () => { + it('should return an "unauthenticated error" when user is not logged in', async () => { + await request + .post(api('livechat/custom-fields.save')) + .send({ + customFieldId: null, + customFieldData: { + field: 'test_field', + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(401); + }); + + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: 'test_field', + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + await restorePermissionToRoles('view-livechat-manager'); + }); + }); + + describe('Create custom field', () => { + afterEach(async () => { + if (customFieldId) { + await deleteCustomField(customFieldId); + customFieldId = ''; + } + }); + + it('should create a new custom field with minimal required fields', async () => { + const fieldName = `field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('_id'); + expect(res.body.customField).to.have.property('label', 'Test Field'); + expect(res.body.customField).to.have.property('scope', 'visitor'); + expect(res.body.customField).to.have.property('visibility', 'public'); + customFieldId = res.body.customField._id; + }); + }); + + it('should create a new custom field with scope "room"', async () => { + const fieldName = `room_field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Room Test Field', + scope: 'room', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('scope', 'room'); + customFieldId = res.body.customField._id; + }); + }); + + it('should create a new custom field with all optional fields', async () => { + const fieldName = `full_field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Full Test Field', + scope: 'visitor', + visibility: 'public', + type: 'input', + regexp: '^[A-Za-z]+$', + required: true, + defaultValue: 'default', + options: 'option1,option2,option3', + public: true, + searchable: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('type', 'input'); + expect(res.body.customField).to.have.property('regexp', '^[A-Za-z]+$'); + expect(res.body.customField).to.have.property('required', true); + expect(res.body.customField).to.have.property('defaultValue', 'default'); + expect(res.body.customField).to.have.property('options', 'option1,option2,option3'); + expect(res.body.customField).to.have.property('public', true); + expect(res.body.customField).to.have.property('searchable', true); + customFieldId = res.body.customField._id; + }); + }); + + it('should fail when trying to create a custom field with a field name that already exists', async () => { + const fieldName = `duplicate_field_${Date.now()}`; + + // Create the first custom field + const { body } = await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'First Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect(200); + + customFieldId = body.customField._id; + + // Try to create another with the same field name + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Second Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + + describe('Update custom field', () => { + let existingField: ILivechatCustomField; + + before(async () => { + const fieldName = `update_test_field_${Date.now()}`; + existingField = await createCustomField({ + searchable: true, + field: fieldName, + label: 'Original Label', + defaultValue: 'original_default', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + }); + + after(async () => { + if (existingField?._id) { + await deleteCustomField(existingField._id); + } + }); + + it('should fail when trying to update a non-existent custom field', async () => { + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: 'non-existent-id', + customFieldData: { + field: 'test_field', + label: 'Updated Label', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should update an existing custom field with all optional fields', async () => { + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: existingField._id, + customFieldData: { + field: existingField._id, + label: 'Fully Updated Field', + scope: existingField.scope, + visibility: 'public', + type: 'select', + regexp: '^[0-9]+$', + required: true, + defaultValue: 'new_default', + options: 'opt1,opt2,opt3', + public: true, + searchable: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.customField).to.have.property('label', 'Fully Updated Field'); + expect(res.body.customField).to.have.property('type', 'select'); + expect(res.body.customField).to.have.property('regexp', '^[0-9]+$'); + expect(res.body.customField).to.have.property('required', true); + expect(res.body.customField).to.have.property('defaultValue', 'new_default'); + expect(res.body.customField).to.have.property('options', 'opt1,opt2,opt3'); + expect(res.body.customField).to.have.property('public', true); + expect(res.body.customField).to.have.property('searchable', false); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/login-code.ts b/apps/meteor/tests/end-to-end/api/login-code.ts new file mode 100644 index 0000000..1215b5b --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/login-code.ts @@ -0,0 +1,140 @@ +import { randomBytes } from 'crypto'; + +import type { ILoginCode, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { api, request, getCredentials } from '../../data/api-data'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser } from '../../data/users.helper'; +import { URL_MONGODB } from '../../e2e/config/constants'; + +const CODE_TTL_MS = 60 * 1000; + +async function insertLoginCode(connection: MongoClient, userId: string, insertExpiredToken: boolean = false): Promise { + const now = new Date(); + const code = randomBytes(32).toString('hex'); + + let expireAt = new Date(now.getTime() + CODE_TTL_MS); + + if (insertExpiredToken) { + expireAt = new Date(now.getTime() - 1); + } + + await connection.db().collection('rocketchat_login_codes').insertOne({ + _id: code, + userId, + createdAt: now, + expireAt, + }); + + return code; +} + +describe('[Login Codes]', () => { + let connection: MongoClient; + + before((done) => getCredentials(done)); + + before(async () => { + connection = await MongoClient.connect(URL_MONGODB); + }); + + after(async () => { + await connection.close(); + }); + + describe('POST [/loginCode.redeem]', () => { + let testUser: TestUser; + + before(async () => { + testUser = await createUser(); + }); + + after(async () => { + await deleteUser(testUser); + }); + + it('should fail when code does not exist in the database', async () => { + await request + .post(api('loginCode.redeem')) + .send({ code: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fail when code is an empty string', async () => { + await request + .post(api('loginCode.redeem')) + .send({ code: '' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should return a loginToken and userId for a valid code', async () => { + const code = await insertLoginCode(connection, testUser._id); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('loginToken').that.is.a('string').and.is.not.empty; + expect(res.body).to.have.property('userId', testUser._id); + }); + }); + + it('should invalidate a code after it has been redeemed', async () => { + const code = await insertLoginCode(connection, testUser._id); + + await request.post(api('loginCode.redeem')).send({ code }).expect(200); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-code'); + }); + }); + + it('should return a loginToken that can be used for subsequent authenticated requests', async () => { + const code = await insertLoginCode(connection, testUser._id); + + const redeemRes = await request.post(api('loginCode.redeem')).send({ code }).expect(200); + + const { loginToken, userId } = redeemRes.body; + + await request + .get(api('me')) + .set('X-Auth-Token', loginToken) + .set('X-User-Id', userId) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', testUser._id); + }); + }); + + it('should fail when code is expired', async () => { + const code = await insertLoginCode(connection, testUser._id, true); + + await request + .post(api('loginCode.redeem')) + .send({ code }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-code'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/media-calls.ts b/apps/meteor/tests/end-to-end/api/media-calls.ts new file mode 100644 index 0000000..f4252a8 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/media-calls.ts @@ -0,0 +1,114 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Media Calls]', () => { + let user2: IUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user2 = await createUser(); + userCredentials = await login(user2.username, password); + }); + + after(() => deleteUser(user2)); + + describe('[/media-calls.info]', () => { + it('should return valid internal call information', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'rocketchat.internal.call.test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', 'rocketchat.internal.call.test'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should return valid external call information', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'rocketchat.external.call.test.outbound', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { call } = res.body; + expect(call).to.have.property('_id', 'rocketchat.external.call.test.outbound'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call).to.have.property('sipCallId', 'sipCallId3'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'sip'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(1); + }); + }); + + it('should not return invalid calls', async () => { + await request + .get(api('media-calls.info')) + .set(credentials) + .query({ + callId: 'invalid.call', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should not return calls from other users', async () => { + await request + .get(api('media-calls.info')) + .set(userCredentials) + .query({ + callId: 'rocketchat.internal.call.test', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts new file mode 100644 index 0000000..d24508a --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -0,0 +1,3809 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IMessage, IOmnichannelRoom, IRoom, IThreadMessage, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { retry } from './helpers/retry'; +import { api, credentials, getCredentials, methodCall, methodCallAnon, request } from '../../data/api-data'; +import { sendMessage, sendSimpleMessage } from '../../data/chat.helper'; +import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; +import { closeOmnichannelRoom, createAgent, createLivechatRoom, createVisitor, makeAgentAvailable } from '../../data/livechat/rooms'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +describe('Meteor.methods', () => { + before((done) => getCredentials(done)); + + describe('[@getThreadMessages]', () => { + let rid: IRoom['_id']; + let firstMessage: IMessage; + + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + firstMessage = res.body.message; + }) + .end(done); + }); + + before('send sample message into thread', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + tmid: firstMessage._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('getThreadMessages')) + .send({ + message: JSON.stringify({ + method: 'getThreadMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return message thread', async () => { + const res = await request + .post(methodCall('getThreadMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getThreadMessages', + params: [{ tmid: firstMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + }); + }); + + describe('[@getReadReceipts]', () => { + it('should fail if not logged in', async () => { + await request + .post(methodCall('getReadReceipts')) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: 'test' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + (!IS_EE ? describe : describe.skip)('[@getReadReceipts] CE', () => { + it('should fail if there is no enterprise license', async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: 'test' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + + const data = JSON.parse(res.body.message); + expect(data).to.have.property('error').that.is.an('object'); + expect(data.error).to.have.property('error', 'error-action-not-allowed'); + expect(data.error).to.have.property('message', 'This is an enterprise feature [error-action-not-allowed]'); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('[@getReadReceipts] EE', () => { + let user: TestUser; + let userCredentials: Credentials; + let room: IRoom; + let firstMessage: IMessage; + let firstThreadMessage: IThreadMessage; + + const roomName = `methods-test-channel-${Date.now()}`; + before(async () => { + await Promise.all([updateSetting('Message_Read_Receipt_Enabled', true), updateSetting('Message_Read_Receipt_Store_Users', true)]); + + user = await createUser(); + userCredentials = await login(user.username, password); + room = (await createRoom({ type: 'p', name: roomName, members: [user.username] })).body.group; + firstMessage = (await sendSimpleMessage({ roomId: room._id })).body.message; + firstThreadMessage = (await sendSimpleMessage({ roomId: room._id, tmid: firstMessage._id })).body.message; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'p', roomId: room._id }), + deleteUser(user), + updateSetting('Message_Read_Receipt_Enabled', false), + updateSetting('Message_Read_Receipt_Store_Users', false), + ]), + ); + + describe('simple message and thread that nobody has read yet', () => { + it("should return only the sender's read receipt for a message sent in the main room", async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: firstMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(1); + expect(data.result[0]).to.have.property('userId', credentials['X-User-Id']); + }); + }); + + it("should return only the sender's read receipt for a message sent in a thread", async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: firstThreadMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(1); + expect(data.result[0]).to.have.property('userId', credentials['X-User-Id']); + }); + }); + }); + + describe('simple message and thread where the room message was read by the invited user but the thread message was not', () => { + before("should read all main room's messages with the invited user", async () => { + await request + .post(methodCall('readMessages')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'readMessages', + params: [room._id, true], + }), + }); + }); + + it("should return both the sender's and the invited user's read receipt for a message sent in the main room", async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: firstMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + + const receiptsUserIds = [data.result[0].userId, data.result[1].userId]; + expect(receiptsUserIds).to.have.members([credentials['X-User-Id'], user._id]); + }); + }); + + it("should return only the sender's read receipt for a message sent in a thread", async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: firstThreadMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(1); + expect(data.result[0]).to.have.property('userId', credentials['X-User-Id']); + }); + }); + }); + + describe('simple message and thread where both was read by the invited user', () => { + before('should read thread messages with the invited user', async () => { + await request + .post(methodCall('getThreadMessages')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'getThreadMessages', + params: [ + { + tmid: firstMessage._id, + }, + ], + }), + }); + }); + + it("should return both the sender's and invited user's read receipt for a message sent in a thread", async () => { + await retry( + `Since the read is a detached task, it happens asynchronously, after the read message, so there is no await/way to make sure the read receipt will be rigth + just after the read message. So we need to retry the request to make sure the read receipt is there.`, + async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: firstThreadMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + + const receiptsUserIds = [data.result[0].userId, data.result[1].userId]; + expect(receiptsUserIds).to.have.members([credentials['X-User-Id'], user._id]); + }); + }, + { + delayMs: 100, + }, + ); + }); + }); + + describe('simple message and thread marked as read by the invited user', () => { + let otherMessage: IMessage; + let otherThreadMessage: IThreadMessage; + + before('should send another message and create a thread', async () => { + otherMessage = (await sendSimpleMessage({ roomId: room._id })).body.message; + otherThreadMessage = (await sendSimpleMessage({ roomId: room._id, tmid: otherMessage._id })).body.message; + }); + + before('should mark the thread as read by the invited user', async () => { + await request + .post(methodCall('readThreads')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + method: 'readThreads', + params: [otherMessage._id], + id: 'id', + msg: 'method', + }), + }); + }); + + it("should return both the sender's and invited user's read receipt for a message sent in the main room", async () => { + await retry( + `Since the read is a detached task, it happens asynchronously, after the read message, so there is no await/way to make sure the read receipt will be rigth + just after the read message. So we need to retry the request to make sure the read receipt is there.`, + async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: otherThreadMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + + const receiptsUserIds = [data.result[0].userId, data.result[1].userId]; + expect(receiptsUserIds).to.have.members([credentials['X-User-Id'], user._id]); + }); + }, + ); + }); + + it("should return both the sender's and invited user's read receipt for a message sent in a thread", async () => { + await retry( + `Since the read is a detached task, it happens asynchronously, after the read message, so there is no await/way to make sure the read receipt will be rigth + just after the read message. So we need to retry the request to make sure the read receipt is there.`, + async () => { + await request + .post(methodCall('getReadReceipts')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getReadReceipts', + params: [{ messageId: otherThreadMessage._id }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + + const receiptsUserIds = [data.result[0].userId, data.result[1].userId]; + expect(receiptsUserIds).to.have.members([credentials['X-User-Id'], user._id]); + }); + }, + { + delayMs: 100, + }, + ); + }); + }); + }); + }); + + describe('[@getMessages]', () => { + let rid: IRoom['_id']; + let firstMessage: IMessage; + let lastMessage: IMessage; + + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + firstMessage = res.body.message; + }) + .end(done); + }); + + before('send another sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + lastMessage = res.body.message; + }) + .end(done); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('getMessages')) + .send({ + message: JSON.stringify({ + method: 'getMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if msgIds not specified', async () => { + const res = await request + .post(methodCall('getMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('sanitizedError'); + expect(data.error.sanitizedError).to.have.property('error', 400); + }); + + it('should return the first message', async () => { + const res = await request + .post(methodCall('getMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getMessages', + params: [[firstMessage._id]], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(1); + }); + + it('should return both messages', async () => { + const res = await request + .post(methodCall('getMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getMessages', + params: [[firstMessage._id, lastMessage._id]], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.equal(2); + }); + }); + + describe('[@cleanRoomHistory]', () => { + let rid: IRoom['_id']; + let testUser: IUser; + let testUserCredentials: Credentials; + let channelName: string; + + before('update permissions', async () => { + await updatePermission('clean-channel-history', ['admin', 'user']); + }); + + before('create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + before('send another sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + after(() => + Promise.all([deleteRoom({ type: 'p', roomId: rid }), deleteUser(testUser), updatePermission('clean-channel-history', ['admin'])]), + ); + + it('should throw an error if user is not part of the room', async () => { + await request + .post(methodCall('cleanRoomHistory')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'cleanRoomHistory', + params: [ + { + roomId: rid, + oldest: { $date: new Date().getTime() }, + latest: { $date: new Date().getTime() }, + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'error-not-allowed'); + }); + }); + + it('should not change the _updatedAt value when nothing is changed on the room', async () => { + const roomBefore = await request.get(api('groups.info')).set(credentials).query({ + roomId: rid, + }); + + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: rid, + latest: '2016-12-09T13:42:25.304Z', + oldest: '2016-08-30T13:42:25.304Z', + excludePinned: false, + filesOnly: false, + ignoreThreads: false, + ignoreDiscussion: false, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('count', 0); + }); + + const roomAfter = await request.get(api('groups.info')).set(credentials).query({ + roomId: rid, + }); + expect(roomBefore.body.group._updatedAt).to.be.equal(roomAfter.body.group._updatedAt); + }); + + it('should change the _updatedAt value when room is cleaned', async () => { + const roomBefore = await request.get(api('groups.info')).set(credentials).query({ + roomId: rid, + }); + + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: rid, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + excludePinned: false, + filesOnly: false, + ignoreThreads: false, + ignoreDiscussion: false, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('count', 2); + }); + + const roomAfter = await request.get(api('groups.info')).set(credentials).query({ + roomId: rid, + }); + expect(roomBefore.body.group._updatedAt).to.not.be.equal(roomAfter.body.group._updatedAt); + }); + }); + + describe('[@loadHistory]', () => { + let rid: IRoom['_id']; + let postMessageDate: unknown; + let lastMessage: IMessage; + + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + postMessageDate = { $date: new Date().getTime() }; + }) + .end(done); + }); + + before('send another sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + lastMessage = res.body.message; + }) + .end(done); + }); + + let publicRid: IRoom['_id']; + let publicMessageId: IMessage['_id']; + + before('create public channel with a message', async () => { + publicRid = (await createRoom({ type: 'c', name: `methods-test-public-${Date.now()}` })).body.channel._id; + publicMessageId = (await sendMessage({ message: { rid: publicRid, msg: 'public message' } })).body.message._id; + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: rid }), deleteRoom({ type: 'c', roomId: publicRid })])); + + describe('anonymous read', () => { + before(() => updateSetting('Accounts_AllowAnonymousRead', true)); + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + + it('should return public channel messages to an anonymous caller when anonymous read is enabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [publicRid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const data = JSON.parse(res.body.message); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.map((m: IMessage) => m._id)).to.include(publicMessageId); + }); + + it('should not return private group messages to an anonymous caller even when anonymous read is enabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [rid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const data = JSON.parse(res.body.message); + expect(data.result).to.equal(false); + }); + }); + + it('should fail for an anonymous caller when anonymous read is disabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [publicRid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + const data = JSON.parse(res.body.message); + expect(data.error).to.have.property('error', 'error-invalid-user'); + }); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('loadHistory')) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if roomId not specified', async () => { + const res = await request + .post(methodCall('loadHistory')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('sanitizedError'); + expect(data.error.sanitizedError).to.have.property('error', 400); + }); + + it('should return all messages for the specified room', async () => { + const res = await request + .post(methodCall('loadHistory')) + .set(credentials) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [rid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(2); + }); + + it('should return only the first message', async () => { + const res = await request + .post(methodCall('loadHistory')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params: [rid, postMessageDate], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(1); + }); + + it('should return only one message when limit = 1', async () => { + const res = await request + .post(methodCall('loadHistory')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params: [rid, { $date: new Date().getTime() }, 1], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(1); + }); + + it('should return the messages since the last one', async () => { + const res = await request + .post(methodCall('loadHistory')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadHistory', + params: [rid, null, 20, lastMessage], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(2); + }); + }); + + describe('[@loadNextMessages]', () => { + let rid: IRoom['_id']; + let postMessageDate: unknown; + const startDate = { $date: new Date().getTime() }; + + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + postMessageDate = { $date: new Date().getTime() }; + }) + .end(done); + }); + + before('send another sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('loadNextMessages')) + .send({ + message: JSON.stringify({ + method: 'loadNextMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if roomId not specified', async () => { + const res = await request + .post(methodCall('loadNextMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadNextMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('sanitizedError'); + expect(data.error.sanitizedError).to.have.property('error', 400); + }); + + it('should return all messages for the specified room', async () => { + const res = await request + .post(methodCall('loadNextMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadNextMessages', + params: [rid], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(2); + }); + + it('should return only the latest message', async () => { + const res = await request + .post(methodCall('loadNextMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadNextMessages', + params: [rid, postMessageDate], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(1); + }); + + it('should return only one message when limit = 1', async () => { + const res = await request + .post(methodCall('loadNextMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadNextMessages', + params: [rid, startDate, 1], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.length).to.equal(1); + }); + }); + + describe('[@getUsersOfRoom]', () => { + let testUser: TestUser; + let rid: IRoom['_id']; + + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('create test user', (done) => { + const username = `user.test.${Date.now()}`; + const email = `${username}@rocket.chat`; + void request + .post(api('users.create')) + .set(credentials) + .send({ email, name: username, username, password: username }) + .end((_err, res) => { + testUser = res.body.user; + done(); + }); + }); + + before('add user to room', (done) => { + void request + .post(api('groups.invite')) + .set(credentials) + .send({ + roomId: rid, + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .end(done); + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: rid }), deleteUser(testUser)])); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('getUsersOfRoom')) + .send({ + message: JSON.stringify({ + method: 'getUsersOfRoom', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should fail if roomId not specified', async () => { + const res = await request + .post(methodCall('getUsersOfRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getUsersOfRoom', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'error-invalid-room'); + }); + + it('should return the users for the specified room', async () => { + const res = await request + .post(methodCall('getUsersOfRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getUsersOfRoom', + params: [rid], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('total', 2); + }); + }); + + describe('[@listCustomUserStatus]', () => { + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('listCustomUserStatus')) + .send({ + message: JSON.stringify({ + method: 'listCustomUserStatus', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return custom status for the current user', async () => { + const res = await request + .post(methodCall('listCustomUserStatus')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'listCustomUserStatus', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + }); + }); + + describe('[@permissions:get]', () => { + const date = { + $date: new Date().getTime(), + }; + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('permissions:get')) + .send({ + message: JSON.stringify({ + method: 'permissions/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return all permissions', async () => { + const res = await request + .post(methodCall('permissions:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'permissions/get', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.be.above(1); + }); + + it('should return all permissions after the given date', async () => { + const res = await request + .post(methodCall('permissions:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'permissions/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('update').that.is.an('array'); + }); + }); + + describe('[@loadMissedMessages]', () => { + let rid: IRoom['_id']; + const date = { + $date: new Date().getTime(), + }; + let postMessageDate: unknown; + + const channelName = `methods-test-channel-${Date.now()}`; + + before('create test group', (done) => { + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + before('send sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + postMessageDate = { $date: new Date().getTime() }; + }) + .end(done); + }); + + before('send another sample message', (done) => { + void request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + text: 'Second Sample message', + rid, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: [rid, date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return an error if the rid param is empty', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: ['', date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.include('error-invalid-room'); + }); + + it('should return an error if the start param is missing', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: [rid], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.include('Match error'); + }); + + it('should return and empty list if using current time', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: [rid, { $date: new Date().getTime() }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.a('array'); + expect(data.result.length).to.be.equal(0); + }); + + it('should return two messages if using a time from before the first msg was sent', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: [rid, date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.a('array'); + expect(data.result.length).to.be.equal(2); + }); + + it('should return a single message if using a time from in between the messages', async () => { + const res = await request + .post(methodCall('loadMissedMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadMissedMessages', + params: [rid, postMessageDate], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.a('array'); + expect(data.result.length).to.be.equal(1); + }); + }); + + describe('[@public-settings:get]', () => { + const date = { + $date: new Date().getTime(), + }; + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('public-settings:get')) + .send({ + message: JSON.stringify({ + method: 'public-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return the list of public settings', async () => { + const res = await request + .post(methodCall('public-settings:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'public-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + }); + }); + + describe('[@private-settings:get]', () => { + const date = { + $date: 0, + }; + + after(() => + Promise.all([ + updatePermission('view-privileged-setting', ['admin']), + updatePermission('edit-privileged-setting', ['admin']), + updatePermission('manage-selected-settings', ['admin']), + ]), + ); + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('private-settings:get')) + .send({ + message: JSON.stringify({ + method: 'private-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return nothing when user doesnt have any permission', (done) => { + void updatePermission('view-privileged-setting', []) + .then(() => updatePermission('edit-privileged-setting', [])) + .then(() => updatePermission('manage-selected-settings', [])) + .then(() => { + void request + .post(methodCall('private-settings:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'private-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.be.equal(0); + }) + .end(done); + }); + }); + + it('should return properties when user has any related permissions', async () => { + await updatePermission('view-privileged-setting', ['admin']); + + const res = await request + .post(methodCall('private-settings:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'private-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('update').that.is.an('array'); + expect(data.result.update.length).to.not.equal(0); + }); + + it('should return properties when user has all related permissions', (done) => { + void updatePermission('view-privileged-setting', ['admin']) + .then(() => updatePermission('edit-privileged-setting', ['admin'])) + .then(() => updatePermission('manage-selected-settings', ['admin'])) + .then(() => { + void request + .post(methodCall('private-settings:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'private-settings/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('update').that.is.an('array'); + expect(data.result.update.length).to.not.equal(0); + }) + .end(done); + }); + }); + }); + + describe('[@subscriptions:get]', () => { + const date = { + $date: new Date().getTime(), + }; + + it('should fail if not logged in', async () => { + const res = await request + .post(methodCall('subscriptions:get')) + .send({ + message: JSON.stringify({ + method: 'subscriptions/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return all subscriptions', async () => { + const res = await request + .post(methodCall('subscriptions:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'subscriptions/get', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.be.above(1); + }); + + it('should return all subscriptions after the given date', async () => { + const res = await request + .post(methodCall('subscriptions:get')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'subscriptions/get', + params: [date], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('update').that.is.an('array'); + }); + }); + + describe('[@sendMessage]', () => { + let rid: IRoom['_id']; + let channelName: string; + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + }) + .end(done); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should send a message', async () => { + const res = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ _id: `${Date.now() + Math.random()}`, rid, msg: 'test message' }], + id: 1000, + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result.msg).to.equal('test message'); + }); + + it('should parse correctly urls sent in message', async () => { + const res = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid, + msg: 'test message with https://github.com', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('urls').that.is.an('array'); + expect(data.result.urls[0].url).to.equal('https://github.com'); + }); + + it('should not send message if it is a system message', async () => { + const msgId = Random.id(); + await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: msgId, + rid: 'GENERAL', + msg: 'xss', + t: 'subscription-role-added', + role: '

XSS', + }, + ], + id: 1000, + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const data = JSON.parse(res.body.message); + expect(data).to.not.have.a.property('result').that.is.an('object'); + expect(data).to.have.a.property('error').that.is.an('object'); + }); + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ msgId }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return an error if request includes unallowed parameters', async () => { + const res = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ _id: `${Date.now() + Math.random()}`, rid, msg: 'test message', _notAllowed: '1' }], + id: 1000, + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error.sanitizedError).to.have.a.property('reason', 'Match failed'); + }); + + it('should accept message sent by js.SDK', async () => { + const res = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ rid, msg: 'test message', bot: { i: 'js.SDK' } }], + id: 1000, + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + + const data = JSON.parse(res.body.message); + + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('bot').that.is.an('object'); + expect(data.result.bot).to.have.a.property('i', 'js.SDK'); + }); + }); + + describe('[@updateMessage]', () => { + let rid: IRoom['_id']; + let roomName: string; + let messageId: IMessage['_id']; + let simpleMessageId: IMessage['_id']; + let messageWithMarkdownId: IMessage['_id']; + let channelName: string; + const siteUrl = process.env.SITE_URL || process.env.TEST_API_URL || 'http://localhost:3000'; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('create room', (done) => { + channelName = `methods-test-channel-${Date.now()}`; + void request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', channelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + rid = res.body.group._id; + roomName = res.body.group.name; + }) + .end(done); + }); + + before('send simple message', async () => { + const res = await sendSimpleMessage({ roomId: rid }); + simpleMessageId = res.body.message._id; + }); + + before('send message with URL', (done) => { + void request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid, + msg: 'test message with https://github.com', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + expect(data.result).to.have.a.property('urls').that.is.an('array'); + expect(data.result.urls[0].url).to.equal('https://github.com'); + messageId = data.result._id; + }) + .end(done); + }); + + before('send message with URL inside markdown', (done) => { + void request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: `${Date.now() + Math.random()}`, + rid, + msg: 'test message with ```https://github.com```', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('object'); + messageWithMarkdownId = data.result._id; + }) + .end(done); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'p', roomId: rid }), + deleteUser(testUser), + updatePermission('bypass-time-limit-edit-and-delete', ['bot', 'app']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), + ]), + ); + + it('should update a message with a URL', async () => { + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'https://github.com updated' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + }); + }); + + it('should fail if user does not have permissions to update a message with the same content', async () => { + await request + .post(methodCall('updateMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'test message with https://github.com' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + it('should fail if user does not have permissions to update a message with different content', async () => { + await request + .post(methodCall('updateMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'updating test message with https://github.com' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + expect(data.error).to.have.a.property('error', 'error-action-not-allowed'); + }); + }); + + it.skip('should add a quote attachment to a message', async () => { + const quotedMsgLink = `${siteUrl}/group/${roomName}?msg=${messageWithMarkdownId}`; + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: `${quotedMsgLink} updated` }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + // TODO: this test is not testing anything useful + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api('chat.getMessage')) + .query({ msgId: messageId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message).to.have.property('msg', `${quotedMsgLink} updated`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(1); + expect(res.body.message.attachments[0]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it('should replace a quote attachment in a message', async () => { + const quotedMsgLink = `${siteUrl}/group/${roomName}?msg=${simpleMessageId}`; + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: `${quotedMsgLink} updated` }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api('chat.getMessage')) + .query({ msgId: messageId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message).to.have.property('msg', `${quotedMsgLink} updated`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(1); + expect(res.body.message.attachments[0]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it('should add multiple quote attachments in a single message', async () => { + const quotedMsgLink = `${siteUrl}/group/${roomName}?msg=${simpleMessageId}`; + const newQuotedMsgLink = `${siteUrl}/group/${roomName}?msg=${messageWithMarkdownId}`; + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: `${newQuotedMsgLink} ${quotedMsgLink} updated` }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api('chat.getMessage')) + .query({ msgId: messageId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message).to.have.property('msg', `${newQuotedMsgLink} ${quotedMsgLink} updated`); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(2); + expect(res.body.message.attachments[0]).to.have.property('message_link', newQuotedMsgLink); + expect(res.body.message.attachments[1]).to.have.property('message_link', quotedMsgLink); + }); + }); + + it.skip('should remove a quote attachment from a message', async () => { + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'updated' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api('chat.getMessage')) + .query({ msgId: messageId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message).to.have.property('msg', 'updated'); + expect(res.body.message).to.have.property('attachments').that.is.an('array').that.has.lengthOf(0); + }); + }); + + it('should update a message when bypass time limits permission is enabled', async () => { + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['admin']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0.01), + ]); + + await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'https://github.com updated with bypass' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + }); + + await request + .get(api('chat.getMessage')) + .query({ msgId: messageId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message.msg).to.equal('https://github.com updated with bypass'); + }); + + await Promise.all([ + updatePermission('bypass-time-limit-edit-and-delete', ['bot', 'app']), + updateSetting('Message_AllowEditing_BlockEditInMinutes', 0), + ]); + }); + + it('should not parse URLs inside markdown on update', (done) => { + void request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [ + { + _id: messageWithMarkdownId, + rid, + msg: 'test message with ```https://github.com``` updated', + }, + ], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.an('string'); + }) + .then(() => { + void request + .get(api('chat.getMessage')) + .query({ msgId: messageWithMarkdownId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message').that.is.an('object'); + expect(res.body.message.msg).to.equal('test message with ```https://github.com``` updated'); + expect(res.body.message).to.have.property('urls'); + expect(res.body.message.urls.length).to.be.equal(0); + }) + .end(done); + }); + }); + + ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg'].forEach((prop) => { + it(`should allow to update a message changing property '${prop}'`, async () => { + const res = await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'Message updated', [prop]: 'valid' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg').that.is.a('string'); + }); + }); + + ['tmid', '_hidden', 'rid'].forEach((prop) => { + it(`should fail to update a message changing invalid property '${prop}'`, async () => { + const res = await request + .post(methodCall('updateMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'updateMessage', + params: [{ _id: messageId, rid, msg: 'Message updated invalid', [prop]: 'invalid' }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'error-invalid-update-key'); + }); + }); + }); + + describe('[@getRoomByTypeAndName]', () => { + let testUser: TestUser; + let testUser2: TestUser; + let testUserCredentials: Credentials; + let dmId: IRoom['_id']; + let room: IRoom; + let privateRoom: IRoom; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before(async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + }) + ).body.channel; + }); + + before('create direct conversation with user', (done) => { + void request + .post(methodCall('createDirectMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser2.username], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + const result = JSON.parse(res.body.message); + expect(result.result).to.be.an('object'); + expect(result.result).to.have.property('rid').that.is.an('string'); + + dmId = result.result.rid; + done(); + }); + }); + + before(async () => { + privateRoom = ( + await createRoom({ + type: 'p', + name: `private.test.${Date.now()}-${Math.random()}`, + }) + ).body.group; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'd', roomId: dmId }), + deleteRoom({ type: 'c', roomId: room._id }), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteUser(testUser), + deleteUser(testUser2), + updateSetting('Accounts_AllowAnonymousRead', false), + ]); + }); + + it('should throw error when anonymous user tries to read private channel with anonymous read enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const payload = { + message: JSON.stringify({ + msg: 'method', + id: '2', + method: 'getRoomByTypeAndName', + params: ['p', privateRoom.name], + }), + }; + + const res = await request.post(methodCallAnon('getRoomByTypeAndName')).send(payload); + + expect(res.body).to.have.property('message'); + const parsedMessage = JSON.parse(res.body.message); + + expect(parsedMessage).to.have.property('error'); + expect(parsedMessage.error).to.have.property('error'); + expect(parsedMessage.error.error).to.equal('error-invalid-user'); + + await updateSetting('Accounts_AllowAnonymousRead', false); + }); + + it("should throw an error if the user isn't logged in", (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.be.equal('You must be logged in to do this.'); + done(); + }); + }); + + it("should throw an error if name isn't provided", (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', null], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('message'); + + const parsedResponse = JSON.parse(res.body.message); + + expect(parsedResponse).to.have.property('error'); + expect(parsedResponse.error).to.have.property('error'); + expect(parsedResponse.error.error).to.equal('error-invalid-room'); + done(); + }); + }); + + it("should throw an error if type isn't provided", (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: [null, dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('message'); + + const parsedResponse = JSON.parse(res.body.message); + + expect(parsedResponse).to.have.property('error'); + expect(parsedResponse.error).to.have.property('error'); + expect(parsedResponse.error.error).to.equal('error-invalid-room'); + done(); + }); + }); + + it("should throw an error if the user doesn't have access to the room", (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('message'); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse).to.have.property('error'); + expect(parsedResponse.error).to.have.property('error'); + expect(parsedResponse.error.error).to.equal('error-no-permission'); + done(); + }); + }); + + it("should throw an error if the room doesn't exist", (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', 'testId'], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('message'); + + const parsedResponse = JSON.parse(res.body.message); + + expect(parsedResponse).to.have.property('error'); + expect(parsedResponse.error).to.have.property('error'); + expect(parsedResponse.error.error).to.equal('error-invalid-room'); + done(); + }); + }); + + it('should return the room object for a Public Channel if anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request.post(methodCallAnon('getRoomByTypeAndName')).send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['c', room._id], + id: 'id', + msg: 'method', + }), + }); + + expect(res.body.success).to.equal(true); + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse.result.name).to.equal(room.name); + + await updateSetting('Accounts_AllowAnonymousRead', false); + }); + + it('should return the room object for a DM', (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse.result._id).to.equal(dmId); + done(); + }); + }); + + it('should return the room object for a DM addressed by username', async () => { + const res = await request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', testUser2.username], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse.result._id).to.equal(dmId); + expect(parsedResponse.result.t).to.equal('d'); + }); + + it('should throw error when the DM addressed by username does not exist', async () => { + const stranger = await createUser(); + + const res = await request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', stranger.username], + id: 'id', + msg: 'method', + }), + }) + .expect(400); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse).to.have.property('error'); + expect(parsedResponse.error.error).to.equal('error-invalid-room'); + + await deleteUser(stranger); + }); + + it('should return the room object for a group DM addressed by a comma separated username list', async () => { + const usernames = `${testUser.username},${testUser2.username}`; + + const groupDm = (await request.post(api('im.create')).set(credentials).send({ usernames }).expect(200)).body.room; + + const res = await request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', usernames], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse.result._id).to.equal(groupDm._id); + + await deleteRoom({ type: 'd', roomId: groupDm._id }); + }); + + it('should keep resolving a DM by username after the other member is renamed', async () => { + const renamedUser = await createUser(); + const renamedDmId = (await request.post(api('im.create')).set(credentials).send({ username: renamedUser.username }).expect(200)).body + .room._id; + + const username = `renamed.${Date.now()}`; + await request.post(api('users.update')).set(credentials).send({ userId: renamedUser._id, data: { username } }).expect(200); + + const res = await request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', username], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse.result._id).to.equal(renamedDmId); + + await deleteRoom({ type: 'd', roomId: renamedDmId }); + await deleteUser(renamedUser); + }); + }); + + describe('[@spotlight]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `methods-spotlight-${Date.now()}` })).body.channel; + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), updateSetting('Accounts_AllowAnonymousRead', false)]); + }); + + const callAnonymousSpotlight = async (text: string) => { + const res = await request + .post(methodCallAnon('spotlight')) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'spotlight', + params: [text], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse).to.not.have.property('error'); + + return parsedResponse.result as { rooms: IRoom[]; users: IUser[] }; + }; + + it('should return no rooms or users for an anonymous user when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + // The unprefixed query also runs the user search with no user id, which used to throw. + const result = await callAnonymousSpotlight(testChannel.name as string); + + expect(result).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(result).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users for an anonymous user when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const result = await callAnonymousSpotlight(testChannel.name as string); + + expect(result.rooms.map((room) => room._id)).to.include(testChannel._id); + expect(result).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + }); + + describe('[@setUserActiveStatus]', () => { + let testUser: TestUser; + let testUser2: TestUser; + let testUserCredentials: Credentials; + let dmId: IRoom['_id']; + let dmTestId: IRoom['_id']; + let dmWithDeactivatedId: IRoom['_id']; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('create direct conversation with user', (done) => { + void request + .post(methodCall('createDirectMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser.username], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + const result = JSON.parse(res.body.message); + expect(result.result).to.be.an('object'); + expect(result.result).to.have.property('rid').that.is.an('string'); + + dmId = result.result.rid; + done(); + }); + }); + + before('create direct conversation between both users', (done) => { + void request + .post(methodCall('createDirectMessage')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser2.username], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + const result = JSON.parse(res.body.message); + expect(result.result).to.be.an('object'); + expect(result.result).to.have.property('rid').that.is.an('string'); + + dmTestId = result.result.rid; + done(); + }); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'd', roomId: dmId }), + deleteRoom({ type: 'd', roomId: dmTestId }), + ...(dmWithDeactivatedId ? [deleteRoom({ type: 'd', roomId: dmWithDeactivatedId })] : []), + deleteUser(testUser), + deleteUser(testUser2), + ]), + ); + + it('should deactivate a user', (done) => { + void request + .post(methodCall('setUserActiveStatus')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'setUserActiveStatus', + params: [testUser._id, false, false], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('success').that.is.an('boolean'); + const result = JSON.parse(res.body.message); + expect(result.result).to.be.equal(true); + done(); + }); + }); + + it('should deactivate another user', (done) => { + void request + .post(methodCall('setUserActiveStatus')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'setUserActiveStatus', + params: [testUser2._id, false, false], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('success').that.is.an('boolean'); + const result = JSON.parse(res.body.message); + expect(result.result).to.be.equal(true); + done(); + }); + }); + + it('should mark the direct conversation between admin=>testUser as readonly when user is deactivated', (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const result = JSON.parse(res.body.message); + expect(result.result.ro).to.equal(true); + done(); + }); + }); + + it('should activate a user', (done) => { + void request + .post(methodCall('setUserActiveStatus')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'setUserActiveStatus', + params: [testUser._id, true, false], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('success').that.is.an('boolean'); + const result = JSON.parse(res.body.message); + expect(result.result).to.be.equal(true); + done(); + }); + }); + + it('should set readonly=false when user is activated (and the other side is also active)', (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const result = JSON.parse(res.body.message); + expect(result.result.ro).to.equal(false); + done(); + }); + }); + + it('should keep the direct conversation between testUser=>testUser2 as readonly when one of them is deactivated', (done) => { + void request + .post(api('login')) + .send({ + user: testUser.username, + password, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + testUserCredentials['X-Auth-Token'] = res.body.data.authToken; + testUserCredentials['X-User-Id'] = res.body.data.userId; + }) + .then(() => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmTestId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const result = JSON.parse(res.body.message); + expect(result.result.ro).to.equal(true); + done(); + }); + }) + .catch(done); + }); + + it('should create a new direct conversation as readonly when the target user is already deactivated', async () => { + const createRes = await request + .post(methodCall('createDirectMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'createDirectMessage', + params: [testUser2.username], + id: 'id', + msg: 'method', + }), + }); + + const createResult = JSON.parse(createRes.body.message); + expect(createResult.result).to.be.an('object'); + expect(createResult.result).to.have.property('rid').that.is.an('string'); + dmWithDeactivatedId = createResult.result.rid; + + const roomRes = await request + .post(methodCall('getRoomByTypeAndName')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmWithDeactivatedId], + id: 'id', + msg: 'method', + }), + }); + + expect(roomRes.body.success).to.equal(true); + const result = JSON.parse(roomRes.body.message); + expect(result.result.ro).to.equal(true); + }); + + it('should activate another user', (done) => { + void request + .post(methodCall('setUserActiveStatus')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'setUserActiveStatus', + params: [testUser2._id, true, false], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body).to.have.property('success').that.is.an('boolean'); + const result = JSON.parse(res.body.message); + expect(result.result).to.be.equal(true); + done(); + }); + }); + + it('should set readonly=false when both users are activated', (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmTestId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const result = JSON.parse(res.body.message); + expect(result.result.ro).to.equal(false); + done(); + }); + }); + + it('should keep readonly=true when user is activated (and the other side is deactivated)', (done) => { + void request + .post(methodCall('getRoomByTypeAndName')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['d', dmTestId], + id: 'id', + msg: 'method', + }), + }) + .end((_err, res) => { + expect(res.body.success).to.equal(true); + const result = JSON.parse(res.body.message); + expect(result.result.ro).to.equal(false); + done(); + }); + }); + }); + + describe('[@addUsersToRoom]', () => { + let guestUser: TestUser; + let user: TestUser; + let room: IRoom; + let createdRooms: IRoom[] = []; + + describe('Direct Message', () => { + let thirdUser: TestUser; + before(async () => { + guestUser = await createUser({ roles: ['user'] }); + thirdUser = await createUser({ roles: ['user'] }); + user = await createUser(); + room = ( + await createRoom({ + type: 'd', + username: guestUser.username, + }) + ).body.room; + createdRooms.push(room); + }); + after(() => + Promise.all([ + ...createdRooms.map((r) => deleteRoom({ type: 'd', roomId: r._id })), + deleteUser(user), + deleteUser(guestUser), + deleteUser(thirdUser), + ]), + ); + + it('should fail when trying to add a user to a direct message room', async () => { + const res = await request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: room._id, users: [thirdUser.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('message').that.is.an('string'); + expect(res.body.message).to.include('error-cant-invite-for-direct-room'); + }); + }); + + describe('Channel', () => { + before(async () => { + guestUser = await createUser({ roles: ['guest'] }); + user = await createUser(); + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + }) + ).body.channel; + createdRooms.push(room); + }); + after(() => + Promise.all([...createdRooms.map((r) => deleteRoom({ type: 'c', roomId: r._id })), deleteUser(user), deleteUser(guestUser)]), + ); + + it('should fail if not logged in', async () => { + const res = await request.post(methodCall('addUsersToRoom')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should add a single user to a room', (done) => { + void request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: room._id, users: [user.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => { + void request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.lengthOf(2); + }) + .end(done); + }) + .catch(done); + }); + + it('should not add guest users to more rooms than defined in the license', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!process.env.IS_EE) { + this.skip(); + } + const promises = []; + for (let i = 0; i < maxRoomsPerGuest; i++) { + promises.push( + createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }), + ); + } + createdRooms = [...createdRooms, ...(await Promise.all(promises)).map((res) => res.body.channel)]; + + void request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: room._id, users: [guestUser.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + expect(parsedBody.error).to.have.property('error', 'error-max-rooms-per-guest-reached'); + }); + }); + }); + }); + + describe('[@saveSettings]', () => { + it('should return an error when trying to save a "NaN" value', async () => { + await request + .post(api('method.call/saveSettings')) + .set(credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: '13', + method: 'saveSettings', + params: [[{ _id: 'Message_AllowEditing_BlockEditInMinutes', value: { $InfNaN: 0 } }]], + }), + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + }); + }); + + it('should return an error when trying to save a "Infinity" value', async () => { + await request + .post(api('method.call/saveSettings')) + .set(credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: '13', + method: 'saveSettings', + params: [[{ _id: 'Message_AllowEditing_BlockEditInMinutes', value: { $InfNaN: 1 } }]], + }), + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + }); + }); + + it('should return an error when trying to save a "-Infinity" value', async () => { + await request + .post(api('method.call/saveSettings')) + .set(credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: '13', + method: 'saveSettings', + params: [[{ _id: 'Message_AllowEditing_BlockEditInMinutes', value: { $InfNaN: -1 } }]], + }), + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('[@auditGetAuditions] EE', () => { + let testUser: TestUser; + let testUserCredentials: Credentials; + + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0).getTime(); + const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime(); + + before('create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + before('generate audits data', async () => { + await request + .post(methodCall('auditGetMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetMessages', + params: [ + { + type: '', + msg: 'test1234', + startDate: { $date: startDate }, + endDate: { $date: endDate }, + rid: 'GENERAL', + users: [], + }, + ], + id: '14', + msg: 'method', + }), + }); + }); + + after(() => Promise.all([deleteUser(testUser)])); + + it('should fail if the user does not have permissions to get auditions', async () => { + await request + .post(methodCall('auditGetAuditions')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'auditGetAuditions', + params: [ + { + startDate: { $date: startDate }, + endDate: { $date: endDate }, + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('message'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error'); + expect(data.error).to.have.a.property('error', 'Not allowed'); + }); + }); + + it('should not return more user data than necessary - e.g. passwords, hashes, tokens', async () => { + await request + .post(methodCall('auditGetAuditions')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'auditGetAuditions', + params: [ + { + startDate: { $date: startDate }, + endDate: { $date: endDate }, + }, + ], + id: '18', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('result').that.is.an('array'); + expect(data.result.length).to.be.greaterThan(0); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', '18'); + data.result.forEach((item: any) => { + expect(item).to.have.all.keys('_id', 'ts', 'results', 'u', 'fields', '_updatedAt'); + expect(item.u).to.not.have.property('services'); + expect(item.u).to.not.have.property('roles'); + expect(item.u).to.not.have.property('lastLogin'); + expect(item.u).to.not.have.property('statusConnection'); + expect(item.u).to.not.have.property('emails'); + }); + }); + }); + }); + + describe('[@joinRoom]', async () => { + let room: IOmnichannelRoom; + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + await updateSetting('Livechat_enabled', true); + await createAgent(); + await makeAgentAvailable(); + + const visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + + user = await createUser(); + await createAgent(user.username); + userCredentials = await login(user.username, password); + }); + + after(() => Promise.all([deleteUser(user), updateSetting('Livechat_enabled', false)])); + + it('should not allow an agent to join a closed livechat room', async () => { + await request + .post(methodCall('joinRoom')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + method: 'joinRoom', + params: [room._id], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.is.a('string'); + + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'room-closed'); + }); + }); + }); + + describe('[@loadSurroundingMessages]', () => { + let rid: IRoom['_id']; + let middleMessage: IMessage; + let channelName: string; + + before('create room', async () => { + channelName = `methods-test-channel-${Date.now()}`; + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: channelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + rid = res.body.group._id; + }); + }); + + before('send messages', async () => { + await sendMessage({ message: { rid, msg: 'Message 1' } }); + await sendMessage({ message: { rid, msg: 'Message 2' } }); + const msg3 = await sendMessage({ message: { rid, msg: 'Message 3' } }); + middleMessage = msg3.body.message; + const threadMsg = await sendMessage({ message: { rid, msg: 'Message 4 (Thread)' } }); + await sendMessage({ message: { rid, msg: 'Message 4.1 (Reply)', tmid: threadMsg.body.message._id } }); + await sendMessage({ message: { rid, msg: 'Message 4.2 (Reply)', tmid: threadMsg.body.message._id } }); + await sendMessage({ message: { rid, msg: 'Message 5' } }); + }); + + after(() => deleteRoom({ type: 'p', roomId: rid })); + + it('should fail if not logged in', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [middleMessage, 5], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + + it('should fail is message param is empty', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.include('Match error'); + }); + }); + + it('should fail is message param type is incorrect', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: ['invalid-message'], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('message').that.include('Match error'); + }); + }); + + it('should return false if message has no _id', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [{ rid }, 5], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + expect(result).to.be.false; + }); + }); + + it('should return false if message does not exist', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [{ _id: 'invalid-id', rid }, 5], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + expect(result).to.be.false; + }); + }); + + it('should return all messages (default limit)', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [middleMessage], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + expect(result).to.have.property('messages').that.is.an('array'); + expect(result.messages).to.have.lengthOf(7); + expect(result.messages[2]).to.have.property('_id', middleMessage._id); + }); + }); + + it('should respect limit when provided', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [middleMessage, 3], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + expect(result).to.have.property('messages').that.is.an('array'); + expect(result.messages).to.have.lengthOf(3); + expect(result.messages[1]).to.have.property('_id', middleMessage._id); + }); + }); + + it('should filter out thread messages when showThreadMessages is false', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [middleMessage, 50, false], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + expect(result).to.have.property('messages').that.is.an('array'); + expect(result.messages).to.have.lengthOf(5); + expect(result.messages[2]).to.have.property('_id', middleMessage._id); + }); + }); + + it('should include thread messages when showThreadMessages is true', async () => { + await request + .post(methodCall('loadSurroundingMessages')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'loadSurroundingMessages', + params: [middleMessage, 50, true], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const { result } = JSON.parse(res.body.message); + + expect(result).to.have.property('messages').that.is.an('array'); + expect(result.messages).to.have.lengthOf(7); + + const messageTexts = result.messages.map((m: IMessage) => m.msg); + expect(messageTexts).to.include('Message 4.1 (Reply)'); + expect(messageTexts).to.include('Message 4.2 (Reply)'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts b/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts new file mode 100644 index 0000000..68a1dfc --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts @@ -0,0 +1,162 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import speakeasy from 'speakeasy'; + +import { getCredentials, methodCall, request } from '../../../data/api-data'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; + +describe('2fa:enable', function () { + this.retries(0); + let totpSecret: string; + let user1: IUser; + let user2: IUser; + let user3: IUser; + let user1Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let user2Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let user3Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before((done) => getCredentials(done)); + + before('create user', async () => { + [user1, user2, user3] = await Promise.all([ + createUser({ username: Random.id(), email: `${Random.id()}@example.com`, verified: true }), + createUser({ username: Random.id(), email: `${Random.id()}@example.com}`, verified: true }), + createUser({ username: Random.id(), email: `${Random.id()}@example.com}`, verified: false }), + ]); + [user1Credentials, user2Credentials, user3Credentials] = await Promise.all([ + login(user1.username, password), + login(user2.username, password), + login(user3.username, password), + ]); + }); + + after('remove user', async () => Promise.all([deleteUser(user1), deleteUser(user2), deleteUser(user3)])); + + it('should return error when user is not logged in', async () => { + await request + .post(methodCall('2fa:enable')) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return secret and qr code url even when user has unverified email', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user3Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('result'); + expect(parsedBody.result).to.have.property('secret').of.a('string'); + expect(parsedBody.result) + .to.have.property('url') + .of.a('string') + .match(/^otpauth:\/\//); + }); + }); + + it('should return secret and qr code url when 2fa is disabled on user', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user1Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('result'); + expect(parsedBody.result).to.have.property('secret').of.a('string'); + expect(parsedBody.result) + .to.have.property('url') + .of.a('string') + .match(/^otpauth:\/\//); + }); + }); + + it('should enable 2fa on the user', async () => { + const enableResponse = await request + .post(methodCall('2fa:enable')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id2', + method: '2fa:enable', + params: [], + }), + }) + .expect(200); + + const enableData = JSON.parse(enableResponse.body.message); + totpSecret = enableData.result.secret; + + await request + .post(methodCall('2fa:validateTempToken')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id3', + method: '2fa:validateTempToken', + params: [speakeasy.totp({ secret: totpSecret, encoding: 'base32' })], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('result'); + expect(parsedBody.result).to.have.property('codes').of.a('array'); + }); + }); + + it('should return error when 2fa is already enabled on the user', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id4', + method: '2fa:enable', + params: [], + }), + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + expect(parsedBody).to.not.have.property('result'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts new file mode 100644 index 0000000..e52cdc6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -0,0 +1,795 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IInstanceStatus, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import { TeamType } from '@rocket.chat/core-typings'; +import type { IInstance } from '@rocket.chat/rest-typings'; +import { AssertionError, expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { adminEmail, adminUsername, adminPassword, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login as doLogin } from '../../data/users.helper'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +describe('miscellaneous', () => { + before((done) => getCredentials(done)); + + describe('API default', () => { + // Required by mobile apps + describe('/info', () => { + let version: string; + it('should return "version", "build", "commit" and "marketplaceApiVersion" when the user is logged in', async () => { + const res = await request.get('/api/info').set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('version').and.to.be.a('string'); + expect(res.body.info).to.have.property('version').and.to.be.a('string'); + expect(res.body.info).to.have.property('build').and.to.be.an('object'); + expect(res.body.info).to.have.property('commit').and.to.be.an('object'); + expect(res.body.info).to.have.property('marketplaceApiVersion').and.to.be.a('string'); + version = res.body.info.version; + }); + it('should return only "version" and the version should not have patch info when the user is not logged in', async () => { + const res = await request.get('/api/info').expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('version'); + expect(res.body).to.not.have.property('info'); + expect(res.body.version).to.be.equal(version.replace(/(\d+\.\d+).*/, '$1')); + }); + }); + }); + + it('/login', () => { + expect(credentials).to.have.property('X-Auth-Token').with.lengthOf.at.least(1); + expect(credentials).to.have.property('X-User-Id').with.lengthOf.at.least(1); + }); + + it('/login (wrapper username)', async () => { + const res = await request + .post(api('login')) + .send({ + user: { + username: adminUsername, + }, + password: adminPassword, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('status', 'success'); + expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body.data).to.have.property('userId'); + expect(res.body.data).to.have.property('authToken'); + expect(res.body.data).to.have.property('me'); + }); + + it('/login (wrapper email)', async () => { + const res = await request + .post(api('login')) + .send({ + user: { + email: adminEmail, + }, + password: adminPassword, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('status', 'success'); + expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body.data).to.have.property('userId'); + expect(res.body.data).to.have.property('authToken'); + expect(res.body.data).to.have.property('me'); + }); + + it('/login by user', async () => { + const res = await request + .post(api('login')) + .send({ + user: adminEmail, + password: adminPassword, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('status', 'success'); + expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body.data).to.have.property('userId'); + expect(res.body.data).to.have.property('authToken'); + expect(res.body.data).to.have.property('me'); + expect(res.body.data.me.services).to.not.have.nested.property('password.bcrypt'); + expect(res.body.data.me.services).to.have.nested.property('password.exists', true); + }); + + it('/login by username', async () => { + const res = await request + .post(api('login')) + .send({ + username: adminUsername, + password: adminPassword, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('status', 'success'); + expect(res.body).to.have.property('data').and.to.be.an('object'); + expect(res.body.data).to.have.property('userId'); + expect(res.body.data).to.have.property('authToken'); + expect(res.body.data).to.have.property('me'); + expect(res.body.data.me.services).to.not.have.nested.property('password.bcrypt'); + expect(res.body.data.me.services).to.have.nested.property('password.exists', true); + }); + + it('/me', async () => { + const user = await createUser(); + const userCredentials = await doLogin(user.username, password); + + await request + .get(api('me')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const allUserPreferencesKeys = [ + 'alsoSendThreadToChannel', + // 'language', + 'newRoomNotification', + 'newMessageNotification', + 'showThreadsInMainChannel', + // 'clockMode', + 'useEmojis', + 'convertAsciiEmoji', + 'saveMobileBandwidth', + 'collapseMediaByDefault', + 'autoImageLoad', + 'emailNotificationMode', + 'unreadAlert', + 'masterVolume', + 'notificationsSoundVolume', + 'voipRingerVolume', + 'omnichannelTranscriptEmail', + IS_EE ? 'omnichannelTranscriptPDF' : false, + 'desktopNotifications', + 'pushNotifications', + 'enableAutoAway', + // 'highlights', + 'desktopNotificationRequireInteraction', + 'hideUsernames', + 'hideRoles', + 'displayAvatars', + 'hideFlexTab', + 'sendOnEnter', + 'idleTimeLimit', + 'sidebarShowFavorites', + 'sidebarShowUnread', + 'themeAppearence', + 'sidebarSortby', + 'sidebarViewMode', + 'sidebarDisplayAvatar', + 'sidebarGroupByType', + 'sidebarSectionsOrder', + 'muteFocusedConversations', + 'notifyCalendarEvents', + 'enableMobileRinging', + 'featuresPreview', + 'desktopNotificationVoiceCalls', + ].filter((p) => Boolean(p)); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', user._id); + expect(res.body).to.have.property('username', user.username); + expect(res.body).to.have.property('active'); + expect(res.body).to.have.property('name'); + expect(res.body).to.have.property('roles').and.to.be.an('array'); + expect(res.body).to.have.nested.property('emails[0].address', user.emails[0].address); + expect(res.body).to.have.nested.property('settings.preferences').and.to.be.an('object'); + expect(res.body.settings.preferences).to.have.all.keys(allUserPreferencesKeys); + expect(res.body).to.have.property('isOAuthUser', false); + expect(res.body.services).to.not.have.nested.property('password.bcrypt'); + expect(res.body.services).to.have.nested.property('password.exists', true); + }); + + await deleteUser(user); + }); + + describe('/directory', () => { + let user: TestUser; + let userWithLeftoverFederationData: TestUser; + let testChannel: IRoom; + let testGroup: IRoom; + let normalUserCredentials: Credentials; + const teamName = `new-team-name-${Date.now()}` as const; + let teamCreated: ITeam; + + before(async () => { + await updatePermission('create-team', ['admin', 'user']); + user = await createUser(); + userWithLeftoverFederationData = await createUser(); + normalUserCredentials = await doLogin(user.username, password); + [testChannel, testGroup, teamCreated] = await Promise.all([ + createRoom({ name: `channel.test.${Date.now()}`, type: 'c' }).then((res) => res.body.channel), + createRoom({ name: `group.test.${Date.now()}`, type: 'p' }).then((res) => res.body.group), + createTeam(normalUserCredentials, teamName, TeamType.PUBLIC), + ]); + + const connection = await MongoClient.connect(URL_MONGODB); + const updateResult = await connection + .db() + .collection('users') + .updateOne({ _id: userWithLeftoverFederationData._id as any }, { $set: { federation: { origin: 'example.com' } } }); + await connection.close(); + expect(updateResult.modifiedCount).to.equal(1); + }); + + after(async () => { + await Promise.all([ + deleteTeam(normalUserCredentials, teamName), + deleteUser(user), + deleteUser(userWithLeftoverFederationData), + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + updatePermission('create-team', ['admin', 'user']), + ]); + }); + + it('should return an array(result) when search by user and execute successfully', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: user.username, + type: 'users', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.result[0]).to.have.property('_id'); + expect(res.body.result[0]).to.have.property('createdAt'); + expect(res.body.result[0]).to.have.property('username'); + expect(res.body.result[0]).to.have.property('emails').and.to.be.an('array'); + expect(res.body.result[0]).to.have.property('name'); + }); + + it('should not return the emails field for non admins', async () => { + const res = await request + .get(api('directory')) + .set(normalUserCredentials) + .query({ + text: user.username, + type: 'users', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.result[0]).to.have.property('_id'); + expect(res.body.result[0]).to.have.property('createdAt'); + expect(res.body.result[0]).to.have.property('username'); + expect(res.body.result[0]).to.not.have.property('emails'); + expect(res.body.result[0]).to.have.property('name'); + }); + it('should find local users that still have leftover federation data', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: userWithLeftoverFederationData.username, + type: 'users', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.result).to.have.lengthOf(1); + expect(res.body.result[0]).to.have.property('_id', userWithLeftoverFederationData._id); + }); + + it('should return an array(result) when search by channel and execute successfully', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: testChannel.name, + type: 'channels', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('result').and.to.be.an('array'); + expect(res.body.result[0]).to.have.property('_id'); + expect(res.body.result[0]).to.have.property('name'); + expect(res.body.result[0]).to.have.property('usersCount').and.to.be.an('number'); + expect(res.body.result[0]).to.have.property('ts'); + }); + + it('should return private group when search by channel and execute successfully', async () => { + await request + .get(api('directory')) + .set(credentials) + .query({ + text: testGroup.name, + type: 'channels', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('result').and.to.be.an('array'); + expect(res.body.result[0]).to.have.property('_id', testGroup._id); + expect(res.body.result[0]).to.have.property('t', 'p'); + expect(res.body.result[0]).to.have.property('name'); + expect(res.body.result[0]).to.have.property('usersCount').and.to.be.an('number'); + expect(res.body.result[0]).to.have.property('ts'); + }); + }); + + it('should return an array(result) when search by channel with sort params correctly and execute successfully', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: testChannel.name, + type: 'channels', + sort: JSON.stringify({ + name: 1, + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('result').and.to.be.an('array'); + expect(res.body.result[0]).to.have.property('_id'); + expect(res.body.result[0]).to.have.property('name'); + expect(res.body.result[0]).to.have.property('usersCount').and.to.be.an('number'); + expect(res.body.result[0]).to.have.property('ts'); + }); + it('should return an error when send invalid query', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: 'invalid channel', + type: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should return an error when have more than one sort parameter', async () => { + const res = await request + .get(api('directory')) + .set(credentials) + .query({ + text: testChannel.name, + type: 'channels', + sort: JSON.stringify({ + name: 1, + test: 1, + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return an object containing rooms and totalCount from teams', async () => { + const res = await request + .get(api('directory')) + .set(normalUserCredentials) + .query({ + text: '', + type: 'teams', + sort: JSON.stringify({ + name: 1, + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('result'); + expect(res.body.result).to.be.an(`array`); + expect(res.body).to.have.property('total'); + expect(res.body.total).to.be.an('number'); + expect(res.body.result[0]).to.have.property('_id', teamCreated.roomId); + expect(res.body.result[0]).to.have.property('fname'); + expect(res.body.result[0]).to.have.property('teamMain'); + expect(res.body.result[0]).to.have.property('name'); + expect(res.body.result[0]).to.have.property('t'); + expect(res.body.result[0]).to.have.property('usersCount'); + expect(res.body.result[0]).to.have.property('ts'); + expect(res.body.result[0]).to.have.property('teamId'); + expect(res.body.result[0]).to.have.property('default'); + expect(res.body.result[0]).to.have.property('roomsCount'); + }); + }); + + describe('[/spotlight]', () => { + let user: TestUser; + let userCredentials: Credentials; + let testChannel: IRoom; + let testTeam: ITeam; + let testChannelSpecialChars: IRoom; + const fnameSpecialCharsRoom = `test ГДΕληνικά-${Date.now()}`; + const teamName = `team-test-${Date.now()}`; + + before(async () => { + user = await createUser(); + userCredentials = await doLogin(user.username, password); + await updateSetting('UI_Allow_room_names_with_special_chars', true); + testChannelSpecialChars = (await createRoom({ type: 'c', name: fnameSpecialCharsRoom, credentials: userCredentials })).body.channel; + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}`, credentials: userCredentials })).body.channel; + testTeam = await createTeam(userCredentials, teamName, TeamType.PUBLIC); + }); + + after(async () => { + await Promise.all([ + deleteUser(user), + updateSetting('UI_Allow_room_names_with_special_chars', false), + deleteTeam(userCredentials, teamName), + ]); + }); + + it('should fail when does not have query param', async () => { + const res = await request.get(api('spotlight')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + it('should return object inside users array when search by a valid user', async () => { + const res = await request + .get(api('spotlight')) + .query({ + query: `@${adminUsername}`, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body.users[0]).to.have.property('_id'); + expect(res.body.users[0]).to.have.property('name'); + expect(res.body.users[0]).to.have.property('username'); + expect(res.body.users[0]).to.have.property('status'); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + }); + it('must return the object inside the room array when searching for a valid room and that user is not a member of it', async () => { + const res = await request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms[0]).to.have.property('_id'); + expect(res.body.rooms[0]).to.have.property('name'); + expect(res.body.rooms[0]).to.have.property('t'); + }); + it('must return the teamMain property when searching for a valid team that the user is not a member of', async () => { + const res = await request + .get(api('spotlight')) + .query({ + query: `${testTeam.name}`, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms[0]).to.have.property('_id'); + expect(res.body.rooms[0]).to.have.property('name'); + expect(res.body.rooms[0]).to.have.property('t'); + expect(res.body.rooms[0]).to.have.property('teamMain'); + }); + it('must return rooms when searching for a valid fname', async () => { + const res = await request + .get(api('spotlight')) + .query({ + query: `#${fnameSpecialCharsRoom}`, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms[0]).to.have.property('_id', testChannelSpecialChars._id); + expect(res.body.rooms[0]).to.have.property('name', testChannelSpecialChars.name); + expect(res.body.rooms[0]).to.have.property('t', testChannelSpecialChars.t); + }); + it('should not return users when the type param disables user search', async () => { + const res = await request + .get(api('spotlight')) + .query({ + query: `${adminUsername}`, + type: JSON.stringify({ users: false, rooms: true }), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + }); + it('should exclude usernames passed in the usernames param from the results', async () => { + const res = await request + .get(api('spotlight')) + .query({ + // Use a non-exact (prefix) query so the regex search path runs; the exact-username + // match branch in Spotlight.searchUsers does not honor the usernames exclusion list. + query: adminUsername.slice(0, -2), + usernames: adminUsername, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body.users.map((u: { username: string }) => u.username)).to.not.include(adminUsername); + }); + describe('anonymous (unauthenticated) requests', () => { + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + + it('should return no rooms when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + const res = await request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + // An unprefixed query keeps `type.users` enabled, so it also runs the user search with no + // user id - the code path that used to throw before the anonymous callers were guarded. + it('should return no rooms or users for an unprefixed query when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + const res = await request + .get(api('spotlight')) + .query({ + query: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.rooms.map((r: { _id: string }) => r._id)).to.include(testChannel._id); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users for an unprefixed query when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('spotlight')) + .query({ + query: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.rooms.map((r: { _id: string }) => r._id)).to.include(testChannel._id); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + }); + }); + + describe('[/instances.get]', () => { + let unauthorizedUserCredentials: Credentials; + before(async () => { + const createdUser = await createUser(); + unauthorizedUserCredentials = await doLogin(createdUser.username, password); + }); + + it('should fail if user is logged in but is unauthorized', async () => { + const res = await request + .get(api('instances.get')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should fail if not logged in', async () => { + const res = await request.get(api('instances.get')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return instances if user is logged in and is authorized', async () => { + const res = await request.get(api('instances.get')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + + expect(res.body).to.have.property('instances').and.to.be.an('array').with.lengthOf(1); + + const instances = res.body.instances as IInstance[]; + + const instanceName = IS_EE ? 'ddp-streamer' : 'rocket.chat'; + + const instance = instances.find((i): i is IInstance & { instanceRecord: IInstanceStatus } => i.instanceRecord?.name === instanceName); + + if (!instance) throw new AssertionError(`no instance named "${instanceName}"`); + + expect(instance).to.have.property('instanceRecord'); + expect(instance).to.have.property('currentStatus'); + + expect(instance.currentStatus).to.have.property('connected'); + + expect(instance.instanceRecord).to.have.property('_id'); + expect(instance.instanceRecord).to.have.property('extraInformation'); + expect(instance.instanceRecord).to.have.property('name'); + expect(instance.instanceRecord).to.have.property('pid'); + + if (!IS_EE) { + expect(instance).to.have.property('address'); + + expect(instance.currentStatus).to.have.property('lastHeartbeatTime'); + expect(instance.currentStatus).to.have.property('local'); + + const { extraInformation } = instance.instanceRecord; + + expect(extraInformation).to.have.property('host'); + expect(extraInformation).to.have.property('port'); + expect(extraInformation).to.have.property('os').and.to.have.property('cpus').to.be.a('number'); + expect(extraInformation).to.have.property('nodeVersion'); + } + }); + }); + + describe('[/shield.svg]', () => { + before(() => updateSetting('API_Enable_Shields', false)); + + after(() => updateSetting('API_Enable_Shields', true)); + + it('should fail if API_Enable_Shields is disabled', async () => { + const res = await request + .get(api('shield.svg')) + .query({ + type: 'online', + icon: true, + channel: 'general', + name: 'Rocket.Chat', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-endpoint-disabled'); + }); + + it('should succeed if API_Enable_Shields is enabled', async () => { + await updateSetting('API_Enable_Shields', true); + + await request + .get(api('shield.svg')) + .query({ + type: 'online', + icon: true, + channel: 'general', + name: 'Rocket.Chat', + }) + .expect('Content-Type', 'image/svg+xml;charset=utf-8') + .expect(200); + }); + }); + + describe('/pw.getPolicy', () => { + it('should return policies', async () => { + const res = await request.get(api('pw.getPolicy')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('enabled'); + expect(res.body).to.have.property('policy').and.to.be.an('array'); + }); + }); + + describe('/fingerprint', () => { + let unauthorizedUser: TestUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await doLogin(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return 401 when called without authentication', async () => { + const res = await request.post(api('fingerprint')).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(401); + expect(res.body).to.have.property('status', 'error'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to acknowledge a deployment configuration change', async () => { + const res = await request + .post(api('fingerprint')) + .set(unauthorizedUserCredentials) + .send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to deregister the workspace as a new workspace', async () => { + const res = await request.post(api('fingerprint')).set(unauthorizedUserCredentials).send({ setDeploymentAs: 'new-workspace' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 200 when a user with the manage-cloud permission acknowledges a deployment configuration change', async () => { + const res = await request.post(api('fingerprint')).set(credentials).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('success', true); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/moderation.ts b/apps/meteor/tests/end-to-end/api/moderation.ts new file mode 100644 index 0000000..162b4c6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/moderation.ts @@ -0,0 +1,814 @@ +import type { IMessage, IModerationAudit, IModerationReport, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createUser, deleteUser } from '../../data/users.helper'; + +const makeModerationApiRequest = async ( + url: 'moderation.reportUser' | 'moderation.user.reportsByUserId', + method: 'get' | 'post', + data?: any, +) => { + let res: any; + + if (method === 'get') { + res = await request.get(api(url)).set(credentials).query(data); + } else if (method === 'post') { + res = await request.post(api(url)).set(credentials).send(data); + } + + return res.body; +}; + +const reportUser = (userId: string, reason: string) => makeModerationApiRequest('moderation.reportUser', 'post', { userId, reason }); + +const getUsersReports = (userId: string) => makeModerationApiRequest('moderation.user.reportsByUserId', 'get', { userId }); + +describe('[Moderation]', () => { + before((done) => getCredentials(done)); + + describe('[/moderation.reportsByUsers]', () => { + it('should return an array of reports', async () => { + await request + .get(api('moderation.reportsByUsers')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with count and offset params', async () => { + await request + .get(api('moderation.reportsByUsers')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with oldest param', async () => { + await request + .get(api('moderation.reportsByUsers')) + .set(credentials) + .query({ + oldest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with latest param', async () => { + await request + .get(api('moderation.reportsByUsers')) + .set(credentials) + .query({ + latest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + }); + + describe('[/moderation.userReports]', () => { + it('should return an array of reports', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with count and offset params', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with oldest param', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + oldest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with latest param', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + latest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + }); + + // test for testing out the moderation.dismissReports endpoint + + describe('[/moderation.dismissReports]', () => { + let reportedMessage: IModerationAudit; + let message: IMessage; + + // post a new message to the channel 'general' by sending a request to chat.postMessage + before(async () => { + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: 'GENERAL', + msg: 'Sample message 0', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + message = res.body.message; + }); + }); + + beforeEach(async () => { + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + beforeEach(async () => { + await request + .get(api('moderation.reportsByUsers')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + reportedMessage = res.body.reports[0]; + }); + }); + + after(async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: 'GENERAL', + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should hide reports of a user', async () => { + await request + .post(api('moderation.dismissReports')) + .set(credentials) + .send({ + userId: reportedMessage.userId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should hide reports of a message', async () => { + await request + .post(api('moderation.dismissReports')) + .set(credentials) + .send({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return an error when the userId && msgId is not provided', async () => { + await request + .post(api('moderation.dismissReports')) + .set(credentials) + .send({ + userId: '', + msgId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + describe('[/moderation.user.reportsByUserId]', () => { + let reportedUser: IUser; + + before(async () => { + reportedUser = await createUser(); + await reportUser(reportedUser._id, 'sample report'); + }); + + after(async () => { + await deleteUser(reportedUser); + }); + + it('should return an array of reports', async () => { + await request + .get(api('moderation.user.reportsByUserId')) + .set(credentials) + .query({ + userId: reportedUser._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array').and.to.have.lengthOf(1); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .get(api('moderation.user.reportsByUserId')) + .set(credentials) + .query({ + userId: '', + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); + + describe('[/moderation.dismissUserReports', () => { + let reportedUser: IUser; + + before(async () => { + reportedUser = await createUser(); + await reportUser(reportedUser._id, 'sample report'); + }); + + after(async () => { + await deleteUser(reportedUser); + }); + + it('should hide reports of a user', async () => { + await request + .post(api('moderation.dismissUserReports')) + .set(credentials) + .send({ + userId: reportedUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await getUsersReports(reportedUser._id).then((res) => { + expect(res.reports).to.have.lengthOf(0); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .post(api('moderation.dismissUserReports')) + .set(credentials) + .send({ + userId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + // test for testing out the moderation.reports endpoint + + describe('[/moderation.reports]', () => { + let message: IMessage; + + // post a new message to the channel 'general' by sending a request to chat.postMessage + before(async () => { + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: 'general', + text: 'messageId', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + message = res.body.message; + }); + }); + + // create a reported message by sending a request to chat.reportMessage + before(async () => { + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: 'GENERAL', + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return the reports for a message', async () => { + await request + .get(api('moderation.reports')) + .set(credentials) + .query({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an error when the msgId is not provided', async () => { + await request + .get(api('moderation.reports')) + .set(credentials) + .query({ + msgId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + // test for testing out the moderation.reportInfo endpoint + + describe('[/moderation.reportInfo]', () => { + let message: IMessage; + let reportedMessage: IModerationReport; + + // post a new message to the channel 'general' by sending a request to chat.postMessage + before(async () => { + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: 'general', + text: 'messageId', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + message = res.body.message; + }); + }); + + // create a reported message by sending a request to chat.reportMessage + before(async () => { + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + // get the report information by sending a request to moderation.reports + before(async () => { + await request + .get(api('moderation.reports')) + .set(credentials) + .query({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + reportedMessage = res.body.reports[0]; + }); + }); + + after(async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: 'GENERAL', + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return the report information', async () => { + await request + .get(api('moderation.reportInfo')) + .set(credentials) + .query({ + reportId: reportedMessage._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('report').and.to.be.an('object'); + expect(res.body.report).to.have.property('_id', reportedMessage._id); + }); + }); + + it('should return an error when the reportId is not provided', async () => { + await request + .get(api('moderation.reportInfo')) + .set(credentials) + .query({ + reportId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + + it('should return an error when the reportId is invalid', async () => { + await request + .get(api('moderation.reportInfo')) + .set(credentials) + .query({ + reportId: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + + it('should return an error when the reportId is not found', async () => { + await request + .get(api('moderation.reportInfo')) + .set(credentials) + .query({ + reportId: '123456789012345678901234', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + // test for testing out the moderation.user.reportedMessages endpoint + + describe('[/moderation.user.reportedMessages]', () => { + let message: IMessage; + let reportedMessage: IModerationReport; + + // post a new message to the channel 'general' by sending a request to chat.postMessage + before(async () => { + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: 'general', + text: 'messageId', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + message = res.body.message; + }); + }); + + // create a reported message by sending a request to chat.reportMessage + before(async () => { + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + // get the report information by sending a request to moderation.reports + before(async () => { + await request + .get(api('moderation.reports')) + .set(credentials) + .query({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + reportedMessage = res.body.reports[0]; + }); + }); + + after(async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({ + roomId: 'GENERAL', + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return the message history', async () => { + await request + .get(api('moderation.user.reportedMessages')) + .set(credentials) + .query({ + userId: reportedMessage.reportedBy._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .get(api('moderation.user.reportedMessages')) + .set(credentials) + .query({ + userId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + // test for testing out the moderation.user.deleteReportedMessages endpoint + + describe('[/moderation.user.deleteReportedMessages]', () => { + let message: IMessage; + let reportedMessage: IModerationReport; + + // post a new message to the channel 'general' by sending a request to chat.postMessage + before(async () => { + await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + channel: 'general', + text: 'messageId', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + message = res.body.message; + }); + }); + + // create a reported message by sending a request to chat.reportMessage + before(async () => { + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: message._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + // get the report information by sending a request to moderation.reports + before(async () => { + await request + .get(api('moderation.reports')) + .set(credentials) + .query({ + msgId: message._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + reportedMessage = res.body.reports[0]; + }); + }); + + it('should delete the message history', async () => { + await request + .post(api('moderation.user.deleteReportedMessages')) + .set(credentials) + .send({ + userId: reportedMessage.reportedBy._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .post(api('moderation.user.deleteReportedMessages')) + .set(credentials) + .send({ + userId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + + describe('[/moderation.reportUser]', () => { + let userToBeReported: IUser; + + before(async () => { + userToBeReported = await createUser(); + }); + + after(async () => { + await deleteUser(userToBeReported); + }); + + it('should report an user', async () => { + await request + .post(api('moderation.reportUser')) + .set(credentials) + .send({ + userId: userToBeReported?._id, + description: 'sample report', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail to report an user if not provided description', async () => { + await request + .post(api('moderation.reportUser')) + .set(credentials) + .send({ + userId: userToBeReported?._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/oauth-server.ts b/apps/meteor/tests/end-to-end/api/oauth-server.ts new file mode 100644 index 0000000..c357073 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/oauth-server.ts @@ -0,0 +1,426 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +async function authorizeAndExchange(loginToken: string, cId: string, cSecret: string, redirectUri: string) { + const authRes = await request + .post(`/oauth/authorize`) + .type('form') + .send({ + token: loginToken, + client_id: cId, + response_type: 'code', + redirect_uri: redirectUri, + state: 'test-state', + allow: 'yes', + }) + .expect(302); + + const location = new URL(authRes.headers.location); + const code = location.searchParams.get('code') as string; + + const tokenRes = await request + .post(`/oauth/token`) + .type('form') + .send({ + grant_type: 'authorization_code', + code, + client_id: cId, + client_secret: cSecret, + redirect_uri: redirectUri, + }) + .expect(200); + + return { accessToken: tokenRes.body.access_token as string, refreshToken: tokenRes.body.refresh_token as string }; +} + +describe('[OAuth Server]', () => { + let oAuthAppId: string; + let clientId: string; + let clientSecret: string; + let code: string; + let refreshToken: string; + let accessToken: string; + let refreshedAccessToken: string; + const redirectUri = 'http://asd.com'; + + before((done) => getCredentials(done)); + + after(async () => { + await request + .post(api('oauth-apps.delete')) + .set(credentials) + .send({ appId: oAuthAppId }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + describe('[/oauth-apps.create]', () => { + it('should create the oauth app', async () => { + const data = { + name: 'api-test', + redirectUri: 'http://test.com,http://asd.com', + active: true, + }; + + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send(data) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('application'); + expect(res.body.application).to.have.property('_id'); + expect(res.body.application).to.have.property('name', data.name); + expect(res.body.application).to.have.property('redirectUri', data.redirectUri); + expect(res.body.application).to.have.property('active', data.active); + expect(res.body.application).to.have.property('clientId'); + expect(res.body.application).to.have.property('clientSecret'); + oAuthAppId = res.body.application._id; + clientId = res.body.application.clientId; + clientSecret = res.body.application.clientSecret; + }); + }); + + it('should authorize oauth to retrieve code', async () => { + const params = new URLSearchParams({ + scope: 'user', + response_type: 'token,code', + response_mode: 'form_post', + state: 'xus2t6ix57g', + }); + + await request + .post(`/oauth/authorize?${params.toString()}`) + .type('form') + .send({ + token: credentials['X-Auth-Token'], + client_id: clientId, + response_type: 'code', + redirect_uri: redirectUri, + allow: 'yes', + }) + .expect(302) + .expect((res: Response) => { + expect(res.headers).to.have.property('location'); + const location = new URL(res.headers.location); + expect(location.origin).to.be.equal(redirectUri); + expect(location.searchParams.get('code')).to.be.string; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + code = location.searchParams.get('code')!; + }); + }); + + it('should use code to retrieve access_token', async () => { + await request + .post(`/oauth/token`) + .type('form') + .send({ + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('token_type', 'Bearer'); + expect(res.body).to.have.property('access_token'); + expect(res.body).to.have.property('expires_in'); + expect(res.body).to.have.property('refresh_token'); + accessToken = res.body.access_token; + refreshToken = res.body.refresh_token; + }); + }); + + it('should return bad request if payload has non string parameters in refresh_token grant', async () => { + await request + .post(`/oauth/token`) + .send({ + grant_type: 'refresh_token', + client_id: { $ne: null }, + client_secret: { $ne: null }, + refresh_token: { $ne: null }, + }) + .expect((res: Response) => { + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('error').that.is.a('string').and.equal('invalid_request'); + }); + }); + + it('should return bad request if payload has non string parameters in authorization_code grant', async () => { + await request + .post(`/oauth/token`) + .send({ + grant_type: 'authorization_code', + client_id: { $ne: null }, + client_secret: { $ne: null }, + code: { $ne: null }, + redirect_uri: { $ne: null }, + }) + .expect((res: Response) => { + expect(res.status).to.be.equal(400); + expect(res.body).to.have.property('error').that.is.a('string').and.equal('invalid_request'); + }); + }); + + it('should be able to refresh the access_token', async () => { + await request + .post(`/oauth/token`) + .type('form') + .send({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('token_type', 'Bearer'); + expect(res.body).to.have.property('access_token').and.not.be.equal(accessToken); + expect(res.body).to.have.property('expires_in'); + expect(res.body).to.have.property('refresh_token'); + refreshedAccessToken = res.body.access_token; + }); + }); + + it('should not be able to get user info with old access_token', async () => { + await request.get(`/oauth/userinfo`).auth(accessToken, { type: 'bearer' }).expect(401); + }); + + it('should be able to get user info with refreshed access_token', async () => { + await request + .get(`/oauth/userinfo`) + .auth(refreshedAccessToken, { type: 'bearer' }) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('sub', 'rocketchat.internal.admin.test'); + }); + }); + }); + + describe('[oauth credentials]', () => { + let accessToken: string; + + before(() => { + accessToken = refreshedAccessToken; + }); + + it('should be able to use oauth credentials to access v1 endpoints (/v1/me)', async () => { + await request + .get(api('me')) + .auth(accessToken, { type: 'bearer' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', 'rocketchat.internal.admin.test'); + }); + }); + + it('should be able to use oauth credentials to access v1 endpoints (/v1/users.info)', async () => { + await request + .get(api('users.info')) + .query({ username: 'rocketchat.internal.admin.test' }) + .auth(accessToken, { type: 'bearer' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user._id', 'rocketchat.internal.admin.test'); + }); + }); + + const malformedTokenPayloads = [ + { query: { 'access_token[$ne]': 'null' }, description: '$ne operator' }, + { query: { 'access_token[$exists]': 'true' }, description: '$exists operator' }, + { query: { 'access_token[$gt]': '' }, description: '$gt operator' }, + { query: { 'access_token[$regex]': '.*' }, description: '$regex operator' }, + { query: { access_token: 'invalid-token' }, description: 'invalid string token' }, + ]; + + malformedTokenPayloads.forEach(({ query, description }) => { + it(`should reject access_token with ${description}`, async () => { + await request + .get(api('me')) + .query(query) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message', 'You must be logged in to do this.'); + }); + }); + }); + }); + + describe('[user deactivation revokes OAuth tokens]', () => { + let testUser: Awaited>; + let testUserCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let deactivationClientId: string; + let deactivationClientSecret: string; + let deactivationAppId: string; + let userAccessToken: string; + let userRefreshToken: string; + const redirectUri = 'http://asd.com'; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + const appRes = await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ name: 'deactivation-test-app', redirectUri: `http://test.com,${redirectUri}`, active: true }) + .expect(200); + + deactivationAppId = appRes.body.application._id; + deactivationClientId = appRes.body.application.clientId; + deactivationClientSecret = appRes.body.application.clientSecret; + + const tokens = await authorizeAndExchange( + testUserCredentials['X-Auth-Token'], + deactivationClientId, + deactivationClientSecret, + redirectUri, + ); + userAccessToken = tokens.accessToken; + userRefreshToken = tokens.refreshToken; + + // Verify tokens work before deactivation + await request.get(api('me')).auth(userAccessToken, { type: 'bearer' }).expect(200); + + // Deactivate the user + await request.post(api('users.setActiveStatus')).set(credentials).send({ userId: testUser._id, activeStatus: false }).expect(200); + }); + + after(async () => { + await request.post(api('oauth-apps.delete')).set(credentials).send({ appId: deactivationAppId }).expect(200); + await deleteUser(testUser); + }); + + it('should reject the access token after user deactivation', async () => { + await request.get(api('me')).auth(userAccessToken, { type: 'bearer' }).expect(401); + }); + + it('should reject the access token on /oauth/userinfo after user deactivation', async () => { + await request.get(`/oauth/userinfo`).auth(userAccessToken, { type: 'bearer' }).expect(401); + }); + + it('should reject the refresh token grant after user deactivation', async () => { + await request + .post(`/oauth/token`) + .type('form') + .send({ + grant_type: 'refresh_token', + refresh_token: userRefreshToken, + client_id: deactivationClientId, + client_secret: deactivationClientSecret, + }) + .expect((res: Response) => { + expect(res.status).to.not.equal(200); + expect(res.body).to.have.property('error'); + expect(res.body).to.not.have.property('access_token'); + }); + }); + + it('should still reject the access token after user reactivation (token was deleted, not just blocked)', async () => { + await request.post(api('users.setActiveStatus')).set(credentials).send({ userId: testUser._id, activeStatus: true }).expect(200); + + await request.get(api('me')).auth(userAccessToken, { type: 'bearer' }).expect(401); + + // Reactivated user can obtain new tokens via a fresh OAuth flow + const reactivatedCredentials = await login(testUser.username, password); + const newTokens = await authorizeAndExchange( + reactivatedCredentials['X-Auth-Token'], + deactivationClientId, + deactivationClientSecret, + redirectUri, + ); + + await request + .get(api('me')) + .auth(newTokens.accessToken, { type: 'bearer' }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', testUser._id); + }); + }); + }); + + describe('[users.deactivateIdle revokes OAuth tokens]', () => { + let idleUser: Awaited>; + let idleUserCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let idleClientId: string; + let idleClientSecret: string; + let idleAppId: string; + let idleAccessToken: string; + let idleRefreshToken: string; + const redirectUri = 'http://asd.com'; + + before(async () => { + idleUser = await createUser(); + idleUserCredentials = await login(idleUser.username, password); + + const appRes = await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ name: 'idle-deactivation-test-app', redirectUri: `http://test.com,${redirectUri}`, active: true }) + .expect(200); + + idleAppId = appRes.body.application._id; + idleClientId = appRes.body.application.clientId; + idleClientSecret = appRes.body.application.clientSecret; + + const tokens = await authorizeAndExchange(idleUserCredentials['X-Auth-Token'], idleClientId, idleClientSecret, redirectUri); + idleAccessToken = tokens.accessToken; + idleRefreshToken = tokens.refreshToken; + + // Verify tokens work before deactivation + await request.get(api('me')).auth(idleAccessToken, { type: 'bearer' }).expect(200); + + // Deactivate via deactivateIdle using daysIdle=0 to catch all users with no recent login + await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ daysIdle: 0, role: idleUser.roles?.[0] ?? 'user' }) + .expect(200); + }); + + after(async () => { + await request.post(api('oauth-apps.delete')).set(credentials).send({ appId: idleAppId }).expect(200); + await deleteUser(idleUser); + }); + + it('should reject the access token after idle deactivation', async () => { + await request.get(api('me')).auth(idleAccessToken, { type: 'bearer' }).expect(401); + }); + + it('should reject the refresh token grant after idle deactivation', async () => { + await request + .post(`/oauth/token`) + .type('form') + .send({ + grant_type: 'refresh_token', + refresh_token: idleRefreshToken, + client_id: idleClientId, + client_secret: idleClientSecret, + }) + .expect((res: Response) => { + expect(res.status).to.not.equal(200); + expect(res.body).to.have.property('error'); + expect(res.body).to.not.have.property('access_token'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/oauthapps.ts b/apps/meteor/tests/end-to-end/api/oauthapps.ts new file mode 100644 index 0000000..2ca17b6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/oauthapps.ts @@ -0,0 +1,420 @@ +import type { IOAuthApps } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('[OAuthApps]', () => { + const createdAppsIds: IOAuthApps['_id'][] = []; + + before((done) => getCredentials(done)); + + after(() => + Promise.all([ + updatePermission('manage-oauth-apps', ['admin']), + ...createdAppsIds.map((appId) => + request.post(api(`oauth-apps.delete`)).set(credentials).send({ + appId, + }), + ), + ]), + ); + + describe('[/oauth-apps.list]', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('manage-oauth-apps', []); + + const res = await request.get(api('oauth-apps.list')).set(credentials).expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return an array of oauth apps', async () => { + await updatePermission('manage-oauth-apps', ['admin']); + + const res = await request.get(api('oauth-apps.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApps').and.to.be.an('array'); + }); + }); + + describe('[/oauth-apps.create]', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('manage-oauth-apps', []); + + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name: 'error', + redirectUri: 'error', + active: false, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + await updatePermission('manage-oauth-apps', ['admin']); + }); + + it("should return an error when the 'name' property is invalid", async () => { + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name: '', + redirectUri: 'error', + active: false, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-name'); + }); + }); + + it("should return an error when the 'redirectUri' property is invalid", async () => { + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name: 'error', + redirectUri: '', + active: false, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-redirectUri'); + }); + }); + + it("should return an error when the 'active' property is not a boolean", async () => { + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name: 'error', + redirectUri: 'error', + active: 'anything', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should create an oauthApp', async () => { + const name = `new app ${Date.now()}`; + const redirectUri = 'http://localhost:3000'; + const active = true; + + await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name, + redirectUri, + active, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('application.name', name); + expect(res.body).to.have.nested.property('application.redirectUri', redirectUri); + expect(res.body).to.have.nested.property('application.active', active); + createdAppsIds.push(res.body.application._id); + }); + }); + }); + + describe('[/oauth-apps.get]', () => { + let clientId = ''; + let _id = ''; + let clientSecret = ''; + + before(async () => { + await updatePermission('manage-oauth-apps', ['admin']); + + const res = await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name: `new app ${Date.now()}`, + redirectUri: 'http://localhost:3000', + active: true, + }); + + if (res.statusCode !== 200 || !res.body?.success || !res.body.application?._id || !res.body.application?.clientId) { + console.error(res); + throw new Error('Failed to create oauth app for tests'); + } + + clientId = res.body.application.clientId; + _id = res.body.application._id; + clientSecret = res.body.application.clientSecret; + createdAppsIds.push(_id); + }); + after(() => updatePermission('manage-oauth-apps', ['admin'])); + + it('should return a single oauthApp by client id', () => { + return request + .get(api('oauth-apps.get')) + .query({ clientId }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal(_id); + expect(res.body.oauthApp).to.have.property('clientSecret'); + + if (clientSecret) { + expect(res.body.oauthApp.clientSecret).to.be.equal(clientSecret); + } + }); + }); + + it('should return a single oauthApp by _id', () => { + return request + .get(api('oauth-apps.get')) + .query({ _id }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal(_id); + expect(res.body.oauthApp.clientId).to.be.equal(clientId); + expect(res.body.oauthApp).to.have.property('clientSecret'); + if (clientSecret) { + expect(res.body.oauthApp.clientSecret).to.be.equal(clientSecret); + } + }); + }); + + it('should return only non sensitive information if user does not have the permission to manage oauth apps when searching by clientId', async () => { + await updatePermission('manage-oauth-apps', []); + await request + .get(api('oauth-apps.get')) + .query({ clientId }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal(_id); + expect(res.body.oauthApp.clientId).to.be.equal(clientId); + expect(res.body.oauthApp).to.not.have.property('clientSecret'); + }); + }); + + it('should return only non sensitive information if user does not have the permission to manage oauth apps when searching by _id', async () => { + await updatePermission('manage-oauth-apps', []); + await request + .get(api('oauth-apps.get')) + .query({ _id }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('oauthApp'); + expect(res.body.oauthApp._id).to.be.equal(_id); + expect(res.body.oauthApp.clientId).to.be.equal(clientId); + expect(res.body.oauthApp).to.not.have.property('clientSecret'); + }); + }); + + it('should fail returning an oauth app when an invalid id is provided (avoid NoSQL injections)', () => { + return request + .get(api('oauth-apps.get')) + .query({ _id: { $ne: '' } }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.include('must be string').and.include('must match exactly one schema in oneOf'); + }); + }); + + it('should fail returning an oauth app when an invalid id string is provided (avoid NoSQL injections)', () => { + return request + .get(api('oauth-apps.get')) + .query({ _id: '{ "$ne": "" }' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'OAuth app not found.'); + }); + }); + + it('should fail returning an oauth app when an invalid clientId is provided (avoid NoSQL injections)', () => { + return request + .get(api('oauth-apps.get')) + .query({ clientId: { $ne: '' } }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.include('must be string').and.include('must match exactly one schema in oneOf'); + }); + }); + + it('should fail returning an oauth app when an invalid clientId string is provided (avoid NoSQL injections)', () => { + return request + .get(api('oauth-apps.get')) + .query({ clientId: '{ "$ne": "" }' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'OAuth app not found.'); + }); + }); + }); + + describe('[/oauth-apps.update]', () => { + let appId: IOAuthApps['_id']; + + before(async () => { + await updatePermission('manage-oauth-apps', ['admin']); + const name = 'test-oauth-app'; + const redirectUri = 'https://test.com'; + const active = true; + const res = await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name, + redirectUri, + active, + }) + .expect('Content-Type', 'application/json') + .expect(200); + appId = res.body.application._id; + createdAppsIds.push(appId); + }); + + after(async () => { + await updatePermission('manage-oauth-apps', ['admin']); + }); + + it("should update an app's name, its Active and Redirect URI fields correctly by its id", async () => { + const name = `new app ${Date.now()}`; + const redirectUri = 'http://localhost:3000'; + const active = false; + + await request + .post(api(`oauth-apps.update`)) + .set(credentials) + .send({ + appId, + name, + redirectUri, + active, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('active', active); + expect(res.body).to.have.property('redirectUri', redirectUri); + expect(res.body).to.have.property('name', name); + }); + }); + + it('should fail updating an app if user does NOT have the manage-oauth-apps permission', async () => { + const name = `new app ${Date.now()}`; + const redirectUri = 'http://localhost:3000'; + const active = false; + + await updatePermission('manage-oauth-apps', []); + await request + .post(api(`oauth-apps.update`)) + .set(credentials) + .send({ + appId, + name, + redirectUri, + active, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/oauth-apps.delete]', () => { + let appId: IOAuthApps['_id']; + + before(async () => { + await updatePermission('manage-oauth-apps', ['admin']); + const name = 'test-oauth-app'; + const redirectUri = 'https://test.com'; + const active = true; + const res = await request + .post(api('oauth-apps.create')) + .set(credentials) + .send({ + name, + redirectUri, + active, + }) + .expect('Content-Type', 'application/json') + .expect(200); + appId = res.body.application._id; + }); + + after(async () => { + await updatePermission('manage-oauth-apps', ['admin']); + }); + + it('should delete an app by its id', async () => { + await request + .post(api(`oauth-apps.delete`)) + .set(credentials) + .send({ + appId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.equals(true); + }); + }); + + it('should fail deleting an app by its id if user does NOT have the manage-oauth-apps permission', async () => { + await updatePermission('manage-oauth-apps', []); + await request + .post(api(`oauth-apps.delete`)) + .set(credentials) + .send({ + appId, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/outgoing-integrations.ts b/apps/meteor/tests/end-to-end/api/outgoing-integrations.ts new file mode 100644 index 0000000..4107c3b --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/outgoing-integrations.ts @@ -0,0 +1,651 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IIntegration, IUser } from '@rocket.chat/core-typings'; +import { assert, expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createIntegration, removeIntegration } from '../../data/integration.helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Outgoing Integrations]', () => { + let integrationCreatedByAnUser: IIntegration; + let user: TestUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + await updatePermission('manage-outgoing-integrations', ['user']); + integrationCreatedByAnUser = await createIntegration( + { + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }, + userCredentials, + ); + }); + + before(async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + updatePermission('manage-outgoing-integrations', []), + ]); + }); + + after(async () => + Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + deleteUser(user), + ]), + ); + + let integration: IIntegration; + + describe('[/integrations.create]', () => { + it('should return an error when the user DOES NOT have the permission "manage-outgoing-integrations" to add an outgoing integration', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }) + .end(done); + }); + }); + + it('should return an error when the user DOES NOT have the permission "manage-own-outgoing-integrations" to add an outgoing integration', (done) => { + void updatePermission('manage-own-outgoing-integrations', []).then(() => { + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }) + .end(done); + }); + }); + + it('should return an error when the user sends an invalid type of integration', (done) => { + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing-invalid', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Invalid integration type.'); + }) + .end(done); + }); + + it('should add the integration successfully when the user ONLY has the permission "manage-outgoing-integrations" to add an outgoing integration', (done) => { + let integrationId: IIntegration['_id']; + void updatePermission('manage-outgoing-integrations', ['admin']).then(() => { + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integrationId = res.body.integration._id; + }) + .end(() => removeIntegration(integrationId, 'outgoing').then(done)); + }); + }); + + it('should add the integration successfully when the user ONLY has the permission "manage-own-outgoing-integrations" to add an outgoing integration', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void updatePermission('manage-own-outgoing-integrations', ['admin']).then(() => { + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration').and.to.be.an('object'); + integration = res.body.integration; + }) + .end(done); + }); + }); + }); + + it('should create an outgoing integration successfully', (done) => { + let integrationId: IIntegration['_id']; + void request + .post(api('integrations.create')) + .set(credentials) + .send({ + type: 'webhook-outgoing', + name: 'Guggy', + enabled: true, + username: 'rocket.cat', + urls: ['http://text2gif.guggy.com/guggify'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!guggy'], + alias: 'guggy', + avatar: 'http://res.guggy.com/logo_128.png', + emoji: ':ghost:', + event: 'sendMessage', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('integration.name', 'Guggy'); + expect(res.body).to.have.nested.property('integration.type', 'webhook-outgoing'); + expect(res.body).to.have.nested.property('integration.enabled', true); + expect(res.body).to.have.nested.property('integration.username', 'rocket.cat'); + expect(res.body).to.have.nested.property('integration.event', 'sendMessage'); + integrationId = res.body.integration._id; + }) + .end(() => removeIntegration(integrationId, 'outgoing').then(done)); + }); + }); + + describe('[/integrations.list]', () => { + it('should return the list of outgoing integrations', (done) => { + void request + .get(api('integrations.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreatedByAdmin = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integration._id, + ); + assert.isDefined(integrationCreatedByAdmin); + expect(integrationCreatedByAdmin).to.be.an('object'); + expect(integrationCreatedByAdmin._id).to.be.equal(integration._id); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('items'); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + + it('should return the list create by the user only', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void updatePermission('manage-own-outgoing-integrations', ['user']).then(() => { + void request + .get(api('integrations.list')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreatedByAdmin = (res.body.integrations as IIntegration[]).find( + (createdIntegration) => createdIntegration._id === integration._id, + ); + expect(integrationCreatedByAdmin).to.be.equal(undefined); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('items'); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + }); + }); + + it('should return unauthorized error when the user does not have any integrations permissions', async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-outgoing-integrations', []), + ]); + + await request + .get(api('integrations.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/integrations.history]', () => { + it('should return an error when the user DOES NOT the necessary permission', async () => { + await updatePermission('manage-outgoing-integrations', []); + await updatePermission('manage-own-outgoing-integrations', []); + await request + .get(api('integrations.history')) + .set(credentials) + .query({ + id: integration._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should return the history of outgoing integrations', (done) => { + void updatePermission('manage-outgoing-integrations', ['admin']).then(() => { + void request + .get(api('integrations.history')) + .set(credentials) + .query({ + id: integration._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('history').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('items'); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + }); + }); + + describe('[/integrations.get]', () => { + it('should return an error when the required "integrationId" query parameters is not sent', (done) => { + void request + .get(api('integrations.get')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `must have required property 'integrationId'`); + }) + .end(done); + }); + + it('should return an error when the user DOES NOT have the permission "manage-outgoing-integrations" to get an outgoing integration', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void request + .get(api('integrations.get')) + .query({ integrationId: integration._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'not-authorized'); + }) + .end(done); + }); + }); + + it('should return an error when the user DOES NOT have the permission "manage-outgoing-integrations" to get an outgoing integration created by another user', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void request + .get(api('integrations.get')) + .query({ integrationId: integrationCreatedByAnUser._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'not-authorized'); + }) + .end(done); + }); + }); + + it('should return an error when the user sends an invalid integration', (done) => { + void updatePermission('manage-outgoing-integrations', ['admin']).then(() => { + void request + .get(api('integrations.get')) + .query({ integrationId: 'invalid' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The integration does not exists.'); + }) + .end(done); + }); + }); + + it('should return the integration successfully when the user is able to see only your own integrations', (done) => { + void updatePermission('manage-outgoing-integrations', []) + .then(() => updatePermission('manage-own-outgoing-integrations', ['user'])) + .then(() => { + void request + .get(api('integrations.get')) + .query({ integrationId: integrationCreatedByAnUser._id }) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integrationCreatedByAnUser._id); + }) + .end(done); + }); + }); + + it('should return the integration successfully', (done) => { + void updatePermission('manage-outgoing-integrations', ['admin']).then(() => { + void request + .get(api('integrations.get')) + .query({ integrationId: integration._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('integration'); + expect(res.body.integration._id).to.be.equal(integration._id); + }) + .end(done); + }); + }); + }); + + describe('[/integrations.remove]', () => { + it('should return an error when the user DOES NOT have the permission "manage-outgoing-integrations" to remove an outgoing integration', (done) => { + void updatePermission('manage-outgoing-integrations', []).then(() => { + void request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'webhook-outgoing', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }) + .end(done); + }); + }); + + it('should return an error when the user DOES NOT have the permission "manage-own-outgoing-integrations" to remove an outgoing integration', (done) => { + void updatePermission('manage-own-incoming-integrations', []).then(() => { + void request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'webhook-outgoing', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }) + .end(done); + }); + }); + + it('should return an error when the user sends an invalid type of integration', (done) => { + void updatePermission('manage-own-outgoing-integrations', ['admin']).then(() => { + void request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: 'invalid-type', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').include(`must match exactly one schema in oneOf`); + }) + .end(done); + }); + }); + + it('should remove the integration successfully when the user at least one of the necessary permission to remove an outgoing integration', (done) => { + void updatePermission('manage-outgoing-integrations', ['admin']).then(() => { + void request + .post(api('integrations.remove')) + .set(credentials) + .send({ + integrationId: integration._id, + type: integration.type, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + + it('the normal user should remove the integration successfully when the user have the "manage-own-outgoing-integrations" to remove an outgoing integration', (done) => { + void updatePermission('manage-own-outgoing-integrations', ['user']).then(() => { + void request + .post(api('integrations.remove')) + .set(userCredentials) + .send({ + integrationId: integrationCreatedByAnUser._id, + type: integrationCreatedByAnUser.type, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + }); + + describe('[/integrations.clearHistory] and [/integrations.replayOutgoing]', () => { + let integrationId: IIntegration['_id']; + + before(async () => { + await updatePermission('manage-outgoing-integrations', ['admin']); + const created = await createIntegration( + { + type: 'webhook-outgoing', + name: 'clear-replay-test', + enabled: true, + username: 'rocket.cat', + urls: ['http://example.com/hook'], + scriptEnabled: false, + channel: '#general', + triggerWords: ['!clearreplaytest'], + event: 'sendMessage', + }, + credentials, + ); + integrationId = created._id; + }); + + after(async () => { + await updatePermission('manage-outgoing-integrations', ['admin']); + await removeIntegration(integrationId, 'outgoing'); + await Promise.all([updatePermission('manage-outgoing-integrations', []), updatePermission('manage-own-outgoing-integrations', [])]); + }); + + // the shared method enforces the permission (no route-level permissionsRequired), so a permission + // failure surfaces as 400 + errorType 'not_authorized', matching integrations.create above. + afterEach(() => updatePermission('manage-outgoing-integrations', ['admin'])); + + describe('[/integrations.clearHistory]', () => { + it('should fail when unauthenticated', () => request.post(api('integrations.clearHistory')).send({ integrationId }).expect(401)); + + it('should fail with 400 when integrationId is not provided', () => + request.post(api('integrations.clearHistory')).set(credentials).send({}).expect(400)); + + it('should fail with 400 when the integration does not exist', () => + request + .post(api('integrations.clearHistory')) + .set(credentials) + .send({ integrationId: 'does-not-exist' }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should fail with 400 when the user lacks the manage-outgoing-integrations permission', async () => { + await Promise.all([updatePermission('manage-outgoing-integrations', []), updatePermission('manage-own-outgoing-integrations', [])]); + await request + .post(api('integrations.clearHistory')) + .set(credentials) + .send({ integrationId }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'not_authorized'); + }); + }); + + it('should clear the integration history', () => + request + .post(api('integrations.clearHistory')) + .set(credentials) + .send({ integrationId }) + .expect(200) + .expect((res) => expect(res.body).to.have.property('success', true))); + }); + + describe('[/integrations.replayOutgoing]', () => { + it('should fail when unauthenticated', () => + request.post(api('integrations.replayOutgoing')).send({ integrationId, historyId: 'x' }).expect(401)); + + it('should fail with 400 when required params are missing', () => + request.post(api('integrations.replayOutgoing')).set(credentials).send({ integrationId }).expect(400)); + + it('should fail with 400 when the integration does not exist', () => + request + .post(api('integrations.replayOutgoing')) + .set(credentials) + .send({ integrationId: 'does-not-exist', historyId: 'x' }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should fail with 400 when the history does not exist', () => + request + .post(api('integrations.replayOutgoing')) + .set(credentials) + .send({ integrationId, historyId: 'does-not-exist' }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should fail with 400 when the user lacks permission (integration becomes inaccessible)', async () => { + await Promise.all([updatePermission('manage-outgoing-integrations', []), updatePermission('manage-own-outgoing-integrations', [])]); + await request + .post(api('integrations.replayOutgoing')) + .set(credentials) + .send({ integrationId, historyId: 'x' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-integration'); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/permissions.ts b/apps/meteor/tests/end-to-end/api/permissions.ts new file mode 100644 index 0000000..1a18310 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/permissions.ts @@ -0,0 +1,270 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import type { TestUser } from '../../data/users.helper.js'; + +describe('[Permissions]', () => { + before((done) => getCredentials(done)); + + after(() => updatePermission('add-oauth-service', ['admin'])); + + describe('[/permissions.listAll]', () => { + it('should return an array with update and remove properties', async () => { + const res = await request.get(api('permissions.listAll')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update').and.to.be.an('array'); + expect(res.body).to.have.property('remove').and.to.be.an('array'); + }); + + it('should return an array with update and remov properties when search by "updatedSince" query parameter', async () => { + const res = await request + .get(api('permissions.listAll')) + .query({ updatedSince: '2018-11-27T13:52:01Z' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update').and.to.be.an('array'); + expect(res.body).to.have.property('remove').and.to.be.an('array'); + }); + + it('should return an error when updatedSince query parameter is not a valid ISODate string', async () => { + const res = await request + .get(api('permissions.listAll')) + .query({ updatedSince: 'fsafdf' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + + describe('[/permissions.update]', () => { + let testUser: TestUser; + let testUserCredentials: Credentials; + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + await updatePermission('access-permissions', ['admin']); + }); + + after(async () => { + await updatePermission('access-permissions', ['admin']); + await deleteUser(testUser); + }); + + it('should change the permissions on the server', async () => { + const permissions = [ + { + _id: 'add-oauth-service', + roles: ['admin', 'user'], + }, + ]; + + const res = await request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('permissions'); + + const firstElement = res.body.permissions[0]; + expect(firstElement).to.have.property('_id'); + expect(firstElement).to.have.property('roles').and.to.be.a('array'); + expect(firstElement).to.have.property('_updatedAt'); + }); + + it('should 400 when trying to set an unknown permission', async () => { + const permissions = [ + { + _id: 'this-permission-does-not-exist', + roles: ['admin'], + }, + ]; + + const res = await request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should 400 when trying to assign a permission to an unknown role', async () => { + const permissions = [ + { + _id: 'add-oauth-service', + roles: ['this-role-does-not-exist'], + }, + ]; + + const res = await request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should 400 when trying to set permissions to a string', async () => { + const permissions = ''; + + const res = await request + .post(api('permissions.update')) + .set(credentials) + .send({ permissions }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should fail updating permission if user does NOT have the access-permissions permission', async () => { + const permissions = [ + { + _id: 'add-oauth-service', + roles: ['admin', 'user'], + }, + ]; + await request + .post(api('permissions.update')) + .set(testUserCredentials) + .send({ permissions }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/permissions.addRole]', () => { + const testPermission = 'add-oauth-service'; + const testRole = 'moderator'; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + await updatePermission('access-permissions', ['admin']); + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + after(async () => { + await request.post(api('permissions.removeRole')).set(credentials).send({ permissionId: testPermission, role: testRole }); + await deleteUser(testUser); + await updatePermission('access-permissions', ['admin']); + }); + + it('should fail when unauthenticated', () => + request.post(api('permissions.addRole')).send({ permissionId: testPermission, role: testRole }).expect(401)); + + it('should fail with 400 when the permission does not exist', () => + request + .post(api('permissions.addRole')) + .set(credentials) + .send({ permissionId: 'this-permission-does-not-exist', role: testRole }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should fail with 400 when the role does not exist', () => + request + .post(api('permissions.addRole')) + .set(credentials) + .send({ permissionId: testPermission, role: 'this-role-does-not-exist' }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should fail with 403 when the user lacks the access-permissions permission', () => + request + .post(api('permissions.addRole')) + .set(testUserCredentials) + .send({ permissionId: testPermission, role: testRole }) + .expect(403) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should add the role to the permission', async () => { + await request + .post(api('permissions.addRole')) + .set(credentials) + .send({ permissionId: testPermission, role: testRole }) + .expect(200) + .expect((res) => expect(res.body).to.have.property('success', true)); + + await request + .get(api('permissions.listAll')) + .set(credentials) + .expect(200) + .expect((res) => { + const permission = res.body.update.find((p: { _id: string; roles: string[] }) => p._id === testPermission); + expect(permission).to.be.an('object'); + expect(permission.roles).to.include(testRole); + }); + }); + }); + + describe('[/permissions.removeRole]', () => { + const testPermission = 'add-oauth-service'; + const testRole = 'moderator'; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + await updatePermission('access-permissions', ['admin']); + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + await request.post(api('permissions.addRole')).set(credentials).send({ permissionId: testPermission, role: testRole }); + }); + + after(async () => { + await deleteUser(testUser); + await updatePermission('access-permissions', ['admin']); + }); + + it('should fail when unauthenticated', () => + request.post(api('permissions.removeRole')).send({ permissionId: testPermission, role: testRole }).expect(401)); + + it('should fail with 403 when the user lacks the access-permissions permission', () => + request + .post(api('permissions.removeRole')) + .set(testUserCredentials) + .send({ permissionId: testPermission, role: testRole }) + .expect(403) + .expect((res) => expect(res.body).to.have.property('success', false))); + + it('should remove the role from the permission', async () => { + await request + .post(api('permissions.removeRole')) + .set(credentials) + .send({ permissionId: testPermission, role: testRole }) + .expect(200) + .expect((res) => expect(res.body).to.have.property('success', true)); + + await request + .get(api('permissions.listAll')) + .set(credentials) + .expect(200) + .expect((res) => { + const permission = res.body.update.find((p: { _id: string; roles: string[] }) => p._id === testPermission); + expect(permission).to.be.an('object'); + expect(permission.roles).to.not.include(testRole); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/presence.ts b/apps/meteor/tests/end-to-end/api/presence.ts new file mode 100644 index 0000000..a622dde --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/presence.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Presence]', () => { + let createdUser: any; + + before((done) => getCredentials(done)); + + let unauthorizedUserCredentials: any; + before(async () => { + createdUser = await createUser(); + unauthorizedUserCredentials = await login(createdUser.username, password); + }); + + after(() => Promise.all([updatePermission('manage-user-status', ['admin']), deleteUser(createdUser)])); + + describe('[/presence.getConnections]', () => { + it('should throw an error if not authenticated', async () => { + await request + .get(api('presence.getConnections')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should throw an error if user is unauthorized', async () => { + await request + .get(api('presence.getConnections')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it("should throw an error if doesn't have required permission", async () => { + await updatePermission('manage-user-status', []); + + await request + .get(api('presence.getConnections')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + await updatePermission('manage-user-status', ['admin']); + }); + + it('should return current and max connections of 200', async () => { + await request + .get(api('presence.getConnections')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('current').to.be.a('number'); + expect(res.body).to.have.property('max').to.be.a('number').and.to.be.equal(200); + }); + }); + }); + + describe('[/presence.enableBroadcast]', () => { + it('should throw an error if not authenticated', async () => { + await request + .post(api('presence.enableBroadcast')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should throw an error if user is unauthorized', async () => { + await request + .post(api('presence.enableBroadcast')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it("should throw an error if doesn't have required permission", async () => { + await updatePermission('manage-user-status', []); + + await request + .post(api('presence.enableBroadcast')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + await updatePermission('manage-user-status', ['admin']); + }); + + it('should return success', async () => { + await request + .post(api('presence.enableBroadcast')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + // describe('[/presence.enableBroadcast]', () => {}); +}); diff --git a/apps/meteor/tests/end-to-end/api/push.ts b/apps/meteor/tests/end-to-end/api/push.ts new file mode 100644 index 0000000..a5a8183 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/push.ts @@ -0,0 +1,286 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updateSetting } from '../../data/permissions.helper'; + +describe('[Push]', () => { + before((done) => getCredentials(done)); + + describe('POST [/push.token]', () => { + it('should succeed with a valid gcm token', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'gcm', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('object'); + }); + }); + + it('should succeed with a valid apn token', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'apn', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result').and.to.be.an('object'); + }); + }); + + it('should fail if not logged in', async () => { + await request + .post(api('push.token')) + .send({ + type: 'gcm', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if missing type', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'type'`); + }); + }); + + it('should fail if missing value', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'gcm', + appName: 'com.example.rocketchat', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'value'`); + }); + }); + + it('should fail if missing appName', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'gcm', + value: 'token', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'appName'`); + }); + }); + + it('should fail if type param is unknown', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'unknownPlatform', + value: 'token', + appName: 'com.example.rocketchat', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must be equal to one of the allowed values`); + }); + }); + + it('should fail if token param is empty', async () => { + await request + .post(api('push.token')) + .set(credentials) + .send({ + type: 'gcm', + appName: 'com.example.rocketchat', + value: '', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must NOT have fewer than 1 characters`); + }); + }); + }); + + describe('DELETE [/push.token]', () => { + it('should fail if not logged in', async () => { + await request + .delete(api('push.token')) + .send({ + token: 'token', + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if missing token key', async () => { + await request + .delete(api('push.token')) + .set(credentials) + .send({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must have required property 'token'`); + }); + }); + + it('should fail if token is empty', async () => { + await request + .delete(api('push.token')) + .set(credentials) + .send({ + token: '', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').that.includes(`must NOT have fewer than 1 characters`); + }); + }); + + it('should fail if token is invalid', async () => { + await request + .delete(api('push.token')) + .set(credentials) + .send({ + token: '123', + }) + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should delete a token if valid', async () => { + await request + .delete(api('push.token')) + .set(credentials) + .send({ + token: 'token', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail if token is already deleted', async () => { + await request + .delete(api('push.token')) + .set(credentials) + .send({ + token: 'token', + }) + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/push.test]', () => { + before(() => updateSetting('Push_enable', false)); + + it('should fail if not logged in', async () => { + await request + .post(api('push.test')) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if push is disabled', async () => { + await request + .post(api('push.test')) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-push-disabled'); + }); + }); + }); + + describe('[/push.info]', () => { + before(() => Promise.all([updateSetting('Push_gateway', 'https://random-gateway.rocket.chat')])); + + after(() => Promise.all([updateSetting('Push_gateway', 'https://gateway.rocket.chat')])); + + it('should fail if not logged in', async () => { + await request + .get(api('push.info')) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should succesfully retrieve non default push notification info', async () => { + await request + .get(api('push.info')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('pushGatewayEnabled', false); + expect(res.body).to.have.property('defaultPushGateway', false); + }); + }); + + it('should succesfully retrieve default push notification info', async () => { + await updateSetting('Push_gateway', 'https://gateway.rocket.chat'); + await request + .get(api('push.info')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('pushGatewayEnabled', false); + expect(res.body).to.have.property('defaultPushGateway', true); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/roles.ts b/apps/meteor/tests/end-to-end/api/roles.ts new file mode 100644 index 0000000..e369aae --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/roles.ts @@ -0,0 +1,575 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { password, adminUsername } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Roles]', function () { + this.retries(0); + const isEnterprise = Boolean(process.env.IS_EE); + + before((done) => getCredentials(done)); + + describe.skip('[/roles.create]', () => { + const testRoleName = `role.test.${Date.now()}`; + it('should throw an error when not running EE to create a role', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (isEnterprise) { + this.skip(); + } + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'This is an enterprise feature [error-action-not-allowed]'); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should successfully create a role in EE', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', testRoleName); + }); + }); + }); + + describe('[/roles.update]', () => { + const testRoleName = `role.test.${Date.now()}`; + const newTestRoleName = `role.test.updated.${Date.now()}`; + let testRoleId = ''; + + before('Create a new role with Users scope', async () => { + if (!isEnterprise) { + return; + } + + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', testRoleName); + testRoleId = res.body.role._id; + }); + }); + + after(async () => { + if (!isEnterprise) { + return; + } + await request.post(api('roles.delete')).set(credentials).send({ + roleId: testRoleId, + }); + }); + + it('should throw an error when not running EE to update a role', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (isEnterprise) { + this.skip(); + } + await request + .post(api('roles.update')) + .set(credentials) + .send({ + name: testRoleName, + roleId: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'This is an enterprise feature [error-action-not-allowed]'); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + + it('should successfully update a role in EE', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.update')) + .set(credentials) + .send({ + name: newTestRoleName, + roleId: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', newTestRoleName); + }); + }); + }); + + describe('[/roles.getUsersInRole]', () => { + let testUser: IUser; + let testUserCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + const testRoleName = `role.getUsersInRole.${Date.now()}`; + let testRoleId = ''; + + before(async () => { + await updatePermission('access-permissions', ['admin']); + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + if (!isEnterprise) { + return; + } + + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', testRoleName); + testRoleId = res.body.role._id; + }); + }); + + after(async () => { + await deleteUser(testUser); + if (!isEnterprise) { + return; + } + + await request.post(api('roles.delete')).set(credentials).send({ + roleId: testRoleId, + }); + }); + + it('should successfully get an empty list of users in a role if no user has been assigned to it', async function () { + if (!isEnterprise) { + this.skip(); + } + + await request + .get(api('roles.getUsersInRole')) + .set(credentials) + .query({ + role: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('users').that.is.an('array').that.is.empty; + }); + }); + + it('should successfully get a list of users in a role', async function () { + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.addUserToRole')) + .set(credentials) + .send({ + roleId: testRoleId, + username: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('roles.getUsersInRole')) + .set(credentials) + .query({ + role: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('users').that.is.an('array').of.length(1); + expect(res.body.users[0]).to.have.property('_id', credentials['X-User-Id']); + }); + }); + + it('should fail getting a list of users in a role in case an invalid role is provided', async function () { + if (!isEnterprise) { + this.skip(); + } + + await request + .get(api('roles.getUsersInRole')) + .set(credentials) + .query({ + role: 'invalid-role', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-roleId'); + }); + }); + + it('should fail when user does NOT have the access-permissions permission', async () => { + await request + .get(api('roles.getUsersInRole')) + .set(testUserCredentials) + .query({ + role: 'admin', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + (isEnterprise ? it : it.skip)('should fail getting a list of users in a role in case a role name is provided', async () => { + await request + .get(api('roles.getUsersInRole')) + .set(credentials) + .query({ + role: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-roleId'); + }); + }); + }); + + describe('[/roles.delete]', () => { + let testUser: IUser; + let testUserCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + const testRoleName = `role.delete.${Date.now()}`; + let testRoleId = ''; + + before(async () => { + if (!isEnterprise) { + return; + } + + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + await updatePermission('access-permissions', ['admin']); + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', testRoleName); + testRoleId = res.body.role._id; + }); + }); + + after(async () => { + if (!isEnterprise) { + return; + } + await deleteUser(testUser); + }); + + it('should fail deleting a role when user does NOT have the access-permissions permission', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.delete')) + .set(testUserCredentials) + .send({ + roleId: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should fail deleting a role in EE in case an invalid role is provided', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.delete')) + .set(credentials) + .send({ + roleId: 'invalid-role', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-roleId'); + expect(res.body).to.have.property('error', 'This role does not exist [error-invalid-roleId]'); + }); + }); + + it('should successfully delete a role in EE', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.delete')) + .set(credentials) + .send({ + roleId: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('[/roles.removeUserFromRole]', () => { + let testUser: IUser; + let testUserCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + const testRoleName = `role.removeUsersFromRole.${Date.now()}`; + let testRoleId = ''; + + before(async () => { + await updatePermission('access-permissions', ['admin']); + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + if (!isEnterprise) { + return; + } + + await request + .post(api('roles.create')) + .set(credentials) + .send({ + name: testRoleName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('role'); + expect(res.body.role).to.have.property('name', testRoleName); + testRoleId = res.body.role._id; + }); + await request + .post(api('roles.addUserToRole')) + .set(credentials) + .send({ + roleId: testRoleId, + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(async () => { + await request.post(api('roles.delete')).set(credentials).send({ + roleId: testRoleId, + }); + await deleteUser(testUser); + }); + + it('should fail removing a user from a role when user does NOT have the access-permissions permission', async () => { + await request + .post(api('roles.removeUserFromRole')) + .set(testUserCredentials) + .send({ + roleId: testRoleId, + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should fail removing an invalid user from a role', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.removeUserFromRole')) + .set(credentials) + .send({ + roleId: testRoleId, + username: 'invalid-username', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-user'); + expect(res.body).to.have.property('error', 'There is no user with this username [error-invalid-user]'); + }); + }); + + it('should fail removing a user from an invalid role', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.removeUserFromRole')) + .set(credentials) + .send({ + roleId: 'invalid-role', + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-roleId'); + expect(res.body).to.have.property('error', 'This role does not exist [error-invalid-roleId]'); + }); + }); + + it('should fail removing a user from a role they do not have', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.removeUserFromRole')) + .set(credentials) + .send({ + roleId: 'admin', + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-not-in-role'); + expect(res.body).to.have.property('error', 'User is not in this role [error-user-not-in-role]'); + }); + }); + + it('should successfully remove a user from a role', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!isEnterprise) { + this.skip(); + } + + await request + .post(api('roles.removeUserFromRole')) + .set(credentials) + .send({ + roleId: testRoleId, + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + await request + .get(api('roles.getUsersInRole')) + .set(credentials) + .query({ + role: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('users'); + expect(res.body.users).to.be.an('array').that.is.empty; + }); + }); + }); + + describe('[/roles.getUsersInPublicRoles]', () => { + it('should return public roles', async () => { + await request + .get(api('roles.getUsersInPublicRoles')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + expect(res.body.users).to.be.an('array'); + // payload schema is checked in the endpoint + expect(res.body.users.length).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts new file mode 100644 index 0000000..776b337 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -0,0 +1,5630 @@ +import fs from 'fs'; +import path from 'path'; + +import type { Credentials } from '@rocket.chat/api-client'; +import type { + IMessage, + IRole, + IRoom, + ITeam, + IUpload, + IUser, + ImageAttachmentProps, + MessageAttachment, + SettingValue, +} from '@rocket.chat/core-typings'; +import { isFileAttachment, isQuoteAttachment, TeamType } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { assert, expect } from 'chai'; +import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; + +import { sleep } from '../../../lib/utils/sleep'; +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { sendSimpleMessage, sendMessage, deleteMessage } from '../../data/chat.helper'; +import { imgURL } from '../../data/interactions'; +import { + getSettingValueById, + restorePermissionToRoles, + updateEEPermission, + updatePermission, + updateSetting, +} from '../../data/permissions.helper'; +import { assignRoleToUser, createCustomRole, deleteCustomRole } from '../../data/roles.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const lstURL = './tests/e2e/fixtures/files/lst-test.lst'; +const svgLogoURL = './public/images/logo/logo.svg'; +const svgLogoFileName = 'logo.svg'; + +describe('[Rooms]', () => { + before((done) => getCredentials(done)); + + it('/rooms.get', async () => { + const res = await request.get(api('rooms.get')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update'); + expect(res.body).to.have.property('remove'); + }); + + it('/rooms.get?updatedSince', async () => { + const res = await request + .get(api('rooms.get')) + .set(credentials) + .query({ + updatedSince: new Date(), + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update').that.have.lengthOf(0); + expect(res.body).to.have.property('remove').that.have.lengthOf(0); + }); + + describe('/rooms.saveNotification:', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('/rooms.saveNotification:', async () => { + const res = await request + .post(api('rooms.saveNotification')) + .set(credentials) + .send({ + roomId: testChannel._id, + notifications: { + disableNotifications: '0', + emailNotifications: 'nothing', + audioNotificationValue: 'beep', + desktopNotifications: 'nothing', + mobilePushNotifications: 'mentions', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + }); + + describe('[/rooms.saveDraft]', () => { + let testChannel: IRoom; + let threadId: IMessage['_id']; + let userWithoutSubscription: TestUser; + let userWithoutSubscriptionCredentials: Credentials; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `rooms.saveDraft.test.${Date.now()}-${Math.random()}` })).body.channel; + userWithoutSubscription = await createUser({ joinDefaultChannels: false }); + userWithoutSubscriptionCredentials = await login(userWithoutSubscription.username, password); + + const rootMessage = (await sendSimpleMessage({ roomId: testChannel._id, text: 'thread root' })).body.message; + threadId = rootMessage._id; + await sendSimpleMessage({ roomId: testChannel._id, text: 'thread reply', tmid: threadId }); + }); + + after(() => Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), deleteUser(userWithoutSubscription)])); + + it('should save a draft on the user subscription', async () => { + const draft = `draft-${Date.now()}`; + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('draft', draft); + }); + }); + + it('should clear the draft from the user subscription', async () => { + const draft = `draft-to-clear-${Date.now()}`; + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft }).expect(200); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('draft', draft); + }); + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: '' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.property('draft'); + }); + }); + + it('should save a thread draft keyed by tmid without touching the main draft', async () => { + const draft = `thread-draft-${Date.now()}`; + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft, tmid: threadId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.nested.property(`threadDrafts.${threadId}`, draft); + expect(res.body.subscription).to.not.have.property('draft'); + }); + }); + + it('should never write a nested key to the subscription when the tmid contains a mongo path separator', async () => { + for (const tmid of ['threadDrafts.polluted', '__proto__.polluted', 'foo.bar.baz', '$set.x']) { + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: 'polluted', tmid }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid }).expect(400); + } + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect(200) + .expect((res) => { + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.polluted'); + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.__proto__'); + expect(res.body.subscription).to.not.have.nested.property('threadDrafts.foo'); + }); + }); + + it('should accept a thread draft for a message whose _id is non-alphanumeric (e.g. imported threads)', async () => { + const importedRootId = `slack-C1-${Date.now()}-000001`; + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ message: { _id: importedRootId, rid: testChannel._id, msg: 'imported thread root' } }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.message).to.have.property('_id', importedRootId); + }); + + const draft = `imported-thread-draft-${Date.now()}`; + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft, tmid: importedRootId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect(200) + .expect((res) => { + expect(res.body.subscription.threadDrafts).to.have.property(importedRootId, draft); + }); + }); + + it('should allow clearing a thread draft even when the tmid is not a thread in the room', async () => { + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: '', tmid: `nonExistentThread${Date.now()}` }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return the same error for a subscribed and an unsubscribed room regardless of tmid existence', async () => { + for (const tmid of [threadId, `nonExistentThread${Date.now()}`]) { + await request + .post(api('rooms.saveDraft')) + .set(userWithoutSubscriptionCredentials) + .send({ rid: testChannel._id, draft: 'probe', tmid }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-subscription'); + }); + } + }); + + it('should clear a thread draft when saving an empty draft for the tmid', async () => { + const draft = `thread-draft-${Date.now()}`; + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft, tmid: threadId }).expect(200); + + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '', tmid: threadId }).expect(200); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.nested.property(`threadDrafts.${threadId}`); + }); + }); + + it('should fail when the user does not have a subscription for the room', async () => { + await request + .post(api('rooms.saveDraft')) + .set(userWithoutSubscriptionCredentials) + .send({ rid: testChannel._id, draft: `draft-${Date.now()}` }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-subscription'); + expect(res.body).to.have.property('error', 'Invalid subscription [error-invalid-subscription]'); + }); + }); + + describe('max allowed message size', () => { + const maxAllowedSize = 10; + let originalMaxAllowedSize: SettingValue; + + before(async () => { + originalMaxAllowedSize = await getSettingValueById('Message_MaxAllowedSize'); + await updateSetting('Message_MaxAllowedSize', maxAllowedSize); + }); + + after(async () => { + await updateSetting('Message_MaxAllowedSize', originalMaxAllowedSize); + }); + + it('should save a draft with the maximum allowed message size', async () => { + const draft = 'a'.repeat(maxAllowedSize); + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('draft', draft); + }); + }); + + it('should fail when the draft exceeds the maximum allowed message size', async () => { + await request.post(api('rooms.saveDraft')).set(credentials).send({ rid: testChannel._id, draft: '' }).expect(200); + + await request + .post(api('rooms.saveDraft')) + .set(credentials) + .send({ rid: testChannel._id, draft: 'a'.repeat(maxAllowedSize + 1) }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-message-size-exceeded'); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.property('draft'); + }); + }); + }); + }); + + describe('/rooms.media', () => { + let testChannel: IRoom; + let user: TestUser; + let userCredentials: Credentials; + const testChannelName = `channel.test.upload.${Date.now()}-${Math.random()}`; + let blockedMediaTypes: SettingValue; + + before(async () => { + user = await createUser({ joinDefaultChannels: false }); + userCredentials = await login(user.username, password); + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + blockedMediaTypes = await getSettingValueById('FileUpload_MediaTypeBlackList'); + const newBlockedMediaTypes = (blockedMediaTypes as string) + .split(',') + .filter((type) => type !== 'image/svg+xml') + .join(','); + await updateSetting('FileUpload_MediaTypeBlackList', newBlockedMediaTypes); + await updateSetting('E2E_Enable_Encrypt_Files', true); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteUser(user), + updateSetting('FileUpload_Restrict_to_room_members', true), + updateSetting('FileUpload_ProtectFiles', true), + updateSetting('FileUpload_MediaTypeBlackList', blockedMediaTypes), + updateSetting('E2E_Enable_Encrypt_Files', true), + ]), + ); + + it("don't upload a file to room with file field other than file", async () => { + const res = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('test', imgURL) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[invalid-field]'); + expect(res.body).to.have.property('errorType', 'invalid-field'); + }); + it("don't upload a file to room with empty file", async () => { + const res = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', '') + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + it("don't upload a file to room with more than 1 file", async () => { + const res = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-too-many-files'); + }); + + let fileNewUrl: string; + let fileOldUrl: string; + let fileId: string; + it('should upload a PNG file to room', async () => { + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + // expect(res.body.message.files[0]).to.have.property('type', 'image/png'); + // expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); + + fileNewUrl = res.body.file.url; + fileOldUrl = res.body.file.url.replace('/file-upload/', '/ufs/GridFS:Uploads/'); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('attachments'); + expect(res.body.message.attachments).to.be.an('array').of.length(1); + expect(res.body.message.attachments[0]).to.have.property('image_type', 'image/png'); + expect(res.body.message.attachments[0]).to.have.property('title', '1024x1024.png'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(2); + expect(res.body.message.files[0]).to.have.property('type', 'image/png'); + expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); + }); + }); + + it('should upload a LST file to room', async () => { + let fileId; + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', lstURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('attachments'); + expect(res.body.message.attachments).to.be.an('array').of.length(1); + expect(res.body.message.attachments[0]).to.have.property('format', 'LST'); + expect(res.body.message.attachments[0]).to.have.property('title', 'lst-test.lst'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(1); + expect(res.body.message.files[0]).to.have.property('name', 'lst-test.lst'); + }); + }); + describe('/rooms.media - Max allowed size', () => { + before(async () => updateSetting('Message_MaxAllowedSize', 10)); + after(async () => updateSetting('Message_MaxAllowedSize', 5000)); + it('should allow uploading a file with description under the max character limit', async () => { + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileNewUrl = res.body.file.url; + fileOldUrl = res.body.file.url.replace('/file-upload/', '/ufs/GridFS:Uploads/'); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .send({ + description: '123456789', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('attachments'); + expect(res.body.message.attachments).to.be.an('array').of.length(1); + expect(res.body.message.attachments[0]).to.have.property('image_type', 'image/png'); + expect(res.body.message.attachments[0]).to.have.property('title', '1024x1024.png'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(2); + expect(res.body.message.files[0]).to.have.property('type', 'image/png'); + expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); + }); + }); + + it('should not allow uploading a file with description over the max character limit', async () => { + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileNewUrl = res.body.file.url; + fileOldUrl = res.body.file.url.replace('/file-upload/', '/ufs/GridFS:Uploads/'); + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .send({ + description: '12345678910', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-message-size-exceeded'); + }); + }); + }); + it('should not allow uploading a blocked media type to a room', async () => { + await updateSetting('FileUpload_MediaTypeBlackList', 'text/plain'); + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', lstURL) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + }); + + it('should be able to get the file', async () => { + await request.get(fileNewUrl).set(credentials).expect('Content-Type', 'image/png').expect(200); + await request.get(fileOldUrl).set(credentials).expect('Content-Type', 'image/png').expect(200); + }); + + it('should be able to get the file when no access to the room if setting allows it', async () => { + await updateSetting('FileUpload_Restrict_to_room_members', false); + await request.get(fileNewUrl).set(userCredentials).expect('Content-Type', 'image/png').expect(200); + await request.get(fileOldUrl).set(userCredentials).expect('Content-Type', 'image/png').expect(200); + }); + + it('should not be able to get the file when no access to the room if setting blocks', async () => { + await updateSetting('FileUpload_Restrict_to_room_members', true); + await request.get(fileNewUrl).set(userCredentials).expect(403); + await request.get(fileOldUrl).set(userCredentials).expect(403); + }); + + it('should be able to get the file if member and setting blocks outside access', async () => { + await updateSetting('FileUpload_Restrict_to_room_members', true); + await request.get(fileNewUrl).set(credentials).expect('Content-Type', 'image/png').expect(200); + await request.get(fileOldUrl).set(credentials).expect('Content-Type', 'image/png').expect(200); + }); + + it('should not be able to get the file without credentials', async () => { + await request.get(fileNewUrl).attach('file', imgURL).expect(403); + await request.get(fileOldUrl).attach('file', imgURL).expect(403); + }); + + it('should be able to get the file without credentials if setting allows', async () => { + await updateSetting('FileUpload_ProtectFiles', false); + await request.get(fileNewUrl).expect('Content-Type', 'image/png').expect(200); + await request.get(fileOldUrl).expect('Content-Type', 'image/png').expect(200); + }); + + it('should generate thumbnail for SVG files correctly', async () => { + const expectedFileName = `thumb-${svgLogoFileName}`; + let res = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', svgLogoURL) + .expect('Content-Type', 'application/json') + .expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + const fileId = res.body.file._id; + + res = await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + const message = res.body.message as IMessage; + const { files, attachments } = message; + + expect(files).to.be.an('array'); + const hasThumbFile = files?.some((file) => file.type === 'image/png' && file.name === expectedFileName); + expect(hasThumbFile).to.be.true; + + expect(attachments).to.be.an('array'); + const thumbAttachment = attachments?.find((attachment) => attachment.title === svgLogoFileName); + assert.isDefined(thumbAttachment); + expect(thumbAttachment).to.be.an('object'); + const thumbUrl = (thumbAttachment as ImageAttachmentProps).image_url; + + await request.get(thumbUrl).set(credentials).expect('Content-Type', 'image/png'); + }); + + it('should generate thumbnail for JPEG files correctly', async () => { + const expectedFileName = `thumb-sample-jpeg.jpg`; + let res = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/sample-jpeg.jpg'))) + .expect('Content-Type', 'application/json') + .expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + const fileId = res.body.file._id; + + res = await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + const message = res.body.message as IMessage; + const { files, attachments } = message; + + expect(files).to.be.an('array'); + const hasThumbFile = files?.some((file) => file.type === 'image/jpeg' && file.name === expectedFileName); + expect(hasThumbFile).to.be.true; + + expect(attachments).to.be.an('array'); + const thumbAttachment = attachments?.find((attachment) => attachment.title === `sample-jpeg.jpg`); + expect(thumbAttachment).to.be.an('object'); + const thumbUrl = (thumbAttachment as ImageAttachmentProps).image_url; + + await request.get(thumbUrl).set(credentials).expect('Content-Type', 'image/jpeg'); + }); + + // Support legacy behavior (not encrypting file) + it('should correctly save file description and properties with type e2e', async () => { + let fileId; + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .send({ + description: 'some_file_description', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('attachments'); + expect(res.body.message.attachments).to.be.an('array').of.length(1); + expect(res.body.message.attachments[0]).to.have.property('image_type', 'image/png'); + expect(res.body.message.attachments[0]).to.have.property('title', '1024x1024.png'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(2); + expect(res.body.message.files[0]).to.have.property('type', 'image/png'); + expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); + expect(res.body.message.attachments[0]).to.have.property('image_alt', 'some_file_description'); + }); + }); + + it('should correctly save encrypted file', async () => { + let fileId; + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(1); + expect(res.body.message.files[0]).to.have.property('type', 'application/octet-stream'); + expect(res.body.message.files[0]).to.have.property('name', 'diagram.drawio'); + }); + }); + + it('should correctly save encrypted file with the default media type even if another type is provided', async () => { + let fileId; + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/sample-jpeg.jpg')), { + contentType: 'image/jpeg', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + expect(res.body.file).to.have.property('url'); + + fileId = res.body.file._id; + }); + + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(1); + expect(res.body.message.files[0]).to.have.property('type', 'application/octet-stream'); + expect(res.body.message.files[0]).to.have.property('name', 'sample-jpeg.jpg'); + }); + }); + + it('should fail encrypted file upload when files encryption is disabled', async () => { + await updateSetting('E2E_Enable_Encrypt_Files', false); + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + }); + + it('should fail encrypted file upload on blacklisted application/octet-stream media type', async () => { + await updateSetting('FileUpload_MediaTypeBlackList', 'application/octet-stream'); + + await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', fs.createReadStream(path.join(__dirname, '../../mocks/files/diagram.drawio')), { + contentType: 'application/octet-stream', + }) + .field({ content: JSON.stringify({ algorithm: 'rc.v1.aes-sha2', ciphertext: 'something' }) }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + }); + }); + + describe('/rooms.favorite', () => { + let testChannel: IRoom; + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should favorite the room when send favorite: true by roomName', async () => { + const res = await request + .post(api('rooms.favorite')) + .set(credentials) + .send({ + roomName: testChannelName, + favorite: true, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should unfavorite the room when send favorite: false by roomName', async () => { + const res = await request + .post(api('rooms.favorite')) + .set(credentials) + .send({ + roomName: testChannelName, + favorite: false, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should favorite the room when send favorite: true by roomId', async () => { + const res = await request + .post(api('rooms.favorite')) + .set(credentials) + .send({ + roomId: testChannel._id, + favorite: true, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should unfavorite room when send favorite: false by roomId', async () => { + const res = await request + .post(api('rooms.favorite')) + .set(credentials) + .send({ + roomId: testChannel._id, + favorite: false, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when send an invalid room', async () => { + const res = await request + .post(api('rooms.favorite')) + .set(credentials) + .send({ + roomId: 'foo', + favorite: false, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + describe('/rooms.nameExists', () => { + let testChannel: IRoom; + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return 401 unauthorized when user is not logged in', async () => { + const res = await request.get(api('rooms.nameExists')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('message'); + }); + + it('should return true if this room name exists', async () => { + const res = await request + .get(api('rooms.nameExists')) + .set(credentials) + .query({ + roomName: testChannelName, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', true); + }); + + it('should return false if this room name does not exist', async () => { + const res = await request + .get(api('rooms.nameExists')) + .set(credentials) + .query({ + roomName: 'foo', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('exists', false); + }); + + it('should return an error when the require parameter (roomName) is not provided', async () => { + const res = await request + .get(api('rooms.nameExists')) + .set(credentials) + .query({ + roomId: 'foo', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + describe('[/rooms.cleanHistory]', () => { + let publicChannel: IRoom; + let privateChannel: IRoom; + let directMessageChannelId: IRoom['_id']; + let user: TestUser; + let userCredentials: Credentials; + + beforeEach(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + await updateSetting('Message_ShowDeletedStatus', true); + + publicChannel = (await createRoom({ type: 'c', name: `testeChannel${+new Date()}` })).body.channel; + privateChannel = (await createRoom({ type: 'p', name: `testPrivateChannel${+new Date()}` })).body.group; + directMessageChannelId = (await createRoom({ type: 'd', username: user.username })).body.room.rid; + }); + + afterEach(() => + Promise.all([ + deleteUser(user), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'd', roomId: directMessageChannelId }), + ]), + ); + + after(() => updateSetting('Message_ShowDeletedStatus', false)); + + it('should return success when send a valid public channel', async () => { + const res = await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '2016-12-09T13:42:25.304Z', + oldest: '2016-08-30T13:42:25.304Z', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should not count hidden or deleted messages when limit param is not sent', async () => { + const res = await sendSimpleMessage({ roomId: publicChannel._id }); + await deleteMessage({ roomId: publicChannel._id, msgId: res.body.message._id }); + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 0); + }); + }); + it('should not count hidden or deleted messages when limit param is sent', async () => { + const res = await sendSimpleMessage({ roomId: publicChannel._id }); + await deleteMessage({ roomId: publicChannel._id, msgId: res.body.message._id }); + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + limit: 2000, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 0); + }); + }); + it('should successfully delete an image and thumbnail from public channel', (done) => { + void request + .post(api(`rooms.media/${publicChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + const message = res.body.message as IMessage; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('message._id', message._id); + expect(res.body).to.have.nested.property('message.rid', publicChannel._id); + assert.isDefined(message.file); + expect(res.body).to.have.nested.property('message.file._id', message.file._id); + expect(res.body).to.have.nested.property('message.file.type', message.file.type); + }); + + void request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + void request + .get(api('channels.files')) + .set(credentials) + .query({ + roomId: publicChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + expect(res.body.files).to.have.lengthOf(0); + }) + .end(done); + }); + + it('should remove only files and file attachments when filesOnly is set to true', async () => { + const message1Response = await sendSimpleMessage({ roomId: publicChannel._id }); + + const mediaUploadResponse = await request + .post(api(`rooms.media/${publicChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + + const message2Response = await request + .post(api(`rooms.mediaConfirm/${publicChannel._id}/${mediaUploadResponse.body.file._id}`)) + .set(credentials) + .send({ msg: 'message with file only' }) + .expect(200); + + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + filesOnly: true, + }) + .expect(200); + + const res = await request.get(api('channels.messages')).set(credentials).query({ roomId: publicChannel._id }).expect(200); + + expect(res.body.messages).to.be.an('array'); + const messageIds = res.body.messages.map((m: IMessage) => m._id); + expect(messageIds).to.contain(message1Response.body.message._id); + expect(messageIds).to.contain(message2Response.body.message._id); + const cleanedMessage = res.body.messages.find((m: { _id: any }) => m._id === message2Response.body.message._id); + expect(cleanedMessage).to.exist; + expect(cleanedMessage.file).to.be.undefined; + expect(cleanedMessage.files?.length ?? 0).to.equal(0); + expect((cleanedMessage.attachments ?? []).find((a: MessageAttachment) => isFileAttachment(a))).to.be.undefined; + + await request + .get(api('channels.files')) + .set(credentials) + .query({ + roomId: publicChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array'); + expect(res.body.files).to.have.lengthOf(0); + }); + }); + + it('should not remove quote attachments when filesOnly is set to true', async () => { + const siteUrl = await getSettingValueById('Site_Url'); + const message1Response = await sendSimpleMessage({ roomId: publicChannel._id }); + const mediaResponse = await request + .post(api(`rooms.media/${publicChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200); + + const message2Response = await request + .post(api(`rooms.mediaConfirm/${publicChannel._id}/${mediaResponse.body.file._id}`)) + .set(credentials) + .send({ + msg: new URL(`/${publicChannel.fname}?msg=${message1Response.body.message._id}`, siteUrl as string).toString(), + }) + .expect(200); + + await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: publicChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + filesOnly: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: publicChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').and.to.be.an('array'); + const message = (res.body.messages.find((m: { _id: any }) => m._id === message2Response.body.message._id) as IMessage) || null; + expect(message).not.to.be.null; + expect(message).to.have.property('attachments'); + const fileAttachment = message.attachments?.find((f) => isFileAttachment(f)) || null; + expect(fileAttachment, 'Expected file attachments to be removed').to.be.null; + const quoteAttachment = message.attachments?.find((f) => isQuoteAttachment(f)) || null; + expect(quoteAttachment, 'Expected quote attachments to be present').not.to.be.null; + expect(message.file).to.be.undefined; + expect(message.files).to.satisfy((files: IMessage['files']) => files === undefined || files.length === 0); + }); + }); + + it('should return success when send a valid private channel', async () => { + const res = await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: privateChannel._id, + latest: '2016-12-09T13:42:25.304Z', + oldest: '2016-08-30T13:42:25.304Z', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return success when send a valid Direct Message channel', async () => { + const res = await request + .post(api('rooms.cleanHistory')) + .set(credentials) + .send({ + roomId: directMessageChannelId, + latest: '2016-12-09T13:42:25.304Z', + oldest: '2016-08-30T13:42:25.304Z', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should return not allowed error when try deleting messages with user without permission', async () => { + const res = await request + .post(api('rooms.cleanHistory')) + .set(userCredentials) + .send({ + roomId: directMessageChannelId, + latest: '2016-12-09T13:42:25.304Z', + oldest: '2016-08-30T13:42:25.304Z', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + describe('test user is not part of room', async () => { + beforeEach(async () => { + await updatePermission('clean-channel-history', ['admin', 'user']); + }); + + afterEach(async () => { + await updatePermission('clean-channel-history', ['admin']); + }); + + it('should return an error when the user with right privileges is not part of the room', async () => { + await request + .post(api('rooms.cleanHistory')) + .set(userCredentials) + .send({ + roomId: privateChannel._id, + latest: '9999-12-31T23:59:59.000Z', + oldest: '0001-01-01T00:00:00.000Z', + limit: 2000, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + expect(res.body).to.have.property('error', 'User does not have access to the room [error-not-allowed]'); + }); + }); + }); + }); + describe('[/rooms.info]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + const expectedKeys = ['_id', 'name', 'fname', 't', 'msgs', 'usersCount', 'u', 'ts', 'ro', 'sysMes', 'default', '_updatedAt']; + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + const testGroupName = `group.test.${Date.now()}-${Math.random()}`; + let user: TestUser; + + before(async () => { + user = await createUser(); + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + testGroup = (await createRoom({ type: 'p', name: testGroupName })).body.group; + testDM = (await createRoom({ type: 'd', username: user.username })).body.room; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteUser(user), + ]), + ); + + it('should return the info about the created channel correctly searching by roomId', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.keys(expectedKeys); + }); + it('should return the info about the created channel correctly searching by roomName', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomName: testChannel.name, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.all.keys(expectedKeys); + }); + it('should return the info about the created group correctly searching by roomId', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.all.keys(expectedKeys); + }); + it('should return the info about the created group correctly searching by roomName', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomName: testGroup.name, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.all.keys(expectedKeys); + }); + it('should return the info about the created DM correctly searching by roomId', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testDM._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + }); + + it('should not return parent & team for room thats not on a team nor is a discussion', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.not.have.property('team'); + expect(res.body.room).to.not.have.property('prid'); + }); + }); + + describe('with team and parent data', () => { + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + const teamName = `test-team-${Date.now()}`; + const discussionName = `test-discussion-${Date.now()}`; + const testChannelOutsideTeamname = `channel.test.outside.${Date.now()}-${Math.random()}`; + let testChannel: IRoom; + let testDiscussion: IRoom; + let testDiscussionMainRoom: IRoom; + let testTeam: ITeam; + let testChannelOutside: IRoom; + let testDiscussionOutsideTeam: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + + const teamResponse = await request.post(api('teams.create')).set(credentials).send({ name: teamName, type: 1 }).expect(200); + testTeam = teamResponse.body.team; + + const resDiscussion = await request.post(api('rooms.createDiscussion')).set(credentials).send({ + prid: testChannel._id, + t_name: discussionName, + }); + testDiscussion = resDiscussion.body.discussion; + + testDiscussionMainRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testTeam.roomId, + t_name: `test-discussion-${Date.now()}-team`, + }) + ).body.discussion; + + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ rooms: [testChannel._id], teamId: testTeam._id }); + }); + + before(async () => { + testChannelOutside = (await createRoom({ type: 'c', name: testChannelOutsideTeamname })).body.channel; + testDiscussionOutsideTeam = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannelOutside._id, + t_name: `test-discussion-${Date.now()}`, + }) + ).body.discussion; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testDiscussion._id }), + deleteRoom({ type: 'c', roomId: testChannelOutside._id }), + deleteRoom({ type: 'p', roomId: testDiscussionOutsideTeam._id }), + deleteRoom({ type: 'p', roomId: testDiscussionMainRoom._id }), + deleteTeam(credentials, teamName), + ]), + ); + + it('should return the channel info, team and parent info', async () => { + const result = await request.get(api('rooms.info')).set(credentials).query({ roomId: testChannel._id }).expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('team'); + expect(result.body).to.have.property('parent'); + expect(result.body.parent).to.have.property('_id').and.to.equal(testTeam.roomId); + }); + + it('should return the dicsussion room info and parent info', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ roomId: testDiscussion._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('parent').and.to.be.an('object'); + expect(res.body.parent).to.have.property('_id').and.to.be.equal(testChannel._id); + }); + }); + + it('should not return parent info for the main room of the team', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ roomId: testTeam.roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('parent'); + expect(res.body).to.have.property('team'); + }); + }); + + it('should not return team for room outside team', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ roomId: testChannelOutside._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.property('team'); + expect(res.body).to.not.have.property('parent'); + }); + }); + + it('should return the parent for discussion outside team', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ roomId: testDiscussionOutsideTeam._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('parent').and.to.be.an('object'); + expect(res.body.parent).to.have.property('_id').and.to.be.equal(testChannelOutside._id); + expect(res.body).to.not.have.property('team'); + }); + }); + + it('should return the parent for a discussion created from team main room', async () => { + await request + .get(api('rooms.info')) + .set(credentials) + .query({ roomId: testDiscussionMainRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('parent').and.to.be.an('object'); + expect(res.body.parent).to.have.property('_id').and.to.be.equal(testTeam.roomId); + expect(res.body).to.not.have.property('team'); + }); + }); + }); + + describe('with archived rooms', () => { + let publicArchivedChannel: IRoom; + let privateArchivedGroup: IRoom; + let nonMember: TestUser; + let nonMemberCredentials: Credentials; + + before(async () => { + publicArchivedChannel = (await createRoom({ type: 'c', name: `rooms.info.archived.c.${Date.now()}-${Math.random()}` })).body + .channel; + privateArchivedGroup = (await createRoom({ type: 'p', name: `rooms.info.archived.p.${Date.now()}-${Math.random()}` })).body.group; + + await request.post(api('channels.archive')).set(credentials).send({ roomId: publicArchivedChannel._id }).expect(200); + await request.post(api('groups.archive')).set(credentials).send({ roomId: privateArchivedGroup._id }).expect(200); + + nonMember = await createUser({ joinDefaultChannels: false }); + nonMemberCredentials = await login(nonMember.username, password); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: publicArchivedChannel._id }), + deleteRoom({ type: 'p', roomId: privateArchivedGroup._id }), + deleteUser(nonMember), + ]), + ); + + it('should return an accessible archived public channel to a non-member (reached via link/mention)', async () => { + await request + .get(api('rooms.info')) + .set(nonMemberCredentials) + .query({ roomId: publicArchivedChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.property('_id', publicArchivedChannel._id); + expect(res.body.room).to.have.property('archived', true); + }); + }); + + it('should still reject an archived room the user cannot access (private group, non-member)', async () => { + await request + .get(api('rooms.info')) + .set(nonMemberCredentials) + .query({ roomId: privateArchivedGroup._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'not-allowed'); + }); + }); + }); + }); + describe('[/rooms.leave]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + let user2: TestUser; + let user2Credentials: Credentials; + const testChannelName = `channel.leave.${Date.now()}-${Math.random()}`; + const testGroupName = `group.leave.${Date.now()}-${Math.random()}`; + + before(async () => { + user2 = await createUser(); + user2Credentials = await login(user2.username, password); + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + testGroup = (await createRoom({ type: 'p', name: testGroupName })).body.group; + testDM = (await createRoom({ type: 'd', username: user2.username })).body.room; + await updateSetting('API_User_Limit', 1000000); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + updatePermission('leave-c', ['admin', 'user', 'bot', 'anonymous', 'app']), + updatePermission('leave-p', ['admin', 'user', 'bot', 'anonymous', 'app']), + deleteUser(user2), + updateSetting('API_User_Limit', 10000), + ]), + ); + + it('should return an Error when trying leave a DM room', async () => { + const res = await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testDM._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + it('should return an Error when trying to leave a public channel and you are the last owner', async () => { + const res = await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-you-are-last-owner'); + }); + it('should return an Error when trying to leave a private group and you are the last owner', async () => { + const res = await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testGroup._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-you-are-last-owner'); + }); + it('should return an Error when trying to leave a public channel and not have the necessary permission(leave-c)', async () => { + await updatePermission('leave-c', []); + + const res = await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + it('should return an Error when trying to leave a private group and not have the necessary permission(leave-p)', async () => { + await updatePermission('leave-p', []); + + const res = await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testGroup._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + it('should leave the public channel when the room has at least another owner and the user has the necessary permission(leave-c)', async () => { + await updatePermission('leave-c', ['admin']); + await request.post(api('channels.addAll')).set(credentials).send({ + roomId: testChannel._id, + }); + + await request.post(api('channels.addOwner')).set(credentials).send({ + roomId: testChannel._id, + userId: user2._id, + }); + + await request + .post(api('rooms.leave')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request.post(api('channels.addOwner')).set(user2Credentials).send({ + roomId: testChannel._id, + userId: credentials['X-User-Id'], + }); + }); + it('should leave the private group when the room has at least another owner and the user has the necessary permission(leave-p)', async () => { + await updatePermission('leave-p', ['user']); + await request.post(api('groups.addAll')).set(credentials).send({ + roomId: testGroup._id, + }); + await request.post(api('groups.addOwner')).set(credentials).send({ + roomId: testGroup._id, + userId: user2._id, + }); + await request + .post(api('rooms.leave')) + .set(user2Credentials) + .send({ + roomId: testGroup._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('/rooms.createDiscussion', () => { + let testChannel: IRoom; + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + let messageSent: IMessage; + let privateTeam: ITeam; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + messageSent = ( + await sendSimpleMessage({ + roomId: testChannel._id, + }) + ).body.message; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + updateSetting('Discussion_enabled', true), + updatePermission('start-discussion', ['admin', 'user', 'guest', 'app']), + updatePermission('start-discussion-other-user', ['admin', 'user', 'guest', 'app']), + deleteTeam(credentials, privateTeam.name), + ]), + ); + + it('should throw an error when the user tries to create a discussion and the feature is disabled', (done) => { + void updateSetting('Discussion_enabled', false).then(() => { + void request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: 'valid name', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }) + .end(() => updateSetting('Discussion_enabled', true).then(done)); + }); + }); + it('should throw an error when the user tries to create a discussion and does not have at least one of the required permissions', (done) => { + void updatePermission('start-discussion', []).then(() => { + void updatePermission('start-discussion-other-user', []).then(() => { + void request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: 'valid name', + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }) + .end(() => { + void updatePermission('start-discussion', ['admin', 'user', 'guest']) + .then(() => updatePermission('start-discussion-other-user', ['admin', 'user', 'guest'])) + .then(done); + }); + }); + }); + }); + it('should throw an error when the user tries to create a discussion without the required parameter "prid"', async () => { + const res = await request.post(api('rooms.createDiscussion')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes("must have required property 'prid'"); + }); + it('should throw an error when the user tries to create a discussion without the required parameter "t_name"', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes("must have required property 't_name'"); + }); + it('should throw an error when the user tries to create a discussion with the required parameter invalid "users"(different from an array)', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: 'valid name', + users: 'invalid-type-of-users', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('must be array'); + }); + it("should throw an error when the user tries to create a discussion with the channel's id invalid", async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: 'invalid-id', + t_name: 'valid name', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + }); + it("should throw an error when the user tries to create a discussion with the message's id invalid", async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: 'valid name', + pmid: 'invalid-message', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-message'); + }); + it('should create a discussion successfully when send only the required parameters', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussion').and.to.be.an('object'); + expect(res.body.discussion).to.have.property('prid').and.to.be.equal(testChannel._id); + expect(res.body.discussion).to.have.property('fname').and.to.be.equal(`discussion-create-from-tests-${testChannel.name}`); + }); + it('should create a discussion successfully when send the required parameters plus the optional parameter "reply"', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + reply: 'reply from discussion tests', + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussion').and.to.be.an('object'); + expect(res.body.discussion).to.have.property('prid').and.to.be.equal(testChannel._id); + expect(res.body.discussion).to.have.property('fname').and.to.be.equal(`discussion-create-from-tests-${testChannel.name}`); + }); + it('should create a discussion successfully when send the required parameters plus the optional parameter "users"', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + reply: 'reply from discussion tests', + users: ['rocket.cat'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussion').and.to.be.an('object'); + expect(res.body.discussion).to.have.property('prid').and.to.be.equal(testChannel._id); + expect(res.body.discussion).to.have.property('fname').and.to.be.equal(`discussion-create-from-tests-${testChannel.name}`); + }); + it('should create a discussion successfully when send the required parameters plus the optional parameter "pmid"', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + reply: 'reply from discussion tests', + users: ['rocket.cat'], + pmid: messageSent._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussion').and.to.be.an('object'); + expect(res.body.discussion).to.have.property('prid').and.to.be.equal(testChannel._id); + expect(res.body.discussion).to.have.property('fname').and.to.be.equal(`discussion-create-from-tests-${testChannel.name}`); + }); + + describe('it should create a *private* discussion if the parent channel is public and inside a private team', async () => { + it('should create a team', async () => { + const res = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `test-team-${Date.now()}`, + type: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + privateTeam = res.body.team; + }); + + it('should add the public channel to the team', async () => { + const res = await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [testChannel._id], + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success'); + }); + + it('should create a private discussion inside the public channel', async () => { + const res = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}-team`, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussion').and.to.be.an('object'); + expect(res.body.discussion).to.have.property('prid').and.to.be.equal(testChannel._id); + expect(res.body.discussion).to.have.property('fname').and.to.be.equal(`discussion-create-from-tests-${testChannel.name}-team`); + expect(res.body.discussion).to.have.property('t').and.to.be.equal('p'); + }); + }); + + describe('E2E forced encryption for private rooms', () => { + let unencryptedPrivateParent: IRoom; + let encryptedPrivateParent: IRoom; + let createdDiscussionId: IRoom['_id'] | undefined; + + before(async () => { + // the unencrypted private parent must exist before the policy is enforced + unencryptedPrivateParent = (await createRoom({ type: 'p', name: `unencrypted-parent-${Date.now()}` })).body.group; + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]); + encryptedPrivateParent = (await createRoom({ type: 'p', name: `encrypted-parent-${Date.now()}`, extraData: { encrypted: true } })) + .body.group; + }); + + after(async () => { + await Promise.all([ + updateSetting('E2E_Enable', false), + updateSetting('E2E_Force_Encryption_For_Private_Rooms', false), + ...(unencryptedPrivateParent?._id ? [deleteRoom({ type: 'p', roomId: unencryptedPrivateParent._id })] : []), + ...(encryptedPrivateParent?._id ? [deleteRoom({ type: 'p', roomId: encryptedPrivateParent._id })] : []), + ...(createdDiscussionId ? [deleteRoom({ type: 'p', roomId: createdDiscussionId })] : []), + ]); + }); + + it('should reject creating a discussion in an unencrypted private room when private room encryption is forced', async () => { + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: unencryptedPrivateParent._id, + t_name: `forced-discussion-${Date.now()}`, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-encrypted-private-rooms-enforced-discussion'); + }); + }); + + it('should create an encrypted discussion in an encrypted private room when private room encryption is forced', async () => { + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: encryptedPrivateParent._id, + t_name: `forced-discussion-encrypted-${Date.now()}`, + }) + .expect(200) + .expect((res) => { + createdDiscussionId = res.body.discussion?._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('discussion.t', 'p'); + expect(res.body).to.have.nested.property('discussion.encrypted', true); + }); + }); + }); + }); + + describe('/rooms.getDiscussions', () => { + let testChannel: IRoom; + const testChannelName = `channel.test.getDiscussions${Date.now()}-${Math.random()}`; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: testChannelName })).body.channel; + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + }); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + updatePermission('view-c-room', ['admin', 'user', 'bot', 'app', 'anonymous']), + ]), + ); + + it('should throw an error when the user tries to gets a list of discussion without a required parameter "roomId"', async () => { + const res = await request.get(api('rooms.getDiscussions')).set(credentials).query({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The parameter "roomId" or "roomName" is required [error-roomid-param-not-provided]'); + }); + it('should throw an error when the user tries to gets a list of discussion and he cannot access the room', (done) => { + void updatePermission('view-c-room', []).then(() => { + void request + .get(api('rooms.getDiscussions')) + .set(credentials) + .query({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Not Allowed'); + }) + .end(() => updatePermission('view-c-room', ['admin', 'user', 'bot', 'anonymous']).then(done)); + }); + }); + it('should return a list of discussions with ONE discussion', async () => { + const res = await request + .get(api('rooms.getDiscussions')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('discussions').and.to.be.an('array'); + expect(res.body.discussions).to.have.lengthOf(1); + }); + }); + + describe('discussion messages count', () => { + let testChannel: IRoom; + let discussion: IRoom; + + const getDiscussionMessage = async () => { + const { body } = await request.get(api('chat.getDiscussions')).set(credentials).query({ roomId: testChannel._id }).expect(200); + + return body.messages.find((message: IMessage & { drid: IRoom['_id'] }) => message.drid === discussion._id); + }; + + const saveDiscussionSettings = (settings: Record) => + request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ rid: discussion._id, ...settings }) + .expect('Content-Type', 'application/json') + .expect(200); + + beforeEach(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` })).body.channel; + + const { body } = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ prid: testChannel._id, t_name: `discussion.test.${Date.now()}-${Math.random()}` }) + .expect(200); + + discussion = body.discussion; + }); + + // deleting the parent channel also deletes its discussions + afterEach(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + describe('with no system message hidden', () => { + it('should count the message just sent on the discussion', async () => { + const sentMessage = await sendSimpleMessage({ roomId: discussion._id }); + const discussionMessage = await getDiscussionMessage(); + + expect(discussionMessage).to.have.property('dcount', 1); + expect(discussionMessage).to.have.property('dlm', sentMessage.body.message.ts); + }); + + it('should count the system messages of the discussion', async () => { + await saveDiscussionSettings({ roomName: `edited-discussion-name-${Date.now()}` }); + expect(await getDiscussionMessage()).to.have.property('dcount', 1); + }); + }); + + describe('with system messages hidden on the discussion', () => { + beforeEach(() => saveDiscussionSettings({ systemMessages: ['r'] })); + + it('should not count the hidden system messages', async () => { + await saveDiscussionSettings({ roomName: `edited-discussion-name-${Date.now()}` }); + await sendSimpleMessage({ roomId: discussion._id }); + + expect(await getDiscussionMessage()).to.have.property('dcount', 1); + }); + + it('should count them again once they are not hidden anymore', async () => { + await saveDiscussionSettings({ roomName: `edited-discussion-name-${Date.now()}` }); + expect(await getDiscussionMessage()).to.have.property('dcount', 0); + + await saveDiscussionSettings({ systemMessages: [] }); + + expect(await getDiscussionMessage()).to.have.property('dcount', 1); + }); + }); + + describe('with system messages hidden by the global setting', () => { + before(() => updateSetting('Hide_System_Messages', ['r'])); + + // the setting applies to the whole workspace, so it has to be restored + after(() => updateSetting('Hide_System_Messages', [])); + + it('should not count the hidden system messages', async () => { + await saveDiscussionSettings({ roomName: `edited-discussion-name-${Date.now()}` }); + expect(await getDiscussionMessage()).to.have.property('dcount', 0); + + await sendSimpleMessage({ roomId: discussion._id }); + + expect(await getDiscussionMessage()).to.have.property('dcount', 1); + }); + }); + }); + + describe('[/rooms.join]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testChannelWithCode: IRoom; + let testDiscussion: IRoom; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + testChannel = (await createRoom({ type: 'c', name: `rooms.join.channel.${Date.now()}` })).body.channel; + testGroup = (await createRoom({ type: 'p', name: `rooms.join.group.${Date.now()}` })).body.group; + testChannelWithCode = (await createRoom({ type: 'c', name: `rooms.join.code.${Date.now()}` })).body.channel; + testDiscussion = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ prid: testChannel._id, t_name: `rooms.join.discussion.${Date.now()}` }) + ).body.discussion; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteRoom({ type: 'c', roomId: testChannelWithCode._id }), + deleteUser(testUser), + updatePermission('join-without-join-code', ['admin', 'bot', 'app']), + ]), + ); + + it('should fail when the room does not exist', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: 'invalid-room-id' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + + it('should join a public channel by roomId', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannel._id); + }); + + it('should join a public channel by roomName', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomName: testChannel.name }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannel._id); + }); + + it('should join a discussion (a room with a parent room) by roomId', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testDiscussion._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testDiscussion._id); + expect(res.body).to.have.nested.property('room.prid', testChannel._id); + }); + + it('should fail to join a private group the user cannot access', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testGroup._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + + describe('with a join code', () => { + before(async () => { + await request.post(api('channels.setJoinCode')).set(credentials).send({ roomId: testChannelWithCode._id, joinCode: '123' }); + await updatePermission('join-without-join-code', []); + }); + + it('should fail to join without a join code', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-code-required'); + }); + + it('should fail to join with an incorrect join code', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id, joinCode: 'WRONG' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-code-invalid'); + }); + + it('should join with the correct join code', async () => { + const res = await request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id, joinCode: '123' }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannelWithCode._id); + }); + }); + }); + + describe('[/rooms.autocomplete.channelAndPrivate]', () => { + let testChannel: IRoom; + + before(async () => { + await updateSetting('UI_Allow_room_names_with_special_chars', true); + testChannel = (await createRoom({ type: 'c', name: 'тест' })).body.channel; + }); + + after(async () => { + await updateSetting('UI_Allow_room_names_with_special_chars', true); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should return an error when the required parameter "selector" is not provided', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include("must have required property 'selector'"); + }); + it('should return the rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate')) + .query({ selector: '{}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + }); + it('should return the rooms with cyrillic characters in channel name', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate')) + .query({ selector: '{ "name": "тест" }' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + expect(res.body.items).to.have.lengthOf(1); + expect(res.body.items[0].fname).to.be.equal('тест'); + }); + }); + + describe('[/rooms.autocomplete.channelAndPrivate.withPagination]', () => { + it('should return an error when the required parameter "selector" is not provided', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate.withPagination')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include("must have required property 'selector'"); + }); + it('should return the rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate.withPagination')) + .query({ selector: '{}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + }); + it('should return the rooms to fill auto complete even requested with count and offset params', async () => { + const res = await request + .get(api('rooms.autocomplete.channelAndPrivate.withPagination')) + .query({ selector: '{}' }) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + expect(res.body).to.have.property('total'); + }); + }); + + describe('[/rooms.autocomplete.availableForTeams]', () => { + it('should return the rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.availableForTeams')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + }); + it('should return the filtered rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.availableForTeams')) + .query({ name: 'group' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + }); + }); + + describe('[/rooms.autocomplete.adminRooms]', () => { + let testGroup: IRoom; + const testGroupName = `channel.test.adminRoom${Date.now()}-${Math.random()}`; + const name = { + name: testGroupName, + }; + + before(async () => { + testGroup = (await createRoom({ type: 'p', name: testGroupName })).body.group; + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testGroup._id, + t_name: `${testGroupName}-discussion`, + }); + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: testGroup._id }), updateEEPermission('can-audit', ['admin', 'auditor'])])); + + (IS_EE ? it : it.skip)('should return an error when the required parameter "selector" is not provided', (done) => { + void updateEEPermission('can-audit', ['admin']).then(() => { + void request + .get(api('rooms.autocomplete.adminRooms')) + .set(credentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include("must have required property 'selector'"); + }) + .end(done); + }); + }); + it('should return the rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.adminRooms')) + .query({ selector: '{}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + }); + it('should return the rooms to fill auto complete', async () => { + const res = await request + .get(api('rooms.autocomplete.adminRooms')) + .set(credentials) + .query({ + selector: JSON.stringify(name), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + expect(res.body).to.have.property('items').that.have.lengthOf(2); + }); + }); + + describe('/rooms.adminRooms', () => { + const suffix = `test-${Date.now()}`; + const fnameRoom = `Ελληνικά-${suffix}`; + const nameRoom = `Ellinika-${suffix}`; + const discussionRoomName = `${nameRoom}-discussion`; + + let testGroup: IRoom; + + before(async () => { + await updateSetting('UI_Allow_room_names_with_special_chars', true); + testGroup = (await createRoom({ type: 'p', name: fnameRoom })).body.group; + await request.post(api('rooms.createDiscussion')).set(credentials).send({ + prid: testGroup._id, + t_name: discussionRoomName, + }); + }); + + after(() => + Promise.all([ + updateSetting('UI_Allow_room_names_with_special_chars', false), + deleteRoom({ type: 'p', roomId: testGroup._id }), + updatePermission('view-room-administration', ['admin']), + ]), + ); + + it('should throw an error when the user tries to gets a list of discussion and he cannot access the room', (done) => { + void updatePermission('view-room-administration', []).then(() => { + void request + .get(api('rooms.adminRooms')) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-authorized'); + }) + .end(() => updatePermission('view-room-administration', ['admin']).then(done)); + }); + }); + it('should return a list of admin rooms', async () => { + const res = await request.get(api('rooms.adminRooms')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + it('should return a list of admin rooms even requested with count and offset params', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + it('should search the list of admin rooms using non-latin characters when UI_Allow_room_names_with_special_chars setting is toggled', async () => { + await updateSetting('UI_Allow_room_names_with_special_chars', true); + + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + filter: fnameRoom, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf(1); + expect(res.body.rooms[0].fname).to.be.equal(fnameRoom); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + it('should search the list of admin rooms using latin characters only when UI_Allow_room_names_with_special_chars setting is disabled', async () => { + await updateSetting('UI_Allow_room_names_with_special_chars', false); + + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + filter: nameRoom, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf(1); + expect(res.body.rooms[0].name).to.be.equal(nameRoom); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + it('should filter by only rooms types', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + types: ['p'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf.at.least(1); + expect(res.body.rooms[0].t).to.be.equal('p'); + expect((res.body.rooms as IRoom[]).find((room) => room.name === nameRoom)).to.exist; + expect((res.body.rooms as IRoom[]).find((room) => room.name === discussionRoomName)).to.not.exist; + }); + it('should filter by only name', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + filter: nameRoom, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf(1); + expect(res.body.rooms[0].name).to.be.equal(nameRoom); + }); + it('should filter by type and name at the same query', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + filter: nameRoom, + types: ['p'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf(1); + expect(res.body.rooms[0].name).to.be.equal(nameRoom); + }); + it('should return an empty array when filter by wrong type and correct room name', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + filter: nameRoom, + types: ['c'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf(0); + }); + it('should return an array sorted by "ts" property', async () => { + const res = await request + .get(api('rooms.adminRooms')) + .set(credentials) + .query({ + sort: JSON.stringify({ + ts: -1, + }), + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body.rooms).to.have.lengthOf.at.least(1); + expect(res.body.rooms[0]).to.have.property('ts').that.is.a('string'); + }); + it('should return the customFields of a private room', async () => { + const roomCustomFields = { department: 'engineering', priority: 'high' }; + + await request.post(api('rooms.saveRoomSettings')).set(credentials).send({ rid: testGroup._id, roomCustomFields }).expect(200); + + const res = await request.get(api('rooms.adminRooms')).set(credentials).query({ filter: nameRoom }).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.rooms).to.have.lengthOf(1); + expect(res.body.rooms[0]._id).to.equal(testGroup._id); + expect(res.body.rooms[0]).to.have.property('customFields').that.deep.equals(roomCustomFields); + }); + }); + + describe('/rooms.adminRooms.privateRooms', () => { + let publicChannel: IRoom; + let privateGroup: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + + before(async () => { + await updatePermission('view-room-administration', ['admin']); + + publicChannel = (await createRoom({ type: 'c', name: `public-channel-${Date.now()}` })).body.channel; + privateGroup = (await createRoom({ type: 'p', name: `private-group-${Date.now()}` })).body.group; + + publicTeam = await createTeam(credentials, `public-team-${Date.now()}`, TeamType.PUBLIC); + privateTeam = await createTeam(credentials, `private-team-${Date.now()}`, TeamType.PRIVATE); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateGroup._id }), + deleteTeam(credentials, publicTeam.name), + deleteTeam(credentials, privateTeam.name), + ]); + }); + + it('should return only the private room when filtering by its name', async () => { + const res = await request + .get(api('rooms.adminRooms.privateRooms')) + .set(credentials) + .query({ + filter: privateGroup.name, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + + const rooms = res.body.rooms as IRoom[]; + expect(rooms).to.have.lengthOf(1); + expect(rooms[0].name).to.equal(privateGroup.name); + expect(rooms[0].t).to.equal('p'); + }); + + it('should return only the private team main when filtering by its name', async () => { + const res = await request + .get(api('rooms.adminRooms.privateRooms')) + .set(credentials) + .query({ + filter: privateTeam.name, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + + const rooms = res.body.rooms as IRoom[]; + expect(rooms).to.have.lengthOf(1); + expect(rooms[0].name).to.equal(privateTeam.name); + expect(rooms[0].t).to.equal('p'); + }); + + it('should not return public rooms or public team mains even when filtering by their names', async () => { + const resPublicChannel = await request + .get(api('rooms.adminRooms.privateRooms')) + .set(credentials) + .query({ + filter: publicChannel.name, + }) + .expect(200); + + expect(resPublicChannel.body).to.have.property('success', true); + expect(resPublicChannel.body).to.have.property('rooms').and.to.be.an('array'); + expect(resPublicChannel.body.rooms).to.have.lengthOf(0); + + const resPublicTeam = await request + .get(api('rooms.adminRooms.privateRooms')) + .set(credentials) + .query({ + filter: publicTeam.name, + }) + .expect(200); + + expect(resPublicTeam.body).to.have.property('success', true); + expect(resPublicTeam.body).to.have.property('rooms').and.to.be.an('array'); + expect(resPublicTeam.body.rooms).to.have.lengthOf(0); + }); + + describe('permissions', () => { + before(async () => { + await updatePermission('view-room-administration', []); + }); + + after(async () => { + await updatePermission('view-room-administration', ['admin']); + }); + + it('should return an error for users without view-room-administration permission', async () => { + const res = await request.get(api('rooms.adminRooms.privateRooms')).set(credentials).expect(403); + + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('/rooms.adminRooms.getRoom', () => { + let roomOwner: TestUser; + let ownerCredentials: Credentials; + let privateRoom: IRoom; + const roomCustomFields = { department: 'engineering', priority: 'high' }; + + before(async () => { + await updatePermission('view-room-administration', ['admin']); + + roomOwner = await createUser(); + ownerCredentials = await login(roomOwner.username, password); + privateRoom = (await createRoom({ type: 'p', name: `admin-getroom-${Date.now()}`, credentials: ownerCredentials })).body.group; + + await request.post(api('rooms.saveRoomSettings')).set(credentials).send({ rid: privateRoom._id, roomCustomFields }).expect(200); + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteUser(roomOwner), + updatePermission('view-room-administration', ['admin']), + ]), + ); + + it('should not expose the private room through groups.info to an admin that is not a member', async () => { + const res = await request.get(api('groups.info')).set(credentials).query({ roomId: privateRoom._id }); + + expect(res.body).to.have.property('success', false); + }); + + it('should return the private room customFields for an admin that is not a member', async () => { + const res = await request.get(api('rooms.adminRooms.getRoom')).set(credentials).query({ rid: privateRoom._id }).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', privateRoom._id); + expect(res.body).to.have.property('t', 'p'); + expect(res.body).to.have.property('customFields').that.deep.equals(roomCustomFields); + }); + + it('should return an error for users without view-room-administration permission', async () => { + await updatePermission('view-room-administration', []); + + const res = await request.get(api('rooms.adminRooms.getRoom')).set(credentials).query({ rid: privateRoom._id }).expect(400); + + expect(res.body).to.have.property('success', false); + + await updatePermission('view-room-administration', ['admin']); + }); + }); + + describe('update group dms name', () => { + let testUser: TestUser; + let roomId: IRoom['_id']; + let testUser2: TestUser; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + + const usernames = [testUser.username, testUser2.username].join(','); + + const result = await request.post(api('dm.create')).set(credentials).send({ + usernames, + }); + + roomId = result.body.room.rid; + }); + + after(async () => + Promise.all([ + updateSetting('UI_Use_Real_Name', false), + deleteRoom({ type: 'd', roomId }), + deleteUser(testUser), + deleteUser(testUser2), + ]), + ); + + const expectSubscriptionFieldToEqual = async (field: 'name' | 'fname', expected: string) => { + for (let attempt = 0; ; attempt++) { + const { body } = await request.get(api('subscriptions.getOne')).set(credentials).query({ roomId }); + if (body.subscription?.[field] === expected || attempt >= 20) { + expect(body.subscription?.[field]).to.equal(expected); + return; + } + await sleep(250); + } + }; + + it('should update group name if user changes username', async () => { + await updateSetting('UI_Use_Real_Name', false); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + username: `changed.username.${testUser.username}`, + }, + }); + + await expectSubscriptionFieldToEqual('name', `changed.username.${testUser.username},${testUser2.username}`); + }); + + describe('use real name', () => { + before(async () => { + await updateSetting('UI_Use_Real_Name', true); + }); + + after(async () => { + await updateSetting('UI_Use_Real_Name', false); + }); + + it('should update group name if user changes name', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + name: `changed.name.${testUser.username}`, + }, + }); + + await expectSubscriptionFieldToEqual('fname', `changed.name.${testUser.username}, ${testUser2.name}`); + }); + }); + }); + + describe('/rooms.delete', () => { + let testChannel: IRoom; + let testTeam: ITeam; + let testUser: IUser; + let testUser2: IUser; + let userCredentials: Credentials; + + before('create channel and team', async () => { + testUser = await createUser(); + testUser2 = await createUser(); + userCredentials = await login(testUser.username, password); + + const { + body: { channel }, + } = await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` }); + testChannel = channel; + testTeam = await createTeam(userCredentials, `team.test.${Date.now()}-${Math.random()}`, TeamType.PUBLIC, [ + testUser.username as string, + testUser2.username as string, + ]); + }); + + after('delete channel and team', async () => { + await deleteTeam(userCredentials, testTeam.name); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should throw an error when roomId is not provided', async () => { + const res = await request.post(api('rooms.delete')).set(credentials).send({}).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'roomId'"); + }); + + it('should delete a room when the request is correct', async () => { + const res = await request + .post(api('rooms.delete')) + .set(credentials) + .send({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should throw an error when the room id doesn exist', async () => { + const res = await request + .post(api('rooms.delete')) + .set(credentials) + .send({ roomId: 'invalid' }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should throw an error when room is a main team room', async () => { + const res = await request + .post(api('rooms.delete')) + .set(credentials) + .send({ roomId: testTeam.roomId }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + + describe('rooms.saveRoomSettings', () => { + let testChannel: IRoom; + const randomString = `randomString${Date.now()}`; + const teamName = `team-${Date.now()}`; + let discussion: IRoom; + let testTeam: ITeam; + + before(async () => { + const result = await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + const resTeam = await request.post(api('teams.create')).set(credentials).send({ name: teamName, type: 0 }); + const resDiscussion = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + t_name: `discussion-create-from-tests-${testChannel.name}`, + }); + + testTeam = resTeam.body.team; + discussion = resDiscussion.body.discussion; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'p', roomId: discussion._id }), + deleteTeam(credentials, testTeam.name), + deleteRoom({ type: 'p', roomId: testChannel._id }), + ]), + ); + + it('should update the room settings', (done) => { + const imageDataUri = `data:image/png;base64,${fs.readFileSync(path.join(process.cwd(), imgURL)).toString('base64')}`; + + void request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + roomAvatar: imageDataUri, + featured: true, + roomName: randomString, + roomTopic: randomString, + roomAnnouncement: randomString, + roomDescription: randomString, + roomType: 'p', + readOnly: true, + reactWhenReadOnly: true, + default: true, + favorite: { + favorite: true, + defaultValue: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .end(done); + }); + + it('should have reflected on rooms.info', async () => { + const res = await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', testChannel._id); + expect(res.body.room).to.have.property('name', randomString); + expect(res.body.room).to.have.property('topic', randomString); + expect(res.body.room).to.have.property('announcement', randomString); + expect(res.body.room).to.have.property('description', randomString); + expect(res.body.room).to.have.property('t', 'p'); + expect(res.body.room).to.have.property('featured', true); + expect(res.body.room).to.have.property('ro', true); + expect(res.body.room).to.have.property('default', true); + expect(res.body.room).to.have.property('favorite', true); + expect(res.body.room).to.have.property('reactWhenReadOnly', true); + }); + + it('should be able to update the discussion name with spaces', async () => { + const newDiscussionName = `${randomString} with spaces`; + + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: discussion._id, + roomName: newDiscussionName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: discussion._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', discussion._id); + expect(res.body.room).to.have.property('fname', newDiscussionName); + }); + }); + + it('should mark a room as favorite', async () => { + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + favorite: { + favorite: true, + defaultValue: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', testChannel._id); + expect(res.body.room).to.have.property('favorite', true); + }); + }); + it('should not mark a room as favorite when room is not a default room', async () => { + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + favorite: { + favorite: true, + defaultValue: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + + expect(res.body.room).to.have.property('_id', testChannel._id); + expect(res.body.room).to.not.have.property('favorite'); + }); + }); + }); + + describe('rooms.images', () => { + let testUserCreds: Credentials; + before(async () => { + const user = await createUser(); + testUserCreds = await login(user.username, password); + }); + + const uploadFile = async ({ roomId, file }: { roomId: IRoom['_id']; file: Buffer | fs.ReadStream | string | boolean | number }) => { + let fileId; + await request + .post(api(`rooms.media/${roomId}`)) + .set(credentials) + .attach('file', file) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('file'); + expect(res.body.file).to.have.property('_id'); + fileId = res.body.file._id; + }); + + const res = await request + .post(api(`rooms.mediaConfirm/${roomId}/${fileId}`)) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + return res.body.message.attachments[0]; + }; + + const getIdFromImgPath = (link: string) => { + return link.split('/')[2]; + }; + + it('should return an error when user is not logged in', async () => { + await request.get(api('rooms.images')).expect(401); + }); + it('should return an error when the required parameter "roomId" is not provided', async () => { + await request.get(api('rooms.images')).set(credentials).expect(400); + }); + it('should return an error when the required parameter "roomId" is not a valid room', async () => { + await request.get(api('rooms.images')).set(credentials).query({ roomId: 'invalid' }).expect(403); + }); + it('should return an error when room is valid but user is not part of it', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + + const { + group: { _id: roomId }, + } = body; + await request.get(api('rooms.images')).set(testUserCreds).query({ roomId }).expect(403); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return an empty array when room is valid and user is part of it but there are no images', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(0); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return an array of images when room is valid and user is part of it and there are images', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + const fileId = getIdFromImgPath(title_link); + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body.files[0]).to.have.property('_id', fileId); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should return multiple images when room is valid and user is part of it and there are multiple images', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link: link1 } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + const { title_link: link2 } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + + const fileId1 = getIdFromImgPath(link1); + const fileId2 = getIdFromImgPath(link2); + + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(2); + expect((res.body.files as IUpload[]).find((file) => file._id === fileId1)).to.exist; + expect((res.body.files as IUpload[]).find((file) => file._id === fileId2)).to.exist; + }); + + await deleteRoom({ type: 'p', roomId }); + }); + it('should allow to filter images passing the startingFromId parameter', async () => { + const { body } = await createRoom({ type: 'p', name: `test-${Date.now()}` }); + const { + group: { _id: roomId }, + } = body; + const { title_link } = await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + await uploadFile({ + roomId, + file: fs.createReadStream(path.join(process.cwd(), imgURL)), + }); + + const fileId2 = getIdFromImgPath(title_link); + await request + .get(api('rooms.images')) + .set(credentials) + .query({ roomId, startingFromId: fileId2 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('files').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body.files[0]).to.have.property('_id', fileId2); + }); + + await deleteRoom({ type: 'p', roomId }); + }); + }); + + describe('/rooms.muteUser', () => { + let testChannel: IRoom; + + before('create a channel', async () => { + const result = await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should invite rocket.cat user to room', () => { + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + username: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.name', testChannel.name); + }); + }); + + it('should mute the rocket.cat user', () => { + return request + .post(api('rooms.muteUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + username: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should contain rocket.cat user in mute list', () => { + return request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.name', testChannel.name); + expect(res.body.channel).to.have.property('muted').and.to.be.an('array'); + expect(res.body.channel.muted).to.have.lengthOf(1); + expect(res.body.channel.muted[0]).to.be.equal('rocket.cat'); + }); + }); + }); + + describe('/rooms.unmuteUser', () => { + let testChannel: IRoom; + + before('create a channel', async () => { + const result = await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: testChannel._id, + readOnly: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + username: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.name', testChannel.name); + }); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should unmute the rocket.cat user in read-only room', () => { + return request + .post(api('rooms.unmuteUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + username: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should contain rocket.cat user in unmute list', () => { + return request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel.name', testChannel.name); + expect(res.body.channel).to.have.property('unmuted').and.to.be.an('array'); + expect(res.body.channel.unmuted).to.have.lengthOf(1); + expect(res.body.channel.unmuted[0]).to.be.equal('rocket.cat'); + }); + }); + }); + + describe('/rooms.export', () => { + let testChannel: IRoom; + let testMessageId: IMessage['_id']; + + before(async () => { + const result = await createRoom({ type: 'c', name: `channel.export.test.${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + testMessageId = message._id; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should fail exporting room as file if dates are incorrectly provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: 'test-date', + dateTo: 'test-date', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if no roomId is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'rid'"); + }); + }); + + it('should fail exporting room as file if no type is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'type'"); + }); + }); + + it('should fail exporting room as file if fromDate is after toDate (incorrect date interval)', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-22', + dateTo: '2024-03-15', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-dates'); + expect(res.body).to.have.property('error', 'From date cannot be after To date [error-invalid-dates]'); + }); + }); + + it('should fail exporting room as file if invalid roomId is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: 'invalid-rid', + type: 'file', + dateFrom: '2024-03-22', + dateTo: '2024-03-15', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + }); + }); + + it('should fail exporting room as file if no format is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if an invalid format is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'invalid-format', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if an invalid type is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'invalid-type', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should succesfully export room as file', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should succesfully export room as file even if no dates are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail exporting room via email if target users AND target emails are NOT provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no target e-mails are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toEmails: [], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no target users or e-mails params are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no messages are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [credentials['X-User-Id']], + subject: 'Test Subject', + messages: [], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should succesfully export room via email', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [credentials['X-User-Id']], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('missing'); + expect(res.body.missing).to.be.an('array').that.is.empty; + }); + }); + }); + + describe('/rooms.isMember', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + + const fakeRoomId = `room.test.${Date.now()}-${Math.random()}`; + const fakeUserId = `user.test.${Date.now()}-${Math.random()}`; + + const testChannelName = `channel.test.${Date.now()}-${Math.random()}`; + const testGroupName = `group.test.${Date.now()}-${Math.random()}`; + + let testUser1: TestUser; + let testUser2: TestUser; + let testUserNonMember: TestUser; + let testUser1Credentials: Credentials; + let testUserNonMemberCredentials: Credentials; + + before(async () => { + testUser1 = await createUser(); + testUser1Credentials = await login(testUser1.username, password); + }); + + before(async () => { + testUser2 = await createUser(); + }); + + before(async () => { + testUserNonMember = await createUser(); + testUserNonMemberCredentials = await login(testUserNonMember.username, password); + }); + + before(async () => { + const response = await createRoom({ + type: 'c', + name: testChannelName, + members: [testUser1.username, testUser2.username], + }); + testChannel = response.body.channel; + }); + + before(async () => { + const response = await createRoom({ + type: 'p', + name: testGroupName, + members: [testUser1.username, testUser2.username], + }); + testGroup = response.body.group; + }); + + before(async () => { + const response = await createRoom({ + type: 'd', + username: testUser2.username, + credentials: testUser1Credentials, + }); + testDM = response.body.room; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteUser(testUser1), + deleteUser(testUser2), + deleteUser(testUserNonMember), + ]), + ); + + it('should return error if room not found', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: fakeRoomId, + userId: testUser1._id, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'The required "roomId" or "roomName" param provided does not match any channel [error-room-not-found]', + ); + }); + }); + + it('should return error if user not found with the given userId', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return error if user not found with the given username', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: fakeUserId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-found'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the channel', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testChannel._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return success with isMember=true if given userId is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testGroup._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the group', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testGroup._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + it('should return success with isMember=true if given userId is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUser2._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=true if given username is a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + username: testUser2.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', true); + }); + }); + + it('should return success with isMember=false if user is not a member of the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUser1Credentials) + .query({ + roomId: testDM._id, + userId: testUserNonMember._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('isMember', false); + }); + }); + + it('should return unauthorized if caller cannot access the DM', () => { + return request + .get(api('rooms.isMember')) + .set(testUserNonMemberCredentials) + .query({ + roomId: testDM._id, + userId: testUser1._id, + }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); + + describe('/rooms.open', () => { + let room: IRoom; + + before(async () => { + room = (await createRoom({ type: 'c', name: `rooms.open.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: room._id }); + }); + + it('should open the room', (done) => { + void request + .post(api('rooms.open')) + .set(credentials) + .send({ roomId: room._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + void request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId: room._id }) + .send() + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('open', true); + }) + .end(done); + }); + + it('should fail if roomId is not provided', async () => { + await request + .post(api('rooms.open')) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/rooms.membersOrderedByRole]', () => { + const isEnterprise = Boolean(process.env.IS_EE); + + let testChannel: IRoom; + let ownerUser: IUser; + let leaderUser: IUser; + let moderatorUser: IUser; + let memberUser1: IUser; + let memberUser2: IUser; + let customRole: IRole; + + let ownerCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let memberUser1Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let memberUser2Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before(async () => { + [ownerUser, leaderUser, moderatorUser, memberUser1, memberUser2] = await Promise.all([ + createUser({ username: `a_${Random.id()}`, roles: ['admin'] }), + createUser({ username: `b_${Random.id()}` }), + createUser({ username: `c_${Random.id()}` }), + createUser({ username: `d_${Random.id()}` }), + createUser({ username: `e_${Random.id()}` }), + ]); + + [ownerCredentials, memberUser1Credentials, memberUser2Credentials] = await Promise.all([ + login(ownerUser.username, password), + login(memberUser1.username, password), + login(memberUser2.username, password), + ]); + + customRole = await createCustomRole({ + name: `customRole.${Random.id()}`, + scope: 'Subscriptions', + description: 'Custom Role', + }); + + // Create a public channel + const roomCreationResponse = await createRoom({ + type: 'c', + name: `rooms.membersOrderedByRole.test.${Date.now()}`, + credentials: ownerCredentials, + }); + testChannel = roomCreationResponse.body.channel; + + await Promise.all( + [leaderUser._id, moderatorUser._id, memberUser1._id, memberUser2._id].map((userId) => + request + .post(api('channels.invite')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId, + }) + .expect(200), + ), + ); + + await Promise.all([ + request + .post(api('channels.addLeader')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: leaderUser._id, + }) + .expect(200), + request + .post(api('channels.addModerator')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: moderatorUser._id, + }) + .expect(200), + ]); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await Promise.all([ownerUser, moderatorUser, memberUser1, memberUser2].map((user) => deleteUser(user))); + if (isEnterprise && customRole) { + await deleteCustomRole({ roleId: customRole._id }); + } + }); + + it('should return a list of members ordered by owner, leader, moderator, then members by default', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an('array'); + + const [first, second, third, ...rest] = response.body.members; + expect(first.username).to.equal(ownerUser.username); + expect(second.username).to.equal(leaderUser.username); + expect(third.username).to.equal(moderatorUser.username); + + const memberUsernames = rest.map((m: any) => m.username); + expect(memberUsernames).to.include(memberUser1.username); + expect(memberUsernames).to.include(memberUser2.username); + + expect(response.body).to.have.property('total'); + expect(response.body.total).to.be.eq(5); + }); + + it('should support sorting by role in descending priority', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: '{"rolePriority":-1}', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const [first, second, third, fourth, fifth] = response.body.members; + + expect(first.username).to.equal(memberUser1.username); + expect(second.username).to.equal(memberUser2.username); + expect(third.username).to.equal(moderatorUser.username); + expect(fourth.username).to.equal(leaderUser.username); + expect(fifth.username).to.equal(ownerUser.username); + }); + + it('should support pagination', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(2); + expect(response.body.total).to.be.eq(5); + }); + + it('should return matched members when using filter param', async () => { + const response = await request + .get(api(`rooms.membersOrderedByRole`)) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: memberUser1.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(1); + expect(response.body.members[0]).have.property('username', memberUser1.username); + }); + + it('should return empty list if no matches (e.g., filter by status that no one has)', async () => { + const response = await request + .get(api(`rooms.membersOrderedByRole`)) + .set(credentials) + .query({ + 'roomId': testChannel._id, + 'status[]': 'SomeRandomStatus', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an.empty('array'); + }); + + it('should support custom sorting by username descending', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: JSON.stringify({ username: -1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const usernames = response.body.members.map((m: any) => m.username); + + const expected = [ + ownerUser.username, // since owner + leaderUser.username, // since leader + moderatorUser.username, // since moderator + memberUser2.username, + memberUser1.username, + ]; + + expect(usernames).to.deep.equal(expected); + }); + + it('should not be affected by custom roles when sorting', async () => { + if (!isEnterprise) { + return; + } + await Promise.all([ + assignRoleToUser({ username: moderatorUser.username as string, roleId: customRole._id }), + assignRoleToUser({ username: memberUser2.username as string, roleId: customRole._id }), + ]); + + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const [first, second, third, fourth, fifth] = response.body.members; + + expect(first.username).to.equal(ownerUser.username); + expect(second.username).to.equal(leaderUser.username); + expect(third.username).to.equal(moderatorUser.username); + expect(fourth.username).to.equal(memberUser1.username); + expect(fifth.username).to.equal(memberUser2.username); + }); + + describe('Sort by user status', () => { + before(async () => { + await request.post(api('settings/Accounts_AllowUserStatusMessageChange')).set(credentials).send({ value: true }).expect(200); + + await Promise.all([ + request.post(api('users.setStatus')).set(memberUser1Credentials).send({ status: 'offline', userId: memberUser1._id }).expect(200), + request.post(api('users.setStatus')).set(memberUser2Credentials).send({ status: 'online', userId: memberUser2._id }).expect(200), + ]); + }); + + // Skipping resetting setting Accounts_AllowUserStatusMessageChange as default value is true + after(() => + request.post(api('users.setStatus')).set(memberUser2Credentials).send({ status: 'offline', userId: memberUser2._id }).expect(200), + ); + + it('should sort by user status after user role', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const [first, second, third, fourth, fifth] = response.body.members; + + expect(first.username).to.equal(ownerUser.username); // since owner + expect(second.username).to.equal(leaderUser.username); // since leader + expect(third.username).to.equal(moderatorUser.username); // since moderator + expect(fourth.username).to.equal(memberUser2.username); // since online + expect(fifth.username).to.equal(memberUser1.username); // since offline + }); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `rooms.membersOrderedByRole.team.public.${Random.id()}`, TeamType.PUBLIC, [ + outsiderUser.username as string, + ]), + createTeam(insideCredentials, `rooms.membersOrderedByRole.team.private.${Random.id()}`, TeamType.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `rooms.membersOrderedByRole.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `rooms.membersOrderedByRole.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([ + deleteTeam(credentials, publicTeam.name), + deleteTeam(credentials, privateTeam.name), + restorePermissionToRoles('view-c-room'), + ]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room members by user not part of room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch private room members by user who is part of the room', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch public room members by user who is part of the room', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch public room members by user not part of room - because public', async () => { + await updatePermission('view-c-room', ['admin', 'user', 'guest']); + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch a private channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone part of the room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a public channel members inside a private team by someone not part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a private channel members inside a private team by someone part of the room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('/rooms.hide', () => { + let roomA: IRoom; + let roomB: IRoom; + const roomName = `rooms.hide.test.${Date.now()}`; + let memberA: TestUser; + let memberB: TestUser; + let nonMember: TestUser; + let nonMemberCredentials: Credentials; + + before(async () => { + memberA = await createUser(); + memberB = await createUser(); + nonMember = await createUser(); + nonMemberCredentials = await login(nonMember.username, password); + }); + + before(async () => { + roomA = (await createRoom({ type: 'c', name: roomName, members: [memberA.username, memberB.username] })).body.channel; + roomB = (await createRoom({ type: 'd', username: memberB.username })).body.room; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: roomA._id }); + await deleteRoom({ type: 'd', roomId: roomB._id }); + await deleteUser(memberA); + await deleteUser(memberB); + await deleteUser(nonMember); + }); + + it('should hide the room', async () => { + await request + .post(api('rooms.hide')) + .set(credentials) + .send({ roomId: roomA._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should be already hidden', async () => { + await request + .post(api('rooms.hide')) + .set(credentials) + .send({ roomId: roomA._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `error-room-already-hidden`); + }); + }); + + it('should fail if roomId is not provided', async () => { + await request + .post(api('rooms.hide')) + .set(credentials) + .send() + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return 401 if user is not logged in', async () => { + await request + .post(api('rooms.hide')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should return forbidden if user does not have access to the room', async () => { + await request + .post(api('rooms.hide')) + .set(nonMemberCredentials) + .send({ roomId: roomB._id }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('/rooms.roles', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}-${Math.random()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should get room roles', async () => { + const response = await request.get(api('rooms.roles')).set(credentials).query({ rid: testChannel._id }).expect(200); + expect(response.body.success).to.be.true; + // the schema is already validated in the server on TEST mode + expect(response.body.roles).to.be.an('array'); + // it should have the user roles + expect(response.body.roles).to.have.lengthOf(1); + expect(response.body.roles[0].rid).to.equal(testChannel._id); + expect(response.body.roles[0].roles).to.be.an('array'); + // it should contain owner role + expect(response.body.roles[0].roles).to.include('owner'); + }); + }); + + describe('/rooms.banUser', () => { + let testChannel: IRoom; + let bannableUser: TestUser; + let bannableUserCredentials: Credentials; + + before(async () => { + bannableUser = await createUser(); + bannableUserCredentials = await login(bannableUser.username, password); + + const result = await createRoom({ type: 'c', name: `ban-test-${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + // Invite the user to the channel + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteUser(bannableUser); + }); + + it('should fail if not authenticated', () => { + return request + .post(api('rooms.banUser')) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401); + }); + + it('should fail if roomId is missing', () => { + return request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should fail if userId and username are both missing', () => { + return request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should ban a user from the room', () => { + return request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail if user is already banned', () => { + return request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should prevent banned user from sending messages', () => { + return request + .post(api('chat.sendMessage')) + .set(bannableUserCredentials) + .send({ + message: { + rid: testChannel._id, + msg: 'This should fail', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should not list the banned user in channel members', () => { + return request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(bannableUser.username); + }); + }); + + describe('unban via re-invite', () => { + it('should fail to invite a banned user', () => { + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-is-banned'); + }); + }); + + it('should list the banned user in rooms.bannedUsers', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + const usernames = res.body.bannedUsers.map((u: { username: string }) => u.username); + expect(usernames).to.include(bannableUser.username); + }); + + it('should unban the user and then re-invite successfully', async () => { + await request + .post(api('rooms.unbanUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + username: bannableUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + // Non Federated room should replace the status from BANNED to member since invite is not in place yet + it('should set the re-invited user subscription to INVITED status', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(bannableUserCredentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).not.to.have.property('status'); + }); + + it('should list the re-invited user in channel members', () => { + return request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(bannableUser.username); + }); + }); + + it('should no longer list the user as banned', () => { + return request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const userIds = res.body.bannedUsers.map((u: IUser) => u._id); + expect(userIds).to.not.include(bannableUser._id); + }); + }); + }); + }); + + describe('/rooms.unbanUser', () => { + let testChannel: IRoom; + let bannableUser: TestUser; + + before(async () => { + bannableUser = await createUser(); + await login(bannableUser.username, password); + + const result = await createRoom({ type: 'c', name: `unban-test-${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + // Invite the user to the channel + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect(200); + + // Ban the user + await request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteUser(bannableUser); + }); + + it('should fail if not authenticated', () => { + return request + .post(api('rooms.unbanUser')) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401); + }); + + it('should unban a user from the room', () => { + return request + .post(api('rooms.unbanUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should NOT list the unbanned user in channel members', () => { + return request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(bannableUser.username); + }); + }); + + it('should NOT list the user as banned after unban', () => { + return request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const userIds = res.body.bannedUsers.map((u: IUser) => u._id); + expect(userIds).to.not.include(bannableUser._id); + }); + }); + + it('should allow re-inviting the unbanned user as a fresh invite', async () => { + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // Verify the user is now listed in channel members + await request + .get(api('channels.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(bannableUser.username); + }); + }); + + it('should fail to unban a user that is not banned', () => { + return request + .post(api('rooms.unbanUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: bannableUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-user-not-banned'); + }); + }); + }); + + describe('/rooms.bannedUsers', () => { + let testChannel: IRoom; + let bannedUsers: TestUser[]; + let bannedUserIds: string[]; + + before(async () => { + const result = await createRoom({ type: 'c', name: `banned-users-list-${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + bannedUsers = await Promise.all([createUser(), createUser(), createUser()]); + bannedUserIds = bannedUsers.map((user) => user._id); + + for (const user of bannedUsers) { + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: user._id, + }) + .expect(200); + + await request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: user._id, + }) + .expect(200); + } + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await Promise.all(bannedUsers.map((user) => deleteUser(user))); + }); + + it('should list every banned user of the room with only the projected fields', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('count', bannedUsers.length); + expect(res.body).to.have.property('offset', 0); + expect(res.body.bannedUsers).to.be.an('array').with.lengthOf(bannedUsers.length); + expect(res.body.bannedUsers.map((u: IUser) => u._id)).to.have.members(bannedUserIds); + + res.body.bannedUsers.forEach((user: IUser) => { + expect(user).to.have.property('_id').that.is.a('string'); + expect(user).to.have.property('username').that.is.a('string'); + expect(Object.keys(user)).to.satisfy( + (keys: string[]) => keys.every((key) => ['_id', 'username', 'name'].includes(key)), + 'response should only contain the projected fields (_id, username, name)', + ); + }); + }); + + it('should limit the number of banned users returned when count is provided', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('offset', 0); + expect(res.body.bannedUsers).to.be.an('array').with.lengthOf(2); + }); + + it('should skip banned users when offset is provided', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 2, + count: 2, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('offset', 2); + expect(res.body.bannedUsers) + .to.be.an('array') + .with.lengthOf(bannedUsers.length - 2); + }); + + it('should paginate through all banned users without overlapping results', async () => { + const firstPage = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + }) + .expect(200); + + const secondPage = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 2, + count: 2, + }) + .expect(200); + + const firstPageIds = firstPage.body.bannedUsers.map((u: IUser) => u._id); + const secondPageIds = secondPage.body.bannedUsers.map((u: IUser) => u._id); + + expect(firstPageIds.filter((id: string) => secondPageIds.includes(id))).to.be.empty; + expect([...firstPageIds, ...secondPageIds]).to.have.members(bannedUserIds); + }); + }); +}); + +describe('[/rooms.history]', () => { + let testChannel: IRoom; + const messageIds: IMessage['_id'][] = []; + const messageCount = 12; + + before((done) => getCredentials(done)); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `rooms-history-${Date.now()}` })).body.channel; + + for (let i = 0; i < messageCount; i++) { + const res = await sendMessage({ message: { rid: testChannel._id, msg: `message-${i}` } }); + messageIds.push(res.body.message._id); + } + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should return the newest page with no forward cursor', async () => { + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5 }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(5); + expect(res.body.cursor).to.have.property('next', null); + expect(res.body.cursor.previous).to.be.a('string'); + + expect(res.body.messages[0]._id).to.equal(messageIds[messageCount - 1]); + expect(res.body.messages[0].msg).to.equal(`message-${messageCount - 1}`); + }); + + it('should page backwards through `previous` without repeating messages', async () => { + const firstPage = await request.get(api('rooms.history')).set(credentials).query({ roomId: testChannel._id, count: 5 }).expect(200); + + const secondPage = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5, previous: firstPage.body.cursor.previous }) + .expect(200); + + const firstIds = firstPage.body.messages.map((m: IMessage) => m._id); + const secondIds = secondPage.body.messages.map((m: IMessage) => m._id); + + expect(secondIds).to.have.lengthOf(5); + expect(firstIds.filter((id: string) => secondIds.includes(id))).to.be.empty; + expect(secondPage.body.cursor.next).to.be.a('string'); + }); + + it('should page forwards through `next` and still return newest-first', async () => { + const firstPage = await request.get(api('rooms.history')).set(credentials).query({ roomId: testChannel._id, count: 5 }).expect(200); + + const older = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5, previous: firstPage.body.cursor.previous }) + .expect(200); + + const forwards = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5, next: older.body.cursor.next }) + .expect(200); + + const timestamps = forwards.body.messages.map((m: IMessage) => new Date(m.ts).getTime()); + expect(timestamps).to.deep.equal([...timestamps].sort((a, b) => b - a)); + + expect(forwards.body.messages.map((m: IMessage) => m._id)).to.have.members(firstPage.body.messages.map((m: IMessage) => m._id)); + }); + + it('should exhaust the room and close the backward cursor', async () => { + let cursor: string | null = null; + const seen = new Set(); + + for (let page = 0; page < 10; page++) { + const res: Awaited> = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5, ...(cursor && { previous: cursor }) }) + .expect(200); + + res.body.messages.forEach((m: IMessage) => seen.add(m._id)); + cursor = res.body.cursor.previous; + + if (cursor === null) { + break; + } + } + + expect(cursor).to.be.null; + messageIds.forEach((id) => expect(seen.has(id)).to.be.true); + }); + + it('should report unreads from `lastSeen` without truncating the page', async () => { + const all = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: messageCount }) + .expect(200); + + const marker = all.body.messages[Math.floor(messageCount / 2)]; + + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, count: 5, lastSeen: new Date(marker.ts).toISOString() }) + .expect(200); + + // `lastSeen` must not bound the page the way `oldest` does + expect(res.body.messages).to.have.lengthOf(5); + expect(res.body).to.have.property('unreadNotLoaded'); + expect(res.body.unreadNotLoaded).to.be.a('number'); + }); + + it('should serve private groups and DMs through the same endpoint', async () => { + const { group } = (await createRoom({ type: 'p', name: `rooms-history-group-${Date.now()}` })).body; + await sendMessage({ message: { rid: group._id, msg: 'group message' } }); + + const groupRes = await request.get(api('rooms.history')).set(credentials).query({ roomId: group._id }).expect(200); + + expect(groupRes.body).to.have.property('success', true); + expect(groupRes.body.messages[0].msg).to.equal('group message'); + + const dm = (await createRoom({ type: 'd', username: 'rocket.cat' })).body.room; + await sendMessage({ message: { rid: dm._id, msg: 'dm message' } }); + + const dmRes = await request.get(api('rooms.history')).set(credentials).query({ roomId: dm._id }).expect(200); + + expect(dmRes.body).to.have.property('success', true); + expect(dmRes.body.messages[0].msg).to.equal('dm message'); + + await Promise.all([deleteRoom({ type: 'p', roomId: group._id }), deleteRoom({ type: 'd', roomId: dm._id })]); + }); + + // Regression: a null `attachments` on the quote attachment used to fail response validation. + it('should return messages carrying a quote attachment', async () => { + const parent = await sendMessage({ message: { rid: testChannel._id, msg: 'parent of a discussion' } }); + + const discussion = await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testChannel._id, + pmid: parent.body.message._id, + t_name: `rooms-history-discussion-${Date.now()}`, + }) + .expect(200); + + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: discussion.body.discussion._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages[0].attachments).to.be.an('array'); + + await deleteRoom({ type: 'c', roomId: discussion.body.discussion._id }); + }); + + it('should build a window around `aroundId` and expose both cursors', async () => { + const target = messageIds[Math.floor(messageCount / 2)]; + + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: target, count: 5 }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.messages).to.have.lengthOf(5); + expect(res.body.messages.map((m: IMessage) => m._id)).to.include(target); + + // the only request shape that can report more in both directions at once + expect(res.body.cursor.previous).to.be.a('string'); + expect(res.body.cursor.next).to.be.a('string'); + + const timestamps = res.body.messages.map((m: IMessage) => new Date(m.ts).getTime()); + expect(timestamps).to.deep.equal([...timestamps].sort((a, b) => b - a)); + }); + + it('should close the cursors at the edges of the room', async () => { + // A dedicated room keeps the edges unambiguous: other tests keep appending to testChannel, + // so its newest message is whatever the previous test happened to send. + const edgeChannel = (await createRoom({ type: 'c', name: `rooms-history-edges-${Date.now()}` })).body.channel; + const ids: IMessage['_id'][] = []; + for (let i = 0; i < 6; i++) { + ids.push((await sendMessage({ message: { rid: edgeChannel._id, msg: `edge-${i}` } })).body.message._id); + } + + try { + const newest = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: edgeChannel._id, aroundId: ids[ids.length - 1], count: 5 }) + .expect(200); + + expect(newest.body.cursor.next).to.be.null; + expect(newest.body.cursor.previous).to.be.a('string'); + + const oldest = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: edgeChannel._id, aroundId: ids[0], count: 5 }) + .expect(200); + + expect(oldest.body.cursor.previous).to.be.null; + expect(oldest.body.cursor.next).to.be.a('string'); + } finally { + await deleteRoom({ type: 'c', roomId: edgeChannel._id }); + } + }); + + it('should page in both directions from a window built by `aroundId`', async () => { + const around = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: messageIds[Math.floor(messageCount / 2)], count: 5 }) + .expect(200); + + const windowIds = around.body.messages.map((m: IMessage) => m._id); + + const [older, newer] = await Promise.all([ + request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, previous: around.body.cursor.previous, count: 5 }) + .expect(200), + request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, next: around.body.cursor.next, count: 5 }) + .expect(200), + ]); + + const olderIds = older.body.messages.map((m: IMessage) => m._id); + const newerIds = newer.body.messages.map((m: IMessage) => m._id); + + expect(olderIds.filter((id: string) => windowIds.includes(id))).to.be.empty; + expect(newerIds.filter((id: string) => windowIds.includes(id))).to.be.empty; + }); + + it('should fail when `aroundId` is combined with a cursor', async () => { + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: messageIds[0], previous: '1' }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-cursor-conflict'); + }); + + it('should report the cursor conflict even when `aroundId` does not resolve', async () => { + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: 'does-not-exist', previous: '1' }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-cursor-conflict'); + }); + + it('should not find a message that belongs to another room', async () => { + const other = (await createRoom({ type: 'c', name: `rooms-history-other-${Date.now()}` })).body.channel; + const stray = await sendMessage({ message: { rid: other._id, msg: 'stray message' } }); + + await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: stray.body.message._id }) + .expect(404); + + await deleteRoom({ type: 'c', roomId: other._id }); + }); + + it('should not find a hidden message', async () => { + // Message_KeepHistory without Message_ShowDeletedStatus hides the deleted message instead of removing it + await updateSetting('Message_KeepHistory', true); + try { + const hidden = await sendMessage({ message: { rid: testChannel._id, msg: 'to be hidden' } }); + await deleteMessage({ roomId: testChannel._id, msgId: hidden.body.message._id }); + + await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, aroundId: hidden.body.message._id }) + .expect(404); + } finally { + await updateSetting('Message_KeepHistory', false); + } + }); + + it('should fail when both cursors are provided', async () => { + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, next: '1', previous: '1' }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-cursor-conflict'); + }); + + it('should fail when a cursor is not parseable', async () => { + const res = await request + .get(api('rooms.history')) + .set(credentials) + .query({ roomId: testChannel._id, previous: 'not-a-cursor' }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-cursor'); + }); + + it('should fail for a room that does not exist', async () => { + await request.get(api('rooms.history')).set(credentials).query({ roomId: 'does-not-exist' }).expect(404); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts new file mode 100644 index 0000000..2cc87ff --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -0,0 +1,861 @@ +import type { IServerEvents, LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it, after, afterEach } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission, updateSetting, getSettingValueById } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +describe('[Settings]', () => { + before((done) => getCredentials(done)); + + describe('[/settings.public]', () => { + it('should return public settings', async () => { + const res = await request.get(api('settings.public')).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('settings'); + expect(res.body).to.have.property('count'); + }); + it('should return public settings even requested with count and offset params', async () => { + const res = await request + .get(api('settings.public')) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success').and.to.be.true; + expect(res.body).to.have.property('settings').and.to.be.an('array').and.to.have.lengthOf(5); + expect(res.body).to.have.property('count').and.to.be.a('number').and.to.equal(5); + }); + it('should return public settings even requested with _id param', async () => { + const res = await request + .get(api('settings.public')) + .query({ + _id: 'Site_Url', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success').and.to.be.true; + expect(res.body).to.have.property('settings').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('count').and.to.be.a('number').and.to.equal(1); + }); + it('should return public settings even requested with _id param as an array', async () => { + const res = await request + .get(api('settings.public')) + .query({ + _id: 'Site_Url,LDAP_Enable', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success').and.to.be.true; + expect(res.body).to.have.property('settings').and.to.be.an('array').and.to.have.lengthOf(2); + expect(res.body).to.have.property('count').and.to.be.a('number').and.to.equal(2); + }); + it('should return an empty response when requesting public settings with a broken _id param', async () => { + const res = await request + .get(api('settings.public')) + .query({ + _id: 10, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success').and.to.be.true; + expect(res.body).to.have.property('settings').and.to.be.an('array').and.to.be.empty; + expect(res.body).to.have.property('count').and.to.be.a('number').and.to.equal(0); + expect(res.body).to.have.property('offset').and.to.be.a('number').and.to.equal(0); + expect(res.body).to.have.property('total').and.to.be.a('number').and.to.equal(0); + }); + }); + + describe('[/settings]', () => { + it('should return private settings', async () => { + const res = await request.get(api('settings')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('settings'); + expect(res.body).to.have.property('count'); + }); + it('should return the default values of the settings when includeDefaults is true', async () => { + return request + .get(api('settings')) + .query({ includeDefaults: true }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('settings'); + expect(res.body).to.have.property('count'); + expect(res.body.settings[0]).to.have.property('packageValue'); + }); + }); + + describe('POST (bulk)', () => { + before(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + after(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + it('should return unauthorized when not authenticated', async () => { + await request + .post(api('settings')) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(401); + }); + + it('should return bad request when settings is missing', async () => { + await request.post(api('settings')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when settings is empty', async () => { + await request.post(api('settings')).set(credentials).send({ settings: [] }).expect(400); + }); + + it('should return bad request when an item is missing value', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable' }] }) + .expect(400); + }); + + it('should reject an empty integer value without changing the setting', async () => { + const settingId = 'UserData_ProcessingFrequency'; + const originalValue = await getSettingValueById(settingId); + + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: settingId, value: null }] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + expect(await getSettingValueById(settingId)).to.equal(originalValue); + }); + + it('should successfully update multiple settings in a single request', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ + settings: [ + { _id: 'LDAP_Enable', value: false }, + { _id: 'Accounts_Default_User_Preferences_masterVolume', value: 50 }, + ], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('settings/LDAP_Enable')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', false); + }); + + await request + .get(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', 50); + }); + }); + + it('should accept an optional editor for a setting', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'Livechat_title_color', value: '#000000', editor: 'color' }] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('settings/Livechat_title_color')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', '#000000'); + }); + }); + + it('should return bad request when editor is an empty string', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'Livechat_title_color', value: '#C1272D', editor: '' }] }) + .expect(400); + }); + + it('should fail when the user does not have edit-privileged-setting permission', async () => { + await updatePermission('edit-privileged-setting', []); + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.match(/error-action-not-allowed/); + }); + }); + }); + }); + + describe('[/settings/:_id]', () => { + before(async () => { + await updatePermission('view-privileged-setting', ['admin']); + await updatePermission('edit-privileged-setting', ['admin']); + }); + + after(async () => { + await updatePermission('view-privileged-setting', ['admin']); + await updatePermission('edit-privileged-setting', ['admin']); + }); + + it('should successfully return one setting (GET)', async () => { + return request + .get(api('settings/Site_Url')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', 'Site_Url'); + expect(res.body).to.have.property('value'); + }); + }); + + it('should fail returning a setting if user does NOT have the view-privileged-setting permission (GET)', async () => { + await updatePermission('view-privileged-setting', []); + return request + .get(api('settings/Site_Url')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should successfully set the value of a setting (POST)', async () => { + return request + .post(api('settings/LDAP_Enable')) + .set(credentials) + .send({ + value: false, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail updating the value of a setting less than its minValue (POST)', async () => { + return request + .post(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .send({ + value: '-1', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'Value for setting Accounts_Default_User_Preferences_masterVolume must be greater than or equal to 0 [error-invalid-setting-value]', + ); + }); + }); + + it('should fail updating the value of a setting greater than its maxValue (POST)', async () => { + return request + .post(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .send({ + value: '101', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'Value for setting Accounts_Default_User_Preferences_masterVolume must be less than or equal to 100 [error-invalid-setting-value]', + ); + }); + }); + + it('should fail updating the value of a setting if user does NOT have the edit-privileged-setting permission (POST)', async () => { + await updatePermission('edit-privileged-setting', []); + return request + .post(api('settings/LDAP_Enable')) + .set(credentials) + .send({ + value: false, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[Room_Toolbox_Layout]', () => { + const validLayout = [ + { roomType: ['c', 'p'], maxVisibleNormal: 3, items: [{ id: 'members-list', featured: true, order: 1 }] }, + { roomType: ['d'], maxVisibleNormal: 1 }, + ]; + + const saveLayout = (value: string) => request.post(api('settings/Room_Toolbox_Layout')).set(credentials).send({ value }); + + after(() => updateSetting('Room_Toolbox_Layout', '')); + + it('should reject a value that is not valid JSON', async () => { + await saveLayout('not json {') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Room_Toolbox_Layout_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject an entry declaring an unsupported room type', async () => { + await saveLayout(JSON.stringify([{ roomType: ['l'] }])) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Room_Toolbox_Layout_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject entries claiming the same room type', async () => { + await saveLayout(JSON.stringify([{ roomType: ['c', 'p'] }, { roomType: ['c'] }])) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Room_Toolbox_Layout_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject an entry repeating a room type', async () => { + await saveLayout(JSON.stringify([{ roomType: ['c', 'c'] }])) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Room_Toolbox_Layout_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject an entry carrying an unknown key', async () => { + await saveLayout(JSON.stringify([{ roomType: ['c'], somethingElse: true }])) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Room_Toolbox_Layout_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should accept a document matching the schema', async () => { + await saveLayout(JSON.stringify(validLayout)) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should accept an empty value, leaving the setting unconfigured', async () => { + await saveLayout('') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('[/service.configurations]', () => { + it('should return service configurations', async () => { + const res = await request.get(api('service.configurations')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('configurations'); + }); + + describe('With OAuth enabled', () => { + before(() => updateSetting('Accounts_OAuth_Google', true)); + + after(() => updateSetting('Accounts_OAuth_Google', false)); + + it('should include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + void request + .get(api('service.configurations')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('configurations'); + + expect((res.body.configurations as LoginServiceConfiguration[]).find(({ service }) => service === 'google')).to.exist; + }) + .end(done); + }, 3000); + }); + }); + + describe('With OAuth disabled', () => { + before(() => updateSetting('Accounts_OAuth_Google', false)); + + after(() => updateSetting('Accounts_OAuth_Google', false)); + + it('should not include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + void request + .get(api('service.configurations')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('configurations'); + + expect((res.body.configurations as LoginServiceConfiguration[]).find(({ service }) => service === 'google')).to.not.exist; + }) + .end(done); + }, 3000); + }); + }); + }); + + describe('/settings.oauth', () => { + it('should have return list of available oauth services when user is not logged', async () => { + const res = await request.get(api('settings.oauth')).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('services').and.to.be.an('array'); + }); + + it('should have return list of available oauth services when user is logged', async () => { + const res = await request.get(api('settings.oauth')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('services').and.to.be.an('array'); + }); + }); + + (IS_EE ? describe : describe.skip)('/audit.settings', () => { + const formatDate = (date: Date) => date.toISOString(); + let startDate: Date; + let endDate: Date; + + before(async () => { + // Changing these settings to true and then false ensures that we have audited events + // So we can test filtering reliably + startDate = new Date(); + await Promise.all([updateSetting('Accounts_AllowAnonymousRead', true), updateSetting('Accounts_AllowDeleteOwnAccount', true)]); + + await Promise.all([updateSetting('Accounts_AllowAnonymousRead', false), updateSetting('Accounts_AllowDeleteOwnAccount', false)]); + endDate = new Date(); + }); + + it('should return list of settings changed (no filters)', async () => { + await request + .get(api('audit.settings')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(4); + }); + }); + + it('should return list of settings between date ranges', async () => { + await request + .get(api('audit.settings')) + .query({ start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(4); + (res.body.events as IServerEvents['settings.changed'][]).forEach((event) => { + expect(new Date(event.ts).getTime()).to.be.greaterThanOrEqual(startDate.getTime()); + expect(new Date(event.ts).getTime()).to.be.lessThanOrEqual(endDate.getTime()); + }); + }); + }); + + it('should throw error when sending invalid dates', async () => { + const startDate = new Date(); + const endDate = 'abcdef'; + + await request + .get(api('audit.settings')) + .query({ start: formatDate(startDate), end: endDate }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The "end" query parameter must be a valid date.'); + }); + }); + + it('should return list of settings changed filtered by an actor', async () => { + await request + .get(api('audit.settings')) + .query({ actor: { type: 'user' }, count: 10 }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(1); + (res.body.events as IServerEvents['settings.changed'][]).forEach((sEvent) => expect(sEvent.actor.type).to.be.equal('user')); + }); + }); + + it('should return list of changes of an specific setting', async () => { + await request + .get(api('audit.settings')) + .query({ settingId: 'Site_Url' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + (res.body.events as IServerEvents['settings.changed'][]).forEach((event) => { + expect(event.data[0].key).to.be.equal('id'); + expect(event.data[0].value).to.be.equal('Site_Url'); + }); + }); + }); + + it('should return list of changes filtered by an actor between date ranges', async () => { + await request + .get(api('audit.settings')) + .query({ actor: { type: 'user' }, start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(4); + (res.body.events as IServerEvents['settings.changed'][]).forEach((event) => { + expect(event.actor.type).to.be.equal('user'); + expect(new Date(event.ts).getTime()).to.be.greaterThanOrEqual(startDate.getTime()); + expect(new Date(event.ts).getTime()).to.be.lessThanOrEqual(endDate.getTime()); + }); + }); + }); + + describe('Masking sensitive settings', () => { + let originalSmtpPassword: string | undefined; + let originalSmtpUsername: string | undefined; + let maskingStart: Date; + let maskingEnd: Date; + const testPassword = 'testpassword123'; + const testUsername = 'testuser@example.com'; + + before(async () => { + maskingStart = new Date(); + originalSmtpPassword = (await getSettingValueById('SMTP_Password')) as string | undefined; + originalSmtpUsername = (await getSettingValueById('SMTP_Username')) as string | undefined; + + await updateSetting('SMTP_Password', testPassword); + await updateSetting('SMTP_Username', testUsername); + maskingEnd = new Date(); + }); + + after(async () => { + await updateSetting('SMTP_Password', originalSmtpPassword || ''); + await updateSetting('SMTP_Username', originalSmtpUsername || ''); + }); + + it('should mask sensitive settings in audit logs', async () => { + await request + .get(api('audit.settings')) + .query({ settingId: 'SMTP_Password', start: formatDate(maskingStart), end: formatDate(maskingEnd) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(1); + + const passwordEvents = res.body.events as IServerEvents['settings.changed'][]; + const passwordEvent = passwordEvents.find((event) => { + const settingId = event.data.find((item) => item.key === 'id')?.value; + return settingId === 'SMTP_Password'; + }); + + expect(passwordEvent).to.exist; + + if (passwordEvent) { + const previous = passwordEvent.data.find((item) => item.key === 'previous')?.value; + const current = passwordEvent.data.find((item) => item.key === 'current')?.value; + + if (previous && typeof previous === 'string' && previous.length > 0) { + expect(previous).to.include('*'); + expect(previous).to.not.equal(testPassword); + if (previous.length > 8) { + expect(previous.substring(0, 3)).to.equal(originalSmtpPassword?.substring(0, 3)); + expect(previous.substring(3)).to.match(/^\*+$/); + } + } + + if (current && typeof current === 'string' && current.length > 0) { + expect(current).to.include('*'); + expect(current).to.not.equal(testPassword); + if (testPassword.length > 8) { + expect(current.substring(0, 3)).to.equal(testPassword.substring(0, 3)); + expect(current.substring(3)).to.match(/^\*+$/); + } else { + expect(current).to.match(/^\*+$/); + } + } + } + }); + + await request + .get(api('audit.settings')) + .query({ settingId: 'SMTP_Username', start: formatDate(maskingStart), end: formatDate(maskingEnd) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('events').and.to.be.an('array'); + expect(res.body.events.length).to.be.greaterThanOrEqual(1); + + const usernameEvents = res.body.events as IServerEvents['settings.changed'][]; + const usernameEvent = usernameEvents.find((event) => { + const settingId = event.data.find((item) => item.key === 'id')?.value; + return settingId === 'SMTP_Username'; + }); + + expect(usernameEvent).to.exist; + + if (usernameEvent) { + const previous = usernameEvent.data.find((item) => item.key === 'previous')?.value; + const current = usernameEvent.data.find((item) => item.key === 'current')?.value; + + if (previous && typeof previous === 'string' && previous.length > 0) { + expect(previous).to.include('*'); + expect(previous).to.not.equal(originalSmtpUsername); + } + + if (current && typeof current === 'string' && current.length > 0) { + expect(current).to.include('*'); + expect(current).to.not.equal(testUsername); + if (testUsername.length > 8) { + expect(current.substring(0, 3)).to.equal(testUsername.substring(0, 3)); + expect(current.substring(3)).to.match(/^\*+$/); + } else { + expect(current).to.match(/^\*+$/); + } + } + } + }); + }); + }); + + describe('Sorting', () => { + const isSortedAscending = (events: IServerEvents['settings.changed'][]) => + events.every((event, i) => i === 0 || new Date(event.ts).getTime() >= new Date(events[i - 1].ts).getTime()); + + const isSortedDescending = (events: IServerEvents['settings.changed'][]) => + events.every((event, i) => i === 0 || new Date(event.ts).getTime() <= new Date(events[i - 1].ts).getTime()); + + it('should sort by timestamp ascending', async () => { + const response = await request + .get(api('audit.settings')) + .query({ sort: JSON.stringify({ ts: 1 }), start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.events).to.have.length.greaterThanOrEqual(2); + expect(isSortedAscending(response.body.events)).to.be.true; + }); + + it('should sort by timestamp descending', async () => { + const response = await request + .get(api('audit.settings')) + .query({ sort: JSON.stringify({ ts: -1 }), start: formatDate(startDate), end: formatDate(endDate) }) + .set(credentials) + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.events).to.have.length.greaterThanOrEqual(2); + expect(isSortedDescending(response.body.events)).to.be.true; + }); + + it('should sort by timestamp descending by default', async () => { + const response = await request.get(api('audit.settings')).set(credentials).expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.events).to.have.length.greaterThanOrEqual(2); + expect(isSortedDescending(response.body.events)).to.be.true; + }); + }); + }); + + describe('Custom OAuth admin endpoints', () => { + const oauthName = 'apitest'; + const enableSettingId = 'Accounts_OAuth_Custom-Apitest'; + + before(() => updatePermission('add-oauth-service', ['admin'])); + + // guarantee the permission is restored even if a permission-denied assertion fails mid-test, + // so a failure cannot cascade into the following cases + afterEach(() => updatePermission('add-oauth-service', ['admin'])); + + after(async () => { + await updatePermission('add-oauth-service', ['admin']); + // best-effort cleanup in case a test left the custom service behind + await request.post(api('settings.removeCustomOAuth')).set(credentials).send({ name: oauthName }); + }); + + describe('[/settings.addCustomOAuth]', () => { + it('should fail when unauthenticated', () => request.post(api('settings.addCustomOAuth')).send({ name: oauthName }).expect(401)); + + it('should fail when the "name" param is not provided', () => + request.post(api('settings.addCustomOAuth')).set(credentials).send({}).expect(400)); + + it('should fail when the user does not have the add-oauth-service permission', async () => { + await updatePermission('add-oauth-service', []); + await request + .post(api('settings.addCustomOAuth')) + .set(credentials) + .send({ name: oauthName }) + .expect((res) => { + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + }); + }); + + it('should add a custom oauth service', async () => { + await request + .post(api('settings.addCustomOAuth')) + .set(credentials) + .send({ name: oauthName }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const value = await getSettingValueById(enableSettingId); + expect(value).to.be.a('boolean'); + }); + }); + + describe('[/settings.refreshOAuthServices]', () => { + it('should fail when unauthenticated', () => request.post(api('settings.refreshOAuthServices')).expect(401)); + + it('should fail when the user does not have the add-oauth-service permission', async () => { + await updatePermission('add-oauth-service', []); + await request + .post(api('settings.refreshOAuthServices')) + .set(credentials) + .expect((res) => { + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + }); + }); + + it('should refresh the oauth services', () => + request + .post(api('settings.refreshOAuthServices')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + })); + }); + + describe('[/settings.removeCustomOAuth]', () => { + it('should fail when unauthenticated', () => request.post(api('settings.removeCustomOAuth')).send({ name: oauthName }).expect(401)); + + it('should fail when the "name" param is not provided', () => + request.post(api('settings.removeCustomOAuth')).set(credentials).send({}).expect(400)); + + it('should fail when the "name" normalizes to an empty string', () => + request + .post(api('settings.removeCustomOAuth')) + .set(credentials) + .send({ name: '!!!' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + })); + + it('should fail when the user does not have the add-oauth-service permission', async () => { + await updatePermission('add-oauth-service', []); + await request + .post(api('settings.removeCustomOAuth')) + .set(credentials) + .send({ name: oauthName }) + .expect((res) => { + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + }); + }); + + it('should remove a custom oauth service', async () => { + await request + .post(api('settings.removeCustomOAuth')) + .set(credentials) + .send({ name: oauthName }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // the provider settings should be gone: settings/:_id answers 400 for an unknown id + await request.get(`/api/v1/settings/${enableSettingId}`).set(credentials).expect(400); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/setupWizard.ts b/apps/meteor/tests/end-to-end/api/setupWizard.ts new file mode 100644 index 0000000..cf65964 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/setupWizard.ts @@ -0,0 +1,87 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { getSettingValueById, updateSetting } from '../../data/permissions.helper'; + +describe('[SetupWizard]', function () { + this.retries(0); + + before((done) => getCredentials(done)); + + describe('[/setupWizard.parameters]', () => { + it('should return the wizard parameters without authentication', async () => { + return request + .get(api('setupWizard.parameters')) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('serverAlreadyRegistered').that.is.a('boolean'); + expect(res.body).to.have.property('settings').that.is.an('array'); + }); + }); + + it('should only return settings that belong to the wizard', async () => { + return request + .get(api('setupWizard.parameters')) + .expect(200) + .expect((res: Response) => { + expect(res.body.settings).to.not.be.empty; + res.body.settings.forEach((setting: Record) => { + expect(setting).to.have.property('wizard'); + expect(setting).to.have.property('_id'); + }); + }); + }); + + it('should return the wizard parameters when authenticated', async () => { + return request + .get(api('setupWizard.parameters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('settings').that.is.an('array'); + }); + }); + + describe('when the setup wizard is already completed', () => { + let previousState: ISetting['value']; + + before(async () => { + previousState = await getSettingValueById('Show_Setup_Wizard'); + await updateSetting('Show_Setup_Wizard', 'completed'); + }); + + after(async () => updateSetting('Show_Setup_Wizard', previousState)); + + it('should not expose the wizard parameters to unauthenticated requests', async () => { + return request + .get(api('setupWizard.parameters')) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.not.have.property('settings'); + expect(res.body).to.not.have.property('serverAlreadyRegistered'); + }); + }); + + it('should not expose the wizard parameters to authenticated requests', async () => { + return request + .get(api('setupWizard.parameters')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.not.have.property('settings'); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts b/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts new file mode 100644 index 0000000..9898801 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts @@ -0,0 +1,334 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { ISidebarCategory, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createRoom, deleteRoom, getSubscriptionByRoomId, addUserToRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import { createUser, login, deleteUser } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const experimentalEndpoint = (path: string) => `/api/experimental/${path}`; + +/** + * Persistence + validation of the `sidebarCategories` user preference (custom sidebar categories). + * + * The feature has no dedicated endpoint — categories are stored on the user preferences and round-trip + * through `users.setPreferences` / `users.getPreferences`. The REST schema validates each entry with + * `required: ['_id', 'name']` and `additionalProperties: false`, so malformed payloads are rejected. + */ +describe('[Sidebar Custom Categories]', () => { + let testUser: IUser; + let testUserCredentials: Credentials; + + const category = (overrides: Partial = {}): ISidebarCategory => ({ + _id: Random.id(), + name: `category-${Random.id()}`, + ...overrides, + }); + + before((done) => getCredentials(done)); + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + after(() => deleteUser(testUser)); + + const setCategories = (data: unknown, asCredentials: Credentials = testUserCredentials) => + request + .post(api('users.setPreferences')) + .set(asCredentials) + .send({ data: { sidebarCategories: data } }); + + describe('persistence', () => { + it('should persist a minimal category (only the required _id + name)', async () => { + const categories = [category({ name: 'Projects' })]; + + await setCategories(categories) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.user.settings.preferences).to.have.property('sidebarCategories').that.is.an('array').with.lengthOf(1); + }); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.preferences.sidebarCategories).to.deep.equal(categories); + }); + }); + + it('should persist the optional showUnreads field verbatim', async () => { + const categories = [category({ name: 'Team', showUnreads: false }), category({ name: 'Personal', showUnreads: true })]; + + await setCategories(categories).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCategories).to.deep.equal(categories); + }); + }); + + it('should preserve the array order (order is the render order)', async () => { + const categories = [category({ name: 'A' }), category({ name: 'B' }), category({ name: 'C' })]; + + await setCategories(categories).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCategories.map((c: ISidebarCategory) => c.name)).to.deep.equal(['A', 'B', 'C']); + }); + }); + + it('should replace the whole array on each write (read-modify-write semantics)', async () => { + await setCategories([category({ name: 'first' })]).expect(200); + const replacement = [category({ name: 'second' })]; + await setCategories(replacement).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCategories).to.have.lengthOf(1); + expect(res.body.preferences.sidebarCategories[0]).to.have.property('name', 'second'); + }); + }); + + it('should accept an empty array (clearing all categories)', async () => { + await setCategories([category({ name: 'temp' })]).expect(200); + await setCategories([]).expect(200); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCategories).to.be.an('array').with.lengthOf(0); + }); + }); + }); + + describe('validation', () => { + const expectInvalid = (res: { body: { success: boolean; errorType: string } }) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }; + + // NOTE: the shared ajv instance runs with `coerceTypes`, so a scalar type mismatch on a coercible + // field (e.g. a numeric `name`, or a numeric entry in `rooms`) is coerced to the schema type and + // accepted rather than rejected. The assertions below therefore cover the constraints coercion + // cannot satisfy: missing required fields, unknown properties, a non-array root, and a value that + // cannot be coerced to the target type. + + it('should reject an entry missing the required "name"', async () => { + await setCategories([{ _id: Random.id() }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject an entry missing the required "_id"', async () => { + await setCategories([{ name: 'no-id' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject an unknown property on an entry (additionalProperties: false)', async () => { + await setCategories([{ _id: Random.id(), name: 'x', color: 'red' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should reject a non-array value', async () => { + await setCategories({ _id: Random.id(), name: 'not-an-array' }).expect(400).expect(expectInvalid); + }); + + it('should reject a "showUnreads" that cannot be coerced to a boolean', async () => { + await setCategories([{ _id: Random.id(), name: 'x', showUnreads: 'yes' }]) + .expect(400) + .expect(expectInvalid); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setPreferences')) + .send({ data: { sidebarCategories: [] } }) + .expect(401); + }); + }); + + describe('permissions', () => { + it("should let an admin set another user's sidebarCategories", async () => { + const categories = [category({ name: 'set-by-admin' })]; + + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ userId: testUser._id, data: { sidebarCategories: categories } }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('users.getPreferences')) + .set(testUserCredentials) + .expect(200) + .expect((res) => { + expect(res.body.preferences.sidebarCategories).to.deep.equal(categories); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('[Experimental] rooms.setCategory', () => { + let catId: string; + let roomId: string; + let roomId2: string; + + const setCategory = (body: object, creds: Credentials = testUserCredentials) => + request.post(experimentalEndpoint('rooms.setCategory')).set(creds).send(body); + + before(async () => { + catId = Random.id(); + await setCategories([{ _id: catId, name: 'E2E Cat' }]); + + // Create rooms as admin (regular users lack create-c permission by default), + // then invite the test user so they are subscribed. + const r1 = await createRoom({ type: 'c', name: `setcat-${Random.id()}` }).expect(200); + roomId = r1.body.channel._id; + await addUserToRoom({ usernames: [testUser.username as string], rid: roomId, type: 'c' }); + + const r2 = await createRoom({ type: 'c', name: `setcat-${Random.id()}` }).expect(200); + roomId2 = r2.body.channel._id; + await addUserToRoom({ usernames: [testUser.username as string], rid: roomId2, type: 'c' }); + }); + + after(async () => { + await setCategories([]); + await Promise.all([ + ...(roomId ? [deleteRoom({ type: 'c', roomId })] : []), + ...(roomId2 ? [deleteRoom({ type: 'c', roomId: roomId2 })] : []), + ]); + }); + + it('should assign a room to a category and persist it on the subscription', async () => { + await setCategory({ roomIds: [roomId], category: catId }) + .expect(200) + .expect((res) => expect(res.body).to.have.property('success', true)); + + const sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.have.property('category', catId); + }); + + it('should unassign a room from its category when category is null', async () => { + await setCategory({ roomIds: [roomId], category: catId }).expect(200); + await setCategory({ roomIds: [roomId], category: null }).expect(200); + + const sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.not.have.property('category'); + }); + + it('should assign multiple rooms to a category in one call', async () => { + await setCategory({ roomIds: [roomId, roomId2], category: catId }).expect(200); + + const [s1, s2] = await Promise.all([ + getSubscriptionByRoomId(roomId, testUserCredentials), + getSubscriptionByRoomId(roomId2, testUserCredentials), + ]); + expect(s1).to.have.property('category', catId); + expect(s2).to.have.property('category', catId); + }); + + it('should reject duplicate roomIds', async () => { + await setCategory({ roomIds: [roomId, roomId], category: catId }).expect(400); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(experimentalEndpoint('rooms.setCategory')) + .send({ roomIds: [roomId], category: catId }) + .expect(401); + }); + + describe('validation', () => { + it('should reject a missing roomIds field', async () => { + await setCategory({ category: catId }).expect(400); + }); + + it('should reject an empty roomIds array (minItems: 1)', async () => { + await setCategory({ roomIds: [], category: catId }).expect(400); + }); + + it('should reject a missing category field', async () => { + await setCategory({ roomIds: [roomId] }).expect(400); + }); + + it('should reject unknown properties (additionalProperties: false)', async () => { + await setCategory({ roomIds: [roomId], category: catId, extra: true }).expect(400); + }); + }); + + describe('business logic', () => { + it('should reject a category id not found in the user preferences', async () => { + await setCategory({ roomIds: [roomId], category: Random.id() }) + .expect(400) + .expect((res) => expect(res.body).to.have.property('success', false)); + }); + + it('should silently skip a room the user is not subscribed to', async () => { + const adminRoom = await createRoom({ type: 'c', name: `admin-only-${Random.id()}` }).expect(200); + const adminRoomId = adminRoom.body.channel._id; + try { + await setCategory({ roomIds: [adminRoomId], category: catId }).expect(200); + // The subscription for adminRoom belongs to the admin, not testUser — setCategoryByRoomIdsAndUserId + // filters by userId, so the unsubscribed room is silently ignored. + const sub = await getSubscriptionByRoomId(adminRoomId, credentials); + expect(sub).to.not.have.property('category'); + } finally { + await deleteRoom({ type: 'c', roomId: adminRoomId }); + } + }); + + it('should remove a room from favorites when assigning it to a category', async () => { + // First favorite the room + await request.post(api('rooms.favorite')).set(testUserCredentials).send({ roomId, favorite: true }).expect(200); + let sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.have.property('f', true); + expect(sub).to.not.have.property('category'); + + // Now assign it to a category — server atomically unsets f + await setCategory({ roomIds: [roomId], category: catId }).expect(200); + sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.have.property('category', catId); + expect(sub).to.have.property('f', false); + }); + + it('should remove a room from its category when favoriting it', async () => { + // First assign the room to a category + await setCategory({ roomIds: [roomId], category: catId }).expect(200); + let sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.have.property('category', catId); + + // Now favorite it — server atomically unsets category + await request.post(api('rooms.favorite')).set(testUserCredentials).send({ roomId, favorite: true }).expect(200); + sub = await getSubscriptionByRoomId(roomId, testUserCredentials); + expect(sub).to.have.property('f', true); + expect(sub).to.not.have.property('category'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/statistics.ts b/apps/meteor/tests/end-to-end/api/statistics.ts new file mode 100644 index 0000000..5a2bbb5 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/statistics.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('[Statistics]', () => { + before((done) => getCredentials(done)); + + after(() => updatePermission('view-statistics', ['admin'])); + + describe('[/statistics]', () => { + let lastUptime: unknown; + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-statistics', []); + + const res = await request.get(api('statistics')).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-allowed'); + }); + + it('should return an object with the statistics', async () => { + await updatePermission('view-statistics', ['admin']); + + const res = await request.get(api('statistics')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('process'); + expect(res.body.process).to.have.property('uptime'); + + lastUptime = res.body.process.uptime; + }); + + it('should update the statistics when is provided the "refresh:true" query parameter', async () => { + const res = await request.get(api('statistics')).query({ refresh: 'true' }).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('process'); + expect(res.body.process).to.have.property('uptime'); + expect(lastUptime).to.not.be.equal(res.body.process.uptime); + }); + }); + + describe('[/statistics.list]', () => { + it('should return an error when the user does not have the necessary permission', async () => { + await updatePermission('view-statistics', []); + + const res = await request.get(api('statistics.list')).set(credentials).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('error-not-allowed'); + }); + + it('should return an array with the statistics', async () => { + await updatePermission('view-statistics', ['admin']); + + const res = await request.get(api('statistics.list')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('statistics').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + + it('should return an array with the statistics even requested with count and offset params', async () => { + await updatePermission('view-statistics', ['admin']); + + const res = await request + .get(api('statistics.list')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('statistics').and.to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/status-visibility.ts b/apps/meteor/tests/end-to-end/api/status-visibility.ts new file mode 100644 index 0000000..6111b16 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/status-visibility.ts @@ -0,0 +1,373 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import { TeamType, UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { api, credentials, getCredentials, request } from '../../data/api-data'; +import { updateEESetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login, setUserStatus } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[Status Visibility] (Enterprise Only)', function () { + this.retries(0); + + let hider: IUser & { username: string }; + let viewer: IUser & { username: string }; + let bystander: IUser & { username: string }; + let hiderCredentials: Credentials; + let viewerCredentials: Credentials; + let bystanderCredentials: Credentials; + let channel: IRoom; + let group: IRoom; + let team: ITeam; + + const usernamesOf = (members: { username?: string }[]) => members.map((member) => member.username); + const statusOf = (members: { username?: string; status?: string }[], username: string) => + members.find((member) => member.username === username)?.status; + + before((done) => getCredentials(done)); + + before(async () => { + await updateEESetting('Accounts_StatusVisibility_Enabled', true); + + [hider, viewer, bystander] = (await Promise.all([ + createUser({ joinDefaultChannels: false }), + createUser({ joinDefaultChannels: false }), + createUser({ joinDefaultChannels: false }), + ])) as (IUser & { username: string })[]; + + [hiderCredentials, viewerCredentials, bystanderCredentials] = await Promise.all([ + login(hider.username, password), + login(viewer.username, password), + login(bystander.username, password), + ]); + + await setUserStatus(hiderCredentials, UserStatus.ONLINE); + + await request + .post(api('users.setPreferences')) + .set(hiderCredentials) + .send({ data: { statusVisibilityDenied: [viewer.username] } }) + .expect(200); + + const members = [hider.username, viewer.username, bystander.username]; + + channel = (await createRoom({ type: 'c', name: `status-visibility-c-${Date.now()}`, members, credentials })).body.channel; + group = (await createRoom({ type: 'p', name: `status-visibility-p-${Date.now()}`, members, credentials })).body.group; + team = await createTeam(credentials, `status-visibility-t-${Date.now()}`, TeamType.PUBLIC, members); + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'c', roomId: channel._id }), deleteRoom({ type: 'p', roomId: group._id })]); + await deleteTeam(credentials, team.name); + await Promise.all([deleteUser(hider), deleteUser(viewer), deleteUser(bystander)]); + await updateEESetting('Accounts_StatusVisibility_Enabled', false); + }); + + describe('[/rooms.membersOrderedByRole]', () => { + it('should not return the hider when the viewer filters by online', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(viewerCredentials) + .query({ 'roomId': channel._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.not.include(hider.username); + expect(res.body.total).to.be.equal(res.body.members.length); + }); + }); + + it('should return the hider as online to a user they do not hide from', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(bystanderCredentials) + .query({ 'roomId': channel._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.ONLINE); + }); + }); + + it('should return the hider as offline when the viewer does not filter', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(viewerCredentials) + .query({ roomId: channel._id }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.include(hider.username); + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.OFFLINE); + }); + }); + + it('should return the hider when the viewer filters by offline', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(viewerCredentials) + .query({ 'roomId': channel._id, 'status[]': UserStatus.OFFLINE }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.include(hider.username); + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.OFFLINE); + }); + }); + }); + + describe('[/channels.members]', () => { + it('should not return the hider when the viewer filters by online', async () => { + await request + .get(api('channels.members')) + .set(viewerCredentials) + .query({ 'roomId': channel._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.not.include(hider.username); + expect(res.body.total).to.be.equal(res.body.members.length); + }); + }); + + it('should return the hider as online to a user they do not hide from', async () => { + await request + .get(api('channels.members')) + .set(bystanderCredentials) + .query({ 'roomId': channel._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.ONLINE); + }); + }); + }); + + describe('[/channels.online]', () => { + it('should not return the hider to the viewer', async () => { + await request + .get(api('channels.online')) + .set(viewerCredentials) + .query({ _id: channel._id }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.online)).to.not.include(hider.username); + }); + }); + + it('should return the hider to a user they do not hide from', async () => { + await request + .get(api('channels.online')) + .set(bystanderCredentials) + .query({ _id: channel._id }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.online)).to.include(hider.username); + }); + }); + }); + + describe('[/groups.members]', () => { + it('should not return the hider when the viewer filters by online', async () => { + await request + .get(api('groups.members')) + .set(viewerCredentials) + .query({ 'roomId': group._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.not.include(hider.username); + expect(res.body.total).to.be.equal(res.body.members.length); + }); + }); + + it('should return the hider as online to a user they do not hide from', async () => { + await request + .get(api('groups.members')) + .set(bystanderCredentials) + .query({ 'roomId': group._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.ONLINE); + }); + }); + }); + + describe('[/groups.online]', () => { + it('should not return the hider to the viewer', async () => { + await request + .get(api('groups.online')) + .set(viewerCredentials) + .query({ _id: group._id }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.online)).to.not.include(hider.username); + }); + }); + + it('should return the hider to a user they do not hide from', async () => { + await request + .get(api('groups.online')) + .set(bystanderCredentials) + .query({ _id: group._id }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.online)).to.include(hider.username); + }); + }); + }); + + describe('[/teams.members]', () => { + const teamUsernamesOf = (members: { user: IUser }[]) => members.map(({ user }) => user.username); + + it('should not return the hider when the viewer filters by online', async () => { + await request + .get(api('teams.members')) + .set(viewerCredentials) + .query({ 'teamId': team._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(teamUsernamesOf(res.body.members)).to.not.include(hider.username); + }); + }); + + it('should return the hider to a user they do not hide from', async () => { + await request + .get(api('teams.members')) + .set(bystanderCredentials) + .query({ 'teamId': team._id, 'status[]': UserStatus.ONLINE }) + .expect(200) + .expect((res) => { + expect(teamUsernamesOf(res.body.members)).to.include(hider.username); + }); + }); + + it('should not expose the block list of any member', async () => { + await request + .get(api('teams.members')) + .set(viewerCredentials) + .query({ teamId: team._id }) + .expect(200) + .expect((res) => { + res.body.members.forEach(({ user }: { user: IUser }) => { + expect(user.settings?.preferences ?? {}).to.not.have.property('statusVisibilityDenied'); + }); + }); + }); + }); + + describe('[/users.getStatus]', () => { + it('should report the hider as offline to the viewer', async () => { + await request + .get(api('users.getStatus')) + .set(viewerCredentials) + .query({ userId: hider._id }) + .expect(200) + .expect((res) => { + expect(res.body.status).to.be.equal(UserStatus.OFFLINE); + expect(res.body).to.not.have.property('statusSource'); + }); + }); + + it('should report the real status to a user they do not hide from', async () => { + await request + .get(api('users.getStatus')) + .set(bystanderCredentials) + .query({ userId: hider._id }) + .expect(200) + .expect((res) => { + expect(res.body.status).to.be.equal(UserStatus.ONLINE); + }); + }); + }); + + describe('[/users.getPresence]', () => { + it('should report the hider as offline to the viewer', async () => { + await request + .get(api('users.getPresence')) + .set(viewerCredentials) + .query({ userId: hider._id }) + .expect(200) + .expect((res) => { + expect(res.body.presence).to.be.equal(UserStatus.OFFLINE); + }); + }); + + it('should report the real presence to a user they do not hide from', async () => { + await request + .get(api('users.getPresence')) + .set(bystanderCredentials) + .query({ userId: hider._id }) + .expect(200) + .expect((res) => { + expect(res.body.presence).to.be.equal(UserStatus.ONLINE); + }); + }); + }); + + describe('[/users.autocomplete]', () => { + const selector = (conditions?: object) => JSON.stringify({ term: hider.username, exceptions: [], ...(conditions && { conditions }) }); + + it('should list the hider as offline to the viewer', async () => { + await request + .get(api('users.autocomplete')) + .set(viewerCredentials) + .query({ selector: selector() }) + .expect(200) + .expect((res) => { + expect(statusOf(res.body.items, hider.username)).to.be.equal(UserStatus.OFFLINE); + }); + }); + + it('should list the real status to a user they do not hide from', async () => { + await request + .get(api('users.autocomplete')) + .set(bystanderCredentials) + .query({ selector: selector() }) + .expect(200) + .expect((res) => { + expect(statusOf(res.body.items, hider.username)).to.be.equal(UserStatus.ONLINE); + }); + }); + + it('should not return the hider when the viewer filters by online through the selector conditions', async () => { + await request + .get(api('users.autocomplete')) + .set(viewerCredentials) + .query({ selector: selector({ status: UserStatus.ONLINE }) }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.items)).to.not.include(hider.username); + }); + }); + + it('should return the hider through the selector conditions to a user they do not hide from', async () => { + await request + .get(api('users.autocomplete')) + .set(bystanderCredentials) + .query({ selector: selector({ status: UserStatus.ONLINE }) }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.items)).to.include(hider.username); + }); + }); + }); + + describe('[/im.members]', () => { + let dm: IRoom; + + before(async () => { + dm = (await createRoom({ type: 'd', username: hider.username, credentials: viewerCredentials })).body.room; + }); + + it('should return the hider when the viewer filters by offline', async () => { + await request + .get(api('im.members')) + .set(viewerCredentials) + .query({ 'roomId': dm._id, 'status[]': UserStatus.OFFLINE }) + .expect(200) + .expect((res) => { + expect(usernamesOf(res.body.members)).to.include(hider.username); + expect(statusOf(res.body.members, hider.username)).to.be.equal(UserStatus.OFFLINE); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts new file mode 100644 index 0000000..a3a5724 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -0,0 +1,432 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IThreadMessage, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { adminUsername } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Subscriptions]', () => { + before((done) => getCredentials(done)); + + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('/subscriptions.get', async () => { + const res = await request.get(api('subscriptions.get')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update'); + expect(res.body).to.have.property('remove'); + }); + + it('/subscriptions.get?updatedSince', async () => { + const res = await request + .get(api('subscriptions.get')) + .set(credentials) + .query({ + updatedSince: new Date(), + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('update').that.have.lengthOf(0); + expect(res.body).to.have.property('remove').that.have.lengthOf(0); + }); + + describe('/subscriptions.getOne', () => { + it('should fail if no roomId provided', async () => { + const res = await request.get(api('subscriptions.getOne')).set(credentials).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', "must have required property 'roomId'"); + }); + + it('should fail if not logged in', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + + it('should return the subscription with success', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + }); + + it('should keep subscription as read after sending a message', async () => { + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body.subscription).to.have.property('alert', false); + }); + + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body.subscription).to.have.property('alert', false); + }); + }); + }); + + describe('[/subscriptions.read]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testDM: IRoom; + let user: TestUser; + + before(async () => { + user = await createUser(); + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}` })).body.channel; + testGroup = (await createRoom({ type: 'p', name: `group.test.${Date.now()}` })).body.group; + testDM = (await createRoom({ type: 'd', username: user.username })).body.room; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'd', roomId: testDM._id }), + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteUser(user), + ]), + ); + + it('should mark public channels as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testChannel._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should mark groups as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testGroup._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should mark DMs as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testDM._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should fail on two params with different ids', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testDM._id, + roomId: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + + it('should fail on mark inexistent public channel as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: 'foobar123-somechannel', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-subscription'); + }); + + it('should fail on mark inexistent group as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: 'foobar123-somegroup', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-subscription'); + }); + + it('should fail on mark inexistent DM as read', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: 'foobar123-somedm', + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-subscription'); + }); + + it('should fail on invalid params', async () => { + const res = await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: 12345, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-subscription'); + }); + + it('should fail on empty params', async () => { + const res = await request.post(api('subscriptions.read')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + + describe('should handle threads correctly', () => { + let threadId: IThreadMessage['_id']; + let user: TestUser; + let threadUserCredentials: Credentials; + + before(async () => { + user = await createUser({ username: 'testthread123', password: 'testthread123' }); + threadUserCredentials = await login('testthread123', 'testthread123'); + + const res = await request + .post(api('chat.sendMessage')) + .set(threadUserCredentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Starting a Thread', + }, + }); + + threadId = res.body.message._id; + }); + + after(async () => { + await deleteUser(user); + }); + + it('should mark threads as read', async () => { + await request + .post(api('chat.sendMessage')) + .set(threadUserCredentials) + .send({ + message: { + rid: testChannel._id, + msg: `@${adminUsername} making admin follow this thread`, + tmid: threadId, + }, + }); + await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testChannel._id, + readThreads: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.property('tunread'); + }); + }); + + it('should not mark threads as read', async () => { + await request + .post(api('chat.sendMessage')) + .set(threadUserCredentials) + .send({ + message: { + rid: testChannel._id, + msg: `@${adminUsername} making admin follow this thread`, + tmid: threadId, + }, + }); + await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testChannel._id, + readThreads: false, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.have.property('tunread'); + expect(res.body.subscription.tunread).to.be.an('array'); + expect(res.body.subscription.tunread).to.deep.equal([threadId]); + }); + }); + }); + }); + + describe('[/subscriptions.unread]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}` })).body.channel; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should fail when there are no messages on an channel', async () => { + const res = await request + .post(api('subscriptions.unread')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body).to.have.property('errorType', 'error-no-message-for-unread'); + }); + + it('sending message', async () => { + const res = await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: testChannel._id, + msg: 'Sample message', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message').and.to.be.an('object'); + }); + + it('should return success: true when make as unread successfully', async () => { + const res = await request + .post(api('subscriptions.unread')) + .set(credentials) + .send({ + roomId: testChannel._id, + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should fail on invalid params', async () => { + const res = await request + .post(api('subscriptions.unread')) + .set(credentials) + .send({ + roomId: 12345, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should fail on empty params', async () => { + const res = await request.post(api('subscriptions.unread')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/teams.ts b/apps/meteor/tests/end-to-end/api/teams.ts new file mode 100644 index 0000000..06f071c --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/teams.ts @@ -0,0 +1,3019 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRole, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import { TeamType } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +interface IUserInfo { + _id: string; + username?: string; + name?: string; + status?: string; + settings?: Record; +} + +interface ITeamMemberInfo { + user: IUserInfo; + roles?: IRole['_id'][] | null; + createdBy: Omit; + createdAt: Date; +} + +const addMembers = async (credentials: Record, teamName: string, members: IUser['_id'][]): Promise => { + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName, + members: members.map((userId) => ({ userId, roles: ['member'] })), + }); +}; + +describe('[Teams]', () => { + before((done) => getCredentials(done)); + + describe('/teams.create', () => { + const name = `test-team-create-${Date.now()}`; + const createdTeams: ITeam[] = []; + let testUser: TestUser; + + before(async () => { + testUser = await createUser(); + await updateSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+'); + }); + + after(async () => { + await Promise.all([ + ...createdTeams.map((team) => deleteTeam(credentials, team.name)), + deleteUser(testUser), + updateSetting('UTF8_Channel_Names_Validation', '[0-9a-zA-Z-_.]+'), + ]); + }); + + before(async () => { + return updatePermission('create-team', ['admin', 'user']); + }); + + after(async () => { + return updatePermission('create-team', ['admin', 'user']); + }); + + it('should create a public team', async () => { + const res = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + createdTeams.push(res.body.team); + }); + + it('should create a public team with a member', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `test-team-${Date.now()}`, + type: 0, + members: [testUser.username], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + createdTeams.push(res.body.team); + }) + .then((response) => { + const teamId = response.body.team._id; + return request + .get(api('teams.members')) + .set(credentials) + .query({ teamId }) + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + + // remove admin user from members because it's added automatically as owner + const members = (response.body.members as ITeamMemberInfo[]).filter(({ user }) => user.username !== adminUsername); + + const [member] = members; + expect(member.user.username).to.be.equal(testUser.username); + }); + }) + .then(() => done()) + .catch(done); + }); + + it('should create private team with a defined owner', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `test-team-private-owner-${Date.now()}`, + type: 1, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + createdTeams.push(res.body.team); + }) + .then((response) => { + const teamId = response.body.team._id; + return request + .get(api('teams.members')) + .set(credentials) + .query({ teamId }) + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + + const member = response.body.members[0]; + expect(member).to.have.property('user'); + expect(member).to.have.property('roles'); + expect(member).to.have.property('createdBy'); + expect(member).to.have.property('createdAt'); + expect(member.user).to.have.property('_id'); + expect(member.user).to.have.property('username'); + expect(member.user).to.have.property('name'); + expect(member.user).to.have.property('status'); + expect(member.createdBy).to.have.property('_id'); + expect(member.createdBy).to.have.property('username'); + expect(member.roles).to.have.length(1); + expect(member.roles[0]).to.be.equal('owner'); + }); + }) + .then(() => done()) + .catch(done); + }); + + it('should throw an error if the team already exists', async () => { + const res = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('team-name-already-exists'); + }); + + it('should not create a team with no associated room', async () => { + const teamName = 'invalid*team*name'; + await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.be.equal('error-team-creation'); + }); + + await request + .get(api('teams.info')) + .set(credentials) + .query({ + teamName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'Team not found'); + }); + + await request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'team-does-not-exist'); + }); + }); + it('should not allow creating a team when the user does NOT have the create-team permission', async () => { + await updatePermission('create-team', []); + await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: `test-team-${Date.now()}`, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('/teams.create - existing room ownership check', () => { + let roomOwner: TestUser; + let roomOwnerCredentials: Credentials; + let attacker: TestUser; + let attackerCredentials: Credentials; + let targetRoom: IRoom; + const teamName = `test-team-hijack-${Date.now()}`; + + before(async () => { + [roomOwner, attacker] = await Promise.all([createUser(), createUser()]); + [roomOwnerCredentials, attackerCredentials] = await Promise.all([ + login(roomOwner.username, password), + login(attacker.username, password), + ]); + targetRoom = (await createRoom({ type: 'c', name: `test-room-hijack-${Date.now()}`, credentials: roomOwnerCredentials })).body + .channel; + }); + + before(() => updatePermission('create-team', ['admin', 'user'])); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: targetRoom._id }), + deleteUser(roomOwner), + deleteUser(attacker), + updatePermission('create-team', ['admin', 'user']), + ]); + }); + + it('should not allow a user with no ownership/moderation of a room to hijack it into a new team by passing room.id', async () => { + await request + .post(api('teams.create')) + .set(attackerCredentials) + .send({ + name: teamName, + type: 0, + room: { id: targetRoom._id }, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request + .get(api('channels.info')) + .set(credentials) + .query({ roomId: targetRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.channel).to.not.have.property('teamId'); + expect(res.body.channel).to.not.have.property('teamMain'); + }); + }); + }); +}); + +describe('/teams.convertToChannel', () => { + let testTeam: ITeam; + let channelToEraseId: IRoom['_id']; + let channelToKeepId: IRoom['_id']; + const teamName = `test-team-convert-to-channel-${Date.now()}`; + const channelToEraseName = `${teamName}-channelToErase`; + const channelToKeepName = `${teamName}-channelToKeep`; + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 1, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + before('create channel (to erase after its team is converted to a channel)', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channelToEraseName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channelToEraseId = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channelToEraseName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }) + .then(() => done()); + }); + + before('add first channel to team', (done) => { + void request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channelToEraseId], + teamId: testTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', testTeam._id); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }) + .then(() => done()) + .catch(done); + }); + + before('create channel (to keep after its team is converted to a channel)', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channelToKeepName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channelToKeepId = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channelToKeepName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }) + .then(() => done()); + }); + + before('add second channel to team', (done) => { + void request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channelToKeepId], + teamId: testTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', testTeam._id); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }) + .then(() => done()); + }); + + after(() => + Promise.all([ + deleteTeam(credentials, teamName), + deleteRoom({ type: 'p', roomId: testTeam.roomId }), + deleteRoom({ type: 'c', roomId: channelToKeepId }), + ]), + ); + + it('should convert the team to a channel, delete the specified room and move the other back to the workspace', (done) => { + void request + .post(api('teams.convertToChannel')) + .set(credentials) + .send({ + teamName, + roomsToRemove: [channelToEraseId], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channelToEraseId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.include('[error-room-not-found]'); + }); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channelToKeepId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('channel'); + expect(response.body.channel).to.have.property('_id', channelToKeepId); + expect(response.body.channel).to.not.have.property('teamId'); + }); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: testTeam.roomId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('channel'); + expect(response.body.channel).to.have.property('_id', testTeam.roomId); + expect(response.body.channel).to.not.have.property('teamId'); + expect(response.body.channel).to.not.have.property('teamMain'); + }); + }) + .then(() => done()) + .catch(done); + }); +}); + +describe('/teams.addMembers', () => { + let testTeam: ITeam; + const teamName = `test-team-add-members-${Date.now()}`; + let testUser: TestUser; + let testUser2: TestUser; + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + after(() => deleteTeam(credentials, teamName)); + + beforeEach(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + }); + + afterEach(() => Promise.all([deleteUser(testUser), deleteUser(testUser2)])); + + it('should add members to a public team', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + { + userId: testUser2._id, + roles: ['member'], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then( + () => + void request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.length(3); + expect(response.body.members[1]).to.have.property('user'); + expect(response.body.members[1]).to.have.property('roles'); + expect(response.body.members[1]).to.have.property('createdBy'); + expect(response.body.members[1]).to.have.property('createdAt'); + + const members = (response.body.members as ITeamMemberInfo[]).map(({ user, roles }) => ({ + _id: user._id, + username: user.username, + name: user.name, + roles, + })); + + expect(members).to.deep.own.include({ + _id: testUser._id, + username: testUser.username, + name: testUser.name, + roles: ['member'], + }); + expect(members).to.deep.own.include({ + _id: testUser2._id, + username: testUser2.username, + name: testUser2.name, + roles: ['member'], + }); + }), + ) + .then(() => done()) + .catch(done); + }); + + it('should add members and assign roles to them properly', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['owner', 'leader'], + }, + { + userId: testUser2._id, + roles: ['moderator'], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testTeam.roomId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.length(3); + expect(response.body.members[1]).to.have.property('_id'); + expect(response.body.members[1]).to.have.property('roles'); + expect(response.body.members[1]).to.have.property('username'); + expect(response.body.members[1]).to.have.property('name'); + + const members = (response.body.members as IUser[]).map(({ _id, username, name, roles }) => ({ + _id, + username, + name, + roles, + })); + + expect(members).to.deep.own.include({ + _id: testUser._id, + username: testUser.username, + name: testUser.name, + roles: ['owner', 'leader'], + }); + + expect(members).to.deep.own.include({ + _id: testUser2._id, + username: testUser2.username, + name: testUser2.name, + roles: ['moderator'], + }); + }), + ) + .then(() => done()) + .catch(done); + }); +}); + +describe('/teams.members', () => { + let testTeam: ITeam; + const teamName = `test-team-members-${Date.now()}`; + let testUser: TestUser; + let testUser2: TestUser; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + before('Add members to team', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + { + userId: testUser2._id, + roles: ['member'], + }, + ], + }) + .end(done); + }); + + after(() => Promise.all([deleteUser(testUser), deleteUser(testUser2), deleteTeam(credentials, teamName)])); + + it('should list all the members from a public team', async () => { + const res = await request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 3); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('members'); + expect(res.body.members).to.have.length(3); + expect(res.body.members[0]).to.have.property('user'); + expect(res.body.members[0]).to.have.property('roles'); + expect(res.body.members[0]).to.have.property('createdBy'); + expect(res.body.members[0]).to.have.property('createdAt'); + expect(res.body.members[0].user).to.have.property('_id'); + expect(res.body.members[0].user).to.have.property('username'); + expect(res.body.members[0].user).to.have.property('name'); + expect(res.body.members[0].user).to.have.property('status'); + expect(res.body.members[0].createdBy).to.have.property('_id'); + expect(res.body.members[0].createdBy).to.have.property('username'); + }); +}); + +describe('/teams.list', () => { + const teamName = `test-team-list-${Date.now()}`; + + let testUser1: TestUser; + let testUser1Credentials: Credentials; + let testTeamAdmin: TestUser; + let testTeam1: IRoom; + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end(done); + }); + + before('Create test users', async () => { + testUser1 = await createUser(); + }); + + before('login test users', async () => { + testUser1Credentials = await login(testUser1.username, password); + }); + + before('Create test team', async () => { + await request.post(api('teams.create')).set(credentials).send({ + name: teamName, + type: 0, + }); + + const team1Name = `test-team-1-${Date.now()}`; + const teamAdminName = `test-team-admin-${Date.now()}`; + + testTeam1 = ( + await request.post(api('teams.create')).set(testUser1Credentials).send({ + name: team1Name, + type: 0, + }) + ).body.team; + testTeamAdmin = ( + await request.post(api('teams.create')).set(credentials).send({ + name: teamAdminName, + type: 0, + }) + ).body.team; + }); + + after(() => + Promise.all([ + deleteTeam(credentials, teamName), + deleteTeam(testUser1Credentials, testTeam1.name!), + deleteTeam(credentials, testTeamAdmin.name!), + ]), + ); + + after('delete test users', () => deleteUser(testUser1)); + + it('should list all teams', async () => { + const res = await request.get(api('teams.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('teams'); + expect(res.body.teams.length).to.be.gte(1); + expect(res.body.teams[0]).to.have.property('_id'); + expect(res.body.teams[0]).to.have.property('_updatedAt'); + expect(res.body.teams[0]).to.have.property('name'); + expect(res.body.teams[0]).to.have.property('type'); + expect(res.body.teams[0]).to.have.property('roomId'); + expect(res.body.teams[0]).to.have.property('createdBy'); + expect(res.body.teams[0].createdBy).to.have.property('_id'); + expect(res.body.teams[0].createdBy).to.have.property('username'); + expect(res.body.teams[0]).to.have.property('createdAt'); + expect(res.body.teams[0]).to.have.property('rooms'); + expect(res.body.teams[0]).to.have.property('numberOfUsers'); + }); + + it("should prevent users from accessing unrelated teams via 'query' parameter", () => { + return request + .get(api('teams.list')) + .set(testUser1Credentials) + .query({ + query: JSON.stringify({ _id: { $regex: '.*' } }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body.teams.length).to.be.gte(1); + expect(res.body.teams) + .to.be.an('array') + .and.to.satisfy( + (teams: ITeam[]) => teams.every((team) => team.createdBy._id === testUser1._id), + `Expected only user's own teams to be returned, but found unowned teams.\n${JSON.stringify( + res.body.teams.filter((team: ITeam) => team.createdBy._id !== testUser1._id), + null, + 2, + )}`, + ); + }); + }); + + it("should prevent admins from accessing unrelated teams via 'query' parameter", () => { + return request + .get(api('teams.list')) + .set(credentials) + .query({ + query: JSON.stringify({ _id: { $regex: '.*' } }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body.teams.length).to.be.gte(1); + expect(res.body.teams) + .to.be.an('array') + .and.to.satisfy( + (teams: ITeam[]) => teams.every((team) => team.createdBy._id === credentials['X-User-Id']), + `Expected only admin's own teams to be returned, but found unowned teams.\n${JSON.stringify( + res.body.teams.filter((team: ITeam) => team.createdBy._id !== credentials['X-User-Id']), + null, + 2, + )}`, + ); + }); + }); +}); + +describe('/teams.listAll', () => { + let teamName: string; + before(async () => { + await updatePermission('view-all-teams', ['admin']); + teamName = `test-team-${Date.now()}`; + await request.post(api('teams.create')).set(credentials).send({ + name: teamName, + type: 0, + }); + }); + + after(() => Promise.all([deleteTeam(credentials, teamName), updatePermission('view-all-teams', ['admin'])])); + + it('should list all teams', async () => { + await request + .get(api('teams.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('teams'); + expect(res.body.teams).to.be.an('array').that.is.not.empty; + expect(res.body.teams[0]).to.have.property('_id'); + expect(res.body.teams[0]).to.have.property('_updatedAt'); + expect(res.body.teams[0]).to.have.property('name'); + expect(res.body.teams[0]).to.have.property('type'); + expect(res.body.teams[0]).to.have.property('roomId'); + expect(res.body.teams[0]).to.have.property('createdBy'); + expect(res.body.teams[0].createdBy).to.have.property('_id'); + expect(res.body.teams[0].createdBy).to.have.property('username'); + expect(res.body.teams[0]).to.have.property('createdAt'); + expect(res.body.teams[0]).to.have.property('rooms'); + expect(res.body.teams[0]).to.have.property('numberOfUsers'); + }); + }); + + it('should return an error when the user does NOT have the view-all-teams permission', async () => { + await updatePermission('view-all-teams', []); + await request + .get(api('teams.listAll')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); +}); + +describe('/teams.updateMember', () => { + let testTeam: ITeam; + const teamName = `test-team-update-member-${Date.now()}`; + let testUser: TestUser; + let testUser2: TestUser; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + before('Add members to team', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + { + userId: testUser2._id, + roles: ['member'], + }, + ], + }) + .end(done); + }); + + after(() => Promise.all([deleteUser(testUser), deleteUser(testUser2), deleteTeam(credentials, teamName)])); + + it("should update member's data in a public team", (done) => { + void request + .post(api('teams.updateMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + member: { + userId: testUser._id, + roles: ['member', 'owner'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then( + () => + void request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.length(3); + + const members = (response.body.members as ITeamMemberInfo[]).map(({ user, roles }) => ({ + _id: user._id, + username: user.username, + name: user.name, + roles, + })); + + expect(members).to.deep.own.include({ + _id: testUser._id, + username: testUser.username, + name: testUser.name, + roles: ['member', 'owner'], + }); + }), + ) + .then(() => done()) + .catch(done); + }); +}); + +describe('/teams.removeMember', () => { + let testTeam: ITeam; + const teamName = `test-team-remove-member-${Date.now()}`; + let testUser: TestUser; + let testUser2: TestUser; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + after(() => Promise.all([deleteUser(testUser), deleteUser(testUser2), deleteTeam(credentials, teamName)])); + + it('should not be able to remove the last owner', (done) => { + void request + .post(api('teams.removeMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + userId: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('last-owner-can-not-be-removed'); + }) + .then(() => done()) + .catch(done); + }); + + it('should not be able to remove if rooms is empty', (done) => { + void request + .post(api('teams.removeMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + userId: credentials['X-User-Id'], + rooms: [], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }) + .then(() => done()) + .catch(done); + }); + + it('should remove one member from a public team', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + { + userId: testUser2._id, + roles: ['member', 'owner'], + }, + ], + }) + .then(() => + request + .post(api('teams.removeMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + userId: testUser2._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request + .get(api('teams.members')) + .set(credentials) + .query({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.length(2); + }), + ) + .then(() => done()), + ) + .catch(done); + }); +}); + +describe('/teams.leave', () => { + let testTeam: ITeam; + const teamName = `test-team-leave-${Date.now()}`; + let testUser: TestUser; + let testUser2: TestUser; + + before(async () => { + testUser = await createUser(); + testUser2 = await createUser(); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + after(() => Promise.all([deleteUser(testUser), deleteUser(testUser2), deleteTeam(credentials, teamName)])); + + it('should not be able to remove the last owner', (done) => { + request + .post(api('teams.leave')) + .set(credentials) + .send({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('last-owner-can-not-be-removed'); + }) + .then(() => done()) + .catch(done); + }); + + it('should remove the calling user from the team', (done) => { + request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam.name, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + { + userId: testUser2._id, + roles: ['member', 'owner'], + }, + ], + }) + .then(() => + request + .post(api('teams.leave')) + .set(credentials) + .send({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => done()), + ) + .catch(done); + }); + + it('should not be able to leave if rooms is empty', (done) => { + request + .post(api('teams.leave')) + .set(credentials) + .send({ + teamName: testTeam.name, + userId: credentials['X-User-Id'], + rooms: [], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }) + .then(() => done()) + .catch(done); + }); +}); + +describe('/teams.info', () => { + const teamName = `test-team-info-${Date.now()}`; + let testTeam: ITeam; + let testTeam2: ITeam; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + testTeam = await createTeam(credentials, teamName, TeamType.PUBLIC); + testTeam2 = await createTeam(credentials, `${teamName}-2`, TeamType.PRIVATE); + }); + + after(() => Promise.all([deleteTeam(credentials, testTeam.name), deleteTeam(credentials, testTeam2.name), deleteUser(testUser)])); + + it('should successfully get a team info by name', (done) => { + request + .get(api('teams.info')) + .set(credentials) + .query({ + teamName: testTeam.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('teamInfo'); + expect(response.body.teamInfo).to.have.property('_id', testTeam._id); + expect(response.body.teamInfo).to.have.property('name', testTeam.name); + }) + .then(() => done()) + .catch(done); + }); + it('should successfully get a team info by id', (done) => { + request + .get(api('teams.info')) + .set(credentials) + .query({ + teamId: testTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('teamInfo'); + expect(response.body.teamInfo).to.have.property('_id', testTeam._id); + expect(response.body.teamInfo).to.have.property('name', testTeam.name); + }) + .then(() => done()) + .catch(done); + }); + it('should fail if a team is not found', (done) => { + request + .get(api('teams.info')) + .set(credentials) + .query({ + teamName: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'Team not found'); + }) + .then(() => done()) + .catch(done); + }); + it('should fail if a user doesnt belong to a team', (done) => { + request + .get(api('teams.info')) + .set(testUserCredentials) + .query({ + teamName: testTeam2.name, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error', 'unauthorized'); + }) + .then(() => done()) + .catch(done); + }); +}); + +describe('/teams.delete', () => { + describe('deleting an empty team', () => { + let roomId: IRoom['_id']; + const tempTeamName = `temporaryTeam-${Date.now()}`; + + before('create team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: tempTeamName, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((resp) => { + expect(resp.body).to.have.property('success', true); + expect(resp.body).to.have.property('team'); + expect(resp.body.team).to.have.property('name', tempTeamName); + expect(resp.body.team).to.have.property('_id'); + expect(resp.body.team).to.have.property('roomId'); + + roomId = resp.body.team.roomId; + }) + .then(() => done()); + }); + + after(() => deleteTeam(credentials, tempTeamName)); + + it('should delete the team and the main room', (done) => { + request + .post(api('teams.delete')) + .set(credentials) + .send({ + teamName: tempTeamName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => { + void request + .get(api('teams.info')) + .set(credentials) + .query({ + teamName: tempTeamName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.be.equal('Team not found'); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.include('[error-room-not-found]'); + }) + .then(() => done()); + }); + }) + .catch(done); + }); + }); + + describe('delete team with two rooms', () => { + const tempTeamName = `temporaryTeam-${Date.now()}`; + const channel1Name = `${tempTeamName}-channel1`; + const channel2Name = `${tempTeamName}-channel2`; + let teamId: ITeam['_id']; + let channel1Id: IRoom['_id']; + let channel2Id: IRoom['_id']; + + before('create team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: tempTeamName, + type: 0, + }) + .then((response) => { + teamId = response.body.team._id; + }) + .then(() => done()); + }); + + before('create channel 1', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channel1Name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channel1Id = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channel1Name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }) + .then(() => done()); + }); + + before('add channel 1 to team', (done) => { + request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channel1Id], + teamId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', teamId); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }) + .then(() => done()) + .catch(done); + }); + + before('create channel 2', (done) => { + void request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channel2Name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channel2Id = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channel2Name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }) + .then(() => done()); + }); + + before('add channel 2 to team', (done) => { + void request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channel2Id], + teamId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', teamId); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }) + .then(() => done()); + }); + + after(() => deleteRoom({ type: 'c', roomId: channel1Id })); + + it('should delete the specified room and move the other back to the workspace', (done) => { + request + .post(api('teams.delete')) + .set(credentials) + .send({ + teamName: tempTeamName, + roomsToRemove: [channel2Id], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channel2Id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.include('[error-room-not-found]'); + }) + .then(() => { + void request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channel1Id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('channel'); + expect(response.body.channel).to.have.property('_id', channel1Id); + expect(response.body.channel).to.not.have.property('teamId'); + }) + .then(() => done()); + }); + }) + .catch(done); + }); + }); + + describe("delete team when team's main room id is provided in roomsToRemove", () => { + const tempTeamName = `temporaryTeam-${Random.id()}`; + const channel1Name = `${tempTeamName}-channel1`; + const channel2Name = `${tempTeamName}-channel2`; + let teamId: ITeam['_id']; + let channel1Id: IRoom['_id']; + let channel2Id: IRoom['_id']; + let teamMainRoomId: IRoom['_id']; + + before('create team', async () => { + await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: tempTeamName, + type: 0, + }) + .then((response) => { + teamId = response.body.team._id; + teamMainRoomId = response.body.team.roomId; + }); + }); + + before('create channel 1', async () => { + await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channel1Name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channel1Id = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channel1Name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }); + }); + + before('add channel 1 to team', async () => { + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channel1Id], + teamId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', teamId); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }); + }); + + before('create channel 2', async () => { + await request + .post(api('channels.create')) + .set(credentials) + .send({ + name: channel2Name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + channel2Id = res.body.channel._id; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', channel2Name); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + }); + }); + + before('add channel 2 to team', async () => { + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [channel2Id], + teamId, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', teamId); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }); + }); + + after(() => deleteRoom({ type: 'c', roomId: channel1Id })); + + it('should delete the specified room and move the other back to the workspace', async () => { + await request + .post(api('teams.delete')) + .set(credentials) + .send({ + teamName: tempTeamName, + roomsToRemove: [channel2Id, teamMainRoomId], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: teamMainRoomId, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.include('[error-room-not-found]'); + }); + await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channel2Id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((response) => { + expect(response.body).to.have.property('success', false); + expect(response.body).to.have.property('error'); + expect(response.body.error).to.include('[error-room-not-found]'); + }); + + await request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId: channel1Id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('channel'); + expect(response.body.channel).to.have.property('_id', channel1Id); + expect(response.body.channel).to.not.have.property('teamId'); + }); + }); + }); +}); + +describe('/teams.addRooms', () => { + let privateRoom: IRoom & { t: 'p' }; + let privateRoom2: IRoom & { t: 'p' }; + let privateRoom3: IRoom & { t: 'p' }; + let publicRoom: IRoom & { t: 'c' }; + let publicRoom2: IRoom & { t: 'c' }; + let publicTeam: ITeam; + let privateTeam: ITeam; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + privateRoom = (await createRoom({ type: 'p', name: `community-channel-private-1-${Date.now()}` })).body.group; + privateRoom2 = (await createRoom({ type: 'p', name: `community-channel-private-2-${Date.now()}` })).body.group; + privateRoom3 = (await createRoom({ type: 'p', name: `community-channel-private-3-${Date.now()}` })).body.group; + publicRoom = (await createRoom({ type: 'c', name: `community-channel-public-1-${Date.now()}` })).body.channel; + publicRoom2 = (await createRoom({ type: 'c', name: `community-channel-public-2-${Date.now()}` })).body.channel; + publicTeam = await createTeam(credentials, `team-name-c-${Date.now()}`, TeamType.PUBLIC); + privateTeam = await createTeam(credentials, `team-name-p-${Date.now()}`, TeamType.PRIVATE); + }); + + after(async () => { + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + await Promise.all([ + updatePermission('move-room-to-team', ['admin', 'owner', 'moderator']), + ...[privateRoom, privateRoom2, privateRoom3, publicRoom, publicRoom2].map((room) => deleteRoom({ type: room.t, roomId: room._id })), + deleteUser(testUser), + ]); + }); + + it('should throw an error if no permission', async () => { + await updatePermission('move-room-to-team', []); + + const res = await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom._id], + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-no-permission-team-channel'); + }); + + it('should add public and private rooms to team', async () => { + await updatePermission('move-room-to-team', ['admin']); + + const res = await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom._id, privateRoom._id], + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.have.length(2); + expect(res.body.rooms[0]).to.have.property('_id'); + expect(res.body.rooms[0]).to.have.property('teamId', publicTeam._id); + expect(res.body.rooms[1]).to.have.property('_id'); + expect(res.body.rooms[1]).to.have.property('teamId', publicTeam._id); + + const rids = (res.body.rooms as IRoom[]).map(({ _id }) => _id); + + expect(rids).to.include(publicRoom._id); + expect(rids).to.include(privateRoom._id); + }); + + it('should add public room to private team', async () => { + await updatePermission('move-room-to-team', ['admin']); + + const res = await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom2._id], + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', privateTeam._id); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }); + + it('should add private room to team', async () => { + await updatePermission('move-room-to-team', ['admin']); + + const res = await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [privateRoom2._id], + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms[0]).to.have.property('teamId', privateTeam._id); + expect(res.body.rooms[0]).to.not.have.property('teamDefault'); + }); + + it('should fail if the user cannot access the channel', (done) => { + void updatePermission('move-room-to-team', ['admin', 'user']) + .then(() => { + void request + .post(api('teams.addRooms')) + .set(testUserCredentials) + .send({ + rooms: [privateRoom3._id], + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('invalid-room'); + }) + .end(done); + }) + .catch(done); + }); + + it('should fail if the user is not the owner of the channel', (done) => { + void request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: privateRoom3._id, users: [testUser.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => { + void request + .post(api('teams.addRooms')) + .set(testUserCredentials) + .send({ + rooms: [privateRoom3._id], + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-no-owner-channel'); + }) + .end(done); + }) + .catch(done); + }); +}); + +describe('/teams.listRooms', () => { + let testUser: TestUser; + let testUserCredentials: Credentials; + let privateTeam: ITeam; + let publicTeam: ITeam; + let privateRoom: IRoom; + let publicRoom: IRoom; + let publicRoom2: IRoom; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + privateTeam = await createTeam(credentials, `teamName-private-${Date.now()}`, TeamType.PRIVATE); + publicTeam = await createTeam(testUserCredentials, `teamName-public-${Date.now()}`, TeamType.PUBLIC); + + privateRoom = (await createRoom({ type: 'p', name: `test-p-${Date.now()}` })).body.group; + publicRoom = (await createRoom({ type: 'c', name: `test-c-${Date.now()}` })).body.channel; + publicRoom2 = (await createRoom({ type: 'c', name: `test-c2-${Date.now()}` })).body.channel; + + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [privateRoom._id], + teamId: publicTeam._id, + }); + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom._id], + teamId: publicTeam._id, + }); + + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom2._id], + teamId: privateTeam._id, + }); + }); + + after(() => + Promise.all([ + updatePermission('view-all-teams', ['admin']), + updatePermission('view-all-team-channels', ['admin', 'owner']), + deleteUser(testUser), + deleteTeam(credentials, privateTeam.name), + deleteTeam(credentials, publicTeam.name), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + deleteRoom({ type: 'c', roomId: publicRoom2._id }), + ]), + ); + + it('should throw an error if team is private and no permission', async () => { + await updatePermission('view-all-teams', []); + + const res = await request + .get(api('teams.listRooms')) + .set(testUserCredentials) + .query({ + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('user-not-on-private-team'); + }); + + it('should return only public rooms for public team', async () => { + await updatePermission('view-all-team-channels', []); + + const res = await request + .get(api('teams.listRooms')) + .set(testUserCredentials) + .query({ + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.be.an('array'); + // main room should not be returned here + expect(res.body.rooms.length).to.equal(1); + }); + + it('should return all rooms for public team', async () => { + await updatePermission('view-all-team-channels', ['user']); + + const res = await request + .get(api('teams.listRooms')) + .set(testUserCredentials) + .query({ + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.be.an('array'); + expect(res.body.rooms.length).to.equal(2); + }); + it('should return all rooms for public team even requested with count and offset params', async () => { + await updatePermission('view-all-team-channels', ['user']); + + const res = await request + .get(api('teams.listRooms')) + .set(testUserCredentials) + .query({ + teamId: publicTeam._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.be.an('array'); + expect(res.body.rooms.length).to.equal(2); + }); + + it('should return public rooms for private team', async () => { + await updatePermission('view-all-team-channels', []); + + await updatePermission('view-all-teams', ['admin']); + + const res = await request + .get(api('teams.listRooms')) + .set(credentials) + .query({ + teamId: privateTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.be.an('array'); + expect(res.body.rooms.length).to.equal(1); + }); + it('should return public rooms for private team even requested with count and offset params', async () => { + await updatePermission('view-all-team-channels', []); + + await updatePermission('view-all-teams', ['admin']); + + const res = await request + .get(api('teams.listRooms')) + .set(credentials) + .query({ + teamId: privateTeam._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms'); + expect(res.body.rooms).to.be.an('array'); + expect(res.body.rooms.length).to.equal(1); + }); + + describe('[teams.listChildren]', () => { + const teamName = `team-${Date.now()}`; + let testTeam: ITeam; + let testPrivateTeam: ITeam; + let testUser: IUser; + let testUserCredentials: Credentials; + + let privateRoom: IRoom; + let privateRoom2: IRoom; + let publicRoom: IRoom; + let publicRoom2: IRoom; + + let discussionOnPrivateRoom: IRoom; + let discussionOnPublicRoom: IRoom; + let discussionOnMainRoom: IRoom; + + before('Create test team', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + const members = testUser.username ? [testUser.username] : []; + testTeam = await createTeam(credentials, teamName, 0, members); + testPrivateTeam = await createTeam(testUserCredentials, `${teamName}private`, 1, []); + }); + + before('make user owner', async () => { + await request + .post(api('teams.updateMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + member: { + userId: testUser._id, + roles: ['member', 'owner'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + before('create rooms', async () => { + privateRoom = (await createRoom({ type: 'p', name: `test-p-${Date.now()}` })).body.group; + privateRoom2 = (await createRoom({ type: 'p', name: `test-p2-${Date.now()}`, credentials: testUserCredentials })).body.group; + publicRoom = (await createRoom({ type: 'c', name: `test-c-${Date.now()}` })).body.channel; + publicRoom2 = (await createRoom({ type: 'c', name: `test-c2-${Date.now()}` })).body.channel; + + await Promise.all([ + request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [privateRoom._id, publicRoom._id, publicRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + request + .post(api('teams.addRooms')) + .set(testUserCredentials) + .send({ + rooms: [privateRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + ]); + }); + + before('Create discussions', async () => { + discussionOnPrivateRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: privateRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnPublicRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: publicRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnMainRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testTeam.roomId, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteRoom({ type: 'p', roomId: privateRoom2._id }), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + deleteRoom({ type: 'c', roomId: publicRoom2._id }), + deleteRoom({ type: 'p', roomId: discussionOnPrivateRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnPublicRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnMainRoom._id }), + deleteTeam(credentials, teamName), + deleteTeam(credentials, testPrivateTeam.name), + deleteUser({ _id: testUser._id }), + ]); + }); + + it('should fail if user is not logged in', async () => { + await request.get(api('teams.listChildren')).expect(401); + }); + + it('should fail if teamId is not passed as queryparam', async () => { + await request.get(api('teams.listChildren')).set(credentials).expect(400); + }); + + it('should fail if teamId is not valid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: 'invalid' }).expect(404); + }); + + it('should fail if teamId is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: '' }).expect(404); + }); + + it('should fail if both properties are passed', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamId: testTeam._id, teamName: testTeam.name }).expect(400); + }); + + it('should fail if teamName is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: '' }).expect(404); + }); + + it('should fail if teamName is invalid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: 'invalid' }).expect(404); + }); + + it('should fail if roomId is empty', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ roomId: '' }).expect(404); + }); + + it('should fail if roomId is invalid', async () => { + await request.get(api('teams.listChildren')).set(credentials).query({ teamName: 'invalid' }).expect(404); + }); + + it('should return a list of valid rooms for user', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testTeam._id }).set(credentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.an('object'); + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.undefined; + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a valid list of rooms for non admin member too', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamName: testTeam.name }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.undefined; + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.an('object'); + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a valid list of rooms for non admin member too when filtering by teams main room id', async () => { + const res = await request.get(api('teams.listChildren')).query({ roomId: testTeam.roomId }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.undefined; + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.an('object'); + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a list of rooms filtered by name using the filter parameter', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, filter: 'test-p' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data[0]._id).to.be.equal(privateRoom._id); + expect(res.body.data.find((room: IRoom) => room._id === privateRoom2._id)).to.be.undefined; + }); + + it('should paginate results', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, offset: 1, count: 2 }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(2); + }); + + it('should return only items of type channel', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, type: 'channels' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(4); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(4); + expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.false; + }); + + it('should return only items of type discussion', async () => { + const res = await request + .get(api('teams.listChildren')) + .query({ teamId: testTeam._id, type: 'discussions' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data.every((room: IRoom) => !!room.prid)).to.be.true; + }); + + it('should return both when type is not passed', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testTeam._id }).set(credentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.true; + expect(res.body.data.some((room: IRoom) => !room.prid)).to.be.true; + }); + + it('should fail if type is other than channel or discussion', async () => { + await request.get(api('teams.listChildren')).query({ teamId: testTeam._id, type: 'other' }).set(credentials).expect(400); + }); + + it('should properly list children of a private team', async () => { + const res = await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(testUserCredentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(1); + }); + + it('should throw an error when a non member user tries to fetch info for team', async () => { + await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(credentials).expect(400); + }); + }); +}); + +describe('/teams.updateRoom', () => { + let publicRoom: IRoom; + let publicTeam: ITeam; + const name = `teamName-update-room-${Date.now()}`; + + before(async () => { + publicRoom = (await createRoom({ type: 'c', name: `public-update-room-${Date.now()}` })).body.channel; + publicTeam = await createTeam(credentials, name, TeamType.PUBLIC); + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom._id], + teamId: publicTeam._id, + }); + }); + + after(async () => { + await deleteTeam(credentials, name); + await Promise.all([ + updatePermission('edit-team-channel', ['admin', 'owner', 'moderator']), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + ]); + }); + + it('should throw an error if no permission', async () => { + await updatePermission('edit-team-channel', []); + + const res = await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: publicRoom._id, + isDefault: true, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('unauthorized'); + }); + + it('should set room to team default', async () => { + await updatePermission('edit-team-channel', ['admin']); + + const res = await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: publicRoom._id, + isDefault: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room'); + expect(res.body.room).to.have.property('teamId', publicTeam._id); + expect(res.body.room).to.have.property('teamDefault', true); + }); + + describe('team auto-join', () => { + let testTeam: ITeam; + let createdRoom: IRoom; + let testUser1: TestUser; + let testUser2: TestUser; + let additionalRoom: IRoom; + let memberTestUser1: TestUser; + let memberTestUser1Credentials: Credentials; + let testUser1Credentials: Credentials; + let testUser2Credentials: Credentials; + + before(async () => { + [testUser1, testUser2, memberTestUser1] = await Promise.all([createUser(), createUser(), createUser()]); + + [memberTestUser1Credentials, testUser1Credentials, testUser2Credentials] = await Promise.all([ + login(memberTestUser1.username, password), + login(testUser1.username, password), + login(testUser2.username, password), + ]); + }); + + beforeEach(async () => { + const timestamp = Date.now(); + testTeam = await createTeam(credentials, `test-team-name${timestamp}`, 0); + createdRoom = (await createRoom({ name: `test-room-name${timestamp}`, type: 'c' })).body.channel; + additionalRoom = (await createRoom({ name: `additional-room-name${timestamp}`, type: 'c' })).body.channel; + + await request + .post(api('teams.addRooms')) + .set(credentials) + .expect(200) + .send({ + rooms: [createdRoom._id, additionalRoom._id], + teamName: testTeam.name, + }); + }); + + afterEach(() => + Promise.all([ + deleteTeam(credentials, testTeam.name), + deleteRoom({ roomId: createdRoom._id, type: 'c' }), + deleteRoom({ roomId: additionalRoom._id, type: 'c' }), + ]), + ); + + after(() => + Promise.all([updateSetting('API_User_Limit', 500), deleteUser(testUser1), deleteUser(testUser2), deleteUser(memberTestUser1)]), + ); + + it('should add members when the members count is less than or equal to the API_User_Limit setting', async () => { + await updateSetting('API_User_Limit', 2); + + await addMembers(credentials, testTeam.name, [testUser1._id, testUser2._id]); + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room.usersCount').and.to.be.equal(3); + }); + }); + + it('should not add all members when we update a team channel to be auto-join and the members count is greater than the API_User_Limit setting', async () => { + await updateSetting('API_User_Limit', 1); + + await addMembers(credentials, testTeam.name, [testUser1._id, testUser2._id]); + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room.usersCount').and.to.be.equal(2); + }); + }); + + it('should auto-join users to auto-join channels when added to team', async () => { + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200); + + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamId: testTeam._id, + members: [{ userId: memberTestUser1._id, roles: ['member'] }], + }) + .expect(200); + + const subscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(memberTestUser1Credentials) + .query({ roomId: createdRoom._id }) + .expect(200); + + expect(subscriptionResponse.body).to.have.property('success', true); + expect(subscriptionResponse.body).to.have.property('subscription').that.is.an('object'); + expect(subscriptionResponse.body.subscription).to.have.property('u').that.is.an('object'); + expect(subscriptionResponse.body.subscription.u).to.have.property('_id', memberTestUser1._id); + }); + + it('should auto-join users to multiple auto-join channels when added to team', async () => { + await Promise.all([ + request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').that.is.an('object'); + expect(res.body.room).to.have.property('teamDefault', true); + }), + request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: additionalRoom._id, + isDefault: true, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').that.is.an('object'); + expect(res.body.room).to.have.property('teamDefault', true); + }), + ]); + + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamId: testTeam._id, + members: [{ userId: memberTestUser1._id, roles: ['member'] }], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('teams.members')) + .set(credentials) + .query({ teamId: testTeam._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').that.is.an('array'); + const userMember = res.body.members.find((member: any) => member.user._id === memberTestUser1._id); + expect(userMember).to.exist; + expect(userMember).to.have.property('roles').that.includes('member'); + }); + + const [subscription1Response, subscription2Response] = await Promise.all([ + request.get(api('subscriptions.getOne')).set(memberTestUser1Credentials).query({ roomId: createdRoom._id }).expect(200), + request.get(api('subscriptions.getOne')).set(memberTestUser1Credentials).query({ roomId: additionalRoom._id }).expect(200), + ]); + + expect(subscription1Response.body).to.have.property('success', true); + expect(subscription1Response.body).to.have.property('subscription').that.is.an('object'); + expect(subscription1Response.body.subscription.u).to.have.property('_id', memberTestUser1._id); + + expect(subscription2Response.body).to.have.property('success', true); + expect(subscription2Response.body).to.have.property('subscription').that.is.an('object'); + expect(subscription2Response.body.subscription.u).to.have.property('_id', memberTestUser1._id); + }); + + it('should not auto-join users to non-default team channels when added to team', async () => { + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200); + + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamId: testTeam._id, + members: [{ userId: memberTestUser1._id, roles: ['member'] }], + }) + .expect(200); + + const createdRoomResponse = await request.get(api('channels.info')).set(credentials).query({ roomId: createdRoom._id }).expect(200); + + expect(createdRoomResponse.body).to.have.property('success', true); + expect(createdRoomResponse.body.channel).to.have.property('usersCount').that.is.at.least(2); + + const additionalRoomResponse = await request + .get(api('channels.info')) + .set(credentials) + .query({ roomId: additionalRoom._id }) + .expect(200); + + expect(additionalRoomResponse.body).to.have.property('success', true); + expect(additionalRoomResponse.body.channel).to.have.property('usersCount', 1); // Only admin, user not auto-joined + }); + + it('should add multiple users to auto-join channels when added to team', async () => { + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200); + + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamId: testTeam._id, + members: [ + { userId: testUser1._id, roles: ['member'] }, + { userId: testUser2._id, roles: ['member'] }, + ], + }) + .expect(200); + + const [testUser1SubscriptionResponse, testUser2SubscriptionResponse] = await Promise.all([ + request.get(api('subscriptions.getOne')).set(testUser1Credentials).query({ roomId: createdRoom._id }).expect(200), + request.get(api('subscriptions.getOne')).set(testUser2Credentials).query({ roomId: createdRoom._id }).expect(200), + ]); + + expect(testUser1SubscriptionResponse.body).to.have.property('success', true); + expect(testUser1SubscriptionResponse.body).to.have.property('subscription').that.is.an('object'); + expect(testUser1SubscriptionResponse.body.subscription.u).to.have.property('_id', testUser1._id); + + expect(testUser2SubscriptionResponse.body).to.have.property('success', true); + expect(testUser2SubscriptionResponse.body).to.have.property('subscription').that.is.an('object'); + expect(testUser2SubscriptionResponse.body.subscription.u).to.have.property('_id', testUser2._id); + + const roomInfoResponse = await request.get(api('channels.info')).set(credentials).query({ roomId: createdRoom._id }).expect(200); + + expect(roomInfoResponse.body).to.have.property('success', true); + expect(roomInfoResponse.body).to.have.property('channel').that.is.an('object'); + expect(roomInfoResponse.body.channel).to.have.property('usersCount').that.is.at.least(3); // admin + testUser1 + testUser2 + }); + + it('should not remove existing members when disabling auto-join on a channel', async () => { + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: true, + }) + .expect(200); + + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamId: testTeam._id, + members: [{ userId: memberTestUser1._id, roles: ['member'] }], + }) + .expect(200); + + const initialSubscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(memberTestUser1Credentials) + .query({ roomId: createdRoom._id }) + .expect(200); + + expect(initialSubscriptionResponse.body).to.have.property('success', true); + expect(initialSubscriptionResponse.body).to.have.property('subscription').that.is.an('object'); + + await request + .post(api('teams.updateRoom')) + .set(credentials) + .send({ + roomId: createdRoom._id, + isDefault: false, + }) + .expect(200); + + // Verify user is still subscribed to the channel after disabling auto-join + const finalSubscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(memberTestUser1Credentials) + .query({ roomId: createdRoom._id }) + .expect(200); + + expect(finalSubscriptionResponse.body).to.have.property('success', true); + expect(finalSubscriptionResponse.body).to.have.property('subscription').that.is.an('object'); + expect(finalSubscriptionResponse.body.subscription.u).to.have.property('_id', memberTestUser1._id); + }); + }); +}); + +describe('/teams.removeRoom', () => { + let publicRoom: IRoom; + let publicTeam: ITeam; + const name = `teamName-remove-room-${Date.now()}`; + + before(async () => { + publicRoom = (await createRoom({ type: 'c', name: `public-remove-room-${Date.now()}` })).body.channel; + publicTeam = await createTeam(credentials, name, TeamType.PUBLIC); + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [publicRoom._id], + teamId: publicTeam._id, + }); + }); + + after(async () => { + await deleteTeam(credentials, name); + await Promise.all([ + updatePermission('edit-team-channel', ['admin', 'owner', 'moderator']), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + ]); + }); + + after(() => + Promise.all([ + updatePermission('remove-team-channel', ['admin', 'owner', 'moderator']), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + deleteTeam(credentials, name), + ]), + ); + + it('should throw an error if no permission', async () => { + await updatePermission('remove-team-channel', []); + + const res = await request + .post(api('teams.removeRoom')) + .set(credentials) + .send({ + roomId: publicRoom._id, + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('unauthorized'); + }); + + it('should remove room from team', async () => { + await updatePermission('remove-team-channel', ['admin']); + + const res = await request + .post(api('teams.removeRoom')) + .set(credentials) + .send({ + roomId: publicRoom._id, + teamId: publicTeam._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room'); + expect(res.body.room).to.not.have.property('teamId'); + expect(res.body.room).to.not.have.property('teamDefault'); + }); +}); + +describe('/teams.update', () => { + let testTeam: ITeam; + let testTeam2: ITeam; + let testTeam3: ITeam; + const teamName = `test-team-name1${Date.now()}`; + const teamName2 = `test-team-name2${Date.now()}`; + const teamName3 = `test-team-name3${Date.now()}`; + const testTeamName = `test-team-name-changed${Date.now()}-1`; + const testTeamName2 = `test-team-name-changed${Date.now()}-2`; + let unauthorizedUser: TestUser; + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type: 0, + }) + .end((_err, res) => { + testTeam = res.body.team; + done(); + }); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName2, + type: 0, + }) + .end((_err, res) => { + testTeam2 = res.body.team; + done(); + }); + }); + + before('Create test team', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName3, + type: 0, + }) + .end((_err, res) => { + testTeam3 = res.body.team; + done(); + }); + }); + + before(async () => { + unauthorizedUser = await createUser(); + }); + + after(() => + Promise.all([...[testTeamName, testTeamName2, teamName3].map((name) => deleteTeam(credentials, name)), deleteUser(unauthorizedUser)]), + ); + + it('should update team name', async () => { + const updateResponse = await request + .post(api('teams.update')) + .set(credentials) + .send({ + teamId: testTeam._id, + data: { + name: testTeamName, + }, + }); + + expect(updateResponse.body).to.have.property('success', true); + + const infoResponse = await request.get(api('teams.info')).set(credentials).query({ teamId: testTeam._id }); + + expect(infoResponse.body).to.have.property('success', true); + + const { teamInfo } = infoResponse.body; + expect(teamInfo).to.have.property('name', testTeamName); + }); + + it('should update team type', async () => { + const updateResponse = await request + .post(api('teams.update')) + .set(credentials) + .send({ + teamId: testTeam._id, + data: { + type: 1, + }, + }); + + expect(updateResponse.body).to.have.property('success', true); + + const infoResponse = await request.get(api('teams.info')).set(credentials).query({ teamId: testTeam._id }); + + expect(infoResponse.body).to.have.property('success', true); + + const { teamInfo } = infoResponse.body; + expect(teamInfo).to.have.property('type', 1); + }); + + it('should update team name and type at once', async () => { + const updateResponse = await request + .post(api('teams.update')) + .set(credentials) + .send({ + teamId: testTeam2._id, + data: { + name: testTeamName2, + type: 1, + }, + }); + + expect(updateResponse.body).to.have.property('success', true); + + const infoResponse = await request.get(api('teams.info')).set(credentials).query({ teamId: testTeam2._id }); + + expect(infoResponse.body).to.have.property('success', true); + + const { teamInfo } = infoResponse.body; + expect(teamInfo).to.have.property('type', 1); + }); + + it('should not update team if permissions are not met', async () => { + const unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + + const res = await request + .post(api('teams.update')) + .set(unauthorizedUserCredentials) + .send({ + teamId: testTeam._id, + data: { + name: 'anyname', + }, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + }); + + describe('should update team room to default and invite users with the right notification preferences', () => { + let userWithPrefs: TestUser; + let userCredentials: Credentials; + let createdRoom: IRoom; + + before(async () => { + userWithPrefs = await createUser(); + userCredentials = await login(userWithPrefs.username, password); + + createdRoom = (await createRoom({ type: 'c', name: `${Date.now()}-testTeam3` })).body.channel; + + await request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [createdRoom._id], + teamId: testTeam3._id, + }); + }); + + after(() => Promise.all([deleteUser(userWithPrefs), deleteRoom({ type: 'c', roomId: createdRoom._id })])); + + it('should update user prefs', async () => { + await request + .post(methodCall('saveUserPreferences')) + .set(userCredentials) + .send({ + message: JSON.stringify({ + method: 'saveUserPreferences', + params: [{ emailNotificationMode: 'nothing' }], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + }); + + it('should add user with prefs to team', async () => { + await request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: testTeam3.name, + members: [ + { + userId: userWithPrefs._id, + roles: ['member'], + }, + ], + }); + }); + + it('should update team channel to auto-join', async () => { + const response = await request.post(api('teams.updateRoom')).set(credentials).send({ + roomId: createdRoom._id, + isDefault: true, + }); + expect(response.body).to.have.property('success', true); + }); + + it('should return the user subscription with the right notification preferences', async () => { + const res = await request + .get(api('subscriptions.getOne')) + .set(userCredentials) + .query({ + roomId: createdRoom._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body).to.have.nested.property('subscription.emailNotifications').and.to.be.equal('nothing'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts new file mode 100644 index 0000000..19f6ed0 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -0,0 +1,6546 @@ +import crypto from 'crypto'; + +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, ISubscription, ITeam, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import type { IGetRoomRoles, PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; +import { assert, expect } from 'chai'; +import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; +import speakeasy from 'speakeasy'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, reservedWords } from '../../data/api-data'; +import { imgURL, tiffURL } from '../../data/interactions'; +import { createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; +import { removeAgent, getAgent } from '../../data/livechat/users'; +import { updatePermission, updateSetting, restorePermissionToRoles, getSettingValueById } from '../../data/permissions.helper'; +import type { ActionRoomParams } from '../../data/rooms.helper'; +import { actionRoom, createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; +import type { IUserWithCredentials } from '../../data/user'; +import { adminEmail, password, adminUsername } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, login, deleteUser, getUserByUsername } from '../../data/users.helper'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +const MAX_BIO_LENGTH = 260; +const MAX_NICKNAME_LENGTH = 120; + +const customFieldText = { + type: 'text', + required: true, + minLength: 2, + maxLength: 10, +}; + +function setCustomFields(customFields: unknown) { + const stringified = customFields ? JSON.stringify(customFields) : ''; + + return request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200); +} + +function clearCustomFields() { + return setCustomFields(null); +} + +const joinChannel = ({ overrideCredentials = credentials, roomId }: { overrideCredentials: Credentials; roomId: IRoom['_id'] }) => + request.post(api('channels.join')).set(overrideCredentials).send({ + roomId, + }); + +const inviteToChannel = ({ + overrideCredentials = credentials, + roomId, + userId, +}: { + overrideCredentials?: Credentials; + roomId: IRoom['_id']; + userId: IUser['_id']; +}) => + request.post(api('channels.invite')).set(overrideCredentials).send({ + userId, + roomId, + }); + +const addRoomOwner = ({ type, roomId, userId }: { type: ActionRoomParams['type']; roomId: IRoom['_id']; userId: IUser['_id'] }) => + actionRoom({ action: 'addOwner', type, roomId, extraData: { userId } }); + +const removeRoomOwner = ({ type, roomId, userId }: { type: ActionRoomParams['type']; roomId: IRoom['_id']; userId: IUser['_id'] }) => + actionRoom({ action: 'removeOwner', type, roomId, extraData: { userId } }); + +const getChannelRoles = async ({ + roomId, + overrideCredentials = credentials, +}: { + roomId: IRoom['_id']; + overrideCredentials?: Credentials; +}) => + ( + await request.get(api('channels.roles')).set(overrideCredentials).query({ + roomId, + }) + ).body.roles as IGetRoomRoles[]; + +const setRoomConfig = ({ roomId, favorite, isDefault }: { roomId: IRoom['_id']; favorite?: boolean; isDefault: boolean }) => { + return request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: roomId, + default: isDefault, + favorite: favorite + ? { + defaultValue: true, + favorite: false, + } + : undefined, + }); +}; + +const preferences = { + data: { + newRoomNotification: 'door', + newMessageNotification: 'chime', + muteFocusedConversations: true, + clockMode: 1, + useEmojis: true, + convertAsciiEmoji: true, + saveMobileBandwidth: true, + collapseMediaByDefault: false, + autoImageLoad: true, + emailNotificationMode: 'mentions', + unreadAlert: true, + notificationsSoundVolume: 100, + desktopNotifications: 'default', + pushNotifications: 'default', + enableAutoAway: true, + highlights: [], + desktopNotificationRequireInteraction: false, + hideUsernames: false, + hideRoles: false, + displayAvatars: true, + hideFlexTab: false, + sendOnEnter: 'normal', + idleTimeLimit: 3600, + notifyCalendarEvents: false, + enableMobileRinging: false, + ...(IS_EE && { + omnichannelTranscriptPDF: false, + }), + }, +}; + +const getUserStatus = (userId: IUser['_id']) => + new Promise<{ + _id: string; + status: 'online' | 'offline' | 'away' | 'busy'; + connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; + statusSource?: string; + statusExpiresAt?: string; + }>((resolve) => { + void request + .get(api('users.getStatus')) + .query({ userId }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .end((_end, res) => { + resolve(res.body); + }); + }); + +const registerUser = async ( + userData: { + username?: string; + email?: string; + name?: string; + pass?: string; + } = {}, + overrideCredentials: Credentials | null = credentials, +) => { + const username = userData.username || `user.test.${Date.now()}`; + const email = userData.email || `${username}@rocket.chat`; + + const req = request.post(api('users.register')); + + if (overrideCredentials) { + req.set(overrideCredentials); + } + const result = await req.send({ email, name: username, username, pass: password, ...userData }); + + return result.body.user; +}; + +// For changing user data when it's not possible to do so via API +const updateUserInDb = async (userId: IUser['_id'], userData: Partial) => { + const connection = await MongoClient.connect(URL_MONGODB); + + await connection + .db() + .collection('users') + .updateOne({ _id: userId as any }, { $set: { ...userData } }); + + await connection.close(); +}; + +describe('[Users]', () => { + let targetUser: { _id: IUser['_id']; username: string; emails: { address: string }[] }; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before('should create a new user', async () => { + const user = await createUser({ + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }); + targetUser = { + _id: user._id, + username: user.username, + emails: user.emails, + }; + userCredentials = await login(user.username, password); + }); + + after(() => Promise.all([deleteUser(targetUser), updateSetting('E2E_Enable', false)])); + + it('enabling E2E in server and generating keys to user...', async () => { + await updateSetting('E2E_Enable', true); + await request + .post(api('e2e.setUserPublicAndPrivateKeys')) + .set(userCredentials) + .send({ + private_key: 'test', + public_key: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + await request + .get(api('e2e.fetchMyKeys')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('public_key', 'test'); + expect(res.body).to.have.property('private_key', 'test'); + }); + }); + + it('should fail when trying to set keys for a user with keys already set', async () => { + await request + .post(api('e2e.setUserPublicAndPrivateKeys')) + .set(userCredentials) + .send({ + private_key: 'test', + public_key: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Keys already set [error-keys-already-set]'); + }); + }); + + describe('[/e2e.requestSubscriptionKeys]', () => { + it('should return unauthorized when not authenticated', async () => { + await request.post(api('e2e.requestSubscriptionKeys')).expect(401); + }); + + it('should accept the request and return success for an authenticated user', async () => { + await request + .post(api('e2e.requestSubscriptionKeys')) + .set(userCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + describe('[/users.create]', () => { + before(async () => clearCustomFields()); + after(async () => clearCustomFields()); + + it('should create a new user with custom fields', async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${apiUsername}`; + const email = `customField_${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + const res = await request + .post(api('users.create')) + .set(credentials) + .send({ + email, + name: username, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); + expect(res.body).to.not.have.nested.property('user.e2e'); + + const { user } = res.body; + + await deleteUser(user); + }); + + function failCreateUser(name: string) { + it(`should not create a new user if username is the reserved word ${name}`, async () => { + const res = await request + .post(api('users.create')) + .set(credentials) + .send({ + email: `create_user_fail_${apiEmail}`, + name: `create_user_fail_${apiUsername}`, + username: name, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); + }); + } + + function failUserWithCustomField(field: { name: string; value: unknown; reason: string }) { + it(`should not create a user if a custom field ${field.reason}`, async () => { + await setCustomFields({ customFieldText }); + + const customFields: Record = {}; + customFields[field.name] = field.value; + + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: `customField_fail_${apiEmail}`, + name: `customField_fail_${apiUsername}`, + username: `customField_fail_${apiUsername}`, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); + }); + }); + } + + [ + { name: 'customFieldText', value: '', reason: 'is required and missing' }, + { name: 'customFieldText', value: '0', reason: 'length is less than minLength' }, + { name: 'customFieldText', value: '0123456789-0', reason: 'length is more than maxLength' }, + ].forEach((field) => { + failUserWithCustomField(field); + }); + + reservedWords.forEach((name) => { + failCreateUser(name); + }); + + describe('users default roles configuration', () => { + const users: IUser[] = []; + + before(async () => { + await updateSetting('Accounts_Registration_Users_Default_Roles', 'user,admin'); + }); + + after(async () => { + await updateSetting('Accounts_Registration_Users_Default_Roles', 'user'); + + await Promise.all(users.map((user) => deleteUser(user))); + }); + + it('should create a new user with default roles', (done) => { + const username = `defaultUserRole_${apiUsername}${Date.now()}`; + const email = `defaultUserRole_${apiEmail}${Date.now()}`; + + void request + .post(api('users.create')) + .set(credentials) + .send({ + email, + name: username, + username, + password, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body.user.roles).to.have.members(['user', 'admin']); + + users.push(res.body.user); + }) + .end(done); + }); + + it('should create a new user with only the role provided', (done) => { + const username = `defaultUserRole_${apiUsername}${Date.now()}`; + const email = `defaultUserRole_${apiEmail}${Date.now()}`; + + void request + .post(api('users.create')) + .set(credentials) + .send({ + email, + name: username, + username, + password, + roles: ['guest'], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body.user.roles).to.have.members(['guest']); + + users.push(res.body.user); + }) + .end(done); + }); + }); + + describe('auto join default channels', () => { + let defaultTeamRoomId: IRoom['_id']; + let defaultTeamId: ITeam['_id']; + let group: IRoom; + let user: IUser; + let userCredentials: Credentials; + let user2: IUser; + let user3: IUser; + let userNoDefault: IUser; + const teamName = `defaultTeam_${Date.now()}`; + + before(async () => { + const defaultTeam = await createTeam(credentials, teamName, 0); + defaultTeamRoomId = defaultTeam.roomId; + defaultTeamId = defaultTeam._id; + }); + + before(async () => { + const { body } = await createRoom({ + name: `defaultGroup_${Date.now()}`, + type: 'p', + credentials, + extraData: { + broadcast: false, + encrypted: false, + teamId: defaultTeamId, + topic: '', + }, + }); + group = body.group; + }); + + after(() => + Promise.all([ + deleteRoom({ roomId: group._id, type: 'p' }), + deleteTeam(credentials, teamName), + deleteUser(user), + deleteUser(user2), + deleteUser(user3), + deleteUser(userNoDefault), + ]), + ); + + it('should not create subscriptions to non default teams or rooms even if joinDefaultChannels is true', async () => { + userNoDefault = await createUser({ joinDefaultChannels: true }); + const noDefaultUserCredentials = await login(userNoDefault.username, password); + await request + .get(api('subscriptions.getOne')) + .set(noDefaultUserCredentials) + .query({ roomId: defaultTeamRoomId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').that.is.null; + }); + + await request + .get(api('subscriptions.getOne')) + .set(noDefaultUserCredentials) + .query({ roomId: group._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').that.is.null; + }); + }); + + it('should create a subscription for a default team room if joinDefaultChannels is true', async () => { + await setRoomConfig({ roomId: defaultTeamRoomId, favorite: true, isDefault: true }); + + user = await createUser({ joinDefaultChannels: true }); + userCredentials = await login(user.username, password); + await request + .get(api('subscriptions.getOne')) + .set(userCredentials) + .query({ roomId: defaultTeamRoomId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription'); + expect(res.body.subscription).to.have.property('rid', defaultTeamRoomId); + }); + }); + + it('should NOT create a subscription for non auto-join rooms inside a default team', async () => { + await request + .get(api('subscriptions.getOne')) + .set(userCredentials) + .query({ roomId: group._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').that.is.null; + }); + }); + + it('should create a subscription for the user in all the auto join rooms of the team', async () => { + await request.post(api('teams.updateRoom')).set(credentials).send({ + roomId: group._id, + isDefault: true, + }); + + user2 = await createUser({ joinDefaultChannels: true }); + const user2Credentials = await login(user2.username, password); + + await request + .get(api('subscriptions.getOne')) + .set(user2Credentials) + .query({ roomId: group._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription'); + expect(res.body.subscription).to.have.property('rid', group._id); + }); + }); + + it('should create a subscription for a default room inside a non default team', async () => { + await setRoomConfig({ roomId: defaultTeamRoomId, isDefault: false }); + await setRoomConfig({ roomId: group._id, favorite: true, isDefault: true }); + + user3 = await createUser({ joinDefaultChannels: true }); + const user3Credentials = await login(user3.username, password); + + // New user should be subscribed to the default room inside a team + await request + .get(api('subscriptions.getOne')) + .set(user3Credentials) + .query({ roomId: group._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription'); + expect(res.body.subscription).to.have.property('rid', group._id); + }); + + // New user should not be subscribed to the parent team + await request + .get(api('subscriptions.getOne')) + .set(user3Credentials) + .query({ roomId: defaultTeamRoomId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').that.is.null; + }); + }); + }); + + (IS_EE ? describe : describe.skip)('Voice call extension', () => { + beforeEach(async () => { + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', true); + }); + + after(async () => { + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', true); + }); + + it('should create a user with a voice call extension', async () => { + const freeSwitchExtension = '888999'; + let user: TestUser; + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: 'success_extension_user@rocket.chat', + name: 'success_extension_user', + username: 'success_extension_user', + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + freeSwitchExtension, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.freeSwitchExtension', freeSwitchExtension); + user = res.body.user; + }); + + if (user!) { + await deleteUser(user); + } + }); + + it('should not create a user with a voice call extension that is already in use', async () => { + const freeSwitchExtension = '123123'; + const user = await createUser({ freeSwitchExtension }); + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: 'fail_extension_in_use@rocket.chat', + name: 'fail_extension_in_use', + username: 'fail_extension_in_use', + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + freeSwitchExtension, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-extension-not-available'); + }); + + await deleteUser(user); + }); + + it('should not create a user if voip is disabled', async () => { + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', false); + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: 'fail_voip_disabled@rocket.chat', + name: 'fail_voip_disabled', + username: 'fail_voip_disabled', + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + freeSwitchExtension: '999', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }); + }); + }); + + describe('default email2fa auto opt in configuration', () => { + let user: IUser; + + afterEach(async () => { + await deleteUser(user); + await updateSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled', true); + await updateSetting('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In', true); + await updateSetting('Accounts_TwoFactorAuthentication_Enabled', true); + }); + + const dummyUser = { + email: 'email2fa_auto_opt_in@rocket.chat', + name: 'email2fa_auto_opt_in', + username: 'email2fa_auto_opt_in', + password, + }; + + it('should auto opt in new users for email2fa ', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send(dummyUser) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + user = res.body.user; + }); + + const newUserCredentials = await login(dummyUser.username, dummyUser.password); + + await request + .get(api('users.info')) + .set(newUserCredentials) + .query({ + username: dummyUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.services.email2fa.enabled', true); + }); + }); + + it('should not auto opt in new users for email2fa if email2fa is disabled', async () => { + await updateSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled', false); + await request + .post(api('users.create')) + .set(credentials) + .send(dummyUser) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + user = res.body.user; + }); + + const newUserCredentials = await login(dummyUser.username, dummyUser.password); + + await request + .get(api('users.info')) + .set(newUserCredentials) + .query({ + username: dummyUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.services.email2fa.enabled'); + }); + }); + + it('should not auto opt in new users for email2fa if two factor authentication is disabled', async () => { + await updateSetting('Accounts_TwoFactorAuthentication_Enabled', false); + await request + .post(api('users.create')) + .set(credentials) + .send(dummyUser) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + user = res.body.user; + }); + + const newUserCredentials = await login(dummyUser.username, dummyUser.password); + + await request + .get(api('users.info')) + .set(newUserCredentials) + .query({ + username: dummyUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.services.email2fa.enabled'); + }); + }); + + it('should not auto opt in new users for email2fa if email2fa is enabled but auto opt in is disabled', async () => { + await updateSetting('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In', false); + + await request + .post(api('users.create')) + .set(credentials) + .send(dummyUser) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + user = res.body.user; + }); + + const newUserCredentials = await login(dummyUser.username, dummyUser.password); + + await request + .get(api('users.info')) + .set(newUserCredentials) + .query({ + username: dummyUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.services.email2fa.enabled'); + }); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.create')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.register]', () => { + const email = `email@email${Date.now()}.com`; + const username = `myusername${Date.now()}`; + const users: IUser[] = []; + + after(async () => { + await Promise.all(users.map((user) => deleteUser(user))); + }); + + it('should register new user', async () => { + const res = await request + .post(api('users.register')) + .send({ + email, + name: 'name', + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', 'name'); + users.push(res.body.user); + }); + + it('should return an error when trying register new user with an invalid username', async () => { + const res = await request + .post(api('users.register')) + .send({ + email, + name: 'name', + username: 'test$username<>', + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('The username provided is not valid'); + }); + + it('should return an error when trying register new user with an existing username', async () => { + const res = await request + .post(api('users.register')) + .send({ + email, + name: 'name', + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('Username is already in use'); + }); + it("should return an error when registering a user's name with invalid characters: >, <, /, or \\", async () => { + const res = await request + .post(api('users.register')) + .send({ + email, + name: '', + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('Name contains invalid characters'); + }); + + it('should return an error when logged in user tries to register', async () => { + const res = await request + .post(api('users.register')) + .set(credentials) + .send({ + email: `newuser${Date.now()}@email.com`, + name: 'New User', + username: `newuser${Date.now()}`, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('Logged in users can not register again.'); + }); + + describe('registration form setting', () => { + let previousRegistrationForm: Awaited>; + + beforeEach(async () => { + previousRegistrationForm = await getSettingValueById('Accounts_RegistrationForm'); + }); + + afterEach(async () => { + await updateSetting('Accounts_RegistrationForm', previousRegistrationForm); + }); + + it('should reject registration when public registration is disabled', async () => { + await updateSetting('Accounts_RegistrationForm', 'Disabled'); + + const username = `disabledRegistration_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-registration-disabled'); + }); + + const user = await getUserByUsername(username); + expect(user).to.be.undefined; + }); + }); + + describe('secret URL registration', () => { + let previousRegistrationForm: Awaited>; + let previousRegistrationFormSecretURL: Awaited>; + + beforeEach(async () => { + [previousRegistrationForm, previousRegistrationFormSecretURL] = await Promise.all([ + getSettingValueById('Accounts_RegistrationForm'), + getSettingValueById('Accounts_RegistrationForm_SecretURL'), + ]); + + await Promise.all([ + updateSetting('Accounts_RegistrationForm', 'Secret URL'), + updateSetting('Accounts_RegistrationForm_SecretURL', 'valid-secret'), + ]); + }); + + afterEach(async () => { + await Promise.all([ + updateSetting('Accounts_RegistrationForm', previousRegistrationForm), + updateSetting('Accounts_RegistrationForm_SecretURL', previousRegistrationFormSecretURL), + ]); + }); + + it('should reject registration without a secret when registration is limited to a secret URL', async () => { + const username = `missingSecret_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-registration-secret'); + }); + + const user = await getUserByUsername(username); + expect(user).to.be.undefined; + }); + + it('should reject registration with an invalid secret when registration is limited to a secret URL', async () => { + const username = `invalidSecret_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + secret: 'invalid-secret', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-user-registration-secret'); + }); + + const user = await getUserByUsername(username); + expect(user).to.be.undefined; + }); + + it('should register a user with a valid secret when registration is limited to a secret URL', async () => { + const username = `validSecret_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + secret: 'valid-secret', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + users.push(res.body.user); + }); + }); + }); + + describe('manual approval', () => { + let previousManuallyApproveNewUsers: Awaited>; + + beforeEach(async () => { + previousManuallyApproveNewUsers = await getSettingValueById('Accounts_ManuallyApproveNewUsers'); + }); + + afterEach(async () => { + await updateSetting('Accounts_ManuallyApproveNewUsers', previousManuallyApproveNewUsers); + }); + + it('should register an inactive user and persist the reason when manual approval is enabled', async () => { + await updateSetting('Accounts_ManuallyApproveNewUsers', true); + + const username = `manualApproval_${Date.now()}`; + const email = `${username}@rocket.chat`; + const reason = 'I need access to the workspace'; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + reason, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.active', false); + users.push(res.body.user); + }); + + const user = await getUserByUsername(username); + expect(user).to.have.property('reason', reason); + }); + }); + + describe('allowed email domains', () => { + let previousAllowedDomainsList: Awaited>; + + beforeEach(async () => { + previousAllowedDomainsList = await getSettingValueById('Accounts_AllowedDomainsList'); + }); + + afterEach(async () => { + await updateSetting('Accounts_AllowedDomainsList', previousAllowedDomainsList); + }); + + it('should reject registration when the email domain is not allowed', async () => { + await updateSetting('Accounts_AllowedDomainsList', 'rocket.chat'); + + const username = `invalidDomain_${Date.now()}`; + const email = `${username}@example.com`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-domain'); + }); + + const user = await getUserByUsername(username); + expect(user).to.be.undefined; + }); + + it('should register a user when the email domain is allowed', async () => { + await updateSetting('Accounts_AllowedDomainsList', 'rocket.chat'); + + const username = `validDomain_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + users.push(res.body.user); + }); + }); + }); + + describe('custom fields', () => { + let previousCustomFields: Awaited>; + + beforeEach(async () => { + previousCustomFields = await getSettingValueById('Accounts_CustomFields'); + await setCustomFields({ customFieldText }); + }); + + afterEach(async () => { + await updateSetting('Accounts_CustomFields', previousCustomFields); + }); + + it('should reject registration when a required custom field is empty', async () => { + const username = `missingCustomField_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + customFields: { + customFieldText: '', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + // TODO: assert error-user-registration-custom-field directly after users.register formats this Meteor.Error correctly + expect(res.body).to.have.property('errorType', 'error-invalid-body'); + expect(res.body).to.have.nested.property('body.error', 'error-user-registration-custom-field'); + }); + + const user = await getUserByUsername(username); + expect(user).to.be.undefined; + }); + + it('should save valid custom fields when registering a user', async () => { + const username = `validCustomField_${Date.now()}`; + const email = `${username}@rocket.chat`; + + await request + .post(api('users.register')) + .send({ + email, + name: username, + username, + pass: password, + customFields: { + customFieldText: 'success', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + users.push(res.body.user); + }); + + const user = await getUserByUsername(username); + expect(user).to.have.nested.property('customFields.customFieldText', 'success'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.register')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.info]', () => { + let infoRoom: IRoom; + + before(async () => { + infoRoom = ( + await createRoom({ + type: 'c', + name: `channel.test.info.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + }); + + after(() => + Promise.all([ + updatePermission('view-other-user-channels', ['admin']), + updatePermission('view-full-other-user-info', ['admin']), + deleteRoom({ type: 'c', roomId: infoRoom._id }), + ]), + ); + + it('should fail when request is without authentication credentials', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + describe('authentication', () => { + before(() => updateSetting('Accounts_AllowAnonymousRead', true)); + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + it('should fail when request is without authentication credentials and Anonymous Read is enabled', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should fail when request is without token and Anonymous Read is enabled', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .set({ 'X-User-Id': credentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + + it('should return an error when the user does not exist', async () => { + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + username: 'invalid-username', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should query information about a user by userId', async () => { + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', targetUser.username); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it('should return "rooms" property when user request it and the user has the necessary permission (admin, "view-other-user-channels")', async () => { + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: targetUser._id, + includeUserRooms: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.rooms').and.to.be.an('array'); + const createdRoom = (res.body.user.rooms as ISubscription[]).find((room) => room.rid === infoRoom._id); + expect(createdRoom).to.have.property('unread'); + }); + + it('should NOT return "rooms" property when user NOT request it but the user has the necessary permission (admin, "view-other-user-channels")', async () => { + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.rooms'); + }); + + it('should return the rooms when the user requests their own rooms but they do NOT have the necessary permission', async () => { + await updatePermission('view-other-user-channels', []); + + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: credentials['X-User-Id'], + includeUserRooms: true, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.rooms'); + expect(res.body.user.rooms).with.lengthOf.at.least(1); + expect(res.body.user.rooms[0]).to.have.property('unread'); + }); + it("should NOT return the rooms when the user requests another user's rooms WITHOUT having the necessary permission", async () => { + await updatePermission('view-other-user-channels', []); + + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: targetUser._id, + fields: JSON.stringify({ userRooms: 1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.rooms'); + }); + it("should NOT return any services fields when requesting another user's info, even if the user has the necessary permission", async () => { + await updatePermission('view-full-other-user-info', ['admin']); + + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.not.have.nested.property('user.services.emailCode'); + expect(res.body).to.not.have.nested.property('user.services'); + }); + it('should return all services fields when request for myself data even without privileged permission', async () => { + await updatePermission('view-full-other-user-info', []); + + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.services.password'); + }); + + it('should correctly route users that have `ufs` in their username', async () => { + const ufsUsername = `ufs-${Date.now()}`; + + const user = await createUser({ + email: `me-${Date.now()}@email.com`, + name: 'testuser', + username: ufsUsername, + password, + }); + + await request + .get(api('users.info')) + .set(credentials) + .query({ + username: ufsUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.user).to.have.property('type', 'user'); + expect(res.body.user).to.have.property('name', 'testuser'); + expect(res.body.user).to.have.property('username', ufsUsername); + expect(res.body.user).to.have.property('active', true); + }); + + await deleteUser(user); + }); + + it("should NOT return sensitive fields on services even though it's the same user requesting its info", async () => { + const res = await request + .get(api('users.info')) + .set(credentials) + .query({ + userId: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.services.password').and.to.be.a('boolean'); + expect(res.body).to.not.have.nested.property('user.services.email'); + expect(res.body).to.not.have.nested.property('user.services.resume'); + expect(res.body).to.not.have.nested.property('user.services.passwordHistory'); + }); + + describe('querying by user email', () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + + describe("with 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + + it('should query information about a user by email', async () => { + const targetEmail = targetUser.emails[0].address; + + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetEmail, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user._id', targetUser._id); + expect(res.body).to.have.nested.property('user.emails[0].address', targetEmail); + }); + }); + it('should return an error when querying by an email that does not exist', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: 'this_is_a_fake_email_that_does_not_exist@invalid.com', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + }); + + describe("without 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + + it('should return an error when querying another user by email and lacking "view-full-other-user-info" permission', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + it('should query information about myself by email', async () => { + await request + .get(api('users.info')) + .set(userCredentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user.emails[0].address', targetUser.emails[0].address); + }); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('querying by freeSwitch extension', () => { + let previousVoipSetting: boolean; + let targetUser: IUser; + + before(async () => { + previousVoipSetting = (await getSettingValueById('VoIP_TeamCollab_SIP_Integration_Enabled')) as boolean; + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', true); + targetUser = await createUser({ freeSwitchExtension: '123123' }); + }); + + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + await deleteUser(targetUser); + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', previousVoipSetting); + }); + + describe("with 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + + it('should successfully return information on an existing user', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + freeSwitchExtension: targetUser.freeSwitchExtension, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user._id', targetUser._id); + expect(res.body).to.have.nested.property('user.freeSwitchExtension', targetUser.freeSwitchExtension); + }); + }); + it('should return an error when user does not exist', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + freeSwitchExtension: 'this_is_a_fake_extension_that_does_not_exist', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + }); + + describe("without 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + + it('should successfully return information on an existing user', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + freeSwitchExtension: targetUser.freeSwitchExtension, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user.freeSwitchExtension', targetUser.freeSwitchExtension); + }); + }); + }); + }); + }); + describe('[/users.getPresence]', () => { + it("should query a user's presence by userId", async () => { + const res = await request + .get(api('users.getPresence')) + .set(credentials) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('presence', 'offline'); + }); + + describe('Logging in with type: "resume"', () => { + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser({ joinDefaultChannels: false }); + userCredentials = await login(user.username, password); + }); + + after(() => deleteUser(user)); + + it('should return "offline" after a login type "resume" via REST', async () => { + await request + .post(api('login')) + .send({ + resume: userCredentials['X-Auth-Token'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .get(api('users.getPresence')) + .set(credentials) + .query({ + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('presence', 'offline'); + }); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPresence')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.presence]', () => { + describe('Not logged in:', () => { + it('should return 401 unauthorized', async () => { + const res = await request.get(api('users.presence')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('message'); + }); + }); + describe('Logged in:', () => { + it('should return online users full list', async () => { + const res = await request.get(api('users.presence')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', true); + + const user = (res.body.users as IUser[]).find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + }); + + it('should return no online users updated after now', async () => { + const res = await request + .get(api('users.presence')) + .query({ from: new Date().toISOString() }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + expect(res.body).to.have.property('users').that.is.an('array').that.has.lengthOf(0); + }); + + it('should return presence for a single id', async () => { + const res = await request + .get(api('users.presence')) + .query({ ids: 'rocket.cat' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); + expect(res.body.users[0]).to.have.property('_id', 'rocket.cat'); + }); + + it('should correctly parse comma-separated ids and not return an empty result', async () => { + const res = await request + .get(api('users.presence')) + .query({ ids: `rocket.cat,${credentials['X-User-Id']}` }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + // only rocket.cat is guaranteed to be online; admin may be offline + expect(res.body.users.map((u: IUser) => u._id)).to.include('rocket.cat'); + }); + + it('should return presence for repeated ids params', async () => { + const res = await request + .get(api('users.presence')) + .query(`ids=rocket.cat&ids=${credentials['X-User-Id']}`) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + // only rocket.cat is guaranteed to be online; admin may be offline + expect(res.body.users.map((u: IUser) => u._id)).to.include('rocket.cat'); + }); + + it('should return full list of online users for more than 10 minutes in the past', (done) => { + const date = new Date(); + date.setMinutes(date.getMinutes() - 11); + + void request + .get(api('users.presence')) + .query({ from: date.toISOString() }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', true); + + const user = (res.body.users as IUser[]).find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + }) + .end(done); + }); + + it('should return an offline user that still carries a custom status (vacation text/expiration survives offline)', async () => { + const vacationUser = await createUser(); + const vacationCredentials = await login(vacationUser.username, password); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + await request + .post(api('users.setStatus')) + .set(vacationCredentials) + .send({ status: 'offline', message: 'On vacation', expiresAt }) + .expect(200); + + const res = await request + .get(api('users.presence')) + .query({ ids: vacationUser._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + const returned = (res.body.users as IUser[]).find((u) => u._id === vacationUser._id); + expect(returned, 'offline user with a custom status must be returned by users.presence').to.not.be.undefined; + expect(returned).to.have.property('status', 'offline'); + expect(returned).to.have.property('statusText', 'On vacation'); + expect(returned).to.have.property('statusExpiresAt'); + + await deleteUser(vacationUser); + }); + }); + }); + + describe('[/users.list]', () => { + let user: TestUser; + let deactivatedUser: TestUser; + let user2: TestUser; + let user2Credentials: Credentials; + let user3: TestUser; + let user3Credentials: Credentials; + let group: IRoom; + let inviteToken: string; + + before(async () => { + const username = `deactivated_${Date.now()}${apiUsername}`; + const email = `deactivated_+${Date.now()}${apiEmail}`; + + const userData = { + email, + name: username, + username, + password, + active: false, + }; + + deactivatedUser = await createUser(userData); + + expect(deactivatedUser).to.not.be.null; + expect(deactivatedUser).to.have.nested.property('username', username); + expect(deactivatedUser).to.have.nested.property('emails[0].address', email); + expect(deactivatedUser).to.have.nested.property('active', false); + expect(deactivatedUser).to.have.nested.property('name', username); + expect(deactivatedUser).to.not.have.nested.property('e2e'); + }); + + before(async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${Date.now()}${apiUsername}`; + const email = `customField_+${Date.now()}${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + const userData = { + email, + name: username, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }; + + user = await createUser(userData); + + expect(user).to.not.be.null; + expect(user).to.have.nested.property('username', username); + expect(user).to.have.nested.property('emails[0].address', email); + expect(user).to.have.nested.property('active', true); + expect(user).to.have.nested.property('name', username); + expect(user).to.have.nested.property('customFields.customFieldText', 'success'); + expect(user).to.not.have.nested.property('e2e'); + }); + + before(async () => { + user2 = await createUser({ joinDefaultChannels: false }); + user2Credentials = await login(user2.username, password); + user3 = await createUser({ joinDefaultChannels: false }); + user3Credentials = await login(user3.username, password); + }); + + before('Create a group', async () => { + group = ( + await createRoom({ + type: 'p', + name: `group.test.${Date.now()}-${Math.random()}`, + }) + ).body.group; + }); + + before('Create invite link', async () => { + inviteToken = ( + await request.post(api('findOrCreateInvite')).set(credentials).send({ + rid: group._id, + days: 0, + maxUses: 0, + }) + ).body._id; + }); + + after('Remove invite link', async () => + request + .delete(api(`removeInvite/${inviteToken}`)) + .set(credentials) + .send(), + ); + + after(() => + Promise.all([ + clearCustomFields(), + deleteUser(deactivatedUser), + deleteUser(user), + deleteUser(user2), + deleteUser(user3), + deleteRoom({ type: 'p', roomId: group._id }), + updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']), + updateSetting('API_Apply_permission_view-outside-room_on_users-list', false), + ]), + ); + + it('should query all users in the system', async () => { + const res = await request.get(api('users.list')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + const myself = (res.body.users as IUser[]).find((user) => user.username === adminUsername); + expect(myself).to.not.have.property('e2e'); + }); + + it('should sort for user statuses and check if deactivated user is correctly sorted', (done) => { + const query = { + fields: JSON.stringify({ + username: 1, + _id: 1, + active: 1, + status: 1, + }), + sort: JSON.stringify({ + status: 1, + }), + }; + + void request + .get(api('users.list')) + .query(query) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('users'); + const firstUser = (res.body.users as IUser[]).find((u) => u._id === deactivatedUser._id); + expect(firstUser).to.have.property('active', false); + }) + .end(done); + }); + + it('should query all users in the system when logged as normal user and `view-outside-room` not granted', async () => { + await updatePermission('view-outside-room', ['admin']); + await request + .get(api('users.list')) + .set(user2Credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should not query users when logged as normal user, `view-outside-room` not granted and temp setting enabled', async () => { + await updatePermission('view-outside-room', ['admin']); + await updateSetting('API_Apply_permission_view-outside-room_on_users-list', true); + + await request.get(api('users.list')).set(user2Credentials).expect('Content-Type', 'application/json').expect(403); + }); + + it('should exclude inviteToken in the user item for privileged users', async () => { + await request + .post(api('useInviteToken')) + .set(user2Credentials) + .send({ token: inviteToken }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room'); + expect(res.body.room).to.have.property('rid', group._id); + }); + + await request + .get(api('users.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ + fields: JSON.stringify({ inviteToken: 1 }), + sort: JSON.stringify({ inviteToken: -1 }), + count: 100, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + res.body.users.forEach((user: IUser) => { + expect(user).to.not.have.property('inviteToken'); + }); + }); + }); + + it('should exclude inviteToken in the user item for normal users', async () => { + await updateSetting('API_Apply_permission_view-outside-room_on_users-list', false); + await request + .post(api('useInviteToken')) + .set(user3Credentials) + .send({ token: inviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room'); + expect(res.body.room).to.have.property('rid', group._id); + }); + + await request + .get(api('users.list')) + .set(user3Credentials) + .expect('Content-Type', 'application/json') + .query({ + fields: JSON.stringify({ inviteToken: 1 }), + sort: JSON.stringify({ inviteToken: -1 }), + count: 100, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + res.body.users.forEach((user: IUser) => { + expect(user).to.not.have.property('inviteToken'); + }); + }); + }); + + describe('querying by user email', async () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + describe("with 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + it('should return the specific user with the "emails" property', async () => { + const targetEmail = targetUser.emails[0].address; + + await request + .get(api('users.list')) + .query({ email: targetEmail }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); + + const returnedUser = res.body.users[0]; + expect(returnedUser).to.have.property('_id', targetUser._id); + expect(returnedUser).to.have.property('emails').that.is.an('array'); + expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail); + }); + }); + it('should return an empty array when querying by an email that does not exist', async () => { + await request + .get(api('users.list')) + .query({ email: 'this_email_does_not_exist@invalid.com' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.property('count', 0); + }); + }); + }); + describe("without 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + it('should return 403 Forbidden', async () => { + await request + .get(api('users.list')) + .query({ email: targetUser.emails[0].address }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.list')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('Avatars', () => { + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + await Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + updatePermission('edit-other-user-avatar', ['admin', 'user']), + ]); + }); + + after(() => + Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + deleteUser(user), + updatePermission('edit-other-user-avatar', ['admin']), + ]), + ); + + describe('[/users.setAvatar]', () => { + it('should set the avatar of the logged user by a local image', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should reject non-renderable image types (e.g. TIFF)', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', tiffURL) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-file-type'); + }); + it('should update the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .field({ userId: credentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should set the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(credentials) + .attach('image', imgURL) + .field({ username: adminUsername }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it("should prevent from updating someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", async () => { + await updatePermission('edit-other-user-avatar', []); + + const res = await request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .field({ userId: credentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should allow users with the edit-other-user-avatar permission to update avatars when the Accounts_AllowUserAvatarChange setting is off', async () => { + await updateSetting('Accounts_AllowUserAvatarChange', false); + + await updatePermission('edit-other-user-avatar', ['admin']); + + const res = await request + .post(api('users.setAvatar')) + .set(credentials) + .attach('image', imgURL) + .field({ userId: userCredentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should prevent users from passing server-side request forgery (SSRF) payloads as avatarUrl', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(credentials) + .send({ + userId: userCredentials['X-User-Id'], + avatarUrl: 'http://169.254.169.254/', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.resetAvatar]', () => { + before(async () => { + await Promise.all([ + updateSetting('Accounts_AllowUserAvatarChange', true), + updatePermission('edit-other-user-avatar', ['admin', 'user']), + ]); + }); + + it('should set the avatar of the logged user by a local image', async () => { + const res = await request + .post(api('users.setAvatar')) + .set(userCredentials) + .attach('image', imgURL) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should reset the avatar of the logged user', async () => { + const res = await request + .post(api('users.resetAvatar')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .send({ + userId: userCredentials['X-User-Id'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should reset the avatar of another user by userId when the logged user has the necessary permission (edit-other-user-avatar)', async () => { + const res = await request + .post(api('users.resetAvatar')) + .set(userCredentials) + .send({ + userId: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should reset the avatar of another user by username and local image when the logged user has the necessary permission (edit-other-user-avatar)', async () => { + const res = await request + .post(api('users.resetAvatar')) + .set(credentials) + .send({ + username: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it("should prevent from resetting someone else's avatar when the logged user doesn't have the necessary permission(edit-other-user-avatar)", async () => { + await updatePermission('edit-other-user-avatar', []); + + const res = await request + .post(api('users.resetAvatar')) + .set(userCredentials) + .send({ + userId: credentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should allow users with the edit-other-user-avatar permission to reset avatars when the Accounts_AllowUserAvatarChange setting is off', async () => { + await updateSetting('Accounts_AllowUserAvatarChange', false); + + await updatePermission('edit-other-user-avatar', ['admin']); + + const res = await request + .post(api('users.resetAvatar')) + .set(credentials) + .send({ + userId: userCredentials['X-User-Id'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.getAvatar]', () => { + it('should get the url of the avatar of the logged user via userId', async () => { + await request + .get(api('users.getAvatar')) + .set(userCredentials) + .query({ + userId: userCredentials['X-User-Id'], + }) + .expect(307); + }); + it('should get the url of the avatar of the logged user via username', async () => { + await request + .get(api('users.getAvatar')) + .set(userCredentials) + .query({ + username: user.username, + }) + .expect(307); + }); + }); + + describe('[/users.getAvatarSuggestion]', () => { + it('should return 401 unauthorized when user is not logged in', (done) => { + void request.get(api('users.getAvatarSuggestion')).expect('Content-Type', 'application/json').expect(401).end(done); + }); + + it('should get avatar suggestion of the logged user via userId', async () => { + const res = await request + .get(api('users.getAvatarSuggestion')) + .set(userCredentials) + .query({ + userId: userCredentials['X-User-Id'], + }) + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('suggestions').and.to.be.an('object'); + }); + }); + }); + + describe('[/users.update]', () => { + before(async () => + Promise.all([ + updateSetting('Accounts_AllowUserProfileChange', true), + updateSetting('Accounts_AllowUsernameChange', true), + updateSetting('Accounts_AllowRealNameChange', true), + updateSetting('Accounts_AllowUserStatusMessageChange', true), + updateSetting('Accounts_AllowEmailChange', true), + updateSetting('Accounts_AllowPasswordChange', true), + updatePermission('edit-other-user-info', ['admin']), + ]), + ); + after(async () => + Promise.all([ + updateSetting('Accounts_AllowUserProfileChange', true), + updateSetting('Accounts_AllowUsernameChange', true), + updateSetting('Accounts_AllowRealNameChange', true), + updateSetting('Accounts_AllowUserStatusMessageChange', true), + updateSetting('Accounts_AllowEmailChange', true), + updateSetting('Accounts_AllowPasswordChange', true), + updatePermission('edit-other-user-info', ['admin']), + ]), + ); + + it("should update a user's info by userId", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + email: apiEmail, + name: `edited${apiUsername}`, + username: `edited${apiUsername}`, + password, + active: true, + roles: ['user'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', `edited${apiUsername}`); + expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', `edited${apiUsername}`); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it("should update a user's email by userId", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + email: `edited${apiEmail}`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].address', `edited${apiEmail}`); + expect(res.body).to.have.nested.property('user.emails[0].verified', false); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it("should update a user's bio by userId", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + bio: `edited-bio-test`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.bio', 'edited-bio-test'); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it("should update a user's nickname by userId", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + nickname: `edited-nickname-test`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.nickname', 'edited-nickname-test'); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it(`should return an error when trying to set a nickname longer than ${MAX_NICKNAME_LENGTH} characters`, async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + nickname: Random.hexString(MAX_NICKNAME_LENGTH + 1), + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `Nickname size exceeds ${MAX_NICKNAME_LENGTH} characters [error-nickname-size-exceeded]`); + }); + + it(`should return an error when trying to set a bio longer than ${MAX_BIO_LENGTH} characters`, async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + bio: Random.hexString(MAX_BIO_LENGTH + 1), + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `Bio size exceeds ${MAX_BIO_LENGTH} characters [error-bio-size-exceeded]`); + }); + + it('should return an error when trying to upsert a user by sending an empty userId', () => { + return request + .post(api('users.update')) + .set(credentials) + .send({ + userId: '', + data: {}, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', 'must NOT have fewer than 1 characters'); + }); + }); + + it('should return an error when trying to use the joinDefaultChannels param, which is not intended for updates', () => { + return request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + joinDefaultChannels: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', 'must NOT have additional properties'); + }); + }); + + it("should update a bot's email", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: 'rocket.cat', + data: { email: 'nouser@rocket.cat' }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it("should verify user's email by userId", async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + verified: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].verified', true); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + + it('should return an error when trying update username and it is not allowed', async () => { + await updatePermission('edit-other-user-info', ['user']); + + await updateSetting('Accounts_AllowUsernameChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: 'fake.name', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update the user name when the required permission is applied', async () => { + await Promise.all([updatePermission('edit-other-user-info', ['admin']), updateSetting('Accounts_AllowUsernameChange', false)]); + + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: `fake.name.${Date.now()}`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return an error when trying update user real name and it is not allowed', async () => { + await updatePermission('edit-other-user-info', ['user']); + + await updateSetting('Accounts_AllowRealNameChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + name: 'Fake name', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update user real name when the required permission is applied', async () => { + await updatePermission('edit-other-user-info', ['admin']); + + await updateSetting('Accounts_AllowRealNameChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + name: 'Fake name', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when trying update user status message and it is not allowed', async () => { + await updatePermission('edit-other-user-info', ['user']); + + await updateSetting('Accounts_AllowUserStatusMessageChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + statusMessage: 'a new status', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update user status message when the required permission is applied', async () => { + await updatePermission('edit-other-user-info', ['admin']); + + await updateSetting('Accounts_AllowUserStatusMessageChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + name: 'a new status', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when trying update user email and it is not allowed', async () => { + await updatePermission('edit-other-user-info', ['user']); + + await updateSetting('Accounts_AllowEmailChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + email: 'itsnotworking@email.com', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update user email when the required permission is applied', async () => { + await updatePermission('edit-other-user-info', ['admin']); + + await updateSetting('Accounts_AllowEmailChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + email: apiEmail, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when trying update user password and it is not allowed', async () => { + await updatePermission('edit-other-user-password', ['user']); + + await updateSetting('Accounts_AllowPasswordChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + password: '1tsn0tw0rkingP@ssw0rd1234.!', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update user password when the required permission is applied', async () => { + await updatePermission('edit-other-user-password', ['admin']); + + await updateSetting('Accounts_AllowPasswordChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + password: '1tsn0tw0rkingP@ssw0rd1234.!', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when trying update profile and it is not allowed', async () => { + await updatePermission('edit-other-user-info', ['user']); + + await updateSetting('Accounts_AllowUserProfileChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + verified: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should update profile when the required permission is applied', async () => { + await updatePermission('edit-other-user-info', ['admin']); + + await updateSetting('Accounts_AllowUserProfileChange', false); + + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + verified: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should delete requirePasswordChangeReason when requirePasswordChange is set to false', async () => { + const user = await createUser({ + requirePasswordChange: true, + }); + + await updateUserInDb(user._id, { requirePasswordChangeReason: 'any_data' }); + + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: user._id, + data: { + requirePasswordChange: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('requirePasswordChange', false); + expect(res.body.user).to.not.have.property('requirePasswordChangeReason'); + }); + + await deleteUser(user); + }); + + describe('email verification', () => { + let admin: TestUser; + let userToUpdate: TestUser; + let userCredentials: Credentials; + + beforeEach(async () => { + admin = await createUser({ roles: ['admin'] }); + userToUpdate = await createUser(); + userCredentials = await login(admin.username, password); + }); + + afterEach(async () => { + await deleteUser(userToUpdate); + await deleteUser(admin); + }); + + it("should update user's email verified correctly", async () => { + await request + .post(api('users.update')) + .set(userCredentials) + .send({ + userId: userToUpdate._id, + data: { + verified: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].verified', true); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + }); + + it("should update user's email verified even if email is not changed", async () => { + const res = await request + .post(api('users.update')) + .set(userCredentials) + .send({ + userId: userToUpdate._id, + data: { + email: userToUpdate.emails[0].address, + verified: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.emails[0].verified', true); + expect(res.body).to.not.have.nested.property('user.e2e'); + }); + }); + + function failUpdateUser(name: string) { + it(`should not update an user if the new username is the reserved word ${name}`, async () => { + const res = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: name, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Could not save user identity [error-could-not-save-identity]'); + }); + } + + reservedWords.forEach((name) => { + failUpdateUser(name); + }); + + describe('Custom Fields', () => { + let testUser: TestUser; + + before(async () => { + await setCustomFields({ + customFieldText1: { + type: 'text', + required: false, + }, + customFieldText2: { + type: 'text', + required: false, + }, + }); + }); + + after(async () => { + await clearCustomFields(); + }); + + beforeEach(async () => { + testUser = await createUser(); + }); + + afterEach(async () => { + await deleteUser(testUser); + }); + + it('should merge custom fields instead of replacing them when updating a user', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: 'value1', + customFieldText2: 'value2', + }, + }, + }) + .expect(200); + + const updateResponse = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: 'updated1', + }, + }, + }) + .expect(200); + + expect(updateResponse.body).to.have.property('success', true); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText1', 'updated1'); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText2', 'value2'); + + const userInfoResponse = await request.get(api('users.info')).set(credentials).query({ userId: testUser._id }).expect(200); + + expect(userInfoResponse.body).to.have.property('success', true); + expect(userInfoResponse.body).to.have.nested.property('user.customFields.customFieldText1', 'updated1'); + expect(userInfoResponse.body).to.have.nested.property('user.customFields.customFieldText2', 'value2'); + }); + + it('should preserve existing custom fields when adding new ones', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: 'initial1', + }, + }, + }) + .expect(200); + + const updateResponse = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText2: 'additional2', + }, + }, + }) + .expect(200); + + expect(updateResponse.body).to.have.property('success', true); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText1', 'initial1'); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText2', 'additional2'); + }); + + it('should update custom field with empty string', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: 'value1', + }, + }, + }) + .expect(200); + + const updateResponse = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: '', + }, + }, + }) + .expect(200); + + expect(updateResponse.body).to.have.property('success', true); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText1', ''); + }); + + it('should update custom field with null', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: 'value1', + }, + }, + }) + .expect(200); + + const updateResponse = await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + customFields: { + customFieldText1: null, + }, + }, + }) + .expect(200); + + expect(updateResponse.body).to.have.property('success', true); + expect(updateResponse.body).to.have.nested.property('user.customFields.customFieldText1', null); + }); + }); + + (IS_EE ? describe : describe.skip)('Voice call extension', () => { + let user: TestUser; + + before(async () => { + user = await createUser(); + }); + + after(async () => { + await Promise.all([deleteUser(user), updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', true)]); + }); + + beforeEach(async () => { + await Promise.all([updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', true)]); + }); + + it("should update the user's voice call extension", async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: user._id, + data: { + freeSwitchExtension: '999', + }, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.freeSwitchExtension', '999'); + }); + }); + + it("should not update the user's voice call extension if the extension is already assigned to another user", async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + freeSwitchExtension: '999', + }, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Extension is already assigned to another user [error-extension-not-available]'); + }); + }); + + it("should not update the user's voice call extension if voip setting is disabled", async () => { + await updateSetting('VoIP_TeamCollab_SIP_Integration_Enabled', false); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: user._id, + data: { + freeSwitchExtension: '9998', + }, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Edit user voice call extension is not allowed [error-action-not-allowed]'); + }); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.update')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.updateOwnBasicInfo]', () => { + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(() => + Promise.all([ + deleteUser(user), + updateSetting('E2E_Enable', false), + updateSetting('Accounts_AllowRealNameChange', true), + updateSetting('Accounts_AllowUsernameChange', true), + updateSetting('Accounts_AllowUserStatusMessageChange', true), + updateSetting('Accounts_AllowEmailChange', true), + updateSetting('Accounts_AllowPasswordChange', true), + ]), + ); + + const newPassword = `${password}test`; + const currentPassword = crypto.createHash('sha256').update(password, 'utf8').digest('hex'); + const editedUsername = `basicInfo.name${+new Date()}`; + const editedName = `basic-info-test-name${+new Date()}`; + const editedEmail = `test${+new Date()}@mail.com`; + + it('enabling E2E in server and generating keys to user...', async () => { + await updateSetting('E2E_Enable', true); + + const res = await request + .post(api('e2e.setUserPublicAndPrivateKeys')) + .set(userCredentials) + .send({ + private_key: 'test', + public_key: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should update the user own basic information', async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + name: editedName, + username: editedUsername, + currentPassword, + newPassword, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { user } = res.body; + expect(res.body).to.have.property('success', true); + expect(user.username).to.be.equal(editedUsername); + expect(user.name).to.be.equal(editedName); + expect(user).to.not.have.property('e2e'); + }); + + it('should update the user name only', async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + username: editedUsername, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { user } = res.body; + expect(res.body).to.have.property('success', true); + expect(user.username).to.be.equal(editedUsername); + expect(user).to.not.have.property('e2e'); + }); + + it('should throw an error when user try change email without the password', async () => { + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + email: editedEmail, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error when user try change password without the actual password', async () => { + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + newPassword: '1Tsn3wP@ssw0rd1234.!', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error when the name is only whitespaces', async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + name: ' ', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it("should set new email as 'unverified'", async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + email: editedEmail, + currentPassword: crypto.createHash('sha256').update(newPassword, 'utf8').digest('hex'), + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { user } = res.body; + expect(res.body).to.have.property('success', true); + expect(user.emails[0].address).to.be.equal(editedEmail); + expect(user.emails[0].verified).to.be.false; + expect(user).to.not.have.property('e2e'); + }); + + it("should not include sensitive data on the 'services' object from the response", async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + username: editedUsername, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const { user } = res.body; + expect(res.body).to.have.property('success', true); + expect(user.services).to.not.have.property('passwordHistory'); + expect(user.services).to.not.have.property('email'); + expect(user.services.password).to.have.property('exists').that.is.a('boolean'); + }); + + function failUpdateUserOwnBasicInfo(name: string) { + it(`should not update an user's basic info if the new username is the reserved word ${name}`, async () => { + const res = await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + username: name, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Could not save user identity [error-could-not-save-identity]'); + }); + } + + reservedWords.forEach((name) => { + failUpdateUserOwnBasicInfo(name); + }); + + it('should throw an error if not allowed to change real name', async () => { + await updateSetting('Accounts_AllowRealNameChange', false); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + name: 'edited name', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should throw an error if not allowed to change username', async () => { + await updateSetting('Accounts_AllowUsernameChange', false); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + username: 'edited.user.name', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should throw an error if not allowed to change statusText', async () => { + await updateSetting('Accounts_AllowUserStatusMessageChange', false); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + statusText: 'My custom status', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should throw an error if not allowed to change email', async () => { + await updateSetting('Accounts_AllowEmailChange', false); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + email: 'changed@email.com', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should throw an error if not allowed to change password', async () => { + await updateSetting('Accounts_AllowPasswordChange', false); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(credentials) + .send({ + data: { + newPassword: '1Tsn3wP@ssw0rd1234.!', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should be able to erase bio and nickname', async () => { + const user = await createUser({ + bio: `edited-bio-test`, + nickname: `edited-nickname-test`, + }); + const userCredentials = await login(user.username, password); + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + bio: '', + nickname: '', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const userData = await getUserByUsername(user.username); + expect(userData).to.not.have.property('bio'); + expect(userData).to.not.have.property('nickname'); + }); + + describe('[Password Policy]', () => { + let previousMinLength: Awaited>; + let previousMaxLength: Awaited>; + + before(async () => { + await updateSetting('Accounts_AllowPasswordChange', true); + await updateSetting('Accounts_TwoFactorAuthentication_Enabled', false); + [previousMinLength, previousMaxLength] = await Promise.all([ + getSettingValueById('Accounts_Password_Policy_MinLength'), + getSettingValueById('Accounts_Password_Policy_MaxLength'), + ]); + }); + + after(async () => { + await updateSetting('Accounts_AllowPasswordChange', true); + await updateSetting('Accounts_TwoFactorAuthentication_Enabled', true); + await updateSetting('Accounts_Password_Policy_MaxLength', previousMaxLength); + await updateSetting('Accounts_Password_Policy_MinLength', previousMinLength); + }); + + it('should throw an error if the password length is less than the minimum length', async () => { + const expectedError = { + error: 'error-password-policy-not-met-minLength', + message: 'The password does not meet the minimum length password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: '2', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password length is greater than the maximum length', async () => { + // max must stay >= min, so lower the minimum before capping the maximum at 5 + await updateSetting('Accounts_Password_Policy_MinLength', 1); + await updateSetting('Accounts_Password_Policy_MaxLength', 5); + + const expectedError = { + error: 'error-password-policy-not-met-maxLength', + message: 'The password does not meet the maximum length password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'Abc@12345678', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password contains repeating characters', async () => { + const expectedError = { + error: 'error-password-policy-not-met-repeatingCharacters', + message: 'The password contains repeating characters which is against the password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'A@123aaaa', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password does not contain at least one lowercase character', async () => { + const expectedError = { + error: 'error-password-policy-not-met-oneLowercase', + message: 'The password does not contain at least one lowercase character which is against the password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'PASSWORD@123', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password does not contain at least one uppercase character', async () => { + const expectedError = { + error: 'error-password-policy-not-met-oneUppercase', + message: 'The password does not contain at least one uppercase character which is against the password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'password@123', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password does not contain at least one numerical character', async () => { + const expectedError = { + error: 'error-password-policy-not-met-oneNumber', + message: 'The password does not contain at least one numerical character which is against the password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'Password@', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should throw an error if the password does not contain at least one special character', async () => { + const expectedError = { + error: 'error-password-policy-not-met-oneSpecial', + message: 'The password does not contain at least one special character which is against the password policy.', + }; + + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: 'Password123', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.errorType).to.be.equal('error-password-policy-not-met'); + expect(res.body.details).to.be.an('array').that.deep.includes(expectedError); + }) + .expect(400); + }); + + it('should be able to update if the password meets all the validation rules', async () => { + await updateSetting('Accounts_Password_Policy_MaxLength', -1); + await request + .post(api('users.updateOwnBasicInfo')) + .set(userCredentials) + .send({ + data: { + currentPassword, + newPassword: '1Tsn3wP@ssw0rd1234.!', + }, + }) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + }) + .expect(200); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.updateOwnBasicInfo')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + // TODO check for all response fields + describe('[/users.setPreferences]', () => { + after(() => updatePermission('edit-other-user-info', ['admin'])); + + it('should return an error when the user try to update info of another user and does not have the necessary permission', (done) => { + const userPreferences = { + userId: 'rocket.cat', + data: { + ...preferences.data, + }, + }; + void updatePermission('edit-other-user-info', []).then(() => { + void request + .post(api('users.setPreferences')) + .set(credentials) + .send(userPreferences) + .expect(400) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Editing user is not allowed [error-action-not-allowed]'); + expect(res.body).to.have.property('errorType', 'error-action-not-allowed'); + }) + .end(done); + }); + }); + it('should return an error when the user try to update info of an nonexistent user', (done) => { + const userPreferences = { + userId: 'invalid-id', + data: { + ...preferences.data, + }, + }; + void updatePermission('edit-other-user-info', ['admin', 'user']).then(() => { + void request + .post(api('users.setPreferences')) + .set(credentials) + .send(userPreferences) + .expect(400) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'The optional "userId" param provided does not match any users [error-invalid-user]', + ); + expect(res.body).to.have.property('errorType', 'error-invalid-user'); + }) + .end(done); + }); + }); + it('should set some preferences of another user successfully', (done) => { + const userPreferences = { + userId: 'rocket.cat', + data: { + ...preferences.data, + }, + }; + void updatePermission('edit-other-user-info', ['admin', 'user']).then(() => { + void request + .post(api('users.setPreferences')) + .set(credentials) + .send(userPreferences) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body.user).to.have.property('settings'); + expect(res.body.user.settings).to.have.property('preferences'); + expect(res.body.user._id).to.be.equal('rocket.cat'); + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + it('should set some preferences by user when execute successfully', (done) => { + const userPreferences = { + userId: credentials['X-User-Id'], + data: { + ...preferences.data, + }, + }; + void request + .post(api('users.setPreferences')) + .set(credentials) + .send(userPreferences) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body.user).to.have.property('settings'); + expect(res.body.user.settings).to.have.property('preferences'); + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + it('should set some preferences and language preference by user when execute successfully', (done) => { + const userPreferences = { + userId: credentials['X-User-Id'], + data: { + ...preferences.data, + language: 'en', + }, + }; + void request + .post(api('users.setPreferences')) + .set(credentials) + .send(userPreferences) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.user.settings.preferences).to.have.property('language', 'en'); + }) + .end(done); + }); + + it('should persist the utcOffset preference on the user document', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 5 } }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('me')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('utcOffset', 5); + }); + }); + + it('should fail when utcOffset is not a number', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 'not-a-number' } }) + .expect(400) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.getPreferences]', () => { + it('should return all preferences when execute successfully', (done) => { + const userPreferences = { + ...preferences.data, + language: 'en', + }; + void request + .get(api('users.getPreferences')) + .set(credentials) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body.preferences).to.be.eql(userPreferences); + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.forgotPassword]', () => { + it('should return an error when "Accounts_PasswordReset" is disabled', async () => { + await updateSetting('Accounts_PasswordReset', false); + + const res = await request + .post(api('users.forgotPassword')) + .send({ + email: adminEmail, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Password reset is not enabled'); + }); + + it('should send email to user (return success), when is a valid email', async () => { + await updateSetting('Accounts_PasswordReset', true); + + const res = await request + .post(api('users.forgotPassword')) + .send({ + email: adminEmail, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should not send email to user(return error), when is a invalid email', async () => { + const res = await request + .post(api('users.forgotPassword')) + .send({ + email: 'invalidEmail', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should return an error when email is missing', async () => { + const res = await request.post(api('users.forgotPassword')).send({}).expect('Content-Type', 'application/json').expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email'"); + }); + }); + + describe('[/users.sendConfirmationEmail]', () => { + [ + { description: 'authenticated + known email', auth: true, email: adminEmail }, + { description: 'unauthenticated + known email', auth: false, email: adminEmail }, + { description: 'unauthenticated + unknown email', auth: false, email: 'nobody@example.invalid' }, + ].forEach(({ description, auth, email }) => { + it(`should return 200 success for ${description}`, async () => { + const req = request.post(api('users.sendConfirmationEmail')).send({ email }); + if (auth) { + req.set(credentials); + } + await req + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.sendConfirmationEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.getUsernameSuggestion]', () => { + const testUsername = `test${+new Date()}`; + let targetUser: TestUser; + let userCredentials: Credentials; + + before(async () => { + targetUser = await registerUser( + { + email: `${testUsername}.@test.com`, + username: `${testUsername}test`, + name: testUsername, + pass: password, + }, + null, + ); + userCredentials = await login(targetUser.username, password); + }); + + after(() => deleteUser(targetUser)); + + it('should return an username suggestion', async () => { + const res = await request + .get(api('users.getUsernameSuggestion')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.exist; + }); + + it('should return 401 when not authenticated', async () => { + const res = await request.get(api('users.getUsernameSuggestion')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + describe('[/users.checkUsernameAvailability]', () => { + let targetUser: TestUser; + let userCredentials: Credentials; + + before(async () => { + targetUser = await registerUser(undefined, null); + userCredentials = await login(targetUser.username, password); + }); + + after(() => deleteUser(targetUser)); + + it('should return 401 unauthorized when user is not logged in', async () => { + const res = await request.get(api('users.checkUsernameAvailability')).expect('Content-Type', 'application/json').expect(401); + + expect(res.body).to.have.property('message'); + }); + + it('should return true if the username is the same user username set', async () => { + const res = await request + .get(api('users.checkUsernameAvailability')) + .set(userCredentials) + .query({ + username: targetUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.result).to.be.equal(true); + }); + + it('should return true if the username is available', async () => { + const res = await request + .get(api('users.checkUsernameAvailability')) + .set(userCredentials) + .query({ + username: `${targetUser.username}-${+new Date()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.result).to.be.equal(true); + }); + + it('should return an error when the username is invalid', async () => { + const res = await request + .get(api('users.checkUsernameAvailability')) + .set(userCredentials) + .query({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + + describe('[/users.deleteOwnAccount]', () => { + let targetUser: TestUser; + let userCredentials: Credentials; + + before(async () => { + targetUser = await registerUser(undefined, null); + userCredentials = await login(targetUser.username, password); + }); + + after(async () => deleteUser(targetUser)); + + it('Enable "Accounts_AllowDeleteOwnAccount" setting...', (done) => { + void request + .post('/api/v1/settings/Accounts_AllowDeleteOwnAccount') + .set(credentials) + .send({ value: true }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(wait(done, 200)); + }); + + it('should delete user own account', async () => { + const res = await request + .post(api('users.deleteOwnAccount')) + .set(userCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + + it('should delete user own account when the SHA256 hash is in upper case', async () => { + const user = await createUser(); + const createdUserCredentials = await login(user.username, password); + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await deleteUser(user); + }); + + describe('last owner cases', () => { + let user: TestUser; + let createdUserCredentials: Credentials; + let room: IRoom; + + beforeEach(async () => { + user = await createUser(); + createdUserCredentials = await login(user.username, password); + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: user.username, + members: [user.username], + }) + ).body.channel; + await addRoomOwner({ type: 'c', roomId: room._id, userId: user._id }); + await removeRoomOwner({ type: 'c', roomId: room._id, userId: credentials['X-User-Id'] }); + }); + + afterEach(async () => { + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); + }); + + it('should return an error when trying to delete user own account if user is the last room owner', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); + + it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should assign a new owner to the room if the last room owner is deleted', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex'), + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roles = await getChannelRoles({ roomId: room._id }); + + expect(roles).to.have.lengthOf(1); + expect(roles[0].roles).to.eql(['owner']); + expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.deleteOwnAccount')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.delete]', () => { + let newUser: TestUser; + + before(async () => { + newUser = await createUser(); + }); + + after(async () => { + await deleteUser(newUser); + await updatePermission('delete-user', ['admin']); + }); + + it('should return an error when trying delete user account without "delete-user" permission', async () => { + await updatePermission('delete-user', ['user']); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should delete user account when logged user has "delete-user" permission', async () => { + await updatePermission('delete-user', ['admin']); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: newUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + describe('last owner cases', () => { + let targetUser: TestUser; + let room: IRoom; + beforeEach(async () => { + targetUser = await registerUser(undefined, null); + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + await addRoomOwner({ type: 'c', roomId: room._id, userId: targetUser._id }); + await removeRoomOwner({ type: 'c', roomId: room._id, userId: credentials['X-User-Id'] }); + }); + + afterEach(() => Promise.all([deleteRoom({ type: 'c', roomId: room._id }), deleteUser(targetUser, { confirmRelinquish: true })])); + + it('should return an error when trying to delete user account if the user is the last room owner', async () => { + await updatePermission('delete-user', ['admin']); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); + + it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { + await updatePermission('delete-user', ['admin']); + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should assign a new owner to the room if the last room owner is deleted', async () => { + await updatePermission('delete-user', ['admin']); + + await request + .post(api('users.delete')) + .set(credentials) + .send({ + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roles = await getChannelRoles({ roomId: room._id }); + + expect(roles).to.have.lengthOf(1); + expect(roles[0].roles).to.eql(['owner']); + expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); + }); + }); + }); + + describe('Personal Access Tokens', () => { + const tokenName = `${Date.now()}token`; + describe('successful cases', () => { + before(() => updatePermission('create-personal-access-tokens', ['admin'])); + after(() => updatePermission('create-personal-access-tokens', ['admin'])); + + it('should accept loginToken when we call /me', async () => { + let loginToken = ''; + await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({ + tokenName: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('token'); + loginToken = res.body.token; + }); + + await request + .get(api('me')) // it does not really matter what we call here, we just want to test that the loginToken is accepted + .set({ + 'X-Auth-Token': loginToken, + 'X-User-Id': credentials['X-User-Id'], + }) + .expect(200); + }); + describe('[/users.getPersonalAccessTokens]', () => { + it('should return an array when the user does not have personal tokens configured', async () => { + const res = await request + .get(api('users.getPersonalAccessTokens')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('tokens').and.to.be.an('array'); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPersonalAccessTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.generatePersonalAccessToken]', () => { + it('should return a personal access token to user', async () => { + const res = await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('token'); + }); + it('should throw an error when user tries generate a token with the same name', async () => { + const res = await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('[/users.regeneratePersonalAccessToken]', () => { + it('should return a personal access token to user when user regenerates the token', async () => { + const res = await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('token'); + }); + it('should throw an error when user tries regenerate a token that does not exist', async () => { + const res = await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({ + tokenName: 'tokenthatdoesnotexist', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('[/users.getPersonalAccessTokens]', () => { + it('should return my personal access tokens', async () => { + const res = await request + .get(api('users.getPersonalAccessTokens')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('tokens').and.to.be.an('array'); + }); + }); + describe('[/users.removePersonalAccessToken]', () => { + it('should return success when user remove a personal access token', async () => { + const res = await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + }); + it('should throw an error when user tries remove a token that does not exist', async () => { + const res = await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({ + tokenName: 'tokenthatdoesnotexist', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + describe('unsuccessful cases', () => { + before(() => updatePermission('create-personal-access-tokens', [])); + after(() => updatePermission('create-personal-access-tokens', ['admin'])); + + describe('should return an error when the user dont have the necessary permission "create-personal-access-tokens"', () => { + it('/users.generatePersonalAccessToken', async () => { + const res = await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('not-authorized'); + }); + it('/users.regeneratePersonalAccessToken', async () => { + const res = await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('not-authorized'); + }); + it('/users.getPersonalAccessTokens', async () => { + const res = await request + .get(api('users.getPersonalAccessTokens')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + it('/users.removePersonalAccessToken', async () => { + const res = await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({ + tokenName, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('not-authorized'); + }); + it('should throw an error when user tries remove a token that does not exist', async () => { + const res = await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({ + tokenName: 'tokenthatdoesnotexist', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('not-authorized'); + }); + }); + }); + }); + + describe('[/users.setActiveStatus]', () => { + let user: TestUser; + let agent: IUserWithCredentials; + let agentUser: TestUser; + let userCredentials: Credentials; + + before(async () => { + agentUser = await createUser(); + const agentUserCredentials = await login(agentUser.username, password); + await createAgent(agentUser.username); + await makeAgentAvailable(agentUserCredentials); + + agent = { + user: agentUser, + credentials: agentUserCredentials, + }; + }); + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + await Promise.all([ + updatePermission('edit-other-user-active-status', ['admin', 'user']), + updatePermission('manage-moderation-actions', ['admin']), + ]); + }); + + after(() => + Promise.all([ + deleteUser(user), + updatePermission('edit-other-user-active-status', ['admin']), + updatePermission('manage-moderation-actions', ['admin']), + ]), + ); + + after(() => Promise.all([removeAgent(agent.user._id), deleteUser(agent.user)])); + + it('should set other user active status to false when the logged user has the necessary permission(edit-other-user-active-status)', async () => { + const res = await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.active', false); + }); + it('should set other user active status to true when the logged user has the necessary permission(edit-other-user-active-status)', async () => { + const res = await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: true, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.active', true); + }); + + it('should return an error when trying to set other user active status and has not the necessary permission(edit-other-user-active-status)', async () => { + await updatePermission('edit-other-user-active-status', []); + + const res = await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + it('should return an error when trying to set user own active status and has not the necessary permission(edit-other-user-active-status)', async () => { + const res = await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + it('should set user own active status to false when the user has the necessary permission(edit-other-user-active-status)', async () => { + await updatePermission('edit-other-user-active-status', ['admin']); + + const res = await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + it('users should retain their roles when they are deactivated', async () => { + const testUser = await createUser({ roles: ['user', 'livechat-agent'] }); + + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({ + activeStatus: false, + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const user = await getUserByUsername(testUser.username); + expect(user).to.have.property('roles'); + expect(user.roles).to.be.an('array').of.length(2); + expect(user.roles).to.include('user', 'livechat-agent'); + + await deleteUser(testUser); + }); + + it('should make agents not-available when the user is deactivated', async () => { + await makeAgentAvailable(agent.credentials); + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({ + activeStatus: false, + userId: agent.user._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const agentInfo = await getAgent(agent.user._id); + expect(agentInfo).to.have.property('statusLivechat', 'not-available'); + }); + + it('should not make agents available when the user is activated', async () => { + let agentInfo = await getAgent(agent.user._id); + expect(agentInfo).to.have.property('statusLivechat', 'not-available'); + + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({ + activeStatus: true, + userId: agent.user._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + agentInfo = await getAgent(agent.user._id); + expect(agentInfo).to.have.property('statusLivechat', 'not-available'); + }); + + describe('last owner cases', () => { + let room: IRoom; + + beforeEach(() => + Promise.all([ + updatePermission('edit-other-user-active-status', ['admin', 'user']), + updatePermission('manage-moderation-actions', ['admin', 'user']), + ]), + ); + + afterEach(() => deleteRoom({ type: 'c', roomId: room._id })); + + it('should return an error when trying to set other user status to inactive and the user is the last owner of a room', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: targetUser.username, + members: [targetUser.username], + }) + ).body.channel; + + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', '[user-last-owner]'); + expect(res.body).to.have.property('errorType', 'user-last-owner'); + }); + }); + + it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + username: targetUser.username, + members: [targetUser.username], + }) + ).body.channel; + + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { + room = ( + await createRoom({ + type: 'c', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [targetUser.username], + }) + ).body.channel; + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: true, + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + await inviteToChannel({ userId: targetUser._id, roomId: room._id }); + await addRoomOwner({ type: 'c', userId: targetUser._id, roomId: room._id }); + await removeRoomOwner({ type: 'c', userId: credentials['X-User-Id'], roomId: room._id }); + + await request + .post(api('users.setActiveStatus')) + .set(userCredentials) + .send({ + activeStatus: false, + userId: targetUser._id, + confirmRelinquish: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roles = await getChannelRoles({ roomId: room._id }); + + expect(roles).to.have.lengthOf(2); + const originalCreator = roles.find((role) => role.u._id === credentials['X-User-Id']); + assert.isDefined(originalCreator); + expect(originalCreator.roles).to.eql(['owner']); + expect(originalCreator.u).to.have.property('_id', credentials['X-User-Id']); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.deactivateIdle]', () => { + let testUser: TestUser; + const testRoleId = 'guest'; + + before('Create test user', async () => { + testUser = await createUser(); + await request + .post(api('roles.addUserToRole')) + .set(credentials) + .send({ + roleId: testRoleId, + username: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + after(() => Promise.all([deleteUser(testUser), updatePermission('edit-other-user-active-status', ['admin'])])); + + it('should fail to deactivate if user doesnt have edit-other-user-active-status permission', async () => { + await updatePermission('edit-other-user-active-status', []); + + const res = await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ + daysIdle: 0, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + it('should deactivate no users when no users in time range', async () => { + await updatePermission('edit-other-user-active-status', ['admin']); + + const res = await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ + daysIdle: 999999, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 0); + }); + it('should deactivate the test user when given its role and daysIdle = 0', async () => { + await updatePermission('edit-other-user-active-status', ['admin']); + + const res = await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ + daysIdle: 0, + role: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 1); + }); + it('should not deactivate the test user again when given its role and daysIdle = 0', async () => { + await updatePermission('edit-other-user-active-status', ['admin']); + + const res = await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ + daysIdle: 0, + role: testRoleId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 0); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should revoke login tokens of deactivated idle users', async () => { + const idleUser = await createUser(); + await request.post(api('roles.addUserToRole')).set(credentials).send({ roleId: testRoleId, username: idleUser.username }).expect(200); + + const idleUserCredentials = await login(idleUser.username, password); + await request.get(api('me')).set(idleUserCredentials).expect(200); + + await updatePermission('edit-other-user-active-status', ['admin']); + await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({ daysIdle: 0, role: testRoleId }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').that.is.greaterThan(0); + }); + + await request.get(api('me')).set(idleUserCredentials).expect(401); + + await deleteUser(idleUser); + }); + }); + + describe('[/users.requestDataDownload]', () => { + it('should return the request data with fullExport false when no query parameter was send', async () => { + const res = await request + .get(api('users.requestDataDownload')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('requested'); + expect(res.body).to.have.property('exportOperation').and.to.be.an('object'); + expect(res.body.exportOperation).to.have.property('fullExport', false); + }); + it('should return the request data with fullExport false when the fullExport query parameter is false', async () => { + const res = await request + .get(api('users.requestDataDownload')) + .query({ fullExport: 'false' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('requested'); + expect(res.body).to.have.property('exportOperation').and.to.be.an('object'); + expect(res.body.exportOperation).to.have.property('fullExport', false); + }); + it('should return the request data with fullExport true when the fullExport query parameter is true', async () => { + const res = await request + .get(api('users.requestDataDownload')) + .query({ fullExport: 'true' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('requested'); + expect(res.body).to.have.property('exportOperation').and.to.be.an('object'); + expect(res.body.exportOperation).to.have.property('fullExport', true); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.requestDataDownload')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.logoutOtherClients]', function () { + let user: TestUser; + let userCredentials: Credentials; + let newCredentials: Credentials; + + this.timeout(20000); + + before(async () => { + user = await createUser({ joinDefaultChannels: false }); + userCredentials = await login(user.username, password); + newCredentials = await login(user.username, password); + }); + after(() => deleteUser(user)); + + it('should invalidate all active sesions', (done) => { + /* We want to validate that the login with the "old" credentials fails + However, the removal of the tokens is done asynchronously. + Thus, we check that within the next seconds, at least one try to + access an authentication requiring route fails */ + let counter = 0; + + async function checkAuthenticationFails() { + const result = await request.get(api('me')).set(userCredentials); + return result.statusCode === 401; + } + + async function tryAuthentication() { + if (await checkAuthenticationFails()) { + done(); + } else if (++counter < 20) { + setTimeout(tryAuthentication, 1000); + } else { + done('Session did not invalidate in time'); + } + } + + void request + .post(api('users.logoutOtherClients')) + .set(newCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('token').and.to.be.a('string'); + expect(res.body).to.have.property('tokenExpires').and.to.be.a('string'); + }) + .then(tryAuthentication); + }); + + it('should remove only logged out session push tokens', async () => { + const credentials1 = await login(user.username, password); + const credentials2 = await login(user.username, password); + + await request + .post(api('push.token')) + .set(credentials1) + .send({ + type: 'gcm', + value: 'device-1-token', + appName: 'com.example.device1', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('push.token')) + .set(credentials2) + .send({ + type: 'gcm', + value: 'device-2-token', + appName: 'com.example.device2', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('users.logoutOtherClients')) + .set(credentials2) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await request + .delete(api('push.token')) + .set(credentials2) + .send({ + token: 'device-1-token', + }) + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + await request + .delete(api('push.token')) + .set(credentials2) + .send({ + token: 'device-2-token', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logoutOtherClients')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/api/v1/logout]', () => { + let user: TestUser; + + before(async () => { + user = await createUser(); + }); + + after(() => deleteUser(user)); + + it('should logout the current user and invalidate the session', async () => { + const userCredentials = await login(user.username, password); + + await request.post(api('logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200); + + const meRes = await request.get(api('me')).set(userCredentials); + expect(meRes.statusCode).to.equal(401); + }); + + (IS_EE ? it : it.skip)('should remove the session from the list after logout', async () => { + const credentials = await login(user.username, password); + const authToken = credentials['X-Auth-Token']; + + await request.post(api('login')).send({ resume: authToken }).expect(200); + + const sessionsBefore = await request.get(api('sessions/list')).set(credentials).expect(200); + expect(sessionsBefore.body.sessions).to.have.lengthOf(1); + const sessionIdBefore = sessionsBefore.body.sessions[0]._id; + + await request.post(api('logout')).set(credentials).expect(200); + + const newCredentials = await login(user.username, password); + const newAuthToken = newCredentials['X-Auth-Token']; + + await request.post(api('login')).send({ resume: newAuthToken }).expect(200); + + const sessionsAfter = await request.get(api('sessions/list')).set(newCredentials).expect(200); + expect(sessionsAfter.body.sessions).to.have.lengthOf(1); + expect(sessionsAfter.body.sessions[0]._id).to.not.equal(sessionIdBefore); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('logout')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.autocomplete]', () => { + after(() => updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user'])); + + describe('[without permission]', function () { + let user: TestUser; + let userCredentials: Credentials; + let user2: TestUser; + let user2Credentials: Credentials; + let roomId: IRoom['_id']; + + this.timeout(20000); + + before(async () => { + const users = await Promise.all([createUser({ joinDefaultChannels: false }), createUser({ joinDefaultChannels: false })]); + + user = users[0]; + user2 = users[1]; + + const credentials = await Promise.all([ + login(user.username, password), + login(user2.username, password), + await updatePermission('view-outside-room', []), + ]); + + userCredentials = credentials[0]; + user2Credentials = credentials[1]; + + roomId = (await createRoom({ type: 'c', credentials: userCredentials, name: `channel.autocomplete.${Date.now()}` })).body.channel + ._id; + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'c', roomId }), deleteUser(user), deleteUser(user2)]); + }); + + it('should return an empty list when the user does not have any subscription', async () => { + const res = await request + .get(api('users.autocomplete')) + .query({ selector: '{}' }) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array').that.has.lengthOf(0); + }); + + it('should return users that are subscribed to the same rooms as the requester', async () => { + await joinChannel({ overrideCredentials: user2Credentials, roomId }); + + void request + .get(api('users.autocomplete')) + .query({ selector: '{}' }) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array').with.lengthOf(1); + }); + }); + }); + + describe('[with permission]', () => { + before(async () => updatePermission('view-outside-room', ['admin', 'user'])); + + it('should return an error when the required parameter "selector" is not provided', async () => { + const res = await request + .get(api('users.autocomplete')) + .query({}) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + it('should return the users to fill auto complete', async () => { + const res = await request + .get(api('users.autocomplete')) + .query({ selector: '{}' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array'); + }); + + (IS_EE ? it : it.skip)('should return users filtered by freeSwitchExtension and display it', async () => { + const user = await createUser({ joinDefaultChannels: false, freeSwitchExtension: '1234567890' }); + await request + .get(api('users.autocomplete')) + .query({ selector: JSON.stringify({ conditions: { freeSwitchExtension: '1234567890' } }) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array').with.lengthOf(1); + expect(res.body.items[0]).to.have.property('freeSwitchExtension', '1234567890'); + }); + + await deleteUser(user); + }); + + it('should filter results when using allowed operators', async () => { + const res = await request + .get(api('users.autocomplete')) + .set(credentials) + .query({ + selector: JSON.stringify({ + conditions: { + $and: [ + { + active: false, + }, + { + status: 'online', + }, + ], + }, + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').and.to.be.an('array').with.lengthOf(0); + }); + + it('should return an error when using forbidden operators', async () => { + const res = await request + .get(api('users.autocomplete')) + .set(credentials) + .query({ + selector: JSON.stringify({ + conditions: { + $nor: [ + { + username: { + $exists: false, + }, + }, + { + status: { + $exists: false, + }, + }, + ], + }, + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.autocomplete')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.getStatus]', () => { + it('should return my own status', async () => { + const res = await request.get(api('users.getStatus')).set(credentials).expect('Content-Type', 'application/json').expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', credentials['X-User-Id']); + expect(res.body).to.have.property('status'); + }); + it('should return other user status', async () => { + const res = await request + .get(api('users.getStatus')) + .query({ userId: 'rocket.cat' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('_id', 'rocket.cat'); + expect(res.body).to.have.property('status'); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.setStatus]', () => { + let user: TestUser; + + before(async () => { + user = await createUser(); + }); + after(() => Promise.all([deleteUser(user), updateSetting('Accounts_AllowUserStatusMessageChange', true)])); + + it('should return an error when the setting "Accounts_AllowUserStatusMessageChange" is disabled', async () => { + await updateSetting('Accounts_AllowUserStatusMessageChange', false); + + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + message: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + expect(res.body.error).to.be.equal('Change status is not allowed [error-not-allowed]'); + }); + it('should update my own status', async () => { + await updateSetting('Accounts_AllowUserStatusMessageChange', true); + + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + message: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + void getUserStatus(credentials['X-User-Id']).then((status) => expect(status.status).to.be.equal('busy')); + }); + it('should return an error when trying to update other user status without the required permission', async () => { + await updatePermission('edit-other-user-info', []); + + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + message: 'test', + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('unauthorized'); + }); + it('should update another user status succesfully', async () => { + await updatePermission('edit-other-user-info', ['admin']); + + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + message: 'test', + userId: user._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + void getUserStatus(credentials['X-User-Id']).then((status) => { + expect(status.status).to.be.equal('busy'); + }); + }); + it('should return an error when the user try to update user status with an invalid status', async () => { + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'invalid', + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.error).to.include('must be equal to one of the allowed values'); + }); + it('should return an error when user changes status to offline and "Accounts_AllowInvisibleStatusOption" is disabled', async () => { + await updateSetting('Accounts_AllowInvisibleStatusOption', false); + + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'offline', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-status-not-allowed'); + expect(res.body.error).to.be.equal('Invisible status is disabled [error-status-not-allowed]'); + }); + + await updateSetting('Accounts_AllowInvisibleStatusOption', true); + }); + it('should reject a message-only update when status resolves to offline via statusDefault and "Accounts_AllowInvisibleStatusOption" is disabled', async () => { + await updateSetting('Accounts_AllowInvisibleStatusOption', true); + await request.post(api('users.setStatus')).set(credentials).send({ status: 'offline' }).expect(200); + await updateSetting('Accounts_AllowInvisibleStatusOption', false); + + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ message: 'still trying to stay invisible' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-status-not-allowed'); + }); + + await updateSetting('Accounts_AllowInvisibleStatusOption', true); + }); + it('should return an error when the payload is missing all supported fields', async () => { + const res = await request + .post(api('users.setStatus')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('Match error: Failed Match.OneOf, Match.Maybe or Match.Optional validation'); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should set status with expiresAt and return statusSource and statusExpiresAt in getStatus', async () => { + const expiresAt = new Date(Date.now() + 3600_000).toISOString(); + + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + message: 'focus time', + expiresAt, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const status = await getUserStatus(credentials['X-User-Id']); + // display status is offline because the test user has no active DDP session; + // the busy claim is persisted in statusDefault and takes effect on reconnect + expect(status.status).to.be.equal('offline'); + expect(status).to.have.property('statusSource', 'manual'); + expect(status).to.have.property('statusExpiresAt'); + expect(new Date(status.statusExpiresAt!).getTime()).to.be.closeTo(new Date(expiresAt).getTime(), 2000); + }); + + it('should reject a past expiresAt date', async () => { + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'busy', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-date'); + expect(res.body.error).to.be.equal('expiresAt must be a future date [error-invalid-date]'); + }); + }); + + it('should not return statusExpiresAt when expiresAt is not set', async () => { + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + status: 'online', + }) + .expect(200); + + const status = await getUserStatus(credentials['X-User-Id']); + expect(status.status).to.be.equal('online'); + expect(status).to.not.have.property('statusExpiresAt'); + }); + + it('should update only the status message when no status is provided', async () => { + await updateSetting('Accounts_AllowUserStatusMessageChange', true); + + await request + .post(api('users.setStatus')) + .set(credentials) + .send({ + message: 'message only, no status', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const infoResponse = await request.get(api('users.info')).query({ userId: credentials['X-User-Id'] }).set(credentials).expect(200); + expect(infoResponse.body.user).to.have.property('statusText', 'message only, no status'); + }); + }); + + describe('[/users.removeOtherTokens]', () => { + let user: TestUser; + let userCredentials: Credentials; + let newCredentials: Credentials; + + before(async () => { + user = await createUser(); + userCredentials = await login(user.username, password); + newCredentials = await login(user.username, password); + }); + after(() => deleteUser(user)); + + it('should invalidate all active sesions', (done) => { + /* We want to validate that the login with the "old" credentials fails + However, the removal of the tokens is done asynchronously. + Thus, we check that within the next seconds, at least one try to + access an authentication requiring route fails */ + let counter = 0; + + async function checkAuthenticationFails() { + const result = await request.get(api('me')).set(userCredentials); + return result.statusCode === 401; + } + + async function tryAuthentication() { + if (await checkAuthenticationFails()) { + done(); + } else if (++counter < 20) { + setTimeout(tryAuthentication, 1000); + } else { + done('Session did not invalidate in time'); + } + } + + void request.post(api('users.removeOtherTokens')).set(newCredentials).expect(200).then(tryAuthentication); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removeOtherTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.listTeams]', () => { + const teamName1 = `team-name-${Date.now()}`; + const teamName2 = `team-name-2-${Date.now()}`; + let testUser: TestUser; + + before('create team 1', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName1, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + }) + .end(done); + }); + + before('create team 2', (done) => { + void request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName2, + type: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('team'); + expect(res.body).to.have.nested.property('team._id'); + }) + .end(done); + }); + + before('create new user', async () => { + testUser = await createUser({ joinDefaultChannels: false }); + }); + + before('add test user to team 1', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: teamName1, + members: [ + { + userId: testUser._id, + roles: ['member'], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => done()); + }); + + before('add test user to team 2', (done) => { + void request + .post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: teamName2, + members: [ + { + userId: testUser._id, + roles: ['member', 'owner'], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => done()); + }); + + after(() => Promise.all([...[teamName1, teamName2].map((team) => deleteTeam(credentials, team)), deleteUser(testUser)])); + + it('should list both channels', async () => { + const res = await request + .get(api('users.listTeams')) + .set(credentials) + .query({ + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('teams'); + + const { teams } = res.body; + + expect(teams).to.have.length(2); + expect(teams[0].isOwner).to.not.be.eql(teams[1].isOwner); + }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.listTeams')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when query is empty', async () => { + await request + .get(api('users.listTeams')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.logout]', () => { + let user: TestUser; + let otherUser: TestUser; + let userCredentials: Credentials; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(() => Promise.all([deleteUser(user), deleteUser(otherUser), updatePermission('logout-other-user', ['admin'])])); + + it('should throw unauthorized error to user w/o "logout-other-user" permission', async () => { + await updatePermission('logout-other-user', []); + + await request + .post(api('users.logout')) + .set(credentials) + .send({ userId: otherUser._id }) + .expect('Content-Type', 'application/json') + .expect(403); + }); + + it('should logout other user', async () => { + await updatePermission('logout-other-user', ['admin']); + + await request + .post(api('users.logout')) + .set(credentials) + .send({ userId: otherUser._id }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should logout the requester', (done) => { + void updatePermission('logout-other-user', []).then(() => { + void request.post(api('users.logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200).end(done); + }); + }); + + it('should remove all push tokens when user logs out', async () => { + const testCredentials = await login(user.username, password); + + await request + .post(api('push.token')) + .set(testCredentials) + .send({ + type: 'gcm', + value: 'logout-test-token', + appName: 'com.example.logout.test', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('users.logout')) + .set(testCredentials) + .send({ + userId: user._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const newCredentials = await login(user.username, password); + + await request + .delete(api('push.token')) + .set(newCredentials) + .send({ + token: 'logout-test-token', + }) + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logout')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.listByStatus]', () => { + let user: TestUser; + let otherUser: TestUser; + let otherUserCredentials: Credentials; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + otherUserCredentials = await login(otherUser.username, password); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + await updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); + await updatePermission('view-d-room', ['admin', 'owner', 'moderator', 'user']); + }); + + it('should list pending users', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: false, type: 'user', count: 50 }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should list all users', async () => { + await request + .get(api('users.listByStatus')) + .query({ searchTerm: user.name }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should list active users', async () => { + await login(user.username, password); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: true, status: 'active', searchTerm: user.name }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by role', async () => { + await login(user.username, password); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ 'roles[]': 'admin' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.not.include(user._id); + }); + }); + + it('should list deactivated users', async () => { + await request.post(api('users.setActiveStatus')).set(credentials).send({ + userId: user._id, + activeStatus: false, + confirmRelinquish: false, + }); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: true, status: 'deactivated' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by username', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ searchTerm: user.username }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body as PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should return error for invalid status params', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'abcd' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.be.equal('must be equal to one of the allowed values'); + }); + }); + + it('should throw unauthorized error to user without "view-d-room" permission', async () => { + await updatePermission('view-d-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should throw unauthorized error to user without "view-outside-room" permission', async () => { + await updatePermission('view-outside-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/users.sendWelcomeEmail]', async () => { + let user: TestUser; + let otherUser: TestUser; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + }); + + it('should send Welcome Email to user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail to send Welcome Email due to SMTP settings missing', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('SMTP is not configured [error-email-send-failed]'); + }); + }); + + it('should fail to send Welcome Email due to missing param', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email'"); + }); + }); + + it('should fail to send Welcome Email due missing user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: 'fake_user32132131231@rocket.chat' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-user'); + expect(res.body).to.have.property('error', 'Invalid user [error-invalid-user]'); + }); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.sendWelcomeEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.createToken]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.createToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is missing required fields', async () => { + await request + .post(api('users.createToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetE2EKey]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetE2EKey')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetE2EKey')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetTOTP]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetTOTP')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetTOTP')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.2fa.enableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.enableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.disableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.disableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.sendEmailCode]', () => { + it('should return 400 when emailOrUsername is missing', async () => { + await request + .post(api('users.2fa.sendEmailCode')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.verifyEmail]', () => { + it('should fail with 400 when the token is not provided', () => request.post(api('users.verifyEmail')).send({}).expect(400)); + + it('should fail with 403 when the token does not match any user', () => + request + .post(api('users.verifyEmail')) + .send({ token: 'this-token-does-not-exist' }) + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + })); + + describe('when a valid token is provided', () => { + let user: TestUser; + const email = `verify.email.${Date.now()}@rocket.chat`; + const token = `valid-verification-token-${Date.now()}`; + + before(async () => { + user = await createUser({ email, verified: false }); + // The verification token is never exposed by any endpoint, so seed a known one directly. + await updateUserInDb(user._id, { + 'emails.0.verified': false, + 'services.email.verificationTokens': [{ token, address: email, when: new Date() }], + } as unknown as Partial); + }); + + after(() => deleteUser(user)); + + it('should verify the email and consume the token', async () => { + await request + .post(api('users.verifyEmail')) + .send({ token }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const updatedUser = await getUserByUsername(user.username); + expect(updatedUser.emails[0]).to.have.property('verified', true); + + // The token was pulled on success, so reusing it must now be rejected. + await request + .post(api('users.verifyEmail')) + .send({ token }) + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); + + describe('[TOTP endpoints]', () => { + let totpUser: TestUser; + let totpCredentials: Credentials; + let secret: string; + + // speakeasy verify is stateless, so the same code is accepted repeatedly within its window — safe to reuse across the flow + const totpCode = () => speakeasy.totp({ secret, encoding: 'base32' }); + + // enableTotp/validateTotp carry `twoFactorRequired` to protect users with an existing 2FA method, but the + // API e2e suite runs with TEST_MODE which bypasses the 2FA challenge (see checkCodeForUser), so these tests + // exercise the endpoint logic for a fresh user without a challenge. + before(async () => { + totpUser = await createUser({ username: Random.id(), email: `${Random.id()}@example.com`, verified: true }); + totpCredentials = await login(totpUser.username, password); + }); + + after(async () => deleteUser(totpUser)); + + describe('[/users.enableTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.enableTotp')).expect(401)); + + it('should return a secret and an otpauth url', () => + request + .post(api('users.enableTotp')) + .set(totpCredentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('secret').that.is.a('string'); + expect(res.body) + .to.have.property('url') + .that.is.a('string') + .and.match(/^otpauth:\/\//); + secret = res.body.secret; + })); + }); + + describe('[/users.validateTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.validateTotp')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.validateTotp')).set(totpCredentials).send({}).expect(400)); + + it('should enable totp and return backup codes for a valid code', () => + request + .post(api('users.validateTotp')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('codes').that.is.an('array'); + })); + }); + + describe('[/users.totpCodesRemaining]', () => { + it('should fail when unauthenticated', () => request.get(api('users.totpCodesRemaining')).expect(401)); + + it('should return the number of remaining backup codes', () => + request + .get(api('users.totpCodesRemaining')) + .set(totpCredentials) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('remaining').that.is.a('number'); + })); + }); + + describe('[/users.regenerateTotpCodes]', () => { + it('should fail when unauthenticated', () => request.post(api('users.regenerateTotpCodes')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.regenerateTotpCodes')).set(totpCredentials).send({}).expect(400)); + + it('should fail with 400 when the code is invalid', () => + request + .post(api('users.regenerateTotpCodes')) + .set(totpCredentials) + .send({ code: '000000' }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + })); + + it('should return a fresh set of backup codes for a valid code', () => + request + .post(api('users.regenerateTotpCodes')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('codes').that.is.an('array'); + })); + }); + + describe('[/users.disableTotp]', () => { + it('should fail when unauthenticated', () => request.post(api('users.disableTotp')).send({ code: '000000' }).expect(401)); + + it('should fail with 400 when the code is missing', () => + request.post(api('users.disableTotp')).set(totpCredentials).send({}).expect(400)); + + it('should disable totp for a valid code', () => + request + .post(api('users.disableTotp')) + .set(totpCredentials) + .send({ code: totpCode() }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('disabled', true); + })); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/webdav.ts b/apps/meteor/tests/end-to-end/api/webdav.ts new file mode 100644 index 0000000..bdbd533 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/webdav.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; + +describe('[Webdav]', () => { + before((done) => getCredentials(done)); + + describe('/webdav.getMyAccounts', () => { + it('should return my webdav accounts', async () => { + const res = await request.get(api('webdav.getMyAccounts')).set(credentials).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('accounts').and.to.be.a('array'); + }); + }); + + describe('/webdav.removeWebdavAccount', () => { + it('should return an error when send an invalid request', async () => { + const res = await request.post(api('webdav.removeWebdavAccount')).set(credentials).send({}).expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + + it('should return an error when using an invalid account id', async () => { + const res = await request + .post(api('webdav.removeWebdavAccount')) + .set(credentials) + .send({ + accountId: {}, + }) + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-api-endpoint-params.ts b/apps/meteor/tests/end-to-end/apps/app-api-endpoint-params.ts new file mode 100644 index 0000000..ced5b5d --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-api-endpoint-params.ts @@ -0,0 +1,33 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appAPIParameterTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - API Endpoint Parameter Parsing', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(appAPIParameterTest); + }); + + after(() => cleanupApps()); + + it('should correctly parse URL parameters in API endpoint', async () => { + const param1Value = 'test-value-1'; + const param2Value = 'test-value-2'; + + const response = await request.get(apps(`/public/${app.id}/api/${param1Value}/${param2Value}/test`)).set(credentials); + + expect(response.status, 'API endpoint request failed').to.equal(200); + expect(response.header['content-type'], 'Content-Type header is incorrect').to.include('text/plain'); + expect(response.text, 'Response body does not contain correct parameters').to.equal(`Param1: ${param1Value}, Param2: ${param2Value}`); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-distinct-values.ts b/apps/meteor/tests/end-to-end/apps/app-logs-distinct-values.ts new file mode 100644 index 0000000..3468342 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-distinct-values.ts @@ -0,0 +1,109 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs Distinct Values', () => { + let appId: string; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + const app = await installTestApp(); + appId = app.id; + }); + + after(() => Promise.all([cleanupApps(), updatePermission('manage-apps', ['admin'])])); + + it('should return distinct values successfully', (done) => { + void request + .get(apps(`/${appId}/logs/distinctValues`)) + .set(credentials) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('instanceIds').that.is.an('array'); + expect(res.body).to.have.a.property('methods').that.is.an('array'); + + const { instanceIds, methods } = res.body; + const uniqueInstanceIds = new Set(instanceIds); + const uniqueMethods = new Set(methods); + + // All instance IDs should be unique + expect(instanceIds).to.have.lengthOf(uniqueInstanceIds.size); + + // All methods should be unique + expect(methods).to.have.lengthOf(uniqueMethods.size); + }) + .end(done); + }); + + it('should require authentication', (done) => { + void request + .get(apps(`/${appId}/logs/distinctValues`)) + .expect(401) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error'); + }) + .end(done); + }); + + it('should require manage-apps permission', (done) => { + void updatePermission('manage-apps', []).then( + () => + void request + .get(apps(`/${appId}/logs/distinctValues`)) + .set(credentials) + .expect(403) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }) + // Doing the `updatePermission` call here pollutes the error reporting, but we shouldn't call `done` before doing it. + .end((err) => void updatePermission('manage-apps', ['admin']).then(() => void done(err))), + ); + }); + + it('should return 404 for non-existent app', (done) => { + void request + .get(apps('/non-existent-app-id/logs/distinctValues')) + .set(credentials) + .expect(404) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error'); + }) + .end(done); + }); + + it('should return empty arrays when no logs exist', (done) => { + // Clean up all apps first to ensure no logs + void cleanupApps().then(async () => { + // Install a fresh app + const app = await installTestApp(); + void request + .get(apps(`/${app.id}/logs/distinctValues`)) + .set(credentials) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('instanceIds').that.is.an('array'); + expect(res.body).to.have.a.property('methods').that.is.an('array'); + // Could be empty or have values from other tests + expect(res.body.instanceIds).to.satisfy((arr: any[]) => Array.isArray(arr)); + expect(res.body.methods).to.satisfy((arr: any[]) => Array.isArray(arr)); + }) + .end(done); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-export.ts b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts new file mode 100644 index 0000000..4022133 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts @@ -0,0 +1,399 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials, getInstanceId } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs Export', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to export logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/export-logs')) + .query({ type: 'json' }) + .set(credentials) + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should export app logs as JSON successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.json'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid JSON + expect(() => JSON.parse(res.text)).to.not.throw(); + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs[0]).to.have.a.property('_id'); + expect(logs[0]).to.have.a.property('appId', app.id); + }) + .end(done); + }); + + it('should export app logs as CSV successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'csv' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.csv'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid CSV (should have headers and at least one row) + const lines = res.text.split('\n'); + expect(lines.length).to.be.at.least(1); // At least header row + if (lines.length > 1 && lines[0]) { + // Check that headers contain expected fields + expect(lines[0]).to.include('_id'); + expect(lines[0]).to.include('appId'); + } + }) + .end(done); + }); + + it('should throw error when type is not specified', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .set(credentials) + .expect(400) + .end(done); + }); + + it('should export app logs with pagination parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(5); + }) + .end(done); + }); + + it('should export app logs with sorting parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + sort: JSON.stringify({ _updatedAt: 1 }), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + // Verify sorting by checking if timestamps are in ascending order + if (logs.length > 1) { + for (let i = 1; i < logs.length; i++) { + const prevTime = new Date(logs[i - 1]._updatedAt).getTime(); + const currTime = new Date(logs[i]._updatedAt).getTime(); + expect(currTime).to.be.at.least(prevTime); + } + } + }) + .end(done); + }); + + it('should export app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: '2', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should export app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ method: 'app:construct', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should export app logs filtered by instance id', (done) => { + const instanceId = getInstanceId(); + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ instanceId, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.instanceId).to.equal(instanceId); + }); + }) + .end(done); + }); + + it('should export app logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + // Verify that all returned logs are within the date range + logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should export app logs with combined filters', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + count: 10, + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(10); + + // Verify that all returned logs match all filter criteria + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + + const logDate = new Date(log._createdAt); + expect(logDate >= startDate && logDate <= endDate).to.be.true; + + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return an error when no logs are found for the specified criteria', (done) => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: futureDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No logs found for the specified criteria'); + }) + .end(done); + }); + + it('should respect the maximum limit of 2000 logs', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5000, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(2000); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should handle authentication requirement', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .expect(401) + .end(done); + }); + + it('should handle authentication via cookie', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set({ cookie: `rc_uid=${credentials['X-User-Id']}; rc_token=${credentials['X-Auth-Token']}` }) + .expect(200) + .end(done); + }); + + it('should include proper filename with timestamp in Content-Disposition header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + const contentDisposition = res.headers['content-disposition']; + expect(contentDisposition).to.include('attachment'); + expect(contentDisposition).to.include(`app-logs-${app.id}`); + expect(contentDisposition).to.include('.json'); + + // Check that timestamp is included (format: YYYY-MM-DDTHH-MM-SS) + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/; + expect(contentDisposition).to.match(timestampRegex); + }) + .end(done); + }); + + it('should include Content-Length header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-length'); + const contentLength = parseInt(res.headers['content-length'], 10); + expect(contentLength).to.be.a('number'); + expect(contentLength).to.be.greaterThan(0); + expect(contentLength).to.equal(Buffer.byteLength(res.text, 'utf8')); + }) + .end(done); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-general.ts b/apps/meteor/tests/end-to-end/apps/app-logs-general.ts new file mode 100644 index 0000000..92d9d66 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-general.ts @@ -0,0 +1,240 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - General Logs (GET /api/apps/logs)', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => Promise.all([cleanupApps(), updatePermission('manage-apps', ['admin'])])); + + it('should return logs for all apps successfully', (done) => { + void request + .get(apps('/logs')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('logs').that.is.an('array').with.a.lengthOf.at.least(1); + expect(res.body).to.have.a.property('count').that.is.a('number'); + expect(res.body).to.have.a.property('total').that.is.a('number'); + expect(res.body).to.have.a.property('offset').that.is.a('number'); + }) + .end(done); + }); + + it('should require authentication', (done) => { + void request + .get(apps('/logs')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error'); + }) + .end(done); + }); + + it('should require manage-apps permission', (done) => { + void updatePermission('manage-apps', []).then( + () => + void request + .get(apps('/logs')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }) + .end((err) => void updatePermission('manage-apps', ['admin']).then(() => void done(err))), + ); + }); + + it('should return logs with pagination', (done) => { + void request + .get(apps('/logs')) + .query({ count: 1, offset: 0 }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf(1); + expect(res.body.count).to.be.equal(1); + expect(res.body.offset).to.be.equal(0); + }) + .end(done); + }); + + it('should return logs with sorting', (done) => { + void request + .get(apps('/logs')) + .query({ sort: JSON.stringify({ _updatedAt: -1 }) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1); + }) + .end(done); + }); + + it('should return logs filtered by appId', (done) => { + void request + .get(apps('/logs')) + .query({ appId: app.id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.appId).to.equal(app.id); + }); + }) + .end(done); + }); + + it('should return logs filtered by method', (done) => { + void request + .get(apps('/logs')) + .query({ method: 'app:construct' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should return logs filtered by logLevel', (done) => { + void request + .get(apps('/logs')) + .query({ logLevel: '2' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps('/logs')) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps('/logs')) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps('/logs')) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps('/logs')) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps('/logs')) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-nested-requests.ts b/apps/meteor/tests/end-to-end/apps/app-logs-nested-requests.ts new file mode 100644 index 0000000..6ef79a8 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-nested-requests.ts @@ -0,0 +1,68 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appCausingNestedRequests } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { loadHistory } from '../../data/rooms.helper'; +import { executeAppSlashCommand } from '../../data/slashcommands.helpers'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs Nested Requests', () => { + before((done) => getCredentials(done)); + + after(() => cleanupApps()); + + it('should log nested requests without errors', async () => { + const app = await installLocalTestPackage(appCausingNestedRequests); + + const roomId = 'GENERAL'; + const execId = Math.random().toString(36).substring(2, 15); + + const slashcommandRes = await executeAppSlashCommand('nest', roomId, execId); + + expect(slashcommandRes.status, 'Slashcommand execution failed').to.equal(200); + + const { messages } = await loadHistory(roomId); + + const targetMessage = messages.find((msg) => msg.msg === `nested_test_message ${execId}`); + + expect(targetMessage, 'Target message from nested request not found').to.exist; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we're checking existence above + const { _id: messageId } = targetMessage!; + + const logsRes = await request.get(apps(`/${app.id}/logs`)).set(credentials); + + expect(logsRes.status, 'Fetching app logs failed').to.equal(200); + expect(logsRes.body, 'Apps logs response success is not true').to.have.a.property('success', true); + expect(logsRes.body, 'Apps logs response logs property is empty or missing') + .to.have.a.property('logs') + .that.is.an('array') + .with.lengthOf.greaterThan(0); + + let slashcommandExecutionLog: ILoggerStorageEntry | undefined; + let postMessageExecutionLog: ILoggerStorageEntry | undefined; + + logsRes.body.logs.forEach((log: ILoggerStorageEntry) => { + if ((log.method as string) === 'slashcommand:nest:executor' && log.entries.find((entry) => entry.args.includes(messageId))) { + slashcommandExecutionLog = log; + return; + } + + if ((log.method as string) === 'app:executePostMessageSent' && log.entries.find((entry) => entry.args.includes(messageId))) { + postMessageExecutionLog = log; + } + }); + + expect(postMessageExecutionLog, 'IPostMessageSent execution log not found').to.exist; + + expect(slashcommandExecutionLog, 'Slashcommand execution log not found').to.exist; + expect( + slashcommandExecutionLog?.entries.filter((entry) => entry.args.includes(execId)), + `Slashcommand execution log does not contain a matching execId "${execId}"`, + ).to.have.lengthOf(1); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-logs.ts b/apps/meteor/tests/end-to-end/apps/app-logs.ts new file mode 100644 index 0000000..71c576e --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs.ts @@ -0,0 +1,288 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to get logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/logs')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.not.have.a.property('logs'); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should return app logs successfully', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('logs').that.is.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + expect(res.body).to.have.a.property('count').that.is.a('number'); + expect(res.body).to.have.a.property('total').that.is.a('number'); + expect(res.body).to.have.a.property('offset').that.is.a('number'); + }) + .end(done); + }); + + it('should return app logs with pagination', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ count: 1, offset: 0 }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf(1); + expect(res.body.count).to.be.equal(1); + expect(res.body.offset).to.be.equal(0); + }) + .end(done); + }); + + it('should return app logs with sorting', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ sort: JSON.stringify({ _updatedAt: -1 }) }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + }) + .end(done); + }); + + it('should return app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ logLevel: '2' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ method: 'app:construct' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should return app logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + // Verify that all returned logs are within the date range + res.body.logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should return app logs with combined filters', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body.logs).to.be.an('array').with.a.lengthOf.at.least(1).and.at.most(50); + + // Verify that all returned logs match all filter criteria + res.body.logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + + const logDate = new Date(log._createdAt); + expect(logDate >= startDate && logDate <= endDate).to.be.true; + + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps(`/${app.id}/logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject appId query parameter', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .query({ appId: 'invalid-id' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error', 'Invalid query parameter "appId": invalid-id'); + }) + .end(done); + }); + + it('should require authentication', (done) => { + void request + .get(apps(`/${app.id}/logs`)) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error'); + }) + .end(done); + }); + + it('should require manage-apps permission', (done) => { + void updatePermission('manage-apps', []).then( + () => + void request + .get(apps(`/${app.id}/logs`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + }) + .end((err) => void updatePermission('manage-apps', ['admin']).then(() => void done(err))), + ); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts b/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts new file mode 100644 index 0000000..68c44c2 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials } from '../../data/api-data'; +import { messageReactionTest } from '../../data/apps/app-packages'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { sendSimpleMessage, getMessageById } from '../../data/chat.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { executeAppSlashCommand } from '../../data/slashcommands.helpers'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Message Reactions', () => { + let roomId: string; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + await installLocalTestPackage(messageReactionTest); + + const res = await createRoom({ type: 'c', name: `apps-reactions-test-${Date.now()}` }); + roomId = res.body.channel._id; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId }); + await cleanupApps(); + }); + + it('should add and remove a thumbs-up reaction to a message', async () => { + const sendRes = await sendSimpleMessage({ roomId, text: 'reaction test message' }); + const messageId = sendRes.body.message._id; + + const slashRes = await executeAppSlashCommand('msg-update', roomId, `add ${messageId}`); + expect(slashRes.status, 'Slash command to add reaction failed').to.equal(200); + + let message = await getMessageById({ msgId: messageId }); + expect(message.reactions, 'Message reactions should exist after adding').to.exist; + expect(message.reactions, 'Message should have a thumbs-up reaction').to.have.property(':+1:'); + + // Now remove the reaction + const removeRes = await executeAppSlashCommand('msg-update', roomId, `remove ${messageId}`); + expect(removeRes.status, 'Slash command to remove reaction failed').to.equal(200); + + message = await getMessageById({ msgId: messageId }); + const hasThumbsUp = Boolean(message.reactions && ':+1:' in message.reactions); + expect(hasThumbsUp, 'Thumbs-up reaction should have been removed').to.be.false; + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts new file mode 100644 index 0000000..ef55804 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-resolve-visitor.ts @@ -0,0 +1,239 @@ +import type { App, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appExternalIdTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps } from '../../data/apps/helper'; +import { createVisitor, createVisitorWithCustomData, createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; +import { updateSetting } from '../../data/permissions.helper'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - resolveVisitor API', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await createAgent(); + await makeAgentAvailable(); + + const installResponse = await request.post(apps()).set(credentials).attach('app', appExternalIdTest); + if (!installResponse.body.success) { + throw new Error(`Failed to install test app: ${installResponse.body.error}`); + } + + app = installResponse.body.app; + }); + + after(async () => { + await cleanupApps(); + }); + + const callResolveVisitor = async ( + externalId: { entityId: string; metadata?: Record }, + contactData?: { phone?: string; email?: string }, + ) => { + return request + .post(apps(`/public/${app.id}/resolve-visitor`)) + .set(credentials) + .send({ externalId, phone: contactData?.phone, email: contactData?.email }); + }; + + const callUpdateExternalId = async (visitorId: string, externalId: { entityId: string; metadata?: Record }) => { + return request + .post(apps(`/public/${app.id}/update-external-id`)) + .set(credentials) + .send({ visitorId, externalId }); + }; + + describe('externalId lookup', () => { + it('should return null when externalId does not exist', async () => { + const response = await callResolveVisitor({ entityId: 'nonexistent-id' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should find visitor directly by externalId', async () => { + const phone = `+1${Date.now()}`; + const visitor = await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@user' } }; + await callResolveVisitor(externalId, { phone }); + + const response = await callResolveVisitor(externalId); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should not update externalId when found by lookup', async () => { + const phone = `+2${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@original' } }; + await callResolveVisitor(externalId, { phone }); + + // resolveVisitor is for resolving/enriching visitors, not for updating existing data. + // When found by externalId, it returns the visitor as-is without modifications. + // To update visitor data, apps should use other methods like ILivechatUpdater. + const response = await callResolveVisitor({ entityId: externalId.entityId, metadata: { username: '@changed' } }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.metadata.username).to.equal('@original'); + }); + }); + + describe('phone fallback', () => { + it('should find visitor by phone and save externalId', async () => { + const phone = `+3${Date.now()}`; + const visitor = await createVisitor(undefined, undefined, undefined, phone); + + const externalId = { entityId: `id-${Date.now()}`, metadata: { username: '@user' } }; + const response = await callResolveVisitor(externalId, { phone }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should overwrite externalId when called with different entityId', async () => { + const phone = `+4${Date.now()}`; + await createVisitor(undefined, undefined, undefined, phone); + + await callResolveVisitor({ entityId: `first-${Date.now()}`, metadata: { username: '@first' } }, { phone }); + + const newExternalId = { entityId: `second-${Date.now()}`, metadata: { username: '@second' } }; + const response = await callResolveVisitor(newExternalId, { phone }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); + expect(appExternalId.metadata.username).to.equal('@second'); + }); + + it('should return null when phone not found', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { phone: '+0000000000' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should not use phone fallback when phone is empty', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { phone: '' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + }); + + describe('email fallback', () => { + it('should find visitor by email and save externalId', async () => { + const email = `test-${Date.now()}@example.com`; + const visitor = await createVisitor(undefined, undefined, email); + + const externalId = { entityId: `id-email-${Date.now()}`, metadata: { username: '@emailuser' } }; + const response = await callResolveVisitor(externalId, { email }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(externalId.entityId); + expect(appExternalId.metadata.username).to.equal(externalId.metadata.username); + }); + + it('should overwrite externalId when called with different entityId via email', async () => { + const email = `test-${Date.now()}@example.com`; + await createVisitor(undefined, undefined, email); + + await callResolveVisitor({ entityId: `first-email-${Date.now()}`, metadata: { username: '@first' } }, { email }); + + const newExternalId = { entityId: `second-email-${Date.now()}`, metadata: { username: '@second' } }; + const response = await callResolveVisitor(newExternalId, { email }); + + const appExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + expect(appExternalId.entityId).to.equal(newExternalId.entityId); + expect(appExternalId.metadata.username).to.equal('@second'); + }); + + it('should return null when email not found', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { email: 'notfound@example.com' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + + it('should not use email fallback when email is empty', async () => { + const response = await callResolveVisitor({ entityId: `id-${Date.now()}` }, { email: '' }); + + expect(response.status).to.equal(200); + expect(response.body.visitor).to.be.null; + }); + }); + + describe('cross-appId lookup', () => { + // Direct MongoDB to inject externalId with a different appId, avoiding the need to install a second test app + const addExternalIdToVisitor = async (visitorId: string, externalId: { appId: string; entityId: string }) => { + const connection = await MongoClient.connect(URL_MONGODB); + try { + await connection + .db() + .collection('rocketchat_livechat_visitor') + .updateOne({ _id: visitorId }, { $push: { externalIds: externalId } }); + } finally { + await connection.close(); + } + }; + + it('should find visitor by entityId even when externalId was saved by different appId', async () => { + // Create visitor without phone to ensure we're testing entityId lookup only + const visitor = await createVisitorWithCustomData({ ignorePhone: true }); + + const oldAppId = 'old-app-version-id-12345'; + const entityId = `cross-app-${Date.now()}`; + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); + + // App searches by entityId only - should find visitor even though appId is different + const response = await callResolveVisitor({ entityId }); + + expect(response.status).to.equal(200); + expect(response.body.visitor.id).to.equal(visitor._id); + const oldExternalId = response.body.visitor.externalIds.find((e: { appId: string }) => e.appId === oldAppId); + expect(oldExternalId.entityId).to.equal(entityId); + }); + + it('should allow app to add its own externalId using updateVisitorExternalId', async () => { + const visitor = await createVisitorWithCustomData({ ignorePhone: true }); + + const oldAppId = 'old-app-version-id-67890'; + const entityId = `cross-app-update-${Date.now()}`; + await addExternalIdToVisitor(visitor._id, { appId: oldAppId, entityId }); + + const lookupResponse = await callResolveVisitor({ entityId }); + expect(lookupResponse.body.visitor.id).to.equal(visitor._id); + + const newExternalId = { entityId, metadata: { username: '@newversion' } }; + const updateResponse = await callUpdateExternalId(visitor._id, newExternalId); + + expect(updateResponse.status).to.equal(200); + expect(updateResponse.body.visitor.id).to.equal(visitor._id); + + const oldEntry = updateResponse.body.visitor.externalIds.find((e: { appId: string }) => e.appId === oldAppId); + const newEntry = updateResponse.body.visitor.externalIds.find((e: { appId: string }) => e.appId === app.id); + + expect(oldEntry).to.exist; + expect(oldEntry.entityId).to.equal(entityId); + expect(newEntry).to.exist; + expect(newEntry.entityId).to.equal(entityId); + expect(newEntry.metadata.username).to.equal('@newversion'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/apps-hooks-file-upload.ts b/apps/meteor/tests/end-to-end/apps/apps-hooks-file-upload.ts new file mode 100644 index 0000000..9cd3576 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/apps-hooks-file-upload.ts @@ -0,0 +1,56 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, request, credentials, api } from '../../data/api-data'; +import { appImplementsIPreFileUpload } from '../../data/apps/app-packages'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('[Apps Hooks - File Upload]', () => { + before((done) => getCredentials(done)); + + describe('IPreFileUpload', () => { + let room: IRoom; + + before(async () => { + await cleanupApps(); + await installLocalTestPackage(appImplementsIPreFileUpload); + room = await createRoom({ type: 'c', name: `file-upload-hook-${Date.now()}` }).then((res) => res.body.channel); + }); + + after(() => Promise.all([deleteRoom({ type: 'c' as const, roomId: room._id }), cleanupApps()])); + + it('should be capable of rejecting an upload based on app logic', async () => { + const fileContents = 'I want to be rejected by the app'; + + await request + .post(api(`rooms.media/${room._id}`)) + .set(credentials) + .attach('file', Buffer.from(fileContents), { filename: 'test-should-reject' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-app-prevented'); + expect(res.body).to.have.property('error').that.is.string(fileContents); + }); + }); + + it('should not prevent an unrelated file upload', async () => { + const fileContents = 'I should not be rejected'; + + await request + .post(api(`rooms.media/${room._id}`)) + .set(credentials) + .attach('file', Buffer.from(fileContents), { filename: 'test-file' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/apps-uninstall.ts b/apps/meteor/tests/end-to-end/apps/apps-uninstall.ts new file mode 100644 index 0000000..c8831cc --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/apps-uninstall.ts @@ -0,0 +1,50 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Uninstall', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + describe('[Uninstall]', () => { + it('should throw an error when trying to uninstall an invalid app', (done) => { + void request + .delete(apps('/invalid-id')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + it('should remove the app successfully', (done) => { + void request + .delete(apps(`/${app.id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('app'); + expect(res.body.app.id).to.be.equal(app.id); + expect(res.body.app.status).to.be.equal('disabled'); + }) + .end(done); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/apps-update.ts b/apps/meteor/tests/end-to-end/apps/apps-update.ts new file mode 100644 index 0000000..723b9ab --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/apps-update.ts @@ -0,0 +1,75 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appUpdateTest, appUpdateTestBroken, appUpdateTestFaulty } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { getUserByUsername } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const APP_USERNAME = 'app-update-test.bot'; + +(IS_EE ? describe : describe.skip)('Apps - Update', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(appUpdateTest); + }); + + after(() => cleanupApps()); + + describe('[App user presence]', () => { + it('should keep the app user online after the app is updated', async () => { + const appUserBefore = await getUserByUsername(APP_USERNAME); + expect(appUserBefore.status).to.be.equal('online'); + + await request + .post(apps(`/${app.id}`)) + .set(credentials) + .attach('app', appUpdateTest) + .expect(200); + + const appUserAfter = await getUserByUsername(APP_USERNAME); + expect(appUserAfter.status).to.be.equal('online'); + }); + + it('should leave the app user unchanged when the update fails to apply', async () => { + const appUserBefore = await getUserByUsername(APP_USERNAME); + expect(appUserBefore.status).to.be.equal('online'); + + await request + .post(apps(`/${app.id}`)) + .set(credentials) + .attach('app', appUpdateTestBroken) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + + const appUserAfter = await getUserByUsername(APP_USERNAME); + expect(appUserAfter.status).to.be.equal('online'); + }); + + it('should take the app user offline when the updated app ends up disabled', async () => { + const appUserBefore = await getUserByUsername(APP_USERNAME); + expect(appUserBefore.status).to.be.equal('online'); + + await request + .post(apps(`/${app.id}`)) + .set(credentials) + .attach('app', appUpdateTestFaulty) + .expect(200) + .expect((res) => { + expect(res.body.app).to.have.property('status', 'disabled'); + }); + + const appUserAfter = await getUserByUsername(APP_USERNAME); + expect(appUserAfter.status).to.be.equal('offline'); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/installation.ts b/apps/meteor/tests/end-to-end/apps/installation.ts new file mode 100644 index 0000000..9f814c6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/installation.ts @@ -0,0 +1,167 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials, api } from '../../data/api-data'; +import { APP_URL, APP_NAME, apps } from '../../data/apps/apps-data'; +import { cleanupApps } from '../../data/apps/helper'; +import { updatePermission } from '../../data/permissions.helper'; +import { getUserByUsername } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const APP_USERNAME = 'appsrocketchattester.bot'; + +describe('Apps - Installation', () => { + before((done) => getCredentials(done)); + + before(async () => cleanupApps()); + + after(() => Promise.all([cleanupApps(), updatePermission('manage-apps', ['admin'])])); + + let app: any; + + describe('[Installation]', () => { + it('should throw an error when trying to install an app and the apps framework is enabled but the user does not have the permission', (done) => { + void updatePermission('manage-apps', []).then(() => { + void request + .post(apps()) + .set(credentials) + .send({ + url: APP_URL, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }) + .end(done); + }); + }); + (IS_EE ? it : it.skip)('should succesfully install an app from a URL in EE, which should be auto-enabled', (done) => { + void updatePermission('manage-apps', ['admin']).then(() => { + void request + .post(apps()) + .set(credentials) + .send({ + url: APP_URL, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('app'); + expect(res.body.app).to.have.a.property('id'); + expect(res.body.app).to.have.a.property('version'); + expect(res.body.app).to.have.a.property('status').and.to.be.equal('manually_enabled'); + + app = res.body.app; + }) + .end(done); + }); + }); + (!IS_EE ? it : it.skip)('should succesfully install an app from a URL in CE, which should not be enabled', (done) => { + void updatePermission('manage-apps', ['admin']).then(() => { + void request + .post(apps()) + .set(credentials) + .send({ + url: APP_URL, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('app'); + expect(res.body.app).to.have.a.property('id'); + expect(res.body.app).to.have.a.property('version'); + expect(res.body.app).to.have.a.property('status').and.to.be.equal('initialized'); + + app = res.body.app; + }) + .end(done); + }); + }); + it('should have created the app user successfully', (done) => { + void getUserByUsername(APP_USERNAME) + .then((user) => { + expect(user.username).to.be.equal(APP_USERNAME); + }) + .then(done); + }); + it('should successfully get app details by id', (done) => { + void request + .get(apps(`/${app.id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('app'); + expect(res.body.app).to.have.a.property('name', APP_NAME); + expect(res.body.app).to.have.a.property('version'); + expect(res.body.app).to.have.a.property('status'); + }) + .end(done); + }); + it('should successfully get app status by id', (done) => { + void request + .get(apps(`/${app.id}/status`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('status'); + expect(res.body.status).to.not.be.empty; + }) + .end(done); + }); + (IS_EE ? describe : describe.skip)('Slash commands registration', () => { + it('should have created the "test-simple" slash command successfully', (done) => { + void request + .get(api('commands.get')) + .query({ command: 'test-simple' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('command'); + expect(res.body.command.command).to.be.equal('test-simple'); + }) + .end(done); + }); + it('should have created the "test-with-arguments" slash command successfully', (done) => { + void request + .get(api('commands.get')) + .query({ command: 'test-with-arguments' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('command'); + expect(res.body.command.command).to.be.equal('test-with-arguments'); + }) + .end(done); + }); + }); + (IS_EE ? describe : describe.skip)('Video Conf Provider registration', () => { + it('should have created two video conf provider successfully', (done) => { + void request + .get(api('video-conference.providers')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(3); + expect(res.body.data[0]).to.be.an('object').with.a.property('key').equal('test'); + expect(res.body.data[1]).to.be.an('object').with.a.property('key').equal('persistentchat'); + expect(res.body.data[2]).to.be.an('object').with.a.property('key').equal('unconfigured'); + }) + .end(done); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/presence-state.ts b/apps/meteor/tests/end-to-end/apps/presence-state.ts new file mode 100644 index 0000000..a52f40a --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/presence-state.ts @@ -0,0 +1,102 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, afterEach, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appPresenceStateTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { adminUsername } from '../../data/user'; +import { getUserByUsername } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Presence State Bridge', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(appPresenceStateTest); + }); + + after(() => cleanupApps()); + + afterEach(async () => { + const user = await getUserByUsername(adminUsername); + await request + .post(apps(`/public/${app.id}/end-active-state`)) + .set(credentials) + .send({ userId: user._id }) + .expect(200); + }); + + describe('[setActiveState]', () => { + it('should set the user presence with status text and source', async () => { + const user = await getUserByUsername(adminUsername); + + await request + .post(apps(`/public/${app.id}/set-active-state`)) + .set(credentials) + .send({ + userId: user._id, + statusDefault: 'busy', + statusText: 'In a meeting', + statusSource: 'internal', + }) + .expect(200); + + const updatedUser = await getUserByUsername(adminUsername); + expect(updatedUser.statusText).to.equal('In a meeting'); + expect(updatedUser.statusSource).to.equal('internal'); + }); + }); + + describe('[endActiveState]', () => { + it('should restore the displaced same-priority state and reset once the queue is exhausted', async () => { + const user = await getUserByUsername(adminUsername); + + await request + .post(apps(`/public/${app.id}/set-active-state`)) + .set(credentials) + .send({ + userId: user._id, + statusDefault: 'busy', + statusText: 'In a meeting', + statusSource: 'internal', + }) + .expect(200); + + await request + .post(apps(`/public/${app.id}/set-active-state`)) + .set(credentials) + .send({ + userId: user._id, + statusDefault: 'busy', + statusText: 'On a call', + statusSource: 'internal', + }) + .expect(200); + + await request + .post(apps(`/public/${app.id}/end-active-state`)) + .set(credentials) + .send({ userId: user._id }) + .expect(200); + + const restoredUser = await getUserByUsername(adminUsername); + expect(restoredUser.statusText).to.equal('In a meeting'); + expect(restoredUser.statusSource).to.equal('internal'); + + await request + .post(apps(`/public/${app.id}/end-active-state`)) + .set(credentials) + .send({ userId: user._id }) + .expect(200); + + const clearedUser = await getUserByUsername(adminUsername); + expect(clearedUser.statusText).to.equal(''); + expect(clearedUser.statusSource).to.be.undefined; + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/send-messages-as-user.ts b/apps/meteor/tests/end-to-end/apps/send-messages-as-user.ts new file mode 100644 index 0000000..f04482a --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/send-messages-as-user.ts @@ -0,0 +1,167 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { App, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installTestApp } from '../../data/apps/helper'; +import { getMessageById } from '../../data/chat.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { adminUsername, password } from '../../data/user'; +import type { TestUser } from '../../data/users.helper'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Send Messages As User', () => { + let app: App; + + before((done) => getCredentials(done)); + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + describe('[Send Message as user]', () => { + it('should return an error when the room is not found', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user`)) + .send({ + roomId: 'invalid-room', + }) + .set(credentials) + .expect(404) + .expect((err: unknown, res: undefined) => { + expect(err).to.have.a.property('error'); + expect(res).to.be.equal(undefined); + expect((err as { error?: any }).error).to.have.a.property('text'); + expect((err as { error?: any }).error.text).to.be.equal('Room "invalid-room" could not be found'); + }) + .end(done); + }); + it('should return an error when the user is not found', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user?userId=invalid-user`)) + .send({ + roomId: 'GENERAL', + }) + .set(credentials) + .expect(404) + .expect((err: unknown, res: undefined) => { + expect(err).to.have.a.property('error'); + expect(res).to.be.equal(undefined); + expect((err as { error?: any }).error).to.have.a.property('text'); + expect((err as { error?: any }).error.text).to.be.equal('User with id "invalid-user" could not be found'); + }) + .end(done); + }); + describe('Send to a Public Channel', () => { + let publicMessageId: IMessage['_id']; + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user?userId=${adminUsername}`)) + .set(credentials) + .send({ + roomId: 'GENERAL', + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + publicMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: publicMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-user test endpoint'); + }); + }); + describe('Send to a Private Channel', () => { + let privateMessageId: IMessage['_id']; + let group: IRoom; + let user: TestUser; + let userCredentials: Credentials; + + before(async () => { + group = ( + await createRoom({ + type: 'p', + name: `apps-e2etest-room-${Date.now()}`, + }) + ).body.group; + user = await createUser(); + userCredentials = await login(user.username, password); + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: group._id }), deleteUser(user)])); + + it('should return 500 when sending a message as user that has no permissions', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user?userId=${user._id}`)) + .set(userCredentials) + .send({ + roomId: group._id, + }) + .expect(500) + .end(done); + }); + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user?userId=${adminUsername}`)) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + privateMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: privateMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-user test endpoint'); + }); + }); + describe('Send to a DM Channel', () => { + let DMMessageId: IMessage['_id']; + let dmRoom: IRoom; + + before(async () => { + dmRoom = ( + await createRoom({ + type: 'd', + username: 'rocket.cat', + }) + ).body.room; + }); + + after(() => deleteRoom({ type: 'd', roomId: dmRoom._id })); + + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-user?userId=${adminUsername}`)) + .set(credentials) + .send({ + roomId: dmRoom._id, + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + DMMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: DMMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-user test endpoint'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/send-messages.ts b/apps/meteor/tests/end-to-end/apps/send-messages.ts new file mode 100644 index 0000000..7e6db4c --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/send-messages.ts @@ -0,0 +1,133 @@ +import type { App, IMessage, IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installTestApp } from '../../data/apps/helper'; +import { getMessageById } from '../../data/chat.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Send Messages As APP User', () => { + let app: App; + + before((done) => getCredentials(done)); + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + describe('[Send Message as app user]', () => { + it('should return an error when the room is not found', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-app-user`)) + .send({ + roomId: 'invalid-room', + }) + .set(credentials) + .expect(404) + .expect((err: unknown, res: undefined) => { + expect(err).to.have.a.property('error'); + expect(res).to.be.equal(undefined); + expect((err as { error?: any }).error).to.have.a.property('text'); + expect((err as { error?: any }).error.text).to.be.equal('Room "invalid-room" could not be found'); + }) + .end(done); + }); + describe('Send to a Public Channel', () => { + let publicMessageId: IMessage['_id']; + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-app-user`)) + .set(credentials) + .send({ + roomId: 'GENERAL', + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + publicMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: publicMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-app-user test endpoint'); + }); + }); + describe('Send to a Private Channel', () => { + let privateMessageId: IMessage['_id']; + let group: IRoom; + + before(async () => { + group = ( + await createRoom({ + type: 'p', + name: `apps-e2etest-room-${Date.now()}`, + }) + ).body.group; + }); + + after(() => deleteRoom({ type: 'p', roomId: group._id })); + + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-app-user`)) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + privateMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: privateMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-app-user test endpoint'); + }); + }); + describe('Send to a DM Channel', () => { + let DMMessageId: IMessage['_id']; + let dmRoom: IRoom; + + before(async () => { + dmRoom = ( + await createRoom({ + type: 'd', + username: 'rocket.cat', + }) + ).body.room; + }); + + after(() => deleteRoom({ type: 'd', roomId: dmRoom._id })); + + it('should send a message as app user', (done) => { + void request + .post(apps(`/public/${app.id}/send-message-as-app-user`)) + .set(credentials) + .send({ + roomId: dmRoom._id, + }) + .expect(200) + .expect((res) => { + const response = JSON.parse(res.text); + expect(response).to.have.a.property('messageId'); + DMMessageId = response.messageId; + }) + .end(done); + }); + it('should be a valid message', async () => { + const message = await getMessageById({ msgId: DMMessageId }); + expect(message.msg).to.be.equal('Executing send-message-as-app-user test endpoint'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts new file mode 100644 index 0000000..4d547d6 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts @@ -0,0 +1,81 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials, api } from '../../data/api-data'; +import { cleanupApps, installTestApp } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Slash Command "test-simple"', () => { + before((done) => getCredentials(done)); + before(async () => { + await cleanupApps(); + await installTestApp(); + }); + + after(() => cleanupApps()); + + describe('[Slash command "test-simple"]', () => { + it('should return an error when no command is provided', (done) => { + void request + .post(api('commands.run')) + .send({ + roomId: 'GENERAL', + command: null, + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.include('must NOT have fewer than 1 characters'); + }) + .end(done); + }); + it('should return an error when the command does not exist', (done) => { + void request + .post(api('commands.run')) + .send({ + roomId: 'GENERAL', + command: 'invalid-command', + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('The command provided does not exist (or is disabled).'); + }) + .end(done); + }); + it('should execute the slash command successfully', (done) => { + void request + .post(api('commands.run')) + .send({ + roomId: 'GENERAL', + command: 'test-simple', + }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }) + .end(done); + }); + it('should have sent the message correctly', (done) => { + void request + .get(api('chat.search')) + .query({ + roomId: 'GENERAL', + searchText: "Slashcommand 'test-simple' successfully executed", + }) + .set(credentials) + .expect(200) + .expect((res) => { + const message = (res.body.messages as IMessage[]).find( + (message) => message.msg === "Slashcommand 'test-simple' successfully executed", + ); + expect(message).to.not.be.equal(undefined); + }) + .end(done); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/slash-command-test-with-arguments.ts b/apps/meteor/tests/end-to-end/apps/slash-command-test-with-arguments.ts new file mode 100644 index 0000000..e462536 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/slash-command-test-with-arguments.ts @@ -0,0 +1,52 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials, api } from '../../data/api-data'; +import { cleanupApps, installTestApp } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Slash Command "test-with-arguments"', () => { + before((done) => getCredentials(done)); + before(async () => { + await cleanupApps(); + await installTestApp(); + }); + + after(() => cleanupApps()); + + describe('[Slash command "test-with-arguments"]', () => { + const params = 'argument'; + it('should execute the slash command successfully', (done) => { + void request + .post(api('commands.run')) + .send({ + roomId: 'GENERAL', + command: 'test-with-arguments', + params, + }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }) + .end(done); + }); + it('should have sent the message correctly', (done) => { + const searchText = `Slashcommand \'test-with-arguments\' successfully executed with arguments: "${params}"`; + void request + .get(api('chat.search')) + .query({ + roomId: 'GENERAL', + searchText, + }) + .set(credentials) + .expect(200) + .expect((res) => { + const message = (res.body.messages as IMessage[]).find((message) => message.msg === searchText); + expect(message).to.not.be.equal(undefined); + }) + .end(done); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/update-status-text.ts b/apps/meteor/tests/end-to-end/apps/update-status-text.ts new file mode 100644 index 0000000..7409643 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/update-status-text.ts @@ -0,0 +1,84 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appUpdateStatusTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { getUserByUsername } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const APP_USERNAME = 'update-status-test.bot'; + +(IS_EE ? describe : describe.skip)('Apps - Update App User Status', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(appUpdateStatusTest); + }); + + after(() => cleanupApps()); + + describe('[updateStatusText]', () => { + it('should update the app user statusText', async () => { + const statusText = `test-status-${Date.now()}`; + + await request + .post(apps(`/public/${app.id}/update-status-text`)) + .set(credentials) + .send({ username: APP_USERNAME, statusText }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + expect(appUser.statusText).to.be.equal(statusText); + }); + + it('should clear the app user statusText', async () => { + await request + .post(apps(`/public/${app.id}/update-status-text`)) + .set(credentials) + .send({ username: APP_USERNAME, statusText: '' }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + expect(appUser.statusText).to.be.equal(''); + }); + }); + + describe('[updateStatus]', () => { + it('should update the app user statusText when status and statusText is provided', async () => { + const statusText = `busy-status-${Date.now()}`; + + await request + .post(apps(`/public/${app.id}/update-status`)) + .set(credentials) + .send({ username: APP_USERNAME, status: 'busy', statusText }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + + // We can't test the status value because the Presence service will override it with OFFLINE + // when the user doesn't have an active session/connection + // expect(appUser.status).to.equal(status); + expect(appUser.statusText).to.be.equal(statusText); + }); + + it('should clear statusText when status is updated without providing statusText', async () => { + await request + .post(apps(`/public/${app.id}/update-status`)) + .set(credentials) + .send({ username: APP_USERNAME, status: 'away' }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + + // The test app defaults statusText to '' when not provided, + // so the presence engine correctly clears it + expect(appUser.statusText).to.be.equal(''); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/apps/video-conferences.ts b/apps/meteor/tests/end-to-end/apps/video-conferences.ts new file mode 100644 index 0000000..5b2edeb --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/video-conferences.ts @@ -0,0 +1,1125 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, request, api, credentials } from '../../data/api-data'; +import { cleanupApps, installTestApp } from '../../data/apps/helper'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { adminUsername, password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +describe('Apps - Video Conferences', () => { + before((done) => getCredentials(done)); + + const roomName = `apps-e2etest-room-${Date.now()}-videoconf`; + let roomId: string | undefined; + + before(async () => { + const res = await createRoom({ + type: 'p', + name: roomName, + username: undefined, + members: undefined, + credentials: undefined, + extraData: undefined, + }); + + roomId = res.body.group._id; + }); + + after(() => + Promise.all([cleanupApps(), deleteRoom({ type: 'p', roomId: roomId as string }), updateSetting('VideoConf_Default_Provider', '')]), + ); + + describe('[With No App]', () => { + before(async () => { + await cleanupApps(); + }); + + it('should fail to load capabilities', async () => { + await request + .get(api('video-conference.capabilities')) + .set(credentials) + .send() + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-videoconf-provider-app'); + }); + }); + + it('should fail to start a call', async () => { + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-videoconf-provider-app'); + }); + }); + }); + + (IS_EE ? describe : describe.skip)('[With Test App]', () => { + before(async () => { + await cleanupApps(); + await installTestApp(); + await updateSetting('Discussion_enabled', true); + }); + + after(async () => { + await updateSetting('VideoConf_Default_Provider', ''); + await updateSetting('Discussion_enabled', true); + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Enable_Persistent_Chat', false); + await updateSetting('VideoConf_Persistent_Chat_Discussion_Name', '[date] - Video Call Persisted Chat'); + }); + + describe('[/video-conference.capabilities]', () => { + it('should fail to load capabilities with no default provider', async () => { + await updateSetting('VideoConf_Default_Provider', ''); + await request + .get(api('video-conference.capabilities')) + .set(credentials) + .send() + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-active-video-conf-provider'); + }); + }); + + it('should fail to load capabilities with an invalid default provider', async () => { + await updateSetting('VideoConf_Default_Provider', 'invalid'); + await request + .get(api('video-conference.capabilities')) + .set(credentials) + .send() + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-active-video-conf-provider'); + }); + }); + + it('should fail to load capabilities with a default provider lacking configuration', async () => { + await updateSetting('VideoConf_Default_Provider', 'unconfigured'); + await request + .get(api('video-conference.capabilities')) + .set(credentials) + .send() + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('video-conf-provider-not-configured'); + }); + }); + + it('should load capabilities successfully with a valid default provider', async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await request + .get(api('video-conference.capabilities')) + .set(credentials) + .send() + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.providerName).to.be.equal('test'); + expect(res.body.capabilities).to.be.an('object'); + expect(res.body.capabilities).to.have.a.property('mic').equal(true); + expect(res.body.capabilities).to.have.a.property('cam').equal(false); + expect(res.body.capabilities).to.have.a.property('title').equal(true); + expect(res.body.capabilities).to.have.a.property('persistentChat').equal(false); + }); + }); + }); + + describe('[/video-conference.start]', () => { + it('should fail to start a call with no default provider', async () => { + await updateSetting('VideoConf_Default_Provider', ''); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-active-video-conf-provider'); + }); + }); + + it('should fail to start a call with an invalid default provider', async () => { + await updateSetting('VideoConf_Default_Provider', 'invalid'); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('no-active-video-conf-provider'); + }); + }); + + it('should fail to start a call with a default provider lacking configuration', async () => { + await updateSetting('VideoConf_Default_Provider', 'unconfigured'); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(400) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + expect(res.body.error).to.be.equal('video-conf-provider-not-configured'); + }); + }); + + it('should start a call successfully with a valid default provider', async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('test'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + // expect(res.body.data).to.have.a.property('rid').equal(roomId); + // expect(res.body.data).to.have.a.property('createdBy').that.is.an('object').with.a.property('username').equal(adminUsername); + }); + }); + + it('should start a call successfully when sending a title', async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + title: 'Conference Title', + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('test'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + }); + }); + + it('should start a call successfully when sending the allowRinging attribute', async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + title: 'Conference Title', + allowRinging: true, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('test'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + }); + }); + + it('should start a call successfully when using a provider that supports persistent chat', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + }); + }); + + it('should start a call successfully when using a provider that supports persistent chat with the feature disabled', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('VideoConf_Enable_Persistent_Chat', false); + + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + }); + }); + + it('should start a call successfully when using a provider that supports persistent chat with discussions disabled', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + await updateSetting('Discussion_enabled', false); + + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body.data).to.be.an('object'); + expect(res.body.data).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body.data).to.have.a.property('type').equal('videoconference'); + expect(res.body.data).to.have.a.property('callId').that.is.a('string'); + }); + }); + }); + + describe('[/video-conference.join]', () => { + let callId: string | undefined; + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + callId = res.body.data.callId; + }); + + it('should join a videoconference successfully', async () => { + await request + .post(api('video-conference.join')) + .set(credentials) + .send({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('test'); + const userId = credentials['X-User-Id']; + expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}/${userId}`); + }); + }); + + it('should join a videoconference using the specified state', async () => { + await request + .post(api('video-conference.join')) + .set(credentials) + .send({ + callId, + state: { + mic: true, + cam: false, + }, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('test'); + const userId = credentials['X-User-Id']; + expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}/${userId}/mic`); + }); + }); + }); + + describe('[/video-conference.info]', () => { + let callId: string | undefined; + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should load the video conference data successfully', async () => { + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('test'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}`); + expect(res.body).to.have.a.property('type').equal('videoconference'); + expect(res.body).to.have.a.property('rid').equal(roomId); + expect(res.body).to.have.a.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.a.property('status').equal(1); + expect(res.body).to.have.a.property('title').equal(roomName); + expect(res.body).to.have.a.property('messages').that.is.an('object'); + expect(res.body.messages).to.have.a.property('started').that.is.a('string'); + expect(res.body).to.have.a.property('createdBy').that.is.an('object'); + expect(res.body.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']); + expect(res.body.createdBy).to.have.a.property('username').equal(adminUsername); + }); + }); + }); + + describe('[video-conference.start + video-conference.info]', () => { + describe('[Test provider with the persistent chat feature disabled]', () => { + let callId: string | undefined; + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await updateSetting('Discussion_enabled', true); + + if (process.env.IS_EE) { + await updateSetting('VideoConf_Enable_Persistent_Chat', false); + } + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should load the video conference data successfully', async () => { + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('test'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}`); + expect(res.body).to.have.a.property('type').equal('videoconference'); + expect(res.body).to.have.a.property('rid').equal(roomId); + expect(res.body).to.have.a.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.a.property('status').equal(1); + expect(res.body).to.have.a.property('title').equal(roomName); + expect(res.body).to.have.a.property('messages').that.is.an('object'); + expect(res.body.messages).to.have.a.property('started').that.is.a('string'); + expect(res.body).to.have.a.property('createdBy').that.is.an('object'); + expect(res.body.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']); + expect(res.body.createdBy).to.have.a.property('username').equal(adminUsername); + expect(res.body).to.not.have.a.property('discussionRid'); + }); + }); + }); + + describe('[Test provider with the persistent chat feature enabled]', () => { + let callId: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'test'); + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should load the video conference data successfully', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('test'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}`); + expect(res.body).to.have.a.property('type').equal('videoconference'); + expect(res.body).to.have.a.property('rid').equal(roomId); + expect(res.body).to.have.a.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.a.property('status').equal(1); + expect(res.body).to.have.a.property('title').equal(roomName); + expect(res.body).to.have.a.property('messages').that.is.an('object'); + expect(res.body.messages).to.have.a.property('started').that.is.a('string'); + expect(res.body).to.have.a.property('createdBy').that.is.an('object'); + expect(res.body.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']); + expect(res.body.createdBy).to.have.a.property('username').equal(adminUsername); + expect(res.body).to.not.have.a.property('discussionRid'); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature enabled]', () => { + let callId: string | undefined; + let discussionRid: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + await updateSetting('VideoConf_Persistent_Chat_Discussion_Name', 'Chat History'); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should include a discussion room id on the response', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('discussionRid').that.is.a('string'); + + discussionRid = res.body.discussionRid; + expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/${discussionRid}`); + }); + }); + + it('should have created the discussion room using the configured name', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: discussionRid, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.a.property('_id').equal(discussionRid); + expect(res.body.room) + .to.have.a.property('fname') + .that.is.a('string') + .that.satisfies((msg: string) => !msg.startsWith('Chat History')) + .that.satisfies((msg: string) => msg.includes('Chat History')); + }); + }); + + it('should have created a subscription with open = false', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: discussionRid, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('subscription').and.to.be.an('object'); + expect(res.body.subscription).to.have.a.property('rid').equal(discussionRid); + expect(res.body.subscription) + .to.have.a.property('fname') + .that.is.a('string') + .that.satisfies((msg: string) => !msg.startsWith('Chat History')) + .that.satisfies((msg: string) => msg.includes('Chat History')); + expect(res.body.subscription).to.have.a.property('open', false); + expect(res.body.subscription).to.have.a.property('alert', false); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature enabled and encryption forced on private rooms]', () => { + let callId: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]); + + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data?.callId; + }); + + after(async () => { + if (!process.env.IS_EE) { + return; + } + + await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Force_Encryption_For_Private_Rooms', false)]); + }); + + it('should start the call and treat persistent chat as disabled', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + expect(callId).to.be.a('string'); + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('rid').equal(roomId); + // the call must be fully started, since persistent chat is skipped instead of attempted + expect(res.body).to.have.a.property('url').that.is.a('string'); + expect(res.body).to.have.a.property('status').equal(1); + expect(res.body).to.have.a.property('messages').that.is.an('object'); + expect(res.body.messages).to.have.a.property('started').that.is.a('string'); + // even with VideoConf_Enable_Persistent_Chat on, enforcing encryption disables persistent chat + expect(res.body).to.not.have.a.property('discussionRid'); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature enabled and encryption forced but E2EE off]', () => { + let callId: string | undefined; + let discussionRid: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + // the encryption policy only takes effect while E2EE is enabled, so persistent chat must keep working + await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]); + + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data?.callId; + }); + + after(async () => { + if (!process.env.IS_EE) { + return; + } + + await Promise.all([ + updateSetting('E2E_Force_Encryption_For_Private_Rooms', false), + ...(discussionRid ? [deleteRoom({ type: 'p', roomId: discussionRid })] : []), + ]); + }); + + it('should still create the persistent chat discussion', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + expect(callId).to.be.a('string'); + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + discussionRid = res.body.discussionRid; + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('discussionRid').that.is.a('string'); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature enabled and custom discussion names]', () => { + let callId: string | undefined; + let discussionRid: string | undefined; + let chatDate: string; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + await updateSetting('VideoConf_Persistent_Chat_Discussion_Name', 'Date [date] between'); + chatDate = new Date().toISOString().substring(0, 10); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should include a discussion room id on the response', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.have.a.property('discussionRid').that.is.a('string'); + + discussionRid = res.body.discussionRid; + expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/${discussionRid}`); + }); + }); + + it('should have created the discussion room using the configured name', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('rooms.info')) + .set(credentials) + .query({ + roomId: discussionRid, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('room').and.to.be.an('object'); + expect(res.body.room).to.have.a.property('_id').equal(discussionRid); + expect(res.body.room) + .to.have.a.property('fname') + .that.is.a('string') + .that.satisfies((msg: string) => msg.includes(`Date ${chatDate} between`)); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature disabled]', () => { + let callId: string | undefined; + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', true); + if (process.env.IS_EE) { + await updateSetting('VideoConf_Enable_Persistent_Chat', false); + } + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should not include a discussion room id on the response', async () => { + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.not.have.a.property('discussionRid'); + + expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/none`); + }); + }); + }); + + describe('[Persistent Chat provider with the persistent chat feature enabled but discussions disabled]', () => { + let callId: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('Discussion_enabled', false); + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId = res.body.data.callId; + }); + + it('should not include a discussion room id on the response', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('video-conference.info')) + .set(credentials) + .query({ + callId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('providerName').equal('persistentchat'); + expect(res.body).to.not.have.a.property('providerData'); + expect(res.body).to.have.a.property('_id').equal(callId); + expect(res.body).to.not.have.a.property('discussionRid'); + + expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/none`); + }); + }); + }); + }); + + describe('[/video-conference.list]', () => { + describe('[Test provider]', () => { + let callId1: string | undefined; + let callId2: string | undefined; + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + await updateSetting('Discussion_enabled', true); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + callId1 = res.body.data.callId; + + const res2 = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId2 = res2.body.data.callId; + }); + + it('should load the list of video conferences sorted by new', async () => { + await request + .get(api('video-conference.list')) + .set(credentials) + .query({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('count').that.is.greaterThanOrEqual(2); + expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(res.body.count); + + const call2 = res.body.data[0]; + const call1 = res.body.data[1]; + + expect(call1).to.have.a.property('_id').equal(callId1); + expect(call1).to.have.a.property('url').equal(`test/videoconference/${callId1}/${roomName}`); + expect(call1).to.have.a.property('type').equal('videoconference'); + expect(call1).to.have.a.property('rid').equal(roomId); + expect(call1).to.have.a.property('users').that.is.an('array').with.lengthOf(0); + expect(call1).to.have.a.property('status').equal(1); + expect(call1).to.have.a.property('title').equal(roomName); + expect(call1).to.have.a.property('messages').that.is.an('object'); + expect(call1.messages).to.have.a.property('started').that.is.a('string'); + expect(call1).to.have.a.property('createdBy').that.is.an('object'); + expect(call1.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']); + expect(call1.createdBy).to.have.a.property('username').equal(adminUsername); + expect(call1).to.not.have.a.property('discussionRid'); + + expect(call2).to.have.a.property('_id').equal(callId2); + }); + }); + + it('should paginate video conferences list with count parameter', async () => { + await request + .get(api('video-conference.list')) + .set(credentials) + .query({ + roomId, + count: 1, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('total').that.is.greaterThanOrEqual(2); + expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(1); + expect(res.body.data[0]).to.have.a.property('_id').equal(callId2); + }); + }); + + it('should paginate video conferences list with offset parameter', async () => { + await request + .get(api('video-conference.list')) + .set(credentials) + .query({ + roomId, + count: 1, + offset: 1, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('total').that.is.greaterThanOrEqual(2); + expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(1); + expect(res.body.data[0]).to.have.a.property('_id').equal(callId1); + }); + }); + }); + + describe('[Persistent Chat Provider]', () => { + let callId1: string | undefined; + let callId2: string | undefined; + let callId3: string | undefined; + let callId4: string | undefined; + + before(async () => { + if (!process.env.IS_EE) { + return; + } + + await updateSetting('Discussion_enabled', true); + await updateSetting('VideoConf_Default_Provider', 'test'); + const res = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + callId1 = res.body.data.callId; + + await updateSetting('VideoConf_Default_Provider', 'persistentchat'); + await updateSetting('VideoConf_Enable_Persistent_Chat', false); + const res2 = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId2 = res2.body.data.callId; + + await updateSetting('VideoConf_Enable_Persistent_Chat', true); + const res3 = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId3 = res3.body.data.callId; + + await updateSetting('Discussion_enabled', false); + const res4 = await request.post(api('video-conference.start')).set(credentials).send({ + roomId, + }); + + callId4 = res4.body.data.callId; + }); + + it('should load the list of video conferences sorted by new', async function () { + if (!process.env.IS_EE) { + this.skip(); + } + + await request + .get(api('video-conference.list')) + .set(credentials) + .query({ + roomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + expect(res.body).to.have.a.property('count').that.is.greaterThanOrEqual(4); + expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(res.body.count); + + const call4 = res.body.data[0]; + const call3 = res.body.data[1]; + const call2 = res.body.data[2]; + const call1 = res.body.data[3]; + + expect(call1).to.have.a.property('_id').equal(callId1); + expect(call1).to.have.a.property('url').equal(`test/videoconference/${callId1}/${roomName}`); + expect(call1).to.have.a.property('type').equal('videoconference'); + expect(call1).to.have.a.property('rid').equal(roomId); + expect(call1).to.have.a.property('users').that.is.an('array').with.lengthOf(0); + expect(call1).to.have.a.property('status').equal(1); + expect(call1).to.have.a.property('title').equal(roomName); + expect(call1).to.have.a.property('messages').that.is.an('object'); + expect(call1.messages).to.have.a.property('started').that.is.a('string'); + expect(call1).to.have.a.property('createdBy').that.is.an('object'); + expect(call1.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']); + expect(call1.createdBy).to.have.a.property('username').equal(adminUsername); + expect(call1).to.not.have.a.property('discussionRid'); + + expect(call2).to.have.a.property('_id').equal(callId2); + expect(call2).to.not.have.a.property('discussionRid'); + + expect(call3).to.have.a.property('_id').equal(callId3); + expect(call3).to.have.a.property('discussionRid').that.is.a('string'); + + expect(call4).to.have.a.property('_id').equal(callId4); + expect(call4).to.not.have.a.property('discussionRid'); + }); + }); + }); + }); + + describe('[Read-Only Channel]', () => { + let readOnlyRoomId: string; + let regularUser: Awaited>; + let regularUserCredentials: Awaited>; + + before(async () => { + // Create a regular user + regularUser = await createUser({ username: `regular.user.${Date.now()}`, roles: ['user'] }); + regularUserCredentials = await login(regularUser.username, password); + + const readOnlyRoomRes = await createRoom({ + type: 'c', + name: `read-only-channel-${Date.now()}`, + username: undefined, + members: [regularUser.username], + credentials, + readOnly: true, + }); + readOnlyRoomId = readOnlyRoomRes.body.channel._id; + + // Set up video conference provider + await updateSetting('VideoConf_Default_Provider', 'test'); + }); + + before(async () => { + await updateSetting('VideoConf_Default_Provider', 'test'); + }); + + after(async () => { + await Promise.all([ + ...(readOnlyRoomId ? [deleteRoom({ type: 'c', roomId: readOnlyRoomId })] : []), + deleteUser(regularUser), + updatePermission('post-readonly', ['admin', 'owner', 'moderator']), + ]); + }); + + it('should fail to start a call in read-only channel as a regular user', async () => { + await request + .post(api('video-conference.start')) + .set(regularUserCredentials) + .send({ + roomId: readOnlyRoomId, + }) + .expect(403) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(false); + }); + }); + + it('should successfully start a call in read-only channel as admin', async () => { + await request + .post(api('video-conference.start')) + .set(credentials) + .send({ + roomId: readOnlyRoomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + }); + }); + + it('should successfully start a call in read-only channel as regular user with post-readonly permission', async () => { + await updatePermission('post-readonly', ['admin', 'owner', 'moderator', 'user']); + + await request + .post(api('video-conference.start')) + .set(regularUserCredentials) + .send({ + roomId: readOnlyRoomId, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body.success).to.be.equal(true); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/reporter.ts b/apps/meteor/tests/end-to-end/reporter.ts new file mode 100644 index 0000000..baa5c37 --- /dev/null +++ b/apps/meteor/tests/end-to-end/reporter.ts @@ -0,0 +1,23 @@ +import Mocha from 'mocha'; + +import { getLastRequest } from './teardown'; + +const { EVENT_TEST_FAIL } = Mocha.Runner.constants; + +module.exports = class FailDumpReporter extends Mocha.reporters.Spec { + constructor(runner: Mocha.Runner, options: Mocha.MochaOptions) { + super(runner, options); + runner.on(EVENT_TEST_FAIL, (runnable) => { + const { lastUrl, lastMethod, lastBody, lastQuery, lastResponse } = getLastRequest(); + console.log({ + where: runnable.fullTitle(), + type: runnable.type, + lastUrl, + lastMethod, + lastBody, + lastQuery, + lastResponse: lastResponse?.text, + }); + }); + } +}; diff --git a/apps/meteor/tests/end-to-end/teardown.ts b/apps/meteor/tests/end-to-end/teardown.ts new file mode 100644 index 0000000..286d807 --- /dev/null +++ b/apps/meteor/tests/end-to-end/teardown.ts @@ -0,0 +1,40 @@ +import type { Response } from 'supertest'; + +import { request } from '../data/api-data'; + +const methods = ['get', 'post', 'put', 'del', 'delete'] as const; + +let lastUrl: string; +let lastMethod: string; +let lastBody: unknown; +let lastQuery: unknown; +let lastResponse: Response | undefined; + +methods.forEach((method) => { + const original = request[method]; + request[method] = function (url) { + lastUrl = url; + lastMethod = method; + lastBody = undefined; + lastQuery = undefined; + lastResponse = undefined; + + const test = original(url); + const originalSend = test.send.bind(test); + const originalQuery = test.query.bind(test); + test.send = (data) => { + lastBody = data; + return originalSend(data); + }; + test.query = (data) => { + lastQuery = data; + return originalQuery(data); + }; + + return test.expect((res) => { + lastResponse = res; + }); + }; +}); + +export const getLastRequest = () => ({ lastUrl, lastMethod, lastBody, lastQuery, lastResponse }); diff --git a/apps/meteor/tests/environments/timezone.ts b/apps/meteor/tests/environments/timezone.ts new file mode 100644 index 0000000..392a39a --- /dev/null +++ b/apps/meteor/tests/environments/timezone.ts @@ -0,0 +1,25 @@ +import type { JestEnvironmentConfig, EnvironmentContext } from '@jest/environment'; +import JSDOMEnvironment from 'jest-environment-jsdom'; + +// Pins the test's timezone. +// the (jsdom) environment and the timezone gets locked in; a beforeAll() is too late. +module.exports = class TimezoneEnvironment extends JSDOMEnvironment { + private previousTZ: string | undefined; + + constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { + const { timezone = 'UTC' } = config.projectConfig.testEnvironmentOptions as { timezone?: string }; + const previousTZ: string | undefined = process.env.TZ; + process.env.TZ = timezone; + super(config, context); + this.previousTZ = previousTZ; + } + + override async teardown(): Promise { + if (this.previousTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = this.previousTZ; + } + await super.teardown(); + } +}; diff --git a/apps/meteor/tests/mocks/client/FakeChatProvider.tsx b/apps/meteor/tests/mocks/client/FakeChatProvider.tsx new file mode 100644 index 0000000..39cc852 --- /dev/null +++ b/apps/meteor/tests/mocks/client/FakeChatProvider.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; + +import { ChatContext } from '../../../client/views/room/contexts/ChatContext'; +import { createFakeSubscription } from '../data'; + +export type FakeChatProviderProps = { + children?: ReactNode; +}; + +const FakeChatProvider = ({ children }: FakeChatProviderProps) => { + return ( + { + return createFakeSubscription(); + }, + }, + } as any + } + > + {children} + + ); +}; + +export default FakeChatProvider; diff --git a/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx b/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx new file mode 100644 index 0000000..53e3505 --- /dev/null +++ b/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { RoomContext } from '../../../client/views/room/contexts/RoomContext'; +import { createFakeRoom, createFakeSubscription } from '../data'; + +export type FakeRoomProviderProps = { + children?: ReactNode; + roomOverrides?: Partial; + subscriptionOverrides?: Partial; +}; + +const FakeRoomProvider = ({ children, roomOverrides, subscriptionOverrides }: FakeRoomProviderProps) => { + return ( + { + const room = createFakeRoom(roomOverrides); + const subscription = createFakeSubscription(subscriptionOverrides); + + return { + rid: room._id, + room, + hasMoreNextMessages: faker.datatype.boolean(), + hasMorePreviousMessages: faker.datatype.boolean(), + isLoadingMoreMessages: faker.datatype.boolean(), + subscription, + }; + }, [roomOverrides, subscriptionOverrides])} + > + {children} + + ); +}; + +export default FakeRoomProvider; diff --git a/apps/meteor/tests/mocks/client/ModalContextMock.tsx b/apps/meteor/tests/mocks/client/ModalContextMock.tsx new file mode 100644 index 0000000..8f3a2bc --- /dev/null +++ b/apps/meteor/tests/mocks/client/ModalContextMock.tsx @@ -0,0 +1,22 @@ +import { ModalContext } from '@rocket.chat/ui-contexts'; +import type { ContextType, ReactNode } from 'react'; +import { useMemo } from 'react'; + +export type ModalContextMockProps = { + children: ReactNode; +}; + +const ModalContextMock = ({ children }: ModalContextMockProps) => { + const value = useMemo( + () => ({ + modal: { + setModal: (): void => undefined, + }, + }), + [], + ); + + return }>{children}; +}; + +export default ModalContextMock; diff --git a/apps/meteor/tests/mocks/client/RouterContextMock.tsx b/apps/meteor/tests/mocks/client/RouterContextMock.tsx new file mode 100644 index 0000000..7753778 --- /dev/null +++ b/apps/meteor/tests/mocks/client/RouterContextMock.tsx @@ -0,0 +1,119 @@ +import type { To, SearchParameters, LocationPathname, LocationSearch } from '@rocket.chat/ui-contexts'; +import { RouterContext } from '@rocket.chat/ui-contexts'; +import { compile } from 'path-to-regexp'; +import { useRef, useMemo } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; + +const encodeSearchParameters = (searchParameters: SearchParameters) => { + const search = new URLSearchParams(); + + for (const [key, value] of Object.entries(searchParameters)) { + search.append(key, value); + } + + const searchString = search.toString(); + + return searchString ? (`?${searchString}` as `?${LocationSearch}`) : ''; +}; + +const buildRoutePath = (to: To): LocationPathname | `${LocationPathname}?${LocationSearch}` => { + if (typeof to === 'string') { + return to; + } + + if ('pathname' in to) { + const { pathname, search = {} } = to; + return (pathname + encodeSearchParameters(search)) as LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + if ('pattern' in to) { + const { pattern, params = {}, search = {} } = to; + return (compile(pattern, { encode: encodeURIComponent })(params) + encodeSearchParameters(search)) as + LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + if ('name' in to) { + const { name, params = {}, search = {} } = to; + + switch (name) { + case 'audit-home': + return `/audit${encodeSearchParameters(search)}`; + + case 'audit-log': + return `/audit-log${encodeSearchParameters(search)}`; + + case 'marketplace': + return `/marketplace/${params.context}/${params.page}${encodeSearchParameters(search)}`; + } + + return (compile(name, { encode: encodeURIComponent })(params) + encodeSearchParameters(search)) as + LocationPathname | `${LocationPathname}?${LocationSearch}`; + } + + throw new Error('Invalid route'); +}; + +export type RouterContextMockProps = { + children?: ReactNode; + navigate?: (toOrDelta: number | To) => void; + currentPath?: MutableRefObject; + searchParameters?: Record; + routeParameters?: Record; +}; + +const RouterContextMock = ({ children, navigate, currentPath, searchParameters = {}, routeParameters = {} }: RouterContextMockProps) => { + const history = useRef<{ stack: To[]; index: number }>({ stack: ['/'], index: 0 }); + + if (currentPath) { + currentPath.current = buildRoutePath(history.current.stack[history.current.index]); + } + + return ( + { + return { + subscribeToRouteChange: () => () => undefined, + getLocationPathname: () => '/', + getLocationSearch: () => '', + getLocationHash: () => '', + getRouteParameters: () => routeParameters, + getSearchParameters: () => searchParameters, + getRouteName: () => 'home', + getPreviousRouteName: () => undefined, + buildRoutePath, + navigate: + navigate ?? + ((toOrDelta: number | To) => { + if (typeof toOrDelta === 'number') { + history.current.index += toOrDelta; + + if (history.current.index < 0) { + history.current.index = 0; + } + + if (history.current.index >= history.current.stack.length) { + history.current.index = history.current.stack.length - 1; + } + + return; + } + + history.current.stack = history.current.stack.slice(0, history.current.index + 1); + history.current.stack.push(toOrDelta); + history.current.index = history.current.stack.length - 1; + + if (currentPath) { + currentPath.current = buildRoutePath(history.current.stack[history.current.index]); + } + }), + defineRoutes: () => () => undefined, + getRoomRoute: () => ({ path: '/' }), + }; + }, [currentPath, navigate, routeParameters, searchParameters])} + > + {children} + + ); +}; + +export default RouterContextMock; diff --git a/apps/meteor/tests/mocks/client/ServerProviderMock.tsx b/apps/meteor/tests/mocks/client/ServerProviderMock.tsx new file mode 100644 index 0000000..7460634 --- /dev/null +++ b/apps/meteor/tests/mocks/client/ServerProviderMock.tsx @@ -0,0 +1,91 @@ +import type { Serialized } from '@rocket.chat/core-typings'; +import type { Method, PathPattern, OperationParams, UrlParams, OperationResult } from '@rocket.chat/rest-typings'; +import type { EndpointFunction, ServerContextValue } from '@rocket.chat/ui-contexts'; +import { ServerContext } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import type { ContextType, ReactNode } from 'react'; + +type RegisterEndpoint = ( + method: TMethod, + pathPattern: TPathPattern, + endpoint: EndpointFunction, +) => void; + +type CallEndpoint = (args: { + method: TMethod; + pathPattern: TPathPattern; + keys: UrlParams; + params: OperationParams; +}) => Promise>>; + +// this function should be used to create a new instance of `callEndpoint` to be passed to the `ServerProviderMock` +// as the second parameter, it returns a function that can be used to register endpoint mocks +// the rest should be self-explanatory, just rely on the types. +export const makeCallEndpoint = (): [CallEndpoint, RegisterEndpoint] => { + const endpoints = new Map<`${Method}/${PathPattern}`, (params: any) => Promise>(); + + const getEndpoint = ( + method: TMethod, + pathPattern: TPathPattern, + ): EndpointFunction => { + const endpoint = endpoints.get(`${method}/${pathPattern}`); + if (!endpoint) { + throw new Error('Endpoint not implemented'); + } + + return endpoint as EndpointFunction; + }; + + const registerEndpoint: RegisterEndpoint = (method, pathPattern, endpoint) => { + endpoints.set(`${method}/${pathPattern}`, endpoint); + }; + + const callEndpoint: CallEndpoint = (args) => { + const endpoint = getEndpoint(args.method, args.pathPattern); + if (!endpoint) { + throw new Error('Endpoint not implemented'); + } + return endpoint(args.params); + }; + + return [callEndpoint, registerEndpoint]; +}; + +const absoluteUrl = () => ''; // to be implemented +const uploadToEndpoint = async () => { + throw new Error('not implemented'); +}; // to be implemented +const getStream = () => () => () => undefined; // to be implemented +const getStreamAll = () => () => () => undefined; // to be implemented +const callEndpoint = () => { + throw new Error('not implemented'); +}; // to be implemented +const writeStream = () => undefined; // to be implemented + +const contextValue: ServerContextValue = { + connected: true, + status: 'connected', + retryCount: 0, + info: undefined, + absoluteUrl, + // callMethod, + callEndpoint, + uploadToEndpoint, + getStream, + getStreamAll, + reconnect: () => undefined, + disconnect: () => undefined, + writeStream, +}; + +export type ServerProviderMockProps = { + children?: ReactNode; + callEndpoint?: ContextType['callEndpoint']; +}; + +const ServerProviderMock = ({ children, callEndpoint }: ServerProviderMockProps) => { + const value = useMemo(() => ({ ...contextValue, callEndpoint: callEndpoint ?? contextValue.callEndpoint }), [callEndpoint]); + return {children}; +}; + +export default ServerProviderMock; diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx new file mode 100644 index 0000000..a571ddf --- /dev/null +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -0,0 +1,47 @@ +import { AppClientManager } from '@rocket.chat/apps/dist/client/AppClientManager'; +import { AppsEngineUIHost } from '@rocket.chat/apps/dist/client/AppsEngineUIHost'; +import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo'; +import type { ReactNode } from 'react'; + +import { AppsContext, type IAppsOrchestrator } from '../../../client/contexts/AppsContext'; +import { createFakeExternalComponentRoomInfo, createFakeExternalComponentUserInfo } from '../data'; + +class MockedAppsEngineUIHost extends AppsEngineUIHost { + public async getClientRoomInfo(): Promise { + return createFakeExternalComponentRoomInfo(); + } + + public async getClientUserInfo() { + return createFakeExternalComponentUserInfo(); + } +} + +class MockedAppClientManager extends AppClientManager {} + +export const mockAppsOrchestrator = () => { + const appsEngineUIHost = new MockedAppsEngineUIHost(); + const manager = new MockedAppClientManager(appsEngineUIHost); + + const orchestrator: IAppsOrchestrator = { + load: () => Promise.resolve(), + getAppClientManager: () => manager, + handleError: () => undefined, + getInstalledApps: async () => [], + getAppsFromMarketplace: async () => ({ apps: [] }), + getAppsOnBundle: async () => [], + getApp: () => Promise.reject(new Error('not implemented')), + setAppSettings: async () => undefined, + installApp: () => Promise.reject(new Error('not implemented')), + updateApp: () => Promise.reject(new Error('not implemented')), + buildExternalUrl: () => Promise.reject(new Error('not implemented')), + buildExternalAppRequest: () => Promise.reject(new Error('not implemented')), + buildIncompatibleExternalUrl: () => Promise.reject(new Error('not implemented')), + getCategories: () => Promise.reject(new Error('not implemented')), + }; + + return orchestrator; +}; + +export const mockedAppsContext = (children: ReactNode) => ( + {children} +); diff --git a/apps/meteor/tests/mocks/client/meteor.ts b/apps/meteor/tests/mocks/client/meteor.ts new file mode 100644 index 0000000..466ccc1 --- /dev/null +++ b/apps/meteor/tests/mocks/client/meteor.ts @@ -0,0 +1,57 @@ +import { jest } from '@jest/globals'; + +export const Meteor = { + loginWithSamlToken: jest.fn((_token, callback: () => void) => callback()), + connection: { + _stream: { on: jest.fn() }, + }, + _localStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + }, + users: {}, + userId: () => 'uid', + _SynchronousQueue: class _SynchronousQueue { + drain = jest.fn(); + }, + _runFresh: jest.fn(), + _isPromise: jest.fn(() => false), + defer: jest.fn((fn: () => void) => queueMicrotask(fn)), +}; + +export const Mongo = { + Collection: class Collection { + _collection = { + _docs: {}, + }; + + find = jest.fn(() => ({ + fetch: jest.fn(() => []), + observe: jest.fn(), + })); + + findOne = jest.fn(); + + update = jest.fn(); + }, +}; + +export const Accounts = { + onLogin: jest.fn(), + onLogout: jest.fn(), +}; + +export const Tracker = { autorun: jest.fn() }; + +export const ReactiveVar = class ReactiveVar {}; + +export const EJSON = { + isBinary: jest.fn(() => false), + clone: jest.fn((obj) => obj), + equals: jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)), +}; + +export const DDPCommon = { + parseDDP: jest.fn((msg: string) => JSON.parse(msg)), + stringifyDDP: jest.fn((msg: unknown) => JSON.stringify(msg)), +}; diff --git a/apps/meteor/tests/mocks/client/mockRetentionPolicySettings.ts b/apps/meteor/tests/mocks/client/mockRetentionPolicySettings.ts new file mode 100644 index 0000000..87a53fa --- /dev/null +++ b/apps/meteor/tests/mocks/client/mockRetentionPolicySettings.ts @@ -0,0 +1,37 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; + +export const createRenteionPolicySettingsMock = ({ + enabled = true, + filesOnly = false, + doNotPrunePinned = false, + appliesToChannels = false, + TTLChannels = 60000, + appliesToGroups = false, + TTLGroups = 60000, + appliesToDMs = false, + TTLDMs = 60000, + precision = '0', + advancedPrecision = false, + advancedPrecisionCron = '*/30 * * * *', +} = {}) => { + return mockAppRoot() + .withTranslations('en', 'core', { + RetentionPolicy_RoomWarning_NextRunDate: '{{maxAge}} {{nextRunDate}}', + RetentionPolicy_RoomWarning_Unpinned_NextRunDate: 'Unpinned {{maxAge}} {{nextRunDate}}', + RetentionPolicy_RoomWarning_FilesOnly_NextRunDate: 'FilesOnly {{maxAge}} {{nextRunDate}}', + RetentionPolicy_RoomWarning_UnpinnedFilesOnly_NextRunDate: 'UnpinnedFilesOnly {{maxAge}} {{nextRunDate}}', + }) + .withSetting('RetentionPolicy_Enabled', enabled) + .withSetting('RetentionPolicy_FilesOnly', filesOnly) + .withSetting('RetentionPolicy_DoNotPrunePinned', doNotPrunePinned) + .withSetting('RetentionPolicy_AppliesToChannels', appliesToChannels) + .withSetting('RetentionPolicy_TTL_Channels', TTLChannels) + .withSetting('RetentionPolicy_AppliesToGroups', appliesToGroups) + .withSetting('RetentionPolicy_TTL_Groups', TTLGroups) + .withSetting('RetentionPolicy_AppliesToDMs', appliesToDMs) + .withSetting('RetentionPolicy_TTL_DMs', TTLDMs) + .withSetting('RetentionPolicy_Precision', precision) + .withSetting('RetentionPolicy_Advanced_Precision', advancedPrecision) + .withSetting('RetentionPolicy_Advanced_Precision_Cron', advancedPrecisionCron) + .build(); +}; diff --git a/apps/meteor/tests/mocks/data.ts b/apps/meteor/tests/mocks/data.ts new file mode 100644 index 0000000..29bf5eb --- /dev/null +++ b/apps/meteor/tests/mocks/data.ts @@ -0,0 +1,502 @@ +import { faker } from '@faker-js/faker'; +import type { IExternalComponentRoomInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentRoomInfo'; +import type { IExternalComponentUserInfo } from '@rocket.chat/apps/dist/client/definition/IExternalComponentUserInfo'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; +import { + AppSubscriptionStatus, + ILivechatAgentStatus, + LivechatPriorityWeight, + OmnichannelSourceType, + UserStatus, +} from '@rocket.chat/core-typings'; +import type { + LicenseInfo, + App, + IMessage, + IRoom, + IUser, + ILivechatContactChannel, + Serialized, + ILivechatAgent, + ILivechatTag, + IOmnichannelBusinessUnit, + ILivechatDepartment, + ILivechatMonitor, + IOmnichannelRoom, +} from '@rocket.chat/core-typings'; +import { parse } from '@rocket.chat/message-parser'; +import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; + +import type { MessageWithMdEnforced } from '../../client/lib/parseMessageTextToAstMarkdown'; + +export function createFakeUser(overrides?: Partial & Omit): TUser; +export function createFakeUser(overrides?: Partial): IUser { + return { + _id: faker.database.mongodbObjectId(), + _updatedAt: faker.date.recent(), + username: faker.internet.userName(), + name: faker.person.fullName(), + createdAt: faker.date.recent(), + roles: ['user'], + active: faker.datatype.boolean(), + type: 'user', + ...overrides, + }; +} + +export const createFakeRoom = (overrides?: Partial): T => + ({ + _id: faker.database.mongodbObjectId(), + _updatedAt: faker.date.recent(), + t: faker.helpers.arrayElement(['c', 'p', 'd']), + msgs: faker.number.int({ min: 0 }), + u: { + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + name: faker.person.fullName(), + ...overrides?.u, + }, + usersCount: faker.number.int({ min: 0 }), + autoTranslateLanguage: faker.helpers.arrayElement(['en', 'es', 'pt', 'ar', 'it', 'ru', 'fr']), + ...overrides, + }) as T; + +export const createFakeOmnichannelRoom = (overrides?: Partial): IOmnichannelRoom => ({ + _id: faker.database.mongodbObjectId(), + _updatedAt: faker.date.recent(), + t: 'l', + msgs: faker.number.int({ min: 0 }), + ts: faker.date.recent(), + u: { + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + name: faker.person.fullName(), + ...overrides?.u, + }, + v: { + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + name: faker.person.fullName(), + status: UserStatus.ONLINE, + token: faker.string.uuid(), + activity: [], + ...overrides?.v, + }, + source: { + type: OmnichannelSourceType.WIDGET, + ...overrides?.source, + }, + usersCount: faker.number.int({ min: 0 }), + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: faker.number.int({ min: 0, max: 100 }), + waitingResponse: faker.datatype.boolean(), + livechatData: {}, + ...overrides, +}); + +export const createFakeSubscription = (overrides?: Partial): SubscriptionWithRoom => ({ + _id: faker.database.mongodbObjectId(), + _updatedAt: faker.date.recent(), + u: { + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + name: faker.person.fullName(), + ...overrides?.u, + }, + rid: faker.database.mongodbObjectId(), + open: faker.datatype.boolean(), + ts: faker.date.recent(), + name: faker.person.fullName(), + unread: faker.number.int({ min: 0 }), + t: faker.helpers.arrayElement(['c', 'p', 'd']), + ls: faker.date.recent(), + lr: faker.date.recent(), + userMentions: faker.number.int({ min: 0 }), + groupMentions: faker.number.int({ min: 0 }), + lowerCaseName: faker.person.fullName().toLowerCase(), + lowerCaseFName: faker.person.fullName().toLowerCase(), + usersCount: faker.number.int({ min: 0 }), + waitingResponse: faker.datatype.boolean(), + priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, + estimatedWaitingTimeQueue: faker.number.int({ min: 0, max: 100 }), + livechatData: faker.date.recent(), + ...overrides, +}); + +export function createFakeMessage(overrides?: Partial & Omit): TMessage; +export function createFakeMessage(overrides?: Partial): IMessage { + return { + _id: faker.database.mongodbObjectId(), + _updatedAt: faker.date.recent(), + rid: faker.database.mongodbObjectId(), + msg: faker.lorem.sentence(), + ts: faker.date.recent(), + u: { + _id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + name: faker.person.fullName(), + ...overrides?.u, + }, + ...overrides, + }; +} + +export function createFakeMessageWithMd( + overrides?: Partial>, +): MessageWithMdEnforced; +export function createFakeMessageWithMd(overrides?: Partial>): MessageWithMdEnforced { + const fakeMessage = createFakeMessage(overrides); + + return { + ...fakeMessage, + md: parse(fakeMessage.msg), + ...overrides, + }; +} + +export function createFakeApp(partialApp: Partial = {}): App { + const appId = faker.database.mongodbObjectId(); + + const app: App = { + id: appId, + iconFileData: faker.image.dataUri(), + name: faker.commerce.productName(), + appRequestStats: { + appId: partialApp.id ?? appId, + totalSeen: faker.number.int({ min: 0, max: 100 }), + totalUnseen: faker.number.int({ min: 0, max: 100 }), + }, + author: { + name: faker.company.name(), + homepage: faker.internet.url(), + support: faker.internet.email(), + }, + description: faker.lorem.paragraph(), + shortDescription: faker.lorem.sentence(), + privacyPolicySummary: faker.lorem.sentence(), + detailedDescription: { + raw: faker.lorem.paragraph(), + rendered: faker.lorem.paragraph(), + }, + detailedChangelog: { + raw: faker.lorem.paragraph(), + rendered: faker.lorem.paragraph(), + }, + categories: [], + version: faker.system.semver(), + versionIncompatible: faker.datatype.boolean(), + price: faker.number.float({ min: 0, max: 1000 }), + purchaseType: faker.helpers.arrayElement(['buy', 'subscription']), + pricingPlans: [], + iconFileContent: faker.image.dataUri(), + isSubscribed: faker.datatype.boolean(), + bundledIn: [], + marketplaceVersion: faker.system.semver(), + get latest() { + return app; + }, + subscriptionInfo: { + typeOf: faker.lorem.word(), + status: faker.helpers.enumValue(AppSubscriptionStatus), + statusFromBilling: faker.datatype.boolean(), + isSeatBased: faker.datatype.boolean(), + seats: faker.number.int({ min: 0, max: 50 }), + maxSeats: faker.number.int({ min: 50, max: 100 }), + license: { + license: faker.lorem.word(), + version: faker.number.int({ min: 0, max: 3 }), + expireDate: faker.date.future().toISOString(), + }, + startDate: faker.date.past().toISOString(), + periodEnd: faker.date.future().toISOString(), + endDate: faker.date.future().toISOString(), + isSubscribedViaBundle: faker.datatype.boolean(), + }, + tosLink: faker.internet.url(), + privacyLink: faker.internet.url(), + modifiedAt: faker.date.recent().toISOString(), + permissions: faker.helpers.multiple(() => ({ + name: faker.hacker.verb(), + required: faker.datatype.boolean(), + })), + languages: faker.helpers.multiple(() => faker.location.countryCode()), + createdDate: faker.date.past().toISOString(), + private: faker.datatype.boolean(), + documentationUrl: faker.internet.url(), + migrated: faker.datatype.boolean(), + ...partialApp, + }; + + return app; +} + +export const createFakeExternalComponentUserInfo = (partial: Partial = {}): IExternalComponentUserInfo => ({ + id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + avatarUrl: faker.image.avatar(), + ...partial, +}); + +export const createFakeExternalComponentRoomInfo = (partial: Partial = {}): IExternalComponentRoomInfo => ({ + id: faker.database.mongodbObjectId(), + members: faker.helpers.multiple(createFakeExternalComponentUserInfo), + slugifiedName: faker.lorem.slug(), + ...partial, +}); + +export const createFakeLicenseInfo = (partial: Partial> = {}): Omit => ({ + activeModules: faker.helpers.arrayElements([ + 'auditing', + 'canned-responses', + 'ldap-enterprise', + 'livechat-enterprise', + 'voip-enterprise', + 'omnichannel-mobile-enterprise', + 'engagement-dashboard', + 'push-privacy', + 'scalability', + 'teams-mention', + 'saml-enterprise', + 'oauth-enterprise', + 'device-management', + 'federation', + 'videoconference-enterprise', + 'message-read-receipt', + 'outlook-calendar', + 'hide-watermark', + 'custom-roles', + 'accessibility-certification', + 'outbound-messaging', + 'abac', + ]), + externalModules: [], + preventedActions: { + activeUsers: faker.datatype.boolean(), + guestUsers: faker.datatype.boolean(), + roomsPerGuest: faker.datatype.boolean(), + privateApps: faker.datatype.boolean(), + marketplaceApps: faker.datatype.boolean(), + monthlyActiveContacts: faker.datatype.boolean(), + }, + limits: { + activeUsers: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + guestUsers: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + roomsPerGuest: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + privateApps: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + marketplaceApps: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + monthlyActiveContacts: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + }, + tags: faker.helpers.multiple(() => ({ + name: faker.commerce.productAdjective(), + color: faker.internet.color(), + })), + trial: faker.datatype.boolean(), + hasValidLicense: faker.datatype.boolean(), + ...partial, +}); + +export function createFakeMessageWithAttachment(overrides?: Partial): TMessage; +export function createFakeMessageWithAttachment(overrides?: Partial): IMessage { + const fakeMessage = createFakeMessage(overrides); + const fileId = faker.database.mongodbObjectId(); + const fileName = faker.system.commonFileName('txt'); + + return { + ...fakeMessage, + msg: '', + file: { + _id: fileId, + name: fileName, + type: 'text/plain', + size: faker.number.int(), + format: faker.string.alpha(), + }, + attachments: [ + { + type: 'file', + title: fileName, + title_link: `/file-upload/${fileId}/${fileName}`, + }, + ], + files: [ + { + _id: fileId, + name: fileName, + type: 'text/plain', + size: faker.number.int(), + format: faker.string.alpha(), + }, + ], + ...overrides, + }; +} + +const guestNames = faker.helpers.uniqueArray(faker.person.firstName, 1000); + +function pullNextVisitorName() { + const guestName = guestNames.pop(); + + if (!guestName) { + throw new Error('exhausted guest names'); + } + + return guestName; +} + +export function createFakeVisitor() { + return { + name: pullNextVisitorName(), + email: faker.internet.email(), + } as const; +} + +export function createFakeContactChannel(overrides?: Partial>): Serialized { + return { + name: 'widget', + blocked: false, + verified: false, + ...overrides, + visitor: { + visitorId: faker.string.uuid(), + source: { + type: OmnichannelSourceType.WIDGET, + }, + ...overrides?.visitor, + }, + details: { + type: OmnichannelSourceType.WIDGET, + destination: '', + ...overrides?.details, + }, + }; +} + +export function createFakeContact(overrides?: Partial>): Serialized { + return { + _id: faker.string.uuid(), + _updatedAt: new Date().toISOString(), + name: pullNextVisitorName(), + phones: [{ phoneNumber: faker.phone.number() }], + emails: [{ address: faker.internet.email() }], + unknown: true, + channels: [createFakeContactChannel()], + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +export function createFakeContactWithManagerData( + overrides?: Partial>, +): Serialized { + const { contactManager: contactManagerOverwrites, ...contactOverwrites } = overrides || {}; + const contact = createFakeContact(contactOverwrites); + return { + ...contact, + contactManager: { + _id: faker.string.uuid(), + name: faker.person.fullName(), + username: faker.internet.userName(), + ...contactManagerOverwrites, + }, + }; +} + +export function createFakeAgent(overrides?: Partial>): Serialized { + const email = faker.internet.email(); + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const username = faker.internet.userName({ firstName, lastName }); + + return { + _id: faker.string.uuid(), + username, + status: UserStatus.ONLINE, + statusLivechat: ILivechatAgentStatus.AVAILABLE, + name: `${firstName} ${lastName}`, + emails: [ + { + address: email, + verified: false, + }, + ], + livechat: { + maxNumberSimultaneousChat: 0, + }, + lastRoutingTime: '', + livechatCount: 0, + active: true, + createdAt: new Date().toISOString(), + _updatedAt: new Date().toISOString(), + roles: [], + type: '', + ...overrides, + }; +} + +export function createFakeTag(overrides?: Partial>): Serialized { + return { + _id: faker.string.uuid(), + name: faker.commerce.department(), + description: 'description', + numDepartments: 0, + departments: [], + _updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +export function createFakeBusinessUnit(overrides?: Partial>): Serialized { + return { + _id: faker.string.uuid(), + name: faker.commerce.department(), + visibility: 'public', + type: 'u', + numMonitors: 1, + numDepartments: 1, + _updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +export const createFakeDepartment = (overrides: Partial> = {}): Serialized => ({ + _id: faker.string.uuid(), + name: `${faker.commerce.department()} ${faker.string.uuid()}`, + enabled: true, + email: faker.internet.email(), + showOnRegistration: false, + showOnOfflineForm: false, + type: 'd', + _updatedAt: new Date().toISOString(), + offlineMessageChannelName: '', + numAgents: 0, + ancestors: undefined, + parentId: undefined, + ...overrides, +}); + +export function createFakeMonitor(overrides?: Partial>): Serialized { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const username = faker.internet.userName({ firstName, lastName }); + + return { + _id: faker.string.uuid(), + username, + name: `${firstName} ${lastName}`, + type: '', + enabled: true, + numMonitors: 0, + visibility: 'visible', + ...overrides, + }; +} + +export const createMockedPagination = (results = 0, total = 0) => ({ + current: 0, + setCurrent: () => undefined, + itemsPerPage: 25 as const, + setItemsPerPage: () => undefined, + itemsPerPageLabel: () => 'Items per page:', + showingResultsLabel: () => `Showing results 1 - ${results} of ${total}`, +}); diff --git a/apps/meteor/tests/mocks/files/audio_mock.mp3 b/apps/meteor/tests/mocks/files/audio_mock.mp3 new file mode 100644 index 0000000..2461008 Binary files /dev/null and b/apps/meteor/tests/mocks/files/audio_mock.mp3 differ diff --git a/apps/meteor/tests/mocks/files/diagram.drawio b/apps/meteor/tests/mocks/files/diagram.drawio new file mode 100644 index 0000000..a86c267 --- /dev/null +++ b/apps/meteor/tests/mocks/files/diagram.drawio @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/meteor/tests/mocks/files/exif-orientation-6.jpg b/apps/meteor/tests/mocks/files/exif-orientation-6.jpg new file mode 100644 index 0000000..af10b0c Binary files /dev/null and b/apps/meteor/tests/mocks/files/exif-orientation-6.jpg differ diff --git a/apps/meteor/tests/mocks/files/sample-jpeg.jpg b/apps/meteor/tests/mocks/files/sample-jpeg.jpg new file mode 100644 index 0000000..7ef071c Binary files /dev/null and b/apps/meteor/tests/mocks/files/sample-jpeg.jpg differ diff --git a/apps/meteor/tests/mocks/files/sample-tiff.tiff b/apps/meteor/tests/mocks/files/sample-tiff.tiff new file mode 100644 index 0000000..c8283ea Binary files /dev/null and b/apps/meteor/tests/mocks/files/sample-tiff.tiff differ diff --git a/apps/meteor/tests/mocks/server/BrokerMocked.ts b/apps/meteor/tests/mocks/server/BrokerMocked.ts new file mode 100644 index 0000000..bf8ed61 --- /dev/null +++ b/apps/meteor/tests/mocks/server/BrokerMocked.ts @@ -0,0 +1,41 @@ +export class BrokerMocked { + actions: Record Promise> = {}; + + services: Map = new Map(); + + async destroyService(name: string): Promise { + this.services.delete(name); + } + + createService(instance: any): void { + this.services.set(instance.name, instance); + } + + async call(method: string, data: any): Promise { + return this.actions[method](data); + } + + async broadcastToServices(): Promise { + // no op + } + + async broadcast(): Promise { + // no op + } + + async broadcastLocal(): Promise { + // no op + } + + async nodeList(): Promise { + // no op + } + + async start(): Promise { + // no op + } + + mockServices(actions: Record Promise>) { + this.actions = actions; + } +} diff --git a/apps/meteor/tests/mocks/server/mongodb.ts b/apps/meteor/tests/mocks/server/mongodb.ts new file mode 100644 index 0000000..ea0be45 --- /dev/null +++ b/apps/meteor/tests/mocks/server/mongodb.ts @@ -0,0 +1,10 @@ +import proxyquire from 'proxyquire'; + +proxyquire.noCallThru().load('mongodb', { + ObjectId: class ObjectId { + toHexString(): string { + return 'hexString'; + } + }, + global: true, +}); diff --git a/apps/meteor/tests/mocks/utils/FakeResponse.ts b/apps/meteor/tests/mocks/utils/FakeResponse.ts new file mode 100644 index 0000000..8ce0fe5 --- /dev/null +++ b/apps/meteor/tests/mocks/utils/FakeResponse.ts @@ -0,0 +1,25 @@ +export class FakeResponse { + private _body: string; + + status: number; + + headers: Record; + + constructor(body: string, init: { status?: number; headers?: Record } = {}) { + this._body = body; + this.status = init.status ?? 200; + this.headers = init.headers ?? {}; + } + + get ok() { + return this.status >= 200 && this.status < 300; + } + + async json() { + return JSON.parse(this._body); + } + + async text() { + return this._body; + } +} diff --git a/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts new file mode 100644 index 0000000..96e187c --- /dev/null +++ b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts @@ -0,0 +1,19 @@ +export function createDeferredPromise() { + let resolve!: (value: R | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createDeferredMockFn() { + const deferred = createDeferredPromise(); + const fn = jest.fn(() => deferred.promise); + return { ...deferred, fn }; +} + +export default createDeferredMockFn; diff --git a/apps/meteor/tests/mocks/utils/createMockGlobalStore.ts b/apps/meteor/tests/mocks/utils/createMockGlobalStore.ts new file mode 100644 index 0000000..47cefb8 --- /dev/null +++ b/apps/meteor/tests/mocks/utils/createMockGlobalStore.ts @@ -0,0 +1,8 @@ +export function createMockGlobalStore(records: any[]) { + return { + use: (selector: any) => selector(records), + get state() { + return records; + }, + }; +} diff --git a/apps/meteor/tests/mocks/utils/index.ts b/apps/meteor/tests/mocks/utils/index.ts new file mode 100644 index 0000000..fc3d3cf --- /dev/null +++ b/apps/meteor/tests/mocks/utils/index.ts @@ -0,0 +1,11 @@ +export { createMockGlobalStore } from './createMockGlobalStore'; + +export const emptyUnread = { + userMentions: 0, + groupMentions: 0, + unread: 0, + tunread: undefined, + tunreadUser: undefined, + tunreadGroup: undefined, + alert: false, +}; diff --git a/apps/meteor/tests/mocks/utils/video-conference.ts b/apps/meteor/tests/mocks/utils/video-conference.ts new file mode 100644 index 0000000..f00f9dd --- /dev/null +++ b/apps/meteor/tests/mocks/utils/video-conference.ts @@ -0,0 +1,31 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom, VideoConferenceType } from '@rocket.chat/core-typings'; + +const callId = faker.database.mongodbObjectId(); +const uid = faker.database.mongodbObjectId(); + +export function createFakeVideoConfCall({ type, rid }: { type: VideoConferenceType; rid: IRoom['_id'] }) { + return { + type, + rid, + _id: callId, + status: 0, + createdBy: { + _id: uid, + username: faker.internet.userName(), + name: faker.person.fullName(), + }, + _updatedAt: faker.date.recent(), + createdAt: faker.date.recent(), + providerName: faker.company.name(), + }; +} + +export function createFakeIncomingCall({ rid }: { rid: IRoom['_id'] }) { + return { + rid, + uid, + callId, + dismissed: false, + }; +} diff --git a/apps/meteor/tests/setup/chaiPlugins.ts b/apps/meteor/tests/setup/chaiPlugins.ts new file mode 100644 index 0000000..de95167 --- /dev/null +++ b/apps/meteor/tests/setup/chaiPlugins.ts @@ -0,0 +1,10 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import chaiDatetime from 'chai-datetime'; +import chaiDom from 'chai-dom'; +import chaiSpies from 'chai-spies'; + +chai.use(chaiSpies); +chai.use(chaiDatetime); +chai.use(chaiDom); +chai.use(chaiAsPromised); diff --git a/apps/meteor/tests/unit/app/apps/server/codecs/enums.spec.ts b/apps/meteor/tests/unit/app/apps/server/codecs/enums.spec.ts new file mode 100644 index 0000000..8ab6f3e --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/codecs/enums.spec.ts @@ -0,0 +1,153 @@ +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import * as z from 'zod'; + +import { + RoomTypeCodec, + SettingTypeCodec, + UserStatusConnectionCodec, + UserTypeCodec, +} from '../../../../../../app/apps/server/converters/codecs/enums'; + +/* + * These are copies of the legacy private helpers the codecs replace. They act as the oracle: + * `decode` must produce identical output for every input the converters could see. + */ + +function legacyUserType(type: string | undefined): string { + switch (type) { + case 'user': + return UserType.USER; + case 'bot': + return UserType.BOT; + case 'app': + return UserType.APP; + case '': + case undefined: + return UserType.UNKNOWN; + default: + return type.toUpperCase(); + } +} + +function legacyStatusConnection(status: string | undefined): string { + switch (status) { + case 'offline': + return UserStatusConnection.OFFLINE; + case 'online': + return UserStatusConnection.ONLINE; + case 'away': + return UserStatusConnection.AWAY; + case 'busy': + return UserStatusConnection.BUSY; + case undefined: + return UserStatusConnection.UNDEFINED; + default: + return !status ? UserStatusConnection.OFFLINE : status.toUpperCase(); + } +} + +function legacyRoomType(typeChar: string): string { + switch (typeChar) { + case 'c': + return RoomType.CHANNEL; + case 'p': + return RoomType.PRIVATE_GROUP; + case 'd': + return RoomType.DIRECT_MESSAGE; + case 'l': + return RoomType.LIVE_CHAT; + default: + return typeChar; + } +} + +function legacySettingType(type: string): string { + switch (type) { + case 'boolean': + return SettingType.BOOLEAN; + case 'code': + return SettingType.CODE; + case 'color': + return SettingType.COLOR; + case 'font': + return SettingType.FONT; + case 'int': + return SettingType.NUMBER; + case 'select': + return SettingType.SELECT; + case 'string': + return SettingType.STRING; + default: + return type; + } +} + +describe('apps converter enum codecs', () => { + describe('UserTypeCodec', () => { + const inputs = ['user', 'bot', 'app', '', undefined, 'newfangled']; + + it('decode matches the legacy _convertUserTypeToEnum for every input', () => { + for (const input of inputs) { + expect(z.decode(UserTypeCodec, input)).to.equal(legacyUserType(input)); + } + }); + + it('encode passes the Apps-Engine value straight through', () => { + for (const value of Object.values(UserType)) { + expect(z.encode(UserTypeCodec, value)).to.equal(value); + } + }); + }); + + describe('UserStatusConnectionCodec', () => { + const inputs = ['offline', 'online', 'away', 'busy', '', undefined, 'weird']; + + it('decode matches the legacy _convertStatusConnectionToEnum for every input', () => { + for (const input of inputs) { + expect(z.decode(UserStatusConnectionCodec, input)).to.equal(legacyStatusConnection(input)); + } + }); + + it('encode passes the Apps-Engine value straight through', () => { + for (const value of Object.values(UserStatusConnection)) { + expect(z.encode(UserStatusConnectionCodec, value)).to.equal(value); + } + }); + }); + + describe('RoomTypeCodec', () => { + const inputs = ['c', 'p', 'd', 'l', 'v']; + + it('decode matches the legacy _convertTypeToApp for every input', () => { + for (const input of inputs) { + expect(z.decode(RoomTypeCodec, input)).to.equal(legacyRoomType(input)); + } + }); + + it('encode passes the Apps-Engine value straight through', () => { + for (const value of Object.values(RoomType)) { + expect(z.encode(RoomTypeCodec, value)).to.equal(value); + } + }); + }); + + describe('SettingTypeCodec', () => { + const inputs = ['boolean', 'code', 'color', 'font', 'int', 'select', 'string', 'multiSelect']; + + it('decode matches the legacy _convertTypeToApp for every input', () => { + for (const input of inputs) { + expect(z.decode(SettingTypeCodec, input)).to.equal(legacySettingType(input)); + } + }); + + it('encode passes the Apps-Engine value straight through', () => { + for (const value of Object.values(SettingType)) { + expect(z.encode(SettingTypeCodec, value)).to.equal(value); + } + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/codecs/mappedData.spec.ts b/apps/meteor/tests/unit/app/apps/server/codecs/mappedData.spec.ts new file mode 100644 index 0000000..8546440 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/codecs/mappedData.spec.ts @@ -0,0 +1,238 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import * as z from 'zod'; + +import { createMappedCodec, mappedDecode, mappedDecodeAsync } from '../../../../../../app/apps/server/converters/codecs/mappedData'; + +describe('createMappedCodec', () => { + const codec = createMappedCodec({ + id: '_id', + name: 'name', + scope: 'scope', + }); + + it('decode renames mapped fields and buckets the rest', () => { + const result = z.decode(codec, { _id: 'r1', name: 'admin', scope: 'Users', extra: 1, _updatedAt: 'x' }); + + expect(result).to.deep.equal({ + id: 'r1', + name: 'admin', + scope: 'Users', + _unmappedProperties_: { extra: 1, _updatedAt: 'x' }, + }); + }); + + it('decode omits mapped targets whose source is undefined but still consumes the source key', () => { + const result = z.decode(codec, { _id: 'r1', name: undefined, other: true }); + + expect(result).to.deep.equal({ + id: 'r1', + _unmappedProperties_: { other: true }, + }); + expect(result).to.not.have.nested.property('_unmappedProperties_.name'); + }); + + it('decode does not mutate the input (deep isolation)', () => { + const input = { _id: 'r1', nested: { a: 1 } }; + const result = z.decode(codec, input) as any; + + result._unmappedProperties_.nested.a = 999; + + expect(input.nested.a).to.equal(1); + }); + + it('encode applies the inverse rename and merges _unmappedProperties_', () => { + const result = z.encode(codec, { id: 'r1', name: 'admin', scope: 'Users', _unmappedProperties_: { extra: 1 } }); + + expect(result).to.deep.equal({ _id: 'r1', name: 'admin', scope: 'Users', extra: 1 }); + }); + + it('round-trips decode -> encode back to the original shape', () => { + const original = { _id: 'r1', name: 'admin', scope: 'Users', extra: 1 }; + + expect(z.encode(codec, z.decode(codec, original))).to.deep.equal(original); + }); +}); + +describe('mappedDecodeAsync', () => { + it('handles string renames, sync and async derived fields, and buckets the rest', async () => { + const result = await mappedDecodeAsync( + { _id: 'r1', t: 'c', u: { _id: 'u1' }, leftover: 42 }, + { + id: '_id', + type: (data) => { + const { t } = data; + delete data.t; + return t === 'c' ? 'channel' : t; + }, + creator: async (data) => { + const { u } = data; + delete data.u; + return u ? { id: u._id } : undefined; + }, + }, + ); + + expect(result).to.deep.equal({ + id: 'r1', + type: 'channel', + creator: { id: 'u1' }, + _unmappedProperties_: { leftover: 42 }, + }); + }); + + it('omits derived fields that return undefined and does not mutate the input', async () => { + const input = { _id: 'r1', nested: { a: 1 } }; + const result = await mappedDecodeAsync(input, { + id: '_id', + creator: () => undefined, + }); + + expect(result).to.deep.equal({ id: 'r1', _unmappedProperties_: { nested: { a: 1 } } }); + expect(result).to.not.have.property('creator'); + + (result as any)._unmappedProperties_.nested.a = 999; + expect(input.nested.a).to.equal(1); + }); + + it('maps a nested object recursively, bucketing unmapped fields at each level', async () => { + const result = await mappedDecodeAsync( + { _id: 'c1', visitor: { visitorId: 'v1', extra: 'drop' }, spare: true }, + { + _id: '_id', + visitor: { from: 'visitor', map: { visitorId: 'visitorId' } }, + }, + ); + + expect(result).to.deep.equal({ + _id: 'c1', + visitor: { visitorId: 'v1', _unmappedProperties_: { extra: 'drop' } }, + _unmappedProperties_: { spare: true }, + }); + }); + + it('omits a nested object whose source is missing without a property access error', async () => { + const result = await mappedDecodeAsync( + { _id: 'c1', spare: true }, + { + _id: '_id', + lastChat: { from: 'lastChat', map: { _id: '_id', ts: 'ts' } }, + visitor: { from: 'visitor', map: { visitorId: 'visitorId' } }, + details: { from: 'details', map: { type: 'type' } }, + }, + ); + + expect(result).to.deep.equal({ _id: 'c1', _unmappedProperties_: { spare: true } }); + expect(result).to.not.have.property('lastChat'); + expect(result).to.not.have.property('visitor'); + expect(result).to.not.have.property('details'); + }); + + it('omits a nested object whose source is null without a property access error', async () => { + const result = await mappedDecodeAsync( + { _id: 'c1', lastChat: null, visitor: null, details: null, spare: true }, + { + _id: '_id', + lastChat: { from: 'lastChat', map: { _id: '_id', ts: 'ts' } }, + visitor: { from: 'visitor', map: { visitorId: 'visitorId' } }, + details: { from: 'details', map: { type: 'type' } }, + }, + ); + + expect(result).to.deep.equal({ _id: 'c1', _unmappedProperties_: { spare: true } }); + expect(result).to.not.have.property('lastChat'); + expect(result).to.not.have.property('visitor'); + expect(result).to.not.have.property('details'); + }); + + it('drops the _unmappedProperties_ bucket at the root and every nested level when `dropUnmapped` is set', async () => { + const result = await mappedDecodeAsync( + { _id: 'c1', visitor: { visitorId: 'v1', extra: 'drop' }, phones: [{ phoneNumber: '1', extra: 'x' }], spare: true }, + { + _id: '_id', + visitor: { from: 'visitor', map: { visitorId: 'visitorId' } }, + phones: { from: 'phones', list: true, map: { phoneNumber: 'phoneNumber' } }, + }, + { dropUnmapped: true }, + ); + + expect(result).to.deep.equal({ + _id: 'c1', + visitor: { visitorId: 'v1' }, + phones: [{ phoneNumber: '1' }], + }); + }); + + it('copies a nested source verbatim when the descriptor has no `map`', async () => { + const result = await mappedDecodeAsync( + { _id: 'c1', details: { type: 'sms', id: 'sms-1' }, spare: true }, + { + _id: '_id', + details: { from: 'details' }, + }, + ); + + expect(result).to.deep.equal({ + _id: 'c1', + details: { type: 'sms', id: 'sms-1' }, + _unmappedProperties_: { spare: true }, + }); + }); + + it('maps a list of objects via `list` + `map`, bucketing each element', async () => { + const result = await mappedDecodeAsync( + { phones: [{ phoneNumber: '1', extra: 'x' }, { phoneNumber: '2' }] }, + { phones: { from: 'phones', list: true, map: { phoneNumber: 'phoneNumber' } } }, + ); + + expect(result).to.deep.equal({ + phones: [ + { phoneNumber: '1', _unmappedProperties_: { extra: 'x' } }, + { phoneNumber: '2', _unmappedProperties_: {} }, + ], + _unmappedProperties_: {}, + }); + }); + + it('drops a source property from the bucket when a function deletes it explicitly', async () => { + const result = await mappedDecodeAsync( + { firstName: 'John', lastName: 'Doe' }, + { + fullName: (d: Record) => { + const value = `${d.firstName} ${d.lastName}`; + delete d.firstName; + return value; + }, + }, + ); + + expect(result).to.deep.equal({ fullName: 'John Doe', _unmappedProperties_: { lastName: 'Doe' } }); + }); + + it('wraps a non-array value into a list when `list` is true', async () => { + const result = await mappedDecodeAsync({ tag: 'admin' }, { tags: { from: 'tag', list: true, map: {} } }); + + expect(result).to.deep.equal({ tags: ['admin'], _unmappedProperties_: {} }); + }); + + it('keeps every property as unmapped when the map is empty', async () => { + const result = await mappedDecodeAsync({ os: 'android', version: '1.9', lan: 'en' }, {}); + + expect(result).to.deep.equal({ _unmappedProperties_: { os: 'android', version: '1.9', lan: 'en' } }); + }); +}); + +describe('mappedDecode type constraints', () => { + type Source = { _id: string; name: string }; + + it('constrains map values to keys of the source type and infers the decoded shape', () => { + const decode = mappedDecode({ id: '_id', label: 'name' }); + const result = decode({ _id: 'a', name: 'b' }); + + // `result` is typed `Decoded`: renamed targets plus the bucket. + expect(result).to.deep.equal({ id: 'a', label: 'b', _unmappedProperties_: {} }); + + // @ts-expect-error - 'missing' is not a key of Source, so the field map is rejected. + mappedDecode({ id: 'missing' }); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/codecs/rooms.spec.ts b/apps/meteor/tests/unit/app/apps/server/codecs/rooms.spec.ts new file mode 100644 index 0000000..1c49ea1 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/codecs/rooms.spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; + +let visitor: Record; + +const { appRoomToRocketChat } = proxyquire.noCallThru().load('../../../../../../app/apps/server/converters/codecs/rooms', { + '@rocket.chat/models': { + LivechatVisitors: { + findOneEnabledById: async () => visitor, + }, + LivechatDepartment: { findOneById: async () => undefined }, + Users: { findOneById: async () => undefined }, + LivechatContacts: { findOneEnabledById: async () => undefined }, + }, +}); + +describe('appRoomToRocketChat', () => { + beforeEach(() => { + visitor = { _id: 'visitor-1', username: 'guest', token: 'tok-1', status: 'online' }; + }); + + it('does not throw when a visitor is present but visitorChannelInfo is missing', async () => { + const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record; + + expect(result.v).to.include({ _id: 'visitor-1' }); + expect(result.v).to.not.have.property('lastMessageTs'); + expect(result.v).to.not.have.property('phone'); + }); + + it('includes the activity when the visitor has a non-empty activity array', async () => { + visitor.activity = ['2026-09']; + + const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record; + + expect(result.v).to.have.property('activity').that.deep.equals(['2026-09']); + }); + + it('omits the activity when the visitor activity is null', async () => { + visitor.activity = null; + + const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record; + + expect(result.v).to.not.have.property('activity'); + }); + + it('omits the activity when the visitor has no activity', async () => { + const result = (await appRoomToRocketChat({ id: 'lc-1', type: 'l', visitor: { id: 'visitor-1' } }, false)) as Record; + + expect(result.v).to.not.have.property('activity'); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/converters.edge.spec.ts b/apps/meteor/tests/unit/app/apps/server/converters.edge.spec.ts new file mode 100644 index 0000000..40ce5a7 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/converters.edge.spec.ts @@ -0,0 +1,65 @@ +import type { IAppsUser } from '@rocket.chat/apps'; +import type { ISetting, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { AppSettingsConverter } from '../../../../../app/apps/server/converters/settings'; +import { AppUsersConverter } from '../../../../../app/apps/server/converters/users'; + +const orch: any = {}; + +const asUser = (value: Record) => value as unknown as IUser; +const asAppUser = (value: Record) => value as unknown as IAppsUser; +const asSetting = (value: Record) => value as unknown as ISetting; + +describe('apps converters — lenient enum edge cases', () => { + describe('AppUsersConverter.convertToApp', () => { + const converter = new AppUsersConverter(orch); + + it('maps an undefined statusConnection to UNDEFINED (Livechat guests / rocket.cat)', () => { + const result = converter.convertToApp(asUser({ _id: 'u', username: 'a', statusConnection: undefined })); + + expect(result.statusConnection).to.equal('undefined'); + expect(result.type).to.equal('unknown'); + }); + + it('does not throw and falls back to OFFLINE for a null statusConnection', () => { + const result = converter.convertToApp(asUser({ _id: 'u', username: 'a', statusConnection: null })); + + expect(result.statusConnection).to.equal('offline'); + }); + }); + + describe('AppUsersConverter.convertToRocketChat', () => { + const converter = new AppUsersConverter(orch); + + it('does not throw when the app user has no type/statusConnection and strips empties', () => { + const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a' })); + + expect(result).to.deep.equal({ _id: 'u', username: 'a' }); + }); + + it('falls back to the legacy utfOffset property when utcOffset is absent', () => { + const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a', utfOffset: -3 })); + + expect(result).to.deep.equal({ _id: 'u', username: 'a', utcOffset: -3 }); + }); + + it('prefers utcOffset over the legacy utfOffset when both are present', () => { + const result = converter.convertToRocketChat(asAppUser({ id: 'u', username: 'a', utcOffset: 5, utfOffset: -3 })); + + expect(result).to.deep.equal({ _id: 'u', username: 'a', utcOffset: 5 }); + }); + }); + + describe('AppSettingsConverter.convertToApp', () => { + const converter = new AppSettingsConverter(orch); + + it('passes an unknown/undefined setting type through without throwing', () => { + const result = converter.convertToApp(asSetting({ _id: 's', type: undefined })); + + expect(result.id).to.equal('s'); + expect(result.type).to.equal(undefined); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/converters.golden.spec.ts b/apps/meteor/tests/unit/app/apps/server/converters.golden.spec.ts new file mode 100644 index 0000000..2fbc08a --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/converters.golden.spec.ts @@ -0,0 +1,904 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { AppContactsConverter } from '../../../../../app/apps/server/converters/contacts'; +import { AppDepartmentsConverter } from '../../../../../app/apps/server/converters/departments'; +import { AppMessagesConverter } from '../../../../../app/apps/server/converters/messages'; +import { AppRolesConverter } from '../../../../../app/apps/server/converters/roles'; +import { AppRoomsConverter } from '../../../../../app/apps/server/converters/rooms'; +import { AppSettingsConverter } from '../../../../../app/apps/server/converters/settings'; +import { AppThreadsConverter } from '../../../../../app/apps/server/converters/threads'; +import { AppUploadsConverter } from '../../../../../app/apps/server/converters/uploads'; +import { AppUsersConverter } from '../../../../../app/apps/server/converters/users'; +import { AppVideoConferencesConverter } from '../../../../../app/apps/server/converters/videoConferences'; +import { AppVisitorsConverter } from '../../../../../app/apps/server/converters/visitors'; + +/* + * Golden snapshots of the converters' deterministic transform methods, captured from the + * behaviour that existed before the Zod-codec migration. These lock the RC <-> Apps-Engine field + * mapping (including the `_unmappedProperties_` bucket and pass-through rules) so that each phase + * of the migration can prove it preserves behaviour exactly. + * + * Output is compared after `JSON.parse(JSON.stringify(...))` so Date instances are normalised to + * ISO strings and `undefined` fields are dropped — matching how these payloads cross the app bridge. + */ + +const ts = new Date('2024-01-01T00:00:00.000Z'); +const updated = new Date('2024-02-02T00:00:00.000Z'); + +const json = (value: unknown): unknown => JSON.parse(JSON.stringify(value)); + +// A stub orchestrator whose cross-converter lookups resolve deterministically, so the decode +// direction of the cross-converter converters (uploads/rooms/messages) can be snapshotted without a +// database. +const stubOrch: any = { + getConverters: () => ({ + get: (key: string) => ({ + convertById: async (id: string) => ({ __converted: key, id }), + convertToApp: (u: any) => ({ __convertedToApp: key, id: u?._id }), + convertByToken: async (token: string) => ({ __converted: key, token }), + }), + }), +}; + +describe('apps converters — golden snapshots (pre-codec behaviour)', () => { + describe('AppSettingsConverter', () => { + it('convertToApp maps a setting to the Apps-Engine shape', () => { + const converter = new AppSettingsConverter(stubOrch); + const setting = { + _id: 'Some_Setting', + type: 'int', + packageValue: 10, + values: [{ key: 'a', i18nLabel: 'A' }], + value: 42, + public: true, + hidden: false, + group: 'General', + i18nLabel: 'Some_Setting', + i18nDescription: 'Some_Setting_Description', + ts, + _updatedAt: updated, + sorter: 5, + }; + + expect(json(converter.convertToApp(setting as any))).to.deep.equal({ + id: 'Some_Setting', + type: 'int', + packageValue: 10, + values: [{ key: 'a', i18nLabel: 'A' }], + value: 42, + public: true, + hidden: false, + group: 'General', + i18nLabel: 'Some_Setting', + i18nDescription: 'Some_Setting_Description', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + }); + }); + }); + + describe('AppUsersConverter', () => { + const converter = new AppUsersConverter(stubOrch); + + it('convertToApp maps a user to the Apps-Engine shape', () => { + const user = { + _id: 'user-1', + username: 'john.doe', + emails: [{ address: 'john@example.com', verified: true }], + type: 'bot', + active: true, + name: 'John Doe', + roles: ['admin', 'user'], + bio: 'hello', + status: 'online', + statusText: 'working', + statusConnection: 'online', + utcOffset: -3, + createdAt: ts, + _updatedAt: updated, + lastLogin: ts, + appId: 'app-1', + customFields: { foo: 'bar' }, + federated: false, + federation: undefined, + freeSwitchExtension: '1001', + settings: { preferences: { language: 'en' } }, + }; + + expect(json(converter.convertToApp(user as any))).to.deep.equal({ + id: 'user-1', + username: 'john.doe', + emails: [{ address: 'john@example.com', verified: true }], + type: 'bot', + isEnabled: true, + name: 'John Doe', + roles: ['admin', 'user'], + bio: 'hello', + status: 'online', + statusText: 'working', + statusConnection: 'online', + utcOffset: -3, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + lastLoginAt: '2024-01-01T00:00:00.000Z', + appId: 'app-1', + customFields: { foo: 'bar' }, + isFederated: false, + sipExtension: '1001', + settings: { preferences: { language: 'en' } }, + }); + }); + + it('convertToRocketChat maps an app user back to the RC shape', () => { + const appUser = { + id: 'user-1', + username: 'john.doe', + emails: [{ address: 'john@example.com', verified: true }], + type: 'bot', + isEnabled: true, + name: 'John Doe', + roles: ['admin', 'user'], + bio: 'hello', + status: 'online', + statusConnection: 'online', + utcOffset: -3, + createdAt: ts, + updatedAt: updated, + lastLoginAt: ts, + appId: 'app-1', + isFederated: false, + federation: undefined, + sipExtension: '1001', + }; + + expect(json(converter.convertToRocketChat(appUser as any))).to.deep.equal({ + _id: 'user-1', + username: 'john.doe', + emails: [{ address: 'john@example.com', verified: true }], + type: 'bot', + active: true, + name: 'John Doe', + roles: ['admin', 'user'], + bio: 'hello', + status: 'online', + statusConnection: 'online', + utcOffset: -3, + createdAt: '2024-01-01T00:00:00.000Z', + _updatedAt: '2024-02-02T00:00:00.000Z', + lastLogin: '2024-01-01T00:00:00.000Z', + appId: 'app-1', + federated: false, + freeSwitchExtension: '1001', + }); + }); + }); + + describe('AppVisitorsConverter', () => { + const converter = new AppVisitorsConverter(stubOrch); + + it('convertVisitor maps a visitor and buckets unmapped fields', async () => { + const visitor = { + _id: 'visitor-1', + username: 'guest-1', + name: 'Guest One', + department: 'dep-1', + _updatedAt: updated, + token: 'tok-abc', + phone: [{ phoneNumber: '+15551234' }], + visitorEmails: [{ address: 'guest@example.com' }], + livechatData: { note: 'vip' }, + status: 'online', + activity: ['2024-01'], + externalIds: ['ext-1'], + lastChat: { _id: 'room-1', ts }, + }; + + expect(json(await converter.convertVisitor(visitor as any))).to.deep.equal({ + id: 'visitor-1', + username: 'guest-1', + name: 'Guest One', + department: 'dep-1', + updatedAt: '2024-02-02T00:00:00.000Z', + token: 'tok-abc', + phone: [{ phoneNumber: '+15551234' }], + visitorEmails: [{ address: 'guest@example.com' }], + livechatData: { note: 'vip' }, + status: 'online', + activity: ['2024-01'], + externalIds: ['ext-1'], + _unmappedProperties_: { lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' } }, + }); + }); + + it('convertAppVisitor maps an app visitor back and merges unmapped fields', () => { + const appVisitor = { + id: 'visitor-1', + username: 'guest-1', + name: 'Guest One', + token: 'tok-abc', + phone: [{ phoneNumber: '+15551234' }], + livechatData: { note: 'vip' }, + status: 'online', + visitorEmails: [{ address: 'guest@example.com' }], + department: 'dep-1', + externalIds: ['ext-1'], + _unmappedProperties_: { customField: 'x' }, + }; + + expect(json(converter.convertAppVisitor(appVisitor as any))).to.deep.equal({ + _id: 'visitor-1', + username: 'guest-1', + name: 'Guest One', + token: 'tok-abc', + phone: [{ phoneNumber: '+15551234' }], + livechatData: { note: 'vip' }, + status: 'online', + visitorEmails: [{ address: 'guest@example.com' }], + department: 'dep-1', + externalIds: ['ext-1'], + customField: 'x', + }); + }); + }); + + describe('AppDepartmentsConverter', () => { + const converter = new AppDepartmentsConverter(stubOrch); + + it('convertDepartment maps a department and buckets unmapped fields', async () => { + const department = { + _id: 'dep-1', + name: 'Support', + email: 'support@example.com', + _updatedAt: updated, + enabled: true, + numAgents: 3, + showOnOfflineForm: true, + description: 'Support dept', + offlineMessageChannelName: 'offline', + requestTagBeforeClosingChat: true, + chatClosingTags: ['done'], + abandonedRoomsCloseCustomMessage: 'bye', + waitingQueueMessage: 'please wait', + departmentsAllowedToForward: ['dep-2'], + showOnRegistration: false, + type: 'd', + }; + + expect(json(await converter.convertDepartment(department as any))).to.deep.equal({ + id: 'dep-1', + name: 'Support', + email: 'support@example.com', + updatedAt: '2024-02-02T00:00:00.000Z', + enabled: true, + numberOfAgents: 3, + showOnOfflineForm: true, + description: 'Support dept', + offlineMessageChannelName: 'offline', + requestTagBeforeClosingChat: true, + chatClosingTags: ['done'], + abandonedRoomsCloseCustomMessage: 'bye', + waitingQueueMessage: 'please wait', + departmentsAllowedToForward: ['dep-2'], + showOnRegistration: false, + _unmappedProperties_: { type: 'd' }, + }); + }); + + it('convertAppDepartment maps an app department back and merges unmapped fields', () => { + const appDepartment = { + id: 'dep-1', + name: 'Support', + email: 'support@example.com', + updatedAt: updated, + enabled: true, + numberOfAgents: 3, + showOnOfflineForm: true, + showOnRegistration: false, + description: 'Support dept', + offlineMessageChannelName: 'offline', + requestTagBeforeClosingChat: true, + chatClosingTags: ['done'], + abandonedRoomsCloseCustomMessage: 'bye', + waitingQueueMessage: 'please wait', + departmentsAllowedToForward: ['dep-2'], + _unmappedProperties_: { extra: 1 }, + }; + + expect(json(converter.convertAppDepartment(appDepartment as any))).to.deep.equal({ + _id: 'dep-1', + name: 'Support', + email: 'support@example.com', + _updatedAt: '2024-02-02T00:00:00.000Z', + enabled: true, + numAgents: 3, + showOnOfflineForm: true, + showOnRegistration: false, + description: 'Support dept', + offlineMessageChannelName: 'offline', + requestTagBeforeClosingChat: true, + chatClosingTags: ['done'], + abandonedRoomsCloseCustomMessage: 'bye', + waitingQueueMessage: 'please wait', + departmentsAllowedToForward: ['dep-2'], + extra: 1, + }); + }); + }); + + describe('AppRolesConverter', () => { + it('convertRole maps a role and buckets unmapped fields', async () => { + const converter = new AppRolesConverter(); + const role = { + _id: 'admin', + name: 'admin', + description: 'Administrator', + mandatory2fa: true, + protected: true, + scope: 'Users', + _updatedAt: updated, + }; + + expect(json(await converter.convertRole(role as any))).to.deep.equal({ + id: 'admin', + name: 'admin', + description: 'Administrator', + mandatory2fa: true, + protected: true, + scope: 'Users', + _unmappedProperties_: { _updatedAt: '2024-02-02T00:00:00.000Z' }, + }); + }); + }); + + describe('AppVideoConferencesConverter', () => { + const converter = new AppVideoConferencesConverter(); + const call = { + _id: 'vc-1', + type: 'direct', + rid: 'room-1', + status: 1, + createdBy: { _id: 'user-1', username: 'john.doe' }, + createdAt: ts, + }; + const expected = { + _id: 'vc-1', + type: 'direct', + rid: 'room-1', + status: 1, + createdBy: { _id: 'user-1', username: 'john.doe' }, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + it('convertVideoConference passes the call through', () => { + expect(json(converter.convertVideoConference(call as any))).to.deep.equal(expected); + }); + + it('convertAppVideoConference passes the call through', () => { + expect(json(converter.convertAppVideoConference(call as any))).to.deep.equal(expected); + }); + }); + + describe('AppContactsConverter', () => { + const converter = new AppContactsConverter(); + + it('convertContact clones the contact unchanged', async () => { + const contact = { + _id: 'contact-1', + _updatedAt: updated, + name: 'Jane', + phones: [{ phoneNumber: '+15550000' }], + emails: [{ address: 'jane@example.com' }], + unknown: false, + customFields: { tier: 'gold' }, + createdAt: ts, + channels: [ + { + name: 'sms', + verified: true, + visitor: { visitorId: 'visitor-1', source: { type: 'sms', id: 'sms-1' } }, + blocked: false, + lastChat: { _id: 'room-1', ts }, + }, + ], + importIds: ['imp-1'], + }; + + expect(json(await converter.convertContact(contact as any))).to.deep.equal(json(contact)); + }); + + it('convertAppContact deep-maps an app contact back, dropping unmapped fields at each level', async () => { + const appContact = { + _id: 'contact-1', + _updatedAt: updated, + name: 'Jane', + phones: [{ phoneNumber: '+15550000', extra: 'drop-me' }], + emails: [{ address: 'jane@example.com' }], + contactManager: 'mgr-1', + unknown: false, + conflictingFields: [{ field: 'name', value: 'Janet' }], + customFields: { tier: 'gold' }, + channels: [ + { + name: 'sms', + verified: true, + visitor: { visitorId: 'visitor-1', source: { type: 'sms', id: 'sms-1' } }, + blocked: false, + field: 'phone', + value: '+15550000', + verifiedAt: ts, + details: { type: 'sms', id: 'sms-1', alias: 'a', label: 'l', sidebarIcon: 'i', defaultIcon: 'd', destination: 'x' }, + lastChat: { _id: 'room-1', ts }, + }, + ], + createdAt: ts, + lastChat: { _id: 'room-1', ts }, + importIds: ['imp-1'], + }; + + expect(json(await converter.convertAppContact(appContact as any))).to.deep.equal({ + _id: 'contact-1', + _updatedAt: '2024-02-02T00:00:00.000Z', + name: 'Jane', + phones: [{ phoneNumber: '+15550000' }], + emails: [{ address: 'jane@example.com' }], + contactManager: 'mgr-1', + unknown: false, + conflictingFields: [{ field: 'name', value: 'Janet' }], + customFields: { tier: 'gold' }, + channels: [ + { + name: 'sms', + verified: true, + visitor: { + visitorId: 'visitor-1', + source: { type: 'sms', id: 'sms-1' }, + }, + blocked: false, + field: 'phone', + value: '+15550000', + verifiedAt: '2024-01-01T00:00:00.000Z', + details: { + type: 'sms', + id: 'sms-1', + alias: 'a', + label: 'l', + sidebarIcon: 'i', + defaultIcon: 'd', + destination: 'x', + }, + lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' }, + }, + ], + createdAt: '2024-01-01T00:00:00.000Z', + lastChat: { _id: 'room-1', ts: '2024-01-01T00:00:00.000Z' }, + importIds: ['imp-1'], + }); + }); + }); + + describe('AppUploadsConverter', () => { + const converter = new AppUploadsConverter(stubOrch); + + it('convertToApp maps an upload and resolves room/user/visitor via converters', async () => { + const upload = { + _id: 'up-1', + name: 'file.png', + size: 1234, + type: 'image/png', + store: 'GridFS', + description: 'a file', + complete: true, + uploading: false, + extension: 'png', + progress: 1, + etag: 'etag-1', + path: '/path', + token: 'up-tok', + url: 'http://example/file.png', + _updatedAt: updated, + uploadedAt: ts, + rid: 'room-1', + userId: 'user-1', + visitorToken: 'tok-abc', + }; + + expect(json(await converter.convertToApp(upload as any))).to.deep.equal({ + id: 'up-1', + name: 'file.png', + size: 1234, + type: 'image/png', + store: 'GridFS', + description: 'a file', + complete: true, + uploading: false, + extension: 'png', + progress: 1, + etag: 'etag-1', + path: '/path', + token: 'up-tok', + url: 'http://example/file.png', + updatedAt: '2024-02-02T00:00:00.000Z', + uploadedAt: '2024-01-01T00:00:00.000Z', + room: { __converted: 'rooms', id: 'room-1' }, + user: { __converted: 'users', id: 'user-1' }, + visitor: { __converted: 'visitors', token: 'tok-abc' }, + _unmappedProperties_: {}, + }); + }); + + it('convertToRocketChat maps an app upload back and merges unmapped fields', () => { + const appUpload = { + id: 'up-1', + name: 'file.png', + size: 1234, + type: 'image/png', + extension: 'png', + description: 'a file', + store: 'GridFS', + etag: 'etag-1', + path: '/path', + complete: true, + uploading: false, + progress: 1, + token: 'up-tok', + url: 'http://example/file.png', + updatedAt: updated, + uploadedAt: ts, + room: { id: 'room-1' }, + user: { id: 'user-1' }, + visitor: { token: 'tok-abc' }, + _unmappedProperties_: { extraUpload: true }, + }; + + expect(json(converter.convertToRocketChat(appUpload as any))).to.deep.equal({ + _id: 'up-1', + name: 'file.png', + size: 1234, + type: 'image/png', + extension: 'png', + description: 'a file', + store: 'GridFS', + etag: 'etag-1', + complete: true, + uploading: false, + progress: 1, + token: 'up-tok', + url: 'http://example/file.png', + _updatedAt: '2024-02-02T00:00:00.000Z', + uploadedAt: '2024-01-01T00:00:00.000Z', + rid: 'room-1', + userId: 'user-1', + visitorToken: 'tok-abc', + extraUpload: true, + }); + }); + + it('convertToRocketChat does not throw when the app upload has no room (e.g. deleted room)', () => { + const appUpload = { + id: 'up-1', + name: 'file.png', + user: { id: 'user-1' }, + }; + + const result = json(converter.convertToRocketChat(appUpload as any)) as Record; + + expect(result).to.deep.equal({ _id: 'up-1', name: 'file.png', userId: 'user-1' }); + expect(result).to.not.have.property('rid'); + }); + }); +}); + +/* + * Decode-direction golden snapshots for the async, cross-converter converters (rooms, messages, + * threads). These are the highest-risk converters for mapping drift (the `transformMappedData` -> + * `mappedDecodeAsync` swap), yet previously had no golden coverage. Cross-converter lookups resolve + * through `stubOrch`, so these snapshots run without a database. + */ +describe('apps converters — decode golden snapshots (rooms/messages/threads)', () => { + describe('AppRoomsConverter.convertRoom', () => { + const converter = new AppRoomsConverter(stubOrch); + + it('maps a channel room: renames, boolean/type function fields, creator lookup, bucket', async () => { + const result = await converter.convertRoom({ + _id: 'GENERAL', + t: 'c', + fname: 'General', + name: 'general', + u: { _id: 'owner-1' }, + msgs: 10, + ts, + _updatedAt: updated, + default: true, + ro: false, + customFields: { a: 1 }, + teamId: 'team-1', + teamMain: true, + federated: false, + lastMessage: { _id: 'lm-1' }, + } as any); + + expect(json(result)).to.deep.equal({ + id: 'GENERAL', + displayName: 'General', + slugifiedName: 'general', + messageCount: 10, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + customFields: { a: 1 }, + teamId: 'team-1', + isTeamMain: true, + isFederated: false, + isDefault: true, + isReadOnly: false, + displaySystemMessages: true, + type: 'c', + creator: { __converted: 'users', id: 'owner-1' }, + _unmappedProperties_: { lastMessage: { _id: 'lm-1' } }, + }); + }); + + it('maps a livechat room: visitor/contact/department/closedBy/servedBy/responseBy/parentRoom + secure fields', async () => { + const result = await converter.convertRoom({ + _id: 'lc-1', + t: 'l', + fname: 'Chat', + msgs: 3, + ts, + _updatedAt: updated, + closer: 'user', + closedBy: { _id: 'closer-1' }, + servedBy: { _id: 'agent-1' }, + responseBy: { _id: 'resp-1' }, + v: { _id: 'visitor-1', phone: '+15550000', lastMessageTs: ts }, + departmentId: 'dep-1', + contactId: 'contact-1', + prid: 'parent-1', + open: true, + waitingResponse: true, + sysMes: false, + abacAttributes: { level: 'secret' }, + spare: 'keep', + } as any); + + expect(json(result)).to.deep.equal({ + 'id': 'lc-1', + 'displayName': 'Chat', + 'messageCount': 3, + 'createdAt': '2024-01-01T00:00:00.000Z', + 'updatedAt': '2024-02-02T00:00:00.000Z', + 'isWaitingResponse': true, + 'isOpen': true, + 'closer': 'user', + 'isDefault': false, + 'isReadOnly': false, + 'displaySystemMessages': false, + 'type': 'l', + 'visitor': { __converted: 'visitors', id: 'visitor-1' }, + 'contact': { __converted: 'contacts', id: 'contact-1' }, + 'visitorChannelInfo': { phone: '+15550000', lastMessageTs: '2024-01-01T00:00:00.000Z' }, + 'department': { __converted: 'departments', id: 'dep-1' }, + 'closedBy': { __converted: 'users', id: 'closer-1' }, + 'servedBy': { __converted: 'users', id: 'agent-1' }, + 'responseBy': { __converted: 'users', id: 'resp-1' }, + 'parentRoom': { __converted: 'rooms', id: 'parent-1' }, + '@@SecureFields': [{ permission: 'abac.read', name: 'abacAttributes', value: { level: 'secret' } }], + '_unmappedProperties_': { + v: { _id: 'visitor-1', phone: '+15550000', lastMessageTs: '2024-01-01T00:00:00.000Z' }, + contactId: 'contact-1', + spare: 'keep', + }, + }); + }); + }); + + describe('AppRoomsConverter.convertRoomRaw', () => { + const converter = new AppRoomsConverter(stubOrch); + + it('maps a raw room without cross-converter resolution (inline user/visitor lookups)', async () => { + const result = await converter.convertRoomRaw({ + _id: 'lc-2', + t: 'l', + fname: 'Raw', + msgs: 1, + ts, + _updatedAt: updated, + v: { _id: 'visitor-2', username: 'guest', extra: 'x' }, + closer: 'visitor', + closedBy: { _id: 'visitor-2', username: 'guest' }, + u: { _id: 'owner-2', username: 'owner', name: 'Owner' }, + leftover: true, + } as any); + + expect(json(result)).to.deep.equal({ + id: 'lc-2', + displayName: 'Raw', + messageCount: 1, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + closer: 'visitor', + visitor: { id: 'visitor-2', username: 'guest', extra: 'x' }, + displaySystemMessages: true, + type: 'l', + creator: { _id: 'owner-2', username: 'owner', name: 'Owner' }, + closedBy: { _id: 'visitor-2', username: 'guest' }, + _unmappedProperties_: { leftover: true }, + }); + }); + }); + + describe('AppMessagesConverter.convertMessage', () => { + const converter = new AppMessagesConverter(stubOrch); + + it('maps a message: room/editor/sender lookups, attachment sub-map, bucket', async () => { + const result = await converter.convertMessage({ + _id: 'msg-1', + rid: 'GENERAL', + tmid: 'thread-1', + u: { _id: 'user-1', username: 'u1' }, + msg: 'hello', + ts, + _updatedAt: updated, + t: 'p', + editedBy: { _id: 'editor-1' }, + editedAt: updated, + emoji: ':smile:', + alias: 'alias-1', + groupable: false, + attachments: [ + { + text: 'attachment text', + ts, + author_name: 'Author', + author_link: 'http://a', + author_icon: 'http://i', + title: 'Title', + title_link: 'http://t', + image_url: 'http://img', + fields: [{ title: 'f', value: 'v' }], + }, + ], + spare: 'keep', + } as any); + + expect(json(result)).to.deep.equal({ + id: 'msg-1', + threadId: 'thread-1', + text: 'hello', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + editedAt: '2024-02-02T00:00:00.000Z', + emoji: ':smile:', + alias: 'alias-1', + groupable: false, + type: 'p', + room: { __converted: 'rooms', id: 'GENERAL' }, + editor: { __converted: 'users', id: 'editor-1' }, + attachments: [ + { + text: 'attachment text', + imageUrl: 'http://img', + fields: [{ title: 'f', value: 'v' }], + author: { name: 'Author', link: 'http://a', icon: 'http://i' }, + title: { value: 'Title', link: 'http://t' }, + timestamp: '2024-01-01T00:00:00.000Z', + _unmappedProperties_: {}, + }, + ], + sender: { __converted: 'users', id: 'user-1' }, + _unmappedProperties_: { spare: 'keep' }, + }); + }); + + it('sender falls back to the original message.u when the primary lookup resolves to nothing', async () => { + // Old system messages from visitors have no `token`, so the primary lookup can miss; the + // fallback must still receive the original sender rather than the already-deleted `message.u`. + const fallbackOrch: any = { + getConverters: () => ({ + get: (key: string) => ({ + convertById: async () => undefined, + convertToApp: (u: any) => (u ? { __fallback: key, id: u._id } : undefined), + convertByToken: async () => undefined, + }), + }), + }; + const fallbackConverter = new AppMessagesConverter(fallbackOrch); + + const result = await fallbackConverter.convertMessage({ + _id: 'msg-3', + rid: 'GENERAL', + u: { _id: 'user-9', username: 'u9' }, + msg: 'hi', + ts, + _updatedAt: updated, + t: 'p', + } as any); + + expect((json(result) as any).sender).to.deep.equal({ __fallback: 'users', id: 'user-9' }); + }); + }); + + describe('AppMessagesConverter.convertMessageRaw', () => { + const converter = new AppMessagesConverter(stubOrch); + + it('maps a raw message (roomId/editor/sender/threadMsgCount pass-through, bucket)', async () => { + const result = await converter.convertMessageRaw({ + _id: 'msg-2', + rid: 'GENERAL', + u: { _id: 'user-2' }, + msg: 'raw', + ts, + _updatedAt: updated, + t: 'p', + tcount: 4, + editedBy: { _id: 'editor-2' }, + leftover: 1, + } as any); + + expect(json(result)).to.deep.equal({ + id: 'msg-2', + text: 'raw', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + roomId: 'GENERAL', + editor: { _id: 'editor-2' }, + sender: { _id: 'user-2' }, + threadMsgCount: 4, + type: 'p', + _unmappedProperties_: { leftover: 1 }, + }); + }); + }); + + describe('AppThreadsConverter.convertMessage', () => { + const converter = new AppThreadsConverter(stubOrch); + const convertUserById = (async (id: string) => ({ __converted: 'users', id })) as any; + const convertToApp = ((u: any) => ({ __convertedToApp: 'users', id: u?._id })) as any; + + it('maps a thread reply: injected room, editor/sender via passed-in lookups, attachment sub-map, bucket', async () => { + const result = await converter.convertMessage( + { + _id: 'tmsg-1', + rid: 'GENERAL', + tmid: 'thread-1', + u: { _id: 'user-3', username: 'u3' }, + msg: 'reply', + ts, + _updatedAt: updated, + editedBy: { _id: 'editor-3' }, + editedAt: updated, + attachments: [{ text: 'a', ts, title: 'T', title_link: 'http://t' }], + spare: 'keep', + } as any, + { id: 'GENERAL' } as any, + convertUserById, + convertToApp, + ); + + expect(json(result)).to.deep.equal({ + id: 'tmsg-1', + threadId: 'thread-1', + text: 'reply', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-02-02T00:00:00.000Z', + editedAt: '2024-02-02T00:00:00.000Z', + room: { id: 'GENERAL' }, + attachments: [ + { + text: 'a', + title: { value: 'T', link: 'http://t' }, + timestamp: '2024-01-01T00:00:00.000Z', + _unmappedProperties_: {}, + }, + ], + sender: { __converted: 'users', id: 'user-3' }, + _unmappedProperties_: { + rid: 'GENERAL', + u: { _id: 'user-3', username: 'u3' }, + editedBy: { _id: 'editor-3' }, + spare: 'keep', + }, + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/messages.tests.js b/apps/meteor/tests/unit/app/apps/server/messages.tests.js new file mode 100644 index 0000000..29921ed --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/messages.tests.js @@ -0,0 +1,179 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +import { appMessageMock, appMessageInvalidRoomMock, appPartialMessageMock } from './mocks/data/messages.data'; +import { MessagesMock } from './mocks/models/Messages.mock'; +import { RoomsMock } from './mocks/models/Rooms.mock'; +import { UsersMock } from './mocks/models/Users.mock'; +import { AppServerOrchestratorMock } from './mocks/orchestrator.mock'; + +const { AppMessagesConverter } = proxyquire.noCallThru().load('../../../../../app/apps/server/converters/messages', { + '@rocket.chat/random': { + Random: { + id: () => 1, + }, + }, + '@rocket.chat/models': { + Rooms: new RoomsMock(), + Messages: new MessagesMock(), + Users: new UsersMock(), + }, + '@rocket.chat/core-typings': { + isMessageFromVisitor: (message) => 'token' in message, + }, +}); + +describe('The AppMessagesConverter instance', () => { + let messagesConverter; + let messagesMock; + + before(() => { + const orchestrator = new AppServerOrchestratorMock(); + + const usersConverter = orchestrator.getConverters().get('users'); + + usersConverter.convertById = function convertUserByIdStub(id) { + return UsersMock.convertedData[id]; + }; + + usersConverter.convertToApp = function convertUserToAppStub(user) { + return { + id: user._id, + username: user.username, + name: user.name, + }; + }; + + orchestrator.getConverters().get('rooms').convertById = async function convertRoomByIdStub(id) { + return RoomsMock.convertedData[id]; + }; + + messagesConverter = new AppMessagesConverter(orchestrator); + messagesMock = new MessagesMock(); + }); + + const createdAt = new Date('2019-03-30T01:22:08.389Z'); + const updatedAt = new Date('2019-03-30T01:22:08.412Z'); + + describe('when converting a message from Rocket.Chat to the Engine schema', () => { + it('should return `undefined` when `msgObj` is falsy', async () => { + const appMessage = await messagesConverter.convertMessage(undefined); + + expect(appMessage).to.be.undefined; + }); + + it('should return a proper schema', async () => { + const appMessage = await messagesConverter.convertMessage(messagesMock.findOneById('SimpleMessageMock')); + + expect(appMessage).to.have.property('id', 'SimpleMessageMock'); + expect(appMessage).to.have.property('createdAt').which.equalTime(createdAt); + expect(appMessage).to.have.property('updatedAt').which.equalTime(updatedAt); + expect(appMessage).to.have.property('groupable', false); + expect(appMessage).to.have.property('sender').which.includes({ id: 'rocket.cat' }); + expect(appMessage).to.have.property('room').which.includes({ id: 'GENERAL' }); + + expect(appMessage).not.to.have.property('editor'); + expect(appMessage).not.to.have.property('attachments'); + expect(appMessage).not.to.have.property('reactions'); + expect(appMessage).not.to.have.property('avatarUrl'); + expect(appMessage).not.to.have.property('alias'); + expect(appMessage).not.to.have.property('customFields'); + expect(appMessage).not.to.have.property('emoji'); + }); + + it('should not mutate the original message object', () => { + const rocketchatMessageMock = messagesMock.findOneById('SimpleMessageMock'); + + messagesConverter.convertMessage(rocketchatMessageMock); + + expect(rocketchatMessageMock).to.deep.equal({ + _id: 'SimpleMessageMock', + t: 'uj', + rid: 'GENERAL', + ts: new Date('2019-03-30T01:22:08.389Z'), + msg: 'rocket.cat', + u: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + groupable: false, + _updatedAt: new Date('2019-03-30T01:22:08.412Z'), + }); + }); + + it("should return basic sender info when it's not a Rocket.Chat user (e.g. Livechat Guest)", async () => { + const appMessage = await messagesConverter.convertMessage(messagesMock.findOneById('LivechatGuestMessageMock')); + + expect(appMessage).to.have.property('sender').which.includes({ + id: 'guest1234', + username: 'guest1234', + name: 'Livechat Guest', + }); + }); + }); + + describe('when converting a message from the Engine schema back to Rocket.Chat', () => { + it('should return `undefined` when `message` is falsy', async () => { + const rocketchatMessage = await messagesConverter.convertAppMessage(undefined); + + expect(rocketchatMessage).to.be.undefined; + }); + + it('should return a proper schema', async () => { + const rocketchatMessage = await messagesConverter.convertAppMessage(appMessageMock); + + expect(rocketchatMessage).to.have.property('_id', 'appMessageMock'); + expect(rocketchatMessage).to.have.property('rid', 'GENERAL'); + expect(rocketchatMessage).to.have.property('groupable', false); + expect(rocketchatMessage).to.have.property('ts').which.equalTime(createdAt); + expect(rocketchatMessage).to.have.property('_updatedAt').which.equalTime(updatedAt); + expect(rocketchatMessage).to.have.property('u').which.includes({ + _id: 'rocket.cat', + username: 'rocket.cat', + name: 'Rocket.Cat', + }); + }); + + it('should return a proper schema when receiving a partial object', async () => { + const rocketchatMessage = await messagesConverter.convertAppMessage(appPartialMessageMock, true); + + expect(rocketchatMessage).to.have.property('_id', 'appPartialMessageMock'); + expect(rocketchatMessage).to.have.property('groupable', false); + expect(rocketchatMessage).to.have.property('emoji', ':smirk:'); + expect(rocketchatMessage).to.have.property('alias', 'rocket.feline'); + + expect(rocketchatMessage).to.not.have.property('ts'); + expect(rocketchatMessage).to.not.have.property('u'); + expect(rocketchatMessage).to.not.have.property('rid'); + expect(rocketchatMessage).to.not.have.property('_updatedAt'); + }); + + it('should merge `_unmappedProperties_` into the returned message', async () => { + const rocketchatMessage = await messagesConverter.convertAppMessage(appMessageMock); + + expect(rocketchatMessage).not.to.have.property('_unmappedProperties_'); + expect(rocketchatMessage).to.have.property('t', 'uj'); + }); + + it('should not merge `_unmappedProperties_` into the returned message when receiving a partial object', async () => { + const invalidPartialMessage = structuredClone(appPartialMessageMock); + invalidPartialMessage._unmappedProperties_ = { + t: 'uj', + }; + + const rocketchatMessage = await messagesConverter.convertAppMessage(invalidPartialMessage, true); + + expect(rocketchatMessage).to.not.have.property('_unmappedProperties_'); + expect(rocketchatMessage).to.not.have.property('t'); + }); + + it('should throw if message has an invalid room', async () => { + try { + await messagesConverter.convertAppMessage(appMessageInvalidRoomMock); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + expect(e.message).to.equal('Invalid room provided on the message.'); + } + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/models/BaseModel.mock.js b/apps/meteor/tests/unit/app/apps/server/mocks/models/BaseModel.mock.js new file mode 100644 index 0000000..920db0d --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/models/BaseModel.mock.js @@ -0,0 +1,5 @@ +export class BaseModelMock { + findOneById(id) { + return this.data[id]; + } +} diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/models/Messages.mock.js b/apps/meteor/tests/unit/app/apps/server/mocks/models/Messages.mock.js new file mode 100644 index 0000000..e0e2880 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/models/Messages.mock.js @@ -0,0 +1,36 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class MessagesMock extends BaseModelMock { + data = { + SimpleMessageMock: { + _id: 'SimpleMessageMock', + t: 'uj', + rid: 'GENERAL', + ts: new Date('2019-03-30T01:22:08.389Z'), + msg: 'rocket.cat', + u: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + groupable: false, + _updatedAt: new Date('2019-03-30T01:22:08.412Z'), + }, + + LivechatGuestMessageMock: { + _id: 'LivechatGuestMessageMock', + rid: 'LivechatRoom', + msg: 'Help wanted', + token: 'guest-token', + alias: 'Livechat Guest', + ts: new Date('2019-04-06T03:57:28.263Z'), + u: { + _id: 'guest1234', + username: 'guest1234', + name: 'Livechat Guest', + }, + _updatedAt: new Date('2019-04-06T03:57:28.278Z'), + mentions: [], + channels: [], + }, + }; +} diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/models/Rooms.mock.js b/apps/meteor/tests/unit/app/apps/server/mocks/models/Rooms.mock.js new file mode 100644 index 0000000..de59997 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/models/Rooms.mock.js @@ -0,0 +1,147 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class RoomsMock extends BaseModelMock { + data = { + GENERAL: { + _id: 'GENERAL', + ts: new Date('2019-03-27T20:51:36.808Z'), + t: 'c', + name: 'general', + usernames: [], + msgs: 31, + usersCount: 3, + default: true, + _updatedAt: new Date('2019-04-10T17:44:34.931Z'), + lastMessage: { + _id: 1, + t: 'uj', + rid: 'GENERAL', + ts: new Date('2019-03-30T01:22:08.389Z'), + msg: 'rocket.cat', + u: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + groupable: false, + _updatedAt: new Date('2019-03-30T01:22:08.412Z'), + }, + lm: new Date('2019-04-10T17:44:34.873Z'), + }, + + LivechatRoom: { + _id: 'LivechatRoom', + msgs: 41, + usersCount: 1, + lm: new Date('2019-04-07T23:45:25.407Z'), + fname: 'Livechat Guest', + t: 'l', + ts: new Date('2019-04-06T03:56:17.040Z'), + v: { + _id: 'yDLaWs5Rzf5mzQsmB', + username: 'guest-4', + token: 'tkps932ccsl6me7intd3', + status: 'away', + }, + servedBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + ts: new Date('2019-04-06T03:56:17.040Z'), + }, + cl: false, + open: true, + _updatedAt: new Date('2019-04-07T23:45:25.469Z'), + lastMessage: { + _id: 'zgEMhaMLCyDPu7xMn', + rid: 'JceP6CZrpcA4j3NNe', + msg: 'a', + ts: new Date('2019-04-07T23:45:25.407Z'), + u: { + _id: '3Wz2wANqwrd7Hu5Fo', + username: 'dgubert', + name: 'Douglas Gubert', + }, + _updatedAt: new Date('2019-04-07T23:45:25.433Z'), + mentions: [], + channels: [], + }, + metrics: { + v: { + lq: new Date('2019-04-06T03:57:28.263Z'), + }, + reaction: { + fd: new Date('2019-04-06T03:57:17.083Z'), + ft: 60.043, + tt: 52144.278, + }, + response: { + avg: 26072.0655, + fd: new Date('2019-04-06T03:57:17.083Z'), + ft: 59.896, + total: 2, + tt: 52144.131, + }, + servedBy: { + lr: new Date('2019-04-06T18:25:32.394Z'), + }, + }, + responseBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + }, + }; + + static convertedData = { + GENERAL: { + id: 'GENERAL', + slugifiedName: 'general', + displayName: undefined, + creator: undefined, + createdAt: new Date('2019-03-27T20:51:36.808Z'), + type: 'c', + messageCount: 31, + displaySystemMessages: true, + isReadOnly: false, + isDefault: true, + updatedAt: new Date('2019-04-10T17:44:34.931Z'), + lastModifiedAt: new Date('2019-04-10T17:44:34.873Z'), + customFields: {}, + }, + + GENERALPartial: { + id: 'GENERAL', + slugifiedName: 'general', + displaySystemMessages: true, + updatedAt: new Date('2019-04-10T17:44:34.931Z'), + }, + + GENERALPartialWithOptionalProps: { + id: 'GENERAL', + slugifiedName: 'general', + displaySystemMessages: true, + updatedAt: new Date('2019-04-10T17:44:34.931Z'), + messageCount: 40, + type: 'c', + }, + + UpdatedRoom: { + customFields: { custom: 'field' }, + }, + + LivechatRoom: { + id: 'LivechatRoom', + slugifiedName: undefined, + displayName: 'Livechat Guest', + creator: undefined, + createdAt: new Date('2019-04-06T03:56:17.040Z'), + type: 'l', // Apps-Engine defines the wrong type for livechat rooms + messageCount: 41, + displaySystemMessages: true, + isReadOnly: false, + isDefault: false, + updatedAt: new Date('2019-04-07T23:45:25.469Z'), + lastModifiedAt: new Date('2019-04-07T23:45:25.407Z'), + customFields: {}, + }, + }; +} diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/models/Users.mock.js b/apps/meteor/tests/unit/app/apps/server/mocks/models/Users.mock.js new file mode 100644 index 0000000..8454d16 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/models/Users.mock.js @@ -0,0 +1,43 @@ +import { BaseModelMock } from './BaseModel.mock'; + +export class UsersMock extends BaseModelMock { + data = { + 'rocket.cat': { + _id: 'rocket.cat', + createdAt: new Date('2019-03-27T20:51:36.821Z'), + avatarOrigin: 'local', + name: 'Rocket.Cat', + username: 'rocket.cat', + status: 'online', + statusDefault: 'online', + utcOffset: 0, + active: true, + type: 'bot', + _updatedAt: new Date('2019-03-30T01:11:50.496Z'), + roles: ['bot'], + }, + }; + + static convertedData = { + 'rocket.cat': { + id: 'rocket.cat', + username: 'rocket.cat', + emails: [ + { + address: 'rocketcat@rocket.chat', + verified: true, + }, + ], + type: 'bot', + isEnabled: true, + name: 'Rocket.Cat', + roles: ['bot'], + status: 'online', + statusConnection: 'online', + utcOffset: 0, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: undefined, + }, + }; +} diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/models/index.js b/apps/meteor/tests/unit/app/apps/server/mocks/models/index.js new file mode 100644 index 0000000..7257050 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/models/index.js @@ -0,0 +1,7 @@ +import { MessagesMock } from './Messages.mock'; +import { RoomsMock } from './Rooms.mock'; +import { UsersMock } from './Users.mock'; + +export const Messages = new MessagesMock(); +export const Rooms = new RoomsMock(); +export const Users = new UsersMock(); diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/orchestrator.mock.js b/apps/meteor/tests/unit/app/apps/server/mocks/orchestrator.mock.js new file mode 100644 index 0000000..79e8996 --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/mocks/orchestrator.mock.js @@ -0,0 +1,108 @@ +/* istanbul ignore file */ + +export class AppServerOrchestratorMock { + constructor() { + this._marketplaceClient = {}; + + this._model = {}; + this._logModel = {}; + this._persistModel = {}; + this._storage = {}; + this._logStorage = {}; + + this._converters = new Map(); + this._converters.set('messages', {}); + this._converters.set('rooms', {}); + this._converters.set('settings', {}); + this._converters.set('users', {}); + + this._bridges = {}; + + this._manager = {}; + + this._communicators = new Map(); + this._communicators.set('methods', {}); + this._communicators.set('notifier', {}); + this._communicators.set('restapi', {}); + } + + getMarketplaceClient() { + return this._marketplaceClient; + } + + getModel() { + return this._model; + } + + getPersistenceModel() { + return this._persistModel; + } + + getStorage() { + return this._storage; + } + + getLogStorage() { + return this._logStorage; + } + + getConverters() { + return this._converters; + } + + getBridges() { + return this._bridges; + } + + getNotifier() { + return this._communicators.get('notifier'); + } + + getManager() { + return this._manager; + } + + isEnabled() { + return true; + } + + isLoaded() { + return this.getManager().areAppsLoaded(); + } + + isDebugging() { + return true; + } + + debugLog(...args) { + if (this.isDebugging()) { + console.log(...args); + } + } + + load() { + // Don't try to load it again if it has + // already been loaded + if (this.isLoaded()) { + return; + } + + this._manager + .load() + .then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${affs.length} Apps!`)) + .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)); + } + + unload() { + // Don't try to unload it if it's already been + // unlaoded or wasn't unloaded to start with + if (!this.isLoaded()) { + return; + } + + this._manager + .unload() + .then(() => console.log('Unloaded the Apps Framework.')) + .catch((err) => console.warn('Failed to unload the Apps Framework!', err)); + } +} diff --git a/apps/meteor/tests/unit/app/apps/server/rooms.tests.ts b/apps/meteor/tests/unit/app/apps/server/rooms.tests.ts new file mode 100644 index 0000000..fa2dabc --- /dev/null +++ b/apps/meteor/tests/unit/app/apps/server/rooms.tests.ts @@ -0,0 +1,177 @@ +import type { IAppRoomsConverter, IAppsRoom } from '@rocket.chat/apps'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; + +import { MessagesMock } from './mocks/models/Messages.mock'; +import { RoomsMock } from './mocks/models/Rooms.mock'; +import { UsersMock } from './mocks/models/Users.mock'; +import { AppServerOrchestratorMock } from './mocks/orchestrator.mock'; + +const { AppRoomsConverter } = proxyquire.noCallThru().load('../../../../../app/apps/server/converters/rooms', { + '@rocket.chat/random': { + Random: { + id: () => 1, + }, + }, + '@rocket.chat/models': { + Rooms: new RoomsMock(), + Messages: new MessagesMock(), + Users: new UsersMock(), + }, +}); + +describe('The AppMessagesConverter instance', () => { + let roomConverter: IAppRoomsConverter; + let roomsMock: RoomsMock; + + before(() => { + const orchestrator = new AppServerOrchestratorMock(); + + const usersConverter = orchestrator.getConverters().get('users'); + + usersConverter.convertById = function convertUserByIdStub(id: string) { + return UsersMock.convertedData[id as 'rocket.cat'] || undefined; + }; + + usersConverter.convertToApp = function convertUserToAppStub(user: UsersMock['data']['rocket.cat']) { + return { + id: user._id, + username: user.username, + name: user.name, + }; + }; + + orchestrator.getConverters().get('messages').convertById = async function convertRoomByIdStub(_id: string) { + return {}; + }; + + roomConverter = new AppRoomsConverter(orchestrator); + roomsMock = new RoomsMock(); + }); + + describe('when converting a room from Rocket.Chat to the Engine schema', () => { + it('should return `undefined` when `originalRoom` is falsy', async () => { + const appRoom = await roomConverter.convertRoom(undefined); + + expect(appRoom).to.be.undefined; + }); + + it('should return a proper schema', async () => { + const mockedRoom = roomsMock.findOneById('GENERAL') as RoomsMock['data']['GENERAL']; + const appRoom = await roomConverter.convertRoom(mockedRoom as unknown as IRoom); + + expect(appRoom).to.have.property('id', mockedRoom._id); + expect(appRoom).to.have.property('type', mockedRoom.t); + expect(appRoom).to.have.property('slugifiedName', mockedRoom.name); + expect(appRoom).to.have.property('createdAt').which.equalTime(mockedRoom.ts); + expect(appRoom).to.have.property('updatedAt').which.equalTime(mockedRoom._updatedAt); + expect(appRoom).to.have.property('messageCount', mockedRoom.msgs); + }); + + it('should not mutate the original room object', async () => { + const rocketchatRoomMock = structuredClone(roomsMock.findOneById('GENERAL')); + + await roomConverter.convertRoom(rocketchatRoomMock); + + expect(rocketchatRoomMock).to.deep.equal(roomsMock.findOneById('GENERAL')); + }); + + it('should add an `_unmappedProperties_` field to the converted room which contains the `lastMessage` property of the room', async () => { + const mockedRoom = roomsMock.findOneById('GENERAL') as RoomsMock['data']['GENERAL']; + const appMessage = await roomConverter.convertRoom(mockedRoom as unknown as IRoom); + + expect(appMessage).to.have.property('_unmappedProperties_').which.has.property('lastMessage').to.deep.equal(mockedRoom.lastMessage); + }); + }); + + describe('when converting a room from the Engine schema back to Rocket.Chat', () => { + it('should return `undefined` when `room` is falsy', async () => { + const rocketchatMessage = await roomConverter.convertAppRoom(undefined); + + expect(rocketchatMessage).to.be.undefined; + }); + + it('should return a proper schema', async () => { + const appRoom = RoomsMock.convertedData.GENERAL as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom); + + expect(rocketchatRoom).to.have.property('_id', appRoom.id); + expect(rocketchatRoom).to.have.property('ts', appRoom.createdAt); + expect(rocketchatRoom).to.have.property('lm', appRoom.lastModifiedAt); + expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt); + expect(rocketchatRoom).to.have.property('t', appRoom.type); + expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName); + }); + + it('should return a proper schema when receiving a partial object', async () => { + const appRoom = RoomsMock.convertedData.GENERALPartial as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true); + + expect(rocketchatRoom).to.have.property('_id', appRoom.id); + expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName); + expect(rocketchatRoom).to.have.property('sysMes', appRoom.displaySystemMessages); + expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt); + + expect(rocketchatRoom).to.not.have.property('msgs'); + expect(rocketchatRoom).to.not.have.property('ro'); + expect(rocketchatRoom).to.not.have.property('default'); + expect(rocketchatRoom).to.not.have.property('t'); + }); + + it('should return a proper schema when receiving a partial object', async () => { + const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true); + + expect(rocketchatRoom).to.have.property('_id', appRoom.id); + expect(rocketchatRoom).to.have.property('name', appRoom.slugifiedName); + expect(rocketchatRoom).to.have.property('sysMes', appRoom.displaySystemMessages); + expect(rocketchatRoom).to.have.property('_updatedAt', appRoom.updatedAt); + expect(rocketchatRoom).to.have.property('msgs', appRoom.messageCount); + expect(rocketchatRoom).to.have.property('t', 'c'); + + expect(rocketchatRoom).to.not.have.property('ro'); + expect(rocketchatRoom).to.not.have.property('default'); + }); + + it('should not include properties that are not present in the app room', async () => { + const appRoom = RoomsMock.convertedData.UpdatedRoom as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true); + + expect(rocketchatRoom).to.have.property('customFields'); + expect(rocketchatRoom).to.not.have.property('_id'); + expect(rocketchatRoom).to.not.have.property('t'); + }); + + it('should not include name as undefined if the room doesnt have a name property', async () => { + const appRoom = RoomsMock.convertedData.UpdatedRoom as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true); + + expect(rocketchatRoom.name).to.be.undefined; + }); + + it('should include a name if the source room has slugifiedName property', async () => { + const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom; + const rocketchatRoom = await roomConverter.convertAppRoom(appRoom, true); + + expect(rocketchatRoom.name).to.equal(appRoom.slugifiedName); + }); + + it('should not use _unmappedProperties when the room is a partial object', async () => { + const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom; + // @ts-expect-error - _unmappedProperties + const rocketchatRoom = await roomConverter.convertAppRoom({ ...appRoom, _unmappedProperties_: { unmapped: 'property' } }, true); + + expect(rocketchatRoom).to.not.have.property('unmapped'); + }); + + it('should use _unmappedProperties when the room is a partial object', async () => { + const appRoom = RoomsMock.convertedData.GENERALPartialWithOptionalProps as unknown as IAppsRoom; + // @ts-expect-error - _unmappedProperties + const rocketchatRoom = await roomConverter.convertAppRoom({ ...appRoom, _unmappedProperties_: { unmapped: 'property' } }, false); + + expect(rocketchatRoom).to.have.property('unmapped', 'property'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/emoji/emojiNativeParser.spec.ts b/apps/meteor/tests/unit/app/emoji/emojiNativeParser.spec.ts new file mode 100644 index 0000000..a4315f9 --- /dev/null +++ b/apps/meteor/tests/unit/app/emoji/emojiNativeParser.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { getEmojiData } from '../../../../app/emoji-native/lib/generateEmojiData'; +import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig'; +import { shortnameToUnicode } from '../../../../app/emoji-native/lib/shortnameToUnicode'; + +describe('emoji-native shortcode resolution', () => { + describe('shortnameToUnicode', () => { + it('resolves skin-tone variants stored under emojione alternate names', () => { + expect(shortnameToUnicode(':thumbsup_tone3:')).to.equal('👍🏽'); + expect(shortnameToUnicode(':thumbup_tone3:')).to.equal('👍🏽'); + expect(shortnameToUnicode(':clap_tone2:')).to.equal('👏🏼'); + expect(shortnameToUnicode(':pray_tone4:')).to.equal('🙏🏾'); + }); + + it('still resolves the emojibase primary skin-tone name', () => { + expect(shortnameToUnicode(':+1_tone3:')).to.equal('👍🏽'); + }); + + it('resolves regional indicator letters', () => { + expect(shortnameToUnicode(':regional_indicator_a:')).to.equal('🇦'); + expect(shortnameToUnicode(':regional_indicator_z:')).to.equal('🇿'); + }); + + it('resolves legacy emojione base aliases', () => { + expect(shortnameToUnicode(':digit_zero:')).to.equal('0️⃣'); + expect(shortnameToUnicode(':digit_nine:')).to.equal('9️⃣'); + expect(shortnameToUnicode(':pound_symbol:')).to.equal('#️⃣'); + expect(shortnameToUnicode(':asterisk_symbol:')).to.equal('*️⃣'); + expect(shortnameToUnicode(':tone3:')).to.equal('🏽'); + }); + + it('leaves unknown shortcodes untouched', () => { + expect(shortnameToUnicode(':not_a_real_emoji:')).to.equal(':not_a_real_emoji:'); + }); + + it('resolves multiple shortcodes within a single string', () => { + expect(shortnameToUnicode('hi :thumbsup_tone3: and :regional_indicator_a:')).to.equal('hi 👍🏽 and 🇦'); + }); + }); + + describe('render', () => { + const { render } = getEmojiConfig({ list: {}, packages: {} }); + + it('renders a known shortcode as an emoji span', () => { + expect(render(':smile:')).to.contain('class="emoji"'); + }); + + it('leaves prototype property names untouched', () => { + expect(render(':toString:')).to.equal(':toString:'); + expect(render('topic :toString: end')).to.equal('topic :toString: end'); + }); + }); + + describe('getEmojiData', () => { + it('registers regional indicators in emojiList but keeps them out of the picker', () => { + const { emojiList, emojisByCategory } = getEmojiData(); + expect(emojiList[':regional_indicator_a:']?.unicode).to.equal('🇦'); + expect(emojisByCategory.flags).to.not.include('regional_indicator_a'); + }); + + it('registers alternate skin-tone shortnames alongside the primary', () => { + const { emojiList } = getEmojiData(); + expect(emojiList[':thumbsup_tone3:']?.unicode).to.equal('👍🏽'); + expect(emojiList[':thumbup_tone3:']?.unicode).to.equal('👍🏽'); + expect(emojiList[':+1_tone3:']?.unicode).to.equal('👍🏽'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/emoji/helpers.spec.ts b/apps/meteor/tests/unit/app/emoji/helpers.spec.ts new file mode 100644 index 0000000..728e306 --- /dev/null +++ b/apps/meteor/tests/unit/app/emoji/helpers.spec.ts @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, before } from 'mocha'; + +import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig'; +import { getEmojisBySearchTerm, updateRecent, removeFromRecent, replaceEmojiInRecent } from '../../../../client/lib/emoji/helpers'; +import { emoji } from '../../../../client/lib/emoji/lib'; + +const registerNativeEmojis = () => { + const config = getEmojiConfig(emoji); + + emoji.packages.native = { + emojiCategories: config.emojiCategories as any, + emojisByCategory: config.emojisByCategory, + toneList: config.toneList, + render: config.render, + renderPicker: config.renderPicker, + sprites: config.sprites, + }; + + for (const [key, currentEmoji] of Object.entries(config.emojiList)) { + currentEmoji.emojiPackage = 'native'; + emoji.list[key] = currentEmoji as any; + + if (currentEmoji.shortnames) { + currentEmoji.shortnames.forEach((shortname: string) => { + emoji.list[shortname] = currentEmoji as any; + }); + } + } +}; + +const registerCustomEmoji = (name: string) => { + if (!emoji.packages.emojiCustom) { + emoji.packages.emojiCustom = { + emojiCategories: [{ key: 'rocket', i18n: 'Custom' as any }], + emojisByCategory: { rocket: [] }, + toneList: {}, + render: (html: string) => html, + renderPicker: () => '', + } as any; + } + emoji.packages.emojiCustom.emojisByCategory.rocket.push(name); + emoji.list[`:${name}:`] = { emojiPackage: 'emojiCustom', name } as any; +}; + +describe('Emoji Client Helpers', () => { + beforeEach(() => { + emoji.packages.base.emojisByCategory.recent = []; + }); + + describe('getEmojisBySearchTerm', () => { + before(registerNativeEmojis); + + const search = (term: string) => getEmojisBySearchTerm(term, 0, [], () => undefined); + const names = (term: string) => search(term).map((result) => result.emoji); + const rendersThumbsUp = (term: string) => search(term).some((result) => result.image?.includes('👍')); + + it('finds an emoji by its primary shortcode', () => { + expect(names('+1')).to.include('+1'); + }); + + it('finds an emoji by its first alias (thumbsup -> 👍)', () => { + expect(rendersThumbsUp('thumbsup')).to.be.true; + }); + + it('finds an emoji by a secondary alias (thumbup -> 👍)', () => { + expect(rendersThumbsUp('thumbup')).to.be.true; + }); + + it('matches aliases partially (thumb -> thumbsup)', () => { + expect(names('thumb')).to.include('thumbsup'); + }); + + it('does not list the same emoji more than once', () => { + const images = search('grinning').map((result) => result.image); + expect(images.length).to.equal(new Set(images).size); + }); + + it('excludes skin-tone variants from the results', () => { + expect(names('+1').some((name) => /_tone[1-5]/.test(name))).to.be.false; + }); + + it('applies the selected skin tone when searching by an alias', () => { + const result = getEmojisBySearchTerm('thumbsup', 2, [], () => undefined).find(({ image }) => image?.includes('👍')); + + expect(result).to.exist; + expect(result?.image).to.include('👍🏼'); + expect(result?.emoji).to.equal('thumbsup_tone2'); + }); + + it('finds a custom emoji whose name ends in a mixed skin-tone suffix (CORE-2473)', () => { + registerCustomEmoji('mycustom_tone1-2'); + expect(names('mycustom_tone1-2')).to.include('mycustom_tone1-2'); + }); + }); + + describe('updateRecent', () => { + it('should update recent emojis with the provided emojis', () => { + const recentEmojis = ['emoji1', 'emoji2']; + updateRecent(recentEmojis); + expect(emoji.packages.base.emojisByCategory.recent).to.contain('emoji1'); + expect(emoji.packages.base.emojisByCategory.recent).to.contain('emoji2'); + }); + }); + + describe('removeFromRecent', () => { + it('should remove a specific emoji from recent emojis', () => { + emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2', 'emoji3']; + removeFromRecent('emoji2', emoji.packages.base.emojisByCategory.recent); + expect(emoji.packages.base.emojisByCategory.recent).to.not.include('emoji2'); + expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji3']); + }); + + it('should do nothing if the emoji is not in the recent list', () => { + emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2']; + removeFromRecent('emoji3', emoji.packages.base.emojisByCategory.recent); + expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji2']); + }); + }); + + describe('replaceEmojiInRecent', () => { + it('should replace an existing emoji with a new one in recent emojis', () => { + emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2', 'emoji3']; + replaceEmojiInRecent({ oldEmoji: 'emoji2', newEmoji: 'emoji4' }); + expect(emoji.packages.base.emojisByCategory.recent).to.not.include('emoji2'); + expect(emoji.packages.base.emojisByCategory.recent).to.include('emoji4'); + expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji4', 'emoji3']); + }); + + it('should do nothing if the emoji to replace is not in the recent list', () => { + emoji.packages.base.emojisByCategory.recent = ['emoji1', 'emoji2']; + replaceEmojiInRecent({ oldEmoji: 'emoji3', newEmoji: 'emoji4' }); + expect(emoji.packages.base.emojisByCategory.recent).to.deep.equal(['emoji1', 'emoji2']); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/highlight-words/helper.tests.js b/apps/meteor/tests/unit/app/highlight-words/helper.tests.js new file mode 100644 index 0000000..e47582a --- /dev/null +++ b/apps/meteor/tests/unit/app/highlight-words/helper.tests.js @@ -0,0 +1,62 @@ +import { expect } from 'chai'; + +import { highlightWords, getRegexHighlight, getRegexHighlightUrl } from '../../../../client/lib/highlightWords'; + +describe('helper', () => { + describe('highlightWords', () => { + it('highlights the correct words', () => { + const res = highlightWords( + 'here is some word', + ['word'].map((highlight) => ({ + highlight, + regex: getRegexHighlight(highlight), + urlRegex: getRegexHighlightUrl(highlight), + })), + ); + + expect(res).to.be.equal('here is some word'); + }); + + describe('handles links', () => { + it('not highlighting one link', () => { + const res = highlightWords( + 'here we go https://somedomain.com/here-some.word/pulls more words after', + ['word'].map((highlight) => ({ + highlight, + regex: getRegexHighlight(highlight), + urlRegex: getRegexHighlightUrl(highlight), + })), + ); + + expect(res).to.be.equal('here we go https://somedomain.com/here-some.word/pulls more words after'); + }); + + it('not highlighting two links', () => { + const msg = 'here https://somedomain.com/here-some-foo/pulls more words after http://www.domain.com/some.foo/bar words after'; + const res = highlightWords( + msg, + ['foo'].map((highlight) => ({ + highlight, + regex: getRegexHighlight(highlight), + urlRegex: getRegexHighlightUrl(highlight), + })), + ); + + expect(res).to.be.equal(msg); + }); + + it('not highlighting link but keep words on message highlighted', () => { + const res = highlightWords( + 'here we go https://somedomain.com/here-some.foo/pulls more foo after', + ['foo'].map((highlight) => ({ + highlight, + regex: getRegexHighlight(highlight), + urlRegex: getRegexHighlightUrl(highlight), + })), + ); + + expect(res).to.be.equal('here we go https://somedomain.com/here-some.foo/pulls more foo after'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/markdown/client.mocks.js b/apps/meteor/tests/unit/app/markdown/client.mocks.js new file mode 100644 index 0000000..9ca2ec8 --- /dev/null +++ b/apps/meteor/tests/unit/app/markdown/client.mocks.js @@ -0,0 +1,24 @@ +import proxyquire from 'proxyquire'; + +const mocks = { + 'meteor/meteor': { + 'Meteor': { + absoluteUrl() { + return 'http://localhost:3000/'; + }, + }, + '@global': true, + }, + '@rocket.chat/random': { + 'Random': { + id() { + return Math.random().toString().replace('0.', 'A'); + }, + }, + '@global': true, + }, +}; + +export const { Markdown } = proxyquire.noCallThru().load('../../../../app/markdown/lib/markdown', mocks); +export const { original } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/original/original', mocks); +export const { filtered } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/filtered/filtered', mocks); diff --git a/apps/meteor/tests/unit/app/markdown/client.tests.js b/apps/meteor/tests/unit/app/markdown/client.tests.js new file mode 100644 index 0000000..5195f44 --- /dev/null +++ b/apps/meteor/tests/unit/app/markdown/client.tests.js @@ -0,0 +1,479 @@ +import { escapeHTML } from '@rocket.chat/tools'; +import { expect } from 'chai'; + +import { Markdown, original, filtered } from './client.mocks'; + +const wrapper = (text, tag) => `${tag}${text}${tag}`; +const boldWrapper = (text) => wrapper(`${text}`, '*'); +const italicWrapper = (text) => wrapper(`${text}`, '_'); +const strikeWrapper = (text) => wrapper(`${text}`, '~'); +const headerWrapper = (text, level) => `${text}`; +const quoteWrapper = (text) => + `
>${text}
`; +const linkWrapped = (link, title) => `${title}`; +const inlinecodeWrapper = (text) => wrapper(`${text}`, '`'); +const codeWrapper = (text, lang) => + `
\`\`\`
${text}
\`\`\`
`; + +const bold = { + '**': '**', + '* *': '* *', + '** *': '** *', + '** **': '** **', + '* Hello*': '* Hello*', + '*Hello *': '*Hello *', + ':custom*emoji*case:': `:custom${boldWrapper('emoji')}case:`, + 'text*hello*text': `text${boldWrapper('hello')}text`, + '*hello*text': `${boldWrapper('hello')}text`, + 'text*hello*': `text${boldWrapper('hello')}`, + '*Hel lo*': boldWrapper('Hel lo'), + '*Hello*': boldWrapper('Hello'), + '**Hello**': boldWrapper('Hello'), + '**Hello*': `*${boldWrapper('Hello')}`, + '*Hello**': `${boldWrapper('Hello')}*`, + 'Hello': 'Hello', + '*Hello': '*Hello', + 'Hello*': 'Hello*', + 'He*llo': 'He*llo', + '***Hello***': `*${boldWrapper('Hello')}*`, + '***Hello**': `*${boldWrapper('Hello')}`, + '*Hello* this is dog': `${boldWrapper('Hello')} this is dog`, + 'Rocket cat says *Hello*': `Rocket cat says ${boldWrapper('Hello')}`, + 'He said *Hello* to her': `He said ${boldWrapper('Hello')} to her`, + '**Hello** this is dog': `${boldWrapper('Hello')} this is dog`, + 'Rocket cat says **Hello**': `Rocket cat says ${boldWrapper('Hello')}`, + 'He said **Hello** to her': `He said ${boldWrapper('Hello')} to her`, + 'He was a**nn**oyed': `He was a${boldWrapper('nn')}oyed`, + 'There are two o in f*oo*tball': `There are two o in f${boldWrapper('oo')}tball`, +}; + +const italic = { + '__': '__', + '_ _': '_ _', + '__ _': '__ _', + '__ __': '__ __', + '_ Hello_': '_ Hello_', + '_Hello _': '_Hello _', + ':custom_emoji_case:': ':custom_emoji_case:', + 'text_hello_text': 'text_hello_text', + '_hello_text': '_hello_text', + 'text_hello_': 'text_hello_', + '_Hel lo_': italicWrapper('Hel lo'), + '_Hello_': italicWrapper('Hello'), + '__Hello__': italicWrapper('Hello'), + '__Hello_': `_${italicWrapper('Hello')}`, + '_Hello__': `${italicWrapper('Hello')}_`, + 'Hello': 'Hello', + '_Hello': '_Hello', + 'Hello_': 'Hello_', + 'He_llo': 'He_llo', + '___Hello___': `_${italicWrapper('Hello')}_`, + '___Hello__': `_${italicWrapper('Hello')}`, + '_Hello_ this is dog': `${italicWrapper('Hello')} this is dog`, + 'Rocket cat says _Hello_': `Rocket cat says ${italicWrapper('Hello')}`, + 'He said _Hello_ to her': `He said ${italicWrapper('Hello')} to her`, + '__Hello__ this is dog': `${italicWrapper('Hello')} this is dog`, + 'Rocket cat says __Hello__': `Rocket cat says ${italicWrapper('Hello')}`, + 'He said __Hello__ to her': `He said ${italicWrapper('Hello')} to her`, +}; + +const strike = { + '~~': '~~', + '~ ~': '~ ~', + '~~ ~': '~~ ~', + '~~ ~~': '~~ ~~', + '~ Hello~': '~ Hello~', + '~Hello ~': '~Hello ~', + ':custom~emoji~case:': `:custom${strikeWrapper('emoji')}case:`, + 'text~hello~text': `text${strikeWrapper('hello')}text`, + '~hello~text': `${strikeWrapper('hello')}text`, + 'text~hello~': `text${strikeWrapper('hello')}`, + '~Hel lo~': strikeWrapper('Hel lo'), + '~Hello~': strikeWrapper('Hello'), + '~~Hello~~': strikeWrapper('Hello'), + '~~Hello~': `~${strikeWrapper('Hello')}`, + '~Hello~~': `${strikeWrapper('Hello')}~`, + 'Hello': 'Hello', + '~Hello': '~Hello', + 'Hello~': 'Hello~', + 'He~llo': 'He~llo', + '~~~Hello~~~': `~${strikeWrapper('Hello')}~`, + '~~~Hello~~': `~${strikeWrapper('Hello')}`, + '~Hello~ this is dog': `${strikeWrapper('Hello')} this is dog`, + 'Rocket cat says ~Hello~': `Rocket cat says ${strikeWrapper('Hello')}`, + 'He said ~Hello~ to her': `He said ${strikeWrapper('Hello')} to her`, + '~~Hello~~ this is dog': `${strikeWrapper('Hello')} this is dog`, + 'Rocket cat says ~~Hello~~': `Rocket cat says ${strikeWrapper('Hello')}`, + 'He said ~~Hello~~ to her': `He said ${strikeWrapper('Hello')} to her`, +}; + +const headersLevel1 = { + '# Hello': headerWrapper('Hello', 1), + '# Rocket.Cat': headerWrapper('Rocket.Cat', 1), + '# Hi': headerWrapper('Hi', 1), + '# Hello this is dog': headerWrapper('Hello this is dog', 1), + '# Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 1), + '# He said Hello to her': headerWrapper('He said Hello to her', 1), + '#Hello': '#Hello', + '#Hello#': '#Hello#', + 'He#llo': 'He#llo', +}; + +const headersLevel2 = { + '## Hello': headerWrapper('Hello', 2), + '## Rocket.Cat': headerWrapper('Rocket.Cat', 2), + '## Hi': headerWrapper('Hi', 2), + '## Hello this is dog': headerWrapper('Hello this is dog', 2), + '## Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 2), + '## He said Hello to her': headerWrapper('He said Hello to her', 2), + '##Hello': '##Hello', + '##Hello##': '##Hello##', + 'He##llo': 'He##llo', +}; + +const headersLevel3 = { + '### Hello': headerWrapper('Hello', 3), + '### Rocket.Cat': headerWrapper('Rocket.Cat', 3), + '### Hi': headerWrapper('Hi', 3), + '### Hello this is dog': headerWrapper('Hello this is dog', 3), + '### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 3), + '### He said Hello to her': headerWrapper('He said Hello to her', 3), + '###Hello': '###Hello', + '###Hello###': '###Hello###', + 'He###llo': 'He###llo', +}; + +const headersLevel4 = { + '#### Hello': headerWrapper('Hello', 4), + '#### Rocket.Cat': headerWrapper('Rocket.Cat', 4), + '#### Hi': headerWrapper('Hi', 4), + '#### Hello this is dog': headerWrapper('Hello this is dog', 4), + '#### Rocket cat says Hello': headerWrapper('Rocket cat says Hello', 4), + '#### He said Hello to her': headerWrapper('He said Hello to her', 4), + '####Hello': '####Hello', + '####Hello####': '####Hello####', + 'He####llo': 'He####llo', +}; + +const quote = { + '>Hello': escapeHTML('>Hello'), + '>Rocket.Cat': escapeHTML('>Rocket.Cat'), + '>Hi': escapeHTML('>Hi'), + '> Hello this is dog': escapeHTML('> Hello this is dog'), + '> Rocket cat says Hello': escapeHTML('> Rocket cat says Hello'), + '> He said Hello to her': escapeHTML('> He said Hello to her'), + '> He said Hello to her ': escapeHTML('> He said Hello to her '), + '<Hello': escapeHTML('<Hello'), + '<Rocket.Cat>': escapeHTML('<Rocket.Cat>'), + ' >Hi': escapeHTML(' >Hi'), + 'Hello > this is dog': escapeHTML('Hello > this is dog'), + 'Roc>ket cat says Hello': escapeHTML('Roc>ket cat says Hello'), + 'He said Hello to her>': escapeHTML('He said Hello to her>'), + '>Hello': quoteWrapper('Hello'), + '>Rocket.Cat': quoteWrapper('Rocket.Cat'), + '>Hi': quoteWrapper('Hi'), + '> Hello this is dog': quoteWrapper(' Hello this is dog'), + '> Rocket cat says Hello': quoteWrapper(' Rocket cat says Hello'), + '> He said Hello to her': quoteWrapper(' He said Hello to her'), + '': escapeHTML(''), + ' >Hi': escapeHTML(' >Hi'), + 'Hello > this is dog': escapeHTML('Hello > this is dog'), + 'Roc>ket cat says Hello': escapeHTML('Roc>ket cat says Hello'), + 'He said Hello to her>': escapeHTML('He said Hello to her>'), +}; + +const link = { + '<http://link|Text>': escapeHTML('<http://link|Text>'), + '<https://open.rocket.chat/|Open Site For Rocket.Chat>': escapeHTML('<https://open.rocket.chat/|Open Site For Rocket.Chat>'), + '<https://open.rocket.chat/ | Open Site For Rocket.Chat>': escapeHTML( + '<https://open.rocket.chat/ | Open Site For Rocket.Chat>', + ), + '<https://rocket.chat/|Rocket.Chat Site>': '&lt;https://rocket.chat/|Rocket.Chat Site&gt;', + '<https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': escapeHTML( + '<https://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>', + ), + '<http://linkText>': escapeHTML('<http://linkText>'), + '<https:open.rocket.chat/ | Open Site For Rocket.Chat>': escapeHTML('<https:open.rocket.chat/ | Open Site For Rocket.Chat>'), + 'https://open.rocket.chat/|Open Site For Rocket.Chat': escapeHTML('https://open.rocket.chat/|Open Site For Rocket.Chat'), + '<www.open.rocket.chat/|Open Site For Rocket.Chat>': escapeHTML('<www.open.rocket.chat/|Open Site For Rocket.Chat>'), + '<htps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<htps://rocket.chat/|Rocket.Chat Site>'), + '<ttps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<ttps://rocket.chat/|Rocket.Chat Site>'), + '<tps://rocket.chat/|Rocket.Chat Site>': escapeHTML('<tps://rocket.chat/|Rocket.Chat Site>'), + '<open.rocket.chat/|Open Site For Rocket.Chat>': escapeHTML('<open.rocket.chat/|Open Site For Rocket.Chat>'), + '<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>': escapeHTML( + '<htts://rocket.chat/docs/developer-guides/testing/#testing|Testing Entry on Rocket.Chat Docs Site>', + ), + + '': escapeHTML(''), + '': linkWrapped('http://link', 'Text'), + '': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'), + '': linkWrapped( + encodeURI('https://open.rocket.chat/ '), + ' Open Site For Rocket.Chat', + ), + '': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'), + '': linkWrapped( + 'https://rocket.chat/docs/developer-guides/testing/#testing', + 'Testing Entry on Rocket.Chat Docs Site', + ), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML(''), + '': escapeHTML( + '', + ), + + '[Text](http://invalid link)': '[Text](http://invalid link)', + '[Text](http://link)': linkWrapped('http://link', 'Text'), + '[Open Site For Rocket.Chat](https://open.rocket.chat/)': linkWrapped('https://open.rocket.chat/', 'Open Site For Rocket.Chat'), + '[ Open Site For Rocket.Chat ](https://open.rocket.chat/)': linkWrapped('https://open.rocket.chat/', ' Open Site For Rocket.Chat '), + '[Rocket.Chat Site](https://rocket.chat/)': linkWrapped('https://rocket.chat/', 'Rocket.Chat Site'), + '[Testing Entry on Rocket.Chat Docs Site](https://rocket.chat/docs/developer-guides/testing/#testing)': linkWrapped( + 'https://rocket.chat/docs/developer-guides/testing/#testing', + 'Testing Entry on Rocket.Chat Docs Site', + ), + '[](http://linkText)': '[](http://linkText)', + '[text]': '[text]', + '[Open Site For Rocket.Chat](https:open.rocket.chat/)': '[Open Site For Rocket.Chat](https:open.rocket.chat/)', + '[Open Site For Rocket.Chat](www.open.rocket.chat/)': '[Open Site For Rocket.Chat](www.open.rocket.chat/)', + '[Rocket.Chat Site](htps://rocket.chat/)': '[Rocket.Chat Site](htps://rocket.chat/)', + '[Rocket.Chat Site](ttps://rocket.chat/)': '[Rocket.Chat Site](ttps://rocket.chat/)', + '[Rocket.Chat Site](tps://rocket.chat/)': '[Rocket.Chat Site](tps://rocket.chat/)', + '[Open Site For Rocket.Chat](open.rocket.chat/)': '[Open Site For Rocket.Chat](open.rocket.chat/)', + '[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)': + '[Testing Entry on Rocket.Chat Docs Site](htts://rocket.chat/docs/developer-guides/testing/#testing)', + '[Text](http://link?param1=1¶m2=2)': linkWrapped('http://link?param1=1&param2=2', 'Text'), + '[Testing Double parentheses](https://en.wikipedia.org/wiki/Disambiguation_(disambiguation))': linkWrapped( + 'https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)', + 'Testing Double parentheses', + ), + '[Testing data after Double parentheses](https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)/blabla/bla)': linkWrapped( + 'https://en.wikipedia.org/wiki/Disambiguation_(disambiguation)/blabla/bla', + 'Testing data after Double parentheses', + ), +}; + +Object.entries(link).forEach(([key, value]) => { + link[`before (test) ${key} after (test)`] = `before (test) ${value} after (test)`; +}); + +const inlinecode = { + '`code`': inlinecodeWrapper('code'), + '`code` begin': `${inlinecodeWrapper('code')} begin`, + 'End `code`': `End ${inlinecodeWrapper('code')}`, + 'Middle `code` middle': `Middle ${inlinecodeWrapper('code')} middle`, + '`code`begin': `${inlinecodeWrapper('code')}begin`, + 'End`code`': `End${inlinecodeWrapper('code')}`, + 'Middle`code`middle': `Middle${inlinecodeWrapper('code')}middle`, +}; + +const code = { + '```code```': codeWrapper('code', 'clean'), + '```code': codeWrapper('code\n', 'clean'), + '```code\n': codeWrapper('code\n', 'clean'), + '```\ncode\n```': codeWrapper('code\n', 'clean'), + '```code\n```': codeWrapper('code\n', 'clean'), + '```\ncode```': codeWrapper('code', 'clean'), + "```javascript\nvar a = 'log';\nconsole.log(a);```": codeWrapper( + 'var a = 'log';\nconsole.log(a);', + 'javascript', + ), + '```*code*```': codeWrapper('*code*', 'markdown'), + '```**code**```': codeWrapper('**code**', 'markdown'), + '```__code__```': codeWrapper('__code__', 'markdown'), +}; + +const nested = { + '> some quote\n`window.location.reload();`': `${quoteWrapper(' some quote')}${inlinecodeWrapper('window.location.reload();')}`, +}; + +/* + * Markdown Filters + */ +const boldFiltered = { + '*Hello*': 'Hello', + '**Hello**': 'Hello', + '*Hello**': 'Hello', + 'He*llo': 'He*llo', + '*Hello': '*Hello', + 'Hello*': 'Hello*', + '***Hello***': '***Hello***', + '***Hello**': '***Hello**', + '*Hello* there': 'Hello there', + '**Hello** there': 'Hello there', + 'Hi, *Hello*': 'Hi, Hello', + 'Hi, **Hello**': 'Hi, Hello', + 'Hi, *Hello* how are you?': 'Hi, Hello how are you?', + 'Hi, **Hello** how are you?': 'Hi, Hello how are you?', +}; + +const italicFiltered = { + '_Hello_': 'Hello', + '__Hello__': 'Hello', + '_Hello__': 'Hello', + 'He_llo': 'He_llo', + '_Hello': '_Hello', + '__Hello': '__Hello', + 'Hello_': 'Hello_', + '___Hello___': '___Hello___', + '___Hello__': '___Hello__', + '_Hello_ there': 'Hello there', + '__Hello__ there': 'Hello there', + 'Hi, _Hello_': 'Hi, Hello', + 'Hi, __Hello__': 'Hi, Hello', + 'Hi, _Hello_ how are you?': 'Hi, Hello how are you?', + 'Hi, __Hello__ how are you?': 'Hi, Hello how are you?', +}; + +const strikeFiltered = { + '~Hello~': 'Hello', + '~~Hello~~': 'Hello', + '~~Hello': '~~Hello', + '~Hello~~': 'Hello', + 'He~llo': 'He~llo', + '~Hello': '~Hello', + 'Hello~': 'Hello~', + '~~~Hello~~~': '~~~Hello~~~', + '~~~Hello~~': '~~~Hello~~', + '~Hello~ there': 'Hello there', + '~~Hello~~ there': 'Hello there', + 'Hi, ~Hello~': 'Hi, Hello', + 'Hi, ~~Hello~~': 'Hi, Hello', + 'Hi, ~Hello~ how are you?': 'Hi, Hello how are you?', + 'Hi, ~~Hello~~ how are you?': 'Hi, Hello how are you?', +}; + +const headingFiltered = { + '# Hello': 'Hello', + '## Hello': 'Hello', + '### Hello': 'Hello', + '#### Hello': 'Hello', + '#Hello': '#Hello', + '##Hello': '##Hello', + '###Hello': '###Hello', + '####Hello': '####Hello', + 'He#llo': 'He#llo', + '# Hello there': 'Hello there', + 'Hi, # Hello': 'Hi, # Hello', + 'Hi, # Hello there': 'Hi, # Hello there', +}; + +const quoteFiltered = { + '>Hello': 'Hello', + '> Hello': ' Hello', + '>>>\nHello\n<<<': 'Hello', + '>>>\nHello there!\n<<<': 'Hello there!', + '>>>\n Hello there! \n<<<': ' Hello there! ', +}; + +const linkFiltered = { + '[Text](http://link)': 'Text', + '[Open Site For Rocket.Chat](https://open.rocket.chat/)': 'Open Site For Rocket.Chat', + '[ Open Site For Rocket.Chat](https://open.rocket.chat/ )': ' Open Site For Rocket.Chat', + '[Rocket.Chat Site](https://rocket.chat/)': 'Rocket.Chat Site', + '': 'Text', + '': 'Text for test', +}; + +const inlinecodeFiltered = { + '`code`': 'code', + '`code` begin': 'code begin', + 'End `code`': 'End code', + 'Middle `code` middle': 'Middle code middle', + '`code`begin': 'codebegin', + 'End`code`': 'Endcode', + 'Middle`code`middle': 'Middlecodemiddle', +}; + +const blockcodeFiltered = { + '```code```': 'code', + '```code': 'code', + 'code```': 'code', + 'Here ```code``` lies': 'Here code lies', + 'Here```code```lies': 'Herecodelies', +}; + +const defaultObjectTest = (result, object, objectKey) => expect(result.html).to.be.equal(object[objectKey]); + +const testObject = (object, parser = original, test = defaultObjectTest) => { + Object.keys(object).forEach((objectKey) => { + describe(objectKey, () => { + const message = parser === original ? { html: escapeHTML(objectKey) } : objectKey; + const result = parser === original ? Markdown.mountTokensBack(parser(message)) : { html: parser(message) }; + it(`should be equal to ${object[objectKey]}`, () => { + test(result, object, objectKey); + }); + }); + }); +}; + +describe('Original', () => { + describe('Bold', () => testObject(bold)); + + describe('Italic', () => testObject(italic)); + + describe('Strike', () => testObject(strike)); + + describe('Headers', () => { + describe('Level 1', () => testObject(headersLevel1)); + + describe('Level 2', () => testObject(headersLevel2)); + + describe('Level 3', () => testObject(headersLevel3)); + + describe('Level 4', () => testObject(headersLevel4)); + }); + + describe('Quote', () => testObject(quote)); + + describe('Link', () => testObject(link)); + + describe('Inline Code', () => testObject(inlinecode)); + + describe('Code', () => testObject(code)); + + describe('Nested', () => testObject(nested)); +}); + +describe('Filtered', () => { + describe('BoldFilter', () => testObject(boldFiltered, filtered)); + + describe('Italic', () => testObject(italicFiltered, filtered)); + + describe('StrikeFilter', () => testObject(strikeFiltered, filtered)); + + describe('HeadingFilter', () => testObject(headingFiltered, filtered)); + + describe('QuoteFilter', () => testObject(quoteFiltered, filtered)); + + describe('LinkFilter', () => testObject(linkFiltered, filtered)); + + describe('inlinecodeFilter', () => testObject(inlinecodeFiltered, filtered)); + + describe('blockcodeFilter', () => testObject(blockcodeFiltered, filtered)); +}); + +// describe('Marked', function() { +// describe('Bold', () => testObject(bold, marked)); + +// describe('Italic', () => testObject(italic, marked)); + +// describe('Strike', () => testObject(strike, marked)); + +// describe('Headers', () => { +// describe('Level 1', () => testObject(headersLevel1, marked)); + +// describe('Level 2', () => testObject(headersLevel2, marked)); + +// describe('Level 3', () => testObject(headersLevel3, marked)); + +// describe('Level 4', () => testObject(headersLevel4, marked)); +// }); + +// describe('Quote', () => testObject(quote, marked)); +// }); diff --git a/apps/meteor/tests/unit/app/markdown/hljs.tests.ts b/apps/meteor/tests/unit/app/markdown/hljs.tests.ts new file mode 100644 index 0000000..7465280 --- /dev/null +++ b/apps/meteor/tests/unit/app/markdown/hljs.tests.ts @@ -0,0 +1,212 @@ +import { expect } from 'chai'; + +import hljs, { register } from '../../../../app/markdown/lib/hljs'; + +const lazyLanguages: string[] = [ + 'onec', + 'abnf', + 'accesslog', + 'actionscript', + 'ada', + 'apache', + 'applescript', + 'arduino', + 'armasm', + 'asciidoc', + 'aspectj', + 'autohotkey', + 'autoit', + 'avrasm', + 'awk', + 'axapta', + 'bash', + 'basic', + 'bnf', + 'brainfuck', + 'cal', + 'capnproto', + 'ceylon', + 'clean', + 'clojure', + 'clojure-repl', + 'cmake', + 'coffeescript', + 'coq', + 'cos', + 'cpp', + 'crmsh', + 'crystal', + 'csp', + 'css', + 'd', + 'dart', + 'delphi', + 'diff', + 'django', + 'dns', + 'dockerfile', + 'dos', + 'dsconfig', + 'dts', + 'dust', + 'ebnf', + 'elixir', + 'elm', + 'erb', + 'erlang', + 'excel', + 'fix', + 'flix', + 'fortran', + 'fsharp', + 'gams', + 'gauss', + 'gcode', + 'gherkin', + 'glsl', + 'go', + 'golo', + 'gradle', + 'groovy', + 'haml', + 'handlebars', + 'haskell', + 'haxe', + 'hsp', + 'http', + 'hy', + 'inform7', + 'ini', + 'irpf90', + 'java', + 'javascript', + 'jboss-cli', + 'json', + 'julia', + 'julia-repl', + 'kotlin', + 'lasso', + 'ldif', + 'leaf', + 'less', + 'lisp', + 'livecodeserver', + 'livescript', + 'llvm', + 'lsl', + 'lua', + 'makefile', + 'markdown', + 'mathematica', + 'matlab', + 'maxima', + 'mel', + 'mercury', + 'mipsasm', + 'mizar', + 'perl', + 'mojolicious', + 'monkey', + 'moonscript', + 'n1ql', + 'nginx', + 'nix', + 'nsis', + 'objectivec', + 'ocaml', + 'openscad', + 'oxygene', + 'parser3', + 'pf', + 'php', + 'pony', + 'powershell', + 'processing', + 'profile', + 'prolog', + 'protobuf', + 'puppet', + 'purebasic', + 'python', + 'q', + 'qml', + 'r', + 'rib', + 'roboconf', + 'rsl', + 'ruleslanguage', + 'rust', + 'scala', + 'scheme', + 'scilab', + 'scss', + 'shell', + 'smali', + 'smalltalk', + 'sml', + 'sqf', + 'sql', + 'stan', + 'stata', + 'step21', + 'stylus', + 'subunit', + 'swift', + 'taggerscript', + 'yaml', + 'tap', + 'tcl', + 'thrift', + 'tp', + 'twig', + 'typescript', + 'vala', + 'vbnet', + 'vbscript', + 'verilog', + 'vhdl', + 'vim', + 'x86asm', + 'xl', + 'xquery', + 'zephir', +]; + +const eagerLanguages = ['markdown', 'clean', 'javascript']; + +describe('hljs', () => { + describe('eagerly registered languages', () => { + eagerLanguages.forEach((lang) => { + it(`should register '${lang}' when the module is loaded`, () => { + expect(hljs.getLanguage(lang)).to.be.an('object'); + }); + }); + }); + + describe('register', () => { + afterEach(() => { + hljs + .listLanguages() + .filter((lang) => !eagerLanguages.includes(lang)) + .forEach((lang) => hljs.unregisterLanguage(lang)); + }); + + lazyLanguages.forEach((lang) => { + it(`should register a syntax definition for '${lang}'`, async () => { + hljs.unregisterLanguage(lang); + + await register(lang); + + expect(hljs.listLanguages()).to.include(lang); + }); + }); + + ['', 'not-a-language'].forEach((lang) => { + it(`should fall back to plaintext for '${lang}'`, async () => { + await register(lang); + + expect(hljs.listLanguages()).to.include('plaintext'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/markdown/markdown.mocks.ts b/apps/meteor/tests/unit/app/markdown/markdown.mocks.ts new file mode 100644 index 0000000..a40f62d --- /dev/null +++ b/apps/meteor/tests/unit/app/markdown/markdown.mocks.ts @@ -0,0 +1,43 @@ +import proxyquire from 'proxyquire'; + +export type MarkdownToken = { token: string; type?: string; text: string; noHtml?: string }; + +export type ParsedMessage = { html?: string; msg?: string; tokens?: MarkdownToken[] }; + +export type MarkdownOptions = { supportSchemesForLink?: string; headers?: boolean; rootUrl?: string }; + +const mocks = { + 'meteor/meteor': { + Meteor: { + absoluteUrl() { + return 'http://localhost:3000/'; + }, + }, + }, +}; + +export const { Markdown, filterMarkdown, createMarkdownMessageRenderer, createMarkdownNotificationRenderer } = proxyquire + .noCallThru() + .load('../../../../app/markdown/lib/markdown', mocks) as typeof import('../../../../app/markdown/lib/markdown'); + +export const { original } = proxyquire.noCallThru().load('../../../../app/markdown/lib/parser/original/original', mocks) as { + original: (message: ParsedMessage, options?: MarkdownOptions) => ParsedMessage; +}; + +export const { code } = proxyquire + .noCallThru() + .load('../../../../app/markdown/lib/parser/original/code', mocks) as typeof import('../../../../app/markdown/lib/parser/original/code'); + +const copyonly = (text: string, marker: string) => `${marker}${text}${marker}`; + +export const markup = { + bold: (text: string) => copyonly(`${text}`, '*'), + inlineCode: (text: string) => copyonly(`${text}`, '`'), + anchor: (url: string, title: string, target = '_blank') => + `${title}`, + image: (url: string, title: string, target = '_blank') => + `` + + `
`, + blockquote: (text: string, marker = '>') => + `
${marker}${text}
`, +}; diff --git a/apps/meteor/tests/unit/app/markdown/markdown.tests.ts b/apps/meteor/tests/unit/app/markdown/markdown.tests.ts new file mode 100644 index 0000000..40914af --- /dev/null +++ b/apps/meteor/tests/unit/app/markdown/markdown.tests.ts @@ -0,0 +1,235 @@ +import { escapeHTML } from '@rocket.chat/tools'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import type { MarkdownOptions, ParsedMessage } from './markdown.mocks'; +import { + Markdown, + original, + code, + filterMarkdown, + createMarkdownMessageRenderer, + createMarkdownNotificationRenderer, + markup, +} from './markdown.mocks'; +import hljs from '../../../../app/markdown/lib/hljs'; + +const { bold, inlineCode, anchor, image, blockquote } = markup; + +const rootUrl = 'http://localhost:3000/'; +const defaultOptions = { supportSchemesForLink: 'http,https', headers: true, rootUrl }; + +const render = (text: string, options: MarkdownOptions = defaultOptions) => + Markdown.mountTokensBack(original({ html: text }, options)).html; +const renderEscaped = (text: string, options: MarkdownOptions = defaultOptions) => render(escapeHTML(text), options); + +describe('Markdown entry points', () => { + it('should escape HTML before parsing', () => { + expect(Markdown.parse(' *Hello*')).to.equal(`<img src=x onerror=alert(1)> ${bold('Hello')}`); + }); + + it('should keep HTML when parsing unescaped input', () => { + expect(Markdown.parseNotEscaped('x *Hello*')).to.equal(`x ${bold('Hello')}`); + }); + + it('should leave the tokens unmounted when parsing a message', () => { + const message = Markdown.parseMessageNotEscaped({ html: '`code`' }); + + expect(message.tokens).to.have.lengthOf(1); + expect(message.tokens?.[0].type).to.equal('inlinecode'); + expect(message.html).to.equal(message.tokens?.[0].token); + }); + + it('should expose the code parser', () => { + const message = Markdown.code({ html: '`code`' }); + + expect(Markdown.mountTokensBack(message).html).to.equal(inlineCode('code')); + }); + + it('should strip the markdown syntax when filtering', () => { + expect(Markdown.filterMarkdownFromMessage('*Hello* `there`')).to.equal('Hello there'); + expect(filterMarkdown('*Hello* `there`')).to.equal('Hello there'); + }); +}); + +describe('Markdown token restoration', () => { + it('should return the message untouched when it has no tokens', () => { + const message = { html: 'plain text' }; + + expect(Markdown.mountTokensBack(message)).to.equal(message); + expect(message.html).to.equal('plain text'); + }); + + it('should restore the plain text representation when useHtml is false', () => { + const message = Markdown.parseMessageNotEscaped({ html: 'see `code`' }); + + expect(Markdown.mountTokensBack(message, false).html).to.equal('see `code`'); + }); + + it('should restore tokens that are only revealed by another token', () => { + const message = { + html: 'start =!=outer=!=', + tokens: [ + { token: '=!=inner=!=', text: 'inner' }, + { token: '=!=outer=!=', text: '=!=inner=!=' }, + ], + }; + + expect(Markdown.mountTokensBack(message).html).to.equal('start inner'); + }); + + it('should leave tokens that never appear in the message', () => { + const message = { + html: 'start =!=present=!=', + tokens: [ + { token: '=!=absent=!=', text: 'absent' }, + { token: '=!=present=!=', text: 'present' }, + ], + }; + + expect(Markdown.mountTokensBack(message).html).to.equal('start present'); + }); + + it('should not treat $ sequences in the token text as replacement patterns', () => { + const message = { html: '=!=t=!=', tokens: [{ token: '=!=t=!=', text: '$& $1 $$' }] }; + + expect(Markdown.mountTokensBack(message).html).to.equal('$& $1 $$'); + }); +}); + +describe('Markdown renderers', () => { + const renderMessage = createMarkdownMessageRenderer({ + rootUrl: 'http://localhost:3000/', + supportSchemesForLink: 'http,https', + headers: true, + }); + + it('should render markdown of a non-empty message', () => { + expect(renderMessage({ html: '# Hello' })?.html).to.equal('

Hello

'); + }); + + const emptyMessages: (ParsedMessage | undefined)[] = [undefined, { html: '' }, { html: ' \n\t ' }]; + + emptyMessages.forEach((message, index) => { + it(`should return the message as-is when there is nothing to render (case ${index})`, () => { + expect(renderMessage(message)).to.equal(message); + }); + }); + + it('should strip the markdown syntax when rendering a notification', () => { + expect(createMarkdownNotificationRenderer()('*Hello* there')).to.equal('Hello there'); + }); +}); + +describe('Original parser', () => { + it('should convert every new line to a
', () => { + expect(render('a\nb\nc')).to.equal('a
b
c'); + }); + + it('should not render headings when the option is disabled', () => { + expect(render('# Hello', { ...defaultOptions, headers: false })).to.equal('# Hello'); + }); + + describe('blockquotes', () => { + it('should render consecutive single-line blockquotes without a line break in between', () => { + expect(renderEscaped('>a\n>b')).to.equal(`${blockquote('a')}${blockquote('b')}`); + }); + + it('should render a multiline blockquote delimited by >>> and <<<', () => { + expect(renderEscaped('>>>\nline1\nline2\n<<<')).to.equal( + blockquote('line1
line2<<<', '>>>'), + ); + }); + }); + + describe('links', () => { + it('should render an internal link without a target', () => { + expect(render(`[Home](${rootUrl}channel/general)`)).to.equal(anchor(`${rootUrl}channel/general`, 'Home', '')); + expect(render(`<${rootUrl}channel/general|Home>`)).to.equal(anchor(`${rootUrl}channel/general`, 'Home', '')); + }); + + it('should not render a link whose URL is not parseable', () => { + expect(render('[Text](http://[)')).to.equal('[Text](http://[)'); + expect(render('')).to.equal(''); + }); + + it('should not render a link whose URL contains another token', () => { + expect(render('[Text](http://example.com/`code`)')).to.equal(`[Text](http://example.com/${inlineCode('code')})`); + }); + + it('should not render a link whose title holds a disallowed token', () => { + expect(render('[`code`](http://example.com/)')).to.equal(`[${inlineCode('code')}](http://example.com/)`); + expect(render('')).to.equal(``); + }); + + it('should render a link whose title only looks like a token', () => { + expect(render('[=!=abcdefghijklmnopq=!=](http://example.com/)')).to.equal(anchor('http://example.com/', '=!=abcdefghijklmnopq=!=')); + }); + + it('should only render the schemes it was configured with', () => { + expect(render('[Text](ftp://example.com/)', { ...defaultOptions, supportSchemesForLink: 'ftp' })).to.equal( + anchor('ftp://example.com/', 'Text'), + ); + expect(render('[Text](http://example.com/)', { ...defaultOptions, supportSchemesForLink: 'ftp' })).to.equal( + '[Text](http://example.com/)', + ); + }); + }); + + describe('images', () => { + it('should render an external image with target _blank', () => { + expect(render('![alt](http://example.com/a.png)')).to.equal(image('http://example.com/a.png', 'alt')); + }); + + it('should render an internal image without a target', () => { + expect(render(`![alt](${rootUrl}a.png)`)).to.equal(image(`${rootUrl}a.png`, 'alt', '')); + }); + + it('should not render an image whose URL is not parseable', () => { + expect(render('![alt](http://[)')).to.equal('![alt](http://[)'); + }); + + it('should not render an image whose title holds a disallowed token', () => { + expect(render('![`code`](http://example.com/a.png)')).to.equal(`![${inlineCode('code')}](http://example.com/a.png)`); + }); + }); +}); + +describe('Code parser', () => { + it('should leave a message without code untouched', () => { + expect(code({ html: 'no code here' }).tokens).to.be.undefined; + expect(code({}).html).to.be.undefined; + }); + + it('should close an unclosed code block in both html and msg', () => { + const message: ParsedMessage = { html: '```code', msg: '```code' }; + + code(message); + + expect(message.msg).to.equal('```code\n```'); + expect(message.html).to.equal(message.tokens?.[0].token); + }); + + it('should restore the original fenced block when tokens are mounted back as text', () => { + const message = original({ html: '```\ncode\n```' }, defaultOptions); + + expect(Markdown.mountTokensBack(message, false).html).to.equal('```\ncode\n```'); + }); + + it('should fall back to automatic highlighting when highlighting the explicit language fails', () => { + const consoleError = sinon.stub(console, 'error'); + const highlight = sinon.stub(hljs, 'highlight').throws(new Error('highlighting failed')); + const highlightAuto = sinon.spy(hljs, 'highlightAuto'); + + try { + const message = code({ html: '```javascript\nvar a;\n```' }); + + expect(highlight.args).to.deep.equal([['javascript', 'var a;\n']]); + expect(highlightAuto.calledOnce).to.be.true; + expect(message.tokens?.[0].text).to.contain(`code-colors hljs ${highlightAuto.firstCall.returnValue.language}'`); + expect(consoleError.calledOnce).to.be.true; + } finally { + sinon.restore(); + } + }); +}); diff --git a/apps/meteor/tests/unit/app/mentions/client.tests.js b/apps/meteor/tests/unit/app/mentions/client.tests.js new file mode 100644 index 0000000..70ac7b9 --- /dev/null +++ b/apps/meteor/tests/unit/app/mentions/client.tests.js @@ -0,0 +1,314 @@ +import { expect } from 'chai'; + +import { MentionsParser } from '../../../../app/mentions/lib/MentionsParser'; + +let mentionsParser; +beforeEach(() => { + mentionsParser = new MentionsParser({ + pattern: () => '[0-9a-zA-Z-_.]+', + me: () => 'me', + }); +}); + +describe('Mention', () => { + describe('getUserMentions', () => { + describe('for simple text, no mentions', () => { + const result = []; + ['#rocket.cat', 'hello rocket.cat how are you?'].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); + }); + }); + }); + + describe('for one user', () => { + const result = ['@rocket.cat']; + [ + '@rocket.cat', + ' @rocket.cat ', + 'hello @rocket.cat', + // 'hello,@rocket.cat', // this test case is ignored since is not compatible with the message box behavior + '@rocket.cat, hello', + '@rocket.cat,hello', + 'hello @rocket.cat how are you?', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); + }); + }); + + it.skip('should return without the "." from "@rocket.cat."', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat.')); + }); + + it.skip('should return without the "_" from "@rocket.cat_"', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat_')); + }); + + it.skip('should return without the "-" from "@rocket.cat-"', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('@rocket.cat-')); + }); + }); + + describe('for two users', () => { + const result = ['@rocket.cat', '@all']; + [ + '@rocket.cat @all', + ' @rocket.cat @all ', + 'hello @rocket.cat and @all', + '@rocket.cat, hello @all', + 'hello @rocket.cat and @all how are you?', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions(text)); + }); + }); + }); + }); + + describe('getChannelMentions', () => { + describe('for simple text, no mentions', () => { + const result = []; + ['@rocket.cat', 'hello rocket.cat how are you?'].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for one channel', () => { + const result = ['#general']; + ['#general', ' #general ', 'hello #general', '#general, hello', 'hello #general, how are you?'].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); + }); + }); + + it.skip('should return without the "." from "#general."', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general.')); + }); + + it.skip('should return without the "_" from "#general_"', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general_')); + }); + + it.skip('should return without the "-" from "#general."', () => { + expect(result).to.be.deep.equal(mentionsParser.getUserMentions('#general-')); + }); + }); + + describe('for two channels', () => { + const result = ['#general', '#other']; + [ + '#general #other', + ' #general #other', + 'hello #general and #other', + '#general, hello #other', + 'hello #general #other, how are you?', + ].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for url with fragments', () => { + const result = []; + ['http://localhost/#general'].forEach((text) => { + it(`should return nothing from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); + }); + }); + }); + + describe('for messages with url and channels', () => { + const result = ['#general']; + ['http://localhost/#general #general'].forEach((text) => { + it(`should return "${JSON.stringify(result)}" from "${text}"`, () => { + expect(result).to.be.deep.equal(mentionsParser.getChannelMentions(text)); + }); + }); + }); + }); +}); + +const message = { + mentions: [ + { username: 'rocket.cat', name: 'Rocket.Cat' }, + { username: 'admin', name: 'Admin' }, + { username: 'me', name: 'Me' }, + { username: 'specialchars', name: '' }, + ], + channels: [ + { name: 'general', _id: '42' }, + { name: 'rocket.cat', _id: '169' }, + ], +}; + +describe('replace methods', () => { + describe('replaceUsers', () => { + it('should render for @all', () => { + const result = mentionsParser.replaceUsers('@all', message, 'me'); + expect(result).to.be.equal('all'); + }); + + const str2 = 'rocket.cat'; + + it(`should render for "@${str2}"`, () => { + const result = mentionsParser.replaceUsers(`@${str2}`, message, 'me'); + expect(result).to.be.equal(`${str2}`); + }); + + it(`should render for "hello ${str2}"`, () => { + const result = mentionsParser.replaceUsers(`hello @${str2}`, message, 'me'); + expect(result).to.be.equal(`hello ${str2}`); + }); + + it('should render for unknow/private user "hello @unknow"', () => { + const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); + expect(result).to.be.equal('hello @unknow'); + }); + + it('should render for me', () => { + const result = mentionsParser.replaceUsers('hello @me', message, 'me'); + expect(result).to.be.equal('hello me'); + }); + }); + + describe('replaceUsers (RealNames)', () => { + beforeEach(() => { + mentionsParser.useRealName = () => true; + }); + + it('should render for @all', () => { + const result = mentionsParser.replaceUsers('@all', message, 'me'); + expect(result).to.be.equal('all'); + }); + + const str2 = 'rocket.cat'; + const str2Name = 'Rocket.Cat'; + + it(`should render for "@${str2}"`, () => { + const result = mentionsParser.replaceUsers(`@${str2}`, message, 'me'); + expect(result).to.be.equal(`${str2Name}`); + }); + + it(`should render for "hello @${str2}"`, () => { + const result = mentionsParser.replaceUsers(`hello @${str2}`, message, 'me'); + expect(result).to.be.equal( + `hello ${str2Name}`, + ); + }); + + const specialchars = 'specialchars'; + const specialcharsName = '<img onerror=alert(hello)>'; + + it(`should escape special characters in "hello @${specialchars}"`, () => { + const result = mentionsParser.replaceUsers(`hello @${specialchars}`, message, 'me'); + expect(result).to.be.equal( + `hello ${specialcharsName}`, + ); + }); + + it(`should render for "hello
@${str2}
"`, () => { + const result = mentionsParser.replaceUsers(`hello
@${str2}
`, message, 'me'); + expect(result).to.be.equal( + `hello
${str2Name}
`, + ); + }); + + it('should render for unknow/private user "hello @unknow"', () => { + const result = mentionsParser.replaceUsers('hello @unknow', message, 'me'); + expect(result).to.be.equal('hello @unknow'); + }); + + it('should render for me', () => { + const result = mentionsParser.replaceUsers('hello @me', message, 'me'); + expect(result).to.be.equal('hello Me'); + }); + }); + + describe('replaceChannels', () => { + it('should render for #general', () => { + const result = mentionsParser.replaceChannels('#general', message); + expect('<).to.be.equal(class="mention-link mention-link--room" data-channel="42">#general', result); + }); + + const str2 = '#rocket.cat'; + + it(`should render for ${str2}`, () => { + const result = mentionsParser.replaceChannels(str2, message); + expect(result).to.be.equal(`${str2}`); + }); + + it(`should render for "hello ${str2}"`, () => { + const result = mentionsParser.replaceChannels(`hello ${str2}`, message); + expect(result).to.be.equal(`hello ${str2}`); + }); + + it('should render for unknow/private channel "hello #unknow"', () => { + const result = mentionsParser.replaceChannels('hello #unknow', message); + expect(result).to.be.equal('hello #unknow'); + }); + }); + + describe('parse all', () => { + it('should render for #general', () => { + message.html = '#general'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal('#general'); + }); + + it('should render for "#general and @rocket.cat', () => { + message.html = '#general and @rocket.cat'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal( + '#general and rocket.cat', + ); + }); + + it('should render for "', () => { + message.html = ''; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal(''); + }); + + it('should render for "simple text', () => { + message.html = 'simple text'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal('simple text'); + }); + }); + + describe('parse all (RealNames)', () => { + beforeEach(() => { + mentionsParser.useRealName = () => true; + }); + + it('should render for #general', () => { + message.html = '#general'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal('#general'); + }); + + it('should render for "#general and @rocket.cat', () => { + message.html = '#general and @rocket.cat'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal( + '#general and Rocket.Cat', + ); + }); + + it('should render for "', () => { + message.html = ''; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal(''); + }); + + it('should render for "simple text', () => { + message.html = 'simple text'; + const result = mentionsParser.parse(message, 'me'); + expect(result.html).to.be.equal('simple text'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/utils/lib/getURL.tests.js b/apps/meteor/tests/unit/app/utils/lib/getURL.tests.js new file mode 100644 index 0000000..8a714a1 --- /dev/null +++ b/apps/meteor/tests/unit/app/utils/lib/getURL.tests.js @@ -0,0 +1,162 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +import { ltrim, rtrim } from '../../../../../lib/utils/stringUtils'; + +const { _getURL } = proxyquire.noCallThru().load('../../../../../app/utils/lib/getURL', { + 'meteor/meteor': { + 'Meteor': { + absoluteUrl() { + return 'http://localhost:3000/'; + }, + }, + '@global': true, + }, +}); + +const testPaths = (o, _processPath) => { + let processPath = _processPath; + if (o._root_url_path_prefix !== '') { + processPath = (path) => _processPath(o._root_url_path_prefix + path); + } + + const cloudDeepLinkUrl = 'https://go.rocket.chat'; + + expect(_getURL('', o, cloudDeepLinkUrl)).to.be.equal(processPath('')); + expect(_getURL('/', o, cloudDeepLinkUrl)).to.be.equal(processPath('')); + expect(_getURL('//', o, cloudDeepLinkUrl)).to.be.equal(processPath('')); + expect(_getURL('///', o, cloudDeepLinkUrl)).to.be.equal(processPath('')); + expect(_getURL('/channel', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel/', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel//', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel')); + expect(_getURL('/channel/123', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123')); + expect(_getURL('/channel/123/', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123')); + expect(_getURL('/channel/123?id=456&name=test', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123?id=456&name=test')); + expect(_getURL('/channel/123/?id=456&name=test', o, cloudDeepLinkUrl)).to.be.equal(processPath('/channel/123?id=456&name=test')); +}; + +const getCloudUrl = (_site_url, path) => { + path = ltrim(path, '/'); + const url = `https://go.rocket.chat/?host=${encodeURIComponent(_site_url.replace(/https?:\/\//, ''))}&path=${encodeURIComponent(path)}`; + if (_site_url.includes('http://')) { + return `${url}&secure=no`; + } + return url; +}; + +const testCases = (options) => { + const _site_url = rtrim(options._site_url, '/'); + + if (!options.cloud) { + if (options._cdn_prefix === '') { + if (options.full && !options.cdn) { + it('should return with host if full: true', () => { + testPaths(options, (path) => _site_url + path); + }); + } + + if (!options.full && options.cdn) { + it('should return without cdn prefix if cdn: true', () => { + testPaths(options, (path) => path); + }); + } + + if (!options.full && !options.cdn) { + it('should return without host by default', () => { + testPaths(options, (path) => path); + }); + } + + if (options.full && options.cdn) { + it('should return with host if full: true and cdn: true', () => { + testPaths(options, (path) => _site_url + path); + }); + } + } else { + if (options.full && !options.cdn) { + it('should return with host if full: true', () => { + testPaths(options, (path) => _site_url + path); + }); + } + + if (!options.full && options.cdn) { + it('should return with cdn prefix if cdn: true', () => { + testPaths(options, (path) => options._cdn_prefix + path); + }); + } + + if (!options.full && !options.cdn) { + it('should return without host by default', () => { + testPaths(options, (path) => path); + }); + } + + if (options.full && options.cdn) { + it('should return with cdn prefix if full: true and cdn: true', () => { + testPaths(options, (path) => options._cdn_prefix + path); + }); + } + } + } else if (options._cdn_prefix === '') { + if (!options.full && options.cdn) { + it('should return with cloud host if cdn: true', () => { + testPaths(options, (path) => getCloudUrl(_site_url, path)); + }); + } + + if (!options.full && !options.cdn) { + it('should return with cloud host if full: fase and cdn: false', () => { + testPaths(options, (path) => getCloudUrl(_site_url, path)); + }); + } + } else if (!options.full && !options.cdn) { + it('should return with cloud host if full: fase and cdn: false', () => { + testPaths(options, (path) => getCloudUrl(_site_url, path)); + }); + } +}; + +const testCasesForOptions = (description, options) => { + describe(description, () => { + testCases({ ...options, cdn: false, full: false, cloud: false }); + testCases({ ...options, cdn: true, full: false, cloud: false }); + testCases({ ...options, cdn: false, full: true, cloud: false }); + testCases({ ...options, cdn: false, full: false, cloud: true }); + testCases({ ...options, cdn: true, full: true, cloud: false }); + testCases({ ...options, cdn: false, full: true, cloud: true }); + testCases({ ...options, cdn: true, full: false, cloud: true }); + testCases({ ...options, cdn: true, full: true, cloud: true }); + }); +}; + +describe('getURL', () => { + testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000/', { + _cdn_prefix: '', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000/', + }); + + testCasesForOptions('getURL with no CDN, no PREFIX for http://localhost:3000', { + _cdn_prefix: '', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000', + }); + + testCasesForOptions('getURL with CDN, no PREFIX for http://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: '', + _site_url: 'http://localhost:3000/', + }); + + testCasesForOptions('getURL with CDN, PREFIX for http://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: 'sub', + _site_url: 'http://localhost:3000/', + }); + + testCasesForOptions('getURL with CDN, PREFIX for https://localhost:3000/', { + _cdn_prefix: 'https://cdn.com', + _root_url_path_prefix: 'sub', + _site_url: 'https://localhost:3000/', + }); +}); diff --git a/apps/meteor/tests/unit/app/utils/lib/restrictions.spec.ts b/apps/meteor/tests/unit/app/utils/lib/restrictions.spec.ts new file mode 100644 index 0000000..f7a6b44 --- /dev/null +++ b/apps/meteor/tests/unit/app/utils/lib/restrictions.spec.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; + +import { fileUploadIsValidContentTypeFromSettings } from '../../../../../app/utils/lib/restrictions'; + +describe('fileUploadIsValidContentTypeFromSettings', () => { + it('should return true if type is not defined and whiteList is not defined', () => { + expect(fileUploadIsValidContentTypeFromSettings(undefined, '', '')).to.be.true; + }); + + it('should return false if type is not defined and whiteList is defined', () => { + expect(fileUploadIsValidContentTypeFromSettings(undefined, 'image/jpeg', '')).to.be.false; + }); + + it('should return true if type is defined and whiteList is not defined', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', '')).to.be.true; + }); + + it('should return true if type is defined and whiteList is defined and type is in whiteList', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', 'image/jpeg', '')).to.be.true; + }); + + it('should return false if type is defined and whiteList is defined and type is not in whiteList', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/png', 'image/jpeg', '')).to.be.false; + }); + + it('should return false if type is defined and whiteList is not defined and type is in blackList', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', 'image/jpeg')).to.be.false; + }); + + it('should return true if type is defined and whiteList is not defined and type is not in blackList', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/png', '', 'image/jpeg')).to.be.true; + }); + + it('should return true if type is defined and whiteList is defined and type is in whiteList with wildcard', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', 'image/*', '')).to.be.true; + }); + + it('should return false if type is defined and whiteList is defined and type is not in whiteList with wildcard', () => { + expect(fileUploadIsValidContentTypeFromSettings('text/plain', 'image/*', '')).to.be.false; + }); + + it('should return false if type is defined and whiteList is not defined and type is in blackList with wildcard', () => { + expect(fileUploadIsValidContentTypeFromSettings('image/jpeg', '', 'image/*')).to.be.false; + }); + + it('should return true if type is defined and whiteList is defined and type is not in blackList with wildcard', () => { + expect(fileUploadIsValidContentTypeFromSettings('text/plain', '', 'image/*')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/federation/FederationAddServerProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationAddServerProps.spec.ts new file mode 100644 index 0000000..f249dda --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationAddServerProps.spec.ts @@ -0,0 +1,41 @@ +import { isFederationAddServerProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('FederationAddServerProps (definition/rest/v1)', () => { + describe('isFederationAddServerProps', () => { + it('should be a function', () => { + assert.isFunction(isFederationAddServerProps); + }); + it('should return false when provided anything that is not an FederationAddServerProps', () => { + assert.isFalse(isFederationAddServerProps(undefined)); + assert.isFalse(isFederationAddServerProps(null)); + assert.isFalse(isFederationAddServerProps('')); + assert.isFalse(isFederationAddServerProps(123)); + assert.isFalse(isFederationAddServerProps({})); + assert.isFalse(isFederationAddServerProps([])); + assert.isFalse(isFederationAddServerProps(new Date())); + assert.isFalse(isFederationAddServerProps(new Error())); + }); + + it('should return false if serverName is not provided to FederationAddServerProps', () => { + assert.isFalse(isFederationAddServerProps({})); + }); + + it('should accept a serverName with nothing else', () => { + assert.isTrue( + isFederationAddServerProps({ + serverName: 'serverName', + }), + ); + }); + + it('should return false when extra parameters are provided to FederationAddServerProps', () => { + assert.isFalse( + isFederationAddServerProps({ + serverName: 'serverName', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/federation/FederationJoinExternalPublicRoomProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationJoinExternalPublicRoomProps.spec.ts new file mode 100644 index 0000000..40c399a --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationJoinExternalPublicRoomProps.spec.ts @@ -0,0 +1,49 @@ +import { isFederationJoinExternalPublicRoomProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('FederationJoinExternalPublicRoomProps (definition/rest/v1)', () => { + describe('isFederationJoinExternalPublicRoomProps', () => { + it('should be a function', () => { + assert.isFunction(isFederationJoinExternalPublicRoomProps); + }); + it('should return false when provided anything that is not an FederationJoinExternalPublicRoomProps', () => { + assert.isFalse(isFederationJoinExternalPublicRoomProps(undefined)); + assert.isFalse(isFederationJoinExternalPublicRoomProps(null)); + assert.isFalse(isFederationJoinExternalPublicRoomProps('')); + assert.isFalse(isFederationJoinExternalPublicRoomProps(123)); + assert.isFalse(isFederationJoinExternalPublicRoomProps({})); + assert.isFalse(isFederationJoinExternalPublicRoomProps([])); + assert.isFalse(isFederationJoinExternalPublicRoomProps(new Date())); + assert.isFalse(isFederationJoinExternalPublicRoomProps(new Error())); + }); + + it('should return false if externalRoomId is not provided to FederationJoinExternalPublicRoomProps', () => { + assert.isFalse(isFederationJoinExternalPublicRoomProps({})); + }); + + it('should return false if externalRoomId was provided in the wrong format FederationJoinExternalPublicRoomProps', () => { + assert.isFalse( + isFederationJoinExternalPublicRoomProps({ + externalRoomId: 'wrongFormatId', + }), + ); + }); + + it('should accept a externalRoomId with nothing else', () => { + assert.isTrue( + isFederationJoinExternalPublicRoomProps({ + externalRoomId: '!externalRoomId:server.com', + }), + ); + }); + + it('should return false when extra parameters are provided to FederationJoinExternalPublicRoomProps', () => { + assert.isFalse( + isFederationJoinExternalPublicRoomProps({ + externalRoomId: '!externalRoomId:server.com', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/federation/FederationPublicRoomsProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationPublicRoomsProps.spec.ts new file mode 100644 index 0000000..557cee8 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationPublicRoomsProps.spec.ts @@ -0,0 +1,37 @@ +import { isFederationSearchPublicRoomsProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('FederationPublicRoomProps (definition/rest/v1)', () => { + describe('isFederationSearchPublicRoomsProps', () => { + it('should be a function', () => { + assert.isFunction(isFederationSearchPublicRoomsProps); + }); + it('should return false when provided anything that is not an FederationPublicRoomProps', () => { + assert.isFalse(isFederationSearchPublicRoomsProps('')); + assert.isFalse(isFederationSearchPublicRoomsProps(123)); + }); + + it('should accept a externalRoomId with nothing else', () => { + assert.isTrue( + isFederationSearchPublicRoomsProps({ + serverName: 'server', + roomName: 'roomName', + count: '1', + pageToken: 'token', + }), + ); + }); + + it('should return false when extra parameters are provided to FederationPublicRoomProps', () => { + assert.isFalse( + isFederationSearchPublicRoomsProps({ + serverName: 'server', + roomName: 'roomName', + count: '1', + pageToken: 'token', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/federation/FederationRemoveServerProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationRemoveServerProps.spec.ts new file mode 100644 index 0000000..3538c34 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/federation/FederationRemoveServerProps.spec.ts @@ -0,0 +1,41 @@ +import { isFederationRemoveServerProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('FederationRemoveServerProps (definition/rest/v1)', () => { + describe('isFederationRemoveServerProps', () => { + it('should be a function', () => { + assert.isFunction(isFederationRemoveServerProps); + }); + it('should return false when provided anything that is not an FederationRemoveServerProps', () => { + assert.isFalse(isFederationRemoveServerProps(undefined)); + assert.isFalse(isFederationRemoveServerProps(null)); + assert.isFalse(isFederationRemoveServerProps('')); + assert.isFalse(isFederationRemoveServerProps(123)); + assert.isFalse(isFederationRemoveServerProps({})); + assert.isFalse(isFederationRemoveServerProps([])); + assert.isFalse(isFederationRemoveServerProps(new Date())); + assert.isFalse(isFederationRemoveServerProps(new Error())); + }); + + it('should return false if serverName is not provided to FederationRemoveServerProps', () => { + assert.isFalse(isFederationRemoveServerProps({})); + }); + + it('should accept a serverName with nothing else', () => { + assert.isTrue( + isFederationRemoveServerProps({ + serverName: 'serverName', + }), + ); + }); + + it('should return false when extra parameters are provided to FederationRemoveServerProps', () => { + assert.isFalse( + isFederationRemoveServerProps({ + serverName: 'serverName', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsAddMembersProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsAddMembersProps.spec.ts new file mode 100644 index 0000000..747e655 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsAddMembersProps.spec.ts @@ -0,0 +1,91 @@ +import { isTeamsAddMembersProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsAddMemberProps (definition/rest/v1)', () => { + describe('isTeamsAddMembersProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsAddMembersProps); + }); + it('should return false if the parameter is empty', () => { + assert.isFalse(isTeamsAddMembersProps({})); + }); + + it('should return false if teamId is provided but no member was provided', () => { + assert.isFalse(isTeamsAddMembersProps({ teamId: '123' })); + }); + + it('should return false if teamName is provided but no member was provided', () => { + assert.isFalse(isTeamsAddMembersProps({ teamName: '123' })); + }); + + it('should return false if members is provided but no teamId or teamName were provided', () => { + assert.isFalse(isTeamsAddMembersProps({ members: [{ userId: '123' }] })); + }); + + it('should return false if teamName was provided but members are empty', () => { + assert.isFalse(isTeamsAddMembersProps({ teamName: '123', members: [] })); + }); + + it('should return false if teamId was provided but members are empty', () => { + assert.isFalse(isTeamsAddMembersProps({ teamId: '123', members: [] })); + }); + + it('should return false if members with role is provided but no teamId or teamName were provided', () => { + assert.isFalse(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }] })); + }); + + it('should return true if members is provided and teamId is provided', () => { + assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123' }], teamId: '123' })); + }); + + it('should return true if members is provided and teamName is provided', () => { + assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123' }], teamName: '123' })); + }); + + it('should return true if members with role is provided and teamId is provided', () => { + assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }], teamId: '123' })); + }); + + it('should return true if members with role is provided and teamName is provided', () => { + assert.isTrue(isTeamsAddMembersProps({ members: [{ userId: '123', roles: ['123'] }], teamName: '123' })); + }); + + it('should return false if teamName was provided and members contains an invalid property', () => { + assert.isFalse( + isTeamsAddMembersProps({ + teamName: '123', + members: [{ userId: '123', roles: ['123'], invalid: true }], + }), + ); + }); + + it('should return false if teamId was provided and members contains an invalid property', () => { + assert.isFalse( + isTeamsAddMembersProps({ + teamId: '123', + members: [{ userId: '123', roles: ['123'], invalid: true }], + }), + ); + }); + + it('should return false if teamName informed but contains an invalid property', () => { + assert.isFalse( + isTeamsAddMembersProps({ + member: [{ userId: '123', roles: ['123'] }], + teamName: '123', + invalid: true, + }), + ); + }); + + it('should return false if teamId informed but contains an invalid property', () => { + assert.isFalse( + isTeamsAddMembersProps({ + member: [{ userId: '123', roles: ['123'] }], + teamId: '123', + invalid: true, + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsConvertToChannelProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsConvertToChannelProps.spec.ts new file mode 100644 index 0000000..4e7df34 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsConvertToChannelProps.spec.ts @@ -0,0 +1,34 @@ +import { isTeamsConvertToChannelProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsConvertToChannelProps (definition/rest/v1)', () => { + describe('isTeamsConvertToChannelProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsConvertToChannelProps); + }); + it('should return false if neither teamName or teamId is provided', () => { + assert.isFalse(isTeamsConvertToChannelProps({})); + }); + + it('should return true if teamName is provided', () => { + assert.isTrue(isTeamsConvertToChannelProps({ teamName: 'teamName' })); + }); + + it('should return true if teamId is provided', () => { + assert.isTrue(isTeamsConvertToChannelProps({ teamId: 'teamId' })); + }); + + it('should return false if both teamName and teamId are provided', () => { + assert.isFalse(isTeamsConvertToChannelProps({ teamName: 'teamName', teamId: 'teamId' })); + }); + + it('should return false if an additionalProperties is provided', () => { + assert.isFalse( + isTeamsConvertToChannelProps({ + teamName: 'teamName', + additionalProperties: 'additionalProperties', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsDeleteProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsDeleteProps.spec.ts new file mode 100644 index 0000000..076901b --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsDeleteProps.spec.ts @@ -0,0 +1,60 @@ +import { isTeamsDeleteProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsDeleteProps (definition/rest/v1)', () => { + describe('isTeamsDeleteProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsDeleteProps); + }); + + it('should return false if neither teamName or teamId is provided', () => { + assert.isFalse(isTeamsDeleteProps({})); + }); + + it('should return true if teamId is provided', () => { + assert.isTrue(isTeamsDeleteProps({ teamId: 'teamId' })); + }); + + it('should return true if teamName is provided', () => { + assert.isTrue(isTeamsDeleteProps({ teamName: 'teamName' })); + }); + + it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is empty', () => { + assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: [] })); + }); + + it('should return false if teamName and roomsToRemove are provided, but roomsToRemove is empty', () => { + assert.isFalse(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: [] })); + }); + + it('should return true if teamId and roomsToRemove are provided', () => { + assert.isTrue(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: ['roomId'] })); + }); + + it('should return true if teamName and roomsToRemove are provided', () => { + assert.isTrue(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: ['roomId'] })); + }); + + it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is not an array', () => { + assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: {} })); + }); + + it('should return false if teamName and roomsToRemove are provided, but roomsToRemove is not an array', () => { + assert.isFalse(isTeamsDeleteProps({ teamName: 'teamName', roomsToRemove: {} })); + }); + + it('should return false if teamName and rooms are provided but an extra property is provided', () => { + assert.isFalse( + isTeamsDeleteProps({ + teamName: 'teamName', + roomsToRemove: ['roomsToRemove'], + extra: 'extra', + }), + ); + }); + + it('should return false if teamId and rooms are provided but an extra property is provided', () => { + assert.isFalse(isTeamsDeleteProps({ teamId: 'teamId', roomsToRemove: ['roomsToRemove'], extra: 'extra' })); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsLeaveProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsLeaveProps.spec.ts new file mode 100644 index 0000000..ee5e089 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsLeaveProps.spec.ts @@ -0,0 +1,62 @@ +import { isTeamsLeaveProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsLeaveProps (definition/rest/v1)', () => { + describe('isTeamsLeaveProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsLeaveProps); + }); + + it('should return false if neither teamName or teamId is provided', () => { + assert.isFalse(isTeamsLeaveProps({})); + }); + + it('should return true if teamId is provided', () => { + assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId' })); + }); + + it('should return true if teamName is provided', () => { + assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName' })); + }); + + it('should return false if teamId and roomsToRemove are provided, but roomsToRemove is empty', () => { + assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: [] })); + }); + + it('should return true if teamId are provided, but rooms are undefined', () => { + assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId', rooms: undefined })); + }); + + it('should return false if teamName and rooms are provided, but rooms is empty', () => { + assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: [] })); + }); + + it('should return true if teamName are provided, but rooms are undefined', () => { + assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName', rooms: undefined })); + }); + + it('should return true if teamId and rooms are provided', () => { + assert.isTrue(isTeamsLeaveProps({ teamId: 'teamId', rooms: ['roomId'] })); + }); + + it('should return true if teamName and rooms are provided', () => { + assert.isTrue(isTeamsLeaveProps({ teamName: 'teamName', rooms: ['roomId'] })); + }); + + it('should return false if teamId and rooms are provided, but rooms is not an array', () => { + assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: {} })); + }); + + it('should return false if teamName and rooms are provided, but rooms is not an array', () => { + assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: {} })); + }); + + it('should return false if teamName and rooms are provided but an extra property is provided', () => { + assert.isFalse(isTeamsLeaveProps({ teamName: 'teamName', rooms: ['rooms'], extra: 'extra' })); + }); + + it('should return false if teamId and rooms are provided but an extra property is provided', () => { + assert.isFalse(isTeamsLeaveProps({ teamId: 'teamId', rooms: ['rooms'], extra: 'extra' })); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveMemberProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveMemberProps.spec.ts new file mode 100644 index 0000000..445363c --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveMemberProps.spec.ts @@ -0,0 +1,83 @@ +import { isTeamsRemoveMemberProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('Teams (definition/rest/v1)', () => { + describe('isTeamsRemoveMemberProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsRemoveMemberProps); + }); + it('should return false if parameter is empty', () => { + assert.isFalse(isTeamsRemoveMemberProps({})); + }); + it('should return false if teamId is is informed but missing userId', () => { + assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId' })); + }); + it('should return false if teamName is is informed but missing userId', () => { + assert.isFalse(isTeamsRemoveMemberProps({ teamName: 'teamName' })); + }); + + it('should return true if teamId and userId are informed', () => { + assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId' })); + }); + it('should return true if teamName and userId are informed', () => { + assert.isTrue(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId' })); + }); + + it('should return false if teamName and userId are informed but rooms are empty', () => { + assert.isFalse(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId', rooms: [] })); + }); + + it('should return true if teamName and userId are informed but rooms are undefined', () => { + assert.isTrue(isTeamsRemoveMemberProps({ teamName: 'teamName', userId: 'userId', rooms: undefined })); + }); + + it('should return false if teamId and userId are informed and rooms are empty', () => { + assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: [] })); + }); + + it('should return true if teamId and userId are informed but rooms are undefined', () => { + assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: undefined })); + }); + + it('should return false if teamId and userId are informed but rooms are empty', () => { + assert.isFalse(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: [] })); + }); + + it('should return true if teamId and userId are informed and rooms are informed', () => { + assert.isTrue(isTeamsRemoveMemberProps({ teamId: 'teamId', userId: 'userId', rooms: ['room'] })); + }); + + it('should return false if teamName and userId are informed and rooms are informed but there is an extra property', () => { + assert.isFalse( + isTeamsRemoveMemberProps({ + teamName: 'teamName', + userId: 'userId', + rooms: ['room'], + extra: 'extra', + }), + ); + }); + + it('should return false if teamId and userId are informed and rooms are informed but there is an extra property', () => { + assert.isFalse( + isTeamsRemoveMemberProps({ + teamId: 'teamId', + userId: 'userId', + rooms: ['room'], + extra: 'extra', + }), + ); + }); + + it('should return false if teamName and userId are informed and rooms are informed but there is an extra property', () => { + assert.isFalse( + isTeamsRemoveMemberProps({ + teamName: 'teamName', + userId: 'userId', + rooms: ['room'], + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveRoomProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveRoomProps.spec.ts new file mode 100644 index 0000000..106f686 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsRemoveRoomProps.spec.ts @@ -0,0 +1,25 @@ +import { isTeamsRemoveRoomProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsRemoveRoomProps (definition/rest/v1)', () => { + describe('isTeamsRemoveRoomProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsRemoveRoomProps); + }); + it('should return false if roomId is not provided', () => { + assert.isFalse(isTeamsRemoveRoomProps({})); + }); + it('should return false if roomId is provided but no teamId or teamName were provided', () => { + assert.isFalse(isTeamsRemoveRoomProps({ roomId: 'roomId' })); + }); + it('should return false if roomId is provided and teamId is provided', () => { + assert.isTrue(isTeamsRemoveRoomProps({ roomId: 'roomId', teamId: 'teamId' })); + }); + it('should return true if roomId is provided and teamName is provided', () => { + assert.isTrue(isTeamsRemoveRoomProps({ roomId: 'roomId', teamName: 'teamName' })); + }); + it('should return false if roomId and teamName are provided but an additional property is provided', () => { + assert.isFalse(isTeamsRemoveRoomProps({ roomId: 'roomId', teamName: 'teamName', foo: 'bar' })); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateMemberProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateMemberProps.spec.ts new file mode 100644 index 0000000..75b5e27 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateMemberProps.spec.ts @@ -0,0 +1,63 @@ +import { isTeamsUpdateMemberProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsUpdateMemberProps (definition/rest/v1)', () => { + describe('isTeamsUpdateMemberProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsUpdateMemberProps); + }); + it('should return false if the parameter is empty', () => { + assert.isFalse(isTeamsUpdateMemberProps({})); + }); + + it('should return false if teamId is provided but no member was provided', () => { + assert.isFalse(isTeamsUpdateMemberProps({ teamId: '123' })); + }); + + it('should return false if teamName is provided but no member was provided', () => { + assert.isFalse(isTeamsUpdateMemberProps({ teamName: '123' })); + }); + + it('should return false if member is provided but no teamId or teamName were provided', () => { + assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123' } })); + }); + + it('should return false if member with role is provided but no teamId or teamName were provided', () => { + assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] } })); + }); + + it('should return true if member is provided and teamId is provided', () => { + assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123' }, teamId: '123' })); + }); + + it('should return true if member is provided and teamName is provided', () => { + assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123' }, teamName: '123' })); + }); + + it('should return true if member with role is provided and teamId is provided', () => { + assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] }, teamId: '123' })); + }); + + it('should return true if member with role is provided and teamName is provided', () => { + assert.isTrue(isTeamsUpdateMemberProps({ member: { userId: '123', roles: ['123'] }, teamName: '123' })); + }); + + it('should return false if teamName was provided and member contains an invalid property', () => { + assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', invalid: '123' }, teamName: '123' })); + }); + + it('should return false if teamId was provided and member contains an invalid property', () => { + assert.isFalse(isTeamsUpdateMemberProps({ member: { userId: '123', invalid: '123' }, teamId: '123' })); + }); + + it('should return false if contains an invalid property', () => { + assert.isFalse( + isTeamsUpdateMemberProps({ + member: { userId: '123', roles: ['123'] }, + teamName: '123', + invalid: true, + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateProps.spec.ts new file mode 100644 index 0000000..b363754 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/teams/TeamsUpdateProps.spec.ts @@ -0,0 +1,191 @@ +import { isTeamsUpdateProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('TeamsUpdateMemberProps (definition/rest/v1)', () => { + describe('isTeamsUpdateProps', () => { + it('should be a function', () => { + assert.isFunction(isTeamsUpdateProps); + }); + it('should return false when provided anything that is not an TeamsUpdateProps', () => { + assert.isFalse(isTeamsUpdateProps(undefined)); + assert.isFalse(isTeamsUpdateProps(null)); + assert.isFalse(isTeamsUpdateProps('')); + assert.isFalse(isTeamsUpdateProps(123)); + assert.isFalse(isTeamsUpdateProps({})); + assert.isFalse(isTeamsUpdateProps([])); + assert.isFalse(isTeamsUpdateProps(new Date())); + assert.isFalse(isTeamsUpdateProps(new Error())); + }); + it('should return false when only teamName is provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamName: 'teamName', + }), + ); + }); + + it('should return false when only teamId is provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamId: 'teamId', + }), + ); + }); + + it('should return false when teamName and data are provided to TeamsUpdateProps but data is an empty object', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamName: 'teamName', + data: {}, + }), + ); + }); + + it('should return false when teamId and data are provided to TeamsUpdateProps but data is an empty object', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamId: 'teamId', + data: {}, + }), + ); + }); + + it('should return false when teamName and data are provided to TeamsUpdateProps but data is not an object', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamName: 'teamName', + data: 'data', + }), + ); + }); + + it('should return false when teamId and data are provided to TeamsUpdateProps but data is not an object', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamId: 'teamId', + data: 'data', + }), + ); + }); + + it('should return true when teamName and data.name are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamName: 'teamName', + data: { + name: 'name', + }, + }), + ); + }); + + it('should return true when teamId and data.name are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamId: 'teamId', + data: { + name: 'name', + }, + }), + ); + }); + + it('should return true when teamName and data.type are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamName: 'teamName', + data: { + type: 0, + }, + }), + ); + }); + + it('should return true when teamId and data.type are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamId: 'teamId', + data: { + type: 0, + }, + }), + ); + }); + + it('should return true when teamName and data.name and data.type are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamName: 'teamName', + data: { + name: 'name', + type: 0, + }, + }), + ); + }); + + it('should return true when teamId and data.name and data.type are provided to TeamsUpdateProps', () => { + assert.isTrue( + isTeamsUpdateProps({ + teamId: 'teamId', + data: { + name: 'name', + type: 0, + }, + }), + ); + }); + + it('should return false when teamName, data.name, data.type are some more extra data are provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamName: 'teamName', + data: { + name: 'name', + type: 0, + extra: 'extra', + }, + }), + ); + }); + + it('should return false when teamId, data.name, data.type are some more extra data are provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamId: 'teamId', + data: { + name: 'name', + type: 0, + extra: 'extra', + }, + }), + ); + }); + + it('should return false when teamName, data.name, data.type are some more extra parameter are provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamName: 'teamName', + extra: 'extra', + data: { + name: 'name', + type: 0, + }, + }), + ); + }); + + it('should return false when teamId, data.name, data.type are some more extra parameter are provided to TeamsUpdateProps', () => { + assert.isFalse( + isTeamsUpdateProps({ + teamId: 'teamId', + extra: 'extra', + data: { + name: 'name', + type: 0, + }, + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts new file mode 100644 index 0000000..d387492 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts @@ -0,0 +1,40 @@ +import { isVideoConfCancelProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('VideoConfCancelProps (definition/rest/v1)', () => { + describe('isVideoConfCancelProps', () => { + it('should be a function', () => { + assert.isFunction(isVideoConfCancelProps); + }); + it('should return false when provided anything that is not an VideoConfCancelProps', () => { + assert.isFalse(isVideoConfCancelProps(undefined)); + assert.isFalse(isVideoConfCancelProps(null)); + assert.isFalse(isVideoConfCancelProps('')); + assert.isFalse(isVideoConfCancelProps(123)); + assert.isFalse(isVideoConfCancelProps({})); + assert.isFalse(isVideoConfCancelProps([])); + assert.isFalse(isVideoConfCancelProps(new Date())); + assert.isFalse(isVideoConfCancelProps(new Error())); + }); + it('should return false if callId is not provided to VideoConfCancelProps', () => { + assert.isFalse(isVideoConfCancelProps({})); + }); + + it('should accept a callId with nothing else', () => { + assert.isTrue( + isVideoConfCancelProps({ + callId: 'callId', + }), + ); + }); + + it('should return false when extra parameters are provided to VideoConfCancelProps', () => { + assert.isFalse( + isVideoConfCancelProps({ + callId: 'callId', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfInfoProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfInfoProps.spec.ts new file mode 100644 index 0000000..3080938 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfInfoProps.spec.ts @@ -0,0 +1,40 @@ +import { isVideoConfInfoProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('VideoConfInfoProps (definition/rest/v1)', () => { + describe('isVideoConfInfoProps', () => { + it('should be a function', () => { + assert.isFunction(isVideoConfInfoProps); + }); + it('should return false when provided anything that is not an VideoConfInfoProps', () => { + assert.isFalse(isVideoConfInfoProps(undefined)); + assert.isFalse(isVideoConfInfoProps(null)); + assert.isFalse(isVideoConfInfoProps('')); + assert.isFalse(isVideoConfInfoProps(123)); + assert.isFalse(isVideoConfInfoProps({})); + assert.isFalse(isVideoConfInfoProps([])); + assert.isFalse(isVideoConfInfoProps(new Date())); + assert.isFalse(isVideoConfInfoProps(new Error())); + }); + it('should return false if callId is not provided to VideoConfInfoProps', () => { + assert.isFalse(isVideoConfInfoProps({})); + }); + + it('should accept a callId with nothing else', () => { + assert.isTrue( + isVideoConfInfoProps({ + callId: 'callId', + }), + ); + }); + + it('should return false when extra parameters are provided to VideoConfInfoProps', () => { + assert.isFalse( + isVideoConfInfoProps({ + callId: 'callId', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfJoinProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfJoinProps.spec.ts new file mode 100644 index 0000000..5b9c8fd --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfJoinProps.spec.ts @@ -0,0 +1,45 @@ +import { isVideoConfJoinProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('VideoConfJoinProps (definition/rest/v1)', () => { + describe('isVideoConfJoinProps', () => { + it('should be a function', () => { + assert.isFunction(isVideoConfJoinProps); + }); + it('should return false when provided anything that is not an VideoConfJoinProps', () => { + assert.isFalse(isVideoConfJoinProps(undefined)); + assert.isFalse(isVideoConfJoinProps(null)); + assert.isFalse(isVideoConfJoinProps('')); + assert.isFalse(isVideoConfJoinProps(123)); + assert.isFalse(isVideoConfJoinProps({})); + assert.isFalse(isVideoConfJoinProps([])); + assert.isFalse(isVideoConfJoinProps(new Date())); + assert.isFalse(isVideoConfJoinProps(new Error())); + }); + it('should return false if callId is not provided to VideoConfJoinProps', () => { + assert.isFalse(isVideoConfJoinProps({})); + }); + + it('should accept a callId with nothing else', () => { + assert.isTrue( + isVideoConfJoinProps({ + callId: 'callId', + }), + ); + }); + + it('should return false when provided with an invalid state', () => { + assert.isFalse(isVideoConfJoinProps({ callId: 'callId', state: 123 })); + assert.isFalse(isVideoConfJoinProps({ callId: 'callId', state: [] })); + }); + + it('should return false when extra parameters are provided to VideoConfJoinProps', () => { + assert.isFalse( + isVideoConfJoinProps({ + callId: 'callId', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfListProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfListProps.spec.ts new file mode 100644 index 0000000..586cc21 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfListProps.spec.ts @@ -0,0 +1,50 @@ +import { isVideoConfListProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('VideoConfListProps (definition/rest/v1)', () => { + describe('isVideoConfListProps', () => { + it('should be a function', () => { + assert.isFunction(isVideoConfListProps); + }); + it('should return false when provided anything that is not an VideoConfListProps', () => { + assert.isFalse(isVideoConfListProps(undefined)); + assert.isFalse(isVideoConfListProps(null)); + assert.isFalse(isVideoConfListProps('')); + assert.isFalse(isVideoConfListProps(123)); + assert.isFalse(isVideoConfListProps({})); + assert.isFalse(isVideoConfListProps([])); + assert.isFalse(isVideoConfListProps(new Date())); + assert.isFalse(isVideoConfListProps(new Error())); + }); + it('should return false if roomId is not provided to VideoConfListProps', () => { + assert.isFalse(isVideoConfListProps({})); + }); + + it('should accept a roomId with nothing else', () => { + assert.isTrue( + isVideoConfListProps({ + roomId: 'roomId', + }), + ); + }); + + it('should accept the count and offset parameters', () => { + assert.isTrue( + isVideoConfListProps({ + roomId: 'roomId', + offset: 50, + count: 25, + }), + ); + }); + + it('should return false when extra parameters are provided to VideoConfListProps', () => { + assert.isFalse( + isVideoConfListProps({ + roomId: 'roomId', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfStartProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfStartProps.spec.ts new file mode 100644 index 0000000..0f45e21 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfStartProps.spec.ts @@ -0,0 +1,58 @@ +import { isVideoConfStartProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +describe('VideoConfStartProps (definition/rest/v1)', () => { + describe('isVideoConfStartProps', () => { + it('should be a function', () => { + assert.isFunction(isVideoConfStartProps); + }); + it('should return false when provided anything that is not an VideoConfStartProps', () => { + assert.isFalse(isVideoConfStartProps(undefined)); + assert.isFalse(isVideoConfStartProps(null)); + assert.isFalse(isVideoConfStartProps('')); + assert.isFalse(isVideoConfStartProps(123)); + assert.isFalse(isVideoConfStartProps({})); + assert.isFalse(isVideoConfStartProps([])); + assert.isFalse(isVideoConfStartProps(new Date())); + assert.isFalse(isVideoConfStartProps(new Error())); + }); + it('should return false if roomId is not provided to VideoConfStartProps', () => { + assert.isFalse(isVideoConfStartProps({})); + }); + + it('should accept a roomId with nothing else', () => { + assert.isTrue( + isVideoConfStartProps({ + roomId: 'roomId', + }), + ); + }); + + it('should accept the allowRinging parameter', () => { + assert.isTrue( + isVideoConfStartProps({ + roomId: 'roomId', + allowRinging: true, + }), + ); + }); + + it('should accept a roomId with a title', () => { + assert.isTrue( + isVideoConfStartProps({ + roomId: 'roomId', + title: 'extra', + }), + ); + }); + + it('should return false when extra parameters are provided to VideoConfStartProps', () => { + assert.isFalse( + isVideoConfStartProps({ + roomId: 'roomId', + extra: 'extra', + }), + ); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/getMessageMaxParseLength.spec.ts b/apps/meteor/tests/unit/lib/getMessageMaxParseLength.spec.ts new file mode 100644 index 0000000..b9173cc --- /dev/null +++ b/apps/meteor/tests/unit/lib/getMessageMaxParseLength.spec.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; + +import { getMessageMaxParseLength } from '../../../lib/getMessageMaxParseLength'; + +describe('getMessageMaxParseLength', () => { + afterEach(() => { + delete process.env.MESSAGE_MAX_PARSE_LENGTH; + }); + + it('should return 0 (default) when env var is not set', () => { + delete process.env.MESSAGE_MAX_PARSE_LENGTH; + expect(getMessageMaxParseLength()).to.equal(0); + }); + + it('should return 0 (default) when env var is empty string', () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = ''; + expect(getMessageMaxParseLength()).to.equal(0); + }); + + it('should return 0 (default) when env var is not a number', () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = 'abc'; + expect(getMessageMaxParseLength()).to.equal(0); + }); + + it('should return the parsed number when env var is a valid integer', () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = '5000'; + expect(getMessageMaxParseLength()).to.equal(5000); + }); + + it('should return 0 when env var is "0"', () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = '0'; + expect(getMessageMaxParseLength()).to.equal(0); + }); + + it('should return 0 (default) when env var is Infinity', () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = 'Infinity'; + expect(getMessageMaxParseLength()).to.equal(0); + }); +}); diff --git a/apps/meteor/tests/unit/lib/mimeTypes.tests.ts b/apps/meteor/tests/unit/lib/mimeTypes.tests.ts new file mode 100644 index 0000000..a3ef408 --- /dev/null +++ b/apps/meteor/tests/unit/lib/mimeTypes.tests.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai'; + +import { getExtension } from '../../../app/utils/lib/mimeTypes'; + +describe('mimeTypes', () => { + describe('#getExtension()', () => { + it('should return an empty string if the given param is an invalid mime type', () => { + expect(getExtension('invalid-mime')).to.be.equal(''); + }); + + it('should return the correct extension when the mime type is valid', () => { + expect(getExtension('image/png')).to.be.equal('png'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/roles/getMostImportantRole.tests.ts b/apps/meteor/tests/unit/lib/roles/getMostImportantRole.tests.ts new file mode 100644 index 0000000..1609d7f --- /dev/null +++ b/apps/meteor/tests/unit/lib/roles/getMostImportantRole.tests.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; + +import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole'; + +describe('getMostImportantRole', () => { + it('should return the same role if only one exists', () => { + expect(getMostImportantRole(['admin'])).to.be.eq('admin'); + expect(getMostImportantRole(['livechat-manager'])).to.be.eq('livechat-manager'); + expect(getMostImportantRole(['livechat-monitor'])).to.be.eq('livechat-monitor'); + expect(getMostImportantRole(['livechat-agent'])).to.be.eq('livechat-agent'); + expect(getMostImportantRole(['user'])).to.be.eq('user'); + expect(getMostImportantRole(['guest'])).to.be.eq('guest'); + expect(getMostImportantRole(['anonymous'])).to.be.eq('anonymous'); + expect(getMostImportantRole(['app'])).to.be.eq('app'); + expect(getMostImportantRole(['bot'])).to.be.eq('bot'); + }); + + it('should return custom roles as `custom-role`', () => { + expect(getMostImportantRole(['custom1'])).to.be.eq('custom-role'); + }); + + it('should return auditor, app and bot as `user`', () => { + expect(getMostImportantRole(['auditor'])).to.be.eq('user'); + expect(getMostImportantRole(['auditor-log'])).to.be.eq('user'); + }); + + it('should return `no-role` if no one exists', () => { + expect(getMostImportantRole([])).to.be.eq('no-role'); + expect(getMostImportantRole()).to.be.eq('no-role'); + }); + + it('should return correct role', () => { + expect(getMostImportantRole(['user', 'admin'])).to.be.eq('admin'); + expect(getMostImportantRole(['user', 'anonymous'])).to.be.eq('user'); + expect(getMostImportantRole(['user', 'guest'])).to.be.eq('user'); + expect(getMostImportantRole(['user', 'guest', 'livechat-monitor'])).to.be.eq('livechat-monitor'); + expect(getMostImportantRole(['user', 'custom1'])).to.be.eq('custom-role'); + expect(getMostImportantRole(['custom2', 'user', 'custom1'])).to.be.eq('custom-role'); + expect(getMostImportantRole(['custom2', 'admin', 'custom1'])).to.be.eq('admin'); + expect(getMostImportantRole(['custom2', 'app'])).to.be.eq('custom-role'); + expect(getMostImportantRole(['anonymous', 'app'])).to.be.eq('app'); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/arrayUtils.tests.ts b/apps/meteor/tests/unit/lib/utils/arrayUtils.tests.ts new file mode 100644 index 0000000..6a6bff5 --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/arrayUtils.tests.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai'; + +import { ensureArray } from '../../../../lib/utils/arrayUtils'; + +describe('Array utils', () => { + it('should return an array with one item', () => { + const value = 'value'; + const result = ensureArray(value); + + expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]); + }); + + it('should return a new array with the same items', () => { + const value = ['value1', 'value2']; + const result = ensureArray(value); + + expect(result).to.be.an('Array').with.lengthOf(2).and.eql(value).and.not.equal(value); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/isRelativeURL.spec.ts b/apps/meteor/tests/unit/lib/utils/isRelativeURL.spec.ts new file mode 100644 index 0000000..86c054f --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/isRelativeURL.spec.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; + +import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; + +describe('isRelativeURL', () => { + const testCases = [ + ['/', false], + ['test', false], // TODO: should be true? + ['test/test', true], + ['.', false], // TODO: should be true? + ['./test', true], + ['https://rocket.chat', false], + ['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', true], // TODO: should be false? + ] as const; + + testCases.forEach(([parameter, expectedResult]) => { + it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => { + const result = isRelativeURL(parameter); + expect(result).to.be.equal(expectedResult); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/normalizeUsername.spec.ts b/apps/meteor/tests/unit/lib/utils/normalizeUsername.spec.ts new file mode 100644 index 0000000..62bec12 --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/normalizeUsername.spec.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai'; + +import { normalizeUsername } from '../../../../lib/utils/normalizeUsername'; + +describe('normalizeUsername', () => { + const testCases = [ + ['john.doe', 'john.doe'], + ['@john.doe:matrix.org', 'john.doe:matrix.org'], + ['@john', 'john'], + ['@@john', '@john'], + ['john@doe', 'john@doe'], + ['', ''], + ] as const; + + testCases.forEach(([parameter, expectedResult]) => { + it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => { + const result = normalizeUsername(parameter); + expect(result).to.be.equal(expectedResult); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/parseCSV.tests.ts b/apps/meteor/tests/unit/lib/utils/parseCSV.tests.ts new file mode 100644 index 0000000..7879a6a --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/parseCSV.tests.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; + +import { parseCSV } from '../../../../lib/utils/parseCSV'; + +describe('Parse CSV', () => { + it('should return an empty array for an empty string', () => { + const result = parseCSV(''); + + expect(result).to.be.an('Array').with.lengthOf(0); + }); + + it('should return an array with one item', () => { + const value = 'value'; + const result = parseCSV(value); + + expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]); + }); + + it('should trim surrounding spaces', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const csv = `${value1}, ${value2}`; + const result = parseCSV(csv); + + expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]); + }); + + it('should ignore empty items', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const csv = `${value1}, , , , ${value2}`; + const result = parseCSV(csv); + + expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]); + }); + + it('should not ignore empty items when requested', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const csv = `${value1}, , ${value2}`; + const result = parseCSV(csv, false); + + expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, '', value2]); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/parseParameters.tests.ts b/apps/meteor/tests/unit/lib/utils/parseParameters.tests.ts new file mode 100644 index 0000000..43e5bbd --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/parseParameters.tests.ts @@ -0,0 +1,185 @@ +import { expect } from 'chai'; + +import { parseParameters } from '../../../../lib/utils/parseParameters'; + +describe('Parse Parameters', () => { + it('should return an empty array for an empty string', () => { + const result = parseParameters(''); + + expect(result).to.be.an('Array').with.lengthOf(0); + }); + + it('should return an array with one item', () => { + const value = 'value'; + const result = parseParameters(value); + + expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]); + }); + + it('should return an array with three items', () => { + const value1 = 'value1'; + const value2 = 'value2'; + const value3 = 'value3'; + + const parameters = `${value1} ${value2} ${value3}`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]); + }); + + it('should ignore extra spaces by default', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const parameters = ` ${value1} ${value2} `; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]); + }); + + it('should NOT ignore extra spaces when requested', () => { + const value1 = ''; + const value2 = ''; + const value3 = 'value3'; + const value4 = ''; + const value5 = ''; + const value6 = 'value6'; + const value7 = ''; + const value8 = ''; + + const parameters = `${value1} ${value2} ${value3} ${value4} ${value5} ${value6} ${value7} ${value8}`; + const result = parseParameters(parameters, false); + + expect(result).to.be.an('Array').with.lengthOf(8).and.eql([value1, value2, value3, value4, value5, value6, value7, value8]); + }); + + it('should return an array with one item without quotes', () => { + const value = 'value'; + const result = parseParameters(`"${value}"`); + + expect(result).to.be.an('Array').with.lengthOf(1).and.eql([value]); + }); + it('should return an array with three items without quotes', () => { + const value1 = 'value1'; + const value2 = 'value2'; + const value3 = 'value3'; + const result = parseParameters(`"${value1}" "${value2}" "${value3}"`); + + expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]); + }); + + it('should not trim spaces inside quotes', () => { + const value1 = 'value1 '; + const value2 = ' value2'; + const value3 = ' value3 '; + + const parameters = `"${value1}" "${value2}" "${value3}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]); + }); + + it('should split parameters even without spaces', () => { + const value1 = 'value1'; + const value2 = 'value2'; + const value3 = 'value3'; + const value4 = 'value4'; + + const parameters = `"${value1}""${value2}""${value3}" "${value4}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(4).and.eql([value1, value2, value3, value4]); + }); + + it('should split parameters by both spaces and quotes in the same line', () => { + const value1 = 'value1'; + const value2 = 'value2'; + + const parameters = `"${value1}" ${value2}`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(2).and.eql([value1, value2]); + }); + + it('should unescape quotes inside values', () => { + const value1 = `this is \\"value1\\" here`; + const value2 = `\\"value2\\"`; + + const parameters = `"${value1}" "${value2}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(2); + expect(result[0]).to.be.equal(value1.replace(/\\\"/g, '"')); + expect(result[1]).to.be.equal(value2.replace(/\\\"/g, '"')); + }); + + it('should not ignore empty quoted parameters', () => { + const value1 = 'value1'; + const value2 = ''; + const value3 = 'value3'; + + const parameters = `"${value1}" "${value2}" "${value3}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3).and.eql([value1, value2, value3]); + }); + + it('should accept line breaks inside quotes', () => { + const value1 = `value1 + is multiline`; + const value2 = 'value2\nhas a multine too'; + const value3 = 'value3\rhas a carriage return'; + + const parameters = `"${value1}" "${value2}" "${value3}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3); + expect(result[0]).to.be.equal(value1); + expect(result[1]).to.be.equal(value2); + expect(result[2]).to.be.equal(value3); + }); + + it('should split on line breaks without quotes', () => { + const value1 = `value1`; + const value2 = 'value2'; + const value3 = 'value3'; + + const parameters = `${value1} +${value2} +${value3}`; + + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3); + expect(result[0]).to.be.equal(value1); + expect(result[1]).to.be.equal(value2); + expect(result[2]).to.be.equal(value3); + }); + + it('should accept tabs inside quotes', () => { + const value1 = `value1 is tabbed`; + const value2 = 'value2'; + + const parameters = `"${value1}" "${value2}"`; + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(2); + expect(result[0]).to.be.equal(value1); + expect(result[1]).to.be.equal(value2); + }); + + it('should split on tabs without quotes', () => { + const value1 = `value1`; + const value2 = 'value2'; + const value3 = 'value3'; + + const parameters = `${value1} ${value2} ${value3}`; + + const result = parseParameters(parameters); + + expect(result).to.be.an('Array').with.lengthOf(3); + expect(result[0]).to.be.equal(value1); + expect(result[1]).to.be.equal(value2); + expect(result[2]).to.be.equal(value3); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/secondsToHHMMSS.spec.ts b/apps/meteor/tests/unit/lib/utils/secondsToHHMMSS.spec.ts new file mode 100644 index 0000000..3d087af --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/secondsToHHMMSS.spec.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; + +import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS'; + +describe('secondsToHHMMSS', () => { + const testCases = [ + [0, '00:00:00'], + [1, '00:00:01'], + [60, '00:01:00'], + [61, '00:01:01'], + [3600, '01:00:00'], + [3601, '01:00:01'], + [3661, '01:01:01'], + [3661.1, '01:01:01'], + [3661.9, '01:01:02'], // rounding down? + [3662, '01:01:02'], + [86400, '24:00:00'], + [172800, '48:00:00'], + [360000, '100:00:00'], // should exceed 8 characters? + ] as const; + + testCases.forEach(([parameter, expectedResult]) => { + it(`should return ${JSON.stringify(expectedResult)} for ${JSON.stringify(parameter)}`, () => { + const result = secondsToHHMMSS(parameter); + expect(result).to.be.equal(expectedResult); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/utils/stringUtils.tests.ts b/apps/meteor/tests/unit/lib/utils/stringUtils.tests.ts new file mode 100644 index 0000000..763d191 --- /dev/null +++ b/apps/meteor/tests/unit/lib/utils/stringUtils.tests.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai'; + +import { truncate } from '../../../../lib/utils/stringUtils'; + +describe('String utils', () => { + it('should return an empty string when the specified string is empty', () => { + expect(truncate('', 10)).to.be.equal(''); + }); + + it('should truncate a larger string', () => { + expect(truncate('this text has a few words', 9)).to.be.equal('this t...'); + }); + + it('should not truncate a smaller string', () => { + expect(truncate('this', 9)).to.be.equal('this'); + }); + + it('should not truncate a string with the exact length', () => { + expect(truncate('this', 4)).to.be.equal('this'); + }); +}); diff --git a/apps/meteor/tests/unit/server/api/canBypassRateLimit.spec.ts b/apps/meteor/tests/unit/server/api/canBypassRateLimit.spec.ts new file mode 100644 index 0000000..33ae8f2 --- /dev/null +++ b/apps/meteor/tests/unit/server/api/canBypassRateLimit.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import mock from 'proxyquire'; + +const userPermissions: Record = { + bot: ['api-bypass-rate-limit', 'send-many-messages'], + legacyBot: ['send-many-messages'], + regular: [], +}; + +const mocks = { + '../lib/authorization/hasPermission': { + hasAtLeastOnePermissionAsync: async (userId: string, permissions: string[]): Promise => + permissions.some((permission) => userPermissions[userId].includes(permission)), + }, + '../lib/deprecationWarningLogger': { + apiDeprecationLogger: { endpoint: () => undefined }, + }, +}; + +const { canBypassRateLimit } = mock.noCallThru().load('../../../../server/api/api.helpers', mocks); + +describe('canBypassRateLimit', () => { + it('should let a user holding api-bypass-rate-limit through on any route', async () => { + expect(await canBypassRateLimit('bot')).to.be.true; + }); + + it('should not let a regular user through', async () => { + expect(await canBypassRateLimit('regular')).to.be.false; + expect(await canBypassRateLimit('regular', ['send-many-messages'])).to.be.false; + }); + + it('should let a user holding a route specific permission through', async () => { + expect(await canBypassRateLimit('legacyBot', ['send-many-messages'])).to.be.true; + }); + + it('should not let a route specific permission apply to routes that do not declare it', async () => { + expect(await canBypassRateLimit('legacyBot')).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/api/checkPermissions.spec.ts b/apps/meteor/tests/unit/server/api/checkPermissions.spec.ts new file mode 100644 index 0000000..558ca2b --- /dev/null +++ b/apps/meteor/tests/unit/server/api/checkPermissions.spec.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import mock from 'proxyquire'; +import Sinon from 'sinon'; + +import type { PermissionsPayload } from '../../../../server/api/api.helpers'; + +const mocks = { + '../lib/deprecationWarningLogger': { + apiDeprecationLogger: { + endpoint: Sinon.stub(), + }, + }, +}; +const { checkPermissions } = mock.noCallThru().load('../../../../server/api/api.helpers', mocks); + +describe('checkPermissions', () => { + it('should return false when no options.permissionsRequired key is present', () => { + const options = {}; + expect(checkPermissions(options)).to.be.false; + }); + it('should return false when options.permissionsRequired is of an invalid format', () => { + const options = { + permissionsRequired: 'invalid', + }; + expect(checkPermissions(options)).to.be.false; + }); + it('should return true and modify options.permissionsRequired when permissionsRequired key is an array (of permissions)', () => { + const options = { + permissionsRequired: ['invalid', 'invalid2'], + }; + expect(checkPermissions(options)).to.be.true; + expect(options.permissionsRequired).to.be.an('object'); + expect(options.permissionsRequired).to.have.property('*'); + // @ts-expect-error -for test purposes :) + expect(options.permissionsRequired['*']).to.be.an('object'); + // @ts-expect-error -for test purposes :) + expect(options.permissionsRequired['*'].permissions).to.be.an('array').that.includes('invalid'); + }); + it('should return true and modify options.permissionsRequired when permissionsRequired key is an object (of permissions)', () => { + const options = { + permissionsRequired: { + GET: ['invalid', 'invalid2'], + }, + }; + expect(checkPermissions(options)).to.be.true; + expect(options.permissionsRequired).to.be.an('object'); + expect(options.permissionsRequired).to.have.property('GET'); + expect(options.permissionsRequired.GET).to.be.an('object'); + expect(options.permissionsRequired.GET).to.have.property('operation', 'hasAll'); + expect(options.permissionsRequired.GET).to.have.property('permissions').that.is.an('array').that.includes('invalid'); + }); + it('should return true and not modify options.permissionsRequired when its of new format', () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAll', + permissions: ['invalid', 'invalid2'], + }, + }, + }; + expect(checkPermissions(options)).to.be.true; + expect(options.permissionsRequired).to.be.an('object'); + expect(options.permissionsRequired).to.have.property('GET'); + expect(options.permissionsRequired.GET).to.be.an('object'); + expect(options.permissionsRequired.GET).to.have.property('operation', 'hasAll'); + expect(options.permissionsRequired.GET).to.have.property('permissions').that.is.an('array').that.includes('invalid'); + }); + + it('should persist the right operation for method', () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAny', + permissions: ['invalid', 'invalid2'], + }, + }, + }; + expect(checkPermissions(options)).to.be.true; + expect(options.permissionsRequired).to.be.an('object'); + expect(options.permissionsRequired).to.have.property('GET'); + expect(options.permissionsRequired.GET).to.be.an('object'); + expect(options.permissionsRequired.GET).to.have.property('operation', 'hasAny'); + expect(options.permissionsRequired.GET).to.have.property('permissions').that.is.an('array').that.includes('invalid'); + }); + it('should persist the right method keys', () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAll', + permissions: ['invalid', 'invalid2'], + }, + POST: { + operation: 'hasAll', + permissions: ['invalid', 'invalid2'], + }, + }, + }; + expect(checkPermissions(options)).to.be.true; + expect(options.permissionsRequired).to.be.an('object'); + expect(options.permissionsRequired).to.have.property('GET'); + expect(options.permissionsRequired).to.have.property('POST'); + expect(options.permissionsRequired.GET).to.be.an('object'); + expect(options.permissionsRequired.GET).to.have.property('operation', 'hasAll'); + expect(options.permissionsRequired.GET).to.have.property('permissions').that.is.an('array').that.includes('invalid'); + }); +}); diff --git a/apps/meteor/tests/unit/server/api/checkPermissionsForInvocation.spec.ts b/apps/meteor/tests/unit/server/api/checkPermissionsForInvocation.spec.ts new file mode 100644 index 0000000..2ecf81b --- /dev/null +++ b/apps/meteor/tests/unit/server/api/checkPermissionsForInvocation.spec.ts @@ -0,0 +1,126 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import mock from 'proxyquire'; +import Sinon from 'sinon'; + +import type { PermissionsPayload } from '../../../../server/api/api.helpers'; + +const userPermissions: { [k: string]: string[] } = { + '4r3fsadfasf': ['view-all', 'view-none'], + '4r3fsadfasf2': ['view-all', 'view-0'], + '4r3fsadfasf3': ['view-all', 'view-1'], + '4r3fsadfasf4': [], +}; + +const mocks = { + '../lib/authorization/hasPermission': { + hasAllPermissionAsync: (userId: string, permissions: string[]): boolean => { + return permissions.every((permission) => userPermissions[userId].includes(permission)); + }, + hasAtLeastOnePermissionAsync: (userId: string, permissions: string[]): boolean => { + return permissions.some((permission) => userPermissions[userId].includes(permission)); + }, + }, + '../lib/deprecationWarningLogger': { + apiDeprecationLogger: { + endpoint: Sinon.stub(), + }, + }, +}; + +const { checkPermissionsForInvocation } = mock.noCallThru().load('../../../../server/api/api.helpers', mocks); + +describe('checkPermissionsForInvocation', () => { + it('should return false when no permissions are provided', async () => { + const options = { + permissionsRequired: {}, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'GET')).to.be.false; + }); + + it('should return false when no config is provided for that specific method', async () => { + const options = { + permissionsRequired: { + GET: { + operation: 'hasAll', + permissions: ['view-all', 'view-none'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'POST')).to.be.false; + }); + + it('should return true path is configured with empty permissions array', async () => { + const options = { + permissionsRequired: { + GET: { permissions: [], operation: 'hasAll' }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'GET')).to.be.true; + }); + + it('should return true when user has all permissions', async () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAll', + permissions: ['view-all', 'view-none'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'GET')).to.be.true; + }); + + it('should read permissions config from * when request method provided doesnt have config', async () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + 'GET': { + operation: 'hasAll', + permissions: ['view-all', 'view-none'], + }, + '*': { + operation: 'hasAll', + permissions: ['view-all', 'view-none'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'PUT')).to.be.true; + }); + + it('should return false when user has no permissions', async () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAll', + permissions: ['view-all', 'view-none'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf4', options.permissionsRequired, 'GET')).to.be.false; + }); + + it('should return false when operation is invalid', async () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + // @ts-expect-error - for testing purposes + operation: 'invalid', + permissions: ['view-all', 'view-none'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'GET')).to.be.false; + }); + + it('should return true when operation is hasAny and user has at least one listed permission', async () => { + const options: { permissionsRequired: PermissionsPayload } = { + permissionsRequired: { + GET: { + operation: 'hasAny', + permissions: ['view-all', 'admin'], + }, + }, + }; + expect(await checkPermissionsForInvocation('4r3fsadfasf', options.permissionsRequired, 'GET')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/api/lib/isValidQuery.spec.ts b/apps/meteor/tests/unit/server/api/lib/isValidQuery.spec.ts new file mode 100644 index 0000000..a0d2dcd --- /dev/null +++ b/apps/meteor/tests/unit/server/api/lib/isValidQuery.spec.ts @@ -0,0 +1,226 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { isValidQuery } from '../../../../../server/api/lib/isValidQuery'; + +describe('isValidQuery', () => { + describe('shallow keys', () => { + it('should return false if the query contains an operation that is not in the props array', () => { + const props = ['_id', 'name']; + const query = { + $or: [ + { + _id: '123', + }, + { + name: '456', + }, + ], + }; + expect(isValidQuery(query, props, [])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + + it('should return true if the query contains operation and the attributes are set in the props array ', () => { + const props = ['_id', 'name']; + const query = { + $or: [ + { + _id: '123', + }, + { + name: '456', + }, + ], + }; + expect(isValidQuery(query, props, ['$or'])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + + it('should return false if the query contains operations allowed but some attributes are not set in the props array ', () => { + const props = ['name']; + const query = { + $or: [ + { + _id: '123', + }, + { + name: '456', + }, + ], + }; + expect(isValidQuery(query, props, ['$or'])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + + it('should return true if the query contains only attributes set in the props array ', () => { + const props = ['_id', 'name']; + const query = { + _id: '123', + name: '456', + }; + expect(isValidQuery(query, props, [])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + + it('should return false if the query contains an attribute that is not in the props array ', () => { + const props = ['_id']; + const query = { + _id: '123', + name: '456', + }; + expect(isValidQuery(query, props, [])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + }); + + describe('deep keys', () => { + it('should return false if the query contains deep attributes that are not set on allowed keys', () => { + const props = ['_id', 'name']; + const query = { + user: { + _id: '123', + name: '456', + }, + }; + expect(isValidQuery(query, props, [])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + + it('should return false if the query contains deep attributes that are and are not set as allowed', () => { + const props = ['user', '_id', 'name']; + const query = { + user: { + _id: '123', + name: '456', + }, + }; + expect(isValidQuery(query, props, [])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + + it('should return true if the query contains deep attributes that are set on allowed keys', () => { + const props = ['user', 'user._id', 'user.name']; + const query = { + user: { + _id: '123', + name: '456', + }, + }; + expect(isValidQuery(query, props, [])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + + it('should return true if the query contains deep attributes that are set on allowed keys even for many layers', () => { + const props = ['user', 'user._id', 'user.name', 'user.address', 'user.address.city']; + const query = { + user: { + _id: '123', + name: '456', + address: { + city: 'New York', + }, + }, + }; + expect(isValidQuery(query, props, [])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + }); + + describe('using .* for match keys', () => { + it('should return true if the query contains attributes and * are being used', () => { + const props = ['user', 'user.*']; + const query = { + user: { + _id: '123', + name: '456', + }, + }; + expect(isValidQuery(query, props, [])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + }); + + describe('using * for match keys', () => { + it('should return true if the query contains attributes and * are being used', () => { + const props = ['user', '*']; + const query = { + user: { + _id: '123', + name: '456', + }, + }; + expect(isValidQuery(query, props, [])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + it('should return false if query uses * but the operation is not allowed', () => { + const props = ['user', '*']; + const query = { + $or: [{ user: '123' }, { user: '456' }], + }; + expect(isValidQuery(query, props, [])).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + }); + + describe('testing $regex', () => { + it('should return true if the query contains attributes and * are being used', () => { + const props = ['user.*']; + const query = { + user: { + _id: '123', + name: { + $regex: '*', + }, + }, + }; + expect(isValidQuery(query, props, ['$or', '$regex'])).to.be.true; + expect(isValidQuery.errors.length).to.be.equals(0); + }); + + it('should return false for services.password.reset.token', () => { + const query = { + $or: [ + { 'emails.address': { $regex: '', $options: 'i' } }, + { username: { $regex: '', $options: 'i' } }, + { name: { $regex: '', $options: 'i' } }, + ], + $and: [{ username: 'g1' }, { 'services.password.reset.token': { $regex: '.*' } }], + }; + expect( + isValidQuery( + query, + ['name', 'username', 'emails', 'roles', 'status', 'active', 'avatarETag', 'lastLogin', 'email.address.*', 'username.*', 'name.*'], + ['$or', '$and'], + ), + ).to.be.false; + }); + it('should return false for services.totp.secret', () => { + const query = { + $or: [ + { 'emails.address': { $regex: '', $options: 'i' } }, + { username: { $regex: '', $options: 'i' } }, + { name: { $regex: '', $options: 'i' } }, + ], + $and: [{ username: 'g1' }, { 'services.totp.secret': { $regex: '.*' } }], + }; + expect( + isValidQuery( + query, + ['name', 'username', 'emails', 'roles', 'status', 'active', 'avatarETag', 'lastLogin', 'email.address.*', 'username.*', 'name.*'], + ['$or', '$and'], + ), + ).to.be.false; + }); + + it('should return false if the query contains nested conditions with disallowed operators', () => { + const props = ['username']; + const allowedOps = ['$and', '$ne']; + const query = { + $and: [{ username: { $exists: true } }, { username: { $ne: '1000' } }], + }; + expect(isValidQuery(query, props, allowedOps)).to.be.false; + expect(isValidQuery.errors.length).to.be.equals(1); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts new file mode 100644 index 0000000..dd7f6df --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const serverFetchStub = sinon.stub(); + +class CloudOfflineLicenseError extends Error {} + +const { MarketplaceAPIClient } = proxyquire.noCallThru().load('../../../../../../ee/server/apps/marketplace/MarketplaceAPIClient.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/server-fetch': { serverFetch: serverFetchStub, Response: class {} }, + './isTesting': { isTesting: () => false }, + '../../../../lib/errors/CloudOfflineLicenseError': { CloudOfflineLicenseError }, +}); + +describe('MarketplaceAPIClient', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + serverFetchStub.reset(); + }); + + it('should reject with CloudOfflineLicenseError and never invoke the fetch strategy when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const client = new MarketplaceAPIClient(); + + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.called).to.be.false; + }); + + it('should re-evaluate the license on every call, not cache the verdict on the instance', async () => { + const client = new MarketplaceAPIClient(); + + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + await client.fetch('v1/apps'); + expect(serverFetchStub.calledOnce).to.be.true; + + // license flips to offline at runtime: the same instance must now reject + hasOfflineLicenseStub.returns(true); + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.calledOnce).to.be.true; + }); + + it('should fetch from the marketplace when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + + const client = new MarketplaceAPIClient(); + await client.fetch('v1/apps'); + + expect(serverFetchStub.calledOnce).to.be.true; + expect(serverFetchStub.firstCall.args[0]).to.equal('https://marketplace.rocket.chat/v1/apps'); + }); +}); diff --git a/apps/meteor/tests/unit/server/hooks/messages/processThreads.spec.ts b/apps/meteor/tests/unit/server/hooks/messages/processThreads.spec.ts new file mode 100644 index 0000000..d73e47c --- /dev/null +++ b/apps/meteor/tests/unit/server/hooks/messages/processThreads.spec.ts @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeMessage, createFakeRoom } from '../../../../mocks/data'; + +const modelsMock = { Messages: { findOneById: sinon.stub() } }; +const getMentionsMock = sinon.stub(); +const replyMock = sinon.stub(); +const callbacksRunAsyncMock = sinon.stub(); + +const { processThreads } = p.noCallThru().load('../../../../../server/hooks/messages/processThreads.ts', { + '@rocket.chat/models': modelsMock, + 'meteor/meteor': { Meteor: { startup: sinon.stub() } }, + './notifyUsersOnMessage': { updateThreadUsersSubscriptions: sinon.stub(), getMentions: getMentionsMock }, + './sendNotificationsOnMessage': { sendMessageNotifications: sinon.stub() }, + '../../lib/callbacks': { + callbacks: { runAsync: callbacksRunAsyncMock, add: sinon.stub(), remove: sinon.stub(), priority: { LOW: 'low' } }, + }, + '../../lib/messaging/threads/functions': { reply: replyMock }, + '../../lib/notifyListener': { notifyOnMessageChange: sinon.stub() }, + '../../settings': { settings: { watch: sinon.stub() } }, +}); + +describe('processThreads', () => { + const room = createFakeRoom(); + const parentMessage = createFakeMessage({ rid: room._id, tcount: 1, replies: [] }); + const author = { _id: 'authorId', username: 'author' }; + + const threadReply = (overrides: Record = {}) => + createFakeMessage({ rid: room._id, tmid: parentMessage._id, u: author, ...overrides }); + + beforeEach(() => { + callbacksRunAsyncMock.reset(); + replyMock.reset(); + modelsMock.Messages.findOneById.reset(); + modelsMock.Messages.findOneById.resolves(parentMessage); + getMentionsMock.reset(); + getMentionsMock.resolves({ mentionIds: [] }); + }); + + it('should mark the thread as read for the author of the reply', async () => { + await processThreads(threadReply(), room); + + expect(callbacksRunAsyncMock.calledWith('afterReadMessages', room, { uid: author._id, tmid: parentMessage._id })).to.be.true; + }); + + it('should mark the thread as read only after the thread metadata is updated', async () => { + await processThreads(threadReply(), room); + + expect(replyMock.calledBefore(callbacksRunAsyncMock)).to.be.true; + }); + + it('should not mark anything as read when the message is not a thread reply', async () => { + await processThreads(createFakeMessage({ rid: room._id, u: author }), room); + + expect(callbacksRunAsyncMock.called).to.be.false; + }); + + it('should not mark anything as read when the message was edited', async () => { + await processThreads(threadReply({ editedAt: new Date(), editedBy: author }), room); + + expect(callbacksRunAsyncMock.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/hooks/omnichannel/beforeNewRoom.spec.ts b/apps/meteor/tests/unit/server/hooks/omnichannel/beforeNewRoom.spec.ts new file mode 100644 index 0000000..7a6f1f0 --- /dev/null +++ b/apps/meteor/tests/unit/server/hooks/omnichannel/beforeNewRoom.spec.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const findStub = sinon.stub(); + +const { beforeNewRoomPatched } = proxyquire.noCallThru().load('../../../../../ee/server/hooks/omnichannel/beforeNewRoom.ts', { + 'meteor/meteor': { + Meteor: { + Error, + }, + }, + '@rocket.chat/models': { + OmnichannelServiceLevelAgreements: { + findOneByIdOrName: findStub, + }, + }, + '../../../../server/lib/omnichannel/hooks': { + beforeNewRoom: { patch: sinon.stub() }, + }, +}); + +describe('beforeRoom', () => { + beforeEach(() => findStub.withArgs('high').resolves({ _id: 'high' }).withArgs('invalid').resolves(null)); + + it('should return roomInfo with customFields when provided', async () => { + const roomInfo = { name: 'test' }; + const extraData = { customFields: { test: 'test' } }; + const result = await beforeNewRoomPatched(undefined, roomInfo, extraData); + expect(result).to.deep.equal({ ...roomInfo, customFields: extraData.customFields }); + }); + + it('should throw an error when provided with an invalid sla', async () => { + const roomInfo = { name: 'test' }; + const extraData = { customFields: { test: 'test' }, sla: 'invalid' }; + await expect(beforeNewRoomPatched(undefined, roomInfo, extraData)).to.be.rejectedWith(Error, 'error-invalid-sla'); + }); + + it('should not include field in roomInfo when extraData has field other than customFields, sla', async () => { + const roomInfo = { name: 'test' }; + const extraData = { customFields: { test: 'test' }, sla: 'high' }; + const result = await beforeNewRoomPatched(undefined, roomInfo, extraData); + expect(result).to.deep.equal({ ...roomInfo, customFields: extraData.customFields, slaId: 'high' }); + }); + + it('should return roomInfo with no customFields when customFields is not an object', async () => { + const roomInfo = { name: 'test' }; + const extraData = { customFields: 'not an object' }; + const result = await beforeNewRoomPatched(undefined, roomInfo, extraData); + expect(result).to.deep.equal({ ...roomInfo }); + }); +}); diff --git a/apps/meteor/tests/unit/server/hooks/omnichannel/markRoomResponded.spec.ts b/apps/meteor/tests/unit/server/hooks/omnichannel/markRoomResponded.spec.ts new file mode 100644 index 0000000..a1278aa --- /dev/null +++ b/apps/meteor/tests/unit/server/hooks/omnichannel/markRoomResponded.spec.ts @@ -0,0 +1,201 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import moment from 'moment'; +import proxyquire from 'proxyquire'; +import Sinon from 'sinon'; + +import { createFakeMessage, createFakeUser } from '../../../../mocks/data'; + +const models = { + LivechatContacts: { isContactActiveOnPeriod: Sinon.stub(), markContactActiveForPeriod: Sinon.stub() }, + LivechatInquiry: { markInquiryActiveForPeriod: Sinon.stub() }, + LivechatRooms: { + getVisitorActiveForPeriodUpdateQuery: Sinon.stub(), + getAgentLastMessageTsUpdateQuery: Sinon.stub(), + getResponseByRoomIdUpdateQuery: Sinon.stub(), + }, +}; + +const settingsGetMock = { + get: Sinon.stub(), +}; + +const isMessageFromBotMock = { isMessageFromBot: Sinon.stub() }; + +const { markRoomResponded } = proxyquire.noCallThru().load('../../../../../server/hooks/omnichannel/markRoomResponded.ts', { + '../../lib/callbacks': { callbacks: { add: Sinon.stub(), priority: { HIGH: 'high' } } }, + '../../lib/notifyListener': { notifyOnLivechatInquiryChanged: Sinon.stub() }, + '@rocket.chat/models': models, + '../../settings': { settings: settingsGetMock }, + '../../lib/omnichannel/isMessageFromBot': isMessageFromBotMock, +}); + +describe('markRoomResponded', () => { + beforeEach(() => { + models.LivechatContacts.isContactActiveOnPeriod.reset(); + models.LivechatContacts.markContactActiveForPeriod.reset(); + models.LivechatInquiry.markInquiryActiveForPeriod.reset(); + models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.reset(); + models.LivechatRooms.getAgentLastMessageTsUpdateQuery.reset(); + models.LivechatRooms.getResponseByRoomIdUpdateQuery.reset(); + + settingsGetMock.get.reset(); + }); + + it('should return void if message is system message', async () => { + const message = { + t: 'livechat-started', + }; + + const room = {}; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.undefined; + }); + + it('should return void if message is edited message', async () => { + const message = { + editedAt: new Date(), + editedBy: { _id: '123' }, + }; + + const room = {}; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.undefined; + }); + + it('should return void if message is from visitor', async () => { + const message = { + token: 'token', + }; + + const room = {}; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.undefined; + }); + + it('should return void if message is from bot and setting is enabled', async () => { + settingsGetMock.get.withArgs('Omnichannel_Metrics_Ignore_Automatic_Messages').resolves(true); + + const user = createFakeUser({ roles: ['bot'] }); + + isMessageFromBotMock.isMessageFromBot.resolves(user); + + const message = createFakeMessage(); + const room = {}; + + const res = await markRoomResponded(message, room, user); + + expect(res).to.be.undefined; + }); + + it('should try to mark visitor as active for current period', async () => { + const message = {}; + const room = { v: { _id: '1234' } }; + + await markRoomResponded(message, room, {}); + + expect(models.LivechatContacts.markContactActiveForPeriod.calledOnce).to.be.true; + }); + + it('should try to mark inquiry as active for current period when room.v.activity doesnt include current period', async () => { + const message = {}; + const room = { v: { activity: [] } }; + + models.LivechatInquiry.markInquiryActiveForPeriod.resolves({}); + + await markRoomResponded(message, room, {}); + + expect(models.LivechatInquiry.markInquiryActiveForPeriod.calledOnce).to.be.true; + }); + + it('should pass the current activity to getVisitorActiveForPeriodUpdateQuery so a null value can be healed', async () => { + const message = {}; + const room = { v: { _id: '1234', activity: null } }; + const roomUpdater = {}; + + models.LivechatInquiry.markInquiryActiveForPeriod.resolves({}); + + await markRoomResponded(message, room, roomUpdater); + + expect(models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.calledOnce).to.be.true; + expect(models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.getCall(0).args).to.be.deep.equal([ + moment().format('YYYY-MM'), + roomUpdater, + null, + ]); + }); + + it('should not coerce an absent activity to null so it still gets the $addToSet treatment', async () => { + const message = {}; + const room = { v: { _id: '1234' } }; + const roomUpdater = {}; + + models.LivechatInquiry.markInquiryActiveForPeriod.resolves({}); + + await markRoomResponded(message, room, roomUpdater); + + expect(models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.calledOnce).to.be.true; + expect(models.LivechatRooms.getVisitorActiveForPeriodUpdateQuery.getCall(0).args[2]).to.be.undefined; + }); + + it('should return room.responseBy when room is not waiting for response', async () => { + const message = {}; + const room = { v: { _id: '1234' }, waitingResponse: false, responseBy: { _id: '1234' } }; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.equal(room.responseBy); + expect(models.LivechatRooms.getAgentLastMessageTsUpdateQuery.calledOnce).to.be.true; + }); + + it('should try to update the lastMessageTs property when a room was already answered by an agent', async () => { + const message = { u: { _id: '1234', username: 'username' }, ts: new Date() }; + const room = { responseBy: { _id: '1234' }, v: { _id: '1234' } }; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.deep.equal(room.responseBy); + expect(models.LivechatRooms.getAgentLastMessageTsUpdateQuery.calledOnce).to.be.true; + }); + + it('should add a new responseBy object when room is waiting for response', async () => { + const message = { u: { _id: '1234', username: 'username' }, ts: new Date() }; + const room = { waitingResponse: true, v: { _id: '1234' } }; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.deep.equal({ _id: '1234', username: 'username', firstResponseTs: message.ts, lastMessageTs: message.ts }); + expect(models.LivechatRooms.getResponseByRoomIdUpdateQuery.calledOnce).to.be.true; + expect(models.LivechatRooms.getResponseByRoomIdUpdateQuery.getCall(0).args[0]).to.be.deep.equal({ + _id: '1234', + lastMessageTs: message.ts, + firstResponseTs: message.ts, + username: 'username', + }); + }); + + // This should never happpen on the wild, checking because of a data inconsistency bug found + it('should update only the lastMessageTs property when a room has both waitingResponse and responseBy properties', async () => { + const message = { u: { _id: '1234', username: 'username' }, ts: new Date() }; + const room = { + waitingResponse: true, + responseBy: { _id: '1234', username: 'username', firstResponseTs: new Date() }, + v: { _id: '1234' }, + }; + + const res = await markRoomResponded(message, room, {}); + + expect(res).to.be.deep.equal({ + _id: '1234', + username: 'username', + firstResponseTs: room.responseBy.firstResponseTs, + lastMessageTs: message.ts, + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/hooks/omnichannel/processRoomAbandonment.spec.ts b/apps/meteor/tests/unit/server/hooks/omnichannel/processRoomAbandonment.spec.ts new file mode 100644 index 0000000..9a1c033 --- /dev/null +++ b/apps/meteor/tests/unit/server/hooks/omnichannel/processRoomAbandonment.spec.ts @@ -0,0 +1,623 @@ +import { expect } from 'chai'; +import { it, describe } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const settingsStub = sinon.stub(); +const models = { + LivechatDepartment: { + findOneById: sinon.stub(), + }, + LivechatBusinessHours: { + findOneById: sinon.stub(), + }, + Messages: { + findAgentLastMessageByVisitorLastMessageTs: sinon.stub(), + }, + LivechatRooms: { + setVisitorInactivityInSecondsById: sinon.stub(), + }, +}; + +const businessHourManagerMock = { + getBusinessHour: sinon.stub(), +}; + +const { getSecondsWhenOfficeHoursIsDisabled, parseDays, getSecondsSinceLastAgentResponse, onCloseRoom } = p + .noCallThru() + .load('../../../../../server/hooks/omnichannel/processRoomAbandonment.ts', { + '@rocket.chat/models': models, + '../../lib/callbacks': { + callbacks: { add: sinon.stub(), priority: { HIGH: 'high' } }, + }, + '../../settings': { + settings: { get: settingsStub }, + }, + '../../lib/omnichannel/business-hour': { businessHourManager: businessHourManagerMock }, + }); + +describe('processRoomAbandonment', () => { + describe('getSecondsWhenOfficeHoursIsDisabled', () => { + it('should return the seconds since the agents last message till room was closed', () => { + const room = { + closedAt: new Date('2024-01-01T12:00:10Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T12:00:00Z'), + }; + const result = getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + expect(result).to.be.equal(10); + }); + it('should return the seconds since agents last message till now when room.closedAt is undefined', () => { + const room = { + closedAt: undefined, + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + expect(result).to.be.equal(10); + }); + }); + describe('parseDays', () => { + it('should properly return the days in the expected format', () => { + const days = [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + ]; + + const result = days.reduce(parseDays, {}); + expect(result).to.be.deep.equal({ + Monday: { + start: { day: 'Monday', time: '10:00' }, + finish: { day: 'Monday', time: '11:00' }, + open: true, + }, + Tuesday: { + start: { day: 'Tuesday', time: '10:00' }, + finish: { day: 'Tuesday', time: '11:00' }, + open: true, + }, + Wednesday: { + start: { day: 'Wednesday', time: '10:00' }, + finish: { day: 'Wednesday', time: '11:00' }, + open: true, + }, + }); + }); + it('should properly parse open/close days', () => { + const days = [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: false, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + ]; + + const result = days.reduce(parseDays, {}); + expect(result).to.be.deep.equal({ + Monday: { + start: { day: 'Monday', time: '10:00' }, + finish: { day: 'Monday', time: '11:00' }, + open: true, + }, + Tuesday: { + start: { day: 'Tuesday', time: '10:00' }, + finish: { day: 'Tuesday', time: '11:00' }, + open: false, + }, + Wednesday: { + start: { day: 'Wednesday', time: '10:00' }, + finish: { day: 'Wednesday', time: '11:00' }, + open: true, + }, + }); + }); + }); + describe('getSecondsSinceLastAgentResponse', () => { + beforeEach(() => { + settingsStub.reset(); + models.LivechatDepartment.findOneById.reset(); + models.LivechatBusinessHours.findOneById.reset(); + businessHourManagerMock.getBusinessHour.reset(); + }); + it('should return the seconds since agent last message when Livechat_enable_business_hours is false', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(false); + const room = { + closedAt: undefined, + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(10); + }); + it('should return the seconds since last agent message when room has a department but department has an invalid business hour attached', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + models.LivechatDepartment.findOneById.withArgs('departmentId').resolves({ + businessHourId: 'businessHourId', + }); + models.LivechatBusinessHours.findOneById.withArgs('businessHourId').resolves(null); + const room = { + closedAt: undefined, + departmentId: 'departmentId', + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(models.LivechatDepartment.findOneById.calledWith(room.departmentId)).to.be.true; + expect(result).to.be.equal(10); + }); + it('should return the seconds since last agent message when department has a valid business hour but business hour doest have work hours', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + models.LivechatDepartment.findOneById.withArgs('departmentId').resolves({ + businessHourId: 'businessHourId', + }); + models.LivechatBusinessHours.findOneById.withArgs('businessHourId').resolves({ + workHours: [], + }); + businessHourManagerMock.getBusinessHour.withArgs('businessHourId').resolves(null); + const room = { + closedAt: undefined, + departmentId: 'departmentId', + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(10); + }); + it('should return the seconds since last agent message when department has a valid business hour but business hour workhours is empty', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + models.LivechatDepartment.findOneById.withArgs('departmentId').resolves({ + businessHourId: 'businessHourId', + }); + models.LivechatBusinessHours.findOneById.withArgs('businessHourId').resolves({ + workHours: [], + }); + businessHourManagerMock.getBusinessHour.withArgs('businessHourId').resolves({ + workHours: [], + }); + const room = { + closedAt: undefined, + departmentId: 'departmentId', + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(10); + }); + it('should get the data from the default business hour when room has no department attached and return the seconds since last agent message when default bh has no workhours', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [], + }); + const room = { + closedAt: undefined, + }; + const agentLastMessage = { + ts: new Date(new Date().getTime() - 10000), + }; + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(models.LivechatDepartment.findOneById.called).to.be.false; + expect(models.LivechatBusinessHours.findOneById.called).to.be.false; + expect(businessHourManagerMock.getBusinessHour.called).to.be.true; + expect(businessHourManagerMock.getBusinessHour.getCall(0).args.length).to.be.equal(0); + expect(result).to.be.equal(10); + }); + it('should return the proper number of seconds the room was inactive considering business hours (inactive same day)', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-01T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + ], + }); + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(3600); + }); + it('should return the proper number of seconds the room was inactive considering business hours (inactive same day)', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-01T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '23:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(7200); + }); + it('should return 0 if a room happened to be inactive on a day outside of business hours', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-03T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-03T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(0); + }); + it('should return the proper number of seconds when a room was inactive for more than 1 day', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-03T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(7200); + }); + it('should return the proper number of seconds when a room was inactive for more than 1 day, and one of those days was a closed day', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-03T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: false, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(7200); + }); + it('should return the proper number of seconds when a room was inactive for more than 1 day and one of those days is not in configuration', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-03T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(7200); + }); + it('should return the proper number of seconds when a room has been inactive for more than a week', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-10T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Wednesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Wednesday', time: '11:00' } }, + open: true, + }, + { + day: 'Thursday', + start: { utc: { dayOfWeek: 'Thursday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Thursday', time: '11:00' } }, + open: false, + }, + { + day: 'Saturday', + start: { utc: { dayOfWeek: 'Friday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Friday', time: '11:00' } }, + open: true, + }, + { + day: 'Sunday', + start: { utc: { dayOfWeek: 'Saturday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Saturday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(28800); + }); + it('should return 0 when room was inactive in the same day but the configuration for bh on that day is invalid', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-01T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T00:00:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: undefined } }, + finish: { utc: { dayOfWeek: 'Monday', time: undefined } }, + open: true, + }, + { + day: 'Wednesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: false, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(0); + }); + it('should return the proper number of seconds when a room has been inactive for more than a day but the inactivity started after BH started', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-02T12:00:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T10:15:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(6300); + }); + it('should return the proper number of seconds when a room was inactive between a BH start and end', async () => { + settingsStub.withArgs('Livechat_enable_business_hours').returns(true); + const room = { + closedAt: new Date('2024-01-01T10:50:00Z'), + }; + const agentLastMessage = { + ts: new Date('2024-01-01T10:15:00Z'), + }; + businessHourManagerMock.getBusinessHour.resolves({ + workHours: [ + { + day: 'Monday', + start: { utc: { dayOfWeek: 'Monday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Monday', time: '11:00' } }, + open: true, + }, + { + day: 'Tuesday', + start: { utc: { dayOfWeek: 'Tuesday', time: '10:00' } }, + finish: { utc: { dayOfWeek: 'Tuesday', time: '11:00' } }, + open: true, + }, + ], + }); + + const result = await getSecondsSinceLastAgentResponse(room, agentLastMessage); + expect(result).to.be.equal(2100); + }); + }); + describe('onCloseRoom', () => { + beforeEach(() => { + models.Messages.findAgentLastMessageByVisitorLastMessageTs.reset(); + }); + it('should skip the hook if room is not an omnichannel room', async () => { + const param = { room: { t: 'd' } }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.called).to.be.false; + expect(r).to.be.equal(param); + }); + it('should skip if room was not closed by agent', async () => { + const param = { room: { t: 'l' }, closer: 'visitor' }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.called).to.be.false; + expect(r).to.be.equal(param); + }); + it('should skip if the last message on room was not from an agent', async () => { + const param = { room: { t: 'l' }, closer: 'user', lastMessage: { token: 'xxxx' } }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.called).to.be.false; + expect(r).to.be.equal(param); + }); + it('should skip if the last message is not on db', async () => { + models.Messages.findAgentLastMessageByVisitorLastMessageTs.resolves(null); + const param = { room: { _id: 'xyz', t: 'l', v: { lastMessageTs: new Date() }, closer: 'user', lastMessage: { msg: 'test' } } }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.calledWith('xyz', param.room.v.lastMessageTs)).to.be.true; + expect(r).to.be.equal(param); + }); + it('should skip if the visitor has not send any messages', async () => { + models.Messages.findAgentLastMessageByVisitorLastMessageTs.resolves({ ts: undefined }); + const param = { room: { _id: 'xyz', t: 'l', v: { token: 'xfasfdsa' }, closer: 'user', lastMessage: { msg: 'test' } } }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.called).to.be.false; + expect(r).to.be.equal(param); + }); + it('should set the visitor inactivity in seconds when all params are valid', async () => { + models.Messages.findAgentLastMessageByVisitorLastMessageTs.resolves({ ts: new Date('2024-01-01T10:15:00Z') }); + settingsStub.withArgs('Livechat_enable_business_hours').returns(false); + const param = { + room: { + _id: 'xyz', + t: 'l', + v: { lastMessageTs: new Date() }, + closedAt: new Date('2024-01-01T10:50:00Z'), + closer: 'user', + lastMessage: { msg: 'test' }, + }, + }; + const r = await onCloseRoom(param); + + expect(models.Messages.findAgentLastMessageByVisitorLastMessageTs.calledWith('xyz', param.room.v.lastMessageTs)).to.be.true; + expect(models.LivechatRooms.setVisitorInactivityInSecondsById.calledWith('xyz', 2100)).to.be.true; + expect(r).to.be.equal(param); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/hooks/omnichannel/sendToCRM.tests.ts b/apps/meteor/tests/unit/server/hooks/omnichannel/sendToCRM.tests.ts new file mode 100644 index 0000000..77da38a --- /dev/null +++ b/apps/meteor/tests/unit/server/hooks/omnichannel/sendToCRM.tests.ts @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const resultObj = { + result: true, +}; + +const { sendMessageType, isOmnichannelNavigationMessage, isOmnichannelClosingMessage, getAdditionalFieldsByType } = p + .noCallThru() + .load('../../../../../server/hooks/omnichannel/sendToCRM', { + '../../settings': { + settings: { + get() { + return resultObj.result; + }, + }, + }, + '../../lib/utils/functions/normalizeMessageFileUpload': { + normalizeMessageFileUpload: sinon.stub().returnsArg(0), + }, + '../../lib/omnichannel/webhooks': {}, + '../../lib/omnichannel/guests': { getLivechatRoomGuestInfo: sinon.stub() }, + }); + +describe('[OC] Send TO CRM', () => { + describe('isOmnichannelNavigationMessage', () => { + it('should return true if the message is a navigation message', () => { + const message = { t: 'livechat_navigation_history' } as any; + expect(isOmnichannelNavigationMessage(message)).to.be.true; + }); + + it('should return false if the message is not a navigation message', () => { + const message = { t: 'livechat-close' } as any; + expect(isOmnichannelNavigationMessage(message)).to.be.false; + }); + }); + + describe('isOmnichannelClosingMessage', () => { + it('should return true if the message is a closing message', () => { + const message = { t: 'livechat-close' } as any; + expect(isOmnichannelClosingMessage(message)).to.be.true; + }); + + it('should return false if the message is not a closing message', () => { + const message = { t: 'livechat_navigation_history' } as any; + expect(isOmnichannelClosingMessage(message)).to.be.false; + }); + }); + + describe('sendMessageType', () => { + it('should return true if the message type is a closing message', () => { + expect(sendMessageType('livechat-close')).to.be.true; + }); + + it('should return true if the message type is a navigation message and the settings are enabled', () => { + expect(sendMessageType('livechat_navigation_history')).to.be.true; + }); + + it('should return false if the message type is a navigation message and the settings are disabled', () => { + resultObj.result = false; + expect(sendMessageType('livechat_navigation_history')).to.be.false; + }); + + it('should return false if the message type is not a closing or navigation message', () => { + expect(sendMessageType('message')).to.be.false; + }); + }); + + describe('getAdditionalFieldsByType', () => { + it('should return the correct fields for the LivechatSessionStart type', () => { + const room = { departmentId: 'departmentId' } as any; + expect(getAdditionalFieldsByType('LivechatSessionStart', room)).to.deep.equal({ departmentId: 'departmentId' }); + }); + + it('should return the correct fields for the LivechatSessionQueued type', () => { + const room = { departmentId: 'departmentId' } as any; + expect(getAdditionalFieldsByType('LivechatSessionQueued', room)).to.deep.equal({ departmentId: 'departmentId' }); + }); + + it('should return the correct fields for the LivechatSession type', () => { + const room = { + departmentId: 'departmentId', + servedBy: 'servedBy', + closedAt: 'closedAt', + closedBy: 'closedBy', + closer: 'closer', + } as any; + expect(getAdditionalFieldsByType('LivechatSession', room)).to.deep.equal({ + departmentId: 'departmentId', + servedBy: 'servedBy', + closedAt: 'closedAt', + closedBy: 'closedBy', + closer: 'closer', + }); + }); + + it('should return the correct fields for the LivechatSessionTaken type', () => { + const room = { departmentId: 'departmentId', servedBy: 'servedBy' } as any; + expect(getAdditionalFieldsByType('LivechatSessionTaken', room)).to.deep.equal({ departmentId: 'departmentId', servedBy: 'servedBy' }); + }); + + it('should return the correct fields for the LivechatSessionForwarded type', () => { + const room = { + departmentId: 'departmentId', + servedBy: 'servedBy', + oldDepartmentId: 'oldDepartmentId', + oldServedBy: 'oldServedBy', + } as any; + expect(getAdditionalFieldsByType('LivechatSessionForwarded', room)).to.deep.equal({ + departmentId: 'departmentId', + servedBy: 'servedBy', + oldDepartmentId: 'oldDepartmentId', + oldServedBy: 'oldServedBy', + }); + }); + + it('should return an empty object for an unknown type', () => { + const room = {} as any; + expect(getAdditionalFieldsByType('unknownType' as any, room)).to.deep.equal({}); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/OutboundMessageProvider.spec.ts b/apps/meteor/tests/unit/server/lib/OutboundMessageProvider.spec.ts new file mode 100644 index 0000000..cc2918d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/OutboundMessageProvider.spec.ts @@ -0,0 +1,141 @@ +import type { + IOutboundEmailMessageProvider, + IOutboundPhoneMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import sinon from 'sinon'; + +import { OutboundMessageProvider } from '../../../../server/lib/OutboundMessageProvider'; + +describe('OutboundMessageProvider', () => { + let outboundMessageProvider: OutboundMessageProvider; + + beforeEach(() => { + outboundMessageProvider = new OutboundMessageProvider(); + }); + + it('should successfully register a phone provider', () => { + const phoneProvider: IOutboundPhoneMessageProvider = { + type: 'phone', + appId: '123', + name: 'Test Phone Provider', + sendOutboundMessage: sinon.stub(), + getProviderMetadata: sinon.stub(), + }; + + outboundMessageProvider.registerPhoneProvider(phoneProvider); + + const providers = outboundMessageProvider.getOutboundMessageProviders('phone'); + + expect(providers).to.have.lengthOf(1); + expect(providers[0]).to.deep.equal({ + providerId: '123', + providerName: 'Test Phone Provider', + providerType: 'phone', + }); + }); + + it('should successfully register a email provider', () => { + const emailProvider: IOutboundEmailMessageProvider = { + type: 'email', + appId: '123', + name: 'Test Email Provider', + sendOutboundMessage: sinon.stub(), + }; + + outboundMessageProvider.registerEmailProvider(emailProvider); + + const providers = outboundMessageProvider.getOutboundMessageProviders('email'); + + expect(providers).to.have.lengthOf(1); + expect(providers[0]).to.deep.equal({ + providerId: '123', + providerName: 'Test Email Provider', + providerType: 'email', + }); + }); + + it('should list currently registered providers [unfiltered]', () => { + const phoneProvider: IOutboundPhoneMessageProvider = { + type: 'phone', + appId: '123', + name: 'Test Phone Provider', + sendOutboundMessage: sinon.stub(), + getProviderMetadata: sinon.stub(), + }; + + const emailProvider: IOutboundEmailMessageProvider = { + type: 'email', + appId: '123', + name: 'Test Email Provider', + sendOutboundMessage: sinon.stub(), + }; + + outboundMessageProvider.registerPhoneProvider(phoneProvider); + outboundMessageProvider.registerEmailProvider(emailProvider); + + const providers = outboundMessageProvider.getOutboundMessageProviders(); + + expect(providers).to.have.lengthOf(2); + expect(providers.some((provider) => provider.providerType === 'phone')).to.be.true; + expect(providers.some((provider) => provider.providerType === 'email')).to.be.true; + }); + + it('should list currently registered providers [filtered by type]', () => { + const phoneProvider: IOutboundPhoneMessageProvider = { + type: 'phone', + appId: '123', + name: 'Test Phone Provider', + sendOutboundMessage: sinon.stub(), + getProviderMetadata: sinon.stub(), + }; + + const emailProvider: IOutboundEmailMessageProvider = { + type: 'email', + appId: '123', + name: 'Test Email Provider', + sendOutboundMessage: sinon.stub(), + }; + + outboundMessageProvider.registerPhoneProvider(phoneProvider); + outboundMessageProvider.registerEmailProvider(emailProvider); + + const providers = outboundMessageProvider.getOutboundMessageProviders('phone'); + + expect(providers).to.have.lengthOf(1); + expect(providers[0].providerType).to.equal('phone'); + }); + + it('should unregister a provider', () => { + const phoneProvider: IOutboundPhoneMessageProvider = { + type: 'phone', + appId: '123', + name: 'Test Phone Provider', + sendOutboundMessage: sinon.stub(), + getProviderMetadata: sinon.stub(), + }; + + const phoneProvider2: IOutboundPhoneMessageProvider = { + type: 'phone', + appId: '456', + name: 'Test Phone Provider 2', + sendOutboundMessage: sinon.stub(), + getProviderMetadata: sinon.stub(), + }; + + outboundMessageProvider.registerPhoneProvider(phoneProvider); + outboundMessageProvider.registerPhoneProvider(phoneProvider2); + + let registeredProviders = outboundMessageProvider.getOutboundMessageProviders('phone'); + + expect(registeredProviders).to.have.lengthOf(2); + + outboundMessageProvider.unregisterProvider('123', 'phone'); + + registeredProviders = outboundMessageProvider.getOutboundMessageProviders('phone'); + + expect(registeredProviders).to.have.lengthOf(1); + expect(registeredProviders.some((provider) => provider.providerId !== '123')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/auth-providers/custom-oauth/transform_helpers.tests.js b/apps/meteor/tests/unit/server/lib/auth-providers/custom-oauth/transform_helpers.tests.js new file mode 100644 index 0000000..ac6fa44 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/auth-providers/custom-oauth/transform_helpers.tests.js @@ -0,0 +1,121 @@ +import { expect } from 'chai'; + +import { + normalizers, + fromTemplate, + renameInvalidProperties, + getNestedValue, + getRegexpMatch, +} from '../../../../../../server/lib/auth-providers/custom-oauth/transform_helpers'; + +const data = { + 'id': '123456', + 'invalid.property': true, + 'name': 'foo', + 'email': 'foo@bar.com', + 'nested': { + 'value': 'baz', + 'another.invalid.prop': true, + }, + 'list': [ + { + 'invalid.property': 'in-array', + }, + ], +}; + +describe('fromTemplate', () => { + const normalizedData = Object.values(normalizers).reduce((normalizedData, normalizer) => { + const result = { ...normalizedData }; + normalizer({ ...result }); + + return result; + }, data); + + it('returns match from regexp on top-level properties', () => { + const template = '{{/^foo@bar.(.+)/::email}}'; + const expected = 'com'; + const result = fromTemplate(template, normalizedData); + expect(result).to.equal(expected); + }); + + it('returns match from regexp on nested properties', () => { + const template = '{{/^ba(.+)/::nested.value}}'; + const expected = 'z'; + const result = fromTemplate(template, normalizedData); + expect(result).to.equal(expected); + }); + + it('returns value from nested prop with plain syntax', () => { + const template = 'nested.value'; + const expected = normalizedData.nested.value; + const result = fromTemplate(template, normalizedData); + expect(result).to.equal(expected); + }); + + it('returns value from nested prop with template syntax', () => { + const template = '{{nested.value}}'; + const expected = normalizedData.nested.value; + const result = fromTemplate(template, normalizedData); + expect(result).to.equal(expected); + }); + + it('returns composed value from nested prop with template syntax', () => { + const template = '{{name}}.{{nested.value}}'; + const expected = `${normalizedData.name}.${normalizedData.nested.value}`; + const result = fromTemplate(template, normalizedData); + + expect(result).to.equal(expected); + }); + + it('returns composed string from multiple template chunks with static parts', () => { + const template = 'composed-{{name}}-at-{{nested.value}}-dot-{{/^foo@bar.(.+)/::email}}-from-template'; + const expected = 'composed-foo-at-baz-dot-com-from-template'; + const result = fromTemplate(template, normalizedData); + expect(result).to.equal(expected); + }); +}); + +describe('getRegexpMatch', () => { + it('returns nested value when formula is not in the regex::field form', () => { + const formula = 'nested.value'; + expect(getRegexpMatch(formula, data)).to.equal(data.nested.value); + }); + + it("returns undefined when regex doesn't match", () => { + const formula = '/^foo@baz(.+)/::email'; + expect(getRegexpMatch(formula, data)).to.be.undefined; + }); + + it("throws when regex isn't valid", () => { + const formula = '/+/::email'; + expect(() => getRegexpMatch(formula, data)).to.throw(); + }); +}); + +describe('renameInvalidProperties', () => { + it('replaces . chars in field names with _', () => { + const result = renameInvalidProperties(data); + + expect(result['invalid.property']).to.be.undefined; + expect(result.invalid_property).to.equal(data['invalid.property']); + + expect(result.nested['invalid.property']).to.be.undefined; + expect(result.nested.invalid_property).to.equal(data.nested['invalid.property']); + + result.list.forEach((item, idx) => { + expect(item['invalid.property']).to.be.undefined; + expect(item.invalid_property).to.equal(data.list[idx]['invalid.property']); + }); + }); +}); + +describe('getNestedValue', () => { + it("returns undefined when nested value doesn't exist", () => { + expect(getNestedValue('nested.does.not.exist', data)).to.be.undefined; + }); + + it('returns nested object property', () => { + expect(getNestedValue('nested.value', data)).to.equal(data.nested.value); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/autotranslate/autotranslate.spec.ts b/apps/meteor/tests/unit/server/lib/autotranslate/autotranslate.spec.ts new file mode 100644 index 0000000..ac4d70b --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/autotranslate/autotranslate.spec.ts @@ -0,0 +1,82 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('TranslationProviderRegistry', () => { + let TranslationProviderRegistry: any; + let addStub: sinon.SinonStub; + let removeStub: sinon.SinonStub; + + const message = { _id: 'msg1', rid: 'room1', msg: 'hello' } as IMessage; + const room = { _id: 'room1' } as IRoom; + + const makeProvider = (name: string) => ({ + _getProviderMetadata: () => ({ name }), + translateMessage: sinon.stub().resolves({ ...message, translations: { [name]: 'translated' } }), + }); + + const getRegisteredCallback = () => addStub.firstCall.args[1]; + + beforeEach(() => { + addStub = sinon.stub(); + removeStub = sinon.stub(); + + ({ TranslationProviderRegistry } = proxyquire + .noCallThru() + .noPreserveCache() + .load('../../../../../server/lib/autotranslate/autotranslate', { + 'meteor/meteor': { Meteor: { startup: sinon.stub() } }, + '@rocket.chat/models': { Messages: {}, Subscriptions: {} }, + '../../settings': { settings: { watch: sinon.stub(), get: sinon.stub() } }, + '../callbacks': { callbacks: { add: addStub, remove: removeStub, priority: { MEDIUM: 2 } } }, + '../messaging/markdown': { Markdown: {} }, + '../notifyListener': { notifyOnMessageChange: sinon.stub() }, + })); + }); + + it('should remove the afterSaveMessage callback and not register a new one when disabled', () => { + TranslationProviderRegistry.setEnable(false); + + expect(removeStub.calledOnceWith('afterSaveMessage', 'autotranslate')).to.be.true; + expect(addStub.called).to.be.false; + }); + + it('should register the afterSaveMessage callback when enabled', () => { + TranslationProviderRegistry.setEnable(true); + + expect(addStub.calledOnce).to.be.true; + expect(addStub.firstCall.args[0]).to.equal('afterSaveMessage'); + expect(addStub.firstCall.args[3]).to.equal('autotranslate'); + }); + + it('should translate with the newly selected provider after switching providers while enabled', async () => { + const providerA = makeProvider('a'); + const providerB = makeProvider('b'); + TranslationProviderRegistry.registerProvider(providerA); + TranslationProviderRegistry.registerProvider(providerB); + + TranslationProviderRegistry.setCurrentProvider('a'); + TranslationProviderRegistry.setEnable(true); + + const callback = getRegisteredCallback(); + + await callback(message, { room }); + expect(providerA.translateMessage.calledOnce).to.be.true; + + TranslationProviderRegistry.setCurrentProvider('b'); + + await callback(message, { room }); + expect(providerB.translateMessage.calledOnce).to.be.true; + expect(providerA.translateMessage.calledOnce).to.be.true; + }); + + it('should resolve null when no provider is active, letting the callback chain keep the original message', async () => { + TranslationProviderRegistry.setCurrentProvider('missing'); + TranslationProviderRegistry.setEnable(true); + + const result = await getRegisteredCallback()(message, { room }); + + expect(result).to.be.null; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/banUserFromRoom.spec.ts b/apps/meteor/tests/unit/server/lib/banUserFromRoom.spec.ts new file mode 100644 index 0000000..1058b9a --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/banUserFromRoom.spec.ts @@ -0,0 +1,212 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const hasPermissionAsyncMock = sinon.stub(); +const hasRoleAsyncMock = sinon.stub(); +const banUserFromRoomMock = sinon.stub(); +const canAccessRoomAsyncMock = sinon.stub(); + +const modelsMock = { + Rooms: { + findOneById: sinon.stub(), + }, + Subscriptions: { + findOneByRoomIdAndUserId: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + findOneByUsernameIgnoringCase: sinon.stub(), + }, + Roles: { + countUsersInRole: sinon.stub(), + }, +}; + +const roomCoordinatorMock = { + getRoomDirectives: sinon.stub(), +}; + +const RoomMemberActions = { + BAN: 'ban', +}; + +const { banUserFromRoomMethod } = p.noCallThru().load('../../../../server/lib/banUserFromRoom.ts', { + '@rocket.chat/core-typings': { + isBannedSubscription: (sub: { status?: string } | null) => sub?.status === 'BANNED', + }, + '@rocket.chat/models': modelsMock, + './authorization': { canAccessRoomAsync: canAccessRoomAsyncMock }, + './authorization/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock }, + './authorization/hasRole': { hasRoleAsync: hasRoleAsyncMock }, + './rooms/banUserFromRoom': { banUserFromRoom: banUserFromRoomMock }, + '../../definition/IRoomTypeConfig': { RoomMemberActions }, + './rooms/roomCoordinator': { roomCoordinator: roomCoordinatorMock }, +}); + +describe('banUserFromRoomMethod', () => { + beforeEach(() => { + hasPermissionAsyncMock.reset(); + hasRoleAsyncMock.reset(); + banUserFromRoomMock.reset(); + canAccessRoomAsyncMock.reset(); + modelsMock.Rooms.findOneById.reset(); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.reset(); + modelsMock.Users.findOneById.reset(); + modelsMock.Users.findOneByUsernameIgnoringCase.reset(); + modelsMock.Roles.countUsersInRole.reset(); + roomCoordinatorMock.getRoomDirectives.reset(); + }); + + it('should throw if user does not have ban-user permission', async () => { + hasPermissionAsyncMock.resolves(false); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed'); + }); + + it('should throw if room does not exist', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves(null); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed'); + }); + + it('should throw if BAN action is not allowed for the room type', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(false), + }); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Not allowed'); + }); + + it('should throw if fromUser does not exist', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves(null); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('Invalid user'); + }); + + it('should throw if fromUser cannot access the room', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(false); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith( + 'The required "roomId" or "roomName" param provided does not match any group', + ); + }); + + it('should throw if the user to ban does not exist', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves(null); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('User not found'); + }); + + it('should throw if the user to ban is not in the room', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' }); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(null); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith( + 'User is not in this room', + ); + }); + + it('should throw if user is already banned', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' }); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1', status: 'BANNED' }); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith( + 'User is already banned from this room', + ); + }); + + it('should throw if user is the last owner', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' }); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + hasRoleAsyncMock.resolves(true); + modelsMock.Roles.countUsersInRole.resolves(1); + + await expect(banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' })).to.be.rejectedWith('You are the last owner'); + }); + + it('should allow banning if user is owner but not the last one', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' }); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + hasRoleAsyncMock.resolves(true); + modelsMock.Roles.countUsersInRole.resolves(2); + banUserFromRoomMock.resolves(); + + const result = await banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' }); + + expect(result).to.be.true; + expect(banUserFromRoomMock.calledOnce).to.be.true; + }); + + it('should successfully ban a user and return true', async () => { + hasPermissionAsyncMock.resolves(true); + modelsMock.Rooms.findOneById.resolves({ _id: 'room1', t: 'c' }); + roomCoordinatorMock.getRoomDirectives.returns({ + allowMemberAction: sinon.stub().resolves(true), + }); + modelsMock.Users.findOneById.resolves({ _id: 'fromUserId', username: 'admin' }); + canAccessRoomAsyncMock.resolves(true); + modelsMock.Users.findOneByUsernameIgnoringCase.resolves({ _id: 'bannedUserId', username: 'testuser' }); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + hasRoleAsyncMock.resolves(false); + banUserFromRoomMock.resolves(); + + const result = await banUserFromRoomMethod('fromUserId', { rid: 'room1', username: 'testuser' }); + + expect(result).to.be.true; + expect(banUserFromRoomMock.calledOnce).to.be.true; + expect(banUserFromRoomMock.firstCall.args[0]).to.equal('room1'); + expect(banUserFromRoomMock.firstCall.args[1]).to.deep.include({ _id: 'bannedUserId', username: 'testuser' }); + expect(banUserFromRoomMock.firstCall.args[2]).to.deep.include({ _id: 'fromUserId', username: 'admin' }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts new file mode 100644 index 0000000..644bc27 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const getOldestStub = sinon.stub(); +const fetchStub = sinon.stub(); +const buildRegistrationDataStub = sinon.stub(); + +const { registerPreIntentWorkspaceWizard } = proxyquire + .noCallThru() + .load('../../../../../server/lib/cloud/registerPreIntentWorkspaceWizard.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { getOldest: getOldestStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './buildRegistrationData': { buildWorkspaceRegistrationData: buildRegistrationDataStub }, + '../../settings': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, + }); + +describe('registerPreIntentWorkspaceWizard', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + getOldestStub.reset(); + fetchStub.reset(); + buildRegistrationDataStub.reset(); + }); + + it('should return false without querying users or fetching when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(getOldestStub.called).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should not fetch when an offline license is applied while registration data is being built', async () => { + // entry check passes, dispatch-time re-check catches the license change mid-flight + hasOfflineLicenseStub.onFirstCall().returns(false); + hasOfflineLicenseStub.onSecondCall().returns(true); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should contact the cloud when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + fetchStub.resolves({ ok: true }); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.true; + expect(fetchStub.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/syncWorkspace/syncCloudData.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/syncWorkspace/syncCloudData.spec.ts new file mode 100644 index 0000000..3d2144a --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/syncWorkspace/syncCloudData.spec.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const models = { + Settings: { updateValueById: sinon.stub() }, +}; + +const mockedFetchWorkspaceSyncPayload = sinon.stub(); + +const { syncCloudData } = proxyquire.noCallThru().load('../../../../../../server/lib/cloud/syncWorkspace/syncCloudData.ts', { + '@rocket.chat/license': { DuplicatedLicenseError: sinon.stub() }, + '@rocket.chat/models': models, + '../../callbacks': { callbacks: { run: sinon.stub() } }, + '../../../../lib/errors/CloudWorkspaceAccessError': { CloudWorkspaceAccessError: sinon.stub() }, + '../../../../lib/errors/CloudWorkspaceRegistrationError': { CloudWorkspaceRegistrationError: sinon.stub() }, + '../../logger/system': { SystemLogger: { info: sinon.stub(), error: sinon.stub() } }, + '../buildRegistrationData': { buildWorkspaceRegistrationData: sinon.stub().resolves({}) }, + '../getWorkspaceAccessToken': { + getWorkspaceAccessToken: sinon.stub().resolves('token'), + CloudWorkspaceAccessTokenEmptyError: sinon.stub(), + }, + '../retrieveRegistrationStatus': { retrieveRegistrationStatus: sinon.stub().resolves({ workspaceRegistered: true }) }, + './fetchWorkspaceSyncPayload': { fetchWorkspaceSyncPayload: mockedFetchWorkspaceSyncPayload }, +}); + +describe('SyncCloudData', () => { + beforeEach(() => { + models.Settings.updateValueById.reset(); + mockedFetchWorkspaceSyncPayload.reset(); + }); + + it('should save cloudSyncAnnouncement payload on Cloud_Sync_Announcement_Payload setting when present', async () => { + const workspaceSyncPayloadResponse = { + workspaceId: 'workspaceId', + publicKey: 'publicKey', + license: {}, + removeLicense: false, + cloudSyncAnnouncement: { + viewId: 'subscription-announcement', + appId: 'cloud-announcements-core', + blocks: [ + { + type: 'callout', + title: { + type: 'plain_text', + text: 'Workspace eligible for Starter Plan', + }, + text: { + type: 'plain_text', + text: 'Get free access to premium capabilities for up to 50 users', + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Switch Plan', + }, + actionId: 'callout-action', + appId: 'cloud-announcements-core', + blockId: 'section-button', + }, + }, + ], + }, + }; + + mockedFetchWorkspaceSyncPayload.resolves(workspaceSyncPayloadResponse); + + await syncCloudData(); + + expect(mockedFetchWorkspaceSyncPayload.calledOnce).to.be.true; + + expect( + models.Settings.updateValueById.calledOnceWith( + 'Cloud_Sync_Announcement_Payload', + JSON.stringify(workspaceSyncPayloadResponse.cloudSyncAnnouncement), + ), + ).to.be.true; + }); + + it("Should save as 'null' the setting update if cloudSyncAnnouncement is not present", async () => { + const workspaceSyncPayloadResponse = { + workspaceId: 'workspaceId', + publicKey: 'publicKey', + license: {}, + removeLicense: false, + }; + + mockedFetchWorkspaceSyncPayload.resolves(workspaceSyncPayloadResponse); + + await syncCloudData(); + + expect(mockedFetchWorkspaceSyncPayload.calledOnce).to.be.true; + + expect(models.Settings.updateValueById.calledOnce).to.be.true; + + expect(models.Settings.updateValueById.calledWith('Cloud_Sync_Announcement_Payload', 'null')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts new file mode 100644 index 0000000..e5cd54e --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const retrieveRegistrationStatusStub = sinon.stub(); +const userLoggedOutStub = sinon.stub(); +const fetchStub = sinon.stub(); +const findOneByIdStub = sinon.stub(); +const settingsGetStub = sinon.stub(); + +const { userLogout } = proxyquire.noCallThru().load('../../../../../server/lib/cloud/userLogout.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { findOneById: findOneByIdStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub }, + './userLoggedOut': { userLoggedOut: userLoggedOutStub }, + '../../settings': { settings: { get: settingsGetStub } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +describe('userLogout', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + retrieveRegistrationStatusStub.reset(); + userLoggedOutStub.reset(); + fetchStub.reset(); + findOneByIdStub.reset(); + settingsGetStub.reset(); + }); + + it('should still destroy local cloud credentials on logout under an offline license, without calling the revocation endpoint', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(true); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(fetchStub.called).to.be.false; + expect(result).to.be.true; + }); + + it('should revoke the refresh token and destroy local cloud credentials when the license is not offline', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(false); + findOneByIdStub.resolves({ _id: 'user-id', services: { cloud: { refreshToken: 'refresh-token' } } }); + settingsGetStub.withArgs('Cloud_Workspace_Client_Id').returns('client-id'); + settingsGetStub.withArgs('Cloud_Workspace_Client_Secret').returns('client-secret'); + settingsGetStub.withArgs('Cloud_Url').returns('https://cloud.rocket.chat'); + fetchStub.resolves({}); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(fetchStub.calledOnce).to.be.true; + expect(fetchStub.firstCall.args[0]).to.equal('https://cloud.rocket.chat/api/oauth/revoke'); + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(result).to.be.true; + }); + + it('should do nothing when the workspace is not registered', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: false }); + + const result = await userLogout('user-id'); + + expect(result).to.equal(''); + expect(fetchStub.called).to.be.false; + expect(userLoggedOutStub.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts new file mode 100644 index 0000000..b89b8f5 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts @@ -0,0 +1,279 @@ +import { faker } from '@faker-js/faker'; +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { MessageData } from '../../../../../server/lib/dataExport/exportRoomMessagesToFile'; +import { exportMessagesMock } from '../../../app/apps/server/mocks/data/messages.data'; + +// Create stubs for dependencies +const stubs = { + findPaginatedMessages: sinon.stub(), + mkdir: sinon.stub(), + writeFile: sinon.stub(), + findPaginatedMessagesCursor: sinon.stub(), + findPaginatedMessagesTotal: sinon.stub(), + translateKey: sinon.stub(), + settings: sinon.stub(), +}; + +const { getMessageData, exportRoomMessages, exportMessageObject } = proxyquire + .noCallThru() + .load('../../../../../server/lib/dataExport/exportRoomMessagesToFile.ts', { + '@rocket.chat/models': { + Messages: { + findPaginated: stubs.findPaginatedMessages, + }, + }, + 'fs/promises': { + mkdir: stubs.mkdir, + writeFile: stubs.writeFile, + }, + '../i18n': { + i18n: { + t: stubs.translateKey, + }, + }, + '../../settings': { + settings: stubs.settings, + }, + }); + +describe('Export - exportMessageObject', () => { + let messagesData: MessageData[]; + const translationPlaceholder = 'translation-placeholder'; + before(() => { + stubs.translateKey.returns(translationPlaceholder); + messagesData = exportMessagesMock.map((message) => getMessageData(message, false)); + }); + + it('should only stringify message object when exporting message as json', async () => { + const result = await exportMessageObject('json', messagesData[3]); + + expect(result).to.be.a.string; + expect(result).to.equal(JSON.stringify(messagesData[3])); + }); + + it('should correctly add tags when exporting plain text message object as html', async () => { + const result = await exportMessageObject('html', messagesData[3]); + + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[3].username} (${new Date(messagesData[3].ts).toUTCString()}):
\n${messagesData[3].msg}\n

`, + ); + }); + + it('should correctly format system messages when exporting message object as html', async () => { + const result = await exportMessageObject('html', messagesData[0]); + + expect(messagesData[0].msg).to.equal(translationPlaceholder); + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[0].username} (${new Date(messagesData[0].ts).toUTCString()}):
\n${ + messagesData[0].msg + }\n

`, + ); + }); + + it('should correctly format non italic system messages when exporting message object as html', async () => { + const result = await exportMessageObject('html', messagesData[4]); + + expect(messagesData[4].msg).to.equal(translationPlaceholder); + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[4].username} (${new Date(messagesData[4].ts).toUTCString()}):
\n${messagesData[4].msg}\n

`, + ); + }); + + it('should correctly reference file when exporting a message object with an attachment as html', async () => { + const result = await exportMessageObject('html', messagesData[1], [exportMessagesMock[1].file]); + + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[1].username} (${new Date(messagesData[1].ts).toUTCString()}):
\n${ + messagesData[1].msg + }\n
${ + messagesData[1].attachments?.[0].title + }\n

`, + ); + }); + + it('should use fallback attachment description when no title is provided on message object export as html', async () => { + const result = await exportMessageObject('html', messagesData[2], [exportMessagesMock[2].file]); + + expect(stubs.translateKey.calledWith('Message_Attachments')).to.be.true; + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[2].username} (${new Date(messagesData[2].ts).toUTCString()}):
\n${ + exportMessagesMock[1].msg + }\n
${translationPlaceholder}\n

`, + ); + }); +}); + +describe('Export - exportMessageObject HTML escaping (XSS prevention)', () => { + const ts = new Date('2020-01-01T00:00:00.000Z'); + + it('should escape HTML in the message body when exporting as html', async () => { + const result = await exportMessageObject('html', { + msg: '', + username: 'attacker', + ts, + }); + + expect(result).to.contain('<img src=x onerror="alert(1)">'); + expect(result).to.not.contain(' { + const result = await exportMessageObject('html', { + msg: 'hello', + username: '', + ts, + }); + + expect(result).to.contain('<script>alert(1)</script>'); + expect(result).to.not.contain('"); + }); + it('should remove any trailing / from the SERVER_URL', () => { + (global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://localhost:3000/' }; + const file = addServerUrlToIndex(''); + expect(file).to.equal(""); + }); + it('should use the value from the global variable always', () => { + const file = addServerUrlToIndex(''); + expect(file).to.equal(""); + + (global as any).__meteor_runtime_config__ = { ROOT_URL: 'http://kodiak.rocket.chat/' }; + const file2 = addServerUrlToIndex(''); + expect(file2).to.equal(""); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/business-hour/BusinessHourManager.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/business-hour/BusinessHourManager.spec.ts new file mode 100644 index 0000000..b9419c3 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/business-hour/BusinessHourManager.spec.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const formatStub = sinon.stub().returns('00:00'); +const isSameStub = sinon.stub().returns(true); +const isDSTStub = sinon.stub().returns(true); + +const momentStub = sinon.stub().returns({ + utc: () => ({ + tz: () => ({ + format: formatStub, + isSame: isSameStub, + isDST: isDSTStub, + }), + }), +}); + +const findActiveBusinessHoursStub = sinon.stub().returns([]); +const LivechatBusinessHoursStub = { + findActiveBusinessHours: findActiveBusinessHoursStub, +}; +const saveBusinessHourStub = sinon.stub(); +const loggerStub = sinon.stub(); + +const { BusinessHourManager } = proxyquire.noCallThru().load('../../../../../../server/lib/omnichannel/business-hour/BusinessHourManager', { + '../../../settings': {}, + '../../callbacks': {}, + './AbstractBusinessHour': {}, + 'moment-timezone': momentStub, + '@rocket.chat/models': { + LivechatBusinessHours: LivechatBusinessHoursStub, + }, + '../logger': { + businessHourLogger: { + error: loggerStub, + }, + }, +}); + +const cronAddStub = sinon.stub(); +const cronRemoveStub = sinon.stub(); + +describe('[OC] BusinessHourManager', () => { + afterEach(() => sinon.restore()); + describe('hasDaylightSavingTimeChanged()', () => { + const manager = new BusinessHourManager({} as any); + + it('should return false if the provided timezone is equal to the current one (No changes in the timezone)', () => { + formatStub.returns('00:00'); + expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false; + }); + + it('should return false if the provided timezone is different to the current one, the current time is in DST but there is no difference between times (current, stored)', () => { + isDSTStub.returns(true); + formatStub.returns('01:00'); + isSameStub.returns(true); + expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false; + }); + + it('should return false if the provided timezone is different to the current one, the current time is NOT in DST but there is no difference between times (current, stored)', () => { + isDSTStub.returns(false); + formatStub.returns('01:00'); + isSameStub.returns(true); + expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.false; + }); + + it('should return true if the provided timezone is different to the current one, the current time is in DST and the current time is different than the stored one', () => { + isDSTStub.returns(true); + formatStub.returns('01:00'); + isSameStub.returns(false); + expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true; + }); + + it('should return true if the provided timezone is different to the current one, the current time is NOT in DST and the current time is different than the stored one', () => { + isDSTStub.returns(false); + formatStub.returns('01:00'); + isSameStub.returns(false); + expect(manager.hasDaylightSavingTimeChanged({ name: 'test', utc: '00:00' })).to.be.true; + }); + }); + + describe('startDaylightSavingTimeVerifier()', () => { + const cronStub = { + add: cronAddStub, + remove: cronRemoveStub, + }; + const manager = new BusinessHourManager(cronStub); + manager.registerBusinessHourBehavior({ + onStartBusinessHours: () => {}, + onDisableBusinessHours: () => {}, + }); + + afterEach(() => { + sinon.restore(); + loggerStub.resetHistory(); + }); + + it('should call the verifier when the manager starts', async () => { + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'setupCallbacks'); + sinon.stub(manager, 'cleanupDisabledDepartmentReferences'); + sinon.stub(manager, 'startDaylightSavingTimeVerifier'); + await manager.startManager(); + expect(manager.startDaylightSavingTimeVerifier.called).to.be.true; + }); + + it('should register the cron job for the DST verifier when the manager starts', async () => { + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'setupCallbacks'); + sinon.stub(manager, 'cleanupDisabledDepartmentReferences'); + sinon.stub(manager, 'startDaylightSavingTimeVerifier'); + await manager.startManager(); + expect(cronAddStub.called).to.be.true; + }); + + it('should remove the cron job for the DST verifier when the manager stops', async () => { + sinon.stub(manager, 'removeCronJobs'); + sinon.stub(manager, 'clearCronJobsCache'); + sinon.stub(manager, 'removeCallbacks'); + sinon.stub(manager, 'startDaylightSavingTimeVerifier'); + await manager.stopManager(); + expect(cronRemoveStub.called).to.be.true; + }); + + it('should remove and add back the cron job for the DST verifier when the manager restarts', async () => { + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'setupCallbacks'); + sinon.stub(manager, 'cleanupDisabledDepartmentReferences'); + sinon.stub(manager, 'startDaylightSavingTimeVerifier'); + sinon.stub(manager, 'removeCronJobs'); + sinon.stub(manager, 'clearCronJobsCache'); + sinon.stub(manager, 'removeCallbacks'); + await manager.restartManager(); + expect(cronRemoveStub.called).to.be.true; + expect(cronAddStub.called).to.be.true; + }); + + it('should NOT save business hours when there is no active business hours', async () => { + sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub }); + findActiveBusinessHoursStub.resolves([]); + await manager.startDaylightSavingTimeVerifier(); + expect(saveBusinessHourStub.called).to.be.false; + }); + + it('should NOT save business hours when there is no timezone with DST changes', async () => { + sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub }); + sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(false); + findActiveBusinessHoursStub.resolves([{ timezone: 'test' }]); + await manager.startDaylightSavingTimeVerifier(); + expect(saveBusinessHourStub.called).to.be.false; + }); + + it('should save business hours AND recreate the cron jobs for the work hours when there is a timezone with DST changes', async () => { + sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub }); + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true); + findActiveBusinessHoursStub.resolves([ + { timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] }, + ]); + await manager.startDaylightSavingTimeVerifier(); + expect( + saveBusinessHourStub.calledWith({ + timezone: { name: 'timezoneName' }, + timezoneName: 'timezoneName', + workHours: [{ start: 'startTime', finish: 'finishTime' }], + }), + ).to.be.true; + expect(manager.createCronJobsForWorkHours.called).to.be.true; + }); + + it('should log any error on the updating process', async () => { + sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub }); + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true); + findActiveBusinessHoursStub.resolves([ + { timezone: { name: 'timezoneName' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] }, + { timezone: { name: 'timezoneName2' }, workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }] }, + ]); + const error = new Error('update error'); + saveBusinessHourStub.onSecondCall().rejects(error); + await manager.startDaylightSavingTimeVerifier(); + expect(loggerStub.calledWith(sinon.match({ msg: 'Failed to update business hours with new timezone' }))).to.be.true; + expect(manager.createCronJobsForWorkHours.called).to.be.true; + }); + + it('should NOT throw any error even if the business hour has an invalid type', async () => { + sinon.stub(manager, 'getBusinessHourType').returns({ saveBusinessHour: saveBusinessHourStub }); + sinon.stub(manager, 'createCronJobsForWorkHours'); + sinon.stub(manager, 'hasDaylightSavingTimeChanged').returns(true); + findActiveBusinessHoursStub.resolves([ + { + type: 'invalid', + timezone: { name: 'timezoneName' }, + workHours: [{ start: { time: 'startTime' }, finish: { time: 'finishTime' } }], + }, + ]); + await expect(manager.startDaylightSavingTimeVerifier()).not.to.be.rejected; + expect(loggerStub.called).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/closeLivechatRoom.tests.ts b/apps/meteor/tests/unit/server/lib/omnichannel/closeLivechatRoom.tests.ts new file mode 100644 index 0000000..9b8a174 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/closeLivechatRoom.tests.ts @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../mocks/data'; + +const subscriptionsStub = { + findOneByRoomIdAndUserId: sinon.stub(), + removeByRoomId: sinon.stub(), + countByRoomId: sinon.stub(), +}; + +const livechatRoomsStub = { + findOneById: sinon.stub(), +}; + +const livechatStub = { + closeRoom: sinon.stub(), +}; + +const hasPermissionStub = sinon.stub(); + +const { closeLivechatRoom } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/closeLivechatRoom.ts', { + './closeRoom': livechatStub, + '../authorization/hasPermission': { + hasPermissionAsync: hasPermissionStub, + }, + '@rocket.chat/models': { + Subscriptions: subscriptionsStub, + LivechatRooms: livechatRoomsStub, + }, +}); + +describe('closeLivechatRoom', () => { + const user = createFakeUser(); + const room = createFakeRoom({ t: 'l', open: true }); + const subscription = createFakeSubscription({ rid: room._id, u: { username: user.username, _id: user._id } }); + + beforeEach(() => { + subscriptionsStub.findOneByRoomIdAndUserId.reset(); + subscriptionsStub.removeByRoomId.reset(); + subscriptionsStub.countByRoomId.reset(); + livechatRoomsStub.findOneById.reset(); + livechatStub.closeRoom.reset(); + hasPermissionStub.reset(); + }); + + it('should not perform any operation when an invalid room id is provided', async () => { + livechatRoomsStub.findOneById.resolves(null); + hasPermissionStub.resolves(true); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-invalid-room'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should not perform any operation when the caller is not subscribed to an open room and does not have the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + hasPermissionStub.resolves(false); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-not-authorized'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should close the room when the caller is not subscribed to it but has the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + hasPermissionStub.resolves(true); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should close the room when the caller is subscribed to it and does not have the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + hasPermissionStub.resolves(false); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should call Livechat.closeRoom directly when forceClose is true even if room is in invalid state', async () => { + // Here we're using `open: false` as a way to simulate an invalid state. This should not happen in production. A room like this is effectively a "bad egg" + livechatRoomsStub.findOneById.resolves({ ...room, open: false }); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + hasPermissionStub.resolves(true); + + await closeLivechatRoom(user, room._id, { forceClose: true }); + + expect( + livechatStub.closeRoom.calledOnceWith( + sinon.match({ room: { ...room, open: false }, user, options: sinon.match({ clientAction: true }), forceClose: true }), + ), + ).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + }); + + it('should throw an error if forceClose is false and room is already closed', async () => { + livechatRoomsStub.findOneById.resolves({ ...room, open: false }); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + hasPermissionStub.resolves(true); + + await expect(closeLivechatRoom(user, room._id, { forceClose: false })).to.be.rejectedWith('error-room-already-closed'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/conditionalLockAgent.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/conditionalLockAgent.spec.ts new file mode 100644 index 0000000..22f3e3e --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/conditionalLockAgent.spec.ts @@ -0,0 +1,100 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const mockUsers = { + acquireAgentLock: sinon.stub(), + releaseAgentLock: sinon.stub(), +}; + +const mockSettings = { + get: sinon.stub(), +}; + +const mockHasRoleAsync = sinon.stub(); + +const { conditionalLockAgent } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/conditionalLockAgent', { + '@rocket.chat/models': { Users: mockUsers }, + '../authorization/hasRole': { hasRoleAsync: mockHasRoleAsync }, + '../../settings': { settings: mockSettings }, +}); + +describe('conditionalLockAgent', () => { + beforeEach(() => { + mockUsers.acquireAgentLock.reset(); + mockUsers.releaseAgentLock.reset(); + mockSettings.get.reset(); + mockHasRoleAsync.reset(); + }); + + describe('when waiting_queue is enabled', () => { + beforeEach(() => { + mockSettings.get.withArgs('Livechat_waiting_queue').returns(true); + mockHasRoleAsync.resolves(false); + }); + + it('should return acquired: true when lock is successfully acquired', async () => { + mockUsers.acquireAgentLock.resolves(true); + + const result = await conditionalLockAgent('agent1'); + + expect(result.acquired).to.equal(true); + expect(result.required).to.equal(true); + expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true); + }); + + it('should return acquired: false when lock is already held by another process', async () => { + mockUsers.acquireAgentLock.resolves(false); + + const result = await conditionalLockAgent('agent1'); + + expect(result.acquired).to.equal(false); + expect(result.required).to.equal(true); + expect(mockUsers.acquireAgentLock.calledOnce).to.equal(true); + }); + + it('should call releaseAgentLock when unlock is called', async () => { + mockUsers.acquireAgentLock.resolves(true); + mockUsers.releaseAgentLock.resolves(true); + + const result = await conditionalLockAgent('agent1'); + await result.unlock(); + + expect(mockUsers.releaseAgentLock.calledOnce).to.equal(true); + expect(mockUsers.releaseAgentLock.firstCall.args[0]).to.equal('agent1'); + expect(mockUsers.releaseAgentLock.firstCall.args[1]).to.be.instanceOf(Date); + }); + + it('should skip lock acquisition for bot agents', async () => { + mockHasRoleAsync.resolves(true); + + const result = await conditionalLockAgent('agent1'); + + expect(result.acquired).to.equal(false); + expect(result.required).to.equal(false); + expect(mockUsers.acquireAgentLock.called).to.equal(false); + }); + }); + + describe('when waiting_queue is disabled', () => { + beforeEach(() => { + mockSettings.get.withArgs('Livechat_waiting_queue').returns(false); + }); + + it('should return acquired: false and required: false without calling acquireAgentLock', async () => { + const result = await conditionalLockAgent('agent1'); + + expect(result.acquired).to.equal(false); + expect(result.required).to.equal(false); + expect(mockUsers.acquireAgentLock.called).to.equal(false); + expect(mockHasRoleAsync.called).to.equal(false); + }); + + it('should have a no-op unlock function', async () => { + const result = await conditionalLockAgent('agent1'); + await result.unlock(); // should not throw an error + + expect(mockUsers.releaseAgentLock.called).to.equal(false); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/custom-fields.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/custom-fields.spec.ts new file mode 100644 index 0000000..8bd8115 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/custom-fields.spec.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + updateById: sinon.stub(), + }, +}; + +const { updateContactsCustomFields } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/custom-fields.ts', { + '@rocket.chat/models': modelsMock, +}); + +describe('[Custom Fields] updateContactsCustomFields', () => { + beforeEach(() => { + modelsMock.LivechatContacts.updateById.reset(); + }); + + it('should do nothing if validCustomFields param is empty', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + await updateContactsCustomFields(contact, []); + expect(modelsMock.LivechatContacts.updateById.called).to.be.false; + }); + it('should add a custom field from the validCustomFields param', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + const validCustomFields = [{ key: 'field1', value: 'value1', overwrite: true }]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'value1' }, + }); + }); + it('should add multiple custom fields from the validCustomFields param', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + const validCustomFields = [ + { key: 'field1', value: 'value1', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'value1', 'customFields.field2': 'value2' }, + }); + }); + it('should add custom field to conflictingFields when the contact already has the field and overwrite is false', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [{ key: 'field1', value: 'newValue', overwrite: false }]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { conflictingFields: [{ field: 'customFields.field1', value: 'newValue' }] }, + }); + }); + it('should correctly add custom field and conflicting field from validCustomFields array', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: false }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field2': 'value2', 'conflictingFields': [{ field: 'customFields.field1', value: 'newValue' }] }, + }); + }); + it('should overwrite an existing field when field is on validCustomFields array & overwrite is true', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'newValue', 'customFields.field2': 'value2' }, + }); + }); + it('should update all custom fields from the validCustomFields array without issues', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + { key: 'field3', value: 'value3', overwrite: true }, + { key: 'field4', value: 'value4', overwrite: true }, + { key: 'field5', value: 'value5', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { + 'customFields.field1': 'newValue', + 'customFields.field2': 'value2', + 'customFields.field3': 'value3', + 'customFields.field4': 'value4', + 'customFields.field5': 'value5', + }, + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/disableContact.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/disableContact.spec.ts new file mode 100644 index 0000000..47e29dd --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/disableContact.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneEnabledById: sinon.stub(), + disableByContactId: sinon.stub(), + }, + LivechatRooms: { + checkContactOpenRooms: sinon.stub(), + }, +}; + +const settingsMock = { + get: sinon.stub(), +}; + +const removeGuestMock = { removeGuest: sinon.stub() }; + +const { disableContactById } = proxyquire.noCallThru().load('../../../../../server/lib/omnichannel/contacts/disableContact.ts', { + '@rocket.chat/models': modelsMock, + '../guests': removeGuestMock, + '../../../settings': { settings: settingsMock }, +}); + +describe('disableContact', () => { + const contact = { + _id: 'contact-id', + channels: [ + { + visitor: { + visitorId: 'visitor-id', + }, + }, + ], + }; + + beforeEach(() => { + modelsMock.LivechatContacts.findOneEnabledById.reset(); + modelsMock.LivechatRooms.checkContactOpenRooms.reset(); + modelsMock.LivechatContacts.disableByContactId.reset(); + settingsMock.get.reset(); + removeGuestMock.removeGuest.reset(); + }); + + it('should disable the contact', async () => { + settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true); + modelsMock.LivechatContacts.findOneEnabledById.resolves(contact); + modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null); + removeGuestMock.removeGuest.resolves(); + modelsMock.LivechatContacts.disableByContactId.resolves(); + + await disableContactById(contact._id); + + expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true; + expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true; + expect(removeGuestMock.removeGuest.calledOnceWith({ _id: 'visitor-id' })).to.be.true; + expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true; + }); + + it('should call removeGuest for each channel the contact has communicated from', async () => { + contact.channels.push({ visitor: { visitorId: 'visitor-id-2' } }); + + settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true); + modelsMock.LivechatContacts.findOneEnabledById.resolves(contact); + modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null); + removeGuestMock.removeGuest.resolves(); + modelsMock.LivechatContacts.disableByContactId.resolves(); + + await disableContactById(contact._id); + + expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true; + expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true; + expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true; + expect(removeGuestMock.removeGuest.calledTwice).to.be.true; + expect(removeGuestMock.removeGuest.getCall(0).args[0]).to.deep.equal({ _id: 'visitor-id' }); + expect(removeGuestMock.removeGuest.getCall(1).args[0]).to.deep.equal({ _id: 'visitor-id-2' }); + expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true; + }); + + it('should throw error if contact is not found', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves(null); + + await expect(disableContactById('nonexistent-contact-id')).to.be.rejectedWith('error-contact-not-found'); + + expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith('nonexistent-contact-id')).to.be.true; + expect(modelsMock.LivechatRooms.checkContactOpenRooms.notCalled).to.be.true; + expect(removeGuestMock.removeGuest.notCalled).to.be.true; + expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true; + }); + + it('should throw error if contact has open rooms and GDPR is disabled', async () => { + settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(false); + modelsMock.LivechatContacts.findOneEnabledById.resolves(contact); + modelsMock.LivechatRooms.checkContactOpenRooms.resolves({ _id: 'room-id' }); + modelsMock.LivechatContacts.disableByContactId.resolves(); + + await expect(disableContactById(contact._id)).to.be.rejectedWith('error-contact-has-open-rooms'); + + expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true; + expect(removeGuestMock.removeGuest.notCalled).to.be.true; + expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/isMessageFromBot.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/isMessageFromBot.spec.ts new file mode 100644 index 0000000..383d1ea --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/isMessageFromBot.spec.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeMessage } from '../../../../mocks/data'; + +const modelsMock = { + Users: { + isUserInRole: sinon.stub(), + }, +}; + +const { isMessageFromBot } = p.noCallThru().load('../../../../../server/lib/omnichannel/isMessageFromBot', { + '@rocket.chat/models': modelsMock, +}); + +describe('isMessageFromBot', () => { + const mockMessage = createFakeMessage(); + + beforeEach(() => { + modelsMock.Users.isUserInRole.reset(); + }); + + it('Should return true if user has bot role', async () => { + modelsMock.Users.isUserInRole.resolves(true); + const result = await isMessageFromBot(mockMessage); + expect(result).to.be.true; + }); + + it('Should return false if user does not have bot role', async () => { + modelsMock.Users.isUserInRole.resolves(false); + const result = await isMessageFromBot(mockMessage); + expect(result).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/parseTranscriptRequest.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/parseTranscriptRequest.spec.ts new file mode 100644 index 0000000..b6368e1 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/parseTranscriptRequest.spec.ts @@ -0,0 +1,127 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + Users: { + findOneById: sinon.stub(), + }, + LivechatVisitors: { + findOneById: sinon.stub(), + }, +}; + +const settingsGetMock = { + get: sinon.stub(), +}; + +const { parseTranscriptRequest } = p.noCallThru().load('../../../../../server/lib/omnichannel/parseTranscriptRequest', { + '@rocket.chat/models': modelsMock, + '../../settings': { settings: settingsGetMock }, +}); + +describe('parseTranscriptRequest', () => { + beforeEach(() => { + settingsGetMock.get.reset(); + modelsMock.Users.findOneById.reset(); + modelsMock.LivechatVisitors.findOneById.reset(); + }); + + it('should return `options` param with no changes when Livechat_enable_transcript setting is true', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(true); + const options = await parseTranscriptRequest({} as any, {} as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with no changes when send always is disabled', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(false); + const options = await parseTranscriptRequest({} as any, {} as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with no changes when visitor is not provided and its not found on db', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.LivechatVisitors.findOneById.resolves(null); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with no changes when visitor is passed but no email is found', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.LivechatVisitors.findOneById.resolves({} as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, { _id: '123' } as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with no changes when visitor is fetched from db, but no email is found', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.LivechatVisitors.findOneById.resolves({} as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with no changes when no user is passed, room is not being served and rocketcat is not present', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.Users.findOneById.resolves(null); + modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any); + expect(options).to.be.deep.equal({}); + }); + + it('should return `options` param with `transcriptRequest` key attached when user is passed', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any, undefined, { _id: '123' } as any); + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0).firstArg).to.be.equal('123'); + expect(options).to.have.property('emailTranscript').that.is.an('object'); + expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat'); + expect(options.emailTranscript.requestData).to.have.property('subject', ''); + expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123' }); + }); + + it('should return `options` param with `transcriptRequest` key attached when no user is passed, but theres an agent serving the room', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.Users.findOneById.resolves({ _id: '123', username: 'kevsxxx', name: 'Kev' } as any); + modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' }, servedBy: { _id: '123' } } as any, {} as any); + + expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('123'); + expect(options).to.have.property('emailTranscript').that.is.an('object'); + expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat'); + expect(options.emailTranscript.requestData).to.have.property('subject', ''); + expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ _id: '123', username: 'kevsxxx', name: 'Kev' }); + }); + + it('should return `options` param with `transcriptRequest` key attached when no user is passed, no agent is serving but rocket.cat is present', async () => { + settingsGetMock.get.withArgs('Livechat_enable_transcript').returns(false); + settingsGetMock.get.withArgs('Livechat_transcript_send_always').returns(true); + modelsMock.Users.findOneById.resolves({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket Cat' } as any); + modelsMock.LivechatVisitors.findOneById.resolves({ visitorEmails: [{ address: 'abc@rocket.chat' }] } as any); + + const options = await parseTranscriptRequest({ v: { _id: '123' } } as any, {} as any); + + expect(modelsMock.Users.findOneById.getCall(0).firstArg).to.be.equal('rocket.cat'); + expect(options).to.have.property('emailTranscript').that.is.an('object'); + expect(options.emailTranscript.requestData).to.have.property('email', 'abc@rocket.chat'); + expect(options.emailTranscript.requestData).to.have.property('subject', ''); + expect(options.emailTranscript.requestData.requestedBy).to.be.deep.equal({ + _id: 'rocket.cat', + username: 'rocket.cat', + name: 'Rocket Cat', + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/sendTranscript.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/sendTranscript.spec.ts new file mode 100644 index 0000000..5087403 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/sendTranscript.spec.ts @@ -0,0 +1,234 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatRooms: { + findOneById: sinon.stub(), + }, + Messages: { + findLivechatClosingMessage: sinon.stub(), + findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, +}; + +const checkMock = sinon.stub(); + +const mockLogger = class { + debug() { + return null; + } + + error() { + return null; + } + + warn() { + return null; + } + + info() { + return null; + } +}; + +const mockSettingValues: Record = { + Livechat_show_agent_info: true, + Language: 'en', + From_Email: 'test@rocket.chat', +}; + +const settingsMock = function (key: string) { + return mockSettingValues[key] || null; +}; + +const getTimezoneMock = sinon.stub(); + +const mailerMock = sinon.stub(); + +const tStub = sinon.stub(); + +const { sendTranscript } = p.noCallThru().load('../../../../../server/lib/omnichannel/sendTranscript', { + // mocked so the spec doesn't depend on whichever broker a previously-run spec installed + '@rocket.chat/core-services': { + Message: { saveSystemMessage: sinon.stub().resolves() }, + Omnichannel: { isWithinMACLimit: sinon.stub().resolves(true) }, + }, + '@rocket.chat/models': modelsMock, + '@rocket.chat/logger': { Logger: mockLogger }, + 'meteor/meteor': { + Meteor: { + Error: globalThis.Error, + }, + }, + 'meteor/check': { check: checkMock }, + '../callbacks': { + callbacks: { + run: sinon.stub(), + }, + }, + '../i18n': { i18n: { t: tStub } }, + '../notifications/email/api': { send: mailerMock }, + '../../settings': { settings: { get: settingsMock } }, + '../utils/lib/getTimezone': { getTimezone: getTimezoneMock }, + // TODO: add tests for file handling on transcripts + '../media/file-upload': { FileUpload: {} }, +}); + +describe('Send transcript', () => { + beforeEach(() => { + checkMock.reset(); + modelsMock.LivechatRooms.findOneById.reset(); + modelsMock.Messages.findLivechatClosingMessage.reset(); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset(); + modelsMock.Users.findOneById.reset(); + mailerMock.reset(); + tStub.reset(); + }); + it('should throw error when rid or email are invalid params', async () => { + checkMock.throws(new Error('Invalid params')); + await expect(sendTranscript({})).to.be.rejectedWith(Error); + }); + it('should throw error when visitor not found', async () => { + await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); + }); + it('should attempt to send an email when params are valid using default subject', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]); + tStub.returns('Conversation Transcript'); + + await sendTranscript({ + rid: 'rid', + token: 'token', + email: 'email', + user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, + }); + + expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; + expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; + expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; + expect( + mailerMock.calledWith({ + to: 'email', + from: 'test@rocket.chat', + subject: 'Conversation Transcript', + replyTo: 'test@rocket.chat', + html: '

', + }), + ).to.be.true; + }); + it('should use provided subject', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]); + + await sendTranscript({ + rid: 'rid', + token: 'token', + email: 'email', + subject: 'A custom subject', + user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, + }); + + expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; + expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; + expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; + expect( + mailerMock.calledWith({ + to: 'email', + from: 'test@rocket.chat', + subject: 'A custom subject', + replyTo: 'test@rocket.chat', + html: '

', + }), + ).to.be.true; + }); + it('should use subject from setting (when configured) when no subject provided', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]); + mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get'; + + await sendTranscript({ + rid: 'rid', + token: 'token', + email: 'email', + user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, + }); + + expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; + expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; + expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; + expect( + mailerMock.calledWith({ + to: 'email', + from: 'test@rocket.chat', + subject: 'A custom subject obtained from setting.get', + replyTo: 'test@rocket.chat', + html: '

', + }), + ).to.be.true; + }); + it('should fail if room provided is invalid', async () => { + modelsMock.LivechatRooms.findOneById.resolves(null); + + await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); + }); + + it('should fail if room provided is of different type', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' }); + + await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); + }); + + it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' }); + + await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); + }); + + it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } }); + + await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); + }); + + it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } }); + + await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error); + }); + + it('should throw an error when token is not the one on room.v', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } }); + + await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error); + }); + it('should work when token matches room.v', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token-123' } }); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.returns([]); + delete mockSettingValues.Livechat_transcript_email_subject; + tStub.returns('Conversation Transcript'); + + await sendTranscript({ + rid: 'rid', + token: 'token-123', + email: 'email', + user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, + }); + + expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; + expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; + expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; + expect( + mailerMock.calledWith({ + to: 'email', + from: 'test@rocket.chat', + subject: 'Conversation Transcript', + replyTo: 'test@rocket.chat', + html: '

', + }), + ).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/validateRequiredCustomFields.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/validateRequiredCustomFields.spec.ts new file mode 100644 index 0000000..d80b7a6 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/validateRequiredCustomFields.spec.ts @@ -0,0 +1,64 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { validateRequiredCustomFields } from '../../../../../server/lib/omnichannel/custom-fields'; + +describe('validateRequiredCustomFields', () => { + it('should throw an error if the required custom fields are not provided', async () => { + const customFields = ['customField1']; + const livechatCustomFields: ILivechatCustomField[] = [ + { _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + { _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + ]; + + try { + validateRequiredCustomFields(customFields, livechatCustomFields); + throw new Error('Expected error was not thrown'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).to.equal('Missing required custom fields: customField2'); + } else { + throw error; + } + } + }); + + it('should not throw an error if all required custom fields are provided', async () => { + const customFields = ['customField1', 'customField2']; + const livechatCustomFields: ILivechatCustomField[] = [ + { _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + { _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + ]; + + validateRequiredCustomFields(customFields, livechatCustomFields); + }); + + it('should not throw an error if no custom fields are required', async () => { + const customFields = ['customField1']; + const livechatCustomFields: ILivechatCustomField[] = [ + { _id: 'customField1', required: false, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + { _id: 'customField2', required: false, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + ]; + + validateRequiredCustomFields(customFields, livechatCustomFields); + }); + + it('should throw an error if none of the required custom fields are provided', async () => { + const customFields: string[] = []; + const livechatCustomFields: ILivechatCustomField[] = [ + { _id: 'customField1', required: true, label: 'Custom Field 1', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + { _id: 'customField2', required: true, label: 'Custom Field 2', scope: 'visitor', visibility: 'public', _updatedAt: new Date() }, + ]; + + try { + validateRequiredCustomFields(customFields, livechatCustomFields); + throw new Error('Expected error was not thrown'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).to.equal('Missing required custom fields: customField1, customField2'); + } else { + throw error; + } + } + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/omnichannel/webhooks.spec.ts b/apps/meteor/tests/unit/server/lib/omnichannel/webhooks.spec.ts new file mode 100644 index 0000000..6243b0d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/omnichannel/webhooks.spec.ts @@ -0,0 +1,293 @@ +import { expect } from 'chai'; +import { describe, it, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +type FetchResponse = { + status: number; + text: () => Promise; +}; + +const mkResponse = (status: number, text = ''): FetchResponse => ({ + status, + text: async () => text, +}); + +const MODULE_PATH = '../../../../../server/lib/omnichannel/webhooks'; + +function buildSubject(options?: { + fetchSequence?: Array<{ status: number; text?: string }>; + fetchOnce?: { status: number; text?: string }; + settings?: Partial>; +}) { + const defaults = { + Livechat_http_timeout: 1000, + Livechat_secret_token: 'super-secret', + Livechat_webhookUrl: 'https://example.test/hook', + }; + + const settingsValues = { ...defaults, ...(options?.settings ?? {}) }; + + const settings = { + get: sinon.stub().callsFake((key: string) => settingsValues[key as keyof typeof settingsValues]), + }; + + const fetchStub = sinon.stub(); + if (options?.fetchSequence && options.fetchSequence.length) { + options.fetchSequence.forEach((spec, i) => { + fetchStub.onCall(i).resolves(mkResponse(spec.status, spec.text)); + }); + } else if (options?.fetchOnce) { + fetchStub.resolves(mkResponse(options.fetchOnce.status, options.fetchOnce.text)); + } else { + fetchStub.resolves(mkResponse(200, 'ok')); + } + + const logger = { + debug: sinon.spy(), + error: sinon.spy(), + }; + + const metrics = { + totalLivechatWebhooksSuccess: { inc: sinon.spy() }, + totalLivechatWebhooksSuccessTotal: { inc: sinon.spy() }, + totalLivechatWebhooksFailures: { inc: sinon.spy() }, + totalLivechatWebhooksFailuresTotal: { inc: sinon.spy() }, + }; + + const { sendRequest } = proxyquire.noCallThru().load(MODULE_PATH, { + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './logger': { webhooksLogger: logger }, + '../metrics': { metrics }, + '../../settings': { settings }, + }); + + return { + sendRequest, + stubs: { fetchStub, logger, metrics, settings }, + values: settingsValues, + }; +} + +describe('livechat/server/lib/webhooks sendRequest', () => { + let clock: sinon.SinonFakeTimers; + + afterEach(() => { + sinon.restore(); + if (clock) { + clock.restore(); + } + }); + + it('sends a POST request with correct options and resolves on 200', async () => { + const postData = { type: 'TestEvent', a: 1 }; + const secret = 'my-secret'; + const timeout = 2500; + const webhookUrl = 'https://example.com/webhook'; + + const { sendRequest, stubs, values } = buildSubject({ + fetchOnce: { status: 200, text: 'OK' }, + settings: { + Livechat_secret_token: secret, + Livechat_http_timeout: timeout, + Livechat_webhookUrl: webhookUrl, + }, + }); + + const cb = sinon.spy(async () => { + /* noop */ + }); + + const result = await sendRequest(postData, 5, cb); + + // fetch was called with correct URL and options + expect(stubs.fetchStub.calledOnce).to.be.true; + const [calledUrl, calledOpts] = stubs.fetchStub.getCall(0).args; + expect(calledUrl).to.equal(values.Livechat_webhookUrl); + expect(calledOpts).to.deep.include({ + method: 'POST', + body: postData, + timeout: values.Livechat_http_timeout, + }); + expect(calledOpts.headers).to.have.property('X-RocketChat-Livechat-Token', secret); + + // success metrics and callback + expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true; + expect(stubs.metrics.totalLivechatWebhooksFailures.inc.notCalled).to.be.true; + expect(cb.calledOnce).to.be.true; + + // result is the fetch response + expect(result).to.be.ok; + expect(result!.status).to.equal(200); + + // debug logging invoked at least once + expect(stubs.logger.debug.called).to.be.true; + expect(stubs.logger.error.notCalled).to.be.true; + }); + + it('omits X-RocketChat-Livechat-Token header when secret token is falsy', async () => { + const { sendRequest, stubs } = buildSubject({ + fetchOnce: { status: 200, text: 'OK' }, + settings: { + Livechat_secret_token: '', + }, + }); + + await sendRequest({ type: 'NoSecret' }); + + const [, calledOpts] = stubs.fetchStub.getCall(0).args; + expect(calledOpts.headers).to.not.have.property('X-RocketChat-Livechat-Token'); + }); + + it('logs and does not retry on non-retryable status (e.g., 400)', async () => { + const { sendRequest, stubs } = buildSubject({ + fetchOnce: { status: 400, text: 'Bad Request' }, + }); + + const cb = sinon.spy(async () => { + /* noop */ + }); + + const result = await sendRequest({ type: 'NonRetryable' }, 5, cb); + + // Does not throw; returns undefined + expect(result).to.be.undefined; + + // fetch called only once, no retry + expect(stubs.fetchStub.calledOnce).to.be.true; + + // failure metric incremented + expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true; + expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true; + + // callback not called on failure + expect(cb.notCalled).to.be.true; + + // error log with status and response + expect(stubs.logger.error.called).to.be.true; + const logArg = stubs.logger.error.getCall(0).args[0]; + expect(logArg).to.include.keys(['msg', 'status', 'response']); + expect(logArg.status).to.equal(400); + expect(logArg.response).to.equal('Bad Request'); + }); + + it('retries once on retryable status (e.g., 500) and succeeds on next attempt', async () => { + clock = sinon.useFakeTimers(); + + const timeout = 1500; + const retryAfter = timeout * 4; + + const { sendRequest, stubs } = buildSubject({ + fetchSequence: [ + { status: 500, text: 'Server Error' }, + { status: 200, text: 'OK' }, + ], + settings: { Livechat_http_timeout: timeout }, + }); + + const cb = sinon.spy(async () => { + /* noop */ + }); + + // Call initial request; it will schedule a retry + await sendRequest({ type: 'RetryableOnce' }, 5, cb); + expect(stubs.fetchStub.callCount).to.equal(1); + expect(stubs.metrics.totalLivechatWebhooksFailures.inc.calledOnce).to.be.true; + + // Advance clock to trigger the retry + // use tickAsync to ensure promise microtasks inside the timer are flushed + await clock.tickAsync(retryAfter); + + // After retry, we should have a success + expect(stubs.fetchStub.callCount).to.equal(2); + expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true; + + // callback called once for the successful attempt + expect(cb.calledOnce).to.be.true; + + // no terminal error log + const errorMsgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg); + expect(errorMsgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.false; + }); + + it('stops after max attempts and logs final error for repeated retryable failures', async () => { + clock = sinon.useFakeTimers(); + + const timeout = 500; + const retryAfter = timeout * 4; + + // Prepare 5 retryable failures (equal to attempts) + const failures = Array.from({ length: 5 }, () => ({ status: 500, text: 'Upstream down' })); + const { sendRequest, stubs } = buildSubject({ + fetchSequence: failures, + settings: { Livechat_http_timeout: timeout }, + }); + + const postData = { type: 'AlwaysFail' }; + + // Fire the initial call + await sendRequest(postData, 5); + + // Process the 4 scheduled retries + for (let i = 0; i < 4; i++) { + await clock.tickAsync(retryAfter); + } + + // fetch was attempted 5 times total + expect(stubs.fetchStub.callCount).to.equal(5); + + // failure metric incremented for each failed attempt + expect(stubs.metrics.totalLivechatWebhooksFailures.inc.callCount).to.equal(5); + expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.notCalled).to.be.true; + + // terminal error logged when max attempts reached + expect(stubs.logger.error.called).to.be.true; + const msgs = stubs.logger.error.getCalls().map((c: sinon.SinonSpyCall) => c.args[0]?.msg); + expect(msgs.some((m: string) => typeof m === 'string' && m.includes('Max attempts'))).to.be.true; + }); + + it('passes the Response to the callback on success', async () => { + const responseText = 'Great Success'; + const { sendRequest, stubs } = buildSubject({ + fetchOnce: { status: 200, text: responseText }, + }); + + const cb = sinon.spy(async (res: FetchResponse) => { + const txt = await res.text(); + expect(txt).to.equal(responseText); + }); + + await sendRequest({ type: 'CbTest' }, 5, cb); + + expect(cb.calledOnce).to.be.true; + expect(stubs.metrics.totalLivechatWebhooksSuccess.inc.calledOnce).to.be.true; + }); + + it('uses default attempts=5 and schedules retry with delay = timeout*4', async () => { + clock = sinon.useFakeTimers(); + + const timeout = 1234; + const retryAfter = timeout * 4; + + const { sendRequest, stubs } = buildSubject({ + fetchSequence: [ + { status: 500, text: 'fail' }, + { status: 200, text: 'ok' }, + ], + settings: { Livechat_http_timeout: timeout }, + }); + + await sendRequest({ type: 'DelayCheck' }); + + // Before advancing time, only first attempt should have been made + expect(stubs.fetchStub.callCount).to.equal(1); + + // Advance just shy of the retryAfter - no retry should happen + await clock.tickAsync(retryAfter - 1); + expect(stubs.fetchStub.callCount).to.equal(1); + + // Advance the remaining 1ms - retry should fire + await clock.tickAsync(1); + expect(stubs.fetchStub.callCount).to.equal(2); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/parseMessageSearchQuery.spec.ts b/apps/meteor/tests/unit/server/lib/parseMessageSearchQuery.spec.ts new file mode 100644 index 0000000..dd445a7 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/parseMessageSearchQuery.spec.ts @@ -0,0 +1,208 @@ +import { expect } from 'chai'; + +import { parseMessageSearchQuery } from '../../../../server/lib/parseMessageSearchQuery'; +import { createFakeUser } from '../../../mocks/data'; + +describe('parseMessageSearchQuery', () => { + const params = { + user: createFakeUser(), + }; + + const utcOffset = new Date().getTimezoneOffset() / 60; + + [ + { + text: 'from:rodrigo mention:gabriel chat', + query: { + 'u.username': { $regex: 'rodrigo', $options: 'i' }, + 'mentions.username': { $regex: 'gabriel', $options: 'i' }, + '$text': { $search: 'chat' }, + }, + options: { + projection: { score: { $meta: 'textScore' } }, + sort: { ts: -1 }, + skip: 0, + limit: 20, + }, + }, + { + text: 'has:star', + query: { 'starred._id': params.user._id }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'has:url', + query: { 'urls.0': { $exists: true } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'has:link', + query: { 'urls.0': { $exists: true } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'is:pinned', + query: { pinned: true }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'has:pin', + query: { pinned: true }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'has:location', + query: { location: { $exists: true } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'has:map', + query: { location: { $exists: true } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'label:label', + query: { 'attachments.0.labels': { $regex: 'label', $options: 'i' } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'label:label1 label:label2', + query: { 'attachments.0.labels': { $regex: 'label2', $options: 'i' } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-title:title', + query: { 'attachments.title': { $regex: 'title', $options: 'i' } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:description', + query: { + 'attachments.description': { $regex: 'description', $options: 'i' }, + }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:"description"', + query: { + 'attachments.description': { $regex: 'description', $options: 'i' }, + }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-title:"monthly report"', + query: { + 'attachments.title': { $regex: 'monthly report', $options: 'i' }, + }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:отчет follow-up needed', + query: { + 'attachments.description': { $regex: 'отчет', $options: 'i' }, + '$text': { $search: 'follow-up needed' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-title:"🚀 launch plan" notes later', + query: { + 'attachments.title': { $regex: '🚀 launch plan', $options: 'i' }, + '$text': { $search: 'notes later' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'notes later file-title:"🚀 launch plan"', + query: { + 'attachments.title': { $regex: '🚀 launch plan', $options: 'i' }, + '$text': { $search: 'notes later' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:report2024 file-title:Q1-review pending', + query: { + 'attachments.description': { $regex: 'report2024', $options: 'i' }, + 'attachments.title': { $regex: 'Q1\\-review', $options: 'i' }, + '$text': { $search: 'pending' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:"中 文 测 试" file-title:"报 表" 其他内容', + query: { + 'attachments.description': { $regex: '中 文 测 试', $options: 'i' }, + 'attachments.title': { $regex: '报 表', $options: 'i' }, + '$text': { $search: '其他内容' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:отчет file-title:"финансовый план" завершено', + query: { + 'attachments.description': { $regex: 'отчет', $options: 'i' }, + 'attachments.title': { $regex: 'финансовый план', $options: 'i' }, + '$text': { $search: 'завершено' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-title:"🔥 final version" confirm now', + query: { + 'attachments.title': { $regex: '🔥 final version', $options: 'i' }, + '$text': { $search: 'confirm now' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'file-desc:"launch" from:username new plans', + query: { + 'attachments.description': { $regex: 'launch', $options: 'i' }, + 'u.username': { $regex: 'username', $options: 'i' }, + '$text': { $search: 'new plans' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'mention:someone file-desc:"budget 💰" file update', + query: { + 'attachments.description': { $regex: 'budget 💰', $options: 'i' }, + 'mentions.username': { $regex: 'someone', $options: 'i' }, + '$text': { $search: 'file update' }, + }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'before:01-01-2023', + query: { ts: { $lte: new Date(2023, 0, 1, utcOffset) } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'after:01-01-2023', + query: { ts: { $gte: new Date(2023, 0, 2, utcOffset) } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'on:01-01-2023', + query: { ts: { $gte: new Date(2023, 0, 1, utcOffset), $lt: new Date(2023, 0, 2, utcOffset) } }, + options: { projection: {}, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + { + text: 'order:asc', + query: {}, + options: { projection: {}, sort: { ts: 1 }, skip: 0, limit: 20 }, + }, + { + text: 'hello world', + query: { $text: { $search: 'hello world' } }, + options: { projection: { score: { $meta: 'textScore' } }, sort: { ts: -1 }, skip: 0, limit: 20 }, + }, + ].forEach(({ text, query: expectedQuery, options: expectedOptions }) => { + it(`should parse ${JSON.stringify(text)}`, () => { + const { query, options } = parseMessageSearchQuery(text, params); + expect(query).to.deep.equal(expectedQuery); + expect(options).to.deep.equal(expectedOptions); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/rooms/banUserFromRoom.spec.ts b/apps/meteor/tests/unit/server/lib/rooms/banUserFromRoom.spec.ts new file mode 100644 index 0000000..45b478c --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/rooms/banUserFromRoom.spec.ts @@ -0,0 +1,264 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + Rooms: { + findOneById: sinon.stub(), + incUsersCountById: sinon.stub(), + }, + Subscriptions: { + findOneByRoomIdAndUserId: sinon.stub(), + banByRoomIdAndUserId: sinon.stub(), + }, + Users: { + removeRoomByUserId: sinon.stub(), + }, +}; + +const messageMock = { + saveSystemMessage: sinon.stub(), +}; + +const removeUserFromRolesAsyncMock = sinon.stub(); +const notifyOnRoomChangedByIdMock = sinon.stub(); +const notifyOnSubscriptionChangedMock = sinon.stub(); +const afterBanFromRoomCallbackMock = { run: sinon.stub() }; + +const meteorErrorMock = class extends Error { + constructor(message: string) { + super(message); + } +}; + +const { performUserBan, banUserFromRoom } = p.noCallThru().load('../../../../../server/lib/rooms/banUserFromRoom.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { Message: messageMock }, + 'meteor/meteor': { Meteor: { Error: meteorErrorMock } }, + '../callbacks/afterBanFromRoomCallback': { afterBanFromRoomCallback: afterBanFromRoomCallbackMock }, + '../roles/removeUserFromRoles': { removeUserFromRolesAsync: removeUserFromRolesAsyncMock }, + '../notifyListener': { + notifyOnRoomChangedById: notifyOnRoomChangedByIdMock, + notifyOnSubscriptionChanged: notifyOnSubscriptionChangedMock, + }, +}); + +describe('banUserFromRoom', () => { + const mockByUser = { _id: 'admin1', username: 'admin' }; + + beforeEach(() => { + modelsMock.Rooms.findOneById.reset(); + modelsMock.Rooms.incUsersCountById.reset(); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.reset(); + modelsMock.Subscriptions.banByRoomIdAndUserId.reset(); + modelsMock.Users.removeRoomByUserId.reset(); + messageMock.saveSystemMessage.reset(); + removeUserFromRolesAsyncMock.reset(); + notifyOnRoomChangedByIdMock.reset(); + notifyOnSubscriptionChangedMock.reset(); + afterBanFromRoomCallbackMock.run.reset(); + }); + + describe('performUserBan', () => { + it('should return early if no subscription exists', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(null); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(modelsMock.Subscriptions.banByRoomIdAndUserId.called).to.be.false; + expect(modelsMock.Users.removeRoomByUserId.called).to.be.false; + }); + + it('should throw if user has no username', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1' }; // no username + + await expect(performUserBan(room, user, mockByUser)).to.be.rejectedWith('User must have a username to be banned from the room'); + }); + + it('should return early if subscription is already BANNED', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1', status: 'BANNED' }); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(modelsMock.Subscriptions.banByRoomIdAndUserId.called).to.be.false; + }); + + it('should ban the user subscription', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(modelsMock.Subscriptions.banByRoomIdAndUserId.calledWith('room1', 'user1')).to.be.true; + }); + + it('should remove the room from user __rooms array', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(modelsMock.Users.removeRoomByUserId.calledWith('user1', 'room1')).to.be.true; + }); + + it('should decrement the room user count', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(modelsMock.Rooms.incUsersCountById.calledWith('room1', -1)).to.be.true; + }); + + it('should remove room-scoped roles for channels', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(removeUserFromRolesAsyncMock.calledWith('user1', ['moderator', 'owner', 'leader'], 'room1')).to.be.true; + }); + + it('should remove room-scoped roles for private rooms', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'p' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(removeUserFromRolesAsyncMock.calledWith('user1', ['moderator', 'owner', 'leader'], 'room1')).to.be.true; + }); + + it('should NOT remove room-scoped roles for DMs', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'd' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(removeUserFromRolesAsyncMock.called).to.be.false; + }); + + it('should save system message including who banned', async () => { + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + const byUser = { _id: 'moderator2', username: 'mod' }; + + await performUserBan(room, user, byUser); + + expect(messageMock.saveSystemMessage.calledOnce).to.be.true; + const { args } = messageMock.saveSystemMessage.firstCall; + expect(args[0]).to.equal('user-banned'); + expect(args[1]).to.equal('room1'); + expect(args[2]).to.equal('testuser'); + expect(args[4]).to.deep.include({ u: byUser }); + }); + + it('should notify subscription as removed so client drops the stream', async () => { + const subscription = { _id: 'sub1', rid: 'room1', u: { _id: 'user1' } }; + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves(subscription); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const room = { _id: 'room1', t: 'c' }; + const user = { _id: 'user1', username: 'testuser' }; + + await performUserBan(room, user, mockByUser); + + expect(notifyOnSubscriptionChangedMock.calledWith(subscription, 'removed')).to.be.true; + expect(notifyOnRoomChangedByIdMock.calledWith('room1')).to.be.true; + }); + }); + + describe('banUserFromRoom', () => { + it('should throw if room does not exist', async () => { + modelsMock.Rooms.findOneById.resolves(null); + + const user = { _id: 'user1', username: 'testuser' }; + + await expect(banUserFromRoom('room1', user, mockByUser)).to.be.rejectedWith('error-invalid-room'); + }); + + it('should call performUserBan with correct arguments', async () => { + const room = { _id: 'room1', t: 'c' }; + modelsMock.Rooms.findOneById.resolves(room); + modelsMock.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'sub1' }); + modelsMock.Subscriptions.banByRoomIdAndUserId.resolves(); + modelsMock.Users.removeRoomByUserId.resolves(); + modelsMock.Rooms.incUsersCountById.resolves(); + removeUserFromRolesAsyncMock.resolves(); + messageMock.saveSystemMessage.resolves(); + + const user = { _id: 'user1', username: 'testuser' }; + + await banUserFromRoom('room1', user, mockByUser); + + expect(modelsMock.Subscriptions.banByRoomIdAndUserId.calledWith('room1', 'user1')).to.be.true; + expect(afterBanFromRoomCallbackMock.run.calledOnce).to.be.true; + expect(afterBanFromRoomCallbackMock.run.firstCall.args[0]).to.deep.include({ + bannedUser: user, + userWhoBanned: mockByUser, + }); + expect(afterBanFromRoomCallbackMock.run.firstCall.args[1]).to.equal(room); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/rooms/findDirectRoomByIdentifier.spec.ts b/apps/meteor/tests/unit/server/lib/rooms/findDirectRoomByIdentifier.spec.ts new file mode 100644 index 0000000..42c347f --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/rooms/findDirectRoomByIdentifier.spec.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import Sinon from 'sinon'; + +const RoomsStub = { + findByTypeAndNameOrId: Sinon.stub(), + findOneDirectRoomContainingAllUserIDs: Sinon.stub(), +}; + +const UsersStub = { + findOneById: Sinon.stub(), + findUsersByUsernames: Sinon.stub(), +}; + +const { findDirectRoomByIdentifier } = proxyquire.noCallThru().load('../../../../../server/lib/rooms/findDirectRoomByIdentifier.ts', { + '@rocket.chat/models': { Rooms: RoomsStub, Users: UsersStub }, +}); + +const cursorOf = (docs: unknown[]) => ({ toArray: async () => docs }); + +describe('findDirectRoomByIdentifier', () => { + beforeEach(() => { + RoomsStub.findByTypeAndNameOrId.reset(); + RoomsStub.findOneDirectRoomContainingAllUserIDs.reset(); + UsersStub.findOneById.reset(); + UsersStub.findUsersByUsernames.reset(); + + RoomsStub.findByTypeAndNameOrId.resolves(null); + UsersStub.findOneById.resolves({ _id: 'me', username: 'me' }); + }); + + it('should return the room when the identifier is a room id', async () => { + const room = { _id: 'rid1', t: 'd' }; + RoomsStub.findByTypeAndNameOrId.resolves(room); + + expect(await findDirectRoomByIdentifier('rid1', 'me')).to.equal(room); + expect(UsersStub.findUsersByUsernames.called).to.be.false; + }); + + it('should resolve a two-person DM by the other username, over sorted uids', async () => { + const room = { _id: 'rid1', t: 'd' }; + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'zeta' }, { _id: 'alpha' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(room); + + expect(await findDirectRoomByIdentifier('alice', 'me')).to.equal(room); + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['alpha', 'zeta'])).to.be.true; + }); + + it('should include the caller in the member set exactly once for a self-DM', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'self', t: 'd' }); + + await findDirectRoomByIdentifier('me', 'me'); + + expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me']); + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['me'])).to.be.true; + }); + + it('should resolve a group DM from a comma-separated identifier', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' }); + + await findDirectRoomByIdentifier('a,b', 'me'); + + expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me', 'a', 'b']); + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledWith(['a', 'b', 'me'])).to.be.true; + }); + + it('should not look the identifier up as a room id when it is a username list', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' }); + + await findDirectRoomByIdentifier('a,b', 'me'); + + expect(RoomsStub.findByTypeAndNameOrId.called).to.be.false; + }); + + it('should trim whitespace around each username in the list', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'a' }, { _id: 'b' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves({ _id: 'group', t: 'd' }); + + await findDirectRoomByIdentifier('a, b', 'me'); + + expect(UsersStub.findUsersByUsernames.firstCall.args[0]).to.deep.equal(['me', 'a', 'b']); + }); + + it('should return null when any username does not resolve to a user', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }])); + + expect(await findDirectRoomByIdentifier('ghost', 'me')).to.be.null; + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.called).to.be.false; + }); + + it('should return null when no direct room contains that member set', async () => { + UsersStub.findUsersByUsernames.returns(cursorOf([{ _id: 'me' }, { _id: 'alice' }])); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + + expect(await findDirectRoomByIdentifier('alice', 'me')).to.be.null; + }); + + it('should return null when the caller has no username', async () => { + UsersStub.findOneById.resolves({ _id: 'me' }); + + expect(await findDirectRoomByIdentifier('alice', 'me')).to.be.null; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.spec.ts b/apps/meteor/tests/unit/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.spec.ts new file mode 100644 index 0000000..b586b21 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.spec.ts @@ -0,0 +1,518 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import Sinon from 'sinon'; + +type RoomType = 'c' | 'p' | 'd' | 'l'; + +interface IUser { + _id: string; + username: string; + federated?: boolean; + federation?: any; +} + +interface IRoom { + _id: string; + t: RoomType; + name?: string; +} + +type GetRoomByNameOrIdWithOptionToJoinFn = (params: { + user: Pick; + nameOrId: string; + type?: RoomType; + tryDirectByUserIdOnly?: boolean; + joinChannel?: boolean; + errorOnEmpty?: boolean; +}) => Promise; + +const RoomsStub = { + findOneByIdOrName: Sinon.stub(), + findOneDirectRoomContainingAllUserIDs: Sinon.stub(), + findOneById: Sinon.stub(), +}; + +const SubscriptionsStub = { + findOneByRoomIdAndUserId: Sinon.stub(), +}; + +const UsersStub = { + findOneById: Sinon.stub(), + findOne: Sinon.stub(), +}; + +const RoomServiceStub = { + join: Sinon.stub().resolves(), +}; + +const MeteorStub = { + Meteor: { + Error: Sinon.stub().callsFake(function (this: any, code: string) { + this.error = code; + this.errorType = 'Meteor.Error'; + } as any), + }, +}; + +const createDirectMessageStub = Sinon.stub().resolves({ rid: 'newDirectRoomId' }); + +const isObjectMock = { + isObject(obj: unknown) { + return obj !== null && typeof obj === 'object'; + }, +}; + +const { + getRoomByNameOrIdWithOptionToJoin, +}: { + getRoomByNameOrIdWithOptionToJoin: GetRoomByNameOrIdWithOptionToJoinFn; +} = proxyquire.noCallThru().load('../../../../../server/lib/rooms/getRoomByNameOrIdWithOptionToJoin.ts', { + '@rocket.chat/models': { + Rooms: RoomsStub, + Subscriptions: SubscriptionsStub, + Users: UsersStub, + }, + '@rocket.chat/core-services': { + Room: RoomServiceStub, + }, + '../../../lib/utils/isObject': isObjectMock, + '../../meteor-methods/messages/createDirectMessage': { + createDirectMessage: createDirectMessageStub, + }, + 'meteor/meteor': MeteorStub, +}) as any; + +describe('getRoomByNameOrIdWithOptionToJoin', () => { + const baseUser: IUser = { + _id: 'userId', + username: 'user', + }; + + beforeEach(() => { + RoomsStub.findOneByIdOrName.reset(); + RoomsStub.findOneDirectRoomContainingAllUserIDs.reset(); + RoomsStub.findOneById.reset(); + + SubscriptionsStub.findOneByRoomIdAndUserId.reset(); + + UsersStub.findOneById.reset(); + UsersStub.findOne.reset(); + + RoomServiceStub.join.reset(); + RoomServiceStub.join.resolves(); + + MeteorStub.Meteor.Error.resetBehavior(); + MeteorStub.Meteor.Error.resetHistory(); + MeteorStub.Meteor.Error.callsFake(function (this: any, code: string) { + this.error = code; + this.errorType = 'Meteor.Error'; + } as any); + + createDirectMessageStub.reset(); + createDirectMessageStub.resolves({ rid: 'newDirectRoomId' }); + }); + + afterEach(() => { + Sinon.restore(); + }); + + describe('channel / group resolution', () => { + it('should strip leading # and lookup by id or name', async () => { + const room: IRoom = { _id: 'room1', t: 'c', name: 'general' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '#general', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('general')).to.equal(true); + expect(result).to.equal(room); + }); + + it('should treat name without # as channel/group and call findOneByIdOrName', async () => { + const room: IRoom = { _id: 'room2', t: 'c', name: 'random' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'random', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('random')).to.equal(true); + expect(result).to.equal(room); + }); + + it('should throw Meteor.Error("invalid-channel") when channel not found and errorOnEmpty=true', async () => { + RoomsStub.findOneByIdOrName.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nonexistent', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + }); + + it('should return null when channel not found and errorOnEmpty=false', async () => { + RoomsStub.findOneByIdOrName.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nonexistent', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + }); + }); + + describe('type filtering', () => { + it('should return room when type matches', async () => { + const room: IRoom = { _id: 'room3', t: 'c', name: 'dev' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'dev', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(result).to.equal(room); + }); + + it('should throw Meteor.Error when type does not match and errorOnEmpty=true', async () => { + const room: IRoom = { _id: 'room4', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + }); + + it('should return null when type does not match and errorOnEmpty=false', async () => { + const room: IRoom = { _id: 'room5', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + }); + }); + + describe('joining channel behaviour', () => { + it('should call Room.join when room is channel, joinChannel=true, and user not subscribed', async () => { + const room: IRoom = { _id: 'room6', t: 'c', name: 'joinable' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'joinable', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room6', baseUser._id, { projection: { _id: 1 } })).to.equal( + true, + ); + expect( + RoomServiceStub.join.calledOnceWithExactly({ + room, + user: baseUser, + }), + ).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when joinChannel=false', async () => { + const room: IRoom = { _id: 'room7', t: 'c', name: 'nojoin' }; + RoomsStub.findOneByIdOrName.resolves(room); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nojoin', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when room type is not channel', async () => { + const room: IRoom = { _id: 'room8', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'p', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when user already subscribed', async () => { + const room: IRoom = { _id: 'room9', t: 'c', name: 'joined' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' }); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'joined', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room9', baseUser._id, { projection: { _id: 1 } })).to.equal( + true, + ); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + }); + + describe('direct message resolution by username', () => { + it('should find direct room with existing roomUser and existing DM room', async () => { + const otherUser: IUser = { _id: 'otherId', username: 'other' }; + + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm1', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@other', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'other' }, { username: 'other' }], + }), + ).to.equal(true); + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledOnce).to.equal(true); + + const callArgs = RoomsStub.findOneDirectRoomContainingAllUserIDs.getCall(0).args[0]; + expect(callArgs).to.be.an('array'); + expect(callArgs.sort()).to.deep.equal(['otherId', 'userId'].sort()); + expect(result).to.equal(dmRoom); + }); + + it('should strip leading @ when resolving DM', async () => { + const otherUser: IUser = { _id: 'otherId2', username: 'other2' }; + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm2', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@other2', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'other2' }, { username: 'other2' }], + }), + ).to.equal(true); + expect(result).to.equal(dmRoom); + }); + + it('should treat nameOrId as DM when type is d even without @', async () => { + const otherUser: IUser = { _id: 'otherId3', username: 'plain' }; + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm3', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'plain', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'plain' }, { username: 'plain' }], + }), + ).to.equal(true); + expect(result).to.equal(dmRoom); + }); + }); + + describe('direct message resolution with tryDirectByUserIdOnly', () => { + it('should lookup user by id only when tryDirectByUserIdOnly=true', async () => { + const otherUser: IUser = { _id: 'idOnly', username: 'ignored' }; + UsersStub.findOneById.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm4', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'idOnly', + type: 'd', + tryDirectByUserIdOnly: true, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(UsersStub.findOneById.calledOnceWithExactly('idOnly')).to.equal(true); + expect(UsersStub.findOne.notCalled).to.equal(true); + expect(result).to.equal(dmRoom); + }); + + it('should fallback to Rooms.findOneById when roomUser is falsy but room exists by id', async () => { + UsersStub.findOneById.resolves(null); + const dmRoom: IRoom = { _id: 'idRoom', t: 'd' }; + RoomsStub.findOneById.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'idRoom', + type: 'd', + tryDirectByUserIdOnly: true, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneById.calledOnceWithExactly('idRoom')).to.equal(true); + expect(result).to.equal(dmRoom); + }); + }); + + describe('direct message creation when roomUser exists but room does not', () => { + it('should throw Meteor.Error when roomUser not found and errorOnEmpty=true', async () => { + UsersStub.findOne.resolves(null); + RoomsStub.findOneById.resolves(null); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@missing', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + expect(createDirectMessageStub.notCalled).to.equal(true); + }); + + it('should return null when roomUser not found and errorOnEmpty=false', async () => { + UsersStub.findOne.resolves(null); + RoomsStub.findOneById.resolves(null); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@missing', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + expect(createDirectMessageStub.notCalled).to.equal(true); + }); + + it('should create a new direct message when roomUser exists and room is not found', async () => { + const otherUser: IUser = { _id: 'otherCreate', username: 'createMe' }; + UsersStub.findOne.resolves(otherUser); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + RoomsStub.findOneById.resolves({ _id: 'newDirectRoomId', t: 'd' }); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@createMe', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(createDirectMessageStub.calledOnceWithExactly(['createMe'], baseUser._id)).to.equal(true); + expect(RoomsStub.findOneById.calledWith('newDirectRoomId')).to.equal(true); + expect(result).to.deep.equal({ _id: 'newDirectRoomId', t: 'd' }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/saml/data.ts b/apps/meteor/tests/unit/server/lib/saml/data.ts new file mode 100644 index 0000000..de91349 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/data.ts @@ -0,0 +1,435 @@ +import type { IServiceProviderOptions } from '../../../../../server/lib/saml/definition/IServiceProviderOptions'; + +export const serviceProviderOptions: IServiceProviderOptions = { + provider: '[test-provider]', + entryPoint: '[entry-point]', + idpSLORedirectURL: '[idpSLORedirectURL]', + issuer: '[issuer]', + cert: '', + privateCert: '', + privateKey: '', + signatureAlgorithm: 'SHA256', + customAuthnContext: 'Password', + authnContextComparison: 'Whatever', + defaultUserRole: 'user', + allowedClockDrift: 0, + signatureValidationType: 'None', + validateLogoutRequestSignature: true, + validateLogoutResponseSignature: true, + identifierFormat: 'email', + nameIDPolicyTemplate: '', + authnContextTemplate: '__authnContext__', + authRequestTemplate: '__identifierFormatTag__ __authnContextTag__ ', + logoutResponseTemplate: '[logout-response-template]', + logoutRequestTemplate: '[logout-request-template]', + metadataCertificateTemplate: '', + metadataTemplate: '', + callbackUrl: '[callback-url]', +}; + +export const simpleMetadata = ` + + + + email + + +`; + +export const metadataWithCertificate = ` + + + + + + [CERTIFICATE_CONTENT] + + + + + + + + email + + +`; + +export const invalidXml = 'not a xml file'; + +export const randomXml = ` + + + Value +`; + +export const simpleLogoutRequest = ` + http://localhost:8080/simplesaml/saml2/idp/metadata.php + _ab7e1d9a603473e92148d569d50176bafa60bcb2e9 + _d6ad0e25459aaddd0433a81e159aa79e55dc52c280 +`; + +export const invalidLogoutRequest = ` + http://localhost:8080/simplesaml/saml2/idp/metadata.php + _d6ad0e25459aaddd0433a81e159aa79e55dc52c280 +`; + +export const simpleLogoutResponse = ` + [IssuerName] + + + +`; + +export const invalidLogoutResponse = ` + [IssuerName] +`; + +const samlResponseStatus = ` + + `; + +const samlResponseAssertion = ` + [ISSUER] + + [NAMEID] + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + channel1 + pets + random + + + `; + +const samlResponseHeader = + ''; +const samlResponseFooter = ''; +const samlResponseIssuer = '[ISSUER]'; + +export const simpleSamlResponse = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseStatus} + ${samlResponseAssertion} +${samlResponseFooter}`; + +export const samlResponseAssertionId = '_assertionwithconditions00000000000000001'; + +const samlResponseAssertionWithConditions = ` + [ISSUER] + + [NAMEID] + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + `; + +export const samlResponseWithConditions = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseStatus} + ${samlResponseAssertionWithConditions} +${samlResponseFooter}`; + +export const samlResponseMissingStatus = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseAssertion} +${samlResponseFooter}`; + +export const samlResponseMissingAssertion = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseStatus} +${samlResponseFooter}`; + +export const samlResponseFailedStatus = `${samlResponseHeader} + ${samlResponseIssuer} + + + + ${samlResponseAssertion} +${samlResponseFooter}`; + +export const samlResponseMultipleAssertions = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseStatus} + ${samlResponseAssertion} + ${samlResponseAssertion} +${samlResponseFooter}`; + +export const samlResponseMultipleIssuers = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseStatus} + + [ISSUER] + [ISSUER] + + [NAMEID] + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + +${samlResponseFooter}`; + +const samlResponseSignature = ` + + + + + + + + + + S2qmjxIC0ncXw+7n6ptxy9p24oc= + + + ECRjbLzq2QbPRfhBSJRhjCR/3hxt/uUN8zjUmBIN2LMvytG8FGsuWzC57pVMDBNpwdKKSwv0U1PieLWU9tMoESKGOhoHLzK4w9otlhgQDfy9qjqYBVv9Bp67D2Tx+dU2S11y2GnKH749fbNnmASYynQumkFxB6nunaCNXmVu842PK0jlJQUufOCb4nMZZHgK6RYir49K8lROXqHn02+L0iJAxJggr5eWHftBsxJWh32pE0T5DTuhu9qm8sq5aSSl5ybJhE9N4L1TOXmWmgeM8qa/MwV4+sNDKIKo32EbLeo1ybEmg9GEzo2vakm5zcFYALxt5egtx29iSrX2qIH75Q== + + + MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ== + + + `; + +const samlResponseFullAssertion = ` + ${samlResponseIssuer} + + + + + + + + + + + 7VshRWNbgdIesGGSQiwS7tuhkzg= + + + EYVTttrq3Yxkp/I+U271CYKpeYMHPEb9oZm/ZKyGzCkMI8GNvwh7YOhT/+M7NwLOVjpdvAZlXQFeyxearlVDgPvyZtUNz8LwpnQEu5LkV8jxzQczW+x71OnantwKecpz3eyAEvvEjjWtZf1m8IoH5UtGVqW6SzIkxWN2ixudRInAUMgSq7IXp0x1BjL4N69Y3IsW48PCdKTpuAcsdefvsR8tLgeWOk3smigfsu72Sp5sVh/n3AHiCXJ5fgLYfLiY8cXwQzZ8JSjFp7H2lyrl0Tth2TCBe1DemBRzCQ2t2ZbAjwUrsI1Xy8GAshq1nNplXMSs53HEqSay40USqqTZ9w== + + + MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ== + + + + + [NAMEID] + + + + + + + http://localhost:3000/_saml/metadata/test-sp + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + 1 + + + group1 + + + user1@example.com + + + `; + +export const samlResponseValidSignatures = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + afI/fG2Gkj4Nlu5vC+PdYon1aNk=Gndt6TSrAaSs/BK84bqVXUMz3cvl34dIpHEZ2o7uqAf66SCF3qjLLm5fV/bFaSMOPwnVNJFjXpxmKdZI9mwBKBMYutxd43wkBvkp+3MYVZcRTpuU2Wo6iQLy9rhScB5MLRMEe3lKpwBRCKGEBUn1V/WaVUWlReHNHtwCnXD6FhtG4PBfd5p4dGePRQxFd9a0Pfm0wN4AposjLNNzGLf8yFTPTmGlZJ44U2IEUlxtOeH0MP7v7yAvwsjOk1PUJcUB4jgM8Y4WPCgEN7ntBdkSH8Q79tS6gyn/gAN9PW8QPcWNf3FVUnRfL8WRUeqUfohTUscRftj4Ff60Ob/FOeoIlQ== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; + +export const samlResponseValidAssertionSignature = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + afI/fG2Gkj4Nlu5vC+PdYon1aNk=invalid signature +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; + +export const samlResponse = `${samlResponseHeader} + ${samlResponseIssuer} + ${samlResponseSignature} + ${samlResponseStatus} + ${samlResponseFullAssertion} +${samlResponseFooter}`; + +export const duplicatedSamlResponse = `${simpleSamlResponse}${simpleSamlResponse}`; + +export const encryptedResponse = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + a9/HYUoNaz3fCqNEWASnblQ2Boc=uj/NBTAqxYarAS+V+in8aQ7/ZoJZapE81HD+v+RR0q5LSRfpFoqy52R6YcSvV4d+eYe365SXpGGHedRVZ9UbzvLyR2XOlopIf/SweFOYXaPBd30W4KxRAlcaauF5kvYjmshDZE0YkGJUcB3x1yNaEyW8UuGA8Bq6be/ytEa6ZRsb2tC/81nR+LOAQwNdfLmsturHDXHSZltobm7MQSLC1oGnS8ha+/7N5laeTWsgQuuYRbUkSP4yTf/2fdg4U5LH7RD/Hhha+kO8geWM/dC1TdME/KtYT7AseHJxAa0CRvOCW2KLACllM24xU/5oLf6Wt447bzQj9Xt2LI9D2g1nNw== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==jVRjHcdQc21xw0vRKUDtPScv+GPY6mxOJiPmiaBFxBkrCDHjlfLmcOi8badR5ZPiloDFfEc9SSVkqEOoJLVHNttnpWxTxP6ySwSD4hD1yAyDm/YBQkQkMkNVsRB9dBkXSx15g9wQk13zfcjzaLIohWqAdau4ISaWyObmMDZjDS8tLa9vE93e/VPwVp8rB/lSDn9OMymtZHWmPHkR7tB6zYGGgusUeUujb4d9LJ795nLst++0QQHbh0C78BvCwV9fUoK8WPBfwVLnSDM15pbWsFPUCwvziBdCh/jQX92S2aeMXbiNWmIsVT3IacItKjYlbHaAFCEfmGQP0ALOg3cFlw== + + GpdyHBIP86f8BCD5coOMxWdz0R7U+t1ICgLvUHwX1aKWXbCLtjWwi2Ke1fcFylUUwOJFvNWR+nIQjHDiv89nUSvSOWtdZJE+eeE8Vo/V1M50+lIfTlVwKJPNnpBbFsAjDqiJzQXEYbEfQM9uvt8zHnaJuR57CxoDFliPNoXmrcQwwpzhZpxFbzh/KDNayqB2W7AgswFAGorfrzCcl4yCaR7V6Yb7mTlD28F7h6zGsNIDX4wBQv2WxsaA8+aLutIQv3Bw0Q212yJVct5ik8sGnP6rZitG4hl4heAgVLY9Svk8PcRfYC/hrd4tpykQWMhOzqPKrUYG/ZDshkfk7EhJyT21XWrV9GrGClk5lBu0TAmhyxF1eBs82RghJ5SFL1TH1GKgTIuUSs+jLPA6G0O7PnWAvMI7D28S14x2mBG/lzN6SGJy4ALzIm6evnKweKsEjm+bGrOoGUdkOrMIrPHLXwOMujssryuqFqjfjhtefLaKCcM+oVrdOZe31bB0TzHxEmOTeBW9aD2KzczZfHQ02taTDc5UW4Elm6hqWnTj5uJtVPB8vDNBrEtc50Cuj7M2GEjkUbdFT35nHUXfSV5YatCQKNYy5FrEmN9o50KADIhDeNULLo8k/1GkypSS2wvrSyJB80HjGJd8+LOIStR5f+iqWrJp+N1kBUGCkyOnmzLkSxBEwK11F+ErqT+tYSMkAiE17540NB+sxpz+J0JsZorztWkVceDFUKPN/mEzEyMaykqc/7Ux1+ixJRuA9BWUT6q7wbYR3QuOlHZZF+jQBwNSOYSu2uYzqUf7GwVpwlzHQtzJryA50ePlP6UghKkSV/nTGXKhzOJ4EAFVrtm07TnjifCZQ57PM6G0yXa/Rm0Poyy3B5UGWBuSWl979tKYxt9q9T4ZBipo+PLmPyFm9iYK0V2le/ebx3BruBXzeYM+929cXhRhqL2otS2Ev2RPB477kBppmrmR2wwq2FkQ3c8q0CQyj7ndj8m6tFJnw8T4C6V2kyNbrVEeJDotoRyln6GYu3bpeGJDWrY+ThEY1vvhWN+U4kY5c1Pwcj2cqdKiG/ljrDJa3Vu+CrJyv8tjs5zAnWn4d8Sr0NZf5IC9X60rFjVFs98OHwo7+fBadlcckSUKUqvq4L1sh2YqjxzMPJbq8Mre7/hxS7VlOe/pDYPH5d4+KuPpLRUhGVxXkWVWlYc1i7vNFDAWQZfQ2zjhesB4+OytELWPKbsoMA+5QjnE0B98gwVPWE94eaLzkwVBYPRBqkZAINGC5GnvDnyMO8fJL/kS6vMRirnA845rzCU9WwQNoKwu6kIyp1h7ls2tPHZuBtSvLdkaLB/bwrr9wdRQwoQ7DcBUzZqSLEPNmiKwc/UX9mmM6ZLdfzyjLeTEnyoz5rUQydz3/gs3MiFoRNkkh84ZMoqaqewX0OGKrN+KCOYEgxEgt5wBhKdOVOIFResUYH6lNLLWstTn0QXHT9uuAUz46juBOlZIxO7j+rU+C70/OLxqVF3zN7sIdzOfUiXLm5g43eYskCp3erwDjtZBRt87uUT5OwH2LgF57mXdXqPmkYsIleY3VcYfDZcaGpxovKsZVfGao6XajFCZGI643yuUFsEw5B944EHbzPPWrJUS1kHakmwExzv/jpJn++255iTpQQCYRX0AOaHsUxJSTKnMU6p1r/4P0pRM8kzZOlpVKx0iCkqb44SJX82uFWzzJ0MLDiOvH0c4NUCz7RIBgbHgwKaEAYeTfWXGAsgQ8ySozoxHyvnzPr2EfsXeVXT2QueZkjpeEQnI1VyWQLK8T70KsfL7Ckzw9jBv9VxRDEjp4VQ0EL2Grn5tcmc2NojLCRwYGYKz23SSgwYfIfGLqKKnj1N35G1iHDuDnKK1rUf2fapDw1C6f+NzsPqp/I4aascZTbaQRVrxwKXAfOTBKDPsCA5vEJ+zd+KhqazqsG3/IrhEe0k2fbitQwEG3ElYDTS0VRKstrSsDRv9Su6aEtGWkWgDjGCI/zSBwVIoScWLIkLY5KcH6Nf1SJHfMbTjJ49LAQBkoVgkA8J7EbYrCEj9GHcKZ/utY2K734d+UzC4QKN0L7LFLNTHRZLDaD/eVvsgcCumcJXLFv+WQVnG5+UWJVtTgsnQqizLTi4HqUt+AjqxB8tpnOcGuP12ElT0w3oSiVFwgDVqITyXqKciKlx2TrOgbIC006qoLYiMBJwDWld0nS8M5HbfGi8tk11+WpnuszrNoBNU8zhpBeTK9NTmeaVU5/Hilboka40IuAYFDaPSmM6yMlVOJWWFrVs8G/yvF+eIrQRDiukXO1ysualbE6W/3H/gTqMQh424ZyUmJBH4GQ+xMn18HMykcnBvKBrP+t2fyCuZgVoH/CbMMiDKvNhOtdgSPQnbVd4Z7AKZSL0UB1ElDijVoGYF5RzTM1nKCvxM6ueEn2w3fFZNRq2j4lRQdULTNXzBiHD8ho/jeHElk05AE2hxBnluFNxo1rH+Y52bTQQhwxnYQUTGrt7ORpAhTQbsP9osWzbGUmBYu6oDjd4yQI4Y+cOgDmrNLlonj862mq3urohNJoZm3m12mlttwF9pzXMn7SQ9fsx5jp/3XssEGG9OlNtNZBv9oRdvppIrdVY/SExLTzlrGlSNpIXZncIjJxM3fB4EzTwWaSz+vGVfjBfOhiWOzMy97YrDbyojH5EnIkkjxJf7GtcLYmgsjIj3GIqTDjxs7Y6MckPMNkD3riv12fizJPTFnRkJm1mA6E7XhF3dqpGtoA8Ut6gPwEzoswus+Gy+M1Jx0cAT+Gbzsyoh6CrRjGj/lpnneDLxnH4fkeKqOa3p9HacEcKswETw9nCV3hYBR3erXyUJLO18VI+uG+NzyqRTePBDJpaGMFQAirJDbS+Y05F/S6xJzsacvlk1OavN8OrG/ie185NnV7uN5weAv+8qVt0mo8nNLhufKktn/NC7XeLh9WWJCa1uxBZVfhvKcj7plqc3g9i4WDKFS+ZguxUw60ApAVma1yD4GzMZl36xLDqkUrpU/4DLNswsyxx9ssA12U2Kvg+CMNjGdyBEHHdvUF28OzsiT8cr82YUjgmKd7YG6G0L1V3mPXC1JwPt2fcMQ/91OiuUuTtR7WLZ+JThxYPx1H51fkqPQg5n6ePygmCFI1o/1YHhelfl3a4zWrT/zo9+A5ow5CtaV6GMbOx3CwfWa07dXvtMujjc+bWeyxrlkq0m2Li8aIgRzQX/KRREVWVR9gUNbdPsAu/9lm38UDnrrWyQOP7yA7PfJ+UKI+ZBcGWmQodcVYrAkWxx2YhOwkrA/3cp/tzR9i14Lmhdw7tGz7H6rjcb2i8jHIFY8qCbRvfhbsYklelmsRGYrfeqbKR5J5BioGXQRL9v0z92kG9svJKnuKmjkTZb8MDmp2V9vYw0laYKqymx7AFtzC9FB6nkajfXAQ444ZdLoZSbsEg94/gHwVvGa1vXWg9QR3+cYx2h+0j+Kh5v6rzVyKMd+Vp8TFYA7VXrljJwvvP7YG8Mp5yEIXukJIWCGC2JuJVI6xsWtp6xzbYOn9BmWzOBuscI6PscEyFB4MZsJsgSGrDX0X1GiXUOAX9h0Fof0jVYheLGNmxD/KV3fAifvSPWIZ3VEMM2jhauAaNetAjzIpnHo9HPg0jLC5oIJMNpOeo34qx4Pffs56CuSGCrEq7YJ7LAmiXnykAiDH2Rpkfpu/fNmEZ0+EoOI3zvVgfxBOffMW+6P2ViDE8ZLa+br15Mb+NauX4+ZV1oCkaX79MyzISuddPbdKg+66MUlFeL5jyZt2N8mWPVZxl59nSXNUgTQ+7T14zuLDQ03gzvuphcbZy6mXOMl96e+BJLDKLQPlaDUFjq+UqUHVhomjDWqyZ5kLEl8wrZ7v//1MHZymOm7uxpn7Ulctkac/TzDkMa/AWi7vnBRjY8PuNiKTaUIdbLa8Fp3Y5L0Xiu8h/ssTT97LTuxitA98XsqOmipAiVhMzljmmLMY12kVajnRf3plzXvkVqxeq72e+Hm2yFSXNEfO8fbrw2vts5fQ/txf0PK4ua2uZMmT2d/yWVdbXCP//1ImY3PvYZyofKg6CdD2IjTRJxxfWm0QQa+HzT5XgqdoYslu+EWO2kZDKZmQMrIv0j5/PHh8ahAc8KVCHP44uQlGclLda/+bCWT9kZ7wbTzUJHKSDkwUIb6Af9wwaWrLAGhPokGI9pcgKxPgRNuK05DrLke4KibX1TbCl/OPBfzPN++uzydI4XIM9Kktqm23+e7IEkHx1mdxQMZTnJVia6XENtSghboiltXWyTet5TiQqXtAXJZiQXSMIQIEuRwRhB9UcVS2Z62lvI2WPYdCsdlrSJXfm13BgzyRib/+LMqZ8IKF6Wh26iUgL1VFH9a+h0NdhXTHP9po3Wzn0dAUQWgon6Mc86PC8yOvHd+NTb5qRBIHS3ZkGIAL0mniSCqqiNCI+/wESshCKmnd8AgKV5MBY5bXuAOK6ZaJj2thO0wae/XGwJ9lx/DEaKB4UwQPuot3MfdGSYeTEJnGIZBZ43N9sCJaPXOYO2yYBX9tiaa/YYPluzhdBPTRRfSYYjqZfTPh4CsPAscrkkIyM71KbtAr9TNHoFfA+aK6Bh/fUZXRhR23ar9IoSUs51TW2zcKhad08WghERCIvP6Df6NhNbrVoiYJrnWpmZTr2lqpOF9CmsdUcyQerdUT+Gqgoto38ALGdNEQ6wx/iLkZt6UIWG4KtPRl5NGnJ8yQmb1YXHM50kpOQLWLkC+NRvCPLBODPfBOE6g3l42DL+qIuoAfJH9BSZrBXMIwxSgC/K+DIHir8RVMjR/mHywwTM4OtmrNCoVpTC4h4Kpjok8x22AHmFdMbFAhgdFPgguvp2+ggz/5jwonfdqIgDzKFbjum1mJCVZlz18qIwPxFttVT/k7rPCM8F+cSiKarhb2RewX7s2MYHl05ODpKOSRSTIYGvlPGYyigDLFY/fXU5CDa7kxQfZO3Y8P+asraQ2VuOiMG6XMW9JrIVRl8w0FotFRBfxUhHrvS1X2svfaZFucikQ+VMOWRH0/8Ro+goM0gK6ba+zfeWElAU8UFShQp179yMqAzm1dyv/6cbwjKSC0ExrSKKbjVr7eOq21lL5JWltwstmQC0B3GuEi1Wiie3rsT9I6GYW0zjREnfmxyNEYingL1eLaJki7UOCD9cHLDy++zTZLUj8lgOK0wrVqHqdsG74/iknGb7UDORIoCEfNy87DC7Hzp8C3uQ0qoxPWO3K1Y6N2QI+4zc9/2xtug+f3o+9UdsnxOP+Z/zIaV2VOy/MySjwiVOgFJu7/xb5L69F4A8WTrkW6ROuQORC26aD3Nt2XDiUy7Dqtg/yT0LXMewSt0Y1wa4McTJh4AXC1QhBGh9SrA5YS0fMgggVVbA1gUcNSM8Qle237z7l9a7Jx4gPDyZ/D2Vqq7WPzHudagAMyu3dSb/Fvw6S8+5rfZ7AN4CKXxhODOXnGgh4MC4cTBxx5yxNkPgAASfoG8MD4do1p8BWvWNHgEzvvzGw9T2oPrHJLZCoXS3yjIC5Ne1BPn0P+NYiZyNbG1a9v7mI2sezvJu+5pgApT/mKVIjDq33XQoM3//22nM9Ki5pSZS4IMomq2pKsHfGI6s1ZvS9j8UcQPMreKm3P/9QvDQkm8KMpgqkIP/aigzgAo2XSjU2S5AttHHHxSlHJBxVP6h4elu4oBywMl+btK01kdFt4jIIub5Xj0JOztLRUhhLKHGzIBHpvaBqkFaq+tjaGYqWeBANkcTAJYbfqT+yIXhWiXiBa9or9HTfAmOEXOlazE5f6jGUBW/DRgrepm+/bFBRIl656+xsJV75ma8a7+vxsLz+avkruh/rTH2SW+UcBmwEzluL5ZI0SjeSmOzIATTO60c4XiXq5sdFvquhaFc9/6dtF7J15C7xeraj3H8yF5/3SHYh1H/TFIO9eVtbPeC6RfIDV8Pug5WYk7staBysv2u+jMw2ECpTVohqWPpyf6mUWRA0uMysem/pqdtV93fJ1+svlWKsdgChlb4RkW1WLH8zbyqnZtBlzpJOZ2vGz7E4yAM85nLzAkRRIxRyxFOi5j12hke4HW3oVi/FN/0gLP/2fukPTO6W9Ms2hvBuMX2AVWEuglr9yBNq2M2rhxlMP8CtggyhcTfydnvFkBhQa7zUdHWH0oBwRXloTJEcbKS3oY3uhujcnyT0DiE3NiOAV8eGMQMiVt1nMxN3+eM/+enu1eEQuhD8be6cNnSKWDnRMk= + +`; + +export const profile = { + issuer: '[IssuerName]', + sessionIndex: '[SessionIndex]', + nameID: '[nameID]', + displayName: '[DisplayName]', + anotherName: '[AnotherName]', + username: '[username]', + anotherUsername: '[AnotherUserName]', + roles: 'user,ruler,admin,king,president,governor,mayor', + otherRoles: 'user,customer,client', + language: 'ptbr', + channels: 'pets,pics,funny,random,babies', + multipleChannels: ['pets', 'pics', 'funny', 'random', 'babies'], + customField1: 'value1', + customField2: 'value2', + customField3: 'value3', + singleEmail: 'testing@server.com', +}; + +export const certificate = `MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ==`; + +export const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCi8vclbfAabX0D +LRQYqXCTiJVL+I2uAViMuZxTbxY8Yzx+FKFjSyqitMUycpuEaM7+DMkb3S7fx3MH +7Bbpq9UJ0CB8zFi+Y174TLQmncdtegl8DmhYTOoPqSHBDP5m/gH/0KJuCB7fHbf5 +aMBHOMMUMnJnJ3QliRLFeDjFj/I/SzvQL+QIkqkaEf0kXuQ1E2lvdgvvJFved19M +U0d5ao9WKmtDYiQEDTM+BhY74UXHUFRvHj23LpqYkwYX6HJ+a/LRfrT0rbV86+YJ +nR/PkKgNTl7wtRU1kcKgyaip/sFQwFPOVUrMlWd/Ejm/Eh0MFv6UricyLYBCbyp8 +C9cWHVrBAgMBAAECggEAFhgHdqW/ZnXt+15DWUywHPDp/VEINM2t6fbIwW9QfoOe +EiJN956be1AzZLGxcHSdjEjDg+mrj2AFss9KFAjea+QyY3l5lub2W4ha7Nl7ztY7 +LvztHPvgyJrQHtLaM7DBKKRrQawMM4heB40ydPW3TafBZ0csMmKxjuDMIc1wtTAQ +2wb2MQt7QwO7H5he/JNArgMOAmZvkPowqIqL/YsWfVz0TdiPgQ8t1JZxBavwMZkn ++wE47aVN+K0ksy67pF5DDtygyMydSJV8iGWRbe9VQqsOwpjNYbGfDiTT7+wsANJm +nTwNQ0vWwzBSA04aRv/40L9cyR642JHV1kg5GZGB2QKBgQDNTHitGmFkAvH+1/zo +JXrSCe3J3xMxTOzSBKOU7B8Fr1VAVh1EcVGEG3/1FB2eyXB4sgtaSDkMdltvV/Ml +ZxnlTTcTPtTNgABofKGQOx0kOdDargM8n+zzuQ0cRTjobpusR2LIKJCllmLM2Q2g +9rsSX/j1tM8KmE2SeJ+9w8s7zwKBgQDLMQm/QZOAQB7zxgfLZ0jtGMqxHBi3HhSP +KQ5Zh0WLXgTFNOv8gjwIc1rtPNM2l0Yzi3ytpnB3jDQXk29t2CxUPXm2aXZRdA2g +TFTf+OqUExebB6XraV0b4/6if2O5gpmFfCdGVSoXuzFg7LKtmpD+rkc2blg833dv +GcbO/9nUbwKBgQCpTJ/TuIaJ8DfaPgms84OGhHOY3yI3rMU7KGIx5Eps6Ls39Avs +rjpX5EmwNKd8k4fxsHnWOOr60Pv0JSY5OP3M79E0SMM6uI0dnXGqvGT6w8btH0VC +EGxaTMd4Acm9O8Ga37+hanpmY08UuQYZMH7y1zw6e6GljhWibWDmH/mQVwKBgBpY +53ynUisFJX5SpVwYrnogBthkXkgQXHYbysKNKdVigZfYvujlMkeePaIZiwG/J9kz +Mx2JQXge8/pCoeZKa6UYu5mNn0v8km/Athi8vB4rQ5pUqY0XAn3FWJVVk2bQqnuG +l8kk7epZ2ZNJ3flo23hKvO0v7b0m9OOxIfhhcKt9AoGAaRibkQ+Xheyx2osMmx/5 +f1+NzddMnmaVanLz9NbodpeiTv7Ny8Ig+Y9GywhFnXyodB/cQ7pvDWtlCgjWoo0j +taYugaU6N5bKTyRKYY7wSsbCXUyC2z8pS3ZGbJNFbZ9lYptFn9LpYXqaAzsx/ad7 +V1v/OC73qmP95kdcjAfjnWY= +-----END PRIVATE KEY-----`; + +export const privateKeyCert = `-----BEGIN CERTIFICATE----- +MIIEIDCCAwigAwIBAgIUL+MGp9n7+oPlQOaFrUZyqxoKakcwDQYJKoZIhvcNAQEL +BQAwgaAxCzAJBgNVBAYTAkJSMRowGAYDVQQIDBFSaW8gR3JhbmRlIGRvIFN1bDES +MBAGA1UEBwwJSWdyZWppbmhhMRQwEgYDVQQKDAtSb2NrZXQuQ2hhdDEQMA4GA1UE +CwwHQmFja2VuZDEPMA0GA1UEAwwGUGllcnJlMSgwJgYJKoZIhvcNAQkBFhlwaWVy +cmUubGVobmVuQHJvY2tldC5jaGF0MB4XDTIwMDYwMzIxMzIyMVoXDTIxMDYwMzIx +MzIyMVowgaAxCzAJBgNVBAYTAkJSMRowGAYDVQQIDBFSaW8gR3JhbmRlIGRvIFN1 +bDESMBAGA1UEBwwJSWdyZWppbmhhMRQwEgYDVQQKDAtSb2NrZXQuQ2hhdDEQMA4G +A1UECwwHQmFja2VuZDEPMA0GA1UEAwwGUGllcnJlMSgwJgYJKoZIhvcNAQkBFhlw +aWVycmUubGVobmVuQHJvY2tldC5jaGF0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAovL3JW3wGm19Ay0UGKlwk4iVS/iNrgFYjLmcU28WPGM8fhShY0sq +orTFMnKbhGjO/gzJG90u38dzB+wW6avVCdAgfMxYvmNe+Ey0Jp3HbXoJfA5oWEzq +D6khwQz+Zv4B/9Cibgge3x23+WjARzjDFDJyZyd0JYkSxXg4xY/yP0s70C/kCJKp +GhH9JF7kNRNpb3YL7yRb3ndfTFNHeWqPViprQ2IkBA0zPgYWO+FFx1BUbx49ty6a +mJMGF+hyfmvy0X609K21fOvmCZ0fz5CoDU5e8LUVNZHCoMmoqf7BUMBTzlVKzJVn +fxI5vxIdDBb+lK4nMi2AQm8qfAvXFh1awQIDAQABo1AwTjAdBgNVHQ4EFgQUiVZJ +ITXceZdB0zdmBprHCdAYmhwwHwYDVR0jBBgwFoAUiVZJITXceZdB0zdmBprHCdAY +mhwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEACfbUixYh/DdTWw7W +wDJZ9lA2ygx8i/mMUzi656LD/p79WRY0XhiM+3ecEEnsctjCHVeRF9ncIsx7TfZ0 +XkrfQQN+InFQnyVgI/NxPQZQMheKvRFzcmIorcaCJWsPDyurK+h7sUFqn4Ax7R7x +IAcxgTaVmD0A1oQbYKjWApKiC+3sDJZIDE78zUneqa+zkH6+7W6H6ZyzwauVSYxq +bysEYp2E/oHvqazQiFwfxWTTrgqKS4VYhN4eV2BQeNRD93UqK8/YUdiNLugc06HH +fLBO7gBdTXqPlRt4IpjNSrtAEvwdhoUO51uvbRiTVoBkbeEbbkXd5cb3IlD3GIb3 +PUdU7A== +-----END CERTIFICATE-----`; + +// The following two responses are derived from `samlResponseValidSignatures` above (same +// `certificate`) and reused by the signature-scope tests. + +// The valid response nested inside an outer element, so it is no longer the document root. +export const samlResponseNestedInWrapper = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + afI/fG2Gkj4Nlu5vC+PdYon1aNk=Gndt6TSrAaSs/BK84bqVXUMz3cvl34dIpHEZ2o7uqAf66SCF3qjLLm5fV/bFaSMOPwnVNJFjXpxmKdZI9mwBKBMYutxd43wkBvkp+3MYVZcRTpuU2Wo6iQLy9rhScB5MLRMEe3lKpwBRCKGEBUn1V/WaVUWlReHNHtwCnXD6FhtG4PBfd5p4dGePRQxFd9a0Pfm0wN4AposjLNNzGLf8yFTPTmGlZJ44U2IEUlxtOeH0MP7v7yAvwsjOk1PUJcUB4jgM8Y4WPCgEN7ntBdkSH8Q79tS6gyn/gAN9PW8QPcWNf3FVUnRfL8WRUeqUfohTUscRftj4Ff60Ob/FOeoIlQ== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; + +// The valid response with the assertion's signature relocated to the Response element, so the +// Response's signature references the assertion instead of the Response itself. +export const samlResponseSignatureReferenceMismatch = `http://localhost:8080/simplesaml/saml2/idp/metadata.php + + + r9sst6WOPoE361N1KL5Rf2pDFwE=p8D+3dBL5+hNtaVnXRMjZ8dFCFH/F1zNhQGSWLK2OPuhWEz/+vA9VgzdcKwH2H72B3Th0dskzRpznznCKYD6NKd9p+RTp9+MFd9xCZ4Aa5gZoiNbk2QcY1Wn30QjyzO3VWbCVcQpFOLJXfNppD/D4aTk8CH+elow+jFDimAIJQ4Y/w0Pzb9ANZpkxUFcBpCZPZ7b1YSgR2O5R7xmT/6x9PyQXqVJ595a7SmDMYzAL6SOfwz9QiJGpdX3WWVKB9lnLEnSjLIb9YV0Acv8+zAuTy7k6oBr428byR8LJbJUGe0a59gxgK5Oia9cmsu8WnCqGwyvFTjPCyq9dhz/9IZL5A== +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==http://localhost:8080/simplesaml/saml2/idp/metadata.php_d19335ecd7687bf141b820e91a8dc95d54a2ae1d8ehttp://localhost:3000/_saml/metadata/test-spurn:oasis:names:tc:SAML:2.0:ac:classes:Password1group1user1@example.comchannel1petsrandom`; diff --git a/apps/meteor/tests/unit/server/lib/saml/helpers.ts b/apps/meteor/tests/unit/server/lib/saml/helpers.ts new file mode 100644 index 0000000..71298aa --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/helpers.ts @@ -0,0 +1,43 @@ +import type { SAMLDocumentType, SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../../../../../server/lib/saml/definition/SAMLEnvelope'; + +export function makeLogoutEnvelope( + type: T, + xml: string, + signedContent?: string, + signature?: string, +): SAMLRedirectEnvelope { + return { + binding: 'HTTP-Redirect', + encodedDocument: xml, + decodedDocument: xml, + type, + relayState: 'relayState', + ...(signedContent && { signedContent }), + ...(signature && { + signature, + sigAlg: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + }), + }; +} + +export function makeLogoutRequestEnvelope(xml: string, signedContent?: string, signature?: string) { + return makeLogoutEnvelope('SAMLRequest', xml, signedContent, signature); +} + +export function makeLogoutResponseEnvelope(xml: string, signedContent?: string, signature?: string) { + return makeLogoutEnvelope('SAMLResponse', xml, signedContent, signature); +} + +export function makeLoginEnvelope(type: T, xml: string): SAMLPOSTEnvelope { + return { + binding: 'HTTP-POST', + encodedDocument: xml, + decodedDocument: xml, + type, + relayState: 'relayState', + }; +} + +export function makeLoginResponseEnvelope(xml: string) { + return makeLoginEnvelope('SAMLResponse', xml); +} diff --git a/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts b/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts new file mode 100644 index 0000000..ceed5a9 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts @@ -0,0 +1,225 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { InvalidIdpMetadataError, parseIdpMetadata } from '../../../../../server/lib/saml/lib/parsers/IdpMetadata'; + +// Real self-signed cert (CN=idp.test) — the parser validates X.509, so fixtures must be real certs. +const TEST_CERT = + 'MIIDBzCCAe+gAwIBAgIUZhaSm8CbG7FmgCQ2wi7+HLFQMokwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIaWRwLnRlc3QwHhcNMjYwNzE4MTk1NzIwWhcNMzYwNzE1MTk1NzIwWjATMREwDwYDVQQDDAhpZHAudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALbr9h69QN4dqnBTjnV7lWotx4FN2cktfL0Y2qVwQjw3q+OylaKCzio7Kvp2V2sQDsKDNgJnsLLP5TNjWqkXCfbbXRP/Iz3xlyyOoLNYJrZA4Sqn9/dFy6Chq5FSrMTwzqPCxx3nVDy/EpGUMknG7p3B0Ix18YFxQLsN5a/MpZXslrCusdl2LLnYkp6ztp44ZZlXHIaQhzeGnAZqzshvARAY9Ur41h4nSzpCgKVGACSi4LWRJeLc8/IXF+JM2MOR4GInCQLb1z31QPRRZ+3yWH3vKIfZ5YkPF6T0uPYw3hmhe0p7ECdOcSfckyeNT0WnvT8WKmeuOAnmmfqYPtgGBrkCAwEAAaNTMFEwHQYDVR0OBBYEFKQ/LDp2SiRg1jnOLLn/EkunXgaSMB8GA1UdIwQYMBaAFKQ/LDp2SiRg1jnOLLn/EkunXgaSMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACGBYnhbQXkSTerNUN0Wu2sy1MfFUzMYTP3SaXaCMJQhlZXUxtnu1PYTuqCgNkgaiV6b/KUBLVkqX4BaDmP0O5SI3di/uYlAJsBUsDCdaXToKsnjpf9tMvci2TGmPMZMDx9Jbr37G0NR9vewTOFvTkcjBQoXVu5oFbr75EDmxu3hqe8KHiavnX8C57zzpZ8kn37ScP+0Zadu0VYCtKEzfNKp48rCOF3BtYugGcxQWdcvbqurNF9Fyk9laSl8cTLHFeDe6zdWig32n3nHzhANOcezQ/wsUY5XUfUpRUl90rend7zqNq0tFZzOiZDl1MjCs7HCYtTRKEkuwlxgXdvP5Tg='; + +// Second distinct self-signed cert (CN=idp2.test), generated the same way, used to prove +// document-order-first selection among multiple KeyDescriptors. +const TEST_CERT_2 = + 'MIIDCTCCAfGgAwIBAgIUS4xN3PfQV0ROnf5nSMMFrN90pmQwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJaWRwMi50ZXN0MB4XDTI2MDcxODIwMTAzN1oXDTM2MDcxNTIwMTAzN1owFDESMBAGA1UEAwwJaWRwMi50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAdfR0s3uE/Rnv59RRTwNRuYVTgJoBMoP+xxMhlm8FfZZTsBqv3vt2eqyLaxnChpS+UWnpf56zZpFrNgEAH+dEFPIkhqRL7nUmKSOCXu3nCTS5K03PEWJzbWcjgtbZ6A7Q7BE9bRmBqkkbUi22XQML06A2GIzPE8xliVhnYH5q4GRNIYf6Eh0eV88XZrsXvYgHC46/O/MUMLV5a6tCq2SPvbBKJSI01YQcE/W/C7NtiHUnmkpvWFrJ7Wn2akAwVeA8rL5SJ2rMbO1CB9JgaspjOGdkLqRc2LsZDO2Fmsk1BS6EGaEnxFIggq/LwQmdeu0nK8bmwLZOAB1MgGXbBq/wIDAQABo1MwUTAdBgNVHQ4EFgQUfgd/rj1KGMuiBR7byOQU0zl/bAMwHwYDVR0jBBgwFoAUfgd/rj1KGMuiBR7byOQU0zl/bAMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGxdEIVNeoN+BV82FbNGmFPXiQRO1FZk0pUqPczQ5TbN8XvTFWfY2YeawgVn0ec6+eA1M97znDAM5KPF+9RHdk5HRrjS+BX+uJcFKlbWEbZJShk4PookxLq6ELZQ5HPSGgGvyqSueeAI0RMg6aBcZOIzsVJduSeOQNAmkvppN6rTryFVBHvkyI9qYLu9bxZW/BvyUfXmvU+yPRDa98s/WzQowIKktNPTCoxwn6KfGLlmeH5nm9ra48aRKFCXuSVhiuph3lmD4IfbUDYijpfe3kLsmp1Up9bhVH7WUiyT/sD2NFBZp60UAohjWGolNXhosrUOELBiZVuR75gvLmLFV5Q=='; + +const REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'; +const POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'; + +const keyDescriptor = (cert: string, use = 'signing') => ` + + + ${cert} + + `; + +const metadata = ({ + keys = keyDescriptor(TEST_CERT), + slo = ``, + sso = ``, + nameIdFormats = '', +} = {}) => ` + + + ${keys} + ${nameIdFormats} + ${slo} + ${sso} + +`; + +describe('parseIdpMetadata', () => { + it('extracts cert, entry point and SLO url from typical metadata', () => { + const result = parseIdpMetadata(metadata()); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40)); + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo'); + expect(result.warnings).to.deep.equal([]); + }); + + it('omits the SLO url without warning when metadata has no SingleLogoutService', () => { + const result = parseIdpMetadata(metadata({ slo: '' })); + expect(result.idpSLORedirectURL).to.be.undefined; + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('uses the first signing cert (document order) and warns when there are multiple', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT) + keyDescriptor(TEST_CERT_2) })); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40)); + expect(result.cert).to.not.include(TEST_CERT_2.substring(0, 40)); + expect(result.warnings).to.include('SAML_Metadata_warning_multiple_certs'); + }); + + it('accepts a KeyDescriptor without a use attribute', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT, '') })); + expect(result.cert).to.be.a('string'); + }); + + it('skips a KeyDescriptor whose content is not a valid X.509 certificate and warns', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') })); + expect(result.cert).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert'); + }); + + it('does not warn about multiple certs when only one of them is valid', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor(TEST_CERT_2) })); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT_2.substring(0, 40)); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_valid_cert'); + }); + + it('warns that no valid cert was found when every KeyDescriptor is invalid', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor('bm90IGEgY2VydA==') })); + expect(result.cert).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs'); + }); + + it('omits the entry point when only HTTP-POST SSO bindings exist', () => { + const result = parseIdpMetadata(metadata({ sso: `` })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('skips a Redirect SSO service with an invalid Location and uses the next valid one', () => { + const sso = ` + + `; + const result = parseIdpMetadata(metadata({ sso })); + expect(result.entryPoint).to.equal('https://idp.test/sso-2'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('omits the entry point and warns when the only Redirect SSO Location is not http(s)', () => { + const sso = ``; + const result = parseIdpMetadata(metadata({ sso })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('omits the SLO url and warns when no SingleLogoutService uses the HTTP-Redirect binding', () => { + const slo = ``; + const result = parseIdpMetadata(metadata({ slo })); + expect(result.idpSLORedirectURL).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('picks the Redirect SingleLogoutService even when a POST one comes first', () => { + const slo = ` + + `; + const result = parseIdpMetadata(metadata({ slo })); + expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo-redirect'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('ignores services nested under Extensions instead of declared directly on the descriptor', () => { + const sso = ` + + + + + `; + const result = parseIdpMetadata(metadata({ sso, slo: '' })); + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.idpSLORedirectURL).to.be.undefined; + }); + + it('omits the entry point and warns when there is no SingleSignOnService element at all', () => { + const result = parseIdpMetadata(metadata({ sso: '' })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('extracts the NameIDFormat', () => { + const result = parseIdpMetadata( + metadata({ nameIdFormats: 'urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress' }), + ); + expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_nameid_formats'); + }); + + it('uses the first NameIDFormat (document order) and warns when there are multiple', () => { + const nameIdFormats = ` + urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:transient`; + const result = parseIdpMetadata(metadata({ nameIdFormats })); + expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress'); + expect(result.warnings).to.include('SAML_Metadata_warning_multiple_nameid_formats'); + }); + + it('picks the SAML 2.0 role when another protocol comes first', () => { + const mixedRoles = ` + + + + + + + +`; + const result = parseIdpMetadata(mixedRoles); + expect(result.entryPoint).to.equal('https://mixed.test/saml2/sso'); + }); + + it('rejects metadata whose only IDPSSODescriptor does not support SAML 2.0', () => { + const saml1Only = ` + + + + +`; + expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects SP-only metadata (no IDPSSODescriptor)', () => { + const spOnly = ` + + +`; + expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects a document whose root is not EntityDescriptor', () => { + expect(() => parseIdpMetadata('nope')).to.throw(InvalidIdpMetadataError); + }); + + it('rejects a federation aggregate wrapping EntityDescriptors', () => { + const aggregate = ` + + + + + + +`; + expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects an otherwise-valid document with an unclosed inner element', () => { + // xmldom auto-closes the unclosed into a structurally-complete document (every guard + // passes), so this only throws because the parser treats the `warning` it emits as fatal. + const unclosedInner = ` + + + + + ${TEST_CERT} + + + + +`; + expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/saml/loginHandler.spec.ts b/apps/meteor/tests/unit/server/lib/saml/loginHandler.spec.ts new file mode 100644 index 0000000..c505165 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/loginHandler.spec.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const retrieveCredential = sinon.stub().resolves(null); +const removeById = sinon.stub().resolves(); +const samlUtilsMock = { + serviceProviders: [{ provider: 'test-saml' }] as any[], + log: sinon.stub(), + mapProfileToUserObject: sinon.stub(), + events: { emit: sinon.stub() }, +}; + +const handler = sinon.stub(); +proxyquire.noCallThru().load('../../../../../server/lib/saml/loginHandler', { + '@rocket.chat/models': { + CredentialTokens: { removeById }, + }, + 'meteor/accounts-base': { + Accounts: { + LoginCancelledError: { numericError: 403 }, + registerLoginHandler: (_name: string, fn: any) => { + handler.callsFake(fn); + }, + }, + }, + 'meteor/meteor': { + Meteor: { Error }, + }, + './lib/SAML': { + SAML: { retrieveCredential }, + }, + './lib/Utils': { + SAMLUtils: samlUtilsMock, + }, + '../i18n': { i18n: { t: sinon.stub().returns('') } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +describe('SAML loginHandler', () => { + beforeEach(() => { + retrieveCredential.reset(); + retrieveCredential.resolves(null); + removeById.reset(); + removeById.resolves(); + samlUtilsMock.serviceProviders = [{ provider: 'test-saml' }]; + }); + + it('should reject non-string credentialToken and never query the database (NoSQL injection prevention)', async () => { + expect(await handler({ saml: true, credentialToken: { $gt: '' } })).to.be.undefined; + expect(await handler({ saml: true, credentialToken: { $ne: null } })).to.be.undefined; + expect(await handler({ saml: true, credentialToken: { $not: { $eq: '__nonexistent__' } } })).to.be.undefined; + expect(await handler({ saml: true, credentialToken: 123 })).to.be.undefined; + expect(await handler({ saml: true, credentialToken: ['a'] })).to.be.undefined; + expect(await handler({ saml: true, credentialToken: null })).to.be.undefined; + + expect(retrieveCredential.called).to.be.false; + }); + + it('should return undefined when no SAML providers are configured', async () => { + samlUtilsMock.serviceProviders = []; + + expect(await handler({ saml: true, credentialToken: 'valid-token' })).to.be.undefined; + expect(retrieveCredential.called).to.be.false; + }); + + it('should delete the credential token after retrieval', async () => { + await handler({ saml: true, credentialToken: 'token-to-delete' }); + + expect(removeById.calledOnce).to.be.true; + expect(removeById.calledWith('token-to-delete')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/saml/server.tests.ts b/apps/meteor/tests/unit/server/lib/saml/server.tests.ts new file mode 100644 index 0000000..a2dc7f3 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/server.tests.ts @@ -0,0 +1,1267 @@ +import zlib from 'node:zlib'; + +import { isTruthy } from '@rocket.chat/tools'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { + serviceProviderOptions, + simpleMetadata, + metadataWithCertificate, + simpleLogoutRequest, + invalidXml, + randomXml, + invalidLogoutRequest, + simpleLogoutResponse, + invalidLogoutResponse, + simpleSamlResponse, + samlResponse, + duplicatedSamlResponse, + samlResponseMissingStatus, + samlResponseFailedStatus, + samlResponseMultipleAssertions, + samlResponseMissingAssertion, + samlResponseMultipleIssuers, + samlResponseWithConditions, + samlResponseAssertionId, + samlResponseValidSignatures, + samlResponseValidAssertionSignature, + samlResponseNestedInWrapper, + samlResponseSignatureReferenceMismatch, + encryptedResponse, + profile, + certificate, + privateKeyCert, + privateKey, +} from './data'; +import { makeLoginResponseEnvelope, makeLogoutRequestEnvelope, makeLogoutResponseEnvelope } from './helpers'; +import { SAMLUtils } from '../../../../../server/lib/saml/lib/Utils'; +import { AuthorizeRequest } from '../../../../../server/lib/saml/lib/generators/AuthorizeRequest'; +import { LogoutRequest } from '../../../../../server/lib/saml/lib/generators/LogoutRequest'; +import { LogoutResponse } from '../../../../../server/lib/saml/lib/generators/LogoutResponse'; +import { LogoutRequestParser } from '../../../../../server/lib/saml/lib/parsers/LogoutRequest'; +import { LogoutResponseParser } from '../../../../../server/lib/saml/lib/parsers/LogoutResponse'; +import { ResponseParser } from '../../../../../server/lib/saml/lib/parsers/Response'; + +const meteorStub = { + 'meteor/meteor': { + '@global': true, + 'Meteor': { + absoluteUrl() { + return 'http://localhost:3000/'; + }, + }, + }, +}; + +const { ServiceProviderMetadata } = proxyquire + .noCallThru() + .load('../../../../../server/lib/saml/lib/generators/ServiceProviderMetadata', meteorStub); + +const { SAMLServiceProvider } = proxyquire.noCallThru().load('../../../../../server/lib/saml/lib/ServiceProvider', meteorStub); + +describe('SAML', () => { + describe('[AuthorizeRequest]', () => { + describe('AuthorizeRequest.generate', () => { + it('should use the custom templates to generate the request', () => { + const credentialToken = '__credentialToken__'; + const authorizeRequest = AuthorizeRequest.generate(serviceProviderOptions, credentialToken); + expect(authorizeRequest.id).to.be.equal(credentialToken); + expect(authorizeRequest.request).to.be.equal( + ' Password ', + ); + }); + + it('should include the unique ID on the request', () => { + const credentialToken = '__credentialToken__'; + const customOptions = { + ...serviceProviderOptions, + authRequestTemplate: '__newId__', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); + expect(authorizeRequest.id).to.be.equal(credentialToken); + expect(authorizeRequest.request).to.be.equal(authorizeRequest.id); + }); + + it('should include the custom options on the request', () => { + const credentialToken = '__credentialToken__'; + const customOptions = { + ...serviceProviderOptions, + authRequestTemplate: '__callbackUrl__ __entryPoint__ __issuer__', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); + expect(authorizeRequest.id).to.be.equal(credentialToken); + expect(authorizeRequest.request).to.be.equal('[callback-url] [entry-point] [issuer]'); + }); + + it('should omit the authn context block when the customAuthnContext setting is empty', () => { + const credentialToken = '__credentialToken__'; + const customOptions = { + ...serviceProviderOptions, + customAuthnContext: '', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); + expect(authorizeRequest.request).to.not.include(' { + const credentialToken = '__credentialToken__'; + const customOptions = { + ...serviceProviderOptions, + customAuthnContext: ' ', + }; + + const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); + expect(authorizeRequest.request).to.not.include(' { + describe('LogoutRequest.generate', () => { + it('should use the custom template to generate the request', () => { + const logoutRequest = LogoutRequest.generate(serviceProviderOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal('[logout-request-template]'); + }); + + it('should include the unique ID on the request', () => { + const customOptions = { + ...serviceProviderOptions, + logoutRequestTemplate: '__newId__', + }; + + const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal(logoutRequest.id); + }); + + it('should include the custom options on the request', () => { + const customOptions = { + ...serviceProviderOptions, + logoutRequestTemplate: '__idpSLORedirectURL__ __issuer__ __identifierFormat__ __nameID__ __sessionIndex__', + }; + + const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); + expect(logoutRequest.request).to.be.equal('[idpSLORedirectURL] [issuer] email NameID sessionIndex'); + }); + }); + + describe('LogoutRequest.validate', () => { + it('should extract the idpSession and nameID from the request', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + + void parser.validate(makeLogoutRequestEnvelope(simpleLogoutRequest), async (err, data) => { + expect(err).to.be.null; + expect(data).to.be.an('object'); + expect(data).to.have.property('idpSession'); + expect(data).to.have.property('nameID'); + // @ts-ignore -- chai already ensured the object exists + expect(data.idpSession).to.be.equal('_d6ad0e25459aaddd0433a81e159aa79e55dc52c280'); + // @ts-ignore -- chai already ensured the object exists + expect(data.nameID).to.be.equal('_ab7e1d9a603473e92148d569d50176bafa60bcb2e9'); + }); + }); + + it('should fail to parse an invalid xml', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + void parser.validate(makeLogoutRequestEnvelope(invalidXml), async (err, data) => { + expect(err).to.exist; + expect(data).to.not.exist; + }); + }); + + it('should fail to parse a xml without any LogoutRequest tag', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + void parser.validate(makeLogoutRequestEnvelope(randomXml), async (err, data) => { + expect(err).to.be.equal('No Request Found'); + expect(data).to.not.exist; + }); + }); + + it('should fail to parse a request with no NameId', () => { + const parser = new LogoutRequestParser(serviceProviderOptions); + + void parser.validate(makeLogoutRequestEnvelope(invalidLogoutRequest), async (err, data) => { + expect(err).to.be.an('error').that.has.property('message').equal('SAML Logout Request: No NameID node found'); + expect(data).to.not.exist; + }); + }); + }); + }); + + describe('[LogoutResponse]', () => { + describe('LogoutResponse.generate', () => { + it('should use the custom template to generate the response', () => { + const logoutResponse = LogoutResponse.generate(serviceProviderOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal('[logout-response-template]'); + }); + + it('should include the unique ID on the response', () => { + const customOptions = { + ...serviceProviderOptions, + logoutResponseTemplate: '__newId__', + }; + + const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal(logoutResponse.id); + }); + + it('should include the custom options on the response', () => { + const customOptions = { + ...serviceProviderOptions, + logoutResponseTemplate: '__idpSLORedirectURL__ __issuer__', + }; + + const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); + expect(logoutResponse.response).to.be.equal('[idpSLORedirectURL] [issuer]'); + }); + }); + + describe('LogoutResponse.validate', () => { + it('should extract the inResponseTo from the response', () => { + const logoutResponse = simpleLogoutResponse.replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success'); + const parser = new LogoutResponseParser(serviceProviderOptions); + + void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { + expect(err).to.be.null; + expect(inResponseTo).to.be.equal('_id-6530db3fcd23dc42a31c'); + }); + }); + + it('should reject a response with a non-success StatusCode', () => { + const logoutResponse = simpleLogoutResponse.replace('[STATUSCODE]', 'Anything'); + const parser = new LogoutResponseParser(serviceProviderOptions); + + void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { + expect(err).to.be.equal('Error. Logout not confirmed by IDP'); + expect(inResponseTo).to.be.null; + }); + }); + + it('should fail to parse an invalid xml', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + void parser.validate(makeLogoutResponseEnvelope(invalidXml), async (err, inResponseTo) => { + expect(err).to.exist; + expect(inResponseTo).to.not.exist; + }); + }); + + it('should fail to parse a xml without any LogoutResponse tag', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + void parser.validate(makeLogoutResponseEnvelope(randomXml), async (err, inResponseTo) => { + expect(err).to.be.equal('No Response Found'); + expect(inResponseTo).to.not.exist; + }); + }); + + it('should fail to parse a xml without an inResponseTo attribute', () => { + const instant = new Date().toISOString(); + const logoutResponse = simpleLogoutResponse + .replace('[INSTANT]', instant) + .replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success') + .replace('InResponseTo=', 'SomethingElse='); + + const parser = new LogoutResponseParser(serviceProviderOptions); + void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { + expect(err).to.be.equal('Unexpected Response from IDP'); + expect(inResponseTo).to.not.exist; + }); + }); + + it('should reject a response with no status tag', () => { + const parser = new LogoutResponseParser(serviceProviderOptions); + + void parser.validate(makeLogoutResponseEnvelope(invalidLogoutResponse), async (err, inResponseTo) => { + expect(err).to.be.equal('Error. Logout not confirmed by IDP'); + expect(inResponseTo).to.be.null; + }); + }); + }); + + describe('logoutResponseToUrl', () => { + const serviceProvider = new SAMLServiceProvider(serviceProviderOptions); + + const generateUrl = (relayState: string | undefined): Promise => + new Promise((resolve, reject) => { + serviceProvider.logoutResponseToUrl('logout-response', relayState, (err: unknown, url?: string) => { + if (err || !url) { + return reject(err ?? new Error('No URL returned')); + } + resolve(url); + }); + }); + + it('should echo back the exact RelayState received on the request', async () => { + const relayState = 'idp-shared-session-relay-state'; + const url = await generateUrl(relayState); + const params = new URLSearchParams(url.split('?')[1]); + + expect(url).to.match(/^\[idpSLORedirectURL\]\?/); + expect(params.get('SAMLResponse')).to.be.a('string').that.is.not.empty; + expect(params.get('RelayState')).to.be.equal(relayState); + }); + + it('should preserve a RelayState that requires URL encoding', async () => { + const relayState = 'https://sp.example.com/return?foo=bar baz&x=1'; + const url = await generateUrl(relayState); + const params = new URLSearchParams(url.split('?')[1]); + + expect(params.get('RelayState')).to.be.equal(relayState); + }); + + it('should omit RelayState entirely when none was received on the request', async () => { + const url = await generateUrl(undefined); + const params = new URLSearchParams(url.split('?')[1]); + + expect(params.has('RelayState')).to.be.false; + expect(params.get('SAMLResponse')).to.be.a('string').that.is.not.empty; + }); + + it('should target the IdP SLO endpoint with the deflated response and the relay state', async () => { + const url = await generateUrl('relay-123'); + const [target, query] = url.split('?'); + const params = new URLSearchParams(query); + + expect(target).to.be.equal('[idpSLORedirectURL]'); + expect([...params.keys()]).to.be.deep.equal(['SAMLResponse', 'RelayState']); + expect(params.get('RelayState')).to.be.equal('relay-123'); + + const inflated = zlib.inflateRawSync(Buffer.from(params.get('SAMLResponse') ?? '', 'base64')).toString(); + expect(inflated).to.be.equal('logout-response'); + }); + }); + }); + + describe('[Metadata]', () => { + describe('[Metadata.generate]', () => { + it('should generate a simple metadata file when no certificate info is included', () => { + const metadata = ServiceProviderMetadata.generate(serviceProviderOptions); + expect(metadata).to.be.equal(simpleMetadata); + }); + + it('should include additional information when a certificate is provided', () => { + const customOptions = { + ...serviceProviderOptions, + privateCert: '[CERTIFICATE_CONTENT]', + privateKey: '[PRIVATE_KEY]', + }; + + const metadata = ServiceProviderMetadata.generate(customOptions); + expect(metadata).to.be.equal(metadataWithCertificate); + }); + }); + }); + + describe('[Response]', () => { + describe('[Response.validate]', () => { + it('should extract a profile from the response', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { + expect(err).to.be.null; + expect(profile).to.be.an('object'); + expect(profile).to.have.property('inResponseToId').equal('[INRESPONSETO]'); + expect(profile).to.have.property('issuer').equal('[ISSUER]'); + expect(profile).to.have.property('nameID').equal('[NAMEID]'); + expect(profile).to.have.property('sessionIndex').equal('[SESSIONINDEX]'); + expect(profile).to.have.property('uid').equal('1'); + expect(profile).to.have.property('eduPersonAffiliation').equal('group1'); + expect(profile).to.have.property('email').equal('user1@example.com'); + expect(profile).to.have.property('channels').that.is.an('array').that.is.deep.equal(['channel1', 'pets', 'random']); + expect(loggedOut).to.be.false; + }); + }); + + it('should contain properties to avoid assertions replay in the profile object from the response', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = samlResponseWithConditions + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { + expect(err).to.be.null; + expect(profile).to.be.an('object'); + expect(profile).to.have.property('assertionId').equal(samlResponseAssertionId); + expect(profile).to.have.property('expireAt'); + // `expireAt` mirrors the assertion's NotOnOrAfter condition. + expect(new Date((profile as any).expireAt).toISOString()).to.equal(notOnOrAfter.toISOString()); + expect(loggedOut).to.be.false; + }); + }); + + it('should respect NotOnOrAfter conditions', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', new Date().toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(profile).to.be.null; + expect(loggedOut).to.be.false; + }); + }); + + it('should respect NotBefore conditions', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() + 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(profile).to.be.null; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse an invalid xml', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(invalidXml), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a xml without any Response tag', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(randomXml), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a xml with multiple responses', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(duplicatedSamlResponse), (err, data, loggedOut) => { + expect(err).to.be.an('error'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a reponse with no Status tag', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseMissingStatus), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing StatusCode'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should fail to parse a reponse with a failed status', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseFailedStatus), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Status is: Failed'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a response with multiple assertions', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseMultipleAssertions), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many SAML assertions'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a response with no assertions', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseMissingAssertion), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing SAML assertion'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a document without signatures if the setting requires at least one', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Either', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('No valid SAML Signature found'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject a document with multiple issuers', () => { + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseMultipleIssuers), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many Issuers'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should decrypt an encrypted response', () => { + const options = { + ...serviceProviderOptions, + privateCert: privateKeyCert, + privateKey, + }; + + const parser = new ResponseParser(options); + parser.validate(makeLoginResponseEnvelope(encryptedResponse), (err, profile, loggedOut) => { + // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should validate signatures on an encrypted response', () => { + const options = { + ...serviceProviderOptions, + privateCert: privateKeyCert, + signatureValidationType: 'All', + privateKey, + cert: certificate, + }; + + const parser = new ResponseParser(options); + parser.validate(makeLoginResponseEnvelope(encryptedResponse), (err, profile, loggedOut) => { + // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + }); + + describe('[Validate Signatures]', () => { + it('should reject an unsigned assertion if the setting says so', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject an unsigned response if the setting says so', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: 'invalidCert', + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should reject an assertion signed with an invalid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: certificate, + }; + + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); + expect(data).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + + it('should accept an assertion with a valid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should reject an assertion with a valid signature if the certificate is not configured', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Assertion', + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unable to validate signature'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with a valid response signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidSignatures), (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should reject a document with a valid signature of the wrong type', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Response', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with both valid signatures', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'All', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidSignatures), (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should reject a document with a single signature when both are expected', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'All', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + it('should accept a document with either valid signature', () => { + const providerOptions = { + ...serviceProviderOptions, + signatureValidationType: 'Either', + cert: certificate, + }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { + // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ + expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); + expect(loggedOut).to.be.false; + expect(profile).to.be.null; + }); + }); + + for (const signatureValidationType of ['None', 'Response', 'Assertion', 'Either', 'All']) { + it(`should reject a response that is not the document root (${signatureValidationType})`, () => { + const providerOptions = { ...serviceProviderOptions, signatureValidationType, cert: certificate }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseNestedInWrapper), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('SAML Response must be the document root'); + expect(profile).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + } + + for (const signatureValidationType of ['Response', 'All']) { + it(`should reject a response whose signature does not cover the validated element (${signatureValidationType})`, () => { + const providerOptions = { ...serviceProviderOptions, signatureValidationType, cert: certificate }; + + const parser = new ResponseParser(providerOptions); + parser.validate(makeLoginResponseEnvelope(samlResponseSignatureReferenceMismatch), (err, profile, loggedOut) => { + expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); + expect(profile).to.not.exist; + expect(loggedOut).to.be.false; + }); + }); + } + }); + }); + + describe('[Login]', () => { + describe('UserMapping', () => { + it('should collect all appropriate data from the profile, respecting the fieldMap', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('samlLogin').that.is.an('object'); + expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); + expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[IssuerName]'); + expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SessionIndex]'); + expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[nameID]'); + expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('testing@server.com'); + expect(userObject).to.have.property('fullName').that.is.equal('[AnotherName]'); + expect(userObject).to.have.property('username').that.is.equal('[AnotherUserName]'); + expect(userObject).to.not.have.property('roles'); + expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny', 'random', 'babies']); + }); + + it('should join array values if username receives an array of values', () => { + const { globalSettings } = SAMLUtils; + + const multipleUsernames = { + ...profile, + anotherUsername: ['user1', 'user2'], + }; + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(multipleUsernames); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('samlLogin').that.is.an('object'); + expect(userObject).to.have.property('username').that.is.equal('user1 user2'); + }); + + // Channels support both a comma separated single value and an array of values + it('should support `channels` attribute with multiple values', () => { + const channelsProfile = { + ...profile, + channels: ['pets', 'pics', 'funny'], + }; + + const userObject = SAMLUtils.mapProfileToUserObject(channelsProfile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny']); + }); + + it('should reject an userDataFieldMap without an email field', () => { + const { globalSettings } = SAMLUtils; + globalSettings.userDataFieldMap = JSON.stringify({}); + SAMLUtils.updateGlobalSettings(globalSettings); + + expect(() => { + SAMLUtils.mapProfileToUserObject(profile); + }).to.throw('SAML Profile did not contain an email address'); + }); + + it('should fail to map a profile that is missing the email field', () => { + const { globalSettings } = SAMLUtils; + const fieldMap = { + inexistentField: 'email', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + SAMLUtils.updateGlobalSettings(globalSettings); + + expect(() => { + SAMLUtils.mapProfileToUserObject(profile); + }).to.throw('SAML Profile did not contain an email address'); + }); + + it('should load data from the default fields when the field map is lacking', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + email: 'singleEmail', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName]'); + expect(userObject).to.have.property('username').that.is.equal('[username]'); + }); + + it('should run custom regexes when one is used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'singleEmail', + regex: '(.*)@.+$', + }, + email: 'singleEmail', + name: 'anotherName', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('username').that.is.equal('testing'); + }); + + it('should run custom templates when one is used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'anotherName', + template: 'user-__anotherName__', + }, + email: 'singleEmail', + name: { + fieldNames: ['anotherName', 'displayName'], + template: '__displayName__ (__anotherName__)', + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('username').that.is.equal('user-[AnotherName]'); + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] ([AnotherName])'); + }); + + it('should combine regexes and templates when both are used', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'anotherName', + template: 'user-__anotherName__45@7', + regex: 'user-(.*)@', + }, + email: 'singleEmail', + name: { + fieldNames: ['anotherName', 'displayName'], + regex: '\\[(.*)\\]', + template: '__displayName__ (__regex__)', + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + // should run the template first, then the regex + expect(userObject).to.have.property('username').that.is.equal('[AnotherName]45'); + // for this one, should run the regex first, then the template + expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] (AnotherName)'); + }); + + it('should support individual array values on templates', () => { + const { globalSettings } = SAMLUtils; + + const multipleUsernames = { + ...profile, + anotherUsername: ['1', '2'], + }; + + const fieldMap = { + username: { + fieldName: 'anotherUsername', + template: 'user-__anotherUsername[-1]__', + }, + email: { + fieldName: 'anotherUsername', + template: 'user-__anotherUsername[0]__', + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(multipleUsernames); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('username').that.is.equal('user-2'); + expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('user-1'); + }); + + it('should collect the values of every attribute on the field map', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + others: { + fieldNames: ['issuer', 'sessionIndex', 'nameID', 'displayName', 'username', 'roles', 'otherRoles', 'language', 'channels'], + }, + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject) + .to.have.property('attributeList') + .that.is.a('Map') + .that.have.keys([ + 'anotherUsername', + 'singleEmail', + 'anotherName', + 'issuer', + 'sessionIndex', + 'nameID', + 'displayName', + 'username', + 'roles', + 'otherRoles', + 'language', + 'channels', + ]); + + // Workaround because chai doesn't handle Maps very well + for (const [key, value] of userObject.attributeList) { + expect(value).to.be.equal(profile[key as keyof typeof profile]); + } + }); + + it('should use the immutable property as default identifier', () => { + const { globalSettings } = SAMLUtils; + + globalSettings.immutableProperty = 'EMail'; + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('email'); + + globalSettings.immutableProperty = 'Username'; + SAMLUtils.updateGlobalSettings(globalSettings); + + const newUserObject = SAMLUtils.mapProfileToUserObject(profile); + expect(newUserObject).to.be.an('object'); + expect(newUserObject).to.have.property('identifier').that.has.property('type').that.is.equal('username'); + }); + + it('should collect the identifier from the fieldset', () => { + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: 'anotherUsername', + email: 'singleEmail', + name: 'anotherName', + __identifier__: 'customField3', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + SAMLUtils.updateGlobalSettings(globalSettings); + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('custom'); + expect(userObject).to.have.property('identifier').that.has.property('attribute').that.is.equal('customField3'); + }); + }); + }); + + describe('Response Mapping', () => { + it('should extract a mapped user from the response', () => { + const notBefore = new Date(); + notBefore.setMinutes(notBefore.getMinutes() - 3); + + const notOnOrAfter = new Date(); + notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); + + const response = simpleSamlResponse + .replace('[NOTBEFORE]', notBefore.toISOString()) + .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); + + const parser = new ResponseParser(serviceProviderOptions); + parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { + expect(profile).to.be.an('object'); + expect(err).to.be.null; + expect(loggedOut).to.be.false; + + const { globalSettings } = SAMLUtils; + + const fieldMap = { + username: { + fieldName: 'uid', + template: 'user-__uid__', + }, + email: 'email', + }; + + globalSettings.userDataFieldMap = JSON.stringify(fieldMap); + + SAMLUtils.updateGlobalSettings(globalSettings); + SAMLUtils.relayState = '[RelayState]'; + + if (!isTruthy(profile)) { + throw new Error('Profile is null'); + } + + const userObject = SAMLUtils.mapProfileToUserObject(profile); + + expect(userObject).to.be.an('object'); + expect(userObject).to.have.property('samlLogin').that.is.an('object'); + expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); + expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[ISSUER]'); + expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SESSIONINDEX]'); + expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[NAMEID]'); + expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('user1@example.com'); + expect(userObject).to.have.property('username').that.is.equal('user-1'); + + const map = new Map(); + map.set('epa', 'group1'); + }); + }); + }); + + describe('[Utils]', () => { + it('should return correct validation action redirect path', () => { + const credentialToken = SAMLUtils.generateUniqueID(); + expect(SAMLUtils.getValidationActionRedirectPath(credentialToken)).to.be.equal( + `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`, + ); + }); + + describe('authorize RelayState encoding', () => { + it('should keep the provider when no loginClient is provided', () => { + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp')).to.be.equal('test-sp'); + }); + + it('should ignore unsupported loginClient values and keep the provider', () => { + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', 'web')).to.be.equal('test-sp'); + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', '')).to.be.equal('test-sp'); + }); + + it('should round-trip provider and loginClient for supported clients', () => { + for (const loginClient of ['mobile', 'desktop']) { + const encoded = SAMLUtils.encodeAuthorizeRelayState('test-sp', loginClient); + expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'test-sp', loginClient }); + } + }); + + it('should drop an unexpected loginClient echoed back in a compound RelayState', () => { + expect(SAMLUtils.decodeAuthorizeRelayState('provider=test-sp&loginClient=hacker')).to.be.deep.equal({ + provider: 'test-sp', + loginClient: undefined, + }); + }); + + it('should preserve providers containing URL-special characters', () => { + const encoded = SAMLUtils.encodeAuthorizeRelayState('a b&c=d', 'mobile'); + expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'a b&c=d', loginClient: 'mobile' }); + }); + }); + }); + + describe('[SAML.processRequest] validate action - assertion replay protection', () => { + const markUsed = sinon.stub(); + const credentialCreate = sinon.stub().resolves(); + + // `pending` captures the promise returned by the (async) validateResponse callback, + // because processValidateAction does not await it. Awaiting it in the test guarantees + // markUsed / storeCredential / redirect have already run before we assert. + let pending: Promise | undefined; + let lastRedirect: string | undefined; + + // A profile as produced by ResponseParser, carrying the replay-guard fields. + const replayProfile = { + assertionId: samlResponseAssertionId, + issuer: '[ISSUER]', + expireAt: new Date(Date.now() + 5 * 60 * 1000), + }; + + const res = { + writeHead: (_code: number, headers?: Record) => { + lastRedirect = headers?.Location; + }, + write: () => undefined, + end: () => undefined, + }; + + const loadSAML = () => + proxyquire.noCallThru().load('../../../../../server/lib/saml/lib/SAML', { + '@rocket.chat/models': { + SamlUsedAssertions: { markUsed }, + CredentialTokens: { create: credentialCreate }, + Users: {}, + Rooms: {}, + Roles: {}, + }, + '@rocket.chat/random': { Random: { id: () => '__credentialToken__' } }, + '@rocket.chat/tools': { escapeRegExp: (s: string) => s, escapeHTML: (s: string) => s }, + 'meteor/accounts-base': { Accounts: { _generateStampedLoginToken: () => ({ token: 't' }) } }, + 'meteor/meteor': { Meteor: { absoluteUrl: (path = '') => `http://localhost:3000/${path}`, Error } }, + './ServiceProvider': { + SAMLServiceProvider: class { + validateResponse(_envelope: unknown, cb: (err: Error | null, profile: any) => Promise) { + pending = cb(null, replayProfile); + } + }, + }, + './Utils': { + SAMLUtils: { + relayState: null, + error: sinon.stub(), + warn: sinon.stub(), + log: sinon.stub(), + getValidationActionRedirectPath: (token: string) => `_saml/validate/${token}`, + decodeAuthorizeRelayState: (provider: string) => ({ provider }), + }, + }, + './getSAMLEnvelope': { getSAMLEnvelope: async () => ({ relayState: null }) }, + '../../../../lib/utils/arrayUtils': { ensureArray: (v: any) => v }, + '../../logger/system': { SystemLogger: { error: sinon.stub(), warn: sinon.stub() } }, + '../../rooms/addUserToRoom': { addUserToRoom: sinon.stub() }, + '../../rooms/createRoom': { createRoom: sinon.stub() }, + '../../users/getUsernameSuggestion': { generateUsernameSuggestion: sinon.stub() }, + '../../users/saveUserIdentity': { saveUserIdentity: sinon.stub() }, + '../../../settings': { settings: { get: sinon.stub() } }, + '../../../../app/utils/lib/i18n': { i18n: { t: (s: string) => s, languages: [] } }, + }).SAML; + + const service = { ...serviceProviderOptions } as any; + const samlObject = { actionName: 'validate', serviceName: 'test-sp', credentialToken: '' } as any; + + beforeEach(() => { + markUsed.reset(); + credentialCreate.reset(); + credentialCreate.resolves(); + pending = undefined; + lastRedirect = undefined; + }); + + it('should mark the assertion as used and store the credential for a fresh assertion', async () => { + markUsed.resolves(true); + const SAML = loadSAML(); + + await SAML.processRequest({} as any, res as any, service, samlObject); + await pending; + + expect(markUsed.calledOnceWithExactly(replayProfile.assertionId, replayProfile.issuer, replayProfile.expireAt)).to.be.true; + expect(credentialCreate.calledOnce).to.be.true; + expect(lastRedirect).to.equal('http://localhost:3000/_saml/validate/__credentialToken__'); + }); + + it('should reject a replayed assertion: no credential stored and redirect to the base url', async () => { + markUsed.resolves(false); + const SAML = loadSAML(); + + await SAML.processRequest({} as any, res as any, service, samlObject); + await pending; + + expect(markUsed.calledOnce).to.be.true; + expect(credentialCreate.called).to.be.false; + expect(lastRedirect).to.equal('http://localhost:3000/'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts new file mode 100644 index 0000000..66af57e --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts @@ -0,0 +1,197 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +import { registerSettingSchema } from '../../../../server/settings/functions/settingSchemas'; +import { + positiveOrDisabled, + notGreaterThanSetting, + notLowerThanSetting, +} from '../../../../server/settings/functions/validationRuleBuilders'; + +const settingsGetMock = sinon.stub(); +const settingsGetSettingMock = sinon.stub(); + +const { validateSettingRules, SettingValidationError } = p.noCallThru().load('../../../../server/lib/settingValidationRules.ts', { + '@rocket.chat/logger': { + Logger: class { + error = (): undefined => undefined; + }, + }, + '../settings': { settings: { get: settingsGetMock, getSetting: settingsGetSettingMock } }, +}); + +const validationBySettingId: Record = { + Accounts_Password_Policy_MinLength: [positiveOrDisabled(), notGreaterThanSetting('Accounts_Password_Policy_MaxLength')], + Accounts_Password_Policy_MaxLength: [positiveOrDisabled(), notLowerThanSetting('Accounts_Password_Policy_MinLength')], +}; + +describe('validateSettingRules', () => { + beforeEach(() => { + settingsGetMock.reset(); + settingsGetSettingMock.reset(); + + settingsGetSettingMock.callsFake((_id: string) => ({ + _id, + type: 'int', + ...(validationBySettingId[_id] ? { validation: JSON.stringify(validationBySettingId[_id]) } : {}), + })); + }); + + it('rejects an incoherent batch with the failed rule i18n key as the error message', () => { + const error = (() => { + try { + validateSettingRules([ + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 4 }, + ]); + } catch (thrown) { + return thrown as Error; + } + })(); + + expect(error).to.be.instanceOf(SettingValidationError); + expect(error?.message).to.equal('Accounts_Password_Policy_MinLength_Invalid'); + }); + + it('resolves values batch-first, not from the stale cached value', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(14); + + expect(() => + validateSettingRules([ + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 10 }, + ]), + ).to.not.throw(); + }); + + it('rejects raising the minimum above the stored maximum, with the minimum-side message', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 12 }])).to.throw( + 'Accounts_Password_Policy_MinLength_Invalid', + ); + }); + + it('rejects lowering the maximum below the stored minimum, with the maximum-side message', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 4 }])).to.throw( + 'Accounts_Password_Policy_MaxLength_Invalid', + ); + }); + + it('treats exactly -1 as a disabled bound, on either side', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(-1); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 20 }])).to.not.throw(); + + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(-1); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 5 }])).to.not.throw(); + + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: -1 }])).to.not.throw(); + }); + + it('rejects bound values below 1 that are not exactly -1', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 0 }])).to.throw( + 'Accounts_Password_Policy_MaxLength_Invalid', + ); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: -5 }])).to.throw( + 'Accounts_Password_Policy_MinLength_Invalid', + ); + }); + + it('validates the pair even when the same batch disables the policy', () => { + expect(() => + validateSettingRules([ + { _id: 'Accounts_Password_Policy_Enabled', value: false }, + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 4 }, + ]), + ).to.throw('Accounts_Password_Policy_MinLength_Invalid'); + }); + + it('does not evaluate rules of settings that are not in the batch', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(4); + + expect(() => validateSettingRules([{ _id: 'Some_Unrelated_Setting', value: 10 }])).to.not.throw(); + }); + + it('passes a rule referencing a setting that does not exist', () => { + settingsGetSettingMock.withArgs('Ref_Setting').returns({ + _id: 'Ref_Setting', + type: 'int', + validation: '[{"query":{"value":{"$lte":{"$setting":"Missing_Setting"}}}}]', + }); + + expect(() => validateSettingRules([{ _id: 'Ref_Setting', value: 999 }])).to.not.throw(); + }); + + it('skips malformed persisted rules instead of crashing the save', () => { + const malformed = [ + '[{"query":null}]', + '[{"query":{"value":{"$gte":1}},"appliesWhen":"junk"}]', + '[{"query":{"value":{"$gte":1}},"appliesWhen":{"_id":"Gate"}}]', + ]; + + for (const validation of malformed) { + settingsGetSettingMock.withArgs('Malformed_Setting').returns({ _id: 'Malformed_Setting', type: 'int', validation }); + + expect(() => validateSettingRules([{ _id: 'Malformed_Setting', value: 0 }]), validation).to.not.throw(); + } + }); + + it('does not enforce the value type, leaving those checks to each save path', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 5.5 }])).to.not.throw(); + }); + + describe('JSON settings with a schema', () => { + const schema = { + type: 'object', + required: ['version'], + additionalProperties: false, + properties: { version: { const: 1 } }, + }; + + beforeEach(() => { + registerSettingSchema('Json_Config', schema); + settingsGetSettingMock.withArgs('Json_Config').returns({ + _id: 'Json_Config', + type: 'code', + code: 'application/json', + }); + }); + + it('accepts a JSON document matching the schema', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":1}' }])).to.not.throw(); + }); + + it('rejects a JSON document violating the schema', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":2}' }])).to.throw('Json_Config_Invalid'); + }); + + it('rejects a value that is not parseable JSON', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: 'not json {' }])).to.throw('Json_Config_Invalid'); + }); + + it('accepts an empty value, meaning the setting is unconfigured', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '' }])).to.not.throw(); + }); + + it('ignores a schema registered for a code setting that is not application/json', () => { + registerSettingSchema('Js_Code', schema); + settingsGetSettingMock.withArgs('Js_Code').returns({ + _id: 'Js_Code', + type: 'code', + code: 'text/javascript', + }); + + expect(() => validateSettingRules([{ _id: 'Js_Code', value: 'not json {' }])).to.not.throw(); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/shared/disableCustomScripts.tests.ts b/apps/meteor/tests/unit/server/lib/shared/disableCustomScripts.tests.ts new file mode 100644 index 0000000..4af38b8 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/shared/disableCustomScripts.tests.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('disableCustomScripts', () => { + let mockLicense: sinon.SinonStubbedInstance; + let disableCustomScripts: () => boolean; + let disableCustomScriptsVar: any; + + beforeEach(() => { + disableCustomScriptsVar = process.env.DISABLE_CUSTOM_SCRIPTS; + mockLicense = { + getLicense: sinon.stub(), + }; + + disableCustomScripts = proxyquire('../../../../../server/lib/shared/disableCustomScripts.ts', { + '@rocket.chat/license': { License: mockLicense }, + }).disableCustomScripts; + }); + + afterEach(() => { + process.env.DISABLE_CUSTOM_SCRIPTS = disableCustomScriptsVar; + sinon.restore(); + }); + + it('should return false when license is missing', () => { + mockLicense.getLicense.returns(null); + + const result = disableCustomScripts(); + expect(result).to.be.false; + }); + + it('should return false when DISABLE_CUSTOM_SCRIPTS is not true', () => { + mockLicense.getLicense.returns({ + information: { + trial: true, + }, + }); + + const result = disableCustomScripts(); + expect(result).to.be.false; + }); + + it('should return false when license is not a trial', () => { + mockLicense.getLicense.returns({ + information: { + trial: false, + }, + }); + + process.env.DISABLE_CUSTOM_SCRIPTS = 'true'; + + const result = disableCustomScripts(); + expect(result).to.be.false; + }); + + it('should return true when DISABLE_CUSTOM_SCRIPTS is true and license is a trial', () => { + mockLicense.getLicense.returns({ + information: { + trial: true, + }, + }); + + process.env.DISABLE_CUSTOM_SCRIPTS = 'true'; + + const result = disableCustomScripts(); + expect(result).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/shared/getModifiedHttpHeaders.tests.ts b/apps/meteor/tests/unit/server/lib/shared/getModifiedHttpHeaders.tests.ts new file mode 100644 index 0000000..29f9224 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/shared/getModifiedHttpHeaders.tests.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; + +import { getModifiedHttpHeaders } from '../../../../../server/lib/shared/getModifiedHttpHeaders'; + +const inputVariants = [ + { + label: 'HTTP path (Headers instance)', + makeInput: (headers: Record) => new Headers(headers), + }, + { + label: 'DDP path (plain object)', + makeInput: (headers: Record) => headers, + }, +]; + +describe('getModifiedHttpHeaders', () => { + for (const { label, makeInput } of inputVariants) { + describe(label, () => { + it('should redact x-auth-token if present', () => { + const inputHeaders = { + 'x-auth-token': '12345', + 'some-other-header': 'value', + }; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result['x-auth-token']).to.equal('[redacted]'); + expect(result['some-other-header']).to.equal('value'); + }); + + it('should not modify headers if x-auth-token is not present', () => { + const inputHeaders = { + 'some-other-header': 'value', + }; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result).to.deep.equal(inputHeaders); + }); + + it('should redact rc_token in cookies if present', () => { + const inputHeaders = { + cookie: 'session_id=abc123; rc_token=98765; other_cookie=value', + }; + const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value'; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result.cookie).to.equal(expectedCookies); + }); + + it('should not modify cookies if rc_token is not present', () => { + const inputHeaders = { + cookie: 'session_id=abc123; other_cookie=value', + }; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result.cookie).to.equal(inputHeaders.cookie); + }); + + it('should return headers unchanged if neither x-auth-token nor cookie are present', () => { + const inputHeaders = { + 'some-other-header': 'value', + }; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result).to.deep.equal(inputHeaders); + }); + + it('should handle cases with both x-auth-token and rc_token in cookie', () => { + const inputHeaders = { + 'x-auth-token': '12345', + 'cookie': 'session_id=abc123; rc_token=98765; other_cookie=value', + }; + const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value'; + const result = getModifiedHttpHeaders(makeInput(inputHeaders)); + expect(result['x-auth-token']).to.equal('[redacted]'); + expect(result.cookie).to.equal(expectedCookies); + }); + }); + } +}); diff --git a/apps/meteor/tests/unit/server/lib/shared/validateNameChars.tests.ts b/apps/meteor/tests/unit/server/lib/shared/validateNameChars.tests.ts new file mode 100644 index 0000000..1c15d52 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/shared/validateNameChars.tests.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; + +import { validateNameChars } from '../../../../../server/lib/shared/validateNameChars'; + +describe('validateNameChars', () => { + it('should return false for undefined input', () => { + expect(validateNameChars(undefined)).to.be.false; + }); + + it('should return false for non-string input', () => { + expect(validateNameChars(123 as any)).to.be.false; + expect(validateNameChars({} as any)).to.be.false; + expect(validateNameChars([] as any)).to.be.false; + }); + + it('should return false for names with invalid characters', () => { + expect(validateNameChars('name<')).to.be.false; + expect(validateNameChars('name>')).to.be.false; + expect(validateNameChars('name/')).to.be.false; + expect(validateNameChars('name\\')).to.be.false; + }); + + it('should return false for names with invalid characters after decoding', () => { + expect(validateNameChars('name%3E')).to.be.false; + expect(validateNameChars('name%5C')).to.be.false; + expect(validateNameChars('name%3C')).to.be.false; + }); + + it('should return false for malicious HTML payloads', () => { + expect(validateNameChars('')).to.be.false; + expect(validateNameChars('%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E')).to.be.false; + expect( + validateNameChars( + '
', + ), + ).to.be.false; + expect( + validateNameChars( + '%3Cform%20action%3D%22http%3A%2F%2Fmalicious.site%22%20method%3D%22post%22%3E%3Cinput%20type%3D%22text%22%20name%3D%22username%22%20value%3D%22Enter%20username%22%3E%3Cinput%20type%3D%22password%22%20name%3D%22password%22%20value%3D%22Enter%20password%22%3E%3Cinput%20type%3D%22submit%22%20value%3D%22Submit%22%3E%3C%2Fform%3E', + ), + ).to.be.false; + }); + + it('should return false if decodeURI throws an error', () => { + expect(validateNameChars('%')).to.be.false; + expect(validateNameChars('%E0%A4%A')).to.be.false; + }); + + it('should return true for valid names', () => { + expect(validateNameChars('name')).to.be.true; + expect(validateNameChars('valid_name')).to.be.true; + expect(validateNameChars('valid-name')).to.be.true; + expect(validateNameChars('valid.name')).to.be.true; + expect(validateNameChars('valid name')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/statistics/lib/UAParserCustom.tests.js b/apps/meteor/tests/unit/server/lib/statistics/lib/UAParserCustom.tests.js new file mode 100644 index 0000000..954cfe2 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/statistics/lib/UAParserCustom.tests.js @@ -0,0 +1,72 @@ +import { expect } from 'chai'; + +import { UAParserMobile, UAParserDesktop } from '../../../../../../server/lib/statistics/lib/UAParserCustom'; + +const UAMobile = 'RC Mobile; iOS 12.2; v3.4.0 (250)'; +const UADesktop = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Rocket.Chat/2.15.2 Chrome/69.0.3497.128 Electron/4.1.4 Safari/537.36'; +const UAChrome = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36'; + +describe('UAParserCustom', () => { + describe('UAParserMobile', () => { + it('should identify mobile UA', () => { + expect(UAParserMobile.isMobileApp(UAMobile)).to.be.true; + }); + + it('should not identify desktop UA', () => { + expect(UAParserMobile.isMobileApp(UADesktop)).to.be.false; + }); + + it('should not identify chrome UA', () => { + expect(UAParserMobile.isMobileApp(UAChrome)).to.be.false; + }); + + it('should parse mobile UA', () => { + expect(UAParserMobile.uaObject(UAMobile)).to.be.deep.equal({ + device: { + type: 'mobile-app', + }, + app: { + name: 'RC Mobile', + version: '3.4.0', + bundle: '250', + }, + os: { + name: 'iOS', + version: '12.2', + }, + }); + }); + }); + + describe('UAParserDesktop', () => { + it('should not identify mobile UA', () => { + expect(UAParserDesktop.isDesktopApp(UAMobile)).to.be.false; + }); + + it('should identify desktop UA', () => { + expect(UAParserDesktop.isDesktopApp(UADesktop)).to.be.true; + }); + + it('should not identify chrome UA', () => { + expect(UAParserDesktop.isDesktopApp(UAChrome)).to.be.false; + }); + + it('should parse desktop UA', () => { + expect(UAParserDesktop.uaObject(UADesktop)).to.be.deep.equal({ + device: { + type: 'desktop-app', + }, + app: { + name: 'Rocket.Chat', + version: '2.15.2', + }, + os: { + name: 'Mac OS', + version: '10.14.1', + }, + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/subscriptionTypeGuards.spec.ts b/apps/meteor/tests/unit/server/lib/subscriptionTypeGuards.spec.ts new file mode 100644 index 0000000..cf93fa2 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/subscriptionTypeGuards.spec.ts @@ -0,0 +1,48 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import { isBannedSubscription, isInviteSubscription } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +describe('ISubscription type guards', () => { + describe('isBannedSubscription', () => { + it('should return true for a subscription with BANNED status', () => { + const sub = { status: 'BANNED' } as ISubscription; + expect(isBannedSubscription(sub)).to.be.true; + }); + + it('should return false for a subscription with INVITED status', () => { + const sub = { status: 'INVITED' } as ISubscription; + expect(isBannedSubscription(sub)).to.be.false; + }); + + it('should return false for a subscription with no status', () => { + const sub = {} as ISubscription; + expect(isBannedSubscription(sub)).to.be.false; + }); + + it('should return false for a subscription with undefined status', () => { + const sub = { status: undefined } as unknown as ISubscription; + expect(isBannedSubscription(sub)).to.be.false; + }); + }); + + describe('isInviteSubscription', () => { + it('should return true for a subscription with INVITED status and inviter', () => { + const sub = { + status: 'INVITED', + inviter: { _id: 'user1', username: 'testuser' }, + } as ISubscription; + expect(isInviteSubscription(sub)).to.be.true; + }); + + it('should return false for a subscription with INVITED status but no inviter', () => { + const sub = { status: 'INVITED' } as ISubscription; + expect(isInviteSubscription(sub)).to.be.false; + }); + + it('should return false for a BANNED subscription', () => { + const sub = { status: 'BANNED' } as ISubscription; + expect(isInviteSubscription(sub)).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/systemMessage/hideSystemMessage.spec.ts b/apps/meteor/tests/unit/server/lib/systemMessage/hideSystemMessage.spec.ts new file mode 100644 index 0000000..ed95fe2 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/systemMessage/hideSystemMessage.spec.ts @@ -0,0 +1,53 @@ +import type { MessageTypesValues } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { isMutedUnmuted, shouldHideSystemMessage } from '../../../../../server/lib/systemMessage/hideSystemMessage'; + +describe('hideSystemMessage', () => { + describe('isMutedUnmuted', () => { + it('should return true for user-muted', () => { + expect(isMutedUnmuted('user-muted')).to.be.true; + }); + + it('should return true for user-unmuted', () => { + expect(isMutedUnmuted('user-unmuted')).to.be.true; + }); + + it('should return false for other message types', () => { + expect(isMutedUnmuted('some-other-type')).to.be.false; + }); + }); + + describe('shouldHideSystemMessage', () => { + it('should return true if message type is in hidden system messages', async () => { + const hiddenMessages: MessageTypesValues[] = ['user-muted', 'mute_unmute']; + + const result = shouldHideSystemMessage('user-muted', hiddenMessages); + expect(result).to.be.true; + }); + + it('should return true if message type is user-muted and mute_unmute is in hidden system messages', async () => { + const hiddenMessages: MessageTypesValues[] = ['mute_unmute']; + + const result = shouldHideSystemMessage('user-muted', hiddenMessages); + expect(result).to.be.true; + }); + + it('should return false if message type is not in hidden system messages', async () => { + const hiddenMessages: MessageTypesValues[] = ['room-archived']; + + const result = shouldHideSystemMessage('user-muted', hiddenMessages); + expect(result).to.be.false; + }); + + it('should return false if message type is of deleted message', async () => { + const result = shouldHideSystemMessage('rm', undefined); + expect(result).to.be.false; + }); + + it('should return false if hidden system messages are undefined', async () => { + const result = shouldHideSystemMessage('user-muted', undefined); + expect(result).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/users/saveUser/sendUserEmail.spec.ts b/apps/meteor/tests/unit/server/lib/users/saveUser/sendUserEmail.spec.ts new file mode 100644 index 0000000..c9a419d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/users/saveUser/sendUserEmail.spec.ts @@ -0,0 +1,154 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import mock from 'proxyquire'; +import Sinon from 'sinon'; + +type SendUserEmailFn = (subject: string, html: string, user: { email?: string; password?: string; name?: string }) => Promise; +type SendHelperFn = (user: { email?: string; password?: string; name?: string }) => Promise; + +const userData = { + email: 'test@example.com', + password: '123456', + name: 'Test User', +}; + +const makeStubs = () => { + const MailerStub = { + getTemplate: Sinon.stub(), + send: Sinon.stub(), + }; + + const SettingsStub = { + settings: { + get: Sinon.stub().callsFake((key: string) => { + if (key === 'From_Email') return 'no-reply@example.com'; + if (key === 'Accounts_UserAddedEmail_Subject') return 'Welcome [name]!'; + if (key === 'Password_Changed_Email_Subject') return 'Password Changed'; + return ''; + }), + }, + }; + + const MeteorStub = { + Meteor: { + startup: Sinon.stub(), + }, + }; + + return { MailerStub, SettingsStub, MeteorStub }; +}; + +describe('sendUserEmail (Mocha + TS)', () => { + let MailerStub: { getTemplate: Sinon.SinonStub; send: Sinon.SinonStub }; + let SettingsStub: { settings: { get: Sinon.SinonStub } }; + let MeteorStub: { Meteor: { startup: Sinon.SinonStub } }; + + let sendUserEmail: SendUserEmailFn; + let sendWelcomeEmail: SendHelperFn; + let sendPasswordEmail: SendHelperFn; + + beforeEach(() => { + const stubs = makeStubs(); + MailerStub = stubs.MailerStub as any; + SettingsStub = stubs.SettingsStub as any; + MeteorStub = stubs.MeteorStub as any; + + const mod = mock.noCallThru().load('../../../../../../server/lib/users/saveUser/sendUserEmail.ts', { + '../../notifications/email/api': MailerStub, + '../../../settings': SettingsStub, + 'meteor/meteor': MeteorStub, + }) as any; + + sendUserEmail = mod.sendUserEmail as SendUserEmailFn; + sendWelcomeEmail = mod.sendWelcomeEmail as SendHelperFn; + sendPasswordEmail = mod.sendPasswordEmail as SendHelperFn; + }); + + afterEach(() => { + Sinon.restore(); + }); + + describe('sendUserEmail', () => { + it('should not send email if userData.email is missing', async () => { + await sendUserEmail('subject', '

html

', { ...userData, email: undefined }); + + Sinon.assert.notCalled(MailerStub.send); + }); + + it('should send email with correct parameters', async () => { + await sendUserEmail('subject', '

html

', userData); + + Sinon.assert.calledOnce(MailerStub.send); + const callArg = MailerStub.send.getCall(0).args[0]; + expect(callArg).to.be.an('object'); + expect(callArg).to.include({ + to: userData.email, + from: SettingsStub.settings.get('From_Email'), + subject: 'subject', + html: '

html

', + }); + expect(callArg.data).to.include({ + email: userData.email, + password: userData.password, + name: userData.name, + }); + }); + + it('should throw MeteorError if Mailer.send fails', async () => { + MailerStub.send.throws(new Error('fail')); + + let thrown = false; + try { + await sendUserEmail('subject', '

html

', userData); + } catch (err) { + thrown = true; + expect(err).to.be.instanceOf(MeteorError); + } + expect(thrown).to.equal(true); + }); + + it('should mask password in email data if password is not provided', async () => { + await sendUserEmail('subject', '

html

', { ...userData, password: undefined }); + + Sinon.assert.calledOnce(MailerStub.send); + const callArg = MailerStub.send.getCall(0).args[0]; + expect(callArg.data).to.include({ + email: userData.email, + password: '******', + name: userData.name, + }); + }); + }); + + describe('sendWelcomeEmail', () => { + it('should call sendUserEmail with welcome subject and template', async () => { + await sendWelcomeEmail(userData); + + Sinon.assert.calledOnce(MailerStub.send); + const callArg = MailerStub.send.getCall(0).args[0]; + expect(callArg).to.include({ + subject: `Welcome [name]!`, + to: userData.email, + }); + expect(callArg.data).to.deep.equal({ + email: userData.email, + password: userData.password, + name: userData.name, + }); + }); + }); + + describe('sendPasswordEmail', () => { + it('should call sendUserEmail with password subject and template', async () => { + await sendPasswordEmail(userData); + + Sinon.assert.calledOnce(MailerStub.send); + const callArg = MailerStub.send.getCall(0).args[0]; + expect(callArg).to.include({ + subject: 'Password Changed', + to: userData.email, + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/users/setUsername.spec.ts b/apps/meteor/tests/unit/server/lib/users/setUsername.spec.ts new file mode 100644 index 0000000..a0c6f6d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/users/setUsername.spec.ts @@ -0,0 +1,336 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('setUsername', () => { + const userId = 'userId'; + const username = 'validUsername'; + + const stubs = { + Users: { + findOneById: sinon.stub(), + setUsername: sinon.stub(), + }, + Subscriptions: { + findUserFederatedRoomIds: sinon.stub(), + }, + Accounts: { + sendEnrollmentEmail: sinon.stub(), + }, + settings: { + get: sinon.stub(), + }, + api: { + broadcast: sinon.stub(), + }, + Invites: { + findOneById: sinon.stub(), + }, + callbacks: { + run: sinon.stub(), + runAsync: sinon.stub(), + }, + checkUsernameAvailability: sinon.stub(), + validateUsername: sinon.stub(), + saveUserIdentity: sinon.stub(), + joinDefaultChannels: sinon.stub(), + getAvatarSuggestionForUser: sinon.stub(), + setUserAvatar: sinon.stub(), + addUserToRoom: sinon.stub(), + notifyOnUserChange: sinon.stub(), + underscore: { + escape: sinon.stub(), + }, + SystemLogger: sinon.stub(), + }; + + const { setUsernameWithValidation, _setUsername } = proxyquire.noCallThru().load('../../../../../server/lib/users/setUsername', { + '../../database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() }, + 'meteor/meteor': { Meteor: { Error } }, + '@rocket.chat/core-services': { api: stubs.api }, + '@rocket.chat/models': { Users: stubs.Users, Invites: stubs.Invites, Subscriptions: stubs.Subscriptions }, + 'meteor/accounts-base': { Accounts: stubs.Accounts }, + 'underscore': stubs.underscore, + '../../settings': { settings: stubs.settings }, + '../notifyListener': { notifyOnUserChange: stubs.notifyOnUserChange }, + '../rooms/addUserToRoom': { addUserToRoom: stubs.addUserToRoom }, + './checkUsernameAvailability': { checkUsernameAvailability: stubs.checkUsernameAvailability }, + './getAvatarSuggestionForUser': { getAvatarSuggestionForUser: stubs.getAvatarSuggestionForUser }, + '../rooms/joinDefaultChannels': { joinDefaultChannels: stubs.joinDefaultChannels }, + './saveUserIdentity': { saveUserIdentity: stubs.saveUserIdentity }, + './setUserAvatar': { setUserAvatar: stubs.setUserAvatar }, + './validateUsername': { validateUsername: stubs.validateUsername }, + '../callbacks': { callbacks: stubs.callbacks }, + '../logger/system': { SystemLogger: stubs.SystemLogger }, + }); + + beforeEach(() => { + stubs.Subscriptions.findUserFederatedRoomIds.returns({ + hasNext: sinon.stub().resolves(false), + close: sinon.stub().resolves(), + }); + }); + + afterEach(() => { + stubs.Users.findOneById.reset(); + stubs.Users.setUsername.reset(); + stubs.Subscriptions.findUserFederatedRoomIds.reset(); + stubs.Accounts.sendEnrollmentEmail.reset(); + stubs.settings.get.reset(); + stubs.api.broadcast.reset(); + stubs.Invites.findOneById.reset(); + stubs.callbacks.run.reset(); + stubs.callbacks.runAsync.reset(); + stubs.checkUsernameAvailability.reset(); + stubs.validateUsername.reset(); + stubs.saveUserIdentity.reset(); + stubs.joinDefaultChannels.reset(); + stubs.getAvatarSuggestionForUser.reset(); + stubs.setUserAvatar.reset(); + stubs.addUserToRoom.reset(); + stubs.notifyOnUserChange.reset(); + stubs.underscore.escape.reset(); + stubs.SystemLogger.reset(); + }); + + describe('setUsernameWithValidation', () => { + it('should throw an error if username is invalid', async () => { + try { + await setUsernameWithValidation(userId, ''); + } catch (error: any) { + expect(error.message).to.equal('error-invalid-username'); + } + }); + + it('should throw an error if user is not found', async () => { + stubs.Users.findOneById.withArgs(userId).returns(null); + + try { + await setUsernameWithValidation(userId, username); + } catch (error: any) { + expect(stubs.Users.findOneById.calledOnce).to.be.true; + expect(error.message).to.equal('error-invalid-user'); + } + }); + + it('should throw an error if username change is not allowed', async () => { + stubs.Users.findOneById.resolves({ username: 'oldUsername' }); + stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(false); + + try { + await setUsernameWithValidation(userId, username); + } catch (error: any) { + expect(stubs.settings.get.calledOnce).to.be.true; + expect(error.message).to.equal('error-not-allowed'); + } + }); + + it('should throw an error if username is not valid', async () => { + stubs.Users.findOneById.resolves({ username: null }); + stubs.validateUsername.returns(false); + + try { + await setUsernameWithValidation(userId, 'invalid-username'); + } catch (error: any) { + expect(stubs.validateUsername.calledOnce).to.be.true; + expect(error.message).to.equal('username-invalid'); + } + }); + + it('should throw an error if username is already in use', async () => { + stubs.Users.findOneById.resolves({ username: null }); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(false); + + try { + await setUsernameWithValidation(userId, 'existingUsername'); + } catch (error: any) { + expect(stubs.checkUsernameAvailability.calledOnce).to.be.true; + expect(error.message).to.equal('error-field-unavailable'); + } + }); + + it('should throw an error if local user is in federated rooms', async () => { + stubs.Users.findOneById.resolves({ _id: userId, username: null }); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + stubs.Subscriptions.findUserFederatedRoomIds.returns({ + hasNext: sinon.stub().resolves(true), + close: sinon.stub().resolves(), + }); + + try { + await setUsernameWithValidation(userId, 'newUsername'); + } catch (error: any) { + expect(stubs.Subscriptions.findUserFederatedRoomIds.calledOnce).to.be.true; + expect(error.message).to.equal('error-not-allowed'); + } + }); + + it('should throw an error if user is federated', async () => { + stubs.Users.findOneById.resolves({ + _id: userId, + username: null, + federated: true, + federation: { version: 1, mui: '@user:origin', origin: 'origin' }, + }); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + + try { + await setUsernameWithValidation(userId, 'newUsername'); + } catch (error: any) { + expect(stubs.Subscriptions.findUserFederatedRoomIds.notCalled).to.be.true; + expect(error.message).to.equal('error-not-allowed'); + } + }); + + it('should save the user identity when valid username is set', async () => { + stubs.Users.findOneById.resolves({ _id: userId, username: null }); + stubs.settings.get.withArgs('Accounts_AllowUsernameChange').returns(true); + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + stubs.saveUserIdentity.resolves(true); + + await setUsernameWithValidation(userId, 'newUsername'); + + expect(stubs.saveUserIdentity.calledOnce).to.be.true; + }); + }); + + describe('_setUsername', () => { + it('should return false if userId or username is missing', async () => { + const result = await _setUsername(null, '', {}); + expect(result).to.be.false; + }); + + it('should return false if username is invalid', async () => { + stubs.validateUsername.returns(false); + + const result = await _setUsername(userId, 'invalid-username', {}); + expect(result).to.be.false; + }); + + it('should return user if username is already set', async () => { + stubs.validateUsername.returns(true); + const mockUser = { username }; + + const result = await _setUsername(userId, username, mockUser); + expect(result).to.equal(mockUser); + }); + + it('should set username when user has no previous username', async () => { + const mockUser = { _id: userId, emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should set username when user has and old that is different from new', async () => { + const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should set username when user has and old that is different from new', async () => { + const mockUser = { _id: userId, username: 'oldUsername', emails: [{ address: 'test@example.com' }] }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.Users.setUsername.calledOnceWith(userId, username)); + expect(stubs.checkUsernameAvailability.calledOnceWith(username)); + expect(stubs.api.broadcast.calledOnceWith('user.autoupdate', { user: mockUser })); + }); + + it('should join the default channels and run afterCreateUser on the first username', async () => { + const mockUser = { _id: userId, roles: ['user'] }; + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.joinDefaultChannels.calledOnceWith(userId)).to.be.true; + expect(stubs.callbacks.runAsync.calledWith('afterCreateUser')).to.be.true; + expect(stubs.callbacks.runAsync.withArgs('afterCreateUser').firstCall.args[1]).to.include({ username }); + }); + + it('should not join the default channels when the username is only being changed', async () => { + const mockUser = { _id: userId, username: 'oldUsername', roles: ['user'] }; + stubs.validateUsername.returns(true); + stubs.checkUsernameAvailability.resolves(true); + + await _setUsername(userId, username, mockUser); + + expect(stubs.joinDefaultChannels.notCalled).to.be.true; + expect(stubs.callbacks.runAsync.calledWith('afterCreateUser')).to.be.false; + }); + + it('should set avatar if Accounts_SetDefaultAvatar is enabled', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } }); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.calledOnceWith(mockUser, 'blobData', 'image/png', 'gravatar')).to.be.true; + }); + + it('should not set avatar if Accounts_SetDefaultAvatar is disabled', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(false); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.called).to.be.false; + }); + + it('should not set avatar if no avatar suggestions are available', async () => { + const mockUser = { _id: userId, username: null }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({}); + + await _setUsername(userId, username, mockUser); + + expect(stubs.setUserAvatar.called).to.be.false; + }); + + it('should add user to room if inviteToken is present', async () => { + const mockUser = { _id: userId, username: null, inviteToken: 'invite token' }; + stubs.validateUsername.returns(true); + stubs.Users.findOneById.resolves(mockUser); + stubs.checkUsernameAvailability.resolves(true); + stubs.settings.get.withArgs('Accounts_SetDefaultAvatar').returns(true); + stubs.getAvatarSuggestionForUser.resolves({ gravatar: { blob: 'blobData', contentType: 'image/png' } }); + stubs.Invites.findOneById.resolves({ rid: 'room id' }); + + await _setUsername(userId, username, mockUser); + + expect(stubs.addUserToRoom.calledOnceWith('room id', mockUser)).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/users/validateUsername.spec.ts b/apps/meteor/tests/unit/server/lib/users/validateUsername.spec.ts new file mode 100644 index 0000000..2c86d5d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/users/validateUsername.spec.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +describe('validateUsername', () => { + const getStub = sinon.stub(); + + const proxySettings = { + settings: { + get: getStub, + }, + }; + + const { validateUsername } = proxyquire.noCallThru().load('../../../../../server/lib/users/validateUsername', { + '../../settings': proxySettings, + }); + + beforeEach(() => { + getStub.reset(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('with default settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('[0-9a-zA-Z-_.]+'); + }); + + it('should return true for a valid username', () => { + const result = validateUsername('valid_username.123'); + expect(result).to.be.true; + }); + + it('should return false for an invalid username containing special HTML tags', () => { + const result = validateUsername('username
$
'); + expect(result).to.be.false; + }); + + it('should return false for an empty username', () => { + const result = validateUsername(''); + expect(result).to.be.false; + }); + + it('should return false for a username with invalid characters', () => { + const result = validateUsername('invalid*username!'); + expect(result).to.be.false; + }); + + it('should return true for a username with allowed special characters', () => { + const result = validateUsername('username-_.'); + expect(result).to.be.true; + }); + }); + + describe('with custom regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('[a-zA-Z]+'); + }); + + it('should return true for a username matching the custom regex', () => { + const result = validateUsername('ValidUsername'); + expect(result).to.be.true; + }); + + it('should return false for a username that does not match the custom regex', () => { + const result = validateUsername('username123'); + expect(result).to.be.false; + }); + }); + + describe('with null regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns(null); + }); + + it('should fallback to the default regex pattern if the settings value is null', () => { + const result = validateUsername('username'); + expect(result).to.be.true; + }); + }); + + describe('with invalid regex settings', () => { + beforeEach(() => { + getStub.withArgs('UTF8_User_Names_Validation').returns('invalid['); + }); + + it('should fallback to the default regex pattern if the settings value is invalid', () => { + const result = validateUsername('username'); + expect(result).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/meteor-methods/auth/createToken.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/auth/createToken.spec.ts new file mode 100644 index 0000000..c8a5ff8 --- /dev/null +++ b/apps/meteor/tests/unit/server/meteor-methods/auth/createToken.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const hasPermissionStub = sinon.stub(); + +const AccountsMock = { + _generateStampedLoginToken: sinon.stub(), + _insertLoginToken: sinon.stub(), +}; + +const UserMock = { + ensureLoginTokensLimit: sinon.stub(), +}; + +const AuthorizationMock = { + hasPermission: hasPermissionStub, +}; + +class MeteorErrorMock extends Error { + error: string; + + constructor(error: string, reason: string) { + super(reason); + this.error = error; + } +} + +process.env.CREATE_TOKENS_FOR_USERS_SECRET = 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7'; + +const { generateAccessToken } = p.noCallThru().load('../../../../../server/meteor-methods/auth/createToken.ts', { + '@rocket.chat/core-services': { Authorization: AuthorizationMock, MeteorError: MeteorErrorMock, User: UserMock }, + 'meteor/accounts-base': { Accounts: AccountsMock }, +}); + +describe('generateAccessToken', () => { + beforeEach(() => { + hasPermissionStub.reset(); + AccountsMock._generateStampedLoginToken.reset(); + AccountsMock._insertLoginToken.reset(); + UserMock.ensureLoginTokensLimit.reset(); + }); + + it('should throw if secret does not match', async () => { + await expect(generateAccessToken('targetId', 'wrong-secret', { _id: 'callerId' })).to.be.rejectedWith('Not authorized'); + }); + + it('should throw if caller targets another user and lacks user-generate-access-token permission', async () => { + hasPermissionStub.resolves(false); + + await expect(generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' })).to.be.rejectedWith( + 'Not authorized', + ); + sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + }); + + it('should succeed if caller targets another user and has user-generate-access-token permission', async () => { + hasPermissionStub.resolves(true); + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' }); + + expect(result).to.deep.equal({ userId: 'targetId', authToken: 'abc123' }); + sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + }); + + it('should succeed without permission check when caller generates token for themselves', async () => { + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('userId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'userId' }); + + expect(result).to.deep.equal({ userId: 'userId', authToken: 'abc123' }); + sinon.assert.notCalled(hasPermissionStub); + }); +}); diff --git a/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts new file mode 100644 index 0000000..8816b55 --- /dev/null +++ b/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts @@ -0,0 +1,144 @@ +import { PassThrough } from 'node:stream'; + +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const progressStep = { + DOWNLOADING_FILE: 'importer_downloading_file', + FILE_LOADED: 'importer_file_loaded', + ERROR: 'importer_import_failed', +}; + +const stubs = { + newOperation: sinon.stub(), + serverFetch: sinon.stub(), + importersGet: sinon.stub(), + createWriteStream: sinon.stub(), + startFileUpload: sinon.stub(), + updateProgress: sinon.stub(), + updateRecord: sinon.stub(), + systemLoggerError: sinon.stub(), +}; + +class MockImporter { + startFileUpload = stubs.startFileUpload; + + updateProgress = stubs.updateProgress; + + updateRecord = stubs.updateRecord; +} + +const { executeDownloadPublicImportFile } = proxyquire + .noCallThru() + .load('../../../../../server/meteor-methods/import/downloadPublicImportFile.ts', { + '@rocket.chat/core-services': { Import: { newOperation: stubs.newOperation } }, + '@rocket.chat/server-fetch': { serverFetch: stubs.serverFetch }, + 'meteor/meteor': { Meteor: { methods: sinon.stub() } }, + '../../lib/authorization/hasPermission': { hasPermissionAsync: sinon.stub() }, + '../../lib/deprecationWarningLogger': { methodDeprecationLogger: { method: sinon.stub() } }, + '../../lib/import': { Importers: { get: stubs.importersGet } }, + '../../lib/import/startup/store': { RocketChatImportFileInstance: { createWriteStream: stubs.createWriteStream } }, + '../../lib/logger/system': { SystemLogger: { error: stubs.systemLoggerError } }, + '../../settings': { settings: { get: sinon.stub().returns('') } }, + '../../../app/importer/lib/ImporterProgressStep': { ProgressStep: progressStep }, + }); + +describe('executeDownloadPublicImportFile', () => { + let writeStream: PassThrough; + + beforeEach(() => { + Object.values(stubs).forEach((stub) => stub.reset()); + + writeStream = new PassThrough(); + stubs.newOperation.resolves({ _id: 'operation-id' }); + stubs.importersGet.returns({ key: 'csv', name: 'CSV', importer: MockImporter }); + stubs.createWriteStream.returns(writeStream); + stubs.startFileUpload.resolves(); + stubs.updateProgress.resolves(); + stubs.updateRecord.resolves(); + }); + + it('rejects an unsuccessful HTTP response and drains its body', async () => { + const responseBody = new PassThrough(); + const resume = sinon.spy(responseBody, 'resume'); + stubs.serverFetch.resolves({ ok: false, status: 404, body: responseBody }); + + await expect(executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv')).to.be.rejectedWith( + 'Unexpected response status 404', + ); + + expect(resume.calledOnce).to.be.true; + expect(writeStream.destroyed).to.be.true; + expect(stubs.updateProgress.calledWith(progressStep.ERROR)).to.be.true; + }); + + it('marks the import as failed and destroys the writable when the HTTP stream fails', async () => { + const responseBody = new PassThrough(); + stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody }); + + await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv'); + const writeStreamClosed = new Promise((resolve) => writeStream.once('close', resolve)); + responseBody.destroy(new Error('stream failed')); + await writeStreamClosed; + + expect(writeStream.destroyed).to.be.true; + expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true; + expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false; + }); + + it('marks the import as failed and destroys the HTTP stream when the writable fails', async () => { + const responseBody = new PassThrough(); + stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody }); + + await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv'); + const responseBodyClosed = new Promise((resolve) => responseBody.once('close', resolve)); + writeStream.destroy(new Error('stream failed')); + await responseBodyClosed; + + expect(responseBody.destroyed).to.be.true; + expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true; + expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false; + }); + + it('handles a rejected error progress update when the writable fails', async () => { + const responseBody = new PassThrough(); + const unhandledRejection = sinon.spy(); + const progressUpdateError = new Error('progress update failed'); + stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody }); + stubs.updateProgress.withArgs(progressStep.ERROR).rejects(progressUpdateError); + process.on('unhandledRejection', unhandledRejection); + + try { + await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv'); + const responseBodyClosed = new Promise((resolve) => responseBody.once('close', resolve)); + writeStream.destroy(new Error('stream failed')); + await responseBodyClosed; + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandledRejection.called).to.be.false; + expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true; + sinon.assert.calledOnceWithExactly(stubs.systemLoggerError, { + msg: 'Failed to update import progress to ERROR', + err: progressUpdateError, + }); + } finally { + process.off('unhandledRejection', unhandledRejection); + } + }); + + it('marks the file as loaded when the writable finishes', async () => { + const responseBody = new PassThrough(); + stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody }); + + await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv'); + expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false; + + const finished = new Promise((resolve) => writeStream.once('finish', resolve)); + responseBody.end('file contents'); + await finished; + + expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/meteor-methods/messages/deleteFileMessage.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/messages/deleteFileMessage.spec.ts new file mode 100644 index 0000000..ec9f3c1 --- /dev/null +++ b/apps/meteor/tests/unit/server/meteor-methods/messages/deleteFileMessage.spec.ts @@ -0,0 +1,124 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const checkMock = sinon.stub(); +const meteorUserIdMock = sinon.stub(); +const meteorMethodsMock = sinon.stub(); +const deleteMessageValidatingPermissionMock = sinon.stub(); +const canDeleteFileMock = sinon.stub(); +const deleteByIdMock = sinon.stub(); +const fileUploadGetStoreMock = sinon.stub().returns({ deleteById: deleteByIdMock }); + +const modelsMock = { + Messages: { + getMessageByFileId: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Uploads: { + findOneById: sinon.stub(), + }, +}; + +p.noCallThru().load('../../../../../server/meteor-methods/messages/deleteFileMessage', { + 'meteor/meteor': { + Meteor: { + userId: meteorUserIdMock, + Error: MeteorError, + methods: meteorMethodsMock, + }, + }, + 'meteor/check': { + check: checkMock, + }, + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { + Upload: { canDeleteFile: canDeleteFileMock }, + }, + '../../lib/media/file-upload': { + FileUpload: { getStore: fileUploadGetStoreMock }, + }, + '../../lib/messages/deleteMessage': { + deleteMessageValidatingPermission: deleteMessageValidatingPermissionMock, + }, +}); + +const deleteFileMessageMethod = meteorMethodsMock.firstCall.args[0].deleteFileMessage; + +describe('deleteFileMessage', () => { + beforeEach(() => { + checkMock.resetHistory(); + meteorUserIdMock.reset(); + deleteMessageValidatingPermissionMock.reset(); + canDeleteFileMock.reset(); + deleteByIdMock.reset(); + fileUploadGetStoreMock.resetHistory(); + modelsMock.Messages.getMessageByFileId.reset(); + modelsMock.Users.findOneById.reset(); + modelsMock.Uploads.findOneById.reset(); + }); + + it('should throw if user is not authenticated', async () => { + meteorUserIdMock.returns(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should delete message validating permission if file has an associated message', async () => { + meteorUserIdMock.returns('user123'); + const mockMsg = { _id: 'msg123', file: { _id: 'file123' } }; + modelsMock.Messages.getMessageByFileId.resolves(mockMsg); + deleteMessageValidatingPermissionMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(checkMock.calledOnceWith('file123', String)).to.be.true; + expect(deleteMessageValidatingPermissionMock.calledOnceWith(mockMsg, 'user123')).to.be.true; + expect(modelsMock.Users.findOneById.called).to.be.false; + }); + + it('should throw if it is an orphan file but user is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should throw if it is an orphan file but file is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid file'); + }); + + it('should not delete orphan file if user does not have permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(false); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Not authorized'); + }); + + it('should delete orphan file if user has permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(true); + deleteByIdMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(fileUploadGetStoreMock.calledOnceWith('Uploads')).to.be.true; + expect(deleteByIdMock.calledOnceWith('file123')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/meteor-methods/messages/loadSurroundingMessages.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/messages/loadSurroundingMessages.spec.ts new file mode 100644 index 0000000..7dfde40 --- /dev/null +++ b/apps/meteor/tests/unit/server/meteor-methods/messages/loadSurroundingMessages.spec.ts @@ -0,0 +1,106 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const checkMock = sinon.stub(); +const meteorUserIdMock = sinon.stub(); +const meteorMethodsMock = sinon.stub(); +const canAccessRoomIdMock = sinon.stub(); +const normalizeMessagesForUserMock = sinon.stub(); +const settingsGetMock = sinon.stub(); + +// the method pushes into the array it gets back, so each call needs a fresh one +const emptyCursor = () => ({ toArray: async () => [] }); + +const modelsMock = { + Messages: { + findOneById: sinon.stub(), + findVisibleByRoomIdBeforeTimestamp: sinon.stub().callsFake(emptyCursor), + findVisibleByRoomIdAfterTimestamp: sinon.stub().callsFake(emptyCursor), + }, +}; + +p.noCallThru().load('../../../../../server/meteor-methods/messages/loadSurroundingMessages', { + 'meteor/meteor': { + Meteor: { + userId: meteorUserIdMock, + Error: MeteorError, + methods: meteorMethodsMock, + }, + }, + 'meteor/check': { + check: checkMock, + }, + '@rocket.chat/models': modelsMock, + '../../lib/authorization/canAccessRoom': { + canAccessRoomIdAsync: canAccessRoomIdMock, + }, + '../../lib/deprecationWarningLogger': { + methodDeprecationLogger: { method: sinon.stub() }, + }, + '../../lib/utils/lib/normalizeMessagesForUser': { + normalizeMessagesForUser: normalizeMessagesForUserMock, + }, + '../../settings': { + settings: { get: settingsGetMock }, + }, +}); + +const loadSurroundingMessagesMethod = meteorMethodsMock.firstCall.args[0].loadSurroundingMessages; + +const mainMessage = { _id: 'msg123', rid: 'room123', ts: new Date('2024-01-01T00:00:00Z') }; + +describe('loadSurroundingMessages', () => { + beforeEach(() => { + checkMock.resetHistory(); + meteorUserIdMock.reset(); + canAccessRoomIdMock.reset(); + normalizeMessagesForUserMock.reset(); + settingsGetMock.reset(); + modelsMock.Messages.findOneById.reset(); + }); + + it('should throw for an anonymous user when anonymous read is disabled', async () => { + meteorUserIdMock.returns(null); + settingsGetMock.withArgs('Accounts_AllowAnonymousRead').returns(false); + + await expect(loadSurroundingMessagesMethod({ _id: 'msg123', rid: 'room123' })).to.be.rejectedWith('Invalid user'); + }); + + it('should load the surrounding messages for an anonymous user when anonymous read is enabled', async () => { + meteorUserIdMock.returns(null); + settingsGetMock.withArgs('Accounts_AllowAnonymousRead').returns(true); + modelsMock.Messages.findOneById.resolves(mainMessage); + canAccessRoomIdMock.resolves(true); + + const result = await loadSurroundingMessagesMethod({ _id: 'msg123', rid: 'room123' }); + + expect(result).to.not.be.false; + expect(result.messages).to.deep.equal([mainMessage]); + expect(canAccessRoomIdMock.calledOnceWith('room123', undefined)).to.be.true; + expect(normalizeMessagesForUserMock.called).to.be.false; + }); + + it('should not load the surrounding messages when the room is out of reach', async () => { + meteorUserIdMock.returns(null); + settingsGetMock.withArgs('Accounts_AllowAnonymousRead').returns(true); + modelsMock.Messages.findOneById.resolves(mainMessage); + canAccessRoomIdMock.resolves(false); + + expect(await loadSurroundingMessagesMethod({ _id: 'msg123', rid: 'room123' })).to.be.false; + }); + + it('should normalize the messages for a logged in user', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.findOneById.resolves(mainMessage); + canAccessRoomIdMock.resolves(true); + normalizeMessagesForUserMock.resolves([mainMessage]); + + await loadSurroundingMessagesMethod({ _id: 'msg123', rid: 'room123' }); + + expect(settingsGetMock.called).to.be.false; + expect(normalizeMessagesForUserMock.calledOnceWith([mainMessage], 'user123')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/meteor-methods/platform/updateGroupKey.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/platform/updateGroupKey.spec.ts new file mode 100644 index 0000000..e216221 --- /dev/null +++ b/apps/meteor/tests/unit/server/meteor-methods/platform/updateGroupKey.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const models = { + Subscriptions: { + findOneByRoomIdAndUserId: sinon.stub(), + setGroupE2EKey: sinon.stub(), + setGroupE2ESuggestedKey: sinon.stub(), + }, + Rooms: { + removeUsersFromE2EEQueueByRoomId: sinon.stub(), + }, +}; + +const { updateGroupKey } = proxyquire.noCallThru().load('../../../../../server/meteor-methods/platform/updateGroupKey.ts', { + '../../lib/notifyListener': { + notifyOnSubscriptionChanged: sinon.stub(), + notifyOnRoomChangedById: sinon.stub(), + notifyOnSubscriptionChangedById: sinon.stub(), + }, + '@rocket.chat/models': models, + 'meteor/meteor': { Meteor: { methods: sinon.stub() } }, +}); + +describe('updateGroupKey', () => { + beforeEach(() => { + models.Subscriptions.findOneByRoomIdAndUserId.reset(); + models.Subscriptions.setGroupE2EKey.reset(); + models.Subscriptions.setGroupE2ESuggestedKey.reset(); + }); + + it('should do nothing if user has no subscription', async () => { + models.Subscriptions.findOneByRoomIdAndUserId.resolves(null); + const res = await updateGroupKey('rid', 'uid', 'key', 'callerUserId'); + + expect(res).to.be.undefined; + }); + it('should suggest the key to the user when uid is different from callerUserId', async () => { + models.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'subscriptionId' }); + models.Subscriptions.setGroupE2ESuggestedKey.resolves({ value: {} }); + const res = await updateGroupKey('rid', 'uid', 'key', 'callerUserId'); + + expect(res).to.be.undefined; + expect(models.Subscriptions.setGroupE2ESuggestedKey.calledOnce).to.be.true; + }); + it('should set the group key when uid is the callerUserId', async () => { + models.Subscriptions.findOneByRoomIdAndUserId.resolves({ _id: 'subscriptionId' }); + models.Subscriptions.setGroupE2EKey.resolves({ modifiedCount: 1 }); + models.Rooms.removeUsersFromE2EEQueueByRoomId.resolves({ modifiedCount: 1 }); + const res = await updateGroupKey('rid', 'uid', 'key', 'uid'); + + expect(res).to.be.undefined; + expect(models.Subscriptions.setGroupE2EKey.calledOnce).to.be.true; + expect(models.Rooms.removeUsersFromE2EEQueueByRoomId.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/modules/notifications/notifications.module.spec.ts b/apps/meteor/tests/unit/server/modules/notifications/notifications.module.spec.ts new file mode 100644 index 0000000..61a4284 --- /dev/null +++ b/apps/meteor/tests/unit/server/modules/notifications/notifications.module.spec.ts @@ -0,0 +1,153 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { Streamer, StreamerCentral } from '../../../../../server/modules/streamer/streamer.module'; + +class TestStreamer extends Streamer { + registerPublication(): void { + // noop + } + + registerMethod(): void { + // noop + } + + changedPayload(): string { + return 'payload'; + } +} +const validateActionStub = sinon.stub(); +const processSerializedSignalStub = sinon.stub(); +const countByRoomIdAndUserIdStub = sinon.stub(); +const findSubscriptionsExcludingUserStub = sinon.stub(); + +const { NotificationsModule } = proxyquire.noCallThru().load('../../../../../server/modules/notifications/notifications.module', { + '@rocket.chat/core-services': { + VideoConf: { validateAction: validateActionStub }, + MediaCall: { processSerializedSignal: processSerializedSignalStub }, + }, + '@rocket.chat/models': { + Subscriptions: { + countByRoomIdAndUserId: countByRoomIdAndUserIdStub, + findByRoomIdAndNotUserId: findSubscriptionsExcludingUserStub, + }, + Rooms: {}, + Users: {}, + }, +}); + +describe('NotificationsModule', () => { + let notifications: any; + + beforeEach(() => { + notifications = new NotificationsModule(TestStreamer as any); + notifications.configure(); + }); + + describe('notify-user allowWrite', () => { + // `isWriteAllowed` is public on the concrete Streamer class but not on the IStreamer + // interface that `streamUser` is typed as, so cast to reach it. + const writeAllowed = (eventName: string, ...args: unknown[]) => + (notifications.streamUser as unknown as Streamer<'notify-user'>).isWriteAllowed({ userId: 'userId' } as any, eventName, args); + + beforeEach(() => { + validateActionStub.reset(); + validateActionStub.resolves(true); + processSerializedSignalStub.reset(); + processSerializedSignalStub.resolves(undefined); + countByRoomIdAndUserIdStub.reset(); + findSubscriptionsExcludingUserStub.reset(); + }); + + afterEach(() => { + Object.keys(StreamerCentral.instances).forEach((name) => delete StreamerCentral.instances[name]); + }); + + ['force_logout', 'notification', 'message', 'uiInteraction', 'subscriptions-changed', 'webdav', 'banners'].forEach((event) => { + it(`should deny a logged-in client writing "${event}" to another user's stream`, async () => { + expect(await writeAllowed(`victim/${event}`, { foo: 'bar' })).to.equal(false); + }); + }); + + it("should deny writes even to the client's own stream", async () => { + expect(await writeAllowed(`userId/force_logout`, undefined)).to.equal(false); + }); + + it('should accept "video-conference" and delegate authorization to VideoConf.validateAction', async () => { + const result = await writeAllowed(`userId/video-conference`, { + action: 'call-start', + params: { callId: '123', uid: '456', rid: '789' }, + }); + + expect(result).to.be.true; + expect(validateActionStub.calledOnceWith('call-start', 'userId', { callId: '123', uid: '456', rid: '789' })).to.be.true; + }); + + it('should process "media-calls" signals server-side and never broadcast them', async () => { + const signal = '{"type":"offer"}'; + const result = await writeAllowed(`userId/media-calls`, signal); + + expect(result).to.equal(false); + expect(processSerializedSignalStub.calledOnceWith('userId', signal)).to.be.true; + }); + }); + + describe('notify-room-users allowWrite', () => { + const writeAllowed = (eventName: string, ...args: unknown[]) => + (notifications.streamRoomUsers as unknown as Streamer<'notify-room-users'>).isWriteAllowed( + { userId: 'attacker' } as any, + eventName, + args, + ); + + beforeEach(() => { + countByRoomIdAndUserIdStub.reset(); + countByRoomIdAndUserIdStub.resolves(1); // attacker is subscribed to the room + findSubscriptionsExcludingUserStub.reset(); + findSubscriptionsExcludingUserStub.returns({ toArray: async () => [{ u: { _id: 'victim' } }] }); + }); + + afterEach(() => { + Object.keys(StreamerCentral.instances).forEach((name) => delete StreamerCentral.instances[name]); + }); + + ['force_logout', 'notification', 'message', 'uiInteraction', 'subscriptions-changed', 'webdav', 'banners'].forEach((event) => { + it(`should deny and not relay an arbitrary "${event}" event to other room members`, async () => { + const emitSpy = sinon.spy(notifications.streamUser, 'emit'); + + const result = await writeAllowed(`room1/${event}`, { foo: 'bar' }); + + expect(result).to.equal(false); + expect(findSubscriptionsExcludingUserStub.called).to.equal(false); + expect(emitSpy.called).to.equal(false); + }); + }); + + it('should not relay anything for a user not subscribed to the room', async () => { + countByRoomIdAndUserIdStub.resolves(0); + const emitSpy = sinon.spy(notifications.streamUser, 'emit'); + + const result = await writeAllowed('room1/video-conference', { + action: 'call-start', + params: { callId: '123', uid: 'victim', rid: 'room1' }, + }); + + expect(result).to.equal(false); + expect(findSubscriptionsExcludingUserStub.called).to.equal(false); + expect(emitSpy.called).to.equal(false); + }); + + ['video-conference', 'userData'].forEach((event) => { + it(`should relay "${event}" event to other room members`, async () => { + const emitSpy = sinon.spy(notifications.streamUser, 'emit'); + + const result = await writeAllowed(`room1/${event}`, { foo: 'bar' }); + + expect(result).to.equal(false); + expect(findSubscriptionsExcludingUserStub.called).to.equal(true); + expect(emitSpy.called).to.equal(true); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts b/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts new file mode 100644 index 0000000..2cf64b7 --- /dev/null +++ b/apps/meteor/tests/unit/server/modules/streamer/streamer.module.spec.ts @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { Streamer, StreamerCentral } from '../../../../../server/modules/streamer/streamer.module'; + +class TestStreamer extends Streamer { + registerPublication(): void { + // no-op for unit test subclass + } + + registerMethod(): void { + // no-op for unit test subclass + } + + changedPayload(): string { + return 'payload'; + } +} + +type TestSubscription = { + entry: any; + connection: Record; + send: sinon.SinonSpy; +}; + +const makeSubscription = (connectionId: string): TestSubscription => { + const send = sinon.spy(); + const connection = { id: connectionId }; + + return { + connection, + send, + entry: { + eventName: 'event', + subscription: { + connection, + _session: { + socket: { send }, + }, + }, + }, + }; +}; + +describe('Streamer.sendToManySubscriptions', () => { + let streamer: TestStreamer; + let streamerNameSeed = 0; + + beforeEach(() => { + streamer = new TestStreamer(`streamer-test-${streamerNameSeed++}`); + }); + + afterEach(() => { + sinon.restore(); + delete StreamerCentral.instances[streamer.name]; + }); + + it('waits for async permission checks before resolving', async () => { + const sub = makeSubscription('conn-1'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed').resolves(true); + + const sendPromise = streamer.sendToManySubscriptions(new Set([sub.entry]), undefined, 'event', [], 'test-msg'); + + expect(sub.send.called).to.equal(false); + + await sendPromise; + + expect(isEmitAllowed.calledOnceWithExactly(sub.entry.subscription, 'event')).to.equal(true); + expect(sub.send.calledOnceWithExactly('test-msg')).to.equal(true); + }); + + it('skips origin subscription and sends only to allowed subscriptions', async () => { + const originSub = makeSubscription('origin'); + const allowedSub = makeSubscription('allowed'); + const deniedSub = makeSubscription('denied'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed'); + isEmitAllowed.onFirstCall().resolves(true); + isEmitAllowed.onSecondCall().resolves(false); + + await streamer.sendToManySubscriptions( + new Set([originSub.entry, allowedSub.entry, deniedSub.entry]), + originSub.connection as any, + 'event', + [], + 'test-msg', + ); + + expect(originSub.send.called).to.equal(false); + expect(allowedSub.send.calledOnceWithExactly('test-msg')).to.equal(true); + expect(deniedSub.send.called).to.equal(false); + }); + + it('continues dispatching to other subscribers when a permission check rejects', async () => { + const failingSub = makeSubscription('failing'); + const successSub = makeSubscription('success'); + const error = new Error('boom'); + + const isEmitAllowed = sinon.stub(streamer, 'isEmitAllowed'); + isEmitAllowed.onFirstCall().rejects(error); + isEmitAllowed.onSecondCall().resolves(true); + + await streamer.sendToManySubscriptions(new Set([failingSub.entry, successSub.entry]), undefined, 'event-name', [], 'test-msg'); + + expect(isEmitAllowed.calledTwice).to.equal(true); + expect(isEmitAllowed.firstCall.calledWithExactly(failingSub.entry.subscription, 'event-name')).to.equal(true); + expect(isEmitAllowed.secondCall.calledWithExactly(successSub.entry.subscription, 'event-name')).to.equal(true); + expect(failingSub.send.called).to.equal(false); + expect(successSub.send.calledOnceWithExactly('test-msg')).to.equal(true); + }); +}); diff --git a/apps/meteor/tests/unit/server/publications/messages.spec.ts b/apps/meteor/tests/unit/server/publications/messages.spec.ts new file mode 100644 index 0000000..c721e7c --- /dev/null +++ b/apps/meteor/tests/unit/server/publications/messages.spec.ts @@ -0,0 +1,354 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const mockMeteorError = class extends Error { + constructor( + public error: string, + public reason: string, + public details: any, + ) { + super(reason); + this.name = 'Meteor.Error'; + } +}; + +const messagesMock = { + findForUpdates: sinon.stub(), + trashFindDeletedAfter: sinon.stub(), + trashFind: sinon.stub(), +}; + +const canAccessRoomIdAsyncStub = sinon.stub().resolves(true); +const getChannelHistoryStub = sinon.stub(); + +const { + extractTimestampFromCursor, + mountCursorQuery, + mountCursorFromMessage, + mountNextCursor, + mountPreviousCursor, + handleWithoutPagination, + handleCursorPagination, + getMessageHistory, +} = proxyquire.noCallThru().load('../../../../server/publications/messages', { + 'meteor/check': { + check: sinon.stub(), + }, + '../lib/authorization/canAccessRoom': { + canAccessRoomIdAsync: canAccessRoomIdAsyncStub, + }, + '../meteor-methods/messages/getChannelHistory': { + getChannelHistory: getChannelHistoryStub, + }, + 'meteor/meteor': { + 'Meteor': { + methods: sinon.stub(), + Error: mockMeteorError, + }, + '@global': true, + }, + '@rocket.chat/models': { + Messages: messagesMock, + }, +}); + +describe('extractTimestampFromCursor', () => { + it('should return the correct timestamp', () => { + const cursor = new Date('2024-01-01T00:00:00.000Z').getTime().toString(); + const timestamp = extractTimestampFromCursor(cursor); + expect(timestamp).to.be.an.instanceOf(Date); + expect(timestamp.getTime()).to.equal(parseInt(cursor, 10)); + }); + + it('should handle non-date compliant string', () => { + expect(() => extractTimestampFromCursor('not-a-date')).to.throw(Error, 'Invalid Date'); + }); +}); + +describe('mountCursorQuery', () => { + const mockDate = new Date('2024-01-01T00:00:00.000Z').getTime(); + + it('should return a query with $gt when next is provided', () => { + const result = mountCursorQuery({ next: mockDate.toString() }); + expect(result.query).to.deep.equal({ $gt: new Date(mockDate) }); + }); + + it('should return a query with $lt when previous is provided', () => { + const result = mountCursorQuery({ previous: mockDate.toString() }); + expect(result.query).to.deep.equal({ $lt: new Date(mockDate) }); + }); + + it('should return a query with $gt and sort when neither next nor previous is provided', () => { + const result = mountCursorQuery({ count: 10 }); + expect(result.query).to.deep.equal({ $gt: new Date(0) }); + expect(result.options).to.deep.equal({ sort: { _updatedAt: 1 } }); + }); + + it('should return a query with $gt and ascending sort when next is provided', () => { + const result = mountCursorQuery({ next: mockDate.toString(), count: 10 }); + expect(result.query).to.deep.equal({ $gt: new Date(mockDate) }); + expect(result.options).to.deep.equal({ sort: { _updatedAt: 1 }, limit: 10 + 1 }); + }); + + it('should return a query with $gt and descending sort when previous is provided', () => { + const result = mountCursorQuery({ previous: mockDate.toString(), count: 10 }); + expect(result.query).to.deep.equal({ $lt: new Date(mockDate) }); + expect(result.options).to.deep.equal({ sort: { _updatedAt: -1 }, limit: 10 + 1 }); + }); +}); + +describe('mountCursorFromMessage', () => { + it('should return the updated timestamp for UPDATED type', () => { + const message: Pick = { + _updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const result = mountCursorFromMessage(message, 'UPDATED'); + expect(result).to.equal(`${message._updatedAt.getTime()}`); + }); + + it('should return the deleted timestamp for DELETED type', () => { + const message: Partial & { _deletedAt: Date } = { + _deletedAt: new Date('2024-01-01T00:00:00Z'), + }; + + const result = mountCursorFromMessage(message, 'DELETED'); + expect(result).to.equal(`${message._deletedAt.getTime()}`); + }); + + it('should throw an error if DELETED type and _deletedAt is not present', () => { + const message: Pick = { + _updatedAt: new Date('2024-01-01T00:00:00Z'), + }; + + expect(() => mountCursorFromMessage(message, 'DELETED')).to.throw(mockMeteorError, 'Cursor not found'); + }); +}); + +describe('mountNextCursor', () => { + const mockMessage = (timestamp: number): Pick => ({ + _id: '1', + ts: new Date(timestamp), + _updatedAt: new Date(timestamp), + }); + + it('should return null if messages array is empty', () => { + expect(mountNextCursor([], 10, 'UPDATED')).to.be.null; + }); + + it('should return the first message cursor if previous is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000)]; + expect(mountNextCursor(messages, 10, 'UPDATED', undefined, 'prev')).to.equal('1000'); + }); + + it('should return null if messages length is less than or equal to count and next is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000)]; + expect(mountNextCursor(messages, 2, 'UPDATED', 'next')).to.be.null; + }); + + it('should return the second last message cursor if messages length is greater than count and next is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000), mockMessage(3000)]; + expect(mountNextCursor(messages, 2, 'UPDATED', 'next')).to.equal('2000'); + }); + + it('should return the last message cursor if no next or previous is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000)]; + expect(mountNextCursor(messages, 10, 'UPDATED')).to.equal('2000'); + }); +}); + +describe('mountPreviousCursor', () => { + const mockMessage = (timestamp: number): Pick => ({ + _id: '1', + ts: new Date(timestamp), + _updatedAt: new Date(timestamp), + }); + + it('should return null if messages array is empty', () => { + expect(mountPreviousCursor([], 10, 'UPDATED')).to.be.null; + }); + + it('should return the first message cursor if messages length is less than or equal to count and next is provided', () => { + const messages = [mockMessage(1000)]; + expect(mountPreviousCursor(messages, 1, 'UPDATED', 'nextCursor')).to.equal('1000'); + }); + + it('should return the first message cursor if messages length is greater than count and next is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000)]; + expect(mountPreviousCursor(messages, 1, 'UPDATED', 'nextCursor')).to.equal('1000'); + }); + + it('should return null if messages length is less than or equal to count and previous is provided', () => { + const messages = [mockMessage(1000)]; + expect(mountPreviousCursor(messages, 1, 'UPDATED', undefined, 'previousCursor')).to.be.null; + }); + + it('should return the second last message cursor if messages length is greater than count and previous is provided', () => { + const messages = [mockMessage(1000), mockMessage(2000), mockMessage(3000)]; + expect(mountPreviousCursor(messages, 2, 'UPDATED', undefined, 'previousCursor')).to.equal('2000'); + }); + + it('should return the first message cursor if no next or previous is provided', () => { + const messages = [mockMessage(1000)]; + expect(mountPreviousCursor(messages, 1, 'UPDATED')).to.equal('1000'); + }); +}); + +describe('handleWithoutPagination', () => { + afterEach(() => { + messagesMock.findForUpdates.reset(); + messagesMock.trashFindDeletedAfter.reset(); + }); + + it('should return updated and deleted messages', async () => { + const rid = 'roomId'; + const lastUpdate = new Date(); + + const updatedMessages = [{ _id: '1', text: 'Hello' }]; + const deletedMessages = [{ _id: '2', _deletedAt: new Date() }]; + + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves(updatedMessages) }); + messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves(deletedMessages) }); + + const result = await handleWithoutPagination(rid, lastUpdate); + + expect(result).to.deep.equal({ + updated: updatedMessages, + deleted: deletedMessages, + }); + + sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: undefined }, { sort: { ts: -1 } }); + sinon.assert.calledWith( + messagesMock.trashFindDeletedAfter, + lastUpdate, + { rid }, + { projection: { _id: 1, _deletedAt: 1 }, sort: { ts: -1 } }, + ); + }); + + it('should bound both queries by `ts` when fromTs is provided', async () => { + const rid = 'roomId'; + const lastUpdate = new Date('2024-01-02T00:00:00.000Z'); + const fromTs = new Date('2024-01-01T00:00:00.000Z'); + + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) }); + messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) }); + + await handleWithoutPagination(rid, lastUpdate, fromTs); + + sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, { sort: { ts: -1 } }); + sinon.assert.calledWith( + messagesMock.trashFindDeletedAfter, + lastUpdate, + { rid, ts: { $gte: fromTs } }, + { projection: { _id: 1, _deletedAt: 1 }, sort: { ts: -1 } }, + ); + }); + + it('should handle empty results', async () => { + const rid = 'roomId'; + const lastUpdate = new Date(); + + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) }); + messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) }); + + const result = await handleWithoutPagination(rid, lastUpdate); + + expect(result).to.deep.equal({ + updated: [], + deleted: [], + }); + }); +}); + +describe('handleCursorPagination', () => { + it('should return updated messages and cursor when type is UPDATED', async () => { + const rid = 'roomId'; + const count = 10; + const messages = [{ _id: 'msg1', _updatedAt: new Date('2024-01-01T00:00:00Z') }]; + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves(messages) }); + + const result = await handleCursorPagination('UPDATED', rid, count); + + expect(messagesMock.findForUpdates.calledOnce).to.be.true; + // the cursor path has no `ts` bound to apply — it paginates on `_updatedAt` alone + sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: new Date(0) } }, { sort: { _updatedAt: 1 } }); + expect(result.updated).to.deep.equal(messages); + expect(result.cursor).to.have.keys(['next', 'previous']); + }); + + it('should return deleted messages and cursor when type is DELETED', async () => { + const rid = 'roomId'; + const count = 10; + const messages = [{ _id: 'msg1', _deletedAt: new Date() }]; + messagesMock.trashFind.returns({ toArray: sinon.stub().resolves(messages) }); + + const result = await handleCursorPagination('DELETED', rid, count); + + expect(messagesMock.trashFind.calledOnce).to.be.true; + expect(result.deleted).to.deep.equal(messages); + expect(result.cursor).to.have.keys(['next', 'previous']); + }); + + it('should handle empty response correctly', async () => { + const rid = 'roomId'; + const count = 10; + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) }); + + const result = await handleCursorPagination('UPDATED', rid, count); + + expect(result.updated).to.deep.equal([]); + expect(result.cursor).to.deep.equal({ next: null, previous: null }); + }); +}); + +describe('getMessageHistory', () => { + const rid = 'roomId'; + const fromId = 'userId'; + + const catchError = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + return error; + } + + throw new Error('expected the call to be rejected'); + }; + + beforeEach(() => { + messagesMock.findForUpdates.reset(); + messagesMock.trashFindDeletedAfter.reset(); + messagesMock.trashFind.reset(); + getChannelHistoryStub.reset(); + }); + + it('should reject "fromTs" when combined with cursor pagination', async () => { + const error = await catchError(getMessageHistory(rid, fromId, { fromTs: new Date(), type: 'UPDATED', next: '1704067200000' })); + + expect(error).to.have.property('error', 'error-fromTs-requires-lastUpdate'); + sinon.assert.notCalled(messagesMock.findForUpdates); + }); + + it('should reject "fromTs" when it would fall through to the channel history', async () => { + const error = await catchError(getMessageHistory(rid, fromId, { fromTs: new Date() })); + + expect(error).to.have.property('error', 'error-fromTs-requires-lastUpdate'); + sinon.assert.notCalled(getChannelHistoryStub); + }); + + it('should bound the query by "fromTs" when "lastUpdate" is provided', async () => { + const lastUpdate = new Date('2024-01-02T00:00:00.000Z'); + const fromTs = new Date('2024-01-01T00:00:00.000Z'); + + messagesMock.findForUpdates.returns({ toArray: sinon.stub().resolves([]) }); + messagesMock.trashFindDeletedAfter.returns({ toArray: sinon.stub().resolves([]) }); + + await getMessageHistory(rid, fromId, { lastUpdate, fromTs }); + + sinon.assert.calledWith(messagesMock.findForUpdates, rid, { updatedAt: { $gt: lastUpdate }, minTs: fromTs }, { sort: { ts: -1 } }); + }); +}); diff --git a/apps/meteor/tests/unit/server/sdk/errors.tests.ts b/apps/meteor/tests/unit/server/sdk/errors.tests.ts new file mode 100644 index 0000000..5451e71 --- /dev/null +++ b/apps/meteor/tests/unit/server/sdk/errors.tests.ts @@ -0,0 +1,57 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; + +describe('MeteorError', () => { + it('should create an error with no reason like Meteor.Error', () => { + const error = new MeteorError('no reason'); + + const stringfiedError = { isClientSafe: true, errorType: 'Meteor.Error', error: 'no reason', message: '[no reason]' }; + + expect(error.error).to.equal('no reason'); + expect(error.reason).to.be.undefined; + expect(error.message).to.equal('[no reason]'); + expect(error.details).to.be.undefined; + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); + it('should create an error with reason like Meteor.Error', () => { + const error = new MeteorError('some message', 'some reason'); + + const stringfiedError = { + isClientSafe: true, + errorType: 'Meteor.Error', + error: 'some message', + message: 'some reason [some message]', + reason: 'some reason', + }; + + expect(error.error).to.equal('some message'); + expect(error.reason).to.equal('some reason'); + expect(error.message).to.equal('some reason [some message]'); + expect(error.details).to.be.undefined; + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); + it('should create an error with reason and details like Meteor.Error', () => { + const error = new MeteorError('some message', 'some reason', { some: 'details' }); + + const stringfiedError = { + isClientSafe: true, + errorType: 'Meteor.Error', + error: 'some message', + message: 'some reason [some message]', + reason: 'some reason', + details: { some: 'details' }, + }; + + expect(error.error).to.equal('some message'); + expect(error.reason).to.equal('some reason'); + expect(error.message).to.equal('some reason [some message]'); + expect(error.details).to.be.deep.equal({ some: 'details' }); + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts b/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts new file mode 100644 index 0000000..b2ee75c --- /dev/null +++ b/apps/meteor/tests/unit/server/services/ai-search/service.tests.ts @@ -0,0 +1,377 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const License = { + hasModule: sinon.stub(), +}; + +const cachedSettings = { + get: sinon.stub(), +}; + +const Messages = { + findVisibleByIds: sinon.stub(), +}; + +const Rooms = { + findByIds: sinon.stub(), + findOneByNameOrFname: sinon.stub(), +}; + +const Subscriptions = { + findByUserId: sinon.stub(), + findByUserIdAndRoomIds: sinon.stub(), +}; + +const Users = { + findOneById: sinon.stub(), +}; + +const serverFetch = sinon.stub(); + +const { AISearchService } = proxyquire.noCallThru().load('../../../../../server/services/ai-search/service', { + '@rocket.chat/core-services': { + License, + ServiceClass: class { + protected name = ''; + }, + }, + '../../settings': { + settings: cachedSettings, + }, + '@rocket.chat/logger': { + Logger: class { + debug = sinon.stub(); + + warn = sinon.stub(); + }, + }, + '@rocket.chat/models': { + Messages, + Rooms, + Subscriptions, + Users, + }, + '@rocket.chat/server-fetch': { + serverFetch, + }, +}); + +type CursorResult = { + toArray(): Promise; + map(callback: (item: T) => U): CursorResult; +}; + +const cursor = (items: T[]): CursorResult => ({ + toArray: async () => items, + map: (callback) => cursor(items.map(callback)), +}); + +const settings: Record = { + AI_Intelligent_Search_Enabled: true, + AI_Intelligent_Search_Pipeline_Base_URL: 'https://pipeline.example.com', + AI_Intelligent_Search_Pipeline_ID: 'workspace', + AI_Intelligent_Search_API_Key: 'key', + AI_Intelligent_Search_API_Key_Secret: 'secret', + AI_Intelligent_Search_Query_Template: '', + AI_Intelligent_Search_Min_Similarity_Percent: 61, + AI_Intelligent_Search_Answer_Enabled: true, + AI_LLM_OpenAI_Base_URL: 'https://llm.example.com', + AI_LLM_OpenAI_API_Key: 'llm-key', + AI_LLM_OpenAI_Model: 'gpt-test', + AI_Intelligent_Search_Answer_System_Prompt: 'Use sources only.', + SSRF_Allowlist: 'pipeline.example.com,llm.example.com', +}; + +const createService = (): InstanceType => new AISearchService(); + +describe('AISearchService', () => { + beforeEach(() => { + License.hasModule.reset(); + cachedSettings.get.reset(); + Messages.findVisibleByIds.reset(); + Rooms.findByIds.reset(); + Rooms.findOneByNameOrFname.reset(); + Subscriptions.findByUserId.reset(); + Subscriptions.findByUserIdAndRoomIds.reset(); + Users.findOneById.reset(); + serverFetch.reset(); + + License.hasModule.resolves(true); + cachedSettings.get.callsFake((key: string) => settings[key]); + Users.findOneById.resolves({ roles: ['admin'] }); + Rooms.findByIds.callsFake((roomIds: string[]) => + cursor( + roomIds.map((roomId) => ({ + _id: roomId, + t: 'c', + name: roomId === 'allowed' ? 'general' : roomId, + fname: roomId === 'allowed' ? 'General' : roomId, + })), + ), + ); + Subscriptions.findByUserIdAndRoomIds.callsFake((_userId: string, roomIds: string[]) => + cursor(roomIds.filter((roomId) => roomId === 'allowed' || roomId === 'room-general').map((rid) => ({ rid }))), + ); + Messages.findVisibleByIds.callsFake((msgIds: string[]) => + cursor( + msgIds.map((msgId) => ({ + _id: msgId, + rid: msgId === 'blocked-msg' ? 'blocked' : 'allowed', + msg: `${msgId} from db`, + ts: new Date('2026-01-05T12:00:00.000Z'), + u: { username: 'alice', name: 'Alice' }, + })), + ), + ); + }); + + describe('status', () => { + it('reports availability from license, settings, pipeline, and LLM configuration', async () => { + expect(await createService().status()).to.deep.equal({ + hasIntelligentSearchLicense: true, + intelligentSearchEnabled: true, + intelligentSearchConfigured: true, + answerGenerationConfigured: true, + }); + }); + + it('marks answer generation unavailable when the answer setting is off', async () => { + cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key])); + + expect(await createService().status()).to.include({ + answerGenerationConfigured: false, + }); + }); + + it('marks answer generation unavailable when the pipeline is not configured', async () => { + cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Pipeline_Base_URL' ? '' : settings[key])); + + expect(await createService().status()).to.include({ + intelligentSearchConfigured: false, + answerGenerationConfigured: false, + }); + }); + }); + + describe('search', () => { + it('does not call the pipeline when license, setting, or configuration is unavailable', async () => { + License.hasModule.resolves(false); + + expect(await createService().search({ query: 'fruit', userId: 'user-id' })).to.deep.equal([]); + expect(serverFetch.called).to.be.false; + }); + + it('sends subscribed room scope to the pipeline for broad searches', async () => { + const subscribedRoomIds = [...Array.from({ length: 1001 }, (_, index) => `room-${index}`), 'allowed']; + Subscriptions.findByUserId.returns(cursor([...subscribedRoomIds.map((rid) => ({ rid })), { rid: 'banned-room', status: 'BANNED' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [ + { metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', score: 0.1 }, + { metadata: { room_id: 'allowed', msg_id: 'allowed-msg' }, text: 'allowed pipeline text', score: 0.39 }, + ], + }), + text: async () => '', + }); + + const results = await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 }); + + expect(results).to.deep.equal([ + { + _id: 'allowed-msg', + rid: 'allowed', + msgId: 'allowed-msg', + text: 'allowed-msg from db', + ts: '2026-01-05T12:00:00.000Z', + u: { username: 'alice', name: 'Alice' }, + score: 0.61, + room: { _id: 'allowed', t: 'c', name: 'general', fname: 'General' }, + }, + ]); + + const [, options] = serverFetch.firstCall.args; + const body = JSON.parse(options.body); + expect(body.params.k).to.equal(5); + expect(body.filters).to.deep.equal({ + room_id: { $in: subscribedRoomIds }, + }); + expect(options).to.include({ + ignoreSsrfValidation: false, + allowList: 'pipeline.example.com,llm.example.com', + }); + expect( + Subscriptions.findByUserId.calledWith('user-id', { + projection: { rid: 1, status: 1 }, + }), + ).to.be.true; + expect( + Subscriptions.findByUserIdAndRoomIds.calledWith('user-id', ['blocked', 'allowed'], { + projection: { rid: 1, status: 1 }, + }), + ).to.be.true; + }); + + it('resolves room-name filters before querying the pipeline', async () => { + Rooms.findOneByNameOrFname.resolves({ _id: 'room-general' }); + Subscriptions.findByUserId.returns(cursor([{ rid: 'room-general' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [ + { metadata: { room_id: 'room-general', msg_id: 'general-msg' }, text: 'general pipeline text', similarity: 0.8 }, + { metadata: { room_id: 'blocked', msg_id: 'blocked-msg' }, text: 'blocked pipeline text', similarity: 0.99 }, + ], + }), + text: async () => '', + }); + Messages.findVisibleByIds.returns( + cursor([ + { + _id: 'general-msg', + rid: 'room-general', + msg: 'general message from db', + ts: new Date('2026-01-05T12:00:00.000Z'), + u: { username: 'alice', name: 'Alice' }, + }, + ]), + ); + Rooms.findByIds.returns(cursor([{ _id: 'room-general', t: 'c', name: 'general', fname: 'General' }])); + + const results = await createService().search({ + query: 'fruit', + userId: 'user-id', + filters: { + roomNames: ['general'], + fromUsernames: ['alice'], + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-01-31T00:00:00.000Z', + }, + limit: 5, + }); + + expect(results).to.have.lengthOf(1); + expect(results[0]).to.include({ _id: 'general-msg', rid: 'room-general', score: 0.8 }); + expect( + Messages.findVisibleByIds.calledWith(['general-msg', 'blocked-msg'], { + projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 }, + }), + ).to.be.true; + + const [, options] = serverFetch.firstCall.args; + expect(JSON.parse(options.body).filters).to.deep.equal({ + room_id: { $eq: 'room-general' }, + username: { $eq: 'alice' }, + timestamp: { + $ge: '2026-01-01T00:00:00.000Z', + $le: '2026-01-31T00:00:00.000Z', + }, + }); + }); + + it('returns no results when room-name filters do not resolve to subscribed rooms', async () => { + Rooms.findOneByNameOrFname.resolves(null); + + expect( + await createService().search({ query: 'fruit', userId: 'user-id', filters: { roomNames: ['unknown-room'] }, limit: 5 }), + ).to.deep.equal([]); + expect(serverFetch.called).to.be.false; + }); + + it('drops pipeline hits that do not reference a message', async () => { + Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [{ metadata: { room_id: 'allowed' }, text: 'index text without a message reference', score: 0.1 }], + }), + text: async () => '', + }); + + expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]); + }); + + it('drops pipeline hits when the referenced message is not visible', async () => { + Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [{ metadata: { room_id: 'allowed', msg_id: 'hidden-msg' }, text: 'pipeline text should not leak', score: 0.1 }], + }), + text: async () => '', + }); + Messages.findVisibleByIds.returns(cursor([])); + + expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]); + }); + + it('drops pipeline hits from rooms where the user subscription is banned', async () => { + Subscriptions.findByUserId.returns(cursor([{ rid: 'allowed' }])); + Subscriptions.findByUserIdAndRoomIds.returns(cursor([{ rid: 'allowed', status: 'BANNED' }])); + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ + results: [{ metadata: { room_id: 'allowed', msg_id: 'allowed-msg' }, text: 'pipeline text', similarity: 0.8 }], + }), + text: async () => '', + }); + + expect(await createService().search({ query: 'fruit', userId: 'user-id', limit: 5 })).to.deep.equal([]); + }); + }); + + describe('answer', () => { + it('rejects answer generation when AI Search or answer generation is unavailable', async () => { + cachedSettings.get.callsFake((key: string) => (key === 'AI_Intelligent_Search_Answer_Enabled' ? false : settings[key])); + + try { + await createService().answer({ query: 'fruit', messages: [{ text: 'oranges are green' }] }); + throw new Error('Expected answer generation to fail'); + } catch (error) { + expect((error as Error).message).to.equal('error-ai-not-enabled'); + } + expect(serverFetch.called).to.be.false; + }); + + it('generates an answer from source messages with the configured LLM provider', async () => { + serverFetch.resolves({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: 'Oranges are green.' } }] }), + text: async () => '', + }); + + expect( + await createService().answer({ + query: 'fruit colors', + messages: [{ text: 'oranges are green', username: 'alice', roomName: 'general', score: 0.61 }], + }), + ).to.deep.equal({ + answer: 'Oranges are green.', + provider: { name: 'OpenAI compatible', model: 'gpt-test' }, + }); + + const [url, options] = serverFetch.firstCall.args; + expect(url).to.equal('https://llm.example.com/chat/completions'); + expect(options.headers.Authorization).to.equal('Bearer llm-key'); + expect(JSON.parse(options.body).messages[0]).to.deep.equal({ role: 'system', content: 'Use sources only.' }); + }); + }); + + describe('models', () => { + it('does not contact the provider without the AI add-on', async () => { + License.hasModule.resolves(false); + + expect(await createService().models()).to.deep.equal([]); + expect(serverFetch.called).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts new file mode 100644 index 0000000..8ea0d3e --- /dev/null +++ b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts @@ -0,0 +1,223 @@ +import type { IAppsEngineService } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const AppsMock = { + self: { + isInitialized: sinon.stub(), + getManager: sinon.stub(), + getStorage: sinon.stub(), + getAppSourceStorage: sinon.stub(), + getRocketChatLogger: sinon.stub(), + triggerEvent: sinon.stub(), + }, +}; + +const apiMock = { + call: sinon.stub(), + nodeList: sinon.stub(), +}; + +const isRunningMsMock = sinon.stub(); + +const serviceMocks = { + '@rocket.chat/apps': { Apps: AppsMock }, + '@rocket.chat/core-services': { + api: apiMock, + ServiceClassInternal: class { + onEvent = sinon.stub(); + }, + }, + '../../lib/isRunningMs': { isRunningMs: isRunningMsMock }, + '../../lib/logger/system': { SystemLogger: { error: sinon.stub() } }, +}; + +const { AppsEngineService, AppsEngineNoNodesFoundError } = proxyquire + .noCallThru() + .load('../../../../../server/services/apps-engine/service', serviceMocks); + +describe('AppsEngineService', () => { + let service: IAppsEngineService; + + it('should instantiate properly', () => { + expect(new AppsEngineService()).to.be.instanceOf(AppsEngineService); + }); + + describe('#getAppsStatusInNode - part 1', () => { + it('should error if api is not available', async () => { + isRunningMsMock.returns(true); + + const service = new AppsEngineService(); + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('AppsEngineService is not initialized'); + }); + }); + + beforeEach(() => { + service = new AppsEngineService(); + (service as any).api = apiMock; + }); + + afterEach(() => { + apiMock.call.reset(); + apiMock.nodeList.reset(); + AppsMock.self.isInitialized.reset(); + AppsMock.self.getManager.reset(); + AppsMock.self.getStorage.reset(); + AppsMock.self.getAppSourceStorage.reset(); + AppsMock.self.getRocketChatLogger.reset(); + AppsMock.self.triggerEvent.reset(); + isRunningMsMock.reset(); + }); + + describe('#isInitialized', () => { + it('should return true when Apps is initialized', () => { + AppsMock.self.isInitialized.returns(true); + expect(service.isInitialized()).to.be.true; + }); + + it('should return false when Apps is not initialized', () => { + AppsMock.self.isInitialized.returns(false); + expect(service.isInitialized()).to.be.false; + }); + }); + + describe('#getApps', () => { + it('should return app info from manager', async () => { + const mockApps = [{ getInfo: () => ({ id: 'app1' }) }]; + const mockManager = { get: sinon.stub().resolves(mockApps) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getApps({}); + expect(result).to.deep.equal([{ id: 'app1' }]); + }); + + it('should return undefined when manager is not available', async () => { + AppsMock.self.getManager.returns(undefined); + const result = await service.getApps({}); + expect(result).to.be.undefined; + }); + }); + + describe('#getAppsStatusLocal', () => { + it('should return app status reports', async () => { + const mockApps = [ + { + getStatus: sinon.stub().resolves('enabled'), + getID: sinon.stub().returns('app1'), + }, + ]; + const mockManager = { get: sinon.stub().resolves(mockApps) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppsStatusLocal(); + expect(result).to.deep.equal([ + { + status: 'enabled', + appId: 'app1', + }, + ]); + }); + + it('should return empty array when manager is not available', async () => { + AppsMock.self.getManager.returns(undefined); + const result = await service.getAppsStatusLocal(); + expect(result).to.deep.equal([]); + }); + }); + + describe('#getAppStorageItemById', () => { + it('should return storage item for existing app', async () => { + const mockStorageItem = { id: 'app1' }; + const mockApp = { + getStorageItem: sinon.stub().returns(mockStorageItem), + }; + const mockManager = { getOneById: sinon.stub().returns(mockApp) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppStorageItemById('app1'); + expect(result).to.equal(mockStorageItem); + }); + + it('should return undefined for non-existent app', async () => { + const mockManager = { getOneById: sinon.stub().returns(undefined) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppStorageItemById('non-existent'); + expect(result).to.be.undefined; + }); + }); + + describe('#getAppsStatusInNode - part 2', () => { + it('should throw error when not in microservices mode', async () => { + isRunningMsMock.returns(false); + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith( + 'Getting apps status in cluster is only available in microservices mode', + ); + }); + + it('should throw error when not enough apps-engine nodes are available', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call.resolves([{ name: 'apps-engine', nodes: ['node1'] }]); + + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith(AppsEngineNoNodesFoundError); + }); + + it('should return status from all nodes', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call + .onCall(0) + .resolves([{ name: 'apps-engine', nodes: ['node1', 'node2', 'node3'] }]) + .onCall(1) + .resolves([ + { status: 'enabled', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]) + .onCall(2) + .resolves([ + { status: 'enabled', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]) + .onCall(3) + .resolves([ + { status: 'initialized', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]); + + const result = await service.getAppsStatusInNodes(); + + expect(result).to.deep.equal({ + app1: [ + { instanceId: 'node1', isLocal: true, status: 'enabled' }, + { instanceId: 'node2', isLocal: false, status: 'enabled' }, + { instanceId: 'node3', isLocal: false, status: 'initialized' }, + ], + app2: [ + { instanceId: 'node1', isLocal: true, status: 'enabled' }, + { instanceId: 'node2', isLocal: false, status: 'enabled' }, + { instanceId: 'node3', isLocal: false, status: 'enabled' }, + ], + }); + }); + + it('should throw error when failed to get status from a node', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call + .onFirstCall() + .resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }]) + .onSecondCall() + .resolves([ + { status: 'enabled', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]) + .onThirdCall() + .resolves(undefined); + + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Failed to get apps status from node node2'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/authorization/canAccessRoom.spec.ts b/apps/meteor/tests/unit/server/services/authorization/canAccessRoom.spec.ts new file mode 100644 index 0000000..7c09cdf --- /dev/null +++ b/apps/meteor/tests/unit/server/services/authorization/canAccessRoom.spec.ts @@ -0,0 +1,119 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const sandbox = sinon.createSandbox(); + +const modelsMock = { + Subscriptions: { + findOneBannedSubscription: sandbox.stub(), + countByRoomIdAndUserId: sandbox.stub(), + }, + Rooms: { + findOneById: sandbox.stub(), + }, + TeamMember: { + findOneByUserIdAndTeamId: sandbox.stub(), + }, + Team: { + findOneById: sandbox.stub(), + }, + Users: { + findOneById: sandbox.stub(), + }, +}; + +const coreServicesMock = { + Authorization: { + hasPermission: sandbox.stub(), + canAccessRoom: sandbox.stub(), + }, + License: { + hasModule: sandbox.stub(), + }, + Abac: { + canAccessObject: sandbox.stub(), + }, + Settings: { + get: sandbox.stub(), + }, +}; + +const canAccessRoomLivechatMock = sandbox.stub(); + +const { canAccessRoom } = p.noCallThru().load('../../../../../server/services/authorization/canAccessRoom.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': coreServicesMock, + './canAccessRoomLivechat': { canAccessRoomLivechat: canAccessRoomLivechatMock }, +}); + +describe('canAccessRoom', () => { + beforeEach(() => { + sandbox.reset(); + + // sane defaults: everything denies access unless a test says otherwise + modelsMock.Subscriptions.findOneBannedSubscription.resolves(null); + modelsMock.Subscriptions.countByRoomIdAndUserId.resolves(0); + modelsMock.Rooms.findOneById.resolves(null); + modelsMock.TeamMember.findOneByUserIdAndTeamId.resolves(null); + modelsMock.Team.findOneById.resolves(null); + modelsMock.Users.findOneById.resolves(null); + coreServicesMock.Authorization.hasPermission.resolves(false); + coreServicesMock.Authorization.canAccessRoom.resolves(false); + coreServicesMock.License.hasModule.resolves(false); + coreServicesMock.Abac.canAccessObject.resolves(false); + coreServicesMock.Settings.get.resolves(false); + canAccessRoomLivechatMock.resolves(false); + }); + + describe('user hydration', () => { + it('should hydrate a partial user before running the validators', async () => { + const fullUser = { _id: 'user-id', username: 'john.doe', roles: ['user'] }; + modelsMock.Users.findOneById.resolves(fullUser); + coreServicesMock.Authorization.hasPermission.resolves(true); + + const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id' }); + + expect(result).to.be.true; + expect(modelsMock.Users.findOneById.calledOnceWith('user-id')).to.be.true; + expect(coreServicesMock.Authorization.hasPermission.calledWith(fullUser, 'view-c-room')).to.be.true; + }); + + it('should throw when the partial user cannot be found', async () => { + await expect(canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id' })).to.be.rejectedWith('User not found'); + }); + + it('should not hydrate a full user', async () => { + await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: 'user-id', username: 'john.doe' } as IUser); + + expect(modelsMock.Users.findOneById.notCalled).to.be.true; + }); + + it('should not hydrate an undefined user', async () => { + await canAccessRoom({ _id: 'room-id', t: 'c' }, undefined); + + expect(modelsMock.Users.findOneById.notCalled).to.be.true; + }); + + it('should not hydrate a user whose _id is undefined, and treat it as anonymous instead of throwing when anonymous read is on', async () => { + coreServicesMock.Settings.get.resolves(true); + + const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: undefined }); + + expect(modelsMock.Users.findOneById.notCalled).to.be.true; + expect(coreServicesMock.Settings.get.calledWith('Accounts_AllowAnonymousRead')).to.be.true; + expect(result).to.be.true; + }); + + it('should deny access to a user whose _id is undefined when anonymous read is off', async () => { + coreServicesMock.Settings.get.resolves(false); + + const result = await canAccessRoom({ _id: 'room-id', t: 'c' }, { _id: undefined }); + + expect(modelsMock.Users.findOneById.notCalled).to.be.true; + expect(result).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/banner/service.tests.ts b/apps/meteor/tests/unit/server/services/banner/service.tests.ts new file mode 100644 index 0000000..657423a --- /dev/null +++ b/apps/meteor/tests/unit/server/services/banner/service.tests.ts @@ -0,0 +1,149 @@ +import type { BannerPlatform, IBanner, IBannerDismiss } from '@rocket.chat/core-typings'; +import { registerModel, BaseRaw } from '@rocket.chat/models'; +import { expect } from 'chai'; +import { afterEach, before, describe, it } from 'mocha'; +import type { FindCursor, FindOptions } from 'mongodb'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const { BannerService } = proxyquire.noCallThru().load('../../../../../server/services/banner/service', {}); + +class BannerModel extends BaseRaw { + findActiveByRoleOrId( + _roles: string[], + _platform: BannerPlatform, + _bannerId?: string, + _options?: FindOptions, + ): FindCursor { + return {} as unknown as FindCursor; + } +} + +class BannerDismissModel extends BaseRaw { + findByUserIdAndBannerId(): FindCursor> { + return {} as unknown as FindCursor>; + } +} + +class UserModel extends BaseRaw { + override findOneById(): Promise { + return Promise.resolve({}); + } +} + +function findCursorFactory(items: T[]): FindCursor { + return { + toArray: () => Promise.resolve(items), + } as unknown as FindCursor; +} + +const bannersModel = new BannerModel({ collection: () => ({}) } as unknown as any, 'banner'); +const bannerDismissModel = new BannerDismissModel({ collection: () => ({}) } as unknown as any, 'banner_dismiss'); +const userModel = new UserModel({ collection: () => ({}) } as unknown as any, 'user'); + +describe('Banners service', () => { + before(() => { + registerModel('IBannersModel', bannersModel); + registerModel('IBannersDismissModel', bannerDismissModel); + registerModel('IUsersModel', userModel); + }); + + afterEach(() => sinon.restore()); + + it('should be defined', () => { + const service = new BannerService(); + + expect(service).to.be.an('object'); + }); + + describe('getBannersForUser', () => { + const FAKE_BANNER: IBanner = { + _id: 'fake-id', + view: { + appId: 'fake-app-id', + viewId: 'fake-view-id', + }, + } as IBanner; + + const A_SECOND_FAKE_BANNER: IBanner = { + _id: 'fake-id-2', + surface: 'modal', + view: {}, + } as IBanner; + + it('should return the banners a user has access to', async () => { + const service = new BannerService(); + const findActiveByRoleOrIdMock = sinon.replace( + bannersModel, + 'findActiveByRoleOrId', + sinon.fake.returns(findCursorFactory([FAKE_BANNER])), + ); + const findByUserIdAndBannerIdMock = sinon.replace( + bannerDismissModel, + 'findByUserIdAndBannerId', + sinon.fake.returns(findCursorFactory>([])), + ); + + sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({}))); + const banners = await service.getBannersForUser('a-fake-id', 'web'); + + expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1); + expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1); + + expect(banners).to.be.an('array'); + expect(banners).to.have.lengthOf(1); + expect(banners[0].view.viewId).to.be.equal(FAKE_BANNER.view.viewId); + expect(banners[0].surface).to.be.equal('banner'); + }); + + it('should return all the banners that were not dismissed', async () => { + const service = new BannerService(); + const findActiveByRoleOrIdMock = sinon.replace( + bannersModel, + 'findActiveByRoleOrId', + sinon.fake.returns(findCursorFactory([FAKE_BANNER])), + ); + const findByUserIdAndBannerIdMock = sinon.replace( + bannerDismissModel, + 'findByUserIdAndBannerId', + sinon.fake.returns(findCursorFactory>([{ bannerId: FAKE_BANNER._id }])), + ); + + sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({}))); + + const banners = await service.getBannersForUser('a-fake-id', 'web'); + + expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1); + expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1); + + expect(banners).to.be.an('array'); + expect(banners).to.have.lengthOf(0); + }); + + it('should use the _id as viewId if not set', async () => { + const service = new BannerService(); + const findActiveByRoleOrIdMock = sinon.replace( + bannersModel, + 'findActiveByRoleOrId', + sinon.fake.returns(findCursorFactory([A_SECOND_FAKE_BANNER])), + ); + const findByUserIdAndBannerIdMock = sinon.replace( + bannerDismissModel, + 'findByUserIdAndBannerId', + sinon.fake.returns(findCursorFactory>([])), + ); + + sinon.replace(userModel, 'findOneById', sinon.fake.returns(Promise.resolve({}))); + + const banners = await service.getBannersForUser('a-fake-id', 'web'); + + expect(findActiveByRoleOrIdMock.callCount).to.be.equal(1); + expect(findByUserIdAndBannerIdMock.callCount).to.be.equal(1); + + expect(banners).to.be.an('array'); + expect(banners).to.have.lengthOf(1); + expect(banners[0].view.viewId).to.be.equal(A_SECOND_FAKE_BANNER._id); + expect(banners[0].surface).to.be.equal('modal'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts b/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts new file mode 100644 index 0000000..902e90f --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/mocks/cronJobs.ts @@ -0,0 +1,32 @@ +import type { AgendaCronJobs } from '@rocket.chat/cron'; + +// #TODO: Move this to a package and write unit tests there ensuring that the behavior of the mock and the real class match 1:1 +export class MockedCronJobs { + public jobNames = new Set(); + + private _started = false; + + public get started(): boolean { + return this._started; + } + + start: AgendaCronJobs['start'] = async () => { + this._started = true; + }; + + add: AgendaCronJobs['add'] = async (name) => { + this.jobNames.add(name); + }; + + addAtTimestamp: AgendaCronJobs['addAtTimestamp'] = async (name) => { + this.jobNames.add(name); + }; + + remove: AgendaCronJobs['remove'] = async (name) => { + this.jobNames.delete(name); + }; + + has: AgendaCronJobs['has'] = async (jobName) => { + return this.jobNames.has(jobName); + }; +} diff --git a/apps/meteor/tests/unit/server/services/calendar/service.tests.ts b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts new file mode 100644 index 0000000..531d582 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/service.tests.ts @@ -0,0 +1,636 @@ +import { api } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { DeleteResult, UpdateResult } from 'mongodb'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { testPrivateMethod, createFreshServiceInstance } from '../utils'; +import { MockedCronJobs } from './mocks/cronJobs'; + +const settingsMock = new Map(); +const cronJobsMock = new MockedCronJobs(); + +const CalendarEventMock = { + insertOne: sinon.stub(), + findOne: sinon.stub(), + findByUserIdAndDate: sinon.stub(), + updateEvent: sinon.stub(), + deleteOne: sinon.stub(), + findNextNotificationDate: sinon.stub(), + findEventsToNotify: sinon.stub(), + flagNotificationSent: sinon.stub(), + findOneByExternalIdAndUserId: sinon.stub(), + findNextFutureEvent: sinon.stub(), + findOverlappingEvents: sinon.stub(), +}; + +const PresenceMock = { + setActiveState: sinon.stub().resolves(), + endActiveState: sinon.stub().resolves(), +}; + +const UsersMock = { + findOneById: sinon.stub().resolves({ language: 'en' }), +}; + +const getUserPreferenceMock = sinon.stub(); + +const serviceMocks = { + '../../settings': { settings: settingsMock }, + '@rocket.chat/core-services': { + api, + ServiceClassInternal: class {}, + Presence: PresenceMock, + }, + '@rocket.chat/cron': { cronJobs: cronJobsMock }, + '@rocket.chat/models': { CalendarEvent: CalendarEventMock, Users: UsersMock }, + '../../lib/utils/lib/getUserPreference': { getUserPreference: getUserPreferenceMock }, + '../../lib/i18n': { i18n: { t: sinon.stub().returns('Outlook: In a meeting') } }, +}; + +const { CalendarService } = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks); + +describe('CalendarService', () => { + let sandbox: sinon.SinonSandbox; + let service: InstanceType; + const fakeUserId = 'user123'; + const fakeEventId = 'event456'; + const fakeExternalId = 'external789'; + const fakeStartTime = new Date('2025-01-01T10:00:00Z'); + const fakeEndTime = new Date('2025-01-01T11:00:00Z'); + const fakeSubject = 'Test Meeting'; + const fakeDescription = 'This is a test meeting'; + const fakeMeetingUrl = 'https://meet.test/123'; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + service = new CalendarService(); + stubServiceMethods(); + setupCalendarEventMocks(); + setupPresenceMocks(); + setupOtherMocks(); + }); + + function stubServiceMethods() { + const proto = Object.getPrototypeOf(service); + sandbox.stub(proto, 'parseDescriptionForMeetingUrl').resolves(fakeMeetingUrl); + sandbox.stub(proto, 'findImportedEvent').callsFake(async (externalId, uid) => { + return CalendarEventMock.findOneByExternalIdAndUserId(externalId, uid); + }); + sandbox.stub(proto, 'sendEventNotification').resolves(); + sandbox.stub(proto, 'sendCurrentNotifications').resolves(); + sandbox.stub(proto, 'doSetupNextNotification').resolves(); + sandbox.stub(proto, 'doSetupNextStatusChange').resolves(); + + sandbox.stub(service, 'setupNextNotification').resolves(); + sandbox.stub(service, 'setupNextStatusChange').resolves(); + } + + function setupCalendarEventMocks() { + const freshMocks = { + insertOne: sinon.stub().resolves({ insertedId: fakeEventId }), + findOne: sinon.stub().resolves(null), + findByUserIdAndDate: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), + updateEvent: sinon.stub().resolves({ modifiedCount: 1, matchedCount: 1 } as UpdateResult), + deleteOne: sinon.stub().resolves({ deletedCount: 1 } as DeleteResult), + findNextNotificationDate: sinon.stub().resolves(null), + findEventsToNotify: sinon.stub().returns({ + toArray: sinon.stub().resolves([]), + }), + flagNotificationSent: sinon.stub().resolves(), + findOneByExternalIdAndUserId: sinon.stub().resolves(null), + findNextFutureEvent: sinon.stub().resolves(null), + findOverlappingEvents: sinon.stub().returns({ + next: sinon.stub().resolves(null), + toArray: sinon.stub().resolves([]), + }), + }; + + Object.assign(CalendarEventMock, freshMocks); + } + + function setupPresenceMocks() { + PresenceMock.setActiveState.resetHistory(); + PresenceMock.endActiveState.resetHistory(); + UsersMock.findOneById.resetHistory(); + UsersMock.findOneById.resolves({ language: 'en' }); + } + + function setupOtherMocks() { + sandbox.stub(api, 'broadcast').resolves(); + + settingsMock.clear(); + settingsMock.set( + 'Calendar_MeetingUrl_Regex', + '(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)', + ); + settingsMock.set('Calendar_BusyStatus_Enabled', true); + + cronJobsMock.jobNames.clear(); + + getUserPreferenceMock.reset(); + getUserPreferenceMock.resolves(true); + } + + afterEach(() => { + sandbox.restore(); + }); + + describe('#create', () => { + it('should create a new calendar event', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + description: fakeDescription, + meetingUrl: fakeMeetingUrl, + reminderMinutesBeforeStart: 5, + }; + + const result = await service.create(eventData); + + expect(result).to.equal(fakeEventId); + expect(CalendarEventMock.insertOne.callCount).to.equal(1); + expect(CalendarEventMock.insertOne.firstCall.args[0]).to.include({ + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + meetingUrl: fakeMeetingUrl, + reminderMinutesBeforeStart: 5, + notificationSent: false, + }); + sinon.assert.calledOnce(service.setupNextStatusChange); + }); + }); + + describe('#import', () => { + it('should create a new event if externalId is not provided', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + }; + + await service.import(eventData); + + sinon.assert.calledOnce(CalendarEventMock.insertOne); + sinon.assert.calledOnce(service.setupNextStatusChange); + }); + + it('should update existing event if found by externalId', async () => { + const eventData = { + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + description: fakeDescription, + externalId: fakeExternalId, + }; + + CalendarEventMock.findOneByExternalIdAndUserId.resolves({ + _id: fakeEventId, + uid: fakeUserId, + externalId: fakeExternalId, + }); + + await service.import(eventData); + + sinon.assert.calledWith(CalendarEventMock.findOneByExternalIdAndUserId, fakeExternalId, fakeUserId); + sinon.assert.calledOnce(CalendarEventMock.updateEvent); + sinon.assert.notCalled(CalendarEventMock.insertOne); + }); + }); + + describe('#update', () => { + it('should update an existing event', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const updateData = { + subject: 'Updated Subject', + description: 'Updated Description', + }; + + await service.update(fakeEventId, updateData); + + sinon.assert.calledWith(CalendarEventMock.updateEvent, fakeEventId, sinon.match.has('subject', 'Updated Subject')); + }); + + it('should reschedule the status change when start/end times change', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + endTime: fakeEndTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + const newStartTime = new Date('2025-01-02T10:00:00Z'); + const newEndTime = new Date('2025-01-02T11:00:00Z'); + + await service.update(fakeEventId, { + startTime: newStartTime, + endTime: newEndTime, + }); + + sinon.assert.calledOnce(service.setupNextStatusChange); + }); + }); + + describe('#delete', () => { + it('should delete an event', async () => { + const fakeEvent = { + _id: fakeEventId, + uid: fakeUserId, + startTime: fakeStartTime, + subject: fakeSubject, + }; + + CalendarEventMock.findOne.resolves(fakeEvent); + + await service.delete(fakeEventId); + + sinon.assert.calledWith(CalendarEventMock.deleteOne, { _id: fakeEventId }); + }); + + it('should lower the busy expiry to the remaining active event when deleting an overlapping in-progress event', async () => { + const t = Date.now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 120 * 60 * 1000), + }); + CalendarEventMock.findOverlappingEvents.returns({ + next: sinon.stub().resolves(null), + toArray: sinon.stub().resolves([{ _id: 'remaining', endTime: new Date(t + 30 * 60 * 1000) }]), + }); + + await service.delete(fakeEventId); + + sinon.assert.calledOnce(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + const [uid, state] = PresenceMock.setActiveState.firstCall.args; + expect(uid).to.equal(fakeUserId); + expect(state.statusExpiresAt.getTime()).to.equal(t + 30 * 60 * 1000); + }); + + it('should end the busy state when deleting the only active in-progress event', async () => { + const t = Date.now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 60 * 60 * 1000), + }); + CalendarEventMock.findOverlappingEvents.returns({ + next: sinon.stub().resolves(null), + toArray: sinon.stub().resolves([]), + }); + + await service.delete(fakeEventId); + + sinon.assert.calledOnceWithExactly(PresenceMock.endActiveState, fakeUserId, 'calendar'); + sinon.assert.notCalled(PresenceMock.setActiveState); + }); + + it('should not touch presence when deleting an event that is not in progress', async () => { + const t = Date.now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t + 60 * 60 * 1000), + endTime: new Date(t + 120 * 60 * 1000), + }); + + await service.delete(fakeEventId); + + sinon.assert.notCalled(PresenceMock.endActiveState); + sinon.assert.notCalled(PresenceMock.setActiveState); + }); + }); + + describe('#setupNextNotification', () => { + it('should call doSetupNextNotification internally', async () => { + const serviceExports = proxyquire.noCallThru().load('../../../../../server/services/calendar/service', serviceMocks); + + const testService = createFreshServiceInstance>(serviceExports); + + const localSandbox = sinon.createSandbox(); + + try { + const doSetupStub = localSandbox.stub(Object.getPrototypeOf(testService), 'doSetupNextNotification').resolves(); + + await testService.setupNextNotification(); + + sinon.assert.calledOnceWithExactly(doSetupStub, false); + } finally { + localSandbox.restore(); + } + }); + }); + + describe('Private: parseDescriptionForMeetingUrl', () => { + it('should extract URL from description with default pattern', async () => { + await testPrivateMethod(service, 'parseDescriptionForMeetingUrl', async (method) => { + const testDescription = 'Join at https://meet.example.com?callUrl=https://special-meeting.com/123'; + const result = await method(testDescription); + expect(result).to.equal('https://special-meeting.com/123'); + }); + }); + }); + + describe('Private: doSetupNextNotification', () => { + it('should schedule notifications at the next date', async () => { + await testPrivateMethod(service, 'doSetupNextNotification', async (method) => { + const nextDate = new Date('2025-01-01T10:00:00Z'); + CalendarEventMock.findNextNotificationDate.resolves(nextDate); + + await method(false); + + expect(cronJobsMock.jobNames.has('calendar-reminders')).to.true; + }); + }); + }); + + describe('Private: doSetupNextStatusChange', () => { + it('should not run when busy status setting is disabled', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', false); + + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; + + const hasStub = sinon.stub().resolves(true); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); + + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; + + try { + await method(); + sinon.assert.calledWith(hasStub, 'calendar-status-scheduler'); + sinon.assert.calledWith(removeStub, 'calendar-status-scheduler'); + sinon.assert.notCalled(addAtTimestampStub); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } + }); + }); + + it('should schedule a job at the next event start time', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', true); + + const futureEvent = { + _id: 'future123', + uid: fakeUserId, + startTime: new Date(Date.now() + 60000), + endTime: new Date(Date.now() + 3660000), + }; + + CalendarEventMock.findNextFutureEvent.resolves(futureEvent); + + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; + + const hasStub = sinon.stub().resolves(false); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); + + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; + + try { + await method(); + + sinon.assert.calledOnce(addAtTimestampStub); + sinon.assert.calledWith(addAtTimestampStub, 'calendar-status-scheduler', futureEvent.startTime, sinon.match.func); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } + }); + }); + + it('should not schedule a job when there are no future events', async () => { + await testPrivateMethod(service, 'doSetupNextStatusChange', async (method) => { + settingsMock.set('Calendar_BusyStatus_Enabled', true); + + CalendarEventMock.findNextFutureEvent.resolves(null); + + const originalHas = cronJobsMock.has; + const originalRemove = cronJobsMock.remove; + const originalAddAtTimestamp = cronJobsMock.addAtTimestamp; + + const hasStub = sinon.stub().resolves(false); + const removeStub = sinon.stub().resolves(); + const addAtTimestampStub = sinon.stub().resolves(); + + cronJobsMock.has = hasStub; + cronJobsMock.remove = removeStub; + cronJobsMock.addAtTimestamp = addAtTimestampStub; + + try { + await method(); + + sinon.assert.notCalled(addAtTimestampStub); + } finally { + cronJobsMock.has = originalHas; + cronJobsMock.remove = originalRemove; + cronJobsMock.addAtTimestamp = originalAddAtTimestamp; + } + }); + }); + }); + + describe('Private: processEventStart', () => { + // await directly: testPrivateMethod doesn't await its callback, so async assertions wouldn't fail + const callProcessEventStart = (event: unknown) => (service as any).processEventStart(event); + + it('should set statusExpiresAt to the latest endTime among overlapping active events', async () => { + const now = Date.now(); + const event = { + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(now - 1000), + endTime: new Date(now + 60 * 60 * 1000), + }; + + CalendarEventMock.findOverlappingEvents.returns({ + next: sinon.stub().resolves(null), + toArray: sinon.stub().resolves([ + { _id: 'later', endTime: new Date(now + 120 * 60 * 1000) }, + { _id: 'earlier', endTime: new Date(now + 30 * 60 * 1000) }, + ]), + }); + + await callProcessEventStart(event); + + sinon.assert.calledOnce(PresenceMock.setActiveState); + const [uid, state] = PresenceMock.setActiveState.firstCall.args; + expect(uid).to.equal(fakeUserId); + expect(state.statusSource).to.equal('external'); + expect(state.statusExpiresAt.getTime()).to.equal(now + 120 * 60 * 1000); + expect(state.statusId).to.equal('calendar'); + expect(state.statusText).to.equal('Outlook: In a meeting'); + }); + + it('should use the event own endTime when there are no overlapping events', async () => { + const now = Date.now(); + const endTime = new Date(now + 60 * 60 * 1000); + const event = { + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(now - 1000), + endTime, + }; + + await callProcessEventStart(event); + + sinon.assert.calledOnce(PresenceMock.setActiveState); + const [, state] = PresenceMock.setActiveState.firstCall.args; + expect(state.statusExpiresAt.getTime()).to.equal(endTime.getTime()); + }); + + it('should not apply busy status when the event has no endTime', async () => { + const event = { + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(), + }; + + await callProcessEventStart(event); + + sinon.assert.notCalled(PresenceMock.setActiveState); + }); + }); + + describe('Private: reconcileInProgressEvent', () => { + const callReconcile = (eventId: string) => (service as any).reconcileInProgressEvent(eventId); + const now = () => Date.now(); + + it('applies the busy claim for an in-progress event using its endTime', async () => { + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 60 * 60 * 1000), + }); + + await callReconcile(fakeEventId); + + sinon.assert.calledOnce(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + const [uid, state] = PresenceMock.setActiveState.firstCall.args; + expect(uid).to.equal(fakeUserId); + expect(state.statusSource).to.equal('external'); + expect(state.statusExpiresAt.getTime()).to.equal(t + 60 * 60 * 1000); + }); + + it('extends the busy claim to the latest end among overlapping in-progress events', async () => { + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 30 * 60 * 1000), + }); + CalendarEventMock.findOverlappingEvents.returns({ + next: sinon.stub().resolves(null), + toArray: sinon.stub().resolves([{ _id: 'longer', endTime: new Date(t + 90 * 60 * 1000) }]), + }); + + await callReconcile(fakeEventId); + + sinon.assert.calledOnce(PresenceMock.setActiveState); + const [, state] = PresenceMock.setActiveState.firstCall.args; + expect(state.statusExpiresAt.getTime()).to.equal(t + 90 * 60 * 1000); + }); + + it('does nothing for an event that has not started yet', async () => { + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t + 60 * 60 * 1000), + endTime: new Date(t + 120 * 60 * 1000), + }); + + await callReconcile(fakeEventId); + + sinon.assert.notCalled(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + }); + + it('does nothing for an event that has already ended', async () => { + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 120 * 60 * 1000), + endTime: new Date(t - 60 * 60 * 1000), + }); + + await callReconcile(fakeEventId); + + sinon.assert.notCalled(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + }); + + it('does nothing for a non-busy event', async () => { + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 60 * 60 * 1000), + busy: false, + }); + + await callReconcile(fakeEventId); + + sinon.assert.notCalled(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + }); + + it('does nothing when busy status is disabled', async () => { + settingsMock.set('Calendar_BusyStatus_Enabled', false); + const t = now(); + CalendarEventMock.findOne.resolves({ + _id: fakeEventId, + uid: fakeUserId, + startTime: new Date(t - 60 * 1000), + endTime: new Date(t + 60 * 60 * 1000), + }); + + await callReconcile(fakeEventId); + + sinon.assert.notCalled(PresenceMock.setActiveState); + sinon.assert.notCalled(PresenceMock.endActiveState); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts b/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts new file mode 100644 index 0000000..8f06858 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/calendar/utils/getShiftedTime.tests.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; + +const { getShiftedTime } = proxyquire.noCallThru().load('../../../../../../server/services/calendar/utils/getShiftedTime', {}); + +describe('#getShiftedTime', () => { + it('should shift time forward by minutes', () => { + const date = new Date('2025-01-01T10:00:00Z'); + const result = getShiftedTime(date, 30); + + expect(result.getTime()).to.equal(new Date('2025-01-01T10:30:00Z').getTime()); + expect(date.getTime()).to.equal(new Date('2025-01-01T10:00:00Z').getTime()); + }); + + it('should shift time backward by negative minutes', () => { + const date = new Date('2025-01-01T10:00:00Z'); + const result = getShiftedTime(date, -15); + + expect(result.getTime()).to.equal(new Date('2025-01-01T09:45:00Z').getTime()); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/instance/service.tests.ts b/apps/meteor/tests/unit/server/services/instance/service.tests.ts new file mode 100644 index 0000000..b8a61d3 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/instance/service.tests.ts @@ -0,0 +1,126 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { IInstanceService } from '../../../../../ee/server/sdk/types/IInstanceService'; + +const ServiceBrokerMock = { + call: sinon.stub(), +}; + +const AppsMock = { + getAppsStatusLocal: sinon.stub(), +}; + +const serviceMocks = { + '@rocket.chat/core-services': { + ServiceClassInternal: class { + onEvent = sinon.stub(); + + onSettingChanged = sinon.stub(); + }, + Apps: AppsMock, + }, + 'moleculer': { + ServiceBroker: sinon.stub().returns(ServiceBrokerMock), + Serializers: { + Base: class {}, + }, + }, +}; + +const { InstanceService } = proxyquire + .noPreserveCache() + .noCallThru() + .load('../../../../../ee/server/local-services/instance/service', serviceMocks); + +describe('InstanceService', () => { + let service: IInstanceService; + + beforeEach(() => { + service = new InstanceService(); + (service as any).broker = ServiceBrokerMock; + }); + + afterEach(() => { + ServiceBrokerMock.call.reset(); + AppsMock.getAppsStatusLocal.reset(); + }); + + describe('#getInstances', () => { + it('should return list of instances', async () => { + const mockInstances = [{ id: 'node1' }]; + ServiceBrokerMock.call.resolves(mockInstances); + + const instances = await service.getInstances(); + + expect(instances).to.deep.equal(mockInstances); + expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true; + }); + + it('should handle empty instance list', async () => { + ServiceBrokerMock.call.resolves([]); + + const instances = await service.getInstances(); + + expect(instances).to.deep.equal([]); + expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true; + }); + }); + + describe('#getAppsStatusInInstances', () => { + it('should return app status from all instances', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + { id: 'node3', local: false }, + ]; + + ServiceBrokerMock.call + .onCall(0) + .resolves(mockInstances) + .onCall(1) + .resolves([{ status: 'enabled', appId: 'app1' }]) + .onCall(2) + .resolves([{ status: 'disabled', appId: 'app2' }]) + .onCall(3) + .resolves([{ status: 'enabled', appId: 'app3' }]); + + const result = await service.getAppsStatusInInstances(); + + expect(result).to.deep.equal({ + app1: [{ instanceId: 'node1', isLocal: true, status: 'enabled' }], + app2: [{ instanceId: 'node2', isLocal: false, status: 'disabled' }], + app3: [{ instanceId: 'node3', isLocal: false, status: 'enabled' }], + }); + + expect(ServiceBrokerMock.call.callCount).to.be.equal(4); + }); + + it('should handle empty app status response', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + ]; + + ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves([]).onThirdCall().resolves([]); + const result = await service.getAppsStatusInInstances(); + + expect(result).to.deep.equal({}); + expect(ServiceBrokerMock.call.calledThrice).to.be.true; + }); + + it('should handle undefined app status response', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + ]; + + ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves(undefined); + + await expect(service.getAppsStatusInInstances()).to.be.rejectedWith(`Failed to get apps status from instance node1`); + expect(ServiceBrokerMock.call.calledThrice).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveBadWords.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveBadWords.tests.ts new file mode 100644 index 0000000..384a451 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveBadWords.tests.ts @@ -0,0 +1,127 @@ +import { expect } from 'chai'; + +import { BeforeSaveBadWords } from '../../../../../../server/services/messages/hooks/BeforeSaveBadWords'; + +const createMessage = (msg?: string) => ({ + _id: 'random', + rid: 'GENERAL', + ts: new Date(), + u: { + _id: 'userId', + username: 'username', + }, + _updatedAt: new Date(), + msg: msg as string, +}); + +describe('Filter bad words before saving message', () => { + it('should return same message if bad words not configured', async () => { + const badWords = new BeforeSaveBadWords(); + + const message = createMessage('hell'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('hell'); + }); + + it("should return same message if no 'msg' property provided", async () => { + const badWords = new BeforeSaveBadWords(); + + const message = createMessage(); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal(undefined); + }); + + it('should return filter bad words from message when configured', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure(); + + const message = createMessage('hell'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.equal('****'); + }); + + it('should return same message if bad words disabled after configured', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure(); + + badWords.disable(); + + const message = createMessage('hell'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('hell'); + }); + + it('should filter custom bad words', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure('good'); + + const message = createMessage('good'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('****'); + }); + + it('should allow custom good words', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure(undefined, 'hell'); + + const message = createMessage('hell'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('hell'); + }); + + it('should filter non ascii bad words', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure('バカ'); + + const message = createMessage('hell is バカ'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('**** is **'); + }); + + it('should filter just the non ascii bad words', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure('バカ'); + + const message = createMessage('バカ'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('**'); + }); + + it('should filter non ascii bad words with punctuation', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure('バカ'); + + const message = createMessage('バカ.'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('**.'); + }); + + it('should not filter non ascii bad words, if part of another word ', async () => { + const badWords = new BeforeSaveBadWords(); + await badWords.configure('バカ'); + + const message = createMessage('TESTバカTEST'); + + const result = await badWords.filterBadWords({ message }); + + return expect(result.msg).to.be.equal('TESTバカTEST'); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveCheckMAC.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveCheckMAC.tests.ts new file mode 100644 index 0000000..faed1cd --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveCheckMAC.tests.ts @@ -0,0 +1,104 @@ +import { api, MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { before, describe } from 'mocha'; + +import { BeforeSaveCheckMAC } from '../../../../../../server/services/messages/hooks/BeforeSaveCheckMAC'; +import { BrokerMocked } from '../../../../../mocks/server/BrokerMocked'; + +const createMessage = (msg?: string, extra: any = {}) => ({ + _id: 'random', + rid: 'GENERAL', + ts: new Date(), + u: { + _id: 'userId', + username: 'username', + }, + _updatedAt: new Date(), + msg: msg as string, + ...extra, +}); + +const createRoom = (extra: any = {}): any => ({ + _id: 'GENERAL', + t: 'c', + u: { + _id: 'userId', + username: 'username', + name: 'name', + }, + msgs: 1, + usersCount: 1, + _updatedAt: new Date(), + ...extra, +}); + +const broker = new BrokerMocked(); + +describe('Check MAC', () => { + before(() => { + api.setBroker(broker as any); + }); + + it('should do nothing if not omnichannel room', async () => { + const checkMAC = new BeforeSaveCheckMAC(); + + const empty = await checkMAC.isWithinLimits({ + message: createMessage('hey'), + room: createRoom(), + }); + + expect(empty).to.not.have.value; + }); + + it('should do nothing if message from visitor', async () => { + const checkMAC = new BeforeSaveCheckMAC(); + + const empty = await checkMAC.isWithinLimits({ + message: createMessage('hey', { token: 'hash' }), + room: createRoom({ t: 'l' }), + }); + + expect(empty).to.not.have.value; + }); + + it('should do nothing if it is a system message', async () => { + const checkMAC = new BeforeSaveCheckMAC(); + + const empty = await checkMAC.isWithinLimits({ + message: createMessage('hey', { t: 'uj' }), + room: createRoom({ t: 'l' }), + }); + + expect(empty).to.not.have.value; + }); + + it('should throw an error if not within limits', () => { + const checkMAC = new BeforeSaveCheckMAC(); + + broker.mockServices({ + 'omnichannel.isWithinMACLimit': async () => false, + }); + + void expect( + checkMAC.isWithinLimits({ + message: createMessage('hey'), + room: createRoom({ t: 'l' }), + }), + ).to.be.rejectedWith(MeteorError, 'error-mac-limit-reached'); + }); + + it('should work if MAC within limits', async () => { + const checkMAC = new BeforeSaveCheckMAC(); + + broker.mockServices({ + 'omnichannel.isWithinMACLimit': async () => true, + }); + + const empty = await checkMAC.isWithinLimits({ + message: createMessage('hey'), + room: createRoom({ t: 'l' }), + }); + + expect(empty).to.not.have.value; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts new file mode 100644 index 0000000..0ce7279 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts @@ -0,0 +1,734 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { BeforeSaveJumpToMessage } from '../../../../../../server/services/messages/hooks/BeforeSaveJumpToMessage'; + +const createMessage = (msg?: string, extra: Partial = {}) => ({ + _id: 'random', + rid: 'GENERAL', + ts: new Date(), + u: { + _id: 'userId', + username: 'username', + }, + _updatedAt: new Date(), + msg: msg as string, + ...extra, +}); + +const createUser = (username?: string) => ({ + _id: 'userId', + username, + name: 'name', + language: 'en', +}); + +const createRoom = (extra: any = {}): any => ({ + _id: 'GENERAL', + t: 'c', + u: { + _id: 'userId', + username: 'username', + name: 'name', + }, + msgs: 1, + usersCount: 1, + _updatedAt: new Date(), + ...extra, +}); + +const countDeep = (msg: any, deep = 1): number => { + if (!msg) { + return deep - 1; + } + + if (Array.isArray(msg?.attachments) && msg.attachments.length > 0) { + return msg.attachments.reduce((count: number, att: any) => Math.max(countDeep(att, deep + 1), count), 0); + } + + return deep - 1; +}; + +describe('Create attachments for message URLs', () => { + it('should return message without attatchment and URLs if no URL provided', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey'), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + return expect(message).to.not.have.property('urls'); + }); + + it('should do nothing if URL is not from SiteUrl', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://google.com', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should do nothing if URL is from SiteUrl but not have a query string', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should do nothing if URL is from SiteUrl but not have a msgId query string', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/?token=value', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should do nothing if it do not find a msg from the URL', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/?msg=value', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should do nothing if it cannot find the room of the message from the URL', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/?msg=value', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should do nothing if user dont have access to the room of the message from the URL', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => false, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/?msg=value', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').of.length(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should not duplicate quote attachment from the message if message_link is the same as the URL', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + attachments: [ + { + text: 'old attachment', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linked', + ts: new Date(), + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + + const [url] = message.urls ?? []; + + expect(url).to.include({ + url: 'https://open.rocket.chat/linked?msg=linked', + ignoreParse: true, + }); + + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const [attachment] = message.attachments ?? []; + + expect(attachment).to.have.property('text', 'linked message'); + }); + + it('should remove existing quote attachments provided in the message if they are not in the urls field', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [], + attachments: [ + { + text: 'old attachment', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linked', + ts: new Date(), + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('attachments').and.to.have.lengthOf(0); + }); + + it('should not consider attachments with undefined message_link as quotes', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [], + attachments: [ + { + text: 'old attachment', + author_name: 'username', + author_icon: 'url', + message_link: undefined, + ts: new Date(), + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + const [attachment] = message.attachments ?? []; + expect(attachment).to.have.property('text', 'old attachment'); + }); + + it('should return an attachment with the message content if a message URL is provided', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + + const [url] = message.urls ?? []; + + expect(url).to.include({ + url: 'https://open.rocket.chat/linked?msg=linked', + ignoreParse: true, + }); + + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const [attachment] = message.attachments ?? []; + + expect(attachment).to.have.property('text', 'linked message'); + }); + + it('should respect chain limit config', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [ + createMessage('linked message', { + _id: 'linked', + attachments: [ + { + text: 'chained 1', + author_name: 'username', + author_icon: 'url', + ts: new Date(), + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + attachments: [ + { + text: 'chained 2', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + ts: new Date(), + attachments: [ + { + text: 'chained 3', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + ts: new Date(), + }, + ], + }, + ], + }, + ], + }), + ], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 3, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const deep = countDeep(message); + expect(deep).to.be.eq(3); + }); + + it('should respect chain limit config but keep the file attachments of last chained message', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [ + createMessage('linked message', { + _id: 'linked', + attachments: [ + { + text: 'chained 1', + author_name: 'username', + author_icon: 'url', + ts: new Date(), + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + attachments: [ + { + text: 'chained 2', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + ts: new Date(), + attachments: [ + { + ts: new Date(), + title: 'file.png', + title_link: 'title-link', + title_link_download: true, + image_dimensions: { + width: 480, + height: 269, + }, + image_preview: 'image-preview', + image_url: 'image-url', + image_type: 'image/png', + image_size: 68016, + type: 'file', + description: 'chained 3 - file', + descriptionMd: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'chained 3 - file', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }), + ], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 3, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const deep = countDeep(message); + expect(deep).to.be.eq(4, 'The chain limit should be respected, but the file attachments of the last chained message should be kept.'); + }); + + it('should create the attachment if cannot access room but message has a livechat token', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom({ t: 'l', v: { token: 'livechatToken' } })], + canAccessRoom: async () => false, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + token: 'livechatToken', + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + + const [url] = message.urls ?? []; + + expect(url).to.include({ + url: 'https://open.rocket.chat/linked?msg=linked', + ignoreParse: true, + }); + + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const [attachment] = message.attachments ?? []; + + expect(attachment).to.have.property('text', 'linked message'); + }); + + it('should do nothing if cannot access room but message has a livechat token but is not from the room does not have a token', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [createMessage('linked message', { _id: 'linked' })], + getRooms: async () => [createRoom({ t: 'l' })], + canAccessRoom: async () => false, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linked', + meta: {}, + }, + ], + token: 'another-token', + }), + user: createUser(), + config: { + chainLimit: 10, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + expect(message).to.not.have.property('attachments'); + }); + + it('should remove the clean up the attachments of the quoted message property if chain limit < 2', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => [ + createMessage('linked message', { + _id: 'linkedMsgId', + attachments: [ + { + text: 'chained 1', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId1', + ts: new Date(), + }, + ], + }), + ], + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=linkedMsgId', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 1, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + expect(message).to.have.property('attachments').and.to.have.lengthOf(1); + + const deep = countDeep(message); + expect(deep).to.be.eq(1); + + const [attachment] = message.attachments ?? []; + + expect(attachment).to.have.property('attachments').and.to.have.lengthOf(0); + expect(attachment).to.include({ + text: 'linked message', + author_name: 'username', + author_icon: 'url', + message_link: 'https://open.rocket.chat/linked?msg=linkedMsgId', + }); + }); + + it('should work for multiple URLs', async () => { + const jumpToMessage = new BeforeSaveJumpToMessage({ + getMessages: async () => { + return [ + createMessage('first message', { + _id: 'msg1', + }), + createMessage('second message', { + _id: 'msg2', + }), + ]; + }, + getRooms: async () => [createRoom()], + canAccessRoom: async () => true, + getUserAvatarURL: () => 'url', + }); + + const message = await jumpToMessage.createAttachmentForMessageURLs({ + message: createMessage('hey', { + urls: [ + { + url: 'https://open.rocket.chat/linked?msg=msg1', + meta: {}, + }, + { + url: 'https://open.rocket.chat/linked?msg=msg2', + meta: {}, + }, + ], + }), + user: createUser(), + config: { + chainLimit: 1, + siteUrl: 'https://open.rocket.chat', + useRealName: true, + }, + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(2); + expect(message).to.have.property('attachments').and.to.have.lengthOf(2); + + const deep = countDeep(message); + expect(deep).to.be.eq(1); + + const [att1, att2] = message.attachments ?? []; + + expect(att1).to.include({ + text: 'first message', + message_link: 'https://open.rocket.chat/linked?msg=msg1', + }); + + expect(att2).to.include({ + text: 'second message', + message_link: 'https://open.rocket.chat/linked?msg=msg2', + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts new file mode 100644 index 0000000..c511b07 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import { beforeEach } from 'mocha'; + +import { BeforeSaveMarkdownParser } from '../../../../../../server/services/messages/hooks/BeforeSaveMarkdownParser'; + +const createMessage = (msg?: string, extra: any = {}) => ({ + _id: 'random', + rid: 'GENERAL', + ts: new Date(), + u: { + _id: 'userId', + username: 'username', + }, + _updatedAt: new Date(), + msg: msg as string, + ...extra, +}); + +describe('Markdown parser', () => { + beforeEach(() => { + delete process.env.MESSAGE_MAX_PARSE_LENGTH; + }); + + afterEach(() => { + delete process.env.MESSAGE_MAX_PARSE_LENGTH; + }); + + it('should do nothing if markdown parser is disabled', async () => { + const markdownParser = new BeforeSaveMarkdownParser(false); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('hey'), + config: {}, + }); + + expect(message).to.not.have.property('md'); + }); + + it('should do nothing for E2E messages', async () => { + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('hey', { t: 'e2e' }), + config: {}, + }); + + expect(message).to.not.have.property('md'); + }); + + it('should skip parsing when msg exceeds MESSAGE_MAX_PARSE_LENGTH', async () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = '10'; + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('a'.repeat(11)), + config: {}, + }); + + expect(message).to.not.have.property('md'); + }); + + it('should parse normally when msg is within MESSAGE_MAX_PARSE_LENGTH', async () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = '100'; + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('short msg'), + config: {}, + }); + + expect(message).to.have.property('md'); + }); + + it('should parse normally when MESSAGE_MAX_PARSE_LENGTH is 0', async () => { + process.env.MESSAGE_MAX_PARSE_LENGTH = '0'; + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('short msg'), + config: {}, + }); + + expect(message).to.have.property('md'); + }); + + it('should parse markdown on the first attachment only', async () => { + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('hey', { + attachments: [ + { + description: 'hey ho', + }, + { + description: 'lets go', + }, + ], + }), + config: {}, + }); + + expect(message).to.have.property('md'); + + const [attachment1, attachment2] = message.attachments || []; + + expect(attachment1).to.have.property('descriptionMd'); + expect(attachment2).to.not.have.property('descriptionMd'); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSavePreventMention.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSavePreventMention.tests.ts new file mode 100644 index 0000000..6dc6d47 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSavePreventMention.tests.ts @@ -0,0 +1,114 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +const Authorization = { + hasPermission: async (_uid: string, _permission: string, _room?: string): Promise => true, +}; + +class MeteorError extends Error {} + +const { BeforeSavePreventMention } = proxyquire + .noCallThru() + .load('../../../../../../server/services/messages/hooks/BeforeSavePreventMention', { + '@rocket.chat/core-services': { + Authorization, + MeteorError, + }, + '../../../lib/i18n': { + i18n: { + t: (s: any) => s, + }, + }, + }); + +describe('Prevent mention on messages', () => { + it('should return void if message has no mentions', async () => { + const preventMention = new BeforeSavePreventMention(); + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-all', + }), + ).to.eventually.be.true; + }); + + it("should return void if message doesnt't have @all mention", () => { + const preventMention = new BeforeSavePreventMention(); + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'here' }], u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-all', + }), + ).to.eventually.be.true; + }); + + it("should return void if message doesnt't have @here mention", () => { + const preventMention = new BeforeSavePreventMention(); + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-here', + }), + ).to.eventually.be.true; + }); + + it('should return true if user has required permission', () => { + const preventMention = new BeforeSavePreventMention(); + + Authorization.hasPermission = async () => true; + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-all', + }), + ).to.eventually.be.true; + }); + + it('should return void if user has required permission on room scope', () => { + const preventMention = new BeforeSavePreventMention(); + + Authorization.hasPermission = async (_uid, _permission, room) => { + if (room) { + return true; + } + + return false; + }; + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-all', + }), + ).to.eventually.be.true; + }); + + it("should throw if user doesn't have required permissions", () => { + Authorization.hasPermission = async (_uid, _permission, _room) => false; + + const preventMention = new BeforeSavePreventMention(); + + return expect( + preventMention.preventMention({ + message: { rid: 'GENERAL', msg: 'hey', mentions: [{ _id: 'all' }], u: { _id: 'random' } }, + user: { _id: 'userId', language: 'en' }, + mention: 'all', + permission: 'mention-all', + }), + ).to.be.rejectedWith(MeteorError); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveSpotify.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveSpotify.tests.ts new file mode 100644 index 0000000..03c2350 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveSpotify.tests.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai'; + +import { BeforeSaveSpotify } from '../../../../../../server/services/messages/hooks/BeforeSaveSpotify'; + +const createMessage = (msg?: string, extra: any = {}) => ({ + _id: 'random', + rid: 'GENERAL', + ts: new Date(), + u: { + _id: 'userId', + username: 'username', + }, + _updatedAt: new Date(), + msg: msg as string, + ...extra, +}); + +describe('Convert Spotify syntax to URLs', () => { + it('should return no URLs if no Spotify syntax provided', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage('hey'), + }); + + return expect(message).to.not.have.property('urls'); + }); + + it('should return no URLs if an undefined message is provided', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage(), + }); + + return expect(message).to.not.have.property('urls'); + }); + + it('should not return a Spotify URL if some Spotify syntax is provided within a code block', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage('test\n```\nspotify:track:1q6IK1l4qpYykOaWaLJkWG\n```'), + }); + + return expect(message).to.not.have.property('urls'); + }); + + it('should not return a Spotify URL if some Spotify syntax is provided within a inline code', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage('test `spotify:track:1q6IK1l4qpYykOaWaLJkWG` ok'), + }); + + return expect(message).to.not.have.property('urls'); + }); + + it('should return a Spotify URL if some Spotify syntax is provided', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage('spotify:track:1q6IK1l4qpYykOaWaLJkWG'), + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(1); + + const [url] = message.urls ?? []; + + expect(url).to.have.property('url', 'https://open.spotify.com/track/1q6IK1l4qpYykOaWaLJkWG'); + expect(url).to.have.property('source', 'spotify:track:1q6IK1l4qpYykOaWaLJkWG'); + }); + + it('should append a Spotify URL when Spotify syntax is provided with already existing URLs', async () => { + const spotify = new BeforeSaveSpotify(); + + const message = await spotify.convertSpotifyLinks({ + message: createMessage('spotify:track:1q6IK1l4qpYykOaWaLJkWG', { + urls: [{ url: 'https://rocket.chat' }], + }), + }); + + expect(message).to.have.property('urls').and.to.have.lengthOf(2); + + const [url1, url2] = message.urls ?? []; + + expect(url1).to.have.property('url', 'https://rocket.chat'); + + expect(url2).to.have.property('url', 'https://open.spotify.com/track/1q6IK1l4qpYykOaWaLJkWG'); + expect(url2).to.have.property('source', 'spotify:track:1q6IK1l4qpYykOaWaLJkWG'); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/nps/spec.tests.ts b/apps/meteor/tests/unit/server/services/nps/spec.tests.ts new file mode 100644 index 0000000..e784768 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/nps/spec.tests.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + 'NpsVote': {}, + 'Nps': { + findOne: sinon.stub(), + save: sinon.stub(), + }, + '@noCallThru': true, +}; + +const servicesMock = { + Banner: { + create: sinon.stub(), + }, + Settings: { + get: sinon.stub(), + }, +}; + +const getbannerforadminsMock = sinon.stub(); + +const { NPSService } = p('../../../../../server/services/nps/service.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': servicesMock, + './sendNpsResults': { 'sendNpsResults': sinon.stub(), '@noCallThru': true }, + '../../lib/logger/system': { 'SystemLogger': { error: sinon.stub() }, '@noCallThru': true }, + './notification': { 'notifyAdmins': sinon.stub(), 'getBannerForAdmins': getbannerforadminsMock, '@noCallThru': true }, +}); + +describe('NPS Service', () => { + it('should instantiate properly', () => { + expect(new NPSService()).to.be.an('object'); + }); + + describe('@create', () => { + beforeEach(() => { + servicesMock.Settings.get.reset(); + modelsMock.Nps.findOne.reset(); + modelsMock.Nps.save.reset(); + servicesMock.Banner.create.reset(); + getbannerforadminsMock.reset(); + }); + it('should fail when user opted out of nps', async () => { + servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(false); + + await expect(new NPSService().create({})).to.be.rejectedWith('Server opted-out for NPS surveys'); + }); + it('should fail when nps expireDate is less than nps startAt', async () => { + servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true); + modelsMock.Nps.findOne.resolves(null); + + await expect(new NPSService().create({ expireAt: new Date('2020-01-01'), startAt: new Date('2020-01-02') })).to.be.rejectedWith( + 'NPS already expired', + ); + }); + it('should fail when expireDate is less than current date', async () => { + servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true); + modelsMock.Nps.findOne.resolves(null); + + await expect(new NPSService().create({ expireAt: new Date('2020-01-02'), startAt: new Date('2020-01-01') })).to.be.rejectedWith( + 'NPS already expired', + ); + }); + it('should try to create a banner when theres no nps saved', async () => { + servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true); + modelsMock.Nps.findOne.resolves(null); + + const today = new Date(); + const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000); + + await new NPSService().create({ + expireAt: tomorrow, + startAt: today, + createdBy: { _id: 'tomorrow', username: 'tomorrow' }, + npsId: 'test', + }); + expect(getbannerforadminsMock.called).to.be.true; + expect(getbannerforadminsMock.calledWith(tomorrow)).to.be.true; + expect(modelsMock.Nps.save.called).to.be.true; + expect( + modelsMock.Nps.save.calledWith( + sinon.match({ + expireAt: tomorrow, + startAt: today, + status: 'open', + _id: 'test', + createdBy: { _id: 'tomorrow', username: 'tomorrow' }, + }), + ), + ).to.be.true; + }); + it('should fail if theres an error when saving the Nps', async () => { + servicesMock.Settings.get.withArgs('NPS_survey_enabled').resolves(true); + modelsMock.Nps.findOne.resolves({ _id: 'test' }); + modelsMock.Nps.save.rejects(); + await expect(new NPSService().create({})).to.be.rejectedWith('Error creating NPS'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts new file mode 100644 index 0000000..fbbda13 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts @@ -0,0 +1,1804 @@ +/* eslint-disable new-cap */ +import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import { expect } from 'chai'; +import moment from 'moment-timezone'; +import sinon from 'sinon'; + +import { AgentOverviewData } from '../../../../../server/services/omnichannel-analytics/AgentData'; + +describe('AgentData Analytics', () => { + describe('updateMap', () => { + it('should update an empty map', () => { + const map = new Map(); + const agentOverview = new AgentOverviewData({} as any); + + agentOverview.updateMap(map, 'test', 1); + + expect(map.get('test')).to.be.equal(1); + }); + it('should update an existing key on a map', () => { + const map = new Map(); + map.set('test', 1); + const agentOverview = new AgentOverviewData({} as any); + + agentOverview.updateMap(map, 'test', 2); + + expect(map.get('test')).to.be.equal(3); + }); + }); + + describe('sortByValue', () => { + it('should sort an array of objects by value key', () => { + const agentOverview = new AgentOverviewData({} as any); + const array = [{ value: '1' }, { value: '3' }, { value: '2' }]; + + agentOverview.sortByValue(array); + + expect(array).to.be.deep.equal([{ value: '1' }, { value: '2' }, { value: '3' }]); + }); + it('should sort an array of objects by value key in inverse order when `inv` is true', () => { + const agentOverview = new AgentOverviewData({} as any); + const array = [{ value: '1' }, { value: '3' }, { value: '2' }]; + + agentOverview.sortByValue(array, true); + + expect(array).to.be.deep.equal([{ value: '3' }, { value: '2' }, { value: '1' }]); + }); + }); + + describe('isActionAllowed', () => { + it('should return true when action is valid', () => { + const agentOverview = new AgentOverviewData({} as any); + + expect(agentOverview.isActionAllowed('Total_conversations')).to.be.true; + }); + it('should return false when action is invalid', () => { + const agentOverview = new AgentOverviewData({} as any); + + expect(agentOverview.isActionAllowed('invalid')).to.be.false; + }); + it('should return false when action is undefined', () => { + const agentOverview = new AgentOverviewData({} as any); + + expect(agentOverview.isActionAllowed(undefined)).to.be.false; + }); + }); + + describe('callAction', () => { + it('should throw an error when action is invalid', async () => { + const agentOverview = new AgentOverviewData({} as any); + + try { + // @ts-expect-error - test + + await agentOverview.callAction('invalid', moment(), moment()); + } catch (e) { + expect(e).to.be.instanceOf(Error); + } + }); + it('should call a valid action', async () => { + const mockModel = { + getAnalyticsMetricsBetweenDateWithMessages: () => [], + getAnalyticsMetricsBetweenDate: () => [], + }; + + const agentOverview = new AgentOverviewData(mockModel as any); + + const spy = sinon.spy(agentOverview, 'Total_conversations'); + const spy2 = sinon.spy(agentOverview, 'Total_messages'); + const spy3 = sinon.spy(agentOverview, 'Avg_chat_duration'); + const spy4 = sinon.spy(agentOverview, 'Avg_first_response_time'); + const spy5 = sinon.spy(agentOverview, 'Best_first_response_time'); + const spy6 = sinon.spy(agentOverview, 'Avg_reaction_time'); + const spy7 = sinon.spy(agentOverview, 'Avg_response_time'); + + await agentOverview.callAction('Total_conversations', moment(), moment()); + + expect(spy.calledOnce).to.be.true; + + await agentOverview.callAction('Total_messages', moment(), moment()); + + expect(spy2.calledOnce).to.be.true; + + await agentOverview.callAction('Avg_chat_duration', moment(), moment()); + + expect(spy3.calledOnce).to.be.true; + + await agentOverview.callAction('Avg_first_response_time', moment(), moment()); + + expect(spy4.calledOnce).to.be.true; + + await agentOverview.callAction('Best_first_response_time', moment(), moment()); + + expect(spy5.calledOnce).to.be.true; + + await agentOverview.callAction('Avg_reaction_time', moment(), moment()); + + expect(spy6.calledOnce).to.be.true; + + await agentOverview.callAction('Avg_response_time', moment(), moment()); + + expect(spy7.calledOnce).to.be.true; + }); + }); + + describe('Total_Conversations', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_conversations(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: '%_of_conversations' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_conversations(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '100.00%', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: '%_of_conversations' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + }, + { + servedBy: { + username: 'agent 2', + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_conversations(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '50.00%', + }, + { + name: 'agent 2', + value: '50.00%', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: '%_of_conversations' }, + ], + }); + }); + it('should properly calculate when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + }, + { + servedBy: { + username: 'agent 2', + }, + }, + { + servedBy: { + username: 'agent 1', + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_conversations(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '66.67%', + }, + { + name: 'agent 2', + value: '33.33%', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: '%_of_conversations' }, + ], + }); + }); + it('should ignore conversations that are not served by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: undefined, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_conversations(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: '%_of_conversations' }, + ], + }); + }); + }); + + describe('Avg_chat_duration', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + chatDuration: 100, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:01:40', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + chatDuration: 100, + }, + }, + { + servedBy: { + username: 'agent 2', + }, + metrics: { + chatDuration: 200, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:01:40', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + it('should properly calculate when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + chatDuration: 100, + }, + }, + { + servedBy: { + username: 'agent 2', + }, + metrics: { + chatDuration: 200, + }, + }, + { + servedBy: { + username: 'agent 1', + }, + metrics: { + chatDuration: 200, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:02:30', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + it('should ignore conversations not being served by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + metrics: { + chatDuration: 100, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: undefined, + }, + { + servedBy: { + username: 'agent 2', + }, + metrics: {}, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_chat_duration(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_chat_duration' }, + ], + }); + }); + }); + + describe('Total_messages', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDateWithMessages']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_messages(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Total_messages' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDateWithMessages']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + msgs: 10, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_messages(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: 10, + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Total_messages' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDateWithMessages']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + msgs: 10, + }, + { + servedBy: { + username: 'agent 2', + }, + msgs: 20, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_messages(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 2', + value: 20, + }, + { + name: 'agent 1', + value: 10, + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Total_messages' }, + ], + }); + }); + it('should properly calculate when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDateWithMessages']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + msgs: 10, + }, + { + servedBy: { + username: 'agent 2', + }, + msgs: 20, + }, + { + servedBy: { + username: 'agent 1', + }, + msgs: 20, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_messages(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: 30, + }, + { + name: 'agent 2', + value: 20, + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Total_messages' }, + ], + }); + }); + it('should ignore conversations that are not served by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDateWithMessages(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDateWithMessages']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + msgs: 10, + }, + { + servedBy: { + username: 'agent 2', + }, + msgs: 20, + }, + { + servedBy: undefined, + msgs: 20, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Total_messages(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 2', + value: 20, + }, + { + name: 'agent 1', + value: 10, + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Total_messages' }, + ], + }); + }); + }); + + describe('Avg_first_response_time', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:01:40', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:01:40', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should associate average first response time with the agent who first responded to the room', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 3', + }, + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + servedBy: { + username: 'agent 4', + }, + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + servedBy: { + username: 'agent 5', + }, + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:02:30', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should calculate correctly when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:02:30', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should ignore conversations not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: undefined, + metrics: { + response: { + ft: 100, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should ignore conversations served, but not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + responseBy: undefined, + metrics: { + response: { + ft: 100, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: undefined, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + }); + + describe('Best_first_response_time', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 3', + }, + metrics: { + response: { + ft: 50, + }, + }, + }, + { + responseBy: { + username: 'agent 4', + }, + metrics: { + response: { + ft: 150, + }, + }, + }, + { + responseBy: { + username: 'agent 5', + }, + metrics: { + response: { + ft: 250, + }, + }, + }, + { + responseBy: { + username: 'agent 6', + }, + metrics: { + response: { + ft: 300, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + { name: 'agent 3', value: '00:00:50' }, + { name: 'agent 4', value: '00:02:30' }, + { name: 'agent 5', value: '00:04:10' }, + { name: 'agent 6', value: '00:05:00' }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should associate best first response time with the agent who first responded to the room', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + servedBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + servedBy: { + username: 'agent 3', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 3', + }, + servedBy: { + username: 'agent 4', + }, + metrics: { + response: { + ft: 50, + }, + }, + }, + { + responseBy: { + username: 'agent 4', + }, + servedBy: { + username: 'agent 5', + }, + metrics: { + response: { + ft: 150, + }, + }, + }, + { + responseBy: { + username: 'agent 5', + }, + servedBy: { + username: 'agent 6', + }, + metrics: { + response: { + ft: 250, + }, + }, + }, + { + responseBy: { + username: 'agent 6', + }, + servedBy: { + username: 'agent 7', + }, + metrics: { + response: { + ft: 300, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + { name: 'agent 3', value: '00:00:50' }, + { name: 'agent 4', value: '00:02:30' }, + { name: 'agent 5', value: '00:04:10' }, + { name: 'agent 6', value: '00:05:00' }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should properly calculate when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 3', + }, + metrics: { + response: { + ft: 50, + }, + }, + }, + { + responseBy: { + username: 'agent 4', + }, + metrics: { + response: { + ft: 150, + }, + }, + }, + { + responseBy: { + username: 'agent 5', + }, + metrics: { + response: { + ft: 250, + }, + }, + }, + { + responseBy: { + username: 'agent 6', + }, + metrics: { + response: { + ft: 300, + }, + }, + }, + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + { name: 'agent 3', value: '00:00:50' }, + { name: 'agent 4', value: '00:02:30' }, + { name: 'agent 5', value: '00:04:10' }, + { name: 'agent 6', value: '00:05:00' }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should ignore conversations not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ responseBy: undefined, metrics: { response: { ft: 100 } } }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should ignore conversations served, but not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ servedBy: { username: 'agent1' }, responseBy: undefined, metrics: { response: { ft: 100 } } }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ responseBy: { username: 'agent 1' }, metrics: undefined }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + }); + + describe('Avg_response_time', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + response: { + avg: 100, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [{ name: 'agent 1', value: '00:01:40' }], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + response: { + avg: 100, + }, + }, + }, + { + servedBy: { + username: 'agent 2', + }, + metrics: { + response: { + avg: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + ], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + it('should calculate correctly when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + servedBy: { + username: 'agent 1', + }, + metrics: { + response: { + avg: 100, + }, + }, + }, + { + servedBy: { + username: 'agent 2', + }, + metrics: { + response: { + avg: 200, + }, + }, + }, + { + servedBy: { + username: 'agent 1', + }, + metrics: { + response: { + avg: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:02:30' }, + { name: 'agent 2', value: '00:03:20' }, + ], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + it('should ignore conversations not being served by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ servedBy: undefined, metrics: { response: { avg: 100 } } }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { servedBy: { username: 'agent 1' }, metrics: undefined }, + { servedBy: { username: 'agent 2' }, metrics: {} }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + }); + }); + }); + + describe('Avg_reaction_time', () => { + it('should return an ConversationData object with empty data when model call returns empty array', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return []; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ servedBy: { username: 'agent 1' }, metrics: { reaction: { ft: 100 } } }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [{ name: 'agent 1', value: '00:01:40' }], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + it('should return an ConversationData object with data when model call returns data with multiple agents', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + const data = [ + { servedBy: { username: 'agent 1' }, metrics: { reaction: { ft: 100 } } }, + { servedBy: { username: 'agent 2' }, metrics: { reaction: { ft: 200 } } }, + ]; + + return data; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + ], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + it('should calculate correctly when agents have multiple conversations', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + const data = [ + { servedBy: { username: 'agent 1' }, metrics: { reaction: { ft: 100 } } }, + { servedBy: { username: 'agent 2' }, metrics: { reaction: { ft: 200 } } }, + { servedBy: { username: 'agent 1' }, metrics: { reaction: { ft: 200 } } }, + ]; + + return data; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:02:30' }, + { name: 'agent 2', value: '00:03:20' }, + ], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + it('should ignore conversations not being served by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + const data = [{ servedBy: undefined, metrics: { reaction: { ft: 100 } } }]; + + return data; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + const data = [ + { servedBy: { username: 'agent 1' }, metrics: undefined }, + { servedBy: { username: 'agent 2' }, metrics: {} }, + ]; + + return data; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_reaction_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/ChartData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/ChartData.tests.ts new file mode 100644 index 0000000..9acce81 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/ChartData.tests.ts @@ -0,0 +1,243 @@ +/* eslint-disable new-cap */ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { ChartData } from '../../../../../server/services/omnichannel-analytics/ChartData'; + +describe('ChartData Analytics', () => { + describe('isActionAllowed', () => { + it('should return false if no action is provided', () => { + const chart = new ChartData({} as any); + expect(chart.isActionAllowed(undefined)).to.be.false; + }); + + it('should return false if an invalid action is provided', () => { + const chart = new ChartData({} as any); + expect(chart.isActionAllowed('invalid_action')).to.be.false; + }); + + it('should return true if a valid action is provided', () => { + const chart = new ChartData({} as any); + expect(chart.isActionAllowed('Total_conversations')).to.be.true; + }); + }); + + describe('callAction', () => { + it('should call the correct action', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [], + getAnalyticsMetricsBetweenDate: () => [], + getTotalConversationsBetweenDate: () => 0, + } as any); + expect(await chart.callAction('Total_conversations', {} as any)).to.be.equal(0); + }); + it('should throw an error if the action is invalid', async () => { + const chart = new ChartData({} as any); + try { + // @ts-expect-error - Invalid action + await chart.callAction('invalid_action', {} as any); + } catch (e: any) { + expect(e.message).to.be.equal('Invalid action'); + } + }); + it('should call the correct action with the correct parameters', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [], + getAnalyticsMetricsBetweenDate: () => [], + getTotalConversationsBetweenDate: () => 0, + } as any); + + const spy = sinon.spy(chart, 'Total_conversations'); + const spy2 = sinon.spy(chart, 'Avg_chat_duration'); + const spy3 = sinon.spy(chart, 'Total_messages'); + const spy4 = sinon.spy(chart, 'Avg_first_response_time'); + const spy5 = sinon.spy(chart, 'Avg_reaction_time'); + + await chart.callAction('Total_conversations', {} as any); + expect(spy.calledOnce).to.be.true; + + await chart.callAction('Avg_chat_duration', {} as any); + expect(spy2.calledOnce).to.be.true; + + await chart.callAction('Total_messages', {} as any); + expect(spy3.calledOnce).to.be.true; + + await chart.callAction('Avg_first_response_time', {} as any); + expect(spy4.calledOnce).to.be.true; + + await chart.callAction('Avg_reaction_time', {} as any); + expect(spy5.calledOnce).to.be.true; + }); + }); + + describe('Total_conversations', () => { + it('should return the total number of conversations', async () => { + const chart = new ChartData({ + getTotalConversationsBetweenDate: async () => 10, + } as any); + expect(await chart.Total_conversations({} as any)).to.be.equal(10); + }); + }); + + describe('Avg_chat_duration', () => { + it('should return the average chat duration', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }], + } as any); + expect(await chart.Avg_chat_duration({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }, { metrics: { chatDuration: 20 } }], + } as any); + expect(await chart.Avg_chat_duration({} as any)).to.be.equal(15); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [], + } as any); + expect(await chart.Avg_chat_duration({} as any)).to.be.equal(0); + }); + it('should ignore conversations without chatDuration', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { chatDuration: 10 } }, { metrics: {} }], + } as any); + expect(await chart.Avg_chat_duration({} as any)).to.be.equal(10); + }); + }); + + describe('Total_messages', () => { + it('should return the total number of messages', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }], + } as any); + expect(await chart.Total_messages({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }, { msgs: 20 }], + } as any); + expect(await chart.Total_messages({} as any)).to.be.equal(30); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [], + } as any); + expect(await chart.Total_messages({} as any)).to.be.equal(0); + }); + it('should ignore conversations without msgs', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDateWithMessages: () => [{ msgs: 10 }, {}], + } as any); + expect(await chart.Total_messages({} as any)).to.be.equal(10); + }); + }); + + describe('Avg_first_response_time', () => { + it('should return the average first response time', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }], + } as any); + expect(await chart.Avg_first_response_time({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: { response: { ft: 20 } } }], + } as any); + expect(await chart.Avg_first_response_time({} as any)).to.be.equal(15); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [], + } as any); + expect(await chart.Avg_first_response_time({} as any)).to.be.equal(0); + }); + it('should ignore conversations without response', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: {} }], + } as any); + expect(await chart.Avg_first_response_time({} as any)).to.be.equal(10); + }); + }); + + describe('Best_first_response_time', () => { + it('should return the best first response time', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }], + } as any); + expect(await chart.Best_first_response_time({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: { response: { ft: 20 } } }], + } as any); + expect(await chart.Best_first_response_time({} as any)).to.be.equal(10); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [], + } as any); + expect(await chart.Best_first_response_time({} as any)).to.be.equal(0); + }); + it('should ignore conversations without response', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { ft: 10 } } }, { metrics: {} }], + } as any); + expect(await chart.Best_first_response_time({} as any)).to.be.equal(10); + }); + }); + + describe('Avg_response_time', () => { + it('should return the average response time', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }], + } as any); + expect(await chart.Avg_response_time({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }, { metrics: { response: { avg: 20 } } }], + } as any); + expect(await chart.Avg_response_time({} as any)).to.be.equal(15); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [], + } as any); + expect(await chart.Avg_response_time({} as any)).to.be.equal(0); + }); + it('should ignore conversations without response', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { response: { avg: 10 } } }, { metrics: {} }], + } as any); + expect(await chart.Avg_response_time({} as any)).to.be.equal(10); + }); + }); + + describe('Avg_reaction_time', () => { + it('should return the average reaction time', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }], + } as any); + expect(await chart.Avg_reaction_time({} as any)).to.be.equal(10); + }); + it('should properly calculate with more conversations', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }, { metrics: { reaction: { ft: 20 } } }], + } as any); + expect(await chart.Avg_reaction_time({} as any)).to.be.equal(15); + }); + it('should return 0 if no conversations are found', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [], + } as any); + expect(await chart.Avg_reaction_time({} as any)).to.be.equal(0); + }); + it('should ignore conversations without reaction', async () => { + const chart = new ChartData({ + getAnalyticsMetricsBetweenDate: () => [{ metrics: { reaction: { ft: 10 } } }, { metrics: {} }], + } as any); + expect(await chart.Avg_reaction_time({} as any)).to.be.equal(10); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts new file mode 100644 index 0000000..e55d4dc --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts @@ -0,0 +1,351 @@ +/* eslint-disable new-cap */ +import { expect } from 'chai'; +import moment from 'moment-timezone'; +import sinon from 'sinon'; + +import { conversations } from './mockData'; +import { OverviewData } from '../../../../../server/services/omnichannel-analytics/OverviewData'; + +const analytics = (date: { gte: Date; lte: Date }) => { + // filter the mockData array with the date param with moment + return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lte, undefined, '[]')); +}; + +describe('OverviewData Analytics', () => { + describe('isActionAllowed', () => { + it('should return false if no action is provided', () => { + const overview = new OverviewData({} as any); + expect(overview.isActionAllowed(undefined)).to.be.false; + }); + + it('should return false if an invalid action is provided', () => { + const overview = new OverviewData({} as any); + expect(overview.isActionAllowed('invalid_action')).to.be.false; + }); + + it('should return true if a valid action is provided', () => { + const overview = new OverviewData({} as any); + expect(overview.isActionAllowed('Conversations')).to.be.true; + }); + }); + + describe('callAction', () => { + it('should fail if the action is invalid', async () => { + const overview = new OverviewData({} as any); + try { + // @ts-expect-error - Invalid action + await overview.callAction('invalid_action', {} as any); + } catch (e: any) { + expect(e.message).to.be.equal('Invalid action'); + } + }); + it('should call the correct action with the correct parameters', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: () => [], + getOnHoldConversationsBetweenDate: () => 0, + getAnalyticsMetricsBetweenDate: () => [], + } as any); + + const spy = sinon.spy(overview, 'Conversations'); + const spy2 = sinon.spy(overview, 'Productivity'); + + await overview.callAction('Conversations', moment(), moment(), '', 'UTC'); + expect(spy.calledOnce).to.be.true; + + await overview.callAction('Productivity', moment(), moment(), '', 'UTC', (v: string): string => v); + expect(spy2.calledOnce).to.be.true; + }); + }); + + describe('getKeyHavingMaxValue', () => { + it('should return the key with the max value', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(overview.getKeyHavingMaxValue(map, 'd')).to.be.equal('c'); + }); + it('should return the default key if the map is empty', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + expect(overview.getKeyHavingMaxValue(map, 'd')).to.be.equal('d'); + }); + }); + + describe('getAllMapKeysSize', () => { + it('should return the sum of all map keys', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(overview.sumAllMapKeys(map)).to.be.equal(6); + }); + it('should return 0 if the map is empty', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + expect(overview.sumAllMapKeys(map)).to.be.equal(0); + }); + }); + + describe('getBusiestDay', () => { + it('should return the day with the most messages', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + map.set( + 'Monday', + new Map([ + ['1', 1], + ['2', 2], + ]), + ); + map.set( + 'Tuesday', + new Map([ + ['13', 1], + ['15', 2], + ]), + ); + map.set( + 'Sunday', + new Map([ + ['12', 2], + ['23', 2], + ]), + ); + expect(overview.getBusiestDay(map)).to.be.equal('Sunday'); + }); + it('should return the first day with the most messages if theres a tie', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + map.set( + 'Monday', + new Map([ + ['1', 1], + ['2', 2], + ]), + ); + map.set( + 'Tuesday', + new Map([ + ['13', 1], + ['15', 2], + ]), + ); + map.set( + 'Sunday', + new Map([ + ['12', 1], + ['23', 2], + ]), + ); + expect(overview.getBusiestDay(map)).to.be.equal('Monday'); + }); + it('should return the default key if the map is empty', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + expect(overview.getBusiestDay(map)).to.be.equal('-'); + }); + }); + + describe('sumAllMapKeys', () => { + it('should return the sum of all map keys', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + expect(overview.sumAllMapKeys(map)).to.be.equal(6); + }); + it('should return 0 if the map is empty', () => { + const overview = new OverviewData({} as any); + const map = new Map(); + expect(overview.sumAllMapKeys(map)).to.be.equal(0); + }); + }); + + describe('Conversations', () => { + it('should return all values as 0 when theres no data', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: () => [], + getOnHoldConversationsBetweenDate: () => 0, + } as any); + const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {}); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Total_messages', value: 0 }, + { title: 'Busiest_day', value: '-' }, + { title: 'Conversations_per_day', value: '0.00' }, + { title: 'Busiest_time', value: '-' }, + ]); + }); + it('should return all values as 0 when theres data but not on the period we pass', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lte: moment().set('month', 9).toDate() }), + getOnHoldConversationsBetweenDate: () => 0, + } as any); + const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {}); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Total_messages', value: 0 }, + { title: 'Busiest_day', value: '-' }, + { title: 'Conversations_per_day', value: '0.00' }, + { title: 'Busiest_time', value: '-' }, + ]); + }); + it('should return the correct values when theres data on the period we pass', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), + getOnHoldConversationsBetweenDate: () => 1, + } as any); + + // Fixed date to assure we get the same data + const result = await overview.Conversations( + moment.utc().set('month', 10).set('year', 2023).set('date', 12).startOf('day'), + moment.utc().set('month', 10).set('year', 2023).set('date', 12).endOf('day'), + '', + 'UTC', + (v: string): string => v, + {}, + ); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 1 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 1 }, + { title: 'Total_messages', value: 93 }, + { title: 'Busiest_day', value: 'Sunday' }, + { title: 'Conversations_per_day', value: '1.00' }, + { title: 'Busiest_time', value: '11AM - 12PM' }, + ]); + }); + it('should only return conversation metrics related to the provided period, and not consider previous or following days', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), + getOnHoldConversationsBetweenDate: () => 1, + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 23); + + // Fixed date to assure we get the same data + const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 1 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 1 }, + { title: 'Total_messages', value: 14 }, + { title: 'Busiest_day', value: 'Thursday' }, + { title: 'Conversations_per_day', value: '1.00' }, + { title: 'Busiest_time', value: '7AM - 8AM' }, + ]); + }); + it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => { + const overview = new OverviewData({ + getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date), + getOnHoldConversationsBetweenDate: () => 0, + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13); + + const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + expect(result).to.be.deep.equal([ + { title: 'Total_conversations', value: 0 }, + { title: 'Open_conversations', value: 0 }, + { title: 'On_Hold_conversations', value: 0 }, + { title: 'Total_messages', value: 0 }, + { title: 'Busiest_day', value: '-' }, + { title: 'Conversations_per_day', value: '0.00' }, + { title: 'Busiest_time', value: '-' }, + ]); + }); + }); + + describe('Productivity', () => { + it('should return all values as 0 when theres no data', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: () => ({ + forEach: () => [], + }), + } as any); + const result = await overview.Productivity(moment(), moment(), '', 'UTC', (v: string): string => v, {}); + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]); + }); + it('should return all values as 0 when theres data but not on the period we pass', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + const result = await overview.Productivity( + moment().set('month', 9), + moment().set('month', 9), + '', + 'UTC', + (v: string): string => v, + {}, + ); + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]); + }); + it('should return the correct values when theres data on the period we pass', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + const result = await overview.Productivity( + moment().set('month', 10).set('year', 2023).startOf('month'), + moment().set('month', 10).set('year', 2023).endOf('month'), + '', + 'UTC', + ); + + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:07' }, + { title: 'Avg_first_response_time', value: '00:00:10' }, + { title: 'Avg_reaction_time', value: '00:00:49' }, + ]); + }); + it('should only return productivity metrics related to the provided period, and not consider previous or following days', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment().set('month', 10).set('year', 2023).set('date', 25); + + const result = await overview.Productivity(targetDate.startOf('day'), targetDate.clone().endOf('day'), '', 'UTC'); + + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:01' }, + { title: 'Avg_first_response_time', value: '00:00:04' }, + { title: 'Avg_reaction_time', value: '00:02:03' }, + ]); + }); + it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => { + const overview = new OverviewData({ + getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date), + } as any); + + // choosing this specific date since the day before and after are not empty + const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13); + + const result = await overview.Productivity(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC'); + + expect(result).to.be.deep.equal([ + { title: 'Avg_response_time', value: '00:00:00' }, + { title: 'Avg_first_response_time', value: '00:00:00' }, + { title: 'Avg_reaction_time', value: '00:00:00' }, + ]); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/mockData.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/mockData.ts new file mode 100644 index 0000000..f2328fe --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/mockData.ts @@ -0,0 +1,38 @@ +export const conversations = [ + { ts: new Date('2023-11-07T14:37:40.081Z'), msgs: 21 }, + { + ts: new Date('2023-11-14T02:10:38.971Z'), + msgs: 48, + metrics: { + response: { avg: 10, ft: 4 }, + reaction: { ft: 10 }, + }, + }, + { ts: new Date('2023-11-04T20:33:27.172Z'), msgs: 82 }, + { ts: new Date('2023-11-12T11:37:17.716Z'), msgs: 93 }, + { ts: new Date('2023-11-30T17:27:00.057Z'), msgs: 65 }, + { + ts: new Date('2023-11-28T01:13:44.980Z'), + msgs: 45, + metrics: { + response: { avg: 10.233, ft: 23.1 }, + reaction: { ft: 13.2343333 }, + }, + }, + { ts: new Date('2023-11-19T04:05:23.717Z'), msgs: 11 }, + { ts: new Date('2023-11-15T09:16:21.890Z'), msgs: 17 }, + { ts: new Date('2023-11-26T03:53:19.443Z'), msgs: 37 }, + { ts: new Date('2023-11-24T13:45:01.650Z'), msgs: 96 }, + { + ts: new Date('2023-11-25T07:46:38.990Z'), + msgs: 38, + metrics: { + response: { avg: 1.34344417, ft: 4.19383484 }, + reaction: { ft: 123.040949 }, + }, + }, + { ts: new Date('2023-11-21T07:27:38.990Z'), msgs: 38 }, + { ts: new Date('2023-11-29T07:37:38.990Z'), msgs: 38 }, + { ts: new Date('2023-11-22T07:47:38.990Z'), msgs: 34 }, + { ts: new Date('2023-11-23T07:57:38.990Z'), msgs: 14 }, +]; diff --git a/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts new file mode 100644 index 0000000..64e073e --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts @@ -0,0 +1,283 @@ +import crypto from 'crypto'; + +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsStub = { + get: sinon.stub(), +}; + +const twilioStub = { + validateRequest: sinon.stub(), + isRequestFromTwilio: sinon.stub(), +}; + +const { Twilio } = proxyquire.noCallThru().load('../../../../../../server/services/omnichannel-integrations/providers/twilio.ts', { + '../../../settings': { settings: settingsStub }, + '../../../lib/utils/restrictions': { fileUploadIsValidContentType: sinon.stub() }, + '../../../lib/i18n': { i18n: sinon.stub() }, + '../../../lib/logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +/** + * Get a valid Twilio signature for a request + * + * @param {String} authToken your Twilio AuthToken + * @param {String} url your webhook URL + * @param {Object} params the included request parameters + */ +function getSignature(authToken: string, url: string, params: Record): string { + // get all request parameters + const data = Object.keys(params) + // sort them + .sort() + // concatenate them to a string + + .reduce((acc, key) => acc + key + params[key], url); + + return ( + crypto + // sign the string with sha1 using your AuthToken + .createHmac('sha1', authToken) + .update(Buffer.from(data, 'utf-8')) + // base64 encode it + .digest('base64') + ); +} + +describe('Twilio Request Validation', () => { + beforeEach(() => { + settingsStub.get.reset(); + twilioStub.validateRequest.reset(); + twilioStub.isRequestFromTwilio.reset(); + }); + + it('should not validate a request when process.env.TEST_MODE is true', async () => { + process.env.TEST_MODE = 'true'; + + const twilio = new Twilio(); + const request = { + headers: { + 'x-twilio-signature': 'test', + }, + }; + + expect(await twilio.validateRequest(request)).to.be.true; + }); + + it('should validate a request when process.env.TEST_MODE is false', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.true; + }); + + it('should validate a request when query string is present', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com/'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + url: 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.true; + }); + + it('should reject a request where signature doesnt match', async () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('anotherAuthToken', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + it('should reject a request where signature is missing', async () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: () => null, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + it('should reject a request where the signature doesnt correspond body', async () => { + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', {}), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + it('should return false if URL is not provided', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns(''); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + it('should return false if authToken is not provided', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns(''); + settingsStub.get.withArgs('Site_Url').returns('https://example.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.false; + }); + + // Twilio signature will always use the workspace public URL, which may difer from the request URL in some circumstances. + it('should use siteURL instead of request.url hostname', async () => { + process.env.TEST_MODE = 'false'; + + settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); + settingsStub.get.withArgs('Site_Url').returns('https://ngrok.barn.com'); + + const twilio = new Twilio(); + const requestBody = { + To: 'test', + From: 'test', + Body: 'test', + }; + + const request = { + url: 'https://example.com/api/v1/livechat/sms-incoming/twilio', + headers: { + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://ngrok.barn.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, + }, + }; + + expect(await twilio.validateRequest(request, requestBody)).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/omnichannel/queue.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel/queue.tests.ts new file mode 100644 index 0000000..52bcf54 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/omnichannel/queue.tests.ts @@ -0,0 +1,441 @@ +import { expect } from 'chai'; +import { beforeEach, describe, after, it } from 'mocha'; +import p from 'proxyquire'; +import Sinon from 'sinon'; + +const dispatchAgentDelegated = Sinon.stub(); +const getConfig = Sinon.stub(); +const delegateInquiry = Sinon.stub(); +const libSettings = { getInquirySortMechanismSetting: Sinon.stub().returns('timestamp') }; +const settings = { + get: Sinon.stub(), +}; + +const queueLogger = { + info: Sinon.stub(), + debug: Sinon.stub(), + error: Sinon.stub(), +}; + +const mockedInquiry = { + _id: 'inquiryId', + rid: 'rid', + department: 'department1', + ts: new Date(), +}; + +const models = { + LivechatInquiry: { + unlockAll: Sinon.stub(), + findNextAndLock: Sinon.stub(), + getDistinctQueuedDepartments: Sinon.stub(), + unlock: Sinon.stub(), + removeByRoomId: Sinon.stub(), + takeInquiry: Sinon.stub(), + }, + LivechatRooms: { + findOneById: Sinon.stub(), + }, +}; + +const license = { + shouldPreventAction: Sinon.stub(), +}; + +const { OmnichannelQueue } = p.noCallThru().load('../../../../../server/services/omnichannel/queue', { + '../../lib/omnichannel/Helper': { + dispatchAgentDelegated, + }, + '../../lib/omnichannel/RoutingManager': { + RoutingManager: { + getConfig, + delegateInquiry, + }, + }, + '../../lib/omnichannel/settings': libSettings, + '../../settings': { settings }, + './logger': { queueLogger }, + '@rocket.chat/models': models, + '@rocket.chat/license': { License: license }, + '../../lib/metrics': { + metrics: { + timeToQueueProcessingByQueue: { observe: Sinon.stub() }, + timeToQueueProcessingByQueueHistogram: { observe: Sinon.stub() }, + totalItemsProcessedByQueue: { inc: Sinon.stub() }, + totalItemsProcessedByReconciliationQueue: { inc: Sinon.stub() }, + totalItemsFailedByQueue: { inc: Sinon.stub() }, + }, + }, +}); + +describe('Omnichannel Queue processor', () => { + describe('isRunning', () => { + it('should return the running status', () => { + const queue = new OmnichannelQueue(); + expect(queue.isRunning()).to.be.false; + }); + it('should return the running status', () => { + const queue = new OmnichannelQueue(); + queue.running = true; + expect(queue.isRunning()).to.be.true; + }); + }); + describe('delay', () => { + after(() => { + settings.get.reset(); + }); + it('should return 5000 if setting is not set', () => { + settings.get.returns(undefined); + + const queue = new OmnichannelQueue(); + expect(queue.delay()).to.be.equal(5000); + }); + it('should return the right value if setting has a value above 1', () => { + settings.get.returns(10); + + const queue = new OmnichannelQueue(); + expect(queue.delay()).to.be.equal(10000); + }); + }); + describe('getActiveQueues', () => { + after(() => { + models.LivechatInquiry.getDistinctQueuedDepartments.reset(); + }); + it('should return empty array when there are no active queues', async () => { + models.LivechatInquiry.getDistinctQueuedDepartments.resolves([]); + + const queue = new OmnichannelQueue(); + expect(await queue.getActiveQueues()).to.be.eql([]); + }); + it('should return [department1] when department1 is an active queue', async () => { + models.LivechatInquiry.getDistinctQueuedDepartments.resolves([{ _id: 'department1' }]); + + const queue = new OmnichannelQueue(); + expect(await queue.getActiveQueues()).to.be.eql(['department1']); + }); + it('should return [null, department1] when department1 is an active queue and there are elements on public queue', async () => { + models.LivechatInquiry.getDistinctQueuedDepartments.resolves([{ _id: 'department1' }, { _id: null }]); + + const queue = new OmnichannelQueue(); + expect(await queue.getActiveQueues()).to.be.eql(['department1', null]); + }); + }); + describe('checkQueue', () => { + let clock: any; + beforeEach(() => { + models.LivechatInquiry.findNextAndLock.resetHistory(); + models.LivechatInquiry.takeInquiry.resetHistory(); + models.LivechatInquiry.unlock.resetHistory(); + queueLogger.error.resetHistory(); + queueLogger.info.resetHistory(); + clock = Sinon.useFakeTimers(); + }); + afterEach(() => { + clock.restore(); + }); + after(() => { + models.LivechatInquiry.findNextAndLock.reset(); + models.LivechatInquiry.takeInquiry.reset(); + models.LivechatInquiry.unlock.reset(); + queueLogger.error.reset(); + queueLogger.info.reset(); + clock.reset(); + }); + + it('should return undefined when the queue is empty', async () => { + models.LivechatInquiry.findNextAndLock.returns(null); + + const queue = new OmnichannelQueue(); + queue.execute = Sinon.stub(); + expect(await queue.checkQueue()).to.be.undefined; + }); + it('should try to process the inquiry when there is one', async () => { + models.LivechatInquiry.findNextAndLock.returns(mockedInquiry); + + const queue = new OmnichannelQueue(); + queue.processWaitingQueue = Sinon.stub().throws('error'); + queue.execute = Sinon.stub(); + await queue.checkQueue(); + + expect(models.LivechatInquiry.findNextAndLock.calledOnce).to.be.true; + expect(queue.processWaitingQueue.calledOnce).to.be.true; + }); + it('should call unlock when the inquiry could not be processed', async () => { + models.LivechatInquiry.findNextAndLock.returns(mockedInquiry); + + const queue = new OmnichannelQueue(); + queue.processWaitingQueue = Sinon.stub().returns(false); + queue.execute = Sinon.stub(); + await queue.checkQueue(); + + expect(queue.processWaitingQueue.calledOnce).to.be.true; + expect(models.LivechatInquiry.unlock.calledOnce).to.be.true; + }); + it('should unlock the inquiry when it was processed succesfully', async () => { + models.LivechatInquiry.findNextAndLock.returns(mockedInquiry); + + const queue = new OmnichannelQueue(); + queue.processWaitingQueue = Sinon.stub().returns(true); + queue.execute = Sinon.stub(); + await queue.checkQueue(); + + expect(queue.processWaitingQueue.calledOnce).to.be.true; + expect(models.LivechatInquiry.unlock.calledOnce).to.be.true; + }); + it('should print a log when there was an error processing inquiry', async () => { + models.LivechatInquiry.findNextAndLock.throws('error'); + + const queue = new OmnichannelQueue(); + queue.execute = Sinon.stub(); + await queue.checkQueue(); + + expect(queueLogger.error.calledOnce).to.be.true; + }); + }); + describe('shouldStart', () => { + beforeEach(() => { + settings.get.resetHistory(); + getConfig.resetHistory(); + }); + after(() => { + settings.get.reset(); + getConfig.reset(); + }); + + it('should call stop if Livechat is not enabled', async () => { + settings.get.returns(false); + + const queue = new OmnichannelQueue(); + queue.stop = Sinon.stub(); + await queue.shouldStart(); + + expect(queue.stop.calledOnce).to.be.true; + }); + it('should call start if routing algorithm supports auto assignment', async () => { + settings.get.returns(true); + getConfig.returns({ autoAssignAgent: true }); + + const queue = new OmnichannelQueue(); + queue.start = Sinon.stub(); + await queue.shouldStart(); + + expect(queue.start.calledOnce).to.be.true; + expect(queue.start.calledAfter(getConfig)).to.be.true; + }); + it('should call stop if routing algorithm does not support auto assignment', async () => { + settings.get.returns(true); + getConfig.returns({ autoAssignAgent: false }); + + const queue = new OmnichannelQueue(); + queue.stop = Sinon.stub(); + await queue.shouldStart(); + + expect(queue.stop.calledOnce).to.be.true; + expect(queue.stop.calledAfter(getConfig)).to.be.true; + }); + }); + describe('reconciliation', () => { + beforeEach(() => { + models.LivechatInquiry.removeByRoomId.resetHistory(); + models.LivechatInquiry.takeInquiry.resetHistory(); + }); + + it('should remove inquiries from rooms that do not exist', async () => { + const queue = new OmnichannelQueue(); + await queue.reconciliation('missing', { roomId: 'rid', inquiryId: 'inquiryId' }); + + expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true; + }); + it('should take an inquiry if the room was taken', async () => { + const queue = new OmnichannelQueue(); + await queue.reconciliation('taken', { roomId: 'rid', inquiryId: 'inquiryId' }); + + expect(models.LivechatInquiry.takeInquiry.calledOnce).to.be.true; + }); + it('should remove inquiries from rooms that were closed', async () => { + const queue = new OmnichannelQueue(); + await queue.reconciliation('closed', { roomId: 'rid', inquiryId: 'inquiryId' }); + + expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true; + }); + it('should return true for any other case', async () => { + const queue = new OmnichannelQueue(); + expect(await queue.reconciliation('random', { roomId: 'rid', inquiryId: 'inquiryId' })).to.be.true; + expect(models.LivechatInquiry.removeByRoomId.notCalled).to.be.true; + expect(models.LivechatInquiry.takeInquiry.notCalled).to.be.true; + }); + }); + describe('processWaitingQueue', () => { + let clock: any; + beforeEach(() => { + models.LivechatRooms.findOneById.reset(); + models.LivechatInquiry.takeInquiry.resetHistory(); + models.LivechatInquiry.removeByRoomId.resetHistory(); + delegateInquiry.resetHistory(); + queueLogger.debug.resetHistory(); + clock = Sinon.useFakeTimers(); + }); + afterEach(() => { + clock.restore(); + }); + after(() => { + models.LivechatRooms.findOneById.reset(); + models.LivechatInquiry.takeInquiry.reset(); + delegateInquiry.reset(); + queueLogger.debug.reset(); + clock.reset(); + }); + + it('should process the public queue when department is undefined', async () => { + const queue = new OmnichannelQueue(); + + expect(await queue.processWaitingQueue(undefined, mockedInquiry)).to.be.true; + expect(queueLogger.debug.calledWith('Processing inquiry inquiryId from queue Public')); + expect(models.LivechatRooms.findOneById.calledOnce).to.be.true; + }); + it('should call removeInquiry when findOneById returns null', async () => { + models.LivechatRooms.findOneById.returns(null); + + const queue = new OmnichannelQueue(); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true; + expect( + queueLogger.debug.calledWith({ + msg: 'Room from inquiry missing. Removing inquiry', + roomId: 'rid', + inquiryId: 'inquiryId', + step: 'reconciliation', + }), + ).to.be.true; + expect(models.LivechatInquiry.removeByRoomId.calledOnce).to.be.true; + }); + it('should call takeInquiry when findOneById returns a room thats already being served', async () => { + models.LivechatRooms.findOneById.returns({ _id: 'rid', servedBy: { some: 'thing' } }); + + const queue = new OmnichannelQueue(); + queue.reconciliation = Sinon.stub().returns(true); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true; + expect(queue.reconciliation.calledOnce).to.be.true; + }); + it('should call removeInquiry when findOneById returns a room that was closed', async () => { + models.LivechatRooms.findOneById.returns({ _id: 'rid', closedAt: new Date() }); + + const queue = new OmnichannelQueue(); + queue.reconciliation = Sinon.stub().returns(true); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true; + expect(queue.reconciliation.calledOnce).to.be.true; + }); + it('should call delegateInquiry when prechecks are met and return false if inquiry was not served', async () => { + models.LivechatRooms.findOneById.returns({ _id: 'rid' }); + delegateInquiry.returns({}); + + const queue = new OmnichannelQueue(); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.false; + expect(delegateInquiry.calledOnce).to.be.true; + }); + it('should call delegateInquiry and return true if inquiry was served', async () => { + models.LivechatRooms.findOneById.returns({ _id: 'rid' }); + delegateInquiry.returns({ _id: 'rid', servedBy: { _id: 'agentId' } }); + + const queue = new OmnichannelQueue(); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true; + expect(delegateInquiry.calledOnce).to.be.true; + }); + it('should call dispatchAgentDelegated if inquiry was served (after 1s)', async () => { + models.LivechatRooms.findOneById.returns({ _id: 'rid' }); + delegateInquiry.returns({ _id: 'rid', servedBy: { _id: 'agentId' } }); + + const queue = new OmnichannelQueue(); + expect(await queue.processWaitingQueue('department1', mockedInquiry)).to.be.true; + expect(delegateInquiry.calledOnce).to.be.true; + clock.tick(1000); + expect(dispatchAgentDelegated.calledOnce).to.be.true; + }); + }); + describe('execute', () => { + beforeEach(() => { + license.shouldPreventAction.reset(); + queueLogger.debug.reset(); + }); + + after(() => { + license.shouldPreventAction.reset(); + queueLogger.debug.reset(); + }); + + it('should return undefined if service is not running', async () => { + const queue = new OmnichannelQueue(); + queue.running = false; + expect(await queue.execute()).to.be.undefined; + }); + it('should return undefined if license is over mac limits', async () => { + license.shouldPreventAction.returns(true); + + const queue = new OmnichannelQueue(); + queue.running = true; + expect(await queue.execute()).to.be.undefined; + expect(license.shouldPreventAction.calledOnce).to.be.true; + expect(queue.running).to.be.false; + }); + it('should try to process a queue if license is not over mac limits', async () => { + license.shouldPreventAction.returns(false); + + const queue = new OmnichannelQueue(); + queue.running = true; + queue.getActiveQueues = Sinon.stub().resolves([null]); + await queue.execute(); + + expect(queue.getActiveQueues.calledOnce).to.be.true; + }); + }); + describe('start', () => { + beforeEach(() => { + queueLogger.info.resetHistory(); + queueLogger.debug.resetHistory(); + }); + after(() => { + queueLogger.info.reset(); + queueLogger.debug.reset(); + }); + it('should do nothing if queue is already running', async () => { + const queue = new OmnichannelQueue(); + queue.running = true; + queue.execute = Sinon.stub(); + await queue.start(); + + expect(queue.execute.notCalled).to.be.true; + }); + it('should fetch active queues and set running to true', async () => { + const queue = new OmnichannelQueue(); + queue.running = false; + queue.getActiveQueues = Sinon.stub().returns(['department1']); + queue.execute = Sinon.stub(); + await queue.start(); + + expect(queue.running).to.be.true; + expect(queue.getActiveQueues.calledOnce).to.be.true; + expect(queueLogger.info.calledOnce).to.be.true; + expect(queueLogger.info.calledWith('Service started')).to.be.true; + expect(queue.execute.calledOnce).to.be.true; + }); + }); + describe('stop', () => { + beforeEach(() => { + models.LivechatInquiry.unlockAll.reset(); + queueLogger.info.resetHistory(); + }); + after(() => { + models.LivechatInquiry.unlockAll.reset(); + queueLogger.info.reset(); + }); + it('should unlock all inquiries and set running to false', async () => { + const queue = new OmnichannelQueue(); + queue.running = true; + await queue.stop(); + + expect(queue.running).to.be.false; + expect(models.LivechatInquiry.unlockAll.calledOnce).to.be.true; + expect(queueLogger.info.calledOnce).to.be.true; + expect(queueLogger.info.calledWith('Service stopped')).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/room/getNameForDMs.spec.ts b/apps/meteor/tests/unit/server/services/room/getNameForDMs.spec.ts new file mode 100644 index 0000000..e8efb08 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/room/getNameForDMs.spec.ts @@ -0,0 +1,128 @@ +import { expect } from 'chai'; + +import { getNameForDMs } from '../../../../../server/services/room/getNameForDMs'; + +describe('getNameForDMs', () => { + it('should return empty object when members array is empty', () => { + const result = getNameForDMs([]); + expect(result).to.deep.equal({}); + }); + + it('should return own name for single member', () => { + const members = [{ _id: 'user1', name: 'John Doe', username: 'john' }]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'John Doe', + name: 'john', + }, + }); + }); + + it('should return name map for two members using name field', () => { + const members = [ + { _id: 'user1', name: 'John Doe', username: 'john' }, + { _id: 'user2', name: 'Jane Smith', username: 'jane' }, + ]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'Jane Smith', + name: 'jane', + }, + user2: { + fname: 'John Doe', + name: 'john', + }, + }); + }); + + it('should fallback to username when name is not available', () => { + const members = [ + { _id: 'user1', name: '', username: 'john' }, + { _id: 'user2', name: '', username: 'jane' }, + ]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'jane', + name: 'jane', + }, + user2: { + fname: 'john', + name: 'john', + }, + }); + }); + + it('should handle multiple members and sort them alphabetically', () => { + const members = [ + { _id: 'user3', name: 'Charlie Brown', username: 'charlie' }, + { _id: 'user1', name: 'Alice Wonder', username: 'alice' }, + { _id: 'user2', name: 'Bob Builder', username: 'bob' }, + ]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'Bob Builder, Charlie Brown', + name: 'bob, charlie', + }, + user2: { + fname: 'Alice Wonder, Charlie Brown', + name: 'alice, charlie', + }, + user3: { + fname: 'Alice Wonder, Bob Builder', + name: 'alice, bob', + }, + }); + }); + + it('should handle mix of members with and without names', () => { + const members = [ + { _id: 'user1', name: 'Alice Wonder', username: 'alice' }, + { _id: 'user2', name: '', username: 'bob' }, + ]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'bob', + name: 'bob', + }, + user2: { + fname: 'Alice Wonder', + name: 'alice', + }, + }); + }); + + it('should sort by username when names are empty', () => { + const members = [ + { _id: 'user1', name: '', username: 'zebra' }, + { _id: 'user2', name: '', username: 'alpha' }, + ]; + + const result = getNameForDMs(members); + + expect(result).to.deep.equal({ + user1: { + fname: 'alpha', + name: 'alpha', + }, + user2: { + fname: 'zebra', + name: 'zebra', + }, + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/room/hooks/FederationActions.tests.ts b/apps/meteor/tests/unit/server/services/room/hooks/FederationActions.tests.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/meteor/tests/unit/server/services/team/service.tests.ts b/apps/meteor/tests/unit/server/services/team/service.tests.ts new file mode 100644 index 0000000..a74e0c0 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/team/service.tests.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const Rooms = { + findDefaultRoomsForTeam: sinon.stub(), + findOneById: sinon.stub(), + unsetTeamId: sinon.stub(), +}; + +const Users = { + findActiveByIds: sinon.stub(), +}; + +const Message = { + saveSystemMessage: sinon.stub(), +}; + +const addUserToRoom = sinon.stub(); + +const { TeamService } = proxyquire.noCallThru().load('../../../../../server/services/team/service', { + '@rocket.chat/core-services': { + Room: {}, + Authorization: {}, + Message, + ServiceClassInternal: class {}, + api: {}, + }, + '@rocket.chat/models': { + Team: {}, + Rooms, + Subscriptions: {}, + Users, + TeamMember: {}, + }, + '@rocket.chat/tools': { + escapeRegExp: (value: string) => value, + }, + '../../lib/rooms/settings': { + saveRoomName: sinon.stub(), + }, + '../../lib/rooms/settings/saveRoomType': { + saveRoomType: sinon.stub(), + }, + '../../lib/rooms/addUserToRoom': { + addUserToRoom, + }, + '../../lib/users/checkUsernameAvailability': { + checkUsernameAvailability: sinon.stub(), + }, + '../../lib/rooms/getRoomsWithSingleOwner': { + getSubscribedRoomsForUserWithDetails: sinon.stub(), + }, + '../../lib/rooms/removeUserFromRoom': { + removeUserFromRoom: sinon.stub(), + }, + '../../lib/notifyListener': { + notifyOnSubscriptionChangedByRoomIdAndUserId: sinon.stub(), + notifyOnRoomChangedById: sinon.stub(), + }, + '../../settings': { + settings: { get: sinon.stub() }, + }, +}); + +const service = new TeamService(); + +describe('Team service', () => { + beforeEach(() => { + addUserToRoom.reset(); + Rooms.findDefaultRoomsForTeam.reset(); + Rooms.findOneById.reset(); + Rooms.unsetTeamId.reset(); + Users.findActiveByIds.reset(); + Message.saveSystemMessage.reset(); + }); + + it('should wait for default room membership operations to finish', async function () { + this.timeout(15000); + + addUserToRoom.onFirstCall().resolves(true); + addUserToRoom.onSecondCall().returns( + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 20); + }), + ); + + Rooms.findDefaultRoomsForTeam.returns({ + toArray: () => Promise.resolve([{ _id: 'default-room' }]), + }); + Users.findActiveByIds.returns({ + toArray: () => + Promise.resolve([ + { _id: 'user-1', username: 'user-1' }, + { _id: 'user-2', username: 'user-2' }, + ]), + }); + + await service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [ + { userId: 'user-1' }, + { userId: 'user-2' }, + ]); + + expect(addUserToRoom.callCount).to.equal(2); + }); + + it('should propagate errors from default room membership operations', async function () { + this.timeout(15000); + + addUserToRoom.rejects(new Error('room-add-failed')); + Rooms.findDefaultRoomsForTeam.returns({ + toArray: () => Promise.resolve([{ _id: 'default-room' }]), + }); + Users.findActiveByIds.returns({ + toArray: () => Promise.resolve([{ _id: 'user-1', username: 'user-1' }]), + }); + + await expect( + service.addMembersToDefaultRooms({ _id: 'inviter', username: 'inviter' }, 'team-id', [{ userId: 'user-1' }]), + ).to.be.rejectedWith('room-add-failed'); + + expect(addUserToRoom.callCount).to.equal(1); + }); + + describe('unsetTeamIdOfRooms', () => { + const user = { _id: 'user-1', username: 'user-1', name: 'User One' }; + const team = { _id: 'team-id', roomId: 'team-room' }; + + it('should announce the conversion on the main room and move the remaining rooms back to the workspace', async () => { + Rooms.findOneById.resolves({ _id: 'team-room', name: 'team-room-name' }); + + await service.unsetTeamIdOfRooms(user, team); + + expect(Message.saveSystemMessage.calledOnceWith('user-converted-to-channel', 'team-room', 'team-room-name', user)).to.be.true; + expect(Rooms.unsetTeamId.calledOnceWith('team-id')).to.be.true; + }); + + it('should move the remaining rooms back to the workspace when the main room no longer exists', async () => { + Rooms.findOneById.resolves(null); + + await service.unsetTeamIdOfRooms(user, team); + + expect(Message.saveSystemMessage.called).to.be.false; + expect(Rooms.unsetTeamId.calledOnceWith('team-id')).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/user/service.tests.ts b/apps/meteor/tests/unit/server/services/user/service.tests.ts new file mode 100644 index 0000000..8c12af0 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/user/service.tests.ts @@ -0,0 +1,68 @@ +import { registerModel, BaseRaw } from '@rocket.chat/models'; +import { expect } from 'chai'; +import { afterEach, before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const maxTokens = { + getMaxLoginTokens: () => 2, +}; + +const { UserService } = proxyquire.noCallThru().load('../../../../../server/services/user/service', { + '../../lib/getMaxLoginTokens': maxTokens, +}); + +class UsersModel extends BaseRaw { + async findAllResumeTokensByUserId(): Promise }>> { + return []; + } + + async removeOlderResumeTokensByUserId(_uid: string, _when: Date) { + // no op + } +} + +const usersModel = new UsersModel({ collection: () => ({}) } as unknown as any, 'user'); + +describe('User service', () => { + before(() => { + registerModel('IUsersModel', usersModel); + }); + + afterEach(() => sinon.restore()); + + it('should do nothing if user has less login tokens than the limit', async () => { + const service = new UserService(); + + sinon.replace(usersModel, 'findAllResumeTokensByUserId', sinon.fake.returns(Promise.resolve([{ tokens: [{ when: new Date() }] }]))); + const updateFake = sinon.replace(usersModel, 'removeOlderResumeTokensByUserId', sinon.fake()); + + const result = await service.ensureLoginTokensLimit('uid'); + + expect(result).to.be.undefined; + expect(updateFake.callCount).to.be.equal(0); + }); + + it('should remove the oldest tokens if over the limit', async () => { + const service = new UserService(); + + const firstOld = new Date('2023-01-03'); + + // the query return the tokens in order so we should do the same here + const tokens = [ + { when: new Date('2023-01-01') }, + { when: new Date('2023-01-02') }, + { when: firstOld }, + { when: new Date('2023-01-04') }, + ]; + + sinon.replace(usersModel, 'findAllResumeTokensByUserId', sinon.fake.returns(Promise.resolve([{ tokens }]))); + const updateFake = sinon.replace(usersModel, 'removeOlderResumeTokensByUserId', sinon.fake()); + + const result = await service.ensureLoginTokensLimit('uid'); + + expect(result).to.be.undefined; + expect(updateFake.callCount).to.be.equal(1); + expect(updateFake.lastCall.lastArg).to.be.equal(firstOld); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/utils.ts b/apps/meteor/tests/unit/server/services/utils.ts new file mode 100644 index 0000000..20ab317 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/utils.ts @@ -0,0 +1,28 @@ +import sinon from 'sinon'; + +export async function testPrivateMethod any>( + service: any, + methodName: string, + testFn: (method: T) => Promise | void, +): Promise { + const proto = Object.getPrototypeOf(service); + const originalMethod = proto[methodName]; + const isStubbed = originalMethod && 'restore' in originalMethod; + + if (isStubbed) { + (originalMethod as sinon.SinonStub).restore(); + } + + const method = proto[methodName]; + void testFn(method.bind(service)); + + if (isStubbed) { + sinon.stub(proto, methodName).callsFake(originalMethod); + } +} + +export function createFreshServiceInstance(moduleExports: any, serviceName?: string): T { + const ServiceClass = serviceName ? moduleExports[serviceName] : Object.values(moduleExports)[0]; + + return new ServiceClass(); +} diff --git a/apps/meteor/tests/unit/server/settings/functions/compareSettingsMetadata.tests.ts b/apps/meteor/tests/unit/server/settings/functions/compareSettingsMetadata.tests.ts new file mode 100644 index 0000000..d8d846e --- /dev/null +++ b/apps/meteor/tests/unit/server/settings/functions/compareSettingsMetadata.tests.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; + +import { compareSettings } from '../../../../../server/settings/SettingsRegistry'; +import { getSettingDefaults } from '../../../../../server/settings/functions/getSettingDefaults'; + +const testSetting = getSettingDefaults({ + _id: 'my_dummy_setting', + type: 'string', + value: 'dummy', +}); + +describe('#compareSettings', () => { + const ignoredKeys = ['value', 'ts', 'createdAt', 'valueSource', 'packageValue', 'processEnvValue', '_updatedAt']; + + ignoredKeys.forEach((key) => + it(`should return true if ${key} changes`, () => { + const copiedSetting: any = { ...testSetting }; + + if (['ts', 'createdAt', '_updatedAt'].includes(key)) { + copiedSetting[key] = new Date(); + } else { + copiedSetting[key] = 'random'; + } + + expect(compareSettings(testSetting, copiedSetting)).to.be.true; + }), + ); +}); diff --git a/apps/meteor/tests/unit/server/settings/functions/getSettingDefaults.tests.ts b/apps/meteor/tests/unit/server/settings/functions/getSettingDefaults.tests.ts new file mode 100644 index 0000000..00a0a8d --- /dev/null +++ b/apps/meteor/tests/unit/server/settings/functions/getSettingDefaults.tests.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; + +import { getSettingDefaults } from '../../../../../server/settings/functions/getSettingDefaults'; + +describe('getSettingDefaults', () => { + it('should return based on _id type value', () => { + const setting = getSettingDefaults({ _id: 'test', value: 'test', type: 'string' }); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('i18nDescription').to.be.equal('test_Description'); + + expect(setting).to.have.property('value').to.be.equal('test'); + expect(setting).to.have.property('packageValue').to.be.equal('test'); + + expect(setting).to.have.property('type').to.be.equal('string'); + + expect(setting).to.have.property('blocked').to.be.equal(false); + + expect(setting).to.not.have.property('multiline'); + expect(setting).to.not.have.property('values'); + + expect(setting).to.not.have.property('group'); + expect(setting).to.not.have.property('section'); + expect(setting).to.not.have.property('tab'); + }); + + it('should return a sorter value', () => { + const setting = getSettingDefaults({ _id: 'test', value: 'test', type: 'string', sorter: 1 }); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('sorter').to.be.equal(1); + }); + + it('should return a private setting', () => { + const setting = getSettingDefaults({ + _id: 'test', + value: 'test', + type: 'string', + public: false, + }); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('public').to.be.equal(false); + }); + + it('should return a public setting', () => { + const setting = getSettingDefaults({ + _id: 'test', + value: 'test', + type: 'string', + public: true, + }); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('public').to.be.equal(true); + }); + + it('should return a blocked setting', () => { + const setting = getSettingDefaults({ + _id: 'test', + value: 'test', + type: 'string', + blocked: true, + }); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('blocked').to.be.equal(true); + }); + + it('should return a blocked setting set by env', () => { + const setting = getSettingDefaults({ _id: 'test', value: 'test', type: 'string' }, new Set(['test'])); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('blocked').to.be.equal(true); + }); + + it('should return a package value', () => { + const setting = getSettingDefaults({ _id: 'test', value: true, type: 'string' }, new Set(['test'])); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.have.property('blocked').to.be.equal(true); + }); + + it('should not return undefined options', () => { + const setting = getSettingDefaults( + { _id: 'test', value: true, type: 'string', section: undefined, group: undefined }, + new Set(['test']), + ); + + expect(setting).to.be.an('object'); + expect(setting).to.have.property('_id'); + + expect(setting).to.not.have.property('section'); + expect(setting).to.not.have.property('group'); + }); +}); diff --git a/apps/meteor/tests/unit/server/settings/functions/overrideGenerator.tests.ts b/apps/meteor/tests/unit/server/settings/functions/overrideGenerator.tests.ts new file mode 100644 index 0000000..e885859 --- /dev/null +++ b/apps/meteor/tests/unit/server/settings/functions/overrideGenerator.tests.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; + +import { getSettingDefaults } from '../../../../../server/settings/functions/getSettingDefaults'; +import { overrideGenerator } from '../../../../../server/settings/functions/overrideGenerator'; + +describe('overrideGenerator', () => { + it('should return a new object with the new value', () => { + const overwrite = overrideGenerator(() => 'value'); + + const setting = getSettingDefaults({ _id: 'test', value: 'test', type: 'string' }); + const overwritten = overwrite(setting); + + expect(overwritten).to.be.an('object'); + expect(overwritten).to.have.property('_id'); + + expect(setting).to.be.not.equal(overwritten); + + expect(overwritten).to.have.property('value').that.equals('value'); + expect(overwritten).to.have.property('valueSource').that.equals('processEnvValue'); + }); + + it('should stamp the env source even when the value didnt change', () => { + const overwrite = overrideGenerator(() => 'test'); + + const setting = getSettingDefaults({ _id: 'test', value: 'test', type: 'string' }); + const overwritten = overwrite(setting); + + expect(setting).to.be.not.equal(overwritten); + expect(overwritten).to.have.property('value').that.equals('test'); + expect(overwritten).to.have.property('valueSource').that.equals('processEnvValue'); + expect(overwritten).to.have.property('processEnvValue').that.equals('test'); + }); + + it('should return the same object when the value and the env stamp are already in place', () => { + const overwrite = overrideGenerator(() => 'test'); + + const setting = { + ...getSettingDefaults({ _id: 'test', value: 'test', type: 'string' }), + valueSource: 'processEnvValue' as const, + processEnvValue: 'test', + }; + const overwritten = overwrite(setting); + + expect(setting).to.be.equal(overwritten); + }); +}); diff --git a/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts b/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts new file mode 100644 index 0000000..5188b52 --- /dev/null +++ b/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts @@ -0,0 +1,721 @@ +import { expect, spy } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; + +import { CachedSettings } from '../../../../../server/settings/CachedSettings'; +import { SettingsRegistry } from '../../../../../server/settings/SettingsRegistry'; +import { getSettingDefaults } from '../../../../../server/settings/functions/getSettingDefaults'; +import { getSettingSchemaValidator, registerSettingSchema } from '../../../../../server/settings/functions/settingSchemas'; +import { Settings } from '../../../../../server/settings/functions/settings.mocks'; + +const testSetting = getSettingDefaults({ + _id: 'my_dummy_setting', + type: 'string', + value: 'dummy', +}); + +describe('Settings', () => { + beforeEach(() => { + Settings.insertCalls = 0; + Settings.upsertCalls = 0; + process.env = {}; + Settings.setDelay(0); + }); + + it('should not insert the same setting twice', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', true, { + type: 'boolean', + sorter: 0, + }); + }); + }); + + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.insertCalls).to.be.equal(2); + + expect(Settings.findOne({ _id: 'my_setting' })).to.be.include({ + type: 'boolean', + sorter: 0, + group: 'group', + section: 'section', + packageValue: true, + value: true, + valueSource: 'packageValue', + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting', + i18nDescription: 'my_setting_Description', + autocomplete: true, + }); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', true, { + type: 'boolean', + sorter: 0, + }); + }); + }); + + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.insertCalls).to.be.equal(2); + + expect(Settings.findOne({ _id: 'my_setting' }).value).to.be.equal(true); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting2', false, { + type: 'boolean', + sorter: 0, + }); + }); + }); + + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.insertCalls).to.be.equal(3); + + expect(Settings.findOne({ _id: 'my_setting' }).value).to.be.equal(true); + expect(Settings.findOne({ _id: 'my_setting2' }).value).to.be.equal(false); + }); + + it('should keep the schema option in memory instead of persisting it', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.add('my_json_setting', ' ', { + type: 'code', + code: 'application/json', + schema: { type: 'object' }, + }); + }); + + expect(Settings.findOne({ _id: 'my_json_setting' })).to.not.have.property('schema'); + expect(getSettingSchemaValidator('my_json_setting')).to.be.a('function'); + }); + + it('should throw at registration when the schema does not compile', () => { + expect(() => registerSettingSchema('my_broken_setting', { type: 'not-a-type' })).to.throw(); + }); + + it('should respect override via environment as int', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + process.env.OVERWRITE_SETTING_my_setting = '1'; + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + const expectedSetting = { + value: 1, + processEnvValue: 1, + valueSource: 'processEnvValue', + type: 'int', + sorter: 0, + group: 'group', + section: 'section', + packageValue: 0, + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting', + i18nDescription: 'my_setting_Description', + autocomplete: true, + }; + + expect(Settings).to.have.property('insertCalls').to.be.equal(2); + expect(Settings).to.have.property('upsertCalls').to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting' })).to.include(expectedSetting); + + process.env.OVERWRITE_SETTING_my_setting = '2'; + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + expect(Settings).to.have.property('insertCalls').to.be.equal(2); + expect(Settings).to.have.property('upsertCalls').to.be.equal(1); + expect(Settings.findOne({ _id: 'my_setting' })).to.include({ + ...expectedSetting, + value: 2, + processEnvValue: 2, + }); + }); + + it('should stamp the env source when the override equals the stored value', async () => { + const addSetting = (registry: SettingsRegistry) => + registry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_stamped', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + const bootRegistry = () => { + const settings = new CachedSettings(); + Settings.settings = settings; + for (const _id of ['group', 'my_setting_stamped']) { + const stored = Settings.findOne({ _id }); + if (stored) { + settings.set(stored); + } + } + settings.initialized(); + return new SettingsRegistry({ store: settings, model: Settings as any }); + }; + + await addSetting(bootRegistry()); + + expect(Settings.findOne({ _id: 'my_setting_stamped' })).to.include({ value: 0, valueSource: 'packageValue' }); + + process.env.OVERWRITE_SETTING_my_setting_stamped = '0'; + + await addSetting(bootRegistry()); + + expect(Settings).to.have.property('upsertCalls').to.be.equal(1); + expect(Settings.findOne({ _id: 'my_setting_stamped' })).to.include({ + value: 0, + processEnvValue: 0, + valueSource: 'processEnvValue', + }); + + await addSetting(bootRegistry()); + + expect(Settings).to.have.property('upsertCalls').to.be.equal(1); + }); + + it('should respect override via environment as boolean', async () => { + process.env.OVERWRITE_SETTING_my_setting_bool = 'true'; + + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_bool', false, { + type: 'boolean', + sorter: 0, + }); + }); + }); + + const expectedSetting = { + value: true, + processEnvValue: true, + valueSource: 'processEnvValue', + type: 'boolean', + sorter: 0, + group: 'group', + section: 'section', + packageValue: false, + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting_bool', + i18nDescription: 'my_setting_bool_Description', + autocomplete: true, + }; + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting_bool' })).to.include(expectedSetting); + + process.env.OVERWRITE_SETTING_my_setting_bool = 'false'; + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_bool', true, { + type: 'boolean', + sorter: 0, + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(1); + expect(Settings.findOne({ _id: 'my_setting_bool' })).to.include({ + value: false, + processEnvValue: false, + packageValue: true, + }); + }); + + it('should respect override via environment as string', async () => { + process.env.OVERWRITE_SETTING_my_setting_str = 'hey'; + + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_str', '', { + type: 'string', + sorter: 0, + }); + }); + }); + + const expectedSetting = { + value: 'hey', + processEnvValue: 'hey', + valueSource: 'processEnvValue', + type: 'string', + sorter: 0, + group: 'group', + section: 'section', + packageValue: '', + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting_str', + i18nDescription: 'my_setting_str_Description', + autocomplete: true, + }; + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting_str' })).to.include(expectedSetting); + + process.env.OVERWRITE_SETTING_my_setting_str = 'hey ho'; + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_str', 'hey', { + type: 'string', + sorter: 0, + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(1); + expect(Settings.findOne({ _id: 'my_setting_str' })).to.include({ + ...expectedSetting, + value: 'hey ho', + processEnvValue: 'hey ho', + packageValue: 'hey', + }); + }); + + it('should work with a setting type multiSelect with a default value', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_multiselect', ['a'], { + type: 'multiSelect', + sorter: 0, + values: [ + { key: 'a', i18nLabel: 'a' }, + { key: 'b', i18nLabel: 'b' }, + { key: 'c', i18nLabel: 'c' }, + ], + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).value).to.be.deep.equal(['a']); + }); + it('should respect override via environment as multiSelect', async () => { + process.env.OVERWRITE_SETTING_my_setting_multiselect = '["a","b"]'; + + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_multiselect', ['a'], { + type: 'multiSelect', + sorter: 0, + values: [ + { key: 'a', i18nLabel: 'a' }, + { key: 'b', i18nLabel: 'b' }, + { key: 'c', i18nLabel: 'c' }, + ], + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).value).to.be.deep.equal(['a', 'b']); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).processEnvValue).to.be.deep.equal(['a', 'b']); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).valueSource).to.be.equal('processEnvValue'); + }); + + it('should ignore override via environment as multiSelect if value is invalid', async () => { + process.env.OVERWRITE_SETTING_my_setting_multiselect = '[INVALID_ARRAY]'; + + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting_multiselect', ['a'], { + type: 'multiSelect', + sorter: 0, + values: [ + { key: 'a', i18nLabel: 'a' }, + { key: 'b', i18nLabel: 'b' }, + { key: 'c', i18nLabel: 'c' }, + ], + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).value).to.be.deep.equal(['a']); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).processEnvValue).to.be.equal(undefined); + expect(Settings.findOne({ _id: 'my_setting_multiselect' }).valueSource).to.be.equal('packageValue'); + }); + + it('should respect initial value via environment', async () => { + process.env.my_setting = '1'; + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + const expectedSetting = { + value: 1, + processEnvValue: 1, + valueSource: 'processEnvValue', + type: 'int', + sorter: 0, + group: 'group', + section: 'section', + packageValue: 0, + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting', + i18nDescription: 'my_setting_Description', + autocomplete: true, + }; + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting' })).to.include(expectedSetting); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting' })).to.include({ ...expectedSetting }); + }); + + it('should respect override via environment when changing settings props', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + }); + + const expectedSetting = { + value: 0, + type: 'int', + sorter: 0, + group: 'group', + section: 'section', + packageValue: 0, + hidden: false, + blocked: false, + secret: false, + i18nLabel: 'my_setting', + i18nDescription: 'my_setting_Description', + autocomplete: true, + }; + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(0); + expect(Settings.findOne({ _id: 'my_setting' })).to.include(expectedSetting); + + process.env.OVERWRITE_SETTING_my_setting = '1'; + await settingsRegistry.addGroup('group', async function () { + // removed section + await this.add('my_setting', 0, { + type: 'int', + sorter: 0, + }); + }); + + expect(Settings.insertCalls).to.be.equal(2); + expect(Settings.upsertCalls).to.be.equal(1); + + const { section: _section, ...removedSection } = expectedSetting; + + const settingWithoutSection = { + ...removedSection, + value: 1, + processEnvValue: 1, + valueSource: 'processEnvValue', + }; + + expect(Settings.findOne({ _id: 'my_setting' })) + .to.include({ ...settingWithoutSection }) + .to.not.have.any.keys('section'); + }); + + it('should ignore setting object from code if only value changes in code and setting already stored', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(Settings.insertCalls).to.be.equal(1); + Settings.insertCalls = 0; + + const settingFromCodeFaked = { ...testSetting, value: Date.now().toString() }; + + await settingsRegistry.add(settingFromCodeFaked._id, settingFromCodeFaked.value, settingFromCodeFaked); + + expect(Settings.insertCalls).to.be.equal(0); + expect(Settings.upsertCalls).to.be.equal(0); + }); + + it('should not update (reset) cached setting with value in code if some prop in code changes (including value)', async () => { + Settings.setDelay(1000); + const settings = new CachedSettings(); + process.env[`OVERWRITE_SETTING_${testSetting._id}`] = 'false'; + const storedSetting = { ...testSetting, value: true, packageValue: true }; + settings.set(storedSetting); + + Settings.settings = settings; + + settings.initialized(); + + expect(settings.get(storedSetting._id)).to.be.equal(true); + + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + const settingFromCodeFaked = { + ...storedSetting, + value: true, + enterprise: true, + invalidValue: '', + }; + + await settingsRegistry.add(settingFromCodeFaked._id, settingFromCodeFaked.value, settingFromCodeFaked); + + expect(Settings.insertCalls).to.be.equal(0); + expect(Settings.upsertCalls).to.be.equal(1); + + expect(settings.get(storedSetting._id)).to.be.equal(false); + }); + + it('should update cached setting with value from environment if some prop including value in code changes', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(Settings.insertCalls).to.be.equal(1); + Settings.insertCalls = 0; + + const settingFromCodeFaked = { ...testSetting, value: Date.now().toString(), enterprise: true, invalidValue: '' }; + + process.env[`OVERWRITE_SETTING_${testSetting._id}`] = Date.now().toString(); + + await settingsRegistry.add(settingFromCodeFaked._id, settingFromCodeFaked.value, settingFromCodeFaked); + + expect(Settings.insertCalls).to.be.equal(0); + expect(Settings.upsertCalls).to.be.equal(1); + + expect(settings.get(testSetting._id)).to.be.equal(process.env[`OVERWRITE_SETTING_${testSetting._id}`]); + }); + + it('should ignore default value from environment if setting is already stored', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + process.env[testSetting._id] = Date.now().toString(); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(Settings.findOne({ _id: testSetting._id }).value).to.be.equal(testSetting.value); + }); + + it('should update setting cache synchronously if overwrite is available in environment', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + settings.set(testSetting); + + process.env[`OVERWRITE_SETTING_${testSetting._id}`] = Date.now().toString(); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(settings.get(testSetting._id)).to.be.equal(process.env[`OVERWRITE_SETTING_${testSetting._id}`]); + }); + + it('should update cached value with OVERWRITE_SETTING value even if both overwrite and default overwrite variables both exist', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + process.env[`OVERWRITE_SETTING_${testSetting._id}`] = Date.now().toString(); + process.env[testSetting._id] = Date.now().toString(); + + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(Settings.insertCalls).to.be.equal(1); + expect(settings.get(testSetting._id)).to.be.equal(process.env[`OVERWRITE_SETTING_${testSetting._id}`]); + }); + + it('should call `settings.get` callback on setting added', async () => { + return new Promise(async (resolve) => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + const spiedCallback1 = spy(); + const spiedCallback2 = spy(); + + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('setting_callback', 'value1', { + type: 'string', + }); + }); + }); + + settings.watch('setting_callback', spiedCallback1, { debounce: 10 }); + settings.watchByRegex(/setting_callback/, spiedCallback2, { debounce: 10 }); + + setTimeout( + () => { + expect(spiedCallback1).to.have.been.called.exactly(1); + expect(spiedCallback2).to.have.been.called.exactly(1); + expect(spiedCallback1).to.have.been.called.always.with('value1'); + expect(spiedCallback2).to.have.been.called.always.with('setting_callback', 'value1'); + resolve(); + }, + settings.getConfig({ debounce: 10 }).debounce, + ); + }); + }); + + it('should call `settings.watch` callback on setting changed registering before initialized', async () => { + return new Promise(async (resolve) => { + const spiedCallback1 = spy(); + const spiedCallback2 = spy(); + const settings = new CachedSettings(); + Settings.settings = settings; + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + settings.watch('setting_callback', spiedCallback1, { debounce: 1 }); + settings.watchByRegex(/setting_callback/gi, spiedCallback2, { debounce: 1 }); + + settings.initialized(); + await settingsRegistry.addGroup('group', async function () { + await this.section('section', async function () { + await this.add('setting_callback', 'value2', { + type: 'string', + }); + }); + }); + setTimeout( + () => { + Settings.updateValueById('setting_callback', 'value3'); + setTimeout( + () => { + expect(spiedCallback1).to.have.been.called.exactly(2); + expect(spiedCallback2).to.have.been.called.exactly(2); + expect(spiedCallback1).to.have.been.called.with('value2'); + expect(spiedCallback1).to.have.been.called.with('value3'); + resolve(); + }, + settings.getConfig({ debounce: 10 }).debounce, + ); + }, + settings.getConfig({ debounce: 10 }).debounce, + ); + }); + }); + + it('should update the stored value on setting change', async () => { + Settings.setDelay(10); + process.env[`OVERWRITE_SETTING_${testSetting._id}`] = 'false'; + const settings = new CachedSettings(); + Settings.settings = settings; + + settings.set(testSetting); + settings.initialized(); + + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + await settingsRegistry.add(testSetting._id, testSetting.value, testSetting); + + expect(settings.get(testSetting._id)).to.be.equal(false); + }); +}); diff --git a/apps/meteor/tests/unit/server/settings/functions/validateSettings.tests.ts b/apps/meteor/tests/unit/server/settings/functions/validateSettings.tests.ts new file mode 100644 index 0000000..8f4856a --- /dev/null +++ b/apps/meteor/tests/unit/server/settings/functions/validateSettings.tests.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; + +import { validateSetting } from '../../../../../server/settings/functions/validateSetting'; + +describe('validateSettings', () => { + it('should validate the type string', () => { + expect(() => validateSetting('test', 'string', 'value')).to.not.throw(); + }); + it('should throw an error expecting string receiving int', () => { + expect(() => validateSetting('test', 'string', 10)).to.throw(); + }); + it('should validate the type int', () => { + expect(() => validateSetting('test', 'int', 10)).to.not.throw(); + }); + it('should throw an error expecting int receiving string', () => { + expect(() => validateSetting('test', 'int', '10')).to.throw(); + }); + it('should validate the type boolean', () => { + expect(() => validateSetting('test', 'boolean', true)).to.not.throw(); + }); + it('should throw an error expecting boolean receiving string', () => { + expect(() => validateSetting('test', 'boolean', 'true')).to.throw(); + }); + it('should validate the type date', () => { + expect(() => validateSetting('test', 'date', new Date())).to.not.throw(); + }); + it('should throw an error expecting date receiving string', () => { + expect(() => validateSetting('test', 'date', '2019-01-01')).to.throw(); + }); + it('should validate the type multiSelect', () => { + expect(() => validateSetting('test', 'multiSelect', [])).to.not.throw(); + }); + it('should throw an error expecting multiSelect receiving string', () => { + expect(() => validateSetting('test', 'multiSelect', '[]')).to.throw(); + }); +}); diff --git a/apps/meteor/tests/unit/server/startup/cloudRegistration.tests.ts b/apps/meteor/tests/unit/server/startup/cloudRegistration.tests.ts new file mode 100644 index 0000000..5e4df50 --- /dev/null +++ b/apps/meteor/tests/unit/server/startup/cloudRegistration.tests.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const models = { + Settings: { + getValueById: sinon.stub(), + findOneById: sinon.stub(), + updateValueById: sinon.stub(), + }, +}; + +const { ensureCloudWorkspaceRegistered } = proxyquire.noCallThru().load('../../../../server/startup/cloudRegistration', { + '@rocket.chat/models': models, +}); + +describe('ensureCloudWorkspaceRegistered', () => { + const stubSettings = (values: Record, showSetupWizard: Record | null) => { + models.Settings.getValueById.callsFake(async (id: string) => values[id]); + models.Settings.findOneById.resolves(showSetupWizard); + }; + + beforeEach(() => { + models.Settings.getValueById.reset(); + models.Settings.findOneById.reset(); + models.Settings.updateValueById.reset(); + }); + + it('should not touch the setting when its value was pinned via env override and is unchanged', async () => { + stubSettings({}, { value: 'completed', valueSource: 'processEnvValue', processEnvValue: 'completed' }); + + await ensureCloudWorkspaceRegistered(); + + expect(models.Settings.updateValueById.called).to.be.false; + }); + + it('should flip the setting when the value diverged from the env override', async () => { + stubSettings({}, { value: 'completed', valueSource: 'processEnvValue', processEnvValue: 'pending' }); + + await ensureCloudWorkspaceRegistered(); + + expect(models.Settings.updateValueById.calledOnceWith('Show_Setup_Wizard', 'in_progress')).to.be.true; + }); + + it('should not touch the setting when the workspace is already registered', async () => { + stubSettings( + { + Cloud_Workspace_Client_Id: 'client-id', + Cloud_Workspace_Client_Secret: 'client-secret', + }, + { value: 'completed', valueSource: 'packageValue' }, + ); + + await ensureCloudWorkspaceRegistered(); + + expect(models.Settings.updateValueById.called).to.be.false; + }); + + it('should not touch the setting when the setup wizard is not completed', async () => { + stubSettings({}, { value: 'in_progress', valueSource: 'packageValue' }); + + await ensureCloudWorkspaceRegistered(); + + expect(models.Settings.updateValueById.called).to.be.false; + }); + + it('should flip the setup wizard to in_progress when unregistered, completed and not env-pinned', async () => { + stubSettings({}, { value: 'completed', valueSource: 'packageValue' }); + + await ensureCloudWorkspaceRegistered(); + + expect(models.Settings.updateValueById.calledOnceWith('Show_Setup_Wizard', 'in_progress')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/startup/initialData.tests.ts b/apps/meteor/tests/unit/server/startup/initialData.tests.ts new file mode 100644 index 0000000..5e98070 --- /dev/null +++ b/apps/meteor/tests/unit/server/startup/initialData.tests.ts @@ -0,0 +1,232 @@ +import { expect } from 'chai'; +import { beforeEach, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const checkUsernameAvailability = sinon.stub(); +const validateEmail = sinon.stub(); +const addUserRolesAsync = sinon.stub(); +const models = { + Settings: {}, + Rooms: {}, + Users: { + create: sinon.stub(), + findOneByEmailAddress: sinon.stub(), + }, + Roles: { + countUsersInRole: sinon.stub(), + }, +}; +const setPasswordAsync = sinon.stub(); +const settingsGet = sinon.stub(); + +const { insertAdminUserFromEnv } = proxyquire.noCallThru().load('../../../../server/startup/initialData.js', { + 'meteor/accounts-base': { + Accounts: { + setPasswordAsync, + }, + }, + 'meteor/meteor': { + Meteor: { + startup: sinon.stub(), + }, + }, + '../lib/media/file-upload': {}, + '../lib/rooms/addUserToDefaultChannels': {}, + '../lib/users/checkUsernameAvailability': { + checkUsernameAvailability, + }, + '../settings': { + settings: { get: settingsGet }, + }, + '@rocket.chat/tools': { + validateEmail, + }, + '../lib/roles/addUserRoles': { + addUserRolesAsync, + }, + '@rocket.chat/models': models, +}); + +describe('insertAdminUserFromEnv', () => { + beforeEach(() => { + checkUsernameAvailability.reset(); + validateEmail.reset(); + addUserRolesAsync.reset(); + models.Users.create.reset(); + models.Users.findOneByEmailAddress.reset(); + setPasswordAsync.reset(); + settingsGet.reset(); + process.env.ADMIN_PASS = 'pass'; + models.Roles.countUsersInRole.reset(); + }); + + it('should do nothing if process.env.ADMIN_PASS is empty', async () => { + process.env.ADMIN_PASS = ''; + const result = await insertAdminUserFromEnv(); + expect(result).to.be.undefined; + }); + it('should do nothing if theres already an admin user', async () => { + models.Roles.countUsersInRole.resolves(1); + + const result = await insertAdminUserFromEnv(); + expect(models.Roles.countUsersInRole.called).to.be.true; + expect(validateEmail.called).to.be.false; + expect(result).to.be.undefined; + }); + it('should try to validate an email when process.env.ADMIN_EMAIL is set', async () => { + process.env.ADMIN_EMAIL = 'email'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(false); + models.Users.create.returns({ insertedId: 'newuserid' }); + + const result = await insertAdminUserFromEnv(); + expect(models.Roles.countUsersInRole.called).to.be.true; + expect(validateEmail.called).to.be.true; + expect(validateEmail.calledWith('email')).to.be.true; + expect(models.Users.create.called).to.be.true; + expect(setPasswordAsync.called).to.be.true; + expect(result).to.be.undefined; + }); + it('should override the admins name when process.env.ADMIN_NAME is set', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_NAME = 'name'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + validateEmail.returns(false); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect( + models.Users.create.calledWith( + sinon.match({ + name: 'name', + username: 'admin', + status: 'offline', + statusDefault: 'online', + utcOffset: 0, + active: true, + type: 'user', + }), + ), + ).to.be.true; + }); + it('should ignore the admin email when another user already has it set', async () => { + process.env.ADMIN_EMAIL = 'email'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns({ _id: 'someuser' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg).to.not.to.have.property('email'); + }); + it('should add the email from env when its valid and no users are using it', async () => { + process.env.ADMIN_EMAIL = 'email'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg) + .to.have.property('emails') + .to.deep.equal([{ address: 'email', verified: false }]); + }); + it('should mark the admin email as verified when process.env.ADMIN_EMAIL_VERIFIED is set to true', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_EMAIL_VERIFIED = 'true'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.getCall(0).firstArg) + .to.have.property('emails') + .to.deep.equal([{ address: 'email', verified: true }]); + }); + it('should validate a username with setting UTF8_User_Names_Validation when process.env.ADMIN_USERNAME is set', async () => { + process.env.ADMIN_USERNAME = '1234'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(checkUsernameAvailability.called).to.be.true; + }); + it('should override the username from admin if the env ADMIN_USERNAME is set, is valid and the username is available', async () => { + process.env.ADMIN_USERNAME = '1234'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: '1234' }))).to.be.true; + }); + it('should ignore the username when it does not pass setting regexp validation', async () => { + process.env.ADMIN_USERNAME = '1234'; + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + settingsGet.returns('[A-Z]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))).to.be.true; + }); + it('should call addUserRolesAsync as the last step when all data is valid and all overrides are valid', async () => { + process.env.ADMIN_EMAIL = 'email'; + process.env.ADMIN_NAME = 'name'; + process.env.ADMIN_USERNAME = '1234'; + process.env.ADMIN_EMAIL_VERIFIED = 'true'; + + models.Roles.countUsersInRole.resolves(0); + validateEmail.returns(true); + settingsGet.returns('[0-9]+'); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + models.Users.findOneByEmailAddress.returns(undefined); + + await insertAdminUserFromEnv(); + + expect(addUserRolesAsync.called).to.be.true; + expect(setPasswordAsync.called).to.be.true; + expect(models.Users.create.calledWith(sinon.match({ name: 'name', username: '1234', emails: [{ address: 'email', verified: true }] }))) + .to.be.true; + }); + it('should use the default nameValidation regex when the regex on the setting is invalid', async () => { + process.env.ADMIN_NAME = 'name'; + process.env.ADMIN_USERNAME = '$$$$$$'; + + models.Roles.countUsersInRole.resolves(0); + settingsGet.returns('['); + checkUsernameAvailability.returns(true); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))); + }); + it('should ignore the username when is not available', async () => { + process.env.ADMIN_USERNAME = '1234'; + + models.Roles.countUsersInRole.resolves(0); + checkUsernameAvailability.throws('some error'); + models.Users.create.returns({ insertedId: 'newuserid' }); + + await insertAdminUserFromEnv(); + + expect(models.Users.create.calledWith(sinon.match({ username: 'admin' }))).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts new file mode 100644 index 0000000..f82b372 --- /dev/null +++ b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// Create stubs for dependencies +const stubs = { + findOneUserById: sinon.stub(), + updateUsernameAndMessageOfMentionByIdAndOldUsername: sinon.stub(), + updateUsernameOfEditByUserId: sinon.stub(), + updateAllUsernamesByUserId: sinon.stub(), + updateDirectNameAndFnameByName: sinon.stub(), + updateUserReferences: sinon.stub(), + updateHistoryReferences: sinon.stub(), + setUsername: sinon.stub(), + setRealName: sinon.stub(), + validateName: sinon.stub(), + FileUpload: sinon.stub(), +}; + +const { saveUserIdentity } = proxyquire.noCallThru().load('../../../../server/lib/users/saveUserIdentity', { + '@rocket.chat/models': { + Users: { + findOneById: stubs.findOneUserById, + }, + Messages: { + updateUsernameAndMessageOfMentionByIdAndOldUsername: stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername, + updateUsernameOfEditByUserId: stubs.updateUsernameOfEditByUserId, + updateAllUsernamesByUserId: stubs.updateAllUsernamesByUserId, + }, + Subscriptions: { + updateDirectNameAndFnameByName: stubs.updateDirectNameAndFnameByName, + }, + VideoConference: { + updateUserReferences: stubs.updateUserReferences, + }, + CallHistory: { + updateUserReferences: stubs.updateHistoryReferences, + }, + }, + 'meteor/meteor': { + 'Meteor': sinon.stub(), + '@global': true, + }, + '../../database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() }, + '../media/file-upload': { + FileUpload: stubs.FileUpload, + }, + './setUsername': { + _setUsername: stubs.setUsername, + _setUsernameWithSession: () => stubs.setUsername, + }, + './setRealName': { + setRealName: stubs.setRealName, + }, + '../rooms/updateGroupDMsName': { + updateGroupDMsName: sinon.stub(), + }, + '../shared/validateName': { + validateName: stubs.validateName, + }, +}); + +describe('Users - saveUserIdentity', () => { + beforeEach(() => { + // Reset stubs before each test + Object.values(stubs).forEach((stub) => stub.reset()); + }); + + it('should return false if _id is not provided', async () => { + const result = await saveUserIdentity({ _id: undefined }); + + expect(stubs.findOneUserById.called).to.be.false; + expect(result).to.be.false; + }); + + it('should return false if user does not exist', async () => { + stubs.findOneUserById.returns(undefined); + const result = await saveUserIdentity({ _id: 'valid_id' }); + + expect(stubs.findOneUserById.calledWith('valid_id')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is not allowed', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'admin' }); + + expect(stubs.validateName.calledWith('admin')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is invalid or unavailable', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'invalidUsername' }); + + expect(stubs.validateName.calledWith('invalidUsername')).to.be.true; + expect(stubs.setUsername.calledWith('valid_id', 'invalidUsername', { username: 'oldUsername' })).to.be.true; + expect(result).to.be.false; + }); + + it("should not update the username if it's not changed", async () => { + stubs.findOneUserById.returns({ username: 'oldUsername', name: 'oldName' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(true); + await saveUserIdentity({ _id: 'valid_id', username: 'oldUsername', name: 'oldName' }); + + expect(stubs.validateName.called).to.be.false; + expect(stubs.setUsername.called).to.be.false; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateAllUsernamesByUserId.called).to.be.false; + expect(stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.false; + expect(stubs.updateUserReferences.called).to.be.false; + expect(stubs.updateHistoryReferences.called).to.be.false; + }); + + it('should update Subscriptions, VideoConference and Call History if name changes', async () => { + stubs.findOneUserById.returns({ name: 'oldName', username: 'oldUsername' }); + stubs.setRealName.returns(true); + const result = await saveUserIdentity({ _id: 'valid_id', name: 'name', username: 'oldUsername' }); + + expect(stubs.setUsername.called).to.be.false; + expect(stubs.setRealName.called).to.be.true; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.true; + expect(stubs.updateUserReferences.called).to.be.true; + expect(stubs.updateHistoryReferences.called).to.be.true; + expect(result).to.be.true; + }); +}); diff --git a/apps/meteor/tsconfig.json b/apps/meteor/tsconfig.json new file mode 100644 index 0000000..e6c0311 --- /dev/null +++ b/apps/meteor/tsconfig.json @@ -0,0 +1,54 @@ +{ + "extends": "@rocket.chat/tsconfig/base.json", + "compilerOptions": { + "target": "es2018", + "module": "esNext", + "lib": ["esnext", "dom"], + + "allowJs": true, + "checkJs": false, + "jsx": "react-jsx", + "noEmit": true, + + /* Strict Type-Checking Options */ + "strictPropertyInitialization": false, + + /* Additional Checks */ + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": false, + + /* Module Resolution Options */ + "baseUrl": ".", + "paths": { + /* Support absolute /imports/* with a leading '/' */ + "/*": ["*"], + /* No .meteor/local/types/packages.d.ts fallback: its `export =` module + declarations take over the module identity under TS7 and discard the + ambient merges from @types/meteor and definition/externals. Everything + it declared is covered by @types/meteor + definition/externals. */ + "meteor/*": ["./node_modules/@types/meteor/*"], + "swiper/modules/index.mjs": ["./node_modules/swiper/types/modules/index.d.ts"], + "swiper/swiper-react.mjs": ["./node_modules/swiper/swiper-react.d.ts"], + }, + "preserveSymlinks": true, + + // "sourceMap": true, + // "declaration": true, + // "removeComments": false, + // "emitDecoratorMetadata": true, + // "experimentalDecorators": true, + }, + "include": ["./**/*", "./.storybook/**/*", "./jest.config.ts", "./.scripts/**/*"], + "exclude": [ + "**/node_modules/**", + "./.meteor/**", + "./imports/client/**", + "**/dist/**", + "./public/**", + // "./ee/server/services/**" + ], + "ts-node": { + "files": false, + "swc": true, + }, +} diff --git a/apps/meteor/tsconfig.webpack.json b/apps/meteor/tsconfig.webpack.json new file mode 100644 index 0000000..1163d4a --- /dev/null +++ b/apps/meteor/tsconfig.webpack.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + }, + "exclude": [ + "./ee/server/services/**" + ] +} diff --git a/apps/uikit-playground/.gitignore b/apps/uikit-playground/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/uikit-playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/uikit-playground/CHANGELOG.md b/apps/uikit-playground/CHANGELOG.md new file mode 100644 index 0000000..557e04d --- /dev/null +++ b/apps/uikit-playground/CHANGELOG.md @@ -0,0 +1,3226 @@ +# @rocket.chat/uikit-playground + +## 0.7.16 + +### Patch Changes + +- ([#41653](https://github.com/RocketChat/Rocket.Chat/pull/41653)) Bumps patch-level dependency updates: `react`, `react-dom` and `react-is` to 19.2.8, `ws` to 8.21.3, `nodemailer` to 9.0.5, `mailparser` to 3.9.15, `google-libphonenumber` to 3.2.46, `twitter-api-v2` to 1.29.1, `ip-range-check` to 0.2.1, `esbuild` to 0.28.2, `@octokit/core` to 7.0.7 and `@octokit/plugin-throttling` to 11.0.5, plus build and test tooling (`@swc/core`, `ts-jest`, `rollup`, `html-webpack-plugin`, `@vitejs/plugin-react`, `@testing-library/user-event`, and the `@types/*` packages). No behavior changes are expected. + +-
Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba]: + + - @rocket.chat/core-typings@8.8.0 + - @rocket.chat/fuselage-ui-kit@34.0.0 + - @rocket.chat/ui-avatar@30.0.0 + - @rocket.chat/ui-contexts@34.0.0 +
+ +## 0.7.16-rc.0 + +### Patch Changes + +- ([#41653](https://github.com/RocketChat/Rocket.Chat/pull/41653)) Bumps patch-level dependency updates: `react`, `react-dom` and `react-is` to 19.2.8, `ws` to 8.21.3, `nodemailer` to 9.0.5, `mailparser` to 3.9.15, `google-libphonenumber` to 3.2.46, `twitter-api-v2` to 1.29.1, `ip-range-check` to 0.2.1, `esbuild` to 0.28.2, `@octokit/core` to 7.0.7 and `@octokit/plugin-throttling` to 11.0.5, plus build and test tooling (`@swc/core`, `ts-jest`, `rollup`, `html-webpack-plugin`, `@vitejs/plugin-react`, `@testing-library/user-event`, and the `@types/*` packages). No behavior changes are expected. + +-
Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba]: + + - @rocket.chat/core-typings@8.8.0-rc.0 + - @rocket.chat/ui-contexts@34.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@34.0.0-rc.0 + - @rocket.chat/ui-avatar@30.0.0-rc.0 +
+ +## 0.7.15 + +### Patch Changes + +-
Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 8d8cd01d0a4e6872ed543320c966efd52140e884, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17]: + + - @rocket.chat/core-typings@8.7.0 + - @rocket.chat/fuselage-ui-kit@33.0.0 + - @rocket.chat/ui-avatar@29.0.0 + - @rocket.chat/ui-contexts@33.0.0 +
+ +## 0.7.15-rc.0 + +### Patch Changes + +-
Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 8d8cd01d0a4e6872ed543320c966efd52140e884, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17]: + + - @rocket.chat/core-typings@8.7.0-rc.0 + - @rocket.chat/fuselage-ui-kit@33.0.0-rc.0 + - @rocket.chat/ui-contexts@33.0.0-rc.0 + - @rocket.chat/ui-avatar@29.0.0-rc.0 +
+ +## 0.7.14 + +### Patch Changes + +-
Updated dependencies [7380c44c751eff9ee624d80bf26370411ffed78b, 6bd9182ae1d914a55e70866db43e8d2038f7be28, 6fa5378a940cbc809800b3c7d7c0639810bb0ab8, f63b965f82b0ddc590c633706f7c31c8c5251b53]: + + - @rocket.chat/core-typings@8.6.0 + - @rocket.chat/fuselage-ui-kit@32.0.0 + - @rocket.chat/ui-avatar@28.0.0 + - @rocket.chat/ui-contexts@32.0.0 +
+ +## 0.7.14-rc.0 + +### Patch Changes + +-
Updated dependencies [7380c44c751eff9ee624d80bf26370411ffed78b, 6bd9182ae1d914a55e70866db43e8d2038f7be28, f63b965f82b0ddc590c633706f7c31c8c5251b53]: + + - @rocket.chat/core-typings@8.6.0-rc.0 + - @rocket.chat/ui-contexts@32.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@32.0.0-rc.0 + - @rocket.chat/ui-avatar@28.0.0-rc.0 +
+ +## 0.7.13 + +### Patch Changes + +-
Updated dependencies [12897e25d0dc25b7373f5264d38f38a5a7444257]: + + - @rocket.chat/core-typings@8.5.0 + - @rocket.chat/fuselage-ui-kit@31.0.0 + - @rocket.chat/ui-avatar@27.0.0 + - @rocket.chat/ui-contexts@31.0.0 +
+ +## 0.7.13-rc.0 + +### Patch Changes + +-
Updated dependencies [ae9f740d6af20557eac61b4af902c868b4132b49, 12897e25d0dc25b7373f5264d38f38a5a7444257]: + + - @rocket.chat/core-typings@8.5.0-rc.0 + - @rocket.chat/fuselage-ui-kit@31.0.0-rc.0 + - @rocket.chat/ui-contexts@31.0.0-rc.0 + - @rocket.chat/ui-avatar@27.0.0-rc.0 +
+ +## 0.7.12 + +### Patch Changes + +-
Updated dependencies [278b84f78360e53792a2e5d7620615039a0e15e9, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 8c0e16ca29b393cfa50b425520db48ba5a74f678]: + + - @rocket.chat/core-typings@8.4.0 + - @rocket.chat/fuselage-ui-kit@30.0.0 + - @rocket.chat/ui-avatar@26.0.0 + - @rocket.chat/ui-contexts@30.0.0 +
+ +## 0.7.12-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.4.0-rc.2 + - @rocket.chat/fuselage-ui-kit@30.0.0-rc.2 + - @rocket.chat/ui-avatar@26.0.0-rc.2 + - @rocket.chat/ui-contexts@30.0.0-rc.2 +
+ +## 0.7.12-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@30.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@30.0.0-rc.1 + - @rocket.chat/ui-avatar@26.0.0-rc.1 + - @rocket.chat/core-typings@8.4.0-rc.1 +
+ +## 0.7.11 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.2 + - @rocket.chat/fuselage-ui-kit@29.0.2 + - @rocket.chat/ui-avatar@25.0.2 + - @rocket.chat/ui-contexts@29.0.2 +
+ +## 0.7.10 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.1 + - @rocket.chat/fuselage-ui-kit@29.0.1 + - @rocket.chat/ui-avatar@25.0.1 + - @rocket.chat/ui-contexts@29.0.1 +
+ +## 0.7.12-rc.0 + +### Patch Changes + +-
Updated dependencies [278b84f78360e53792a2e5d7620615039a0e15e9, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 8c0e16ca29b393cfa50b425520db48ba5a74f678]: + + - @rocket.chat/core-typings@8.4.0-rc.0 + - @rocket.chat/fuselage-ui-kit@30.0.0-rc.0 + - @rocket.chat/ui-avatar@26.0.0-rc.0 + - @rocket.chat/ui-contexts@30.0.0-rc.0 +
+ +## 0.7.9 + +### Patch Changes + +- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration + +-
Updated dependencies [87f9262af4a543d52642a54e1ef546d509a79e23, 539659af22bc19880eda047dfc0b152472ccb65c, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 43d0cfc6a70e8a31d5f3d24162216dae6b07efdd, d83a1a9753464ee916845b3c88757bbcf76884a5, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, c117492ad90d291a361eedc929506f557495caf7]: + + - @rocket.chat/fuselage-ui-kit@29.0.0 + - @rocket.chat/core-typings@8.3.0 + - @rocket.chat/ui-contexts@29.0.0 + - @rocket.chat/ui-avatar@25.0.0 +
+ +## 0.7.9-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.4 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.4 + - @rocket.chat/ui-avatar@25.0.0-rc.4 + - @rocket.chat/ui-contexts@29.0.0-rc.4 +
+ +## 0.7.9-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.3 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.3 + - @rocket.chat/ui-avatar@25.0.0-rc.3 + - @rocket.chat/ui-contexts@29.0.0-rc.3 +
+ +## 0.7.9-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.2 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.2 + - @rocket.chat/ui-avatar@25.0.0-rc.2 + - @rocket.chat/ui-contexts@29.0.0-rc.2 +
+ +## 0.7.9-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.3.0-rc.1 + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.1 + - @rocket.chat/ui-avatar@25.0.0-rc.1 + - @rocket.chat/ui-contexts@29.0.0-rc.1 +
+ +## 0.7.9-rc.0 + +### Patch Changes + +- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration + +-
Updated dependencies [87f9262af4a543d52642a54e1ef546d509a79e23, 539659af22bc19880eda047dfc0b152472ccb65c, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 43d0cfc6a70e8a31d5f3d24162216dae6b07efdd, d83a1a9753464ee916845b3c88757bbcf76884a5, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, c117492ad90d291a361eedc929506f557495caf7]: + + - @rocket.chat/fuselage-ui-kit@29.0.0-rc.0 + - @rocket.chat/core-typings@8.3.0-rc.0 + - @rocket.chat/ui-contexts@29.0.0-rc.0 + - @rocket.chat/ui-avatar@25.0.0-rc.0 +
+ +## 0.7.8 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.2.1 + - @rocket.chat/fuselage-ui-kit@28.0.1 + - @rocket.chat/ui-avatar@24.0.1 + - @rocket.chat/ui-contexts@28.0.1 +
+ +## 0.7.7 + +### Patch Changes + +-
Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34]: + + - @rocket.chat/core-typings@8.2.0 + - @rocket.chat/fuselage-ui-kit@28.0.0 + - @rocket.chat/ui-avatar@24.0.0 + - @rocket.chat/ui-contexts@28.0.0 +
+ +## 0.7.7-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.2.0-rc.2 + - @rocket.chat/fuselage-ui-kit@28.0.0-rc.2 + - @rocket.chat/ui-avatar@24.0.0-rc.2 + - @rocket.chat/ui-contexts@28.0.0-rc.2 +
+ +## 0.7.7-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.2.0-rc.1 + - @rocket.chat/fuselage-ui-kit@28.0.0-rc.1 + - @rocket.chat/ui-avatar@24.0.0-rc.1 + - @rocket.chat/ui-contexts@28.0.0-rc.1 +
+ +## 0.7.7-rc.0 + +### Patch Changes + +-
Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34]: + + - @rocket.chat/core-typings@8.2.0-rc.0 + - @rocket.chat/fuselage-ui-kit@28.0.0-rc.0 + - @rocket.chat/ui-avatar@24.0.0-rc.0 + - @rocket.chat/ui-contexts@28.0.0-rc.0 +
+ +## 0.7.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.1.1 + - @rocket.chat/fuselage-ui-kit@27.0.1 + - @rocket.chat/ui-avatar@23.0.1 + - @rocket.chat/ui-contexts@27.0.1 +
+ +## 0.7.5 + +### Patch Changes + +-
Updated dependencies [6654c5b481f91bdcb03d68ee0f3a12d58201137e]: + + - @rocket.chat/core-typings@8.1.0 + - @rocket.chat/ui-contexts@27.0.0 + - @rocket.chat/fuselage-ui-kit@27.0.0 + - @rocket.chat/ui-avatar@23.0.0 +
+ +## 0.7.5-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.1.0-rc.2 + - @rocket.chat/fuselage-ui-kit@27.0.0-rc.2 + - @rocket.chat/ui-avatar@23.0.0-rc.2 + - @rocket.chat/ui-contexts@27.0.0-rc.2 +
+ +## 0.7.5-rc.1 + +### Patch Changes + +-
Updated dependencies []: +- @rocket.chat/ui-contexts@27.0.0-rc.1 +- @rocket.chat/fuselage-ui-kit@27.0.0-rc.1 +- @rocket.chat/ui-avatar@23.0.0-rc.1 +- @rocket.chat/core-typings@8.1.0-rc.1 +
+ +## 0.7.5-rc.0 + +### Patch Changes + +-
Updated dependencies [6654c5b481f91bdcb03d68ee0f3a12d58201137e]: + + - @rocket.chat/core-typings@8.1.0-rc.0 + - @rocket.chat/ui-contexts@27.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@27.0.0-rc.0 + - @rocket.chat/ui-avatar@23.0.0-rc.0 +
+ +## 0.7.4 + +### Patch Changes + +-
Updated dependencies []: +- @rocket.chat/core-typings@8.0.1 + - @rocket.chat/fuselage-ui-kit@26.0.1 + - @rocket.chat/ui-avatar@22.0.1 + - @rocket.chat/ui-contexts@26.0.1 +
+ +## 0.7.3 + +### Patch Changes + +-
Updated dependencies [176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 94b87d9ef40647d77fe83f3f84dca46a94515b39, ddc935727e9a7275813006d9dcaa7fe866610844, d3538e7045c41f91b8c561d44e5485ff93b93745, 73d9eb2783176954f42aa2cbeda8abf1d49ac260]: + + - @rocket.chat/core-typings@8.0.0 + - @rocket.chat/ui-contexts@26.0.0 + - @rocket.chat/fuselage-ui-kit@26.0.0 + - @rocket.chat/ui-avatar@22.0.0 +
+ +## 0.7.3-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.0.0-rc.5 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.5 + - @rocket.chat/ui-avatar@22.0.0-rc.5 + - @rocket.chat/ui-contexts@26.0.0-rc.5 +
+ +## 0.7.3-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.0.0-rc.4 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.4 + - @rocket.chat/ui-avatar@22.0.0-rc.4 + - @rocket.chat/ui-contexts@26.0.0-rc.4 +
+ +## 0.7.3-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@26.0.0-rc.3 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.3 + - @rocket.chat/ui-avatar@22.0.0-rc.3 + - @rocket.chat/core-typings@8.0.0-rc.3 +
+ +## 0.7.3-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.2 + - @rocket.chat/ui-avatar@22.0.0-rc.2 + - @rocket.chat/ui-contexts@26.0.0-rc.2 +
+ +## 0.7.3-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@8.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.1 + - @rocket.chat/ui-avatar@22.0.0-rc.1 + - @rocket.chat/ui-contexts@26.0.0-rc.1 +
+ +## 0.7.3-rc.0 + +### Patch Changes + +-
Updated dependencies [176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 94b87d9ef40647d77fe83f3f84dca46a94515b39, ddc935727e9a7275813006d9dcaa7fe866610844, d3538e7045c41f91b8c561d44e5485ff93b93745, 73d9eb2783176954f42aa2cbeda8abf1d49ac260]: + + - @rocket.chat/core-typings@8.0.0-rc.0 + - @rocket.chat/ui-contexts@26.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@26.0.0-rc.0 + - @rocket.chat/ui-avatar@22.0.0-rc.0 +
+ +## 0.7.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.13.2 + - @rocket.chat/fuselage-ui-kit@25.0.2 + - @rocket.chat/ui-avatar@21.0.2 + - @rocket.chat/ui-contexts@25.0.2 +
+ +## 0.7.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.13.1 + - @rocket.chat/fuselage-ui-kit@25.0.1 + - @rocket.chat/ui-avatar@21.0.1 + - @rocket.chat/ui-contexts@25.0.1 +
+ +## 0.7.0 + +### Minor Changes + +- ([#37398](https://github.com/RocketChat/Rocket.Chat/pull/37398)) Adds new elements for building apps (ui-kit): `icon` (block element), `icon_button` (block element) and `info_card` (layout block). + +### Patch Changes + +-
Updated dependencies [31ab78f0d4648b2ba99513ffce737d52ee34fe7f, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 44ca3b111f13ac1816a82ab0e4720e9886769c34, 99255057f27b9d854a89da815681a03d0776f846, 65fbcbed9f64004b953dd9d4182b3fccb8147339]: + + - @rocket.chat/ui-contexts@25.0.0 + - @rocket.chat/core-typings@7.13.0 + - @rocket.chat/fuselage-ui-kit@25.0.0 + - @rocket.chat/ui-avatar@21.0.0 +
+ +## 0.7.0-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.13.0-rc.2 + - @rocket.chat/fuselage-ui-kit@25.0.0-rc.2 + - @rocket.chat/ui-avatar@21.0.0-rc.2 + - @rocket.chat/ui-contexts@25.0.0-rc.2 +
+ +## 0.7.0-rc.1 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.13.0-rc.1 + - @rocket.chat/fuselage-ui-kit@25.0.0-rc.1 + - @rocket.chat/ui-avatar@21.0.0-rc.1 + - @rocket.chat/ui-contexts@25.0.0-rc.1 +
+ +## 0.7.0-rc.0 + +### Minor Changes + +- ([#37398](https://github.com/RocketChat/Rocket.Chat/pull/37398)) Adds new elements for building apps (ui-kit): `icon` (block element), `icon_button` (block element) and `info_card` (layout block). + +### Patch Changes + +-
Updated dependencies [31ab78f0d4648b2ba99513ffce737d52ee34fe7f, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 44ca3b111f13ac1816a82ab0e4720e9886769c34, 99255057f27b9d854a89da815681a03d0776f846, 65fbcbed9f64004b953dd9d4182b3fccb8147339]: + + - @rocket.chat/ui-contexts@25.0.0-rc.0 + - @rocket.chat/core-typings@7.13.0-rc.0 + - @rocket.chat/fuselage-ui-kit@25.0.0-rc.0 + - @rocket.chat/ui-avatar@21.0.0-rc.0 + +## 0.6.34 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.12.2 + - @rocket.chat/fuselage-ui-kit@24.0.2 + - @rocket.chat/ui-avatar@20.0.2 + - @rocket.chat/ui-contexts@24.0.2 +
+ +## 0.6.33 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.12.1 + - @rocket.chat/fuselage-ui-kit@24.0.1 + - @rocket.chat/ui-avatar@20.0.1 + - @rocket.chat/ui-contexts@24.0.1 +
+ +## 0.6.32 + +### Patch Changes + +-
Updated dependencies [a25e88c06cafdbe3baf7a40e3bffe3aed5a81cf6, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f]: + + - @rocket.chat/ui-contexts@24.0.0 + - @rocket.chat/core-typings@7.12.0 + - @rocket.chat/fuselage-ui-kit@24.0.0 + - @rocket.chat/ui-avatar@20.0.0 +
+ +## 0.6.32-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.12.0-rc.4 + - @rocket.chat/fuselage-ui-kit@24.0.0-rc.4 + - @rocket.chat/ui-avatar@20.0.0-rc.4 + - @rocket.chat/ui-contexts@24.0.0-rc.4 +
+ +## 0.6.32-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.12.0-rc.3 + - @rocket.chat/fuselage-ui-kit@24.0.0-rc.3 + - @rocket.chat/ui-avatar@20.0.0-rc.3 + - @rocket.chat/ui-contexts@24.0.0-rc.3 +
+ +## 0.6.32-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.12.0-rc.2 + - @rocket.chat/fuselage-ui-kit@24.0.0-rc.2 + - @rocket.chat/ui-avatar@20.0.0-rc.2 + - @rocket.chat/ui-contexts@24.0.0-rc.2 +
+ +## 0.6.32-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.12.0-rc.1 + - @rocket.chat/fuselage-ui-kit@24.0.0-rc.1 + - @rocket.chat/ui-avatar@20.0.0-rc.1 + - @rocket.chat/ui-contexts@24.0.0-rc.1 +
+ +## 0.6.32-rc.0 + +### Patch Changes + +-
Updated dependencies [a25e88c06cafdbe3baf7a40e3bffe3aed5a81cf6, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f]: + + - @rocket.chat/ui-contexts@24.0.0-rc.0 + - @rocket.chat/core-typings@7.12.0-rc.0 + - @rocket.chat/fuselage-ui-kit@24.0.0-rc.0 + - @rocket.chat/ui-avatar@20.0.0-rc.0 +
+ +## 0.6.31 + +### Patch Changes + +- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat + +-
Updated dependencies [feba290fc403de27efb6bb9a571bec43413e976a, b0a4602a4461200b9872b2b073ec56fa55ecb466, c6ef437d9071dbd8c08152984dc39542b1ae7306]: + + - @rocket.chat/ui-contexts@23.0.0 + - @rocket.chat/fuselage-ui-kit@23.0.0 + - @rocket.chat/ui-avatar@19.0.0 + - @rocket.chat/core-typings@7.11.0 +
+ +## 0.6.31-rc.7 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.7 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.7 + - @rocket.chat/ui-avatar@19.0.0-rc.7 + - @rocket.chat/ui-contexts@23.0.0-rc.7 +
+ +## 0.6.31-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.6 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.6 + - @rocket.chat/ui-avatar@19.0.0-rc.6 + - @rocket.chat/ui-contexts@23.0.0-rc.6 +
+ +## 0.6.30-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.5 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.5 + - @rocket.chat/ui-avatar@19.0.0-rc.5 + - @rocket.chat/ui-contexts@23.0.0-rc.5 +
+ +## 0.6.30-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.4 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.4 + - @rocket.chat/ui-avatar@19.0.0-rc.4 + - @rocket.chat/ui-contexts@23.0.0-rc.4 +
+ +## 0.6.30-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.3 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.3 + - @rocket.chat/ui-avatar@19.0.0-rc.3 + - @rocket.chat/ui-contexts@23.0.0-rc.3 +
+ +## 0.6.30-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.2 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.2 + - @rocket.chat/ui-avatar@19.0.0-rc.2 + - @rocket.chat/ui-contexts@23.0.0-rc.2 +
+ +## 0.6.29-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.11.0-rc.1 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.1 + - @rocket.chat/ui-avatar@19.0.0-rc.1 + - @rocket.chat/ui-contexts@23.0.0-rc.1 +
+ +## 0.6.29-rc.0 + +### Patch Changes + +- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat + +-
Updated dependencies [feba290fc403de27efb6bb9a571bec43413e976a, b0a4602a4461200b9872b2b073ec56fa55ecb466, c6ef437d9071dbd8c08152984dc39542b1ae7306]: + + - @rocket.chat/ui-contexts@23.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@23.0.0-rc.0 + - @rocket.chat/ui-avatar@19.0.0-rc.0 + - @rocket.chat/core-typings@7.11.0-rc.0 +
+ +## 0.6.30 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.2 + - @rocket.chat/fuselage-ui-kit@22.0.2 + - @rocket.chat/ui-avatar@18.0.2 + - @rocket.chat/ui-contexts@22.0.2 +
+ +## 0.6.29 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.10.1 + - @rocket.chat/fuselage-ui-kit@22.0.1 + - @rocket.chat/ui-avatar@18.0.1 + - @rocket.chat/ui-contexts@22.0.1 +
+ +## 0.6.28 + +### Patch Changes + +-
Updated dependencies [17bca96ecbf23ea807aba2e6e8abc95ebd66b0d0, c7db598e9f3c2ad47f6a6be2a9ba7078533c245b]: + + - @rocket.chat/core-typings@7.10.0 + - @rocket.chat/ui-contexts@22.0.0 + - @rocket.chat/fuselage-ui-kit@22.0.0 + - @rocket.chat/ui-avatar@18.0.0 +
+ +## 0.6.28-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.6 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.6 + - @rocket.chat/ui-avatar@18.0.0-rc.6 + - @rocket.chat/ui-contexts@22.0.0-rc.6 +
+ +## 0.6.28-rc.5 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.10.0-rc.5 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.5 + - @rocket.chat/ui-avatar@18.0.0-rc.5 + - @rocket.chat/ui-contexts@22.0.0-rc.5 +
+ +## 0.6.25-rc.4 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.10.0-rc.4 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.4 + - @rocket.chat/ui-avatar@18.0.0-rc.4 + - @rocket.chat/ui-contexts@22.0.0-rc.4 +
+ +## 0.6.25-rc.3 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.10.0-rc.3 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.3 + - @rocket.chat/ui-avatar@18.0.0-rc.3 + - @rocket.chat/ui-contexts@22.0.0-rc.3 +
+ +## 0.6.25-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.2 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.2 + - @rocket.chat/ui-avatar@18.0.0-rc.2 + - @rocket.chat/ui-contexts@22.0.0-rc.2 +
+ +## 0.6.25-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.10.0-rc.1 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.1 + - @rocket.chat/ui-avatar@18.0.0-rc.1 + - @rocket.chat/ui-contexts@22.0.0-rc.1 +
+ +## 0.6.25-rc.0 + +### Patch Changes + +-
Updated dependencies [17bca96ecbf23ea807aba2e6e8abc95ebd66b0d0, c7db598e9f3c2ad47f6a6be2a9ba7078533c245b]: + + - @rocket.chat/core-typings@7.10.0-rc.0 + - @rocket.chat/ui-contexts@22.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@22.0.0-rc.0 + - @rocket.chat/ui-avatar@18.0.0-rc.0 +
+ +## 0.6.27 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.9.3 + - @rocket.chat/fuselage-ui-kit@21.0.3 + - @rocket.chat/ui-avatar@17.0.3 + - @rocket.chat/ui-contexts@21.0.3 +
+ +## 0.6.26 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.9.2 + - @rocket.chat/fuselage-ui-kit@21.0.2 + - @rocket.chat/ui-avatar@17.0.2 + - @rocket.chat/ui-contexts@21.0.2 +
+ +## 0.6.25 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.9.1 + - @rocket.chat/fuselage-ui-kit@21.0.1 + - @rocket.chat/ui-avatar@17.0.1 + - @rocket.chat/ui-contexts@21.0.1 +
+ +## 0.6.24 + +### Patch Changes + +-
Updated dependencies [2cec8acd5beddf5ad0c67c29fe632487cb82b026, fd478a7d45a4505ad53d2d7aec8b44e9bf8fa41a]: + + - @rocket.chat/core-typings@7.9.0 + - @rocket.chat/ui-contexts@21.0.0 + - @rocket.chat/fuselage-ui-kit@21.0.0 + - @rocket.chat/ui-avatar@17.0.0 +
+ +## 0.6.24-rc.2 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.9.0-rc.2 + - @rocket.chat/fuselage-ui-kit@21.0.0-rc.2 + - @rocket.chat/ui-avatar@17.0.0-rc.2 + - @rocket.chat/ui-contexts@21.0.0-rc.2 +
+ +## 0.6.24-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.9.0-rc.1 + - @rocket.chat/fuselage-ui-kit@21.0.0-rc.1 + - @rocket.chat/ui-avatar@17.0.0-rc.1 + - @rocket.chat/ui-contexts@21.0.0-rc.1 +
+ +## 0.6.24-rc.0 + +### Patch Changes + +-
Updated dependencies [2cec8acd5beddf5ad0c67c29fe632487cb82b026, fd478a7d45a4505ad53d2d7aec8b44e9bf8fa41a]: + + - @rocket.chat/core-typings@7.9.0-rc.0 + - @rocket.chat/ui-contexts@21.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@21.0.0-rc.0 + - @rocket.chat/ui-avatar@17.0.0-rc.0 +
+ +## 0.6.23 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.3 + - @rocket.chat/fuselage-ui-kit@20.0.3 + - @rocket.chat/ui-contexts@20.0.3 + - @rocket.chat/ui-avatar@16.0.3 +
+ +## 0.6.22 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.2 + - @rocket.chat/fuselage-ui-kit@20.0.2 + - @rocket.chat/ui-contexts@20.0.2 + - @rocket.chat/ui-avatar@16.0.2 +
+ +## 0.6.21 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.1 + - @rocket.chat/fuselage-ui-kit@20.0.1 + - @rocket.chat/ui-contexts@20.0.1 + - @rocket.chat/ui-avatar@16.0.1 +
+ +## 0.6.20 + +### Patch Changes + +-
Updated dependencies [3d024a900426c8bbf646e7ebedce0e17c9f7c140, 3779de0e8c5787f266bdeda5052b27c023c65f1c]: + + - @rocket.chat/core-typings@7.8.0 + - @rocket.chat/ui-contexts@20.0.0 + - @rocket.chat/fuselage-ui-kit@20.0.0 + - @rocket.chat/ui-avatar@16.0.0 +
+ +## 0.6.20-rc.9 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.9 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.9 + - @rocket.chat/ui-contexts@20.0.0-rc.9 + - @rocket.chat/ui-avatar@16.0.0-rc.9 +
+ +## 0.6.17-rc.8 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.8 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.8 + - @rocket.chat/ui-contexts@20.0.0-rc.8 + - @rocket.chat/ui-avatar@16.0.0-rc.8 +
+ +## 0.6.17-rc.7 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.7 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.7 + - @rocket.chat/ui-contexts@20.0.0-rc.7 + - @rocket.chat/ui-avatar@16.0.0-rc.7 +
+ +## 0.6.17-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.6 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.6 + - @rocket.chat/ui-contexts@20.0.0-rc.6 + - @rocket.chat/ui-avatar@16.0.0-rc.6 +
+ +## 0.6.17-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.5 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.5 + - @rocket.chat/ui-contexts@20.0.0-rc.5 + - @rocket.chat/ui-avatar@16.0.0-rc.5 +
+ +## 0.6.17-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.4 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.4 + - @rocket.chat/ui-contexts@20.0.0-rc.4 + - @rocket.chat/ui-avatar@16.0.0-rc.4 +
+ +## 0.6.17-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@20.0.0-rc.3 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.3 + - @rocket.chat/ui-avatar@16.0.0-rc.3 + - @rocket.chat/core-typings@7.8.0-rc.3 +
+ +## 0.6.17-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.8.0-rc.2 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.2 + - @rocket.chat/ui-contexts@20.0.0-rc.2 + - @rocket.chat/ui-avatar@16.0.0-rc.2 +
+ +## 0.6.17-rc.1 + +## 0.6.20-rc.1 + +### Patch Changes + +- @rocket.chat/core-typings@7.8.0-rc.1 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.1 + - @rocket.chat/ui-contexts@20.0.0-rc.1 + - @rocket.chat/ui-avatar@16.0.0-rc.1 +
+ +## 0.6.17-rc.0 + +### Patch Changes + +-
Updated dependencies [3d024a900426c8bbf646e7ebedce0e17c9f7c140, 3779de0e8c5787f266bdeda5052b27c023c65f1c]: + + - @rocket.chat/core-typings@7.8.0-rc.0 + - @rocket.chat/ui-contexts@20.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@20.0.0-rc.0 + - @rocket.chat/ui-avatar@16.0.0-rc.0 + +-
Updated dependencies []: +
+ +## 0.6.19 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.7.4 + - @rocket.chat/fuselage-ui-kit@19.0.4 + - @rocket.chat/ui-contexts@19.0.4 + - @rocket.chat/ui-avatar@15.0.4 +
+ +## 0.6.18 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.7.3 + - @rocket.chat/fuselage-ui-kit@19.0.3 + - @rocket.chat/ui-contexts@19.0.3 + - @rocket.chat/ui-avatar@15.0.3 +
+ +## 0.6.17 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@19.0.2 + - @rocket.chat/fuselage-ui-kit@19.0.2 + - @rocket.chat/ui-avatar@15.0.2 + - @rocket.chat/core-typings@7.7.2 +
+ +## 0.6.16 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@19.0.1 + - @rocket.chat/fuselage-ui-kit@19.0.1 + - @rocket.chat/ui-avatar@15.0.1 + - @rocket.chat/core-typings@7.7.1 +
+ +## 0.6.15 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@19.0.0 + - @rocket.chat/core-typings@7.7.0 + - @rocket.chat/fuselage-ui-kit@19.0.0 + - @rocket.chat/ui-avatar@15.0.0 +
+ +## 0.6.15-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.7.0-rc.6 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.6 + - @rocket.chat/ui-contexts@19.0.0-rc.6 + - @rocket.chat/ui-avatar@15.0.0-rc.6 +
+ +## 0.6.15-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.7.0-rc.5 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.5 + - @rocket.chat/ui-contexts@19.0.0-rc.5 + - @rocket.chat/ui-avatar@15.0.0-rc.5 +
+ +## 0.6.15-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.7.1-rc.4 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.4 + - @rocket.chat/ui-contexts@19.0.0-rc.4 + - @rocket.chat/ui-avatar@15.0.0-rc.4 +
+ +## 0.6.15-rc.3 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.7.0-rc.3 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.3 + - @rocket.chat/ui-contexts@19.0.0-rc.3 + - @rocket.chat/ui-avatar@15.0.0-rc.3 +
+ +## 0.6.15-rc.2 + +### Patch Changes + +-
Updated dependencies []: +- @rocket.chat/core-typings@7.7.0-rc.2 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.2 + - @rocket.chat/ui-contexts@19.0.0-rc.2 + - @rocket.chat/ui-avatar@15.0.0-rc.2 +
+ +## 0.6.15-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.7.0-rc.1 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.1 + - @rocket.chat/ui-contexts@19.0.0-rc.1 + - @rocket.chat/ui-avatar@15.0.0-rc.1 +
+ +## 0.6.15-rc.0 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@19.0.0-rc.0 + - @rocket.chat/core-typings@7.7.0-rc.0 + - @rocket.chat/fuselage-ui-kit@19.0.0-rc.0 + - @rocket.chat/ui-avatar@15.0.0-rc.0 +
+ +## 0.6.14 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.6.2 + - @rocket.chat/fuselage-ui-kit@18.0.2 + - @rocket.chat/ui-contexts@18.0.2 + - @rocket.chat/ui-avatar@14.0.2 +
+ +## 0.6.13 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.6.1 + - @rocket.chat/fuselage-ui-kit@18.0.1 + - @rocket.chat/ui-contexts@18.0.1 + - @rocket.chat/ui-avatar@14.0.1 +
+ +## 0.6.12 + +### Patch Changes + +-
Updated dependencies [aec9eaa941fe9dad81f38d8d18d1b58edd700eb1, 2c190740d0ff166a4cefe8e833b0b2682a41fab1, 1eeb139158fcd621a2b8d3a7de5bb512e659261d, d8eb824d242cbbeafb11b1c4a806860e4541ba79, bbd0b0d9ed181a156430e2a446d3b56092e3f645, 47ae69912cd90743e7bf836fdee4be481a01bbba, 4b28126ac94cf1d3312b30ad9863ca02673f49d4, 4690c55d8e379d0bd5dfa444f3e0a4175e88d8de]: + + - @rocket.chat/core-typings@7.6.0 + - @rocket.chat/ui-contexts@18.0.0 + - @rocket.chat/fuselage-ui-kit@18.0.0 + - @rocket.chat/ui-avatar@14.0.0 +
+ +## 0.6.12-rc.8 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.8 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.8 + - @rocket.chat/ui-contexts@18.0.0-rc.8 + - @rocket.chat/ui-avatar@14.0.0-rc.8 +
+ +## 0.6.12-rc.7 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.7 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.7 + - @rocket.chat/ui-contexts@18.0.0-rc.7 + - @rocket.chat/ui-avatar@14.0.0-rc.7 +
+ +## 0.6.12-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.6 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.6 + - @rocket.chat/ui-contexts@18.0.0-rc.6 + - @rocket.chat/ui-avatar@14.0.0-rc.6 +
+ +## 0.6.12-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.5 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.5 + - @rocket.chat/ui-contexts@18.0.0-rc.5 + - @rocket.chat/ui-avatar@14.0.0-rc.5 +
+ +## 0.6.12-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.4 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.4 + - @rocket.chat/ui-contexts@18.0.0-rc.4 + - @rocket.chat/ui-avatar@14.0.0-rc.4 +
+ +## 0.6.12-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.3 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.3 + - @rocket.chat/ui-contexts@18.0.0-rc.3 + - @rocket.chat/ui-avatar@14.0.0-rc.3 +
+ +## 0.6.12-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.2 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.2 + - @rocket.chat/ui-contexts@18.0.0-rc.2 + - @rocket.chat/ui-avatar@14.0.0-rc.2 +
+ +## 0.6.12-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.6.0-rc.1 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.1 + - @rocket.chat/ui-contexts@18.0.0-rc.1 + - @rocket.chat/ui-avatar@14.0.0-rc.1 +
+ +## 0.6.12-rc.0 + +### Patch Changes + +-
Updated dependencies [aec9eaa941fe9dad81f38d8d18d1b58edd700eb1, 2c190740d0ff166a4cefe8e833b0b2682a41fab1, 1eeb139158fcd621a2b8d3a7de5bb512e659261d, d8eb824d242cbbeafb11b1c4a806860e4541ba79, bbd0b0d9ed181a156430e2a446d3b56092e3f645, 47ae69912cd90743e7bf836fdee4be481a01bbba, 4b28126ac94cf1d3312b30ad9863ca02673f49d4, 4690c55d8e379d0bd5dfa444f3e0a4175e88d8de]: + + - @rocket.chat/core-typings@7.6.0-rc.0 + - @rocket.chat/ui-contexts@18.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@18.0.0-rc.0 + - @rocket.chat/ui-avatar@14.0.0-rc.0 +
+ +## 0.6.11 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.5.1 + - @rocket.chat/fuselage-ui-kit@17.0.1 + - @rocket.chat/ui-contexts@17.0.1 + - @rocket.chat/ui-avatar@13.0.1 +
+ +## 0.6.10 + +### Patch Changes + +- ([#35286](https://github.com/RocketChat/Rocket.Chat/pull/35286)) Bumps fuselage and related packages versions to use the most recent releases of each package, especially the fix for the missing track of the fuselage slider component + +-
Updated dependencies [c7b21f1c1e479533a208771f3e15f064f2c7e3ff, 25592391b04a5a9c5e4be57a3878bca7c7db66b2, c904862b1496cab943e97d28b36d3a24deac21c1]: + + - @rocket.chat/fuselage-ui-kit@17.0.0 + - @rocket.chat/ui-avatar@13.0.0 + - @rocket.chat/ui-contexts@17.0.0 + - @rocket.chat/core-typings@7.5.0 +
+ +## 0.6.10-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.5.0-rc.5 + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.5 + - @rocket.chat/ui-contexts@17.0.0-rc.5 + - @rocket.chat/ui-avatar@13.0.0-rc.5 +
+ +## 0.6.10-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.5.0-rc.4 + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.4 + - @rocket.chat/ui-contexts@17.0.0-rc.4 + - @rocket.chat/ui-avatar@13.0.0-rc.4 +
+ +## 0.6.10-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.5.0-rc.3 + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.3 + - @rocket.chat/ui-contexts@17.0.0-rc.3 + - @rocket.chat/ui-avatar@13.0.0-rc.3 +
+ +## 0.6.10-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@17.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.2 + - @rocket.chat/ui-avatar@13.0.0-rc.2 + - @rocket.chat/core-typings@7.5.0-rc.2 +
+ +## 0.6.10-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.5.0-rc.1 + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.1 + - @rocket.chat/ui-contexts@17.0.0-rc.1 + - @rocket.chat/ui-avatar@13.0.0-rc.1 +
+ +## 0.6.10-rc.0 + +### Patch Changes + +- ([#35286](https://github.com/RocketChat/Rocket.Chat/pull/35286)) Bumps fuselage and related packages versions to use the most recent releases of each package, especially the fix for the missing track of the fuselage slider component + +-
Updated dependencies [c7b21f1c1e479533a208771f3e15f064f2c7e3ff, 25592391b04a5a9c5e4be57a3878bca7c7db66b2, c904862b1496cab943e97d28b36d3a24deac21c1]: + + - @rocket.chat/fuselage-ui-kit@17.0.0-rc.0 + - @rocket.chat/ui-avatar@13.0.0-rc.0 + - @rocket.chat/ui-contexts@17.0.0-rc.0 + - @rocket.chat/core-typings@7.5.0-rc.0 +
+ +## 0.6.9 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.4.1 + - @rocket.chat/fuselage-ui-kit@16.0.1 + - @rocket.chat/ui-contexts@16.0.1 + - @rocket.chat/ui-avatar@12.0.1 +
+ +## 0.6.8 + +### Patch Changes + +-
Updated dependencies [89964144e042c8d9282b51efd89e1e684077fdd7, f85da08765a9d3f8c5aabd9291fd08be6dfdeb85, be5031a21bdcda31270d53d319f7d183e77d84d7, 36e90a2eb2f9698f7ba42f6e8429a240114426bf]: + + - @rocket.chat/fuselage-ui-kit@16.0.0 + - @rocket.chat/core-typings@7.4.0 + - @rocket.chat/ui-contexts@16.0.0 + - @rocket.chat/ui-avatar@12.0.0 +
+ +## 0.6.8-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.4.0-rc.5 + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.5 + - @rocket.chat/ui-contexts@16.0.0-rc.5 + - @rocket.chat/ui-avatar@12.0.0-rc.5 +
+ +## 0.6.8-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.4.0-rc.4 + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.4 + - @rocket.chat/ui-contexts@16.0.0-rc.4 + - @rocket.chat/ui-avatar@12.0.0-rc.4 +
+ +## 0.6.7-rc.3 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.4.0-rc.3 + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.3 + - @rocket.chat/ui-contexts@16.0.0-rc.3 + - @rocket.chat/ui-avatar@12.0.0-rc.3 +
+ +## 0.6.7-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.4.0-rc.2 + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.2 + - @rocket.chat/ui-contexts@16.0.0-rc.2 + - @rocket.chat/ui-avatar@12.0.0-rc.2 +
+ +## 0.6.7-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.4.0-rc.1 + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.1 + - @rocket.chat/ui-contexts@16.0.0-rc.1 + - @rocket.chat/ui-avatar@12.0.0-rc.1 +
+ +## 0.6.7-rc.0 + +### Patch Changes + +-
Updated dependencies [89964144e042c8d9282b51efd89e1e684077fdd7, f85da08765a9d3f8c5aabd9291fd08be6dfdeb85, be5031a21bdcda31270d53d319f7d183e77d84d7, 36e90a2eb2f9698f7ba42f6e8429a240114426bf]: + + - @rocket.chat/fuselage-ui-kit@16.0.0-rc.0 + - @rocket.chat/core-typings@7.4.0-rc.0 + - @rocket.chat/ui-contexts@16.0.0-rc.0 + - @rocket.chat/ui-avatar@12.0.0-rc.0 +
+ +## 0.6.7 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/core-typings@7.3.3 + - @rocket.chat/fuselage-ui-kit@15.0.3 + - @rocket.chat/ui-contexts@15.0.3 + - @rocket.chat/ui-avatar@11.0.3 +
+ +## 0.6.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.2 + - @rocket.chat/fuselage-ui-kit@15.0.2 + - @rocket.chat/ui-contexts@15.0.2 + - @rocket.chat/ui-avatar@11.0.2 +
+ +## 0.6.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.1 + - @rocket.chat/fuselage-ui-kit@15.0.1 + - @rocket.chat/ui-contexts@15.0.1 + - @rocket.chat/ui-avatar@11.0.1 +
+ +## 0.6.4 + +### Patch Changes + +- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases + +-
Updated dependencies [5506c406f4a22145ece065ad2b797225e94423ca, 8942b0032af976738a7c602fa389803dda30c0dc, bfa92f4dba1a16973d7da5a9c0f5d0df998bf944]: + + - @rocket.chat/fuselage-ui-kit@15.0.0 + - @rocket.chat/ui-contexts@15.0.0 + - @rocket.chat/core-typings@7.3.0 + - @rocket.chat/ui-avatar@11.0.0 +
+ +## 0.6.4-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.0-rc.5 + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.5 + - @rocket.chat/ui-contexts@15.0.0-rc.5 + - @rocket.chat/ui-avatar@11.0.0-rc.5 +
+ +## 0.6.4-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.0-rc.4 + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.4 + - @rocket.chat/ui-contexts@15.0.0-rc.4 + - @rocket.chat/ui-avatar@11.0.0-rc.4 +
+ +## 0.6.4-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.0-rc.3 + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.3 + - @rocket.chat/ui-contexts@15.0.0-rc.3 + - @rocket.chat/ui-avatar@11.0.0-rc.3 +
+ +## 0.6.4-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.0-rc.2 + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.2 + - @rocket.chat/ui-contexts@15.0.0-rc.2 + - @rocket.chat/ui-avatar@11.0.0-rc.2 +
+ +## 0.6.4-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.3.0-rc.1 + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.1 + - @rocket.chat/ui-contexts@15.0.0-rc.1 + - @rocket.chat/ui-avatar@11.0.0-rc.1 +
+ +## 0.6.4-rc.0 + +### Patch Changes + +- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases + +-
Updated dependencies [5506c406f4a22145ece065ad2b797225e94423ca, 8942b0032af976738a7c602fa389803dda30c0dc, bfa92f4dba1a16973d7da5a9c0f5d0df998bf944]: + + - @rocket.chat/fuselage-ui-kit@15.0.0-rc.0 + - @rocket.chat/ui-contexts@15.0.0-rc.0 + - @rocket.chat/core-typings@7.3.0-rc.0 + - @rocket.chat/ui-avatar@11.0.0-rc.0 +
+ +## 0.6.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.2.1 + - @rocket.chat/fuselage-ui-kit@14.0.1 + - @rocket.chat/ui-contexts@14.0.1 + - @rocket.chat/ui-avatar@10.0.1 +
+ +## 0.6.2 + +### Patch Changes + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed + +- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics + +-
Updated dependencies [76f6239ff1a9f34f163c03c140c4ceba62563b4e, c43220dcd8c1df86a6143d6553964ad2173903b3, 76f6239ff1a9f34f163c03c140c4ceba62563b4e, 475120dc19fb8cc400fd8af21559cd6f3cc17eb8, 2e4af86f6463166ba4d0b37b153b89ab246e112a, 76f6239ff1a9f34f163c03c140c4ceba62563b4e, 75a14b2e013aca7361cac56316f2b7e8c07d9dc8]: + + - @rocket.chat/fuselage-ui-kit@14.0.0 + - @rocket.chat/core-typings@7.2.0 + - @rocket.chat/ui-contexts@14.0.0 + - @rocket.chat/ui-avatar@10.0.0 +
+ +## 0.6.2-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.2.0-rc.3 + - @rocket.chat/fuselage-ui-kit@14.0.0-rc.3 + - @rocket.chat/ui-contexts@14.0.0-rc.3 + - @rocket.chat/ui-avatar@10.0.0-rc.3 +
+ +## 0.6.2-rc.2 + +### Patch Changes + +- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases + +-
Updated dependencies [c43220dcd8c1df86a6143d6553964ad2173903b3]: + + - @rocket.chat/fuselage-ui-kit@14.0.0-rc.2 + - @rocket.chat/ui-contexts@14.0.0-rc.2 + - @rocket.chat/core-typings@7.2.0-rc.2 + - @rocket.chat/ui-avatar@10.0.0-rc.2 +
+ +## 0.6.2-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.2.0-rc.1 + - @rocket.chat/fuselage-ui-kit@14.0.0-rc.1 + - @rocket.chat/ui-contexts@14.0.0-rc.1 + - @rocket.chat/ui-avatar@10.0.0-rc.1 +
+ +## 0.6.2-rc.0 + +### Patch Changes + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases + +- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics + +-
Updated dependencies [76f6239ff1a9f34f163c03c140c4ceba62563b4e, 76f6239ff1a9f34f163c03c140c4ceba62563b4e, 475120dc19fb8cc400fd8af21559cd6f3cc17eb8, 2e4af86f6463166ba4d0b37b153b89ab246e112a, 76f6239ff1a9f34f163c03c140c4ceba62563b4e, 75a14b2e013aca7361cac56316f2b7e8c07d9dc8]: + + - @rocket.chat/fuselage-ui-kit@14.0.0-rc.0 + - @rocket.chat/core-typings@7.2.0-rc.0 + - @rocket.chat/ui-contexts@14.0.0-rc.0 + - @rocket.chat/ui-avatar@10.0.0-rc.0 +
+ +## 0.6.1 + +### Patch Changes + +-
Updated dependencies [80e36bfc3938775eb26aa5576f1b9b98896e1cc4, 32d93a0666fa1cbe857d02889e93d9bbf45bd4f0]: + + - @rocket.chat/core-typings@7.1.0 + - @rocket.chat/fuselage-ui-kit@13.0.0 + - @rocket.chat/ui-contexts@13.0.0 + - @rocket.chat/ui-avatar@9.0.0 +
+ +## 0.6.1-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.1.0-rc.3 + - @rocket.chat/fuselage-ui-kit@13.0.0-rc.3 + - @rocket.chat/ui-contexts@13.0.0-rc.3 + - @rocket.chat/ui-avatar@9.0.0-rc.3 +
+ +## 0.6.1-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.1.0-rc.2 + - @rocket.chat/fuselage-ui-kit@13.0.0-rc.2 + - @rocket.chat/ui-contexts@13.0.0-rc.2 + - @rocket.chat/ui-avatar@9.0.0-rc.2 +
+ +## 0.6.1-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.1.0-rc.1 + - @rocket.chat/fuselage-ui-kit@13.0.0-rc.1 + - @rocket.chat/ui-contexts@13.0.0-rc.1 + - @rocket.chat/ui-avatar@9.0.0-rc.1 +
+ +## 0.6.1-rc.0 + +### Patch Changes + +-
Updated dependencies [80e36bfc3938775eb26aa5576f1b9b98896e1cc4, 32d93a0666fa1cbe857d02889e93d9bbf45bd4f0]: + + - @rocket.chat/core-typings@7.1.0-rc.0 + - @rocket.chat/fuselage-ui-kit@13.0.0-rc.0 + - @rocket.chat/ui-contexts@13.0.0-rc.0 + - @rocket.chat/ui-avatar@9.0.0-rc.0 +
+ +## 0.6.0 + +### Minor Changes + +- ([#33592](https://github.com/RocketChat/Rocket.Chat/pull/33592)) Adds ability to collapse/expand sidebar groups + +### Patch Changes + +-
Updated dependencies [bcacbb1cee, 9274cf4586, b338807d76, 3ea02d3cc1, e3629e065b, 03d148524b, 81998f3450, 509143d6dd]: + + - @rocket.chat/core-typings@7.0.0 + - @rocket.chat/fuselage-ui-kit@12.0.0 + - @rocket.chat/ui-avatar@8.0.0 + - @rocket.chat/ui-contexts@12.0.0 +
+ +## 0.6.0-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.0.0-rc.6 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.6 + - @rocket.chat/ui-contexts@12.0.0-rc.6 + - @rocket.chat/ui-avatar@8.0.0-rc.6 +
+ +## 0.6.0-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@12.0.0-rc.5 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.5 + - @rocket.chat/ui-avatar@8.0.0-rc.5 + - @rocket.chat/core-typings@7.0.0-rc.5 +
+ +## 0.6.0-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.0.0-rc.4 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.4 + - @rocket.chat/ui-contexts@12.0.0-rc.4 + - @rocket.chat/ui-avatar@8.0.0-rc.4 +
+ +## 0.6.0-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.0.0-rc.3 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.3 + - @rocket.chat/ui-contexts@12.0.0-rc.3 + - @rocket.chat/ui-avatar@8.0.0-rc.3 +
+ +## 0.6.0-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.2 + - @rocket.chat/ui-contexts@12.0.0-rc.2 + - @rocket.chat/ui-avatar@8.0.0-rc.2 +
+ +## 0.6.0-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@7.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.1 + - @rocket.chat/ui-contexts@12.0.0-rc.1 + - @rocket.chat/ui-avatar@8.0.0-rc.1 +
+ +## 0.6.0-rc.0 + +### Minor Changes + +- ([#33592](https://github.com/RocketChat/Rocket.Chat/pull/33592)) Adds ability to collapse/expand sidebar groups + +### Patch Changes + +-
Updated dependencies [7726d68374, bcacbb1cee, 9274cf4586, b338807d76, 3ea02d3cc1, e3629e065b, 03d148524b, 81998f3450, 509143d6dd]: + + - @rocket.chat/core-typings@7.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@12.0.0-rc.0 + - @rocket.chat/ui-avatar@8.0.0-rc.0 + - @rocket.chat/ui-contexts@12.0.0-rc.0 +
+ +## 0.5.0 + +### Minor Changes + +- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview + +### Patch Changes + +-
Updated dependencies [599762739a, 274f4f5881, cd0d50016e, 78e6ba4820, 927710d778, 12d6307998]: + + - @rocket.chat/fuselage-ui-kit@11.0.0 + - @rocket.chat/core-typings@6.13.0 + - @rocket.chat/ui-avatar@7.0.0 + - @rocket.chat/ui-contexts@11.0.0 +
+ +## 0.5.0-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.6 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.6 + - @rocket.chat/ui-contexts@11.0.0-rc.6 + - @rocket.chat/ui-avatar@7.0.0-rc.6 +
+ +## 0.5.0-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.5 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.5 + - @rocket.chat/ui-contexts@11.0.0-rc.5 + - @rocket.chat/ui-avatar@7.0.0-rc.5 +
+ +## 0.5.0-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.4 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.4 + - @rocket.chat/ui-contexts@11.0.0-rc.4 + - @rocket.chat/ui-avatar@7.0.0-rc.4 +
+ +## 0.5.0-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.3 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.3 + - @rocket.chat/ui-contexts@11.0.0-rc.3 + - @rocket.chat/ui-avatar@7.0.0-rc.3 +
+ +## 0.5.0-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.13.0-rc.2 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.2 + - @rocket.chat/ui-contexts@11.0.0-rc.2 + - @rocket.chat/ui-avatar@7.0.0-rc.2 +
+ +## 0.5.0-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@11.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.1 + - @rocket.chat/ui-avatar@7.0.0-rc.1 + - @rocket.chat/core-typings@6.13.0-rc.1 +
+ +## 0.5.0-rc.0 + +### Minor Changes + +- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview + +### Patch Changes + +-
Updated dependencies [599762739a, 274f4f5881, cd0d50016e, 78e6ba4820, 927710d778, 12d6307998]: + + - @rocket.chat/fuselage-ui-kit@11.0.0-rc.0 + - @rocket.chat/core-typings@6.13.0-rc.0 + - @rocket.chat/ui-avatar@7.0.0-rc.0 + - @rocket.chat/ui-contexts@11.0.0-rc.0 +
+ +## 0.4.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.1 + - @rocket.chat/ui-contexts@10.0.1 + - @rocket.chat/ui-avatar@6.0.1 +
+ +## 0.4.0 + +### Minor Changes + +- ([#32969](https://github.com/RocketChat/Rocket.Chat/pull/32969)) Upgrades fuselage-toastbar version in order to add pause on hover functionality + +### Patch Changes + +- ([#32968](https://github.com/RocketChat/Rocket.Chat/pull/32968)) Bumped @rocket.chat/fuselage that fixes the Menu onPointerUp event behavior + +-
Updated dependencies [8ea6517c4e]: + + - @rocket.chat/fuselage-ui-kit@10.0.0 + - @rocket.chat/ui-avatar@6.0.0 + - @rocket.chat/ui-contexts@10.0.0 +
+ +## 0.4.0-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.6 + - @rocket.chat/ui-contexts@10.0.0-rc.6 + - @rocket.chat/ui-avatar@6.0.0-rc.6 +
+ +## 0.4.0-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.5 + - @rocket.chat/ui-contexts@10.0.0-rc.5 + - @rocket.chat/ui-avatar@6.0.0-rc.5 +
+ +## 0.4.0-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.4 + - @rocket.chat/ui-contexts@10.0.0-rc.4 + - @rocket.chat/ui-avatar@6.0.0-rc.4 +
+ +## 0.4.0-rc.3 + +### Patch Changes + +-
Updated dependencies []: + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.3 + - @rocket.chat/ui-contexts@10.0.0-rc.3 + - @rocket.chat/ui-avatar@6.0.0-rc.3 +
+ +## 0.4.0-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.2 + - @rocket.chat/ui-contexts@10.0.0-rc.2 + - @rocket.chat/ui-avatar@6.0.0-rc.2 +
+ +## 0.4.0-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.1 + - @rocket.chat/ui-contexts@10.0.0-rc.1 + - @rocket.chat/ui-avatar@6.0.0-rc.1 +
+ +## 0.4.0-rc.0 + +### Minor Changes + +- ([#32969](https://github.com/RocketChat/Rocket.Chat/pull/32969)) Upgrades fuselage-toastbar version in order to add pause on hover functionality + +### Patch Changes + +- ([#32968](https://github.com/RocketChat/Rocket.Chat/pull/32968)) Bumped @rocket.chat/fuselage that fixes the Menu onPointerUp event behavior + +-
Updated dependencies [8ea6517c4e]: + + - @rocket.chat/fuselage-ui-kit@10.0.0-rc.0 + - @rocket.chat/ui-avatar@6.0.0-rc.0 + - @rocket.chat/ui-contexts@10.0.0-rc.0 +
+ +## 0.3.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.2 + - @rocket.chat/ui-contexts@9.0.2 + - @rocket.chat/ui-avatar@5.0.2 +
+ +## 0.3.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.1 + - @rocket.chat/ui-contexts@9.0.1 + - @rocket.chat/ui-avatar@5.0.1 +
+ +## 0.3.3 + +### Patch Changes + +-
Updated dependencies [88e5219bd2, 2d89a0c448, 4e8aa575a6, b8e5887fb9]: + + - @rocket.chat/fuselage-ui-kit@9.0.0 + - @rocket.chat/ui-contexts@9.0.0 + - @rocket.chat/ui-avatar@5.0.0 +
+ +## 0.3.3-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.6 + - @rocket.chat/ui-contexts@9.0.0-rc.6 + - @rocket.chat/ui-avatar@5.0.0-rc.6 +
+ +## 0.3.3-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.5 + - @rocket.chat/ui-contexts@9.0.0-rc.5 + - @rocket.chat/ui-avatar@5.0.0-rc.5 +
+ +## 0.3.3-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.4 + - @rocket.chat/ui-contexts@9.0.0-rc.4 + - @rocket.chat/ui-avatar@5.0.0-rc.4 +
+ +## 0.3.3-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.3 + - @rocket.chat/ui-contexts@9.0.0-rc.3 + - @rocket.chat/ui-avatar@5.0.0-rc.3 +
+ +## 0.3.3-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.2 + - @rocket.chat/ui-contexts@9.0.0-rc.2 + - @rocket.chat/ui-avatar@5.0.0-rc.2 +
+ +## 0.3.3-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.1 + - @rocket.chat/ui-contexts@9.0.0-rc.1 + - @rocket.chat/ui-avatar@5.0.0-rc.1 +
+ +## 0.3.3-rc.0 + +### Patch Changes + +-
Updated dependencies [88e5219bd2, 2d89a0c448, 4e8aa575a6, b8e5887fb9]: + + - @rocket.chat/fuselage-ui-kit@9.0.0-rc.0 + - @rocket.chat/ui-contexts@9.0.0-rc.0 + - @rocket.chat/ui-avatar@5.0.0-rc.0 +
+ +## 0.3.2 + +### Patch Changes + +-
Updated dependencies [ca6a9d8de8, ca6a9d8de8, ca6a9d8de8, ca6a9d8de8]: + + - @rocket.chat/fuselage-ui-kit@8.0.2 + - @rocket.chat/ui-contexts@8.0.2 + - @rocket.chat/ui-avatar@4.0.2 +
+ +## 0.3.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.1 + - @rocket.chat/ui-contexts@8.0.1 + - @rocket.chat/ui-avatar@4.0.1 +
+ +## 0.3.0 + +### Minor Changes + +- ([#32604](https://github.com/RocketChat/Rocket.Chat/pull/32604)) Upgrades fuselage-toastbar version in order to add RTL support to the component + +- ([#31821](https://github.com/RocketChat/Rocket.Chat/pull/31821)) New runtime for apps in the Apps-Engine based on the Deno platform + +### Patch Changes + +-
Updated dependencies [a565999ae0, 4f72d62aa7]: + + - @rocket.chat/fuselage-ui-kit@8.0.0 + - @rocket.chat/ui-contexts@8.0.0 + - @rocket.chat/ui-avatar@4.0.0 +
+ +## 0.3.0-rc.7 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.7 + - @rocket.chat/ui-contexts@8.0.0-rc.7 + - @rocket.chat/ui-avatar@4.0.0-rc.7 +
+ +## 0.3.0-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.6 + - @rocket.chat/ui-contexts@8.0.0-rc.6 + - @rocket.chat/ui-avatar@4.0.0-rc.6 +
+ +## 0.3.0-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.5 + - @rocket.chat/ui-contexts@8.0.0-rc.5 + - @rocket.chat/ui-avatar@4.0.0-rc.5 +
+ +## 0.3.0-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.4 + - @rocket.chat/ui-contexts@8.0.0-rc.4 + - @rocket.chat/ui-avatar@4.0.0-rc.4 +
+ +## 0.3.0-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.3 + - @rocket.chat/ui-contexts@8.0.0-rc.3 + - @rocket.chat/ui-avatar@4.0.0-rc.3 +
+ +## 0.3.0-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.2 + - @rocket.chat/ui-contexts@8.0.0-rc.2 + - @rocket.chat/ui-avatar@4.0.0-rc.2 +
+ +## 0.3.0-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.1 + - @rocket.chat/ui-contexts@8.0.0-rc.1 + - @rocket.chat/ui-avatar@4.0.0-rc.1 +
+ +## 0.3.0-rc.0 + +### Minor Changes + +- ([#32604](https://github.com/RocketChat/Rocket.Chat/pull/32604)) Upgrades fuselage-toastbar version in order to add RTL support to the component + +- ([#31821](https://github.com/RocketChat/Rocket.Chat/pull/31821)) New runtime for apps in the Apps-Engine based on the Deno platform + +### Patch Changes + +-
Updated dependencies [a565999ae0, 4f72d62aa7]: + + - @rocket.chat/fuselage-ui-kit@8.0.0-rc.0 + - @rocket.chat/ui-contexts@8.0.0-rc.0 + - @rocket.chat/ui-avatar@4.0.0-rc.0 + +## 0.2.26 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@7.0.3 + - @rocket.chat/ui-contexts@7.0.3 + - @rocket.chat/ui-avatar@3.0.3 +
+ +## 0.2.25 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@7.0.2 + - @rocket.chat/ui-contexts@7.0.2 + - @rocket.chat/ui-avatar@3.0.2 +
+ +## 0.2.24 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@7.0.1 + - @rocket.chat/ui-contexts@7.0.1 + - @rocket.chat/ui-avatar@3.0.1 +
+ +## 0.2.23 + +### Patch Changes + +-
Updated dependencies [6205ef14f0, ee5cdfc367]: + + - @rocket.chat/fuselage-ui-kit@7.0.0 + - @rocket.chat/ui-contexts@7.0.0 + - @rocket.chat/ui-avatar@3.0.0 +
+ +## 0.2.23-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@7.0.0-rc.2 + - @rocket.chat/ui-contexts@7.0.0-rc.2 + - @rocket.chat/ui-avatar@3.0.0-rc.2 +
+ +## 0.2.23-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/fuselage-ui-kit@7.0.0-rc.1 + - @rocket.chat/ui-contexts@7.0.0-rc.1 + - @rocket.chat/ui-avatar@3.0.0-rc.1 +
+ +## 0.2.23-rc.0 + +### Patch Changes + +-
Updated dependencies [6205ef14f0, ee5cdfc367]: + + - @rocket.chat/fuselage-ui-kit@7.0.0-rc.0 + - @rocket.chat/ui-contexts@7.0.0-rc.0 + - @rocket.chat/ui-avatar@3.0.0-rc.0 +
+ +## 0.2.22 + +### Patch Changes + +-
Updated dependencies [b94ca7c30b, c0d54d742a]: + + - @rocket.chat/fuselage-ui-kit@6.0.0 + - @rocket.chat/ui-contexts@6.0.0 + - @rocket.chat/ui-avatar@2.0.0 +
+ +## 0.2.22-rc.2 + +### Patch Changes + +-
Updated dependencies [b94ca7c30b]: + + - @rocket.chat/fuselage-ui-kit@6.0.0-rc.2 + - @rocket.chat/ui-contexts@6.0.0-rc.2 + - @rocket.chat/ui-avatar@2.0.0-rc.2 +
+ +## 0.2.23-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@6.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@6.0.0-rc.1 + - @rocket.chat/ui-avatar@2.0.0-rc.1 +
+ +## 0.2.23-rc.0 + +### Patch Changes + +-
Updated dependencies [c0d54d742a]: + + - @rocket.chat/ui-contexts@6.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@6.0.0-rc.0 + - @rocket.chat/ui-avatar@2.0.0-rc.0 + +## 0.2.22 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.2 + - @rocket.chat/fuselage-ui-kit@5.0.2 + - @rocket.chat/ui-avatar@1.0.2 +
+ +## 0.2.21 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.1 + - @rocket.chat/fuselage-ui-kit@5.0.1 + - @rocket.chat/ui-avatar@1.0.1 +
+ +## 0.2.20 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0 + - @rocket.chat/fuselage-ui-kit@5.0.0 + - @rocket.chat/ui-avatar@1.0.0 +
+ +## 0.2.20-rc.4 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0-rc.4 + - @rocket.chat/fuselage-ui-kit@5.0.0-rc.4 + - @rocket.chat/ui-avatar@1.0.0-rc.4 +
+ +## 0.2.20-rc.3 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0-rc.3 + - @rocket.chat/fuselage-ui-kit@5.0.0-rc.3 + - @rocket.chat/ui-avatar@1.0.0-rc.3 +
+ +## 0.2.20-rc.2 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@5.0.0-rc.2 + - @rocket.chat/ui-avatar@1.0.0-rc.2 +
+ +## 0.2.20-rc.1 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0-rc.1 + - @rocket.chat/fuselage-ui-kit@5.0.0-rc.1 + - @rocket.chat/ui-avatar@1.0.0-rc.1 +
+ +## 0.2.20-rc.0 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@5.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@5.0.0-rc.0 + - @rocket.chat/ui-avatar@1.0.0-rc.0 +
+ +## 0.2.19 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.6 + - @rocket.chat/fuselage-ui-kit@4.0.6 +
+ +## 0.2.18 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.5 + - @rocket.chat/fuselage-ui-kit@4.0.5 +
+ +## 0.2.17 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.4 + - @rocket.chat/fuselage-ui-kit@4.0.4 +
+ +## 0.2.16 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.3 + - @rocket.chat/fuselage-ui-kit@4.0.3 +
+ +## 0.2.15 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.2 + - @rocket.chat/fuselage-ui-kit@4.0.2 +
+ +## 0.2.14 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.1 + - @rocket.chat/fuselage-ui-kit@4.0.1 +
+ +## 0.2.13 + +### Patch Changes + +- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo + +-
Updated dependencies [b223cbde14, b2b0035162]: + + - @rocket.chat/fuselage-ui-kit@4.0.0 + - @rocket.chat/ui-contexts@4.0.0 +
+ +## 0.2.13-rc.7 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.0-rc.7 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.7 +
+ +## 0.2.13-rc.6 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.0-rc.6 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.6 +
+ +## 0.2.13-rc.5 + +### Patch Changes + +-
Updated dependencies []: + + - @rocket.chat/ui-contexts@4.0.0-rc.5 + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.5 +
+ +## 0.2.13-rc.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@4.0.0-rc.4 +- @rocket.chat/fuselage-ui-kit@4.0.0-rc.4 + +## 0.2.13-rc.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@4.0.0-rc.3 +- @rocket.chat/fuselage-ui-kit@4.0.0-rc.3 + +## 0.2.13-rc.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@4.0.0-rc.2 +- @rocket.chat/fuselage-ui-kit@4.0.0-rc.2 + +## 0.2.13-rc.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@4.0.0-rc.1 +- @rocket.chat/fuselage-ui-kit@4.0.0-rc.1 + +## 0.2.13-rc.0 + +### Patch Changes + +- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo +- Updated dependencies [b223cbde14] +- Updated dependencies [b2b0035162] + - @rocket.chat/fuselage-ui-kit@4.0.0-rc.0 + - @rocket.chat/ui-contexts@4.0.0-rc.0 + +## 0.2.12 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.3 +- @rocket.chat/fuselage-ui-kit@3.0.3 + +## 0.2.11 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.2 +- @rocket.chat/fuselage-ui-kit@3.0.2 + +## 0.2.10 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.1 +- @rocket.chat/fuselage-ui-kit@3.0.1 + +## 0.2.9 + +### Patch Changes + +- Updated dependencies [7da1edf866] + - @rocket.chat/ui-contexts@3.0.0 + - @rocket.chat/fuselage-ui-kit@3.0.0 + +## 0.2.9-rc.12 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.19 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.19 + +## 0.2.9-rc.11 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.18 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.18 + +## 0.2.9-rc.10 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.17 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.17 + +## 0.2.9-rc.9 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.16 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.16 + +## 0.2.9-rc.8 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.15 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.15 + +## 0.2.9-rc.7 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.14 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.14 + +## 0.2.9-rc.6 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.13 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.13 + +## 0.2.9-rc.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.12 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.12 + +## 0.2.9-rc.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.11 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.11 + +## 0.2.9-rc.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.10 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.10 + +## 0.2.9-rc.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.9 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.9 + +## 0.2.9-rc.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.8 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.8 + +## 0.2.6-rc.7 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.7 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.7 + +## 0.2.6-rc.6 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.6 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.6 + +## 0.2.6-rc.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.5 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.5 + +## 0.2.6-rc.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.4 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.4 + +## 0.2.6-rc.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.3 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.3 + +## 0.2.6-rc.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.2 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.2 + +## 0.2.6-rc.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.0-rc.1 +- @rocket.chat/fuselage-ui-kit@3.0.0-rc.1 + +## 0.2.6-rc.0 + +### Patch Changes + +- Updated dependencies [7da1edf866] + - @rocket.chat/ui-contexts@3.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@3.0.0-rc.0 + +## 0.2.8 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.8 +- @rocket.chat/fuselage-ui-kit@2.0.8 + +## 0.2.7 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.7 +- @rocket.chat/fuselage-ui-kit@2.0.7 + +## 0.2.6 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.6 +- @rocket.chat/fuselage-ui-kit@2.0.6 + +## 0.2.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.5 +- @rocket.chat/fuselage-ui-kit@2.0.5 + +## 0.2.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.4 +- @rocket.chat/fuselage-ui-kit@2.0.4 + +## 0.2.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.3 +- @rocket.chat/fuselage-ui-kit@2.0.3 + +## 0.2.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.2 +- @rocket.chat/fuselage-ui-kit@2.0.2 + +## 0.2.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.1 +- @rocket.chat/fuselage-ui-kit@2.0.1 + +## 0.2.0 + +### Minor Changes + +- 1246a21648: feat: Add missing variants to UIKit button +- f9a748526d: feat: Adding new UIKit components: Callout, Checkbox, Radio Button, Time Picker, Toast Bar, Toggle Switch, Tab Navigation + +### Patch Changes + +- dc1d8ce92e: feat(fuselage-ui-kit): Introduce `TabsNavigationBlock` +- Updated dependencies [1246a21648] +- Updated dependencies [f9a748526d] +- Updated dependencies [dc1d8ce92e] +- Updated dependencies [074db3b419] +- Updated dependencies [dce4a829fa] +- Updated dependencies [b8f3d5014f] + - @rocket.chat/fuselage-ui-kit@2.0.0 + - @rocket.chat/ui-contexts@2.0.0 + +## 0.2.0-rc.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.5 +- @rocket.chat/fuselage-ui-kit@2.0.0-rc.5 + +## 0.2.0-rc.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.4 +- @rocket.chat/fuselage-ui-kit@2.0.0-rc.4 + +## 0.2.0-rc.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.3 +- @rocket.chat/fuselage-ui-kit@2.0.0-rc.3 + +## 0.2.0-rc.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.2 +- @rocket.chat/fuselage-ui-kit@2.0.0-rc.2 + +## 0.2.0-rc.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.1 +- @rocket.chat/fuselage-ui-kit@2.0.0-rc.1 + +## 0.2.0-rc.0 + +### Minor Changes + +- 1246a21648: feat: Add missing variants to UIKit button +- f9a748526d: feat: Adding new UIKit components: Callout, Checkbox, Radio Button, Time Picker, Toast Bar, Toggle Switch, Tab Navigation + +### Patch Changes + +- dc1d8ce92e: feat(fuselage-ui-kit): Introduce `TabsNavigationBlock` +- Updated dependencies [1246a21648] +- Updated dependencies [f9a748526d] +- Updated dependencies [dc1d8ce92e] +- Updated dependencies [074db3b419] +- Updated dependencies [dce4a829fa] +- Updated dependencies [b8f3d5014f] + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.0 + - @rocket.chat/ui-contexts@2.0.0-rc.0 + +## 0.1.8 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.8 +- @rocket.chat/fuselage-ui-kit@1.0.8 + +## 0.1.7 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.7 +- @rocket.chat/fuselage-ui-kit@1.0.7 + +## 0.1.6 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.6 +- @rocket.chat/fuselage-ui-kit@1.0.6 + +## 0.1.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.5 +- @rocket.chat/fuselage-ui-kit@1.0.5 + +## 0.1.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.4 +- @rocket.chat/fuselage-ui-kit@1.0.4 + +## 0.1.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.3 +- @rocket.chat/fuselage-ui-kit@1.0.3 + +## 0.1.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.2 +- @rocket.chat/fuselage-ui-kit@1.0.2 + +## 0.1.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.1 +- @rocket.chat/fuselage-ui-kit@1.0.1 + +## 0.1.0 + +### Minor Changes + +- dbdf45b0e5: feat: Introduce contextualBar surface renderer for UiKit blocks + +### Patch Changes + +- Updated dependencies [e14ec50816] +- Updated dependencies [f76d514341] +- Updated dependencies [dbdf45b0e5] +- Updated dependencies [c0fa567246] + - @rocket.chat/ui-contexts@1.0.0 + - @rocket.chat/fuselage-ui-kit@1.0.0 + +## 0.1.0-rc.10 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.10 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.10 + +## 0.1.0-rc.9 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.9 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.9 + +## 0.1.0-rc.8 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.8 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.8 + +## 0.1.0-rc.7 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.7 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.7 + +## 0.1.0-rc.6 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.6 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.6 + +## 0.1.0-rc.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.5 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.5 + +## 0.1.0-rc.4 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.4 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.4 + +## 0.1.0-rc.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.3 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.3 + +## 0.1.0-rc.2 + +### Patch Changes + +- Updated dependencies [f76d514341] + - @rocket.chat/ui-contexts@1.0.0-rc.2 + - @rocket.chat/fuselage-ui-kit@1.0.0-rc.2 + +## 0.1.0-rc.1 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.0-rc.1 +- @rocket.chat/fuselage-ui-kit@1.0.0-rc.1 + +## 0.1.0-rc.0 + +### Minor Changes + +- dbdf45b0e5: feat: Introduce contextualBar surface renderer for UiKit blocks + +### Patch Changes + +- Updated dependencies [e14ec50816] +- Updated dependencies [dbdf45b0e5] +- Updated dependencies [c0fa567246] + - @rocket.chat/ui-contexts@1.0.0-rc.0 + - @rocket.chat/fuselage-ui-kit@1.0.0-rc.0 diff --git a/apps/uikit-playground/index.html b/apps/uikit-playground/index.html new file mode 100644 index 0000000..751be9f --- /dev/null +++ b/apps/uikit-playground/index.html @@ -0,0 +1,13 @@ + + + + + + + UiKit-Playground + + +
+ + + diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json new file mode 100644 index 0000000..6e45faa --- /dev/null +++ b/apps/uikit-playground/package.json @@ -0,0 +1,56 @@ +{ + "name": "@rocket.chat/uikit-playground", + "version": "0.7.16", + "private": true, + "type": "module", + "scripts": { + ".:build-preview-move": "mkdir -p ../../.preview/ && cp -r ./dist ../../.preview/uikit-playground", + "build-preview": "tsc && vite build", + "dev": "vite", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@hello-pangea/dnd": "^17.0.0", + "@lezer/highlight": "^1.2.3", + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/css-in-js": "^0.33.1", + "@rocket.chat/fuselage": "^0.89.0", + "@rocket.chat/fuselage-hooks": "^0.43.1", + "@rocket.chat/fuselage-toastbar": "~0.36.1", + "@rocket.chat/fuselage-tokens": "^0.34.0", + "@rocket.chat/fuselage-ui-kit": "workspace:~", + "@rocket.chat/icons": "^0.49.0", + "@rocket.chat/logo": "^0.33.2", + "@rocket.chat/styled": "~0.34.1", + "@rocket.chat/ui-avatar": "workspace:^", + "@rocket.chat/ui-contexts": "workspace:~", + "codemirror": "^6.0.2", + "eslint4b-prebuilt": "^6.7.2", + "prettier": "~3.9.6", + "rc-scrollbars": "^1.1.6", + "react": "~19.2.8", + "react-dom": "~19.2.8", + "react-router-dom": "^6.30.6", + "react-split-pane": "^3.2.0", + "react-virtuoso": "~4.18.12", + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@rocket.chat/emitter": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/lodash": "~4.17.25", + "@types/react": "~19.2.18", + "@types/react-dom": "~19.2.5", + "@vitejs/plugin-react": "~6.0.5", + "eslint": "~9.39.5", + "typescript": "~5.9.3", + "vite": "^8.0.16" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/apps/uikit-playground/postcss.config.js b/apps/uikit-playground/postcss.config.js new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/apps/uikit-playground/postcss.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/apps/uikit-playground/public/vite.svg b/apps/uikit-playground/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/uikit-playground/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/uikit-playground/src/App.css b/apps/uikit-playground/src/App.css new file mode 100644 index 0000000..51371f9 --- /dev/null +++ b/apps/uikit-playground/src/App.css @@ -0,0 +1,8 @@ +.App { + text-align: center; +} + +#toastBarRoot{ + position: fixed; + z-index: 9999; +} \ No newline at end of file diff --git a/apps/uikit-playground/src/App.tsx b/apps/uikit-playground/src/App.tsx new file mode 100644 index 0000000..28240e0 --- /dev/null +++ b/apps/uikit-playground/src/App.tsx @@ -0,0 +1,58 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useMediaQueries } from '@rocket.chat/fuselage-hooks'; +import { ToastBarProvider } from '@rocket.chat/fuselage-toastbar'; +import { useContext, useEffect } from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; + +import { HomeLayout } from './Components/Routes/HomeLayout'; +import { ProjectSpecificLayout } from './Components/Routes/ProjectSpecificLayout'; +import { context, isMobileAction, isTabletAction } from './Context'; +import FlowDiagram from './Pages/FlowDiagram'; +import Home from './Pages/Home'; +import Playground from './Pages/Playground'; +import Prototype from './Pages/Prototype'; +import SignInToWorkspace from './Pages/SignInSignUp'; +import routes from './Routes/Routes'; + +import './App.css'; +import './_global.css'; +import './cssVariables.css'; + +function App() { + const { dispatch } = useContext(context); + + const [isMobile, isTablet] = useMediaQueries('(max-width: 630px)', '(max-width: 1050px)'); + + useEffect(() => { + dispatch(isMobileAction(isMobile)); + }, [isMobile, dispatch]); + + useEffect(() => { + dispatch(isTabletAction(isTablet)); + }, [isTablet, dispatch]); + return ( + + + + + }> + } /> + } /> + + {/* }> */} + } /> + }> + } /> + } /> + } /> + + } /> + {/* */} + + + + + ); +} + +export default App; diff --git a/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx b/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx new file mode 100644 index 0000000..c0e62de --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/BlockEditor.tsx @@ -0,0 +1,55 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import type { Extension } from '@codemirror/state'; +import { Box } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useEffect, useContext } from 'react'; + +import { updatePayloadAction, context } from '../../Context'; +import type { IPayload } from '../../Context/initialState'; +import useCodeMirror from '../../hooks/useCodeMirror'; +import useFormatCodeMirrorValue from '../../hooks/useFormatCodeMirrorValue'; +import intendCode from '../../utils/intendCode'; + +type CodeMirrorProps = { + extensions?: Extension[]; +}; + +const BlockEditor = ({ extensions }: CodeMirrorProps) => { + const { + state: { screens, activeScreen }, + dispatch, + } = useContext(context); + + const { editor, changes, setValue } = useCodeMirror(extensions, intendCode(screens[activeScreen]?.payload)); + const debounceValue = useDebouncedValue(changes, 1500); + + useFormatCodeMirrorValue((parsedCode: IPayload, prettifiedCode: { formatted: string; cursorOffset: number }) => { + dispatch( + updatePayloadAction({ + blocks: parsedCode.blocks, + surface: parsedCode.surface, + }), + ); + setValue(prettifiedCode.formatted, { + cursor: prettifiedCode.cursorOffset, + }); + }, debounceValue); + + useEffect(() => { + if (!screens[activeScreen]?.changedByEditor) { + setValue(intendCode(screens[activeScreen]?.payload), {}); + } + }, [screens[activeScreen]?.payload.blocks, screens[activeScreen]?.payload.surface, activeScreen]); + + useEffect(() => { + setValue(intendCode(screens[activeScreen]?.payload), {}); + }, [activeScreen]); + + return ( + <> + + + ); +}; + +export default BlockEditor; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts new file mode 100644 index 0000000..709358d --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/Extensions.ts @@ -0,0 +1,25 @@ +import { json } from '@codemirror/lang-json'; +import { EditorView } from 'codemirror'; + +import highlightStyle from './HighlightStyle'; +import basicSetup from './basicSetup'; +import jsonLinter from './jsonLinter'; +// import payloadLinter from './payloadLinter'; +import theme from './theme'; + +export const actionBlockExtensions = [ + highlightStyle, + json(), + jsonLinter, + basicSetup, + // payloadLinter, + ...theme, +]; + +export const actionPreviewExtensions = [ + EditorView.contentAttributes.of({ contenteditable: 'false' }), + highlightStyle, + json(), + basicSetup, + ...theme, +]; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts new file mode 100644 index 0000000..9ef0dc0 --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/HighlightStyle.ts @@ -0,0 +1,15 @@ +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; + +const highLightStyle = () => { + const style = HighlightStyle.define([ + { tag: t.literal, color: 'var(--RCPG-primary-color)' }, + { tag: t.bool, color: 'var(--RCPG-tertary-color)' }, + { tag: t.number, color: 'var(--RCPG-secondary-color)' }, + { tag: t.null, color: 'var(--RCPG-tertary-color)' }, + ]); + + return syntaxHighlighting(style); +}; + +export default highLightStyle(); diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts new file mode 100644 index 0000000..b3923dd --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/basicSetup.ts @@ -0,0 +1,35 @@ +import { completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { defaultHighlightStyle, syntaxHighlighting, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language'; +import { lintKeymap } from '@codemirror/lint'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import type { Extension } from '@codemirror/state'; +import { keymap, drawSelection, dropCursor, rectangularSelection, crosshairCursor, lineNumbers, EditorView } from '@codemirror/view'; + +const basicSetup: Extension = (() => [ + lineNumbers(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + indentOnInput(), + EditorView.lineWrapping, + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + bracketMatching(), + closeBrackets(), + rectangularSelection(), + crosshairCursor(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...lintKeymap, + indentWithTab, + ]), +])(); + +export default basicSetup; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/index.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/index.ts new file mode 100644 index 0000000..4e2251e --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/index.ts @@ -0,0 +1,9 @@ +import { javascript } from '@codemirror/lang-javascript'; + +import highlightStyle from './HighlightStyle'; +import basicSetup from './basicSetup'; +import theme from './theme'; + +const extensions = [highlightStyle, javascript(), basicSetup, ...theme]; + +export default extensions; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/jsonLinter.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/jsonLinter.ts new file mode 100644 index 0000000..663ec60 --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/jsonLinter.ts @@ -0,0 +1,4 @@ +import { jsonParseLinter } from '@codemirror/lang-json'; +import { lintGutter, linter } from '@codemirror/lint'; + +export default [lintGutter(), linter(jsonParseLinter())]; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts new file mode 100644 index 0000000..25af57c --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/payloadLinter.ts @@ -0,0 +1,60 @@ +import { syntaxTree } from '@codemirror/language'; +import type { Diagnostic } from '@codemirror/lint'; +import { linter } from '@codemirror/lint'; +import type { EditorView } from 'codemirror'; + +import parsePayload from '../Parser'; + +const payloadLinter = linter((view: EditorView) => { + const diagnostics: Diagnostic[] = []; + const tree = syntaxTree(view.state); + let head = tree.topNode.firstChild; + if (!head?.matchContext(['Script'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting a Script', + severity: 'error', + }); + return diagnostics; + } + head = head.firstChild; + if (!head?.matchContext(['ExpressionStatement'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting an expression statement', + severity: 'error', + }); + return diagnostics; + } + head = head.firstChild; + if (!head?.matchContext(['ArrayExpression'])) { + diagnostics.push({ + from: 0, + to: 0, + message: 'Expecting an array expression', + severity: 'error', + }); + return diagnostics; + } + // while (head.nextSibling && head.nextSibling.name !== ']') { + // } + do { + if (head.name !== '[' && head.name !== ',' && head.name !== ']' && head.name !== 'ObjectExpression') { + diagnostics.push({ + from: head.from, + to: head.to, + message: 'Expecting an Object expression', + severity: 'error', + }); + return diagnostics; + } + if (head.name === 'ObjectExpression') parsePayload(head, view); + head = head.nextSibling; + } while (head); + + return diagnostics; +}); + +export default payloadLinter; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts b/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts new file mode 100644 index 0000000..11caa7b --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Extensions/theme.ts @@ -0,0 +1,41 @@ +import type { Extension } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +const gutters: Extension = EditorView.theme({ + '.cm-gutters': { + backgroundColor: 'transparent', + border: 'none', + userSelect: 'none', + minWidth: '32px', + display: 'flex', + justifyContent: 'flex-end', + }, + + '.cm-activeLineGutter': { + backgroundColor: 'transparent', + }, +}); + +const selection: Extension = EditorView.theme({ + '.cm-selectionBackground': { + backgroundColor: 'var(--RCPG-secondary-color) !important', + opacity: 0.3, + }, + + '.cm-selectionMatch': { + backgroundColor: '#74808930 !important', + }, + + '.cm-matchingBracket': { + backgroundColor: 'transparent !important', + border: '1px solid #1d74f580', + }, +}); + +const line: Extension = EditorView.theme({ + '.cm-activeLine': { + backgroundColor: 'transparent !important', + }, +}); + +export default [gutters, selection, line] as const; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Parser/index.ts b/apps/uikit-playground/src/Components/CodeEditor/Parser/index.ts new file mode 100644 index 0000000..482568e --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Parser/index.ts @@ -0,0 +1 @@ +export { default } from './parsePayload'; diff --git a/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts b/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts new file mode 100644 index 0000000..4faeaf5 --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/Parser/parsePayload.ts @@ -0,0 +1,7 @@ +import type { EditorView } from 'codemirror'; + +const parsePayload = (head: { from: number; to: number }, view: EditorView) => { + JSON.parse(view.state.doc.toString().slice(head.from, head.to)); +}; + +export default parsePayload; diff --git a/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx b/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx new file mode 100644 index 0000000..8edf7b1 --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/PreviewEditor.tsx @@ -0,0 +1,31 @@ +import type { Extension } from '@codemirror/state'; +import { Box } from '@rocket.chat/fuselage'; +import { useEffect, useContext } from 'react'; + +import { context } from '../../Context'; +import useCodeMirror from '../../hooks/useCodeMirror'; +import intendCode from '../../utils/intendCode'; + +type CodeMirrorProps = { + extensions?: Extension[]; +}; + +const PreviewEditor = ({ extensions }: CodeMirrorProps) => { + const { + state: { screens, activeScreen }, + } = useContext(context); + const { editor, setValue } = useCodeMirror(extensions, intendCode(screens[activeScreen]?.actionPreview)); + + useEffect(() => { + setValue(intendCode(screens[activeScreen]?.actionPreview), {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screens[activeScreen]?.actionPreview]); + + return ( + <> + + + ); +}; + +export default PreviewEditor; diff --git a/apps/uikit-playground/src/Components/CodeEditor/index.tsx b/apps/uikit-playground/src/Components/CodeEditor/index.tsx new file mode 100644 index 0000000..28d888c --- /dev/null +++ b/apps/uikit-playground/src/Components/CodeEditor/index.tsx @@ -0,0 +1,68 @@ +import type { Extension } from '@codemirror/state'; +import { Box } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import json5 from 'json5'; +import { useEffect, useContext } from 'react'; + +import { updatePayloadAction, context } from '../../Context'; +import type { ILayoutBlock } from '../../Context/initialState'; +import useCodeMirror from '../../hooks/useCodeMirror'; +import codePrettier from '../../utils/codePrettier'; + +type CodeMirrorProps = { + extensions?: Extension[]; +}; + +const CodeEditor = ({ extensions }: CodeMirrorProps) => { + const { + state: { screens, activeScreen }, + dispatch, + } = useContext(context); + const { editor, changes, setValue } = useCodeMirror(extensions, json5.stringify(screens[activeScreen].payload, undefined, 4)); + const debounceValue = useDebouncedValue(changes?.value, 1500); + + useEffect(() => { + if (!changes?.isDispatch) { + try { + const parsedCode: ILayoutBlock[] = json5.parse(changes.value); + dispatch( + updatePayloadAction({ + blocks: parsedCode, + changedByEditor: false, + }), + ); + + dispatch(updatePayloadAction({ blocks: parsedCode })); + } catch (e) { + // do nothing + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changes?.value]); + + useEffect(() => { + if (!changes?.isDispatch) { + void codePrettier(changes.value, changes.cursor || 0).then((prettierCode) => { + setValue(prettierCode.formatted, { + cursor: prettierCode.cursorOffset, + }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceValue]); + + useEffect(() => { + if (!screens[activeScreen].changedByEditor) { + setValue(JSON.stringify(screens[activeScreen].payload, undefined, 4), {}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screens[activeScreen].payload]); + + return ( + <> + + + ); +}; + +export default CodeEditor; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx new file mode 100644 index 0000000..dda8378 --- /dev/null +++ b/apps/uikit-playground/src/Components/ComponentSideBar/ScrollableSideBar.tsx @@ -0,0 +1,23 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Scrollable, Box } from '@rocket.chat/fuselage'; + +import BlocksTree from '../../Payload/actionBlock/BlocksTree'; +import DropDown from '../DropDown'; + +const ScrollableSideBar = () => ( + + + + + +); + +export default ScrollableSideBar; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx new file mode 100644 index 0000000..2191725 --- /dev/null +++ b/apps/uikit-playground/src/Components/ComponentSideBar/SideBar.tsx @@ -0,0 +1,37 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useEffect, useContext } from 'react'; + +import ScrollableSideBar from './ScrollableSideBar'; +import SliderBtn from './SliderBtn'; +import { context, sidebarToggleAction } from '../../Context'; + +const SideBar = () => { + const { state, dispatch } = useContext(context); + + useEffect(() => { + dispatch(sidebarToggleAction(false)); + }, [state?.isMobile, dispatch]); + + const slide = state?.isMobile + ? css` + width: 100%; + user-select: none; + transform: translateX(${state?.sideBarToggle ? '0' : '-100%'}); + transition: var(--animation-default); + ` + : css` + width: var(--sidebar-width); + user-select: none; + transition: var(--animation-default); + `; + + return ( + + + + + ); +}; + +export default SideBar; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx new file mode 100644 index 0000000..7eb02bd --- /dev/null +++ b/apps/uikit-playground/src/Components/ComponentSideBar/SliderBtn.tsx @@ -0,0 +1,90 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Label } from '@rocket.chat/fuselage'; +import { useContext, useMemo } from 'react'; + +import { context, sidebarToggleAction } from '../../Context'; + +const SliderBtn = () => { + const { + state: { sideBarToggle, isMobile }, + dispatch, + } = useContext(context); + const slideBtnAnimation = sideBarToggle + ? css` + clip-path: polygon(10% 0, 50% 40%, 90% 0, 100% 10%, 60% 50%, 100% 90%, 90% 100%, 50% 60%, 10% 100%, 0 90%, 40% 50%, 0 10%); + cursor: pointer; + transition: var(--animation-default); + ` + : css` + clip-path: polygon(32% 35%, 32% 35%, 79% 0, 87% 10%, 32% 50%, 87% 90%, 79% 100%, 32% 64%, 32% 65%, 13% 50%, 13% 50%, 13% 50%); + transform: rotate(180deg); + transition: var(--animation-default); + `; + + const toggleStyle = useMemo(() => { + if (!isMobile) { + return css` + left: 0px; + `; + } + + if (sideBarToggle) { + return css` + right: 0; + transition: var(--animation-default); + `; + } + + return css` + right: 0; + transform: translateX(100%); + cursor: pointer; + transition: var(--animation-default); + `; + }, [isMobile, sideBarToggle]); + + return ( + !sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle))} + zIndex={1} + className={toggleStyle} + > + + {isMobile && ( + sideBarToggle && dispatch(sidebarToggleAction(!sideBarToggle))} + className={css` + cursor: pointer; + `} + > + + + )} + + ); +}; + +export default SliderBtn; diff --git a/apps/uikit-playground/src/Components/ComponentSideBar/index.tsx b/apps/uikit-playground/src/Components/ComponentSideBar/index.tsx new file mode 100644 index 0000000..c902366 --- /dev/null +++ b/apps/uikit-playground/src/Components/ComponentSideBar/index.tsx @@ -0,0 +1 @@ +export { default } from './SideBar'; diff --git a/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx b/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx new file mode 100644 index 0000000..bae2f4a --- /dev/null +++ b/apps/uikit-playground/src/Components/CreateNewScreen/CreateNewScreenContainer.tsx @@ -0,0 +1,103 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, Icon, Scrollable } from '@rocket.chat/fuselage'; +import { useOutsideClick, useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useContext, useRef } from 'react'; + +import ScreenThumbnail from './ScreenThumbnail'; +import { context } from '../../Context'; +import { openCreateNewScreenAction, createNewScreenAction } from '../../Context/action'; +import { useHorizontalScroll } from '../../hooks/useHorizontalScroll'; + +const CreateNewScreenContainer = () => { + const { + state: { projects, screens, activeProject, openCreateNewScreen }, + dispatch, + } = useContext(context); + const ref = useRef(null); + + const onClosehandler = () => { + dispatch(openCreateNewScreenAction(false)); + }; + useOutsideClick([ref], onClosehandler); + + const scrollRef = useHorizontalScroll(); + + const mergedRef = useMergedRefs(scrollRef, ref); + const createNewScreenhandler = () => { + dispatch(createNewScreenAction()); + }; + + return ( + + + + {openCreateNewScreen && ( + + {projects[activeProject]?.screens + .map((id) => screens[id]) + .map((screen, i) => ( + screens[id]).length <= 1} + /> + ))} + + + )} + + + ); +}; + +export default CreateNewScreenContainer; diff --git a/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx b/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx new file mode 100644 index 0000000..7686826 --- /dev/null +++ b/apps/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx @@ -0,0 +1,73 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import type { ChangeEvent, MouseEvent } from 'react'; +import { useContext, useState } from 'react'; + +import { context, renameScreenAction } from '../../Context'; +import { activeScreenAction } from '../../Context/action/activeScreenAction'; +import { deleteScreenAction } from '../../Context/action/deleteScreenAction'; +import { duplicateScreenAction } from '../../Context/action/duplicateScreenAction'; +import type { ScreenType } from '../../Context/initialState'; +import renderPayload from '../RenderPayload/RenderPayload'; +import EditMenu from '../ScreenThumbnail/EditMenu/EditMenu'; +import ScreenThumbnailWrapper from '../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../ScreenThumbnail/Thumbnail'; + +export type ScreenThumbnailProps = { screen: ScreenType; disableDelete: boolean }; + +const ScreenThumbnail = ({ screen, disableDelete }: ScreenThumbnailProps) => { + const { dispatch } = useContext(context); + const [name, setName] = useState(screen?.name); + const toast = useToastBarDispatch(); + + const activateScreenHandler = (e: MouseEvent) => { + e.stopPropagation(); + dispatch(activeScreenAction(screen?.id)); + }; + + const duplicateScreenHandler = () => { + dispatch(duplicateScreenAction({ id: screen?.id })); + }; + + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; + + const nameSaveHandler = () => { + if (!name.trim()) { + setName(screen.name); + return toast({ + type: 'error', + message: 'Cannot rename screen to empty name.', + }); + } + dispatch(renameScreenAction({ id: screen.id, name })); + }; + + const deleteScreenHandler = () => { + if (disableDelete) + return toast({ + type: 'info', + message: 'Cannot delete last screen.', + }); + dispatch(deleteScreenAction(screen?.id)); + }; + return ( + + + + + + + ); +}; + +export default ScreenThumbnail; diff --git a/apps/uikit-playground/src/Components/CreateNewScreen/index.ts b/apps/uikit-playground/src/Components/CreateNewScreen/index.ts new file mode 100644 index 0000000..a11dbe0 --- /dev/null +++ b/apps/uikit-playground/src/Components/CreateNewScreen/index.ts @@ -0,0 +1 @@ +export { default } from './CreateNewScreenContainer'; diff --git a/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx b/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx new file mode 100644 index 0000000..b132288 --- /dev/null +++ b/apps/uikit-playground/src/Components/Draggable/DraggableList.tsx @@ -0,0 +1,35 @@ +import type { OnDragEndResponder } from '@hello-pangea/dnd'; +import { DragDropContext, Droppable } from '@hello-pangea/dnd'; +import { memo } from 'react'; + +import DraggableListItem from './DraggableListItem'; +import type { ILayoutBlock } from '../../Context/initialState'; +import { SurfaceOptions } from '../Preview/Display/Surface/constant'; + +export type Block = { + id: string; + payload: ILayoutBlock; +}; + +export type DraggableListProps = { + blocks: Block[]; + surface?: SurfaceOptions; + onDragEnd: OnDragEndResponder; +}; + +const DraggableList = ({ blocks, surface, onDragEnd }: DraggableListProps) => ( + + + {(provided) => ( +
+ {blocks.map((block, index) => ( + + ))} + {provided.placeholder} +
+ )} +
+
+); + +export default memo(DraggableList); diff --git a/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx b/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx new file mode 100644 index 0000000..b923566 --- /dev/null +++ b/apps/uikit-playground/src/Components/Draggable/DraggableListItem.tsx @@ -0,0 +1,27 @@ +import { Draggable } from '@hello-pangea/dnd'; + +import type { Block } from './DraggableList'; +import DeleteElementBtn from '../Preview/Display/UiKitElementWrapper/DeleteElementBtn'; +import UiKitElementWrapper from '../Preview/Display/UiKitElementWrapper/UiKitElementWrapper'; +import RenderPayload from '../RenderPayload/RenderPayload'; + +export type DraggableListItemProps = { + block: Block; + surface: number; + index: number; +}; + +const DraggableListItem = ({ block, surface, index }: DraggableListItemProps) => ( + + {(provided) => ( +
+ + + + +
+ )} +
+); + +export default DraggableListItem; diff --git a/apps/uikit-playground/src/Components/DropDown/DropDown.tsx b/apps/uikit-playground/src/Components/DropDown/DropDown.tsx new file mode 100644 index 0000000..f602ee4 --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/DropDown.tsx @@ -0,0 +1,31 @@ +import { Box } from '@rocket.chat/fuselage'; +import { Fragment } from 'react'; + +import Items from './Items'; +import type { Item, ItemBranch } from './types'; + +export type DropDownProps = { + readonly blocksTree: Item; +}; + +const DropDown = ({ blocksTree }: DropDownProps) => { + const layer = 1; + + const recursiveComponentTree = (branch: ItemBranch, layer: number) => ( + + {branch.branches?.map((branch: ItemBranch, index: number) => ( + {recursiveComponentTree(branch, layer + 1)} + ))} + + ); + + return ( + + {blocksTree.map((branch: ItemBranch, i: number) => ( + {recursiveComponentTree(branch, layer)} + ))} + + ); +}; + +export default DropDown; diff --git a/apps/uikit-playground/src/Components/DropDown/Items.tsx b/apps/uikit-playground/src/Components/DropDown/Items.tsx new file mode 100644 index 0000000..41c6119 --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/Items.tsx @@ -0,0 +1,62 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Label, Chevron } from '@rocket.chat/fuselage'; +import { useState, useContext } from 'react'; + +import ItemsIcon from './ItemsIcon'; +import { itemStyle, labelStyle } from './itemsStyle'; +import type { ItemProps } from './types'; +import { context, updatePayloadAction } from '../../Context'; +import getUniqueId from '../../utils/getUniqueId'; + +const Items = ({ label, children, layer, payload }: ItemProps) => { + const [isOpen, toggleItemOpen] = useState(layer === 1); + const [hover, setHover] = useState(false); + const { state, dispatch } = useContext(context); + + const itemClickHandler = () => { + toggleItemOpen(!isOpen); + if (!payload) return; + dispatch( + updatePayloadAction({ + blocks: [...state.screens[state.activeScreen].payload.blocks, { actionId: getUniqueId(), ...payload[0] }], + changedByEditor: false, + }), + ); + }; + + return ( + + setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={itemClickHandler} + > + + {children && children.length > 0 && ( + + + + )} + + + + + + + {isOpen && children} + + ); +}; + +export default Items; diff --git a/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx b/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx new file mode 100644 index 0000000..fa24004 --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/ItemsIcon.tsx @@ -0,0 +1,18 @@ +import { Icon } from '@rocket.chat/fuselage'; + +export type ItemsIconProps = { layer: number; lastNode: boolean; hover: boolean }; + +const ItemsIcon = ({ layer, lastNode, hover }: ItemsIconProps) => { + const selectIcon = (layer: number, hover: boolean) => { + if (layer === 1) { + return ; + } + if (lastNode) { + return ; + } + return ; + }; + return <>{selectIcon(layer, hover)}; +}; + +export default ItemsIcon; diff --git a/apps/uikit-playground/src/Components/DropDown/index.tsx b/apps/uikit-playground/src/Components/DropDown/index.tsx new file mode 100644 index 0000000..d76df1b --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/index.tsx @@ -0,0 +1 @@ +export { default } from './DropDown'; diff --git a/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts b/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts new file mode 100644 index 0000000..4a50380 --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/itemsStyle.ts @@ -0,0 +1,45 @@ +import { css } from '@rocket.chat/css-in-js'; + +export const itemStyle = (layer: number, hover: boolean) => { + const style = css` + cursor: pointer !important; + padding-left: ${10 + (layer - 1) * 16}px !important; + background-color: ${hover ? 'var(--RCPG-primary-color) !important' : 'transparent !important'}; + `; + return style; +}; + +export const labelStyle = (layer: number, hover: boolean) => { + let customStyle; + const basicStyle = css` + cursor: pointer !important; + padding-left: 4px !important; + `; + switch (layer) { + case 1: + customStyle = css` + font-weight: 700 !important; + font-size: 14px !important; + letter-spacing: 0.3px !important; + color: ${hover ? '#fff !important' : '#999 !important'}; + text-transform: uppercase !important; + `; + break; + case 2: + customStyle = css` + letter-spacing: 0.1px !important; + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; + `; + break; + default: + customStyle = css` + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; + `; + break; + } + return [customStyle, basicStyle]; +}; diff --git a/apps/uikit-playground/src/Components/DropDown/types.ts b/apps/uikit-playground/src/Components/DropDown/types.ts new file mode 100644 index 0000000..47100c0 --- /dev/null +++ b/apps/uikit-playground/src/Components/DropDown/types.ts @@ -0,0 +1,17 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; +import type { JSX } from 'react'; + +export type ItemProps = { + label: string; + layer: number; + payload?: readonly LayoutBlock[]; + children?: ReadonlyArray; +}; + +export type ItemBranch = { + label: string; + branches?: Item; + payload?: readonly LayoutBlock[]; +}; + +export type Item = ItemBranch[]; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx b/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx new file mode 100644 index 0000000..d9e098e --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx @@ -0,0 +1,27 @@ +const ConnectionLine = ({ + fromX, + fromY, + toX, + toY, +}: { + fromX: number; + fromY: number; + fromPosition: string; + toX: number; + toY: number; + toPosition: string; + connectionLineType: string; +}) => ( + + + + +); + +export default ConnectionLine; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx new file mode 100644 index 0000000..d06bccf --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx @@ -0,0 +1,7 @@ +import { Controls } from 'reactflow'; + +const ControlButtons = () => { + return ; +}; + +export default ControlButtons; diff --git a/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts new file mode 100644 index 0000000..9d122e2 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts @@ -0,0 +1 @@ +export { default } from './ControlButtons'; diff --git a/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx b/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx new file mode 100644 index 0000000..52b3428 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx @@ -0,0 +1,115 @@ +import { useCallback, useContext, useMemo, useRef, useState } from 'react'; +import ReactFlow, { + MiniMap, + Background, + addEdge, + updateEdge, + type Node, + type Viewport, + type ReactFlowInstance, + useReactFlow, + type Connection, + type Edge, +} from 'reactflow'; + +import 'reactflow/dist/style.css'; +import ConnectionLine from './ConnectionLine'; +import ControlButton from './ControlButtons'; +import UIKitWrapper from './UIKitWrapper/UIKitWrapper'; +import { FlowParams } from './utils'; +import { context } from '../../Context'; +import { updateNodesAndViewPortAction } from '../../Context/action/updateNodesAndViewPortAction'; +import { useNodesAndEdges } from '../../hooks/useNodesAndEdges'; + +const FlowContainer = () => { + const { dispatch } = useContext(context); + + const { nodes, edges, Viewport, onNodesChange, onEdgesChange, setEdges } = useNodesAndEdges(); + const { setViewport } = useReactFlow(); + + const nodeTypes = useMemo( + () => ({ + custom: UIKitWrapper, + }), + // used to rerender edge lines on reorder payload + // eslint-disable-next-line react-hooks/exhaustive-deps + [edges], + ); + + const [rfInstance, setRfInstance] = useState(); + const edgeUpdateSuccessful = useRef(true); + + const onConnect = useCallback( + (connection: Connection) => { + if (connection.source === connection.target) return; + const newEdge = { + ...connection, + type: FlowParams.edgeType, + markerEnd: FlowParams.markerEnd, + style: FlowParams.style, + }; + setEdges((eds) => addEdge(newEdge, eds)); + }, + [setEdges], + ); + + const onEdgeUpdateStart = useCallback(() => { + edgeUpdateSuccessful.current = false; + }, []); + + const onEdgeUpdate = useCallback( + (oldEdge: Edge, newConnection: Connection) => { + edgeUpdateSuccessful.current = true; + setEdges((els) => updateEdge(oldEdge, newConnection, els)); + }, + [setEdges], + ); + + const onEdgeUpdateEnd = useCallback( + (_: MouseEvent | TouchEvent, edge: Edge) => { + if (!edgeUpdateSuccessful.current) { + setEdges((eds) => { + return eds.filter((e) => e.id !== edge.id); + }); + } + edgeUpdateSuccessful.current = true; + }, + [setEdges], + ); + + const onNodeDragStop = () => { + if (!rfInstance?.toObject()) return; + const { nodes, viewport }: { nodes: Node[]; viewport: Viewport } = rfInstance.toObject(); + dispatch(updateNodesAndViewPortAction({ nodes, viewport })); + }; + + const onInit = (instance: ReactFlowInstance) => { + setRfInstance(instance); + if (Viewport) setViewport(Viewport); + }; + + return ( + + + + + + ); +}; + +export default FlowContainer; diff --git a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss new file mode 100644 index 0000000..cc3f56c --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss @@ -0,0 +1,7 @@ +.rc-scrollbars-view{ + position: relative !important; + overflow: hidden !important; + width: 100% !important; + z-index: 1000 !important; + margin: 0 !important; +} \ No newline at end of file diff --git a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx new file mode 100644 index 0000000..21470a2 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx @@ -0,0 +1,36 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useContext } from 'react'; +import { Handle, Position } from 'reactflow'; +import './UIKitWrapper.scss'; + +import { context } from '../../../Context'; +import type { idType } from '../../../Context/initialState'; +import SurfaceRender from '../../Preview/Display/Surface/SurfaceRender'; +import RenderPayload from '../../RenderPayload/RenderPayload'; + +export type UIKitWrapperProps = { id: string; data: idType }; + +const UIKitWrapper = ({ id, data }: UIKitWrapperProps) => { + const { + state: { screens }, + } = useContext(context); + if (!screens[data]) return null; + const { blocks, surface } = screens[data].payload; + return ( + + + + {blocks.map((block, index) => ( + + + + + + + ))} + + + ); +}; + +export default UIKitWrapper; diff --git a/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts new file mode 100644 index 0000000..d6fc539 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts @@ -0,0 +1 @@ +export { default } from './UIKitWrapper'; diff --git a/apps/uikit-playground/src/Components/FlowContainer/index.ts b/apps/uikit-playground/src/Components/FlowContainer/index.ts new file mode 100644 index 0000000..7d6e867 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/index.ts @@ -0,0 +1 @@ +export { default } from './FlowContainer'; diff --git a/apps/uikit-playground/src/Components/FlowContainer/utils.ts b/apps/uikit-playground/src/Components/FlowContainer/utils.ts new file mode 100644 index 0000000..39200a5 --- /dev/null +++ b/apps/uikit-playground/src/Components/FlowContainer/utils.ts @@ -0,0 +1,34 @@ +import { MarkerType } from 'reactflow'; + +import type { ScreenType } from '../../Context/initialState'; + +export function createNodesAndEdges(screens: ScreenType[]) { + const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + const nodes = screens.map((screen, i) => { + const degrees = i * (360 / 8); + const radians = degrees * (Math.PI / 180); + const x = 250 * Math.cos(radians) + center.x; + const y = 250 * Math.sin(radians) + center.y; + + return { + id: screen.id, + type: 'custom', + position: { x, y }, + data: screen, + }; + }); + + return { nodes }; +} + +export const FlowParams = { + edgeType: 'smoothstep', + markerEnd: { + type: MarkerType.Arrow, + }, + style: { + strokeWidth: 2, + stroke: 'var(--RCPG-primary-color)', + }, +}; diff --git a/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx b/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx new file mode 100644 index 0000000..1119bba --- /dev/null +++ b/apps/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx @@ -0,0 +1,32 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Label } from '@rocket.chat/fuselage'; +import { useContext } from 'react'; + +import ProjectsList from './ProjectsList/ProjectsList'; +import { context, createNewProjectAction } from '../../Context'; +import CreateNewScreenButton from '../ScreenThumbnail/CreateNewScreenButton'; + +const HomeContainer = () => { + const { dispatch } = useContext(context); + return ( + + + + + dispatch(createNewProjectAction())} /> + + + + + ); +}; + +export default HomeContainer; diff --git a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx new file mode 100644 index 0000000..01d0237 --- /dev/null +++ b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx @@ -0,0 +1,35 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useContext } from 'react'; + +import ProjectsThumbnail from './ProjectsThumbnail'; +import { context } from '../../../Context'; + +const ProjectsList = () => { + const { + state: { screens, projects }, + } = useContext(context); + + return ( + + {Object.values(projects).map((project) => ( + + ))} + + ); +}; + +export default ProjectsList; diff --git a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx new file mode 100644 index 0000000..5e2760b --- /dev/null +++ b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx @@ -0,0 +1,78 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import { type ChangeEvent, useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { activeProjectAction, context, renameProjectAction } from '../../../Context'; +import { deleteProjectAction } from '../../../Context/action/deleteProjectAction'; +import type { ILayoutBlock } from '../../../Context/initialState'; +import routes from '../../../Routes/Routes'; +import { formatDate } from '../../../utils/formatDate'; +import RenderPayload from '../../RenderPayload/RenderPayload'; +import EditMenu from '../../ScreenThumbnail/EditMenu'; +import EditableLabel from '../../ScreenThumbnail/EditableLabel/EditableLabel'; +import ScreenThumbnailWrapper from '../../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../../ScreenThumbnail/Thumbnail'; + +export type ProjectsThumbnailProps = { id: string; name: string; date: string; blocks: ILayoutBlock[] }; + +const ProjectsThumbnail = ({ id, name: _name, date, blocks }: ProjectsThumbnailProps) => { + const [name, setName] = useState(_name); + const navigate = useNavigate(); + const { dispatch } = useContext(context); + const toast = useToastBarDispatch(); + const activeProjectHandler = () => { + dispatch(activeProjectAction(id)); + navigate(`/${id}/${routes.project}`); + }; + + const deleteScreenHandler = () => { + dispatch(deleteProjectAction(id)); + }; + + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; + + const nameSaveHandler = () => { + if (!name.trim()) { + setName(_name); + return toast({ + type: 'error', + message: 'Cannot rename project to empty name.', + }); + } + dispatch(renameProjectAction({ id, name })); + }; + + return ( + + + + } /> + e.stopPropagation()}> + + + + {formatDate(date)} + + + ); +}; + +export default ProjectsThumbnail; diff --git a/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts new file mode 100644 index 0000000..257bedb --- /dev/null +++ b/apps/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectsList'; diff --git a/apps/uikit-playground/src/Components/HomeContainer/index.ts b/apps/uikit-playground/src/Components/HomeContainer/index.ts new file mode 100644 index 0000000..b78a849 --- /dev/null +++ b/apps/uikit-playground/src/Components/HomeContainer/index.ts @@ -0,0 +1 @@ +export { default } from './HomeContainer'; diff --git a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx new file mode 100644 index 0000000..b289929 --- /dev/null +++ b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/BurgerIcon.tsx @@ -0,0 +1,27 @@ +import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { ReactNode } from 'react'; +import { useContext } from 'react'; + +import Line from './Line'; +import Wrapper from './Wrapper'; +import { context } from '../../../Context'; + +export type BurgerIconProps = { children?: ReactNode }; + +const BurgerIcon = ({ children }: BurgerIconProps) => { + const isReducedMotionPreferred = usePrefersReducedMotion(); + const { + state: { navMenuToggle }, + } = useContext(context); + + return ( + + + + + {children} + + ); +}; + +export default BurgerIcon; diff --git a/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx new file mode 100644 index 0000000..6d73c22 --- /dev/null +++ b/apps/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx @@ -0,0 +1,40 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; + +export type LineProps = { animated: boolean; moved?: boolean }; + +const Line = ({ animated, moved }: LineProps) => { + const animatedStyle = animated + ? css` + will-change: transform; + transition: transform 0.1s ease-out; + ` + : ''; + + const movedStyle = moved + ? css` + &:nth-child(1), + &:nth-child(3) { + transform-origin: 50%, 50%, 0; + } + &:nth-child(1) { + transform: translate(-25%, 3px) rotate(-45deg) scale(0.5, 1); + } + [dir='rtl'] &:nth-child(1) { + transform: translate(25%, 3px) rotate(45deg) scale(0.5, 1); + } + &:nth-child(3) { + transform: translate(-25%, -3px) rotate(45deg) scale(0.5, 1); + } + [dir='rtl'] &:nth-child(3) { + transform: translate(25%, -3px) rotate(-45deg) scale(0.5, 1); + } + ` + : ''; + + return ( +